merged create and chat create in one tab
This commit is contained in:
@@ -80,6 +80,7 @@ class Settings(BaseSettings):
|
|||||||
teams_enabled: bool = False
|
teams_enabled: bool = False
|
||||||
microsoft_app_id: str = ""
|
microsoft_app_id: str = ""
|
||||||
microsoft_app_secret: str = ""
|
microsoft_app_secret: str = ""
|
||||||
|
microsoft_app_tenant_id: str = "botframework.com" # "botframework.com" for multi-tenant, tenant ID for single-tenant
|
||||||
|
|
||||||
model_config = SettingsConfigDict(
|
model_config = SettingsConfigDict(
|
||||||
env_file=".env",
|
env_file=".env",
|
||||||
|
|||||||
@@ -21,9 +21,10 @@ _JWKS_TTL = 3600 # 1 hour
|
|||||||
class TeamsService:
|
class TeamsService:
|
||||||
"""Handles Microsoft Teams bot interactions for post approval notifications."""
|
"""Handles Microsoft Teams bot interactions for post approval notifications."""
|
||||||
|
|
||||||
def __init__(self, app_id: str, app_secret: str):
|
def __init__(self, app_id: str, app_secret: str, tenant_id: str = "botframework.com"):
|
||||||
self._app_id = app_id
|
self._app_id = app_id
|
||||||
self._app_secret = app_secret
|
self._app_secret = app_secret
|
||||||
|
self._tenant_id = tenant_id
|
||||||
|
|
||||||
# ==================== BOT AUTH TOKEN ====================
|
# ==================== BOT AUTH TOKEN ====================
|
||||||
|
|
||||||
@@ -37,7 +38,7 @@ class TeamsService:
|
|||||||
|
|
||||||
async with httpx.AsyncClient(timeout=15.0) as client:
|
async with httpx.AsyncClient(timeout=15.0) as client:
|
||||||
resp = await client.post(
|
resp = await client.post(
|
||||||
"https://login.microsoftonline.com/botframework.com/oauth2/v2.0/token",
|
f"https://login.microsoftonline.com/{self._tenant_id}/oauth2/v2.0/token",
|
||||||
data={
|
data={
|
||||||
"grant_type": "client_credentials",
|
"grant_type": "client_credentials",
|
||||||
"client_id": self._app_id,
|
"client_id": self._app_id,
|
||||||
@@ -214,4 +215,8 @@ class TeamsService:
|
|||||||
# Module-level singleton — only created when Teams is enabled
|
# Module-level singleton — only created when Teams is enabled
|
||||||
teams_service: Optional[TeamsService] = None
|
teams_service: Optional[TeamsService] = None
|
||||||
if settings.teams_enabled:
|
if settings.teams_enabled:
|
||||||
teams_service = TeamsService(settings.microsoft_app_id, settings.microsoft_app_secret)
|
teams_service = TeamsService(
|
||||||
|
settings.microsoft_app_id,
|
||||||
|
settings.microsoft_app_secret,
|
||||||
|
settings.microsoft_app_tenant_id,
|
||||||
|
)
|
||||||
|
|||||||
@@ -2,9 +2,9 @@
|
|||||||
from contextlib import asynccontextmanager
|
from contextlib import asynccontextmanager
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
|
||||||
from fastapi import FastAPI
|
from fastapi import FastAPI, Request
|
||||||
from fastapi.staticfiles import StaticFiles
|
from fastapi.staticfiles import StaticFiles
|
||||||
from fastapi.responses import FileResponse, RedirectResponse
|
from fastapi.responses import FileResponse, RedirectResponse, JSONResponse
|
||||||
from starlette.middleware.base import BaseHTTPMiddleware
|
from starlette.middleware.base import BaseHTTPMiddleware
|
||||||
from starlette.middleware.gzip import GZipMiddleware
|
from starlette.middleware.gzip import GZipMiddleware
|
||||||
from loguru import logger
|
from loguru import logger
|
||||||
@@ -116,6 +116,82 @@ app.add_middleware(GZipMiddleware, minimum_size=500)
|
|||||||
app.mount("/static", StaticFiles(directory=Path(__file__).parent / "static"), name="static")
|
app.mount("/static", StaticFiles(directory=Path(__file__).parent / "static"), name="static")
|
||||||
|
|
||||||
|
|
||||||
|
def _load_env_file(path: Path) -> dict:
|
||||||
|
"""Load key=value pairs from a simple .env file."""
|
||||||
|
data = {}
|
||||||
|
if not path.exists():
|
||||||
|
return data
|
||||||
|
for line in path.read_text(encoding="utf-8").splitlines():
|
||||||
|
line = line.strip()
|
||||||
|
if not line or line.startswith("#") or "=" not in line:
|
||||||
|
continue
|
||||||
|
key, val = line.split("=", 1)
|
||||||
|
data[key.strip()] = val.strip()
|
||||||
|
return data
|
||||||
|
|
||||||
|
|
||||||
|
def _send_contact_email(payload: dict) -> bool:
|
||||||
|
"""Send contact email using landing.env SMTP config."""
|
||||||
|
import smtplib
|
||||||
|
from email.mime.text import MIMEText
|
||||||
|
from email.mime.multipart import MIMEMultipart
|
||||||
|
|
||||||
|
root = Path(__file__).resolve().parents[3]
|
||||||
|
env_path = root / "landing" / "landing.env"
|
||||||
|
if not env_path.exists():
|
||||||
|
env_path = root / "landing.env"
|
||||||
|
env = _load_env_file(env_path)
|
||||||
|
|
||||||
|
host = env.get("SMTP_HOST", "")
|
||||||
|
port = int(env.get("SMTP_PORT", "587") or 587)
|
||||||
|
user = env.get("SMTP_USER", "")
|
||||||
|
password = env.get("SMTP_PASSWORD", "")
|
||||||
|
from_name = env.get("SMTP_FROM_NAME", "LinkedIn Post System")
|
||||||
|
to_email = "team@onyva.de"
|
||||||
|
|
||||||
|
if not host or not user or not password:
|
||||||
|
logger.error("Contact email SMTP not configured (landing.env)")
|
||||||
|
return False
|
||||||
|
|
||||||
|
subject = "Neue Kontaktanfrage (Landing Page)"
|
||||||
|
body = (
|
||||||
|
f"Name: {payload.get('name','')}\n"
|
||||||
|
f"E-Mail: {payload.get('email','')}\n"
|
||||||
|
f"Mitarbeiter: {payload.get('team_size','')}\n"
|
||||||
|
f"Posts/Monat: {payload.get('posts_per_month','')}\n"
|
||||||
|
f"Nachricht:\n{payload.get('message','')}\n"
|
||||||
|
)
|
||||||
|
|
||||||
|
msg = MIMEMultipart()
|
||||||
|
msg["From"] = f"{from_name} <{user}>"
|
||||||
|
msg["To"] = to_email
|
||||||
|
msg["Subject"] = subject
|
||||||
|
msg.attach(MIMEText(body, "plain"))
|
||||||
|
|
||||||
|
try:
|
||||||
|
with smtplib.SMTP(host, port) as server:
|
||||||
|
server.starttls()
|
||||||
|
server.login(user, password)
|
||||||
|
server.sendmail(user, to_email, msg.as_string())
|
||||||
|
return True
|
||||||
|
except Exception as exc:
|
||||||
|
logger.error(f"Contact email failed: {exc}")
|
||||||
|
return False
|
||||||
|
|
||||||
|
|
||||||
|
@app.post("/api/contact")
|
||||||
|
async def contact(request: Request):
|
||||||
|
data = await request.json()
|
||||||
|
required = ["name", "email", "team_size", "posts_per_month", "message"]
|
||||||
|
for key in required:
|
||||||
|
if not data.get(key):
|
||||||
|
return JSONResponse({"ok": False, "error": f"Missing {key}"}, status_code=400)
|
||||||
|
ok = _send_contact_email(data)
|
||||||
|
if not ok:
|
||||||
|
return JSONResponse({"ok": False, "error": "Email failed"}, status_code=500)
|
||||||
|
return {"ok": True}
|
||||||
|
|
||||||
|
|
||||||
@app.get("/sw.js", include_in_schema=False)
|
@app.get("/sw.js", include_in_schema=False)
|
||||||
async def service_worker():
|
async def service_worker():
|
||||||
"""Serve Service Worker from root scope so it can intercept all page requests."""
|
"""Serve Service Worker from root scope so it can intercept all page requests."""
|
||||||
|
|||||||
@@ -130,13 +130,6 @@
|
|||||||
<svg class="w-5 h-5 flex-shrink-0" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M11 5H6a2 2 0 00-2 2v11a2 2 0 002 2h11a2 2 0 002-2v-5m-1.414-9.414a2 2 0 112.828 2.828L11.828 15H9v-2.828l8.586-8.586z"/></svg>
|
<svg class="w-5 h-5 flex-shrink-0" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M11 5H6a2 2 0 00-2 2v11a2 2 0 002 2h11a2 2 0 002-2v-5m-1.414-9.414a2 2 0 112.828 2.828L11.828 15H9v-2.828l8.586-8.586z"/></svg>
|
||||||
<span class="sidebar-text">Post erstellen</span>
|
<span class="sidebar-text">Post erstellen</span>
|
||||||
</a>
|
</a>
|
||||||
<a href="/chat-create" class="nav-link flex items-center justify-between px-4 py-3 rounded-lg text-gray-300 hover:bg-brand-bg-light transition-colors {% if page == 'chat-create' %}active{% endif %}">
|
|
||||||
<div class="flex items-center gap-3">
|
|
||||||
<svg class="w-5 h-5 flex-shrink-0" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M8 12h.01M12 12h.01M16 12h.01M21 12c0 4.418-4.03 8-9 8a9.863 9.863 0 01-4.255-.949L3 20l1.395-3.72C3.512 15.042 3 13.574 3 12c0-4.418 4.03-8 9-8s9 3.582 9 8z"/></svg>
|
|
||||||
<span class="sidebar-text">Chat Assistent</span>
|
|
||||||
</div>
|
|
||||||
<span class="sidebar-text px-1.5 py-0.5 bg-brand-highlight text-brand-bg-dark rounded text-xs font-bold">NEU</span>
|
|
||||||
</a>
|
|
||||||
<a href="/posts" class="nav-link flex items-center gap-3 px-4 py-3 rounded-lg text-gray-300 hover:bg-brand-bg-light transition-colors {% if page == 'posts' %}active{% endif %}">
|
<a href="/posts" class="nav-link flex items-center gap-3 px-4 py-3 rounded-lg text-gray-300 hover:bg-brand-bg-light transition-colors {% if page == 'posts' %}active{% endif %}">
|
||||||
<svg class="w-5 h-5 flex-shrink-0" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 11H5m14 0a2 2 0 012 2v6a2 2 0 01-2 2H5a2 2 0 01-2-2v-6a2 2 0 012-2m14 0V9a2 2 0 00-2-2M5 11V9a2 2 0 012-2m0 0V5a2 2 0 012-2h6a2 2 0 012 2v2M7 7h10"/></svg>
|
<svg class="w-5 h-5 flex-shrink-0" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 11H5m14 0a2 2 0 012 2v6a2 2 0 01-2 2H5a2 2 0 01-2-2v-6a2 2 0 012-2m14 0V9a2 2 0 00-2-2M5 11V9a2 2 0 012-2m0 0V5a2 2 0 012-2h6a2 2 0 012 2v2M7 7h10"/></svg>
|
||||||
<span class="sidebar-text">Meine Posts</span>
|
<span class="sidebar-text">Meine Posts</span>
|
||||||
|
|||||||
@@ -233,7 +233,13 @@ aside.collapsed ~ main .chat-fixed-input {
|
|||||||
|
|
||||||
<!-- Header (Fixed at top) -->
|
<!-- Header (Fixed at top) -->
|
||||||
<div class="chat-fixed-header fixed bg-brand-bg z-20" style="top: {% if limit_reached %}1.5rem{% else %}0{% endif %}">
|
<div class="chat-fixed-header fixed bg-brand-bg z-20" style="top: {% if limit_reached %}1.5rem{% else %}0{% endif %}">
|
||||||
<div class="px-8 py-4">
|
<div class="px-8 py-4 flex items-center gap-4">
|
||||||
|
<a href="/create" class="inline-flex items-center gap-2 text-sm text-gray-400 hover:text-brand-highlight transition-colors">
|
||||||
|
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 19l-7-7 7-7"/>
|
||||||
|
</svg>
|
||||||
|
Zurück
|
||||||
|
</a>
|
||||||
<h1 class="text-xl font-bold text-white">💬 Chat Assistent</h1>
|
<h1 class="text-xl font-bold text-white">💬 Chat Assistent</h1>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -5,6 +5,14 @@
|
|||||||
<!-- Wizard Container (hidden during generation) -->
|
<!-- Wizard Container (hidden during generation) -->
|
||||||
<div id="wizardContainer" {% if limit_reached %}style="pointer-events: none; opacity: 0.5;"{% endif %}>
|
<div id="wizardContainer" {% if limit_reached %}style="pointer-events: none; opacity: 0.5;"{% endif %}>
|
||||||
<div class="max-w-2xl mx-auto">
|
<div class="max-w-2xl mx-auto">
|
||||||
|
<div class="mb-4">
|
||||||
|
<a href="/create" class="inline-flex items-center gap-2 text-gray-400 hover:text-brand-highlight transition-colors">
|
||||||
|
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 19l-7-7 7-7"/>
|
||||||
|
</svg>
|
||||||
|
Zurück
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
<div class="mb-8 text-center">
|
<div class="mb-8 text-center">
|
||||||
<h1 class="text-3xl font-bold text-white mb-2">Post erstellen</h1>
|
<h1 class="text-3xl font-bold text-white mb-2">Post erstellen</h1>
|
||||||
<p class="text-gray-400">Generiere einen neuen LinkedIn Post mit AI</p>
|
<p class="text-gray-400">Generiere einen neuen LinkedIn Post mit AI</p>
|
||||||
|
|||||||
43
src/web/templates/user/create_post_select.html
Normal file
43
src/web/templates/user/create_post_select.html
Normal file
@@ -0,0 +1,43 @@
|
|||||||
|
{% extends "base.html" %}
|
||||||
|
{% block title %}Post erstellen - LinkedIn Posts{% endblock %}
|
||||||
|
|
||||||
|
{% block content %}
|
||||||
|
<div class="max-w-3xl mx-auto">
|
||||||
|
<div class="mb-8 text-center">
|
||||||
|
<h1 class="text-3xl font-bold text-white mb-2">Post erstellen</h1>
|
||||||
|
<p class="text-gray-400">Wähle, wie du deinen Post erstellen möchtest</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{% if limit_reached %}
|
||||||
|
<div class="mb-6 card-bg border rounded-xl p-4 text-center text-sm text-gray-300">
|
||||||
|
Token-Limit erreicht – keine KI-Aktionen mehr heute möglich. Morgen wird das Limit zurückgesetzt.
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
<div class="grid md:grid-cols-2 gap-6">
|
||||||
|
<a href="/create/wizard"
|
||||||
|
class="card-bg border rounded-xl p-6 hover:bg-brand-bg-light transition-colors group {% if limit_reached %}opacity-50 pointer-events-none{% endif %}">
|
||||||
|
<div class="w-12 h-12 bg-brand-highlight/20 rounded-lg flex items-center justify-center mb-4">
|
||||||
|
<svg class="w-6 h-6 text-brand-highlight" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
|
||||||
|
d="M12 4v16m8-8H4"/>
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
<h2 class="text-lg font-semibold text-white mb-2">Per Wizard erstellen</h2>
|
||||||
|
<p class="text-gray-400 text-sm">Geführter Prozess mit Post-Typ, Thema und Hook-Auswahl.</p>
|
||||||
|
</a>
|
||||||
|
|
||||||
|
<a href="/chat-create"
|
||||||
|
class="card-bg border rounded-xl p-6 hover:bg-brand-bg-light transition-colors group {% if limit_reached %}opacity-50 pointer-events-none{% endif %}">
|
||||||
|
<div class="w-12 h-12 bg-brand-highlight/20 rounded-lg flex items-center justify-center mb-4">
|
||||||
|
<svg class="w-6 h-6 text-brand-highlight" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
|
||||||
|
d="M8 12h.01M12 12h.01M16 12h.01M21 12c0 4.418-4.03 8-9 8a9.863 9.863 0 01-4.255-.949L3 20l1.395-3.72C3.512 15.042 3 13.574 3 12c0-4.418 4.03-8 9-8s9 3.582 9 8z"/>
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
<h2 class="text-lg font-semibold text-white mb-2">Per Chat erstellen</h2>
|
||||||
|
<p class="text-gray-400 text-sm">Schnell und flexibel mit dem Chat-Assistenten.</p>
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% endblock %}
|
||||||
@@ -1756,8 +1756,37 @@ async def research_page(request: Request):
|
|||||||
|
|
||||||
|
|
||||||
@user_router.get("/create", response_class=HTMLResponse)
|
@user_router.get("/create", response_class=HTMLResponse)
|
||||||
|
async def create_post_select_page(request: Request):
|
||||||
|
"""Post creation selection page - choose wizard or chat."""
|
||||||
|
session = require_user_session(request)
|
||||||
|
if not session:
|
||||||
|
return RedirectResponse(url="/login", status_code=302)
|
||||||
|
|
||||||
|
# Check token limit for companies/employees
|
||||||
|
limit_reached = False
|
||||||
|
limit_message = ""
|
||||||
|
if session.account_type in ("company", "employee") and session.company_id:
|
||||||
|
can_create, error_msg, _, _ = await db.check_company_token_limit(UUID(session.company_id))
|
||||||
|
limit_reached = not can_create
|
||||||
|
limit_message = error_msg
|
||||||
|
|
||||||
|
user_id = UUID(session.user_id)
|
||||||
|
profile_picture = await get_user_avatar(session, user_id)
|
||||||
|
|
||||||
|
return templates.TemplateResponse("create_post_select.html", {
|
||||||
|
"request": request,
|
||||||
|
"page": "create",
|
||||||
|
"session": session,
|
||||||
|
"user_id": session.user_id,
|
||||||
|
"limit_reached": limit_reached,
|
||||||
|
"limit_message": limit_message,
|
||||||
|
"profile_picture": profile_picture
|
||||||
|
})
|
||||||
|
|
||||||
|
|
||||||
|
@user_router.get("/create/wizard", response_class=HTMLResponse)
|
||||||
async def create_post_page(request: Request):
|
async def create_post_page(request: Request):
|
||||||
"""Create post page - with limit check for companies."""
|
"""Create post wizard page - with limit check for companies."""
|
||||||
session = require_user_session(request)
|
session = require_user_session(request)
|
||||||
if not session:
|
if not session:
|
||||||
return RedirectResponse(url="/login", status_code=302)
|
return RedirectResponse(url="/login", status_code=302)
|
||||||
|
|||||||
BIN
teams-app.zip
BIN
teams-app.zip
Binary file not shown.
Reference in New Issue
Block a user