Improved features, implemented moco integration

This commit is contained in:
2026-02-18 19:59:14 +01:00
parent af2c9e7fd8
commit 8e4f155a16
11 changed files with 827 additions and 427 deletions

View File

@@ -1,9 +1,9 @@
"""Admin panel routes (password-protected) - User Management & Statistics."""
import asyncio
import re
import secrets
import math
from datetime import datetime, timedelta
from io import BytesIO
from pathlib import Path
from typing import Optional
from uuid import UUID
@@ -14,19 +14,13 @@ from fastapi.templating import Jinja2Templates
from fastapi.responses import HTMLResponse, RedirectResponse, JSONResponse
from loguru import logger
from reportlab.platypus import SimpleDocTemplate, Paragraph, Spacer, Table, TableStyle, HRFlowable, Image
from reportlab.lib.styles import getSampleStyleSheet, ParagraphStyle
from reportlab.lib.units import cm
from reportlab.lib import colors
from reportlab.lib.pagesizes import A4
from src.database import db
from src.web.admin.auth import (
WEB_PASSWORD, AUTH_COOKIE_NAME, hash_password, verify_auth
)
from src.web.user.auth import UserSession, set_user_session
from src.services.email_service import send_email_with_attachment
from src.config import settings
from src.services import moco_service
# Router with /admin prefix
admin_router = APIRouter(prefix="/admin", tags=["admin"])
@@ -534,10 +528,20 @@ async def license_keys_page(request: Request):
try:
keys = await db.list_license_keys()
# Load offers for all keys in parallel
offers_lists = await asyncio.gather(
*[db.list_license_key_offers(k.id) for k in keys],
return_exceptions=True
)
offers_by_key = {
str(k.id): (v if not isinstance(v, Exception) else [])
for k, v in zip(keys, offers_lists)
}
return templates.TemplateResponse("license_keys.html", {
"request": request,
"page": "license_keys",
"keys": keys,
"offers_by_key": offers_by_key,
"total_keys": len(keys),
"used_keys": len([k for k in keys if k.used]),
"available_keys": len([k for k in keys if not k.used])
@@ -626,255 +630,33 @@ async def delete_license_key_route(request: Request, key_id: str):
raise HTTPException(status_code=500, detail=str(e))
# ==================== LICENSE KEY OFFER ====================
AVG_TOKENS_PER_POST = 50_000
SERVER_COST_EUR = 16.0
SERVER_SHARE_FRACTION = 0.10 # 10% of server costs per customer
API_COST_PER_1K_TOKENS_EUR = 0.003 # rough average API cost in EUR
# ==================== LICENSE KEY OFFER (MOCO) ====================
def _fmt_eur(amount: float) -> str:
"""Format a float as German EUR string, e.g. 1.250,00 €"""
return f"{amount:,.2f}".replace(",", "X").replace(".", ",").replace("X", ".")
@admin_router.get("/api/moco/companies")
async def search_moco_companies_endpoint(request: Request, term: str = ""):
"""Live-search MOCO companies by name."""
if not verify_auth(request):
raise HTTPException(status_code=401)
try:
companies = await moco_service.search_moco_companies(term)
return JSONResponse(companies)
except Exception as e:
logger.error(f"MOCO company search error: {e}")
raise HTTPException(status_code=500, detail=str(e))
def _build_offer_pdf(
key_description: str,
max_employees: int,
daily_token_limit: Optional[int],
price: float,
payment_frequency: str,
recipient_email: str,
) -> bytes:
"""Generate a professional, simple PDF offer with logo."""
buffer = BytesIO()
PAGE_W, PAGE_H = A4
MARGIN = 1.8 * cm
doc = SimpleDocTemplate(
buffer,
pagesize=A4,
rightMargin=MARGIN,
leftMargin=MARGIN,
topMargin=MARGIN,
bottomMargin=MARGIN,
)
# ── Colors ────────────────────────────────────────────────────────────────
C_DARK = colors.HexColor("#2d3838")
C_HIGHLIGHT = colors.HexColor("#ffc700")
C_GRAY = colors.HexColor("#666666")
C_LGRAY = colors.HexColor("#f5f5f5")
C_RULE = colors.HexColor("#e0e0e0")
C_WHITE = colors.white
CONTENT_W = PAGE_W - 2 * MARGIN
HALF_W = (CONTENT_W - 0.3 * cm) / 2 # for 2-col feature grid
# ── Styles ────────────────────────────────────────────────────────────────
s = getSampleStyleSheet()
def ps(name, **kw):
return ParagraphStyle(name, parent=s["Normal"], **kw)
s_body = ps("body", fontSize=9.5, textColor=C_DARK, leading=14)
s_small = ps("sml", fontSize=8, textColor=C_GRAY, leading=12)
s_label = ps("lbl", fontSize=8.5, textColor=C_GRAY, leading=13)
s_value = ps("val", fontSize=9.5, textColor=C_DARK, leading=13, fontName="Helvetica-Bold")
s_sec = ps("sec", fontSize=9, textColor=C_GRAY, leading=12, fontName="Helvetica-Bold",
spaceBefore=0, spaceAfter=0, letterSpacing=0.8)
s_price = ps("prc", fontSize=19, textColor=C_DARK, leading=24, fontName="Helvetica-Bold")
s_feat = ps("feat", fontSize=8.5, textColor=C_DARK, leading=12, fontName="Helvetica-Bold")
s_fdesc = ps("fdsc", fontSize=7.5, textColor=C_GRAY, leading=11)
# ── Computed values ───────────────────────────────────────────────────────
today = datetime.utcnow().strftime("%d.%m.%Y")
validity = (datetime.utcnow() + timedelta(days=30)).strftime("%d.%m.%Y")
offer_number = f"ANG-{datetime.utcnow().strftime('%Y%m%d-%H%M')}"
plan_name = key_description or "Standard Plan"
price_str = _fmt_eur(price)
freq_cap = payment_frequency.capitalize()
if daily_token_limit:
posts_per_month = int(daily_token_limit * 30 / AVG_TOKENS_PER_POST)
token_str = (
f"{daily_token_limit:,}".replace(",", ".") +
f" Tokens / Tag (ca. {posts_per_month} Posts / Monat)"
)
else:
token_str = "Unbegrenzt"
story = []
# ── Header: Logo + ANGEBOT ────────────────────────────────────────────────
logo_path = Path(__file__).parent.parent / "static" / "logo.png"
logo = (
Image(str(logo_path), width=2.8 * cm, height=2.8 * cm, kind="proportional")
if logo_path.exists()
else Paragraph("<b>Onyva</b>", ps("lt", fontSize=13, textColor=C_WHITE, fontName="Helvetica-Bold"))
)
hdr = Table(
[[
logo,
[
Paragraph('<font color="#ffc700"><b>ANGEBOT</b></font>',
ps("ttl", fontSize=20, fontName="Helvetica-Bold", alignment=2, leading=25)),
Spacer(1, 0.1 * cm),
Paragraph(f'<font color="#aaaaaa">{offer_number}</font>',
ps("anr", fontSize=8, textColor=C_GRAY, alignment=2, leading=12)),
],
]],
colWidths=[4.5 * cm, CONTENT_W - 4.5 * cm],
)
hdr.setStyle(TableStyle([
("BACKGROUND", (0, 0), (-1, -1), C_DARK),
("TOPPADDING", (0, 0), (-1, -1), 10),
("BOTTOMPADDING", (0, 0), (-1, -1), 10),
("LEFTPADDING", (0, 0), (0, -1), 14),
("RIGHTPADDING", (-1, 0), (-1, -1), 14),
("VALIGN", (0, 0), (-1, -1), "MIDDLE"),
]))
story.append(hdr)
story.append(Spacer(1, 0.45 * cm))
# ── Meta block (recipient / date / validity) ──────────────────────────────
meta = Table(
[
[Paragraph("Empfänger", s_label), Paragraph(recipient_email, s_value),
Paragraph("Datum", s_label), Paragraph(today, s_body)],
[Paragraph("Plan", s_label), Paragraph(plan_name, s_body),
Paragraph("Gültig bis",s_label), Paragraph(validity, s_body)],
],
colWidths=[2.8 * cm, CONTENT_W / 2 - 2.8 * cm, 2.2 * cm, CONTENT_W / 2 - 2.2 * cm],
)
meta.setStyle(TableStyle([
("TOPPADDING", (0, 0), (-1, -1), 2),
("BOTTOMPADDING", (0, 0), (-1, -1), 2),
("LEFTPADDING", (0, 0), (-1, -1), 0),
("RIGHTPADDING", (0, 0), (-1, -1), 4),
]))
story.append(meta)
story.append(Spacer(1, 0.3 * cm))
story.append(HRFlowable(width="100%", thickness=1.5, color=C_HIGHLIGHT, spaceAfter=0.35 * cm))
# ── Intro (one compact line) ───────────────────────────────────────────────
story.append(Paragraph(
"Sehr geehrte Damen und Herren, nachfolgend erhalten Sie unser Angebot für die Nutzung der "
"<b>Onyva LinkedIn Content Automation</b> Plattform.",
s_body,
))
story.append(Spacer(1, 0.35 * cm))
# ── Section: Lizenzdetails ────────────────────────────────────────────────
story.append(Paragraph("LIZENZDETAILS", s_sec))
story.append(Spacer(1, 0.15 * cm))
# Compact limits bar (single row, two columns)
limits_bar = Table(
[[
Paragraph(f"<b>Mitarbeiter:</b> {max_employees} Nutzer", s_body),
Paragraph(f"<b>KI-Token-Limit:</b> {token_str}", s_body),
]],
colWidths=[CONTENT_W / 2, CONTENT_W / 2],
)
limits_bar.setStyle(TableStyle([
("BACKGROUND", (0, 0), (-1, -1), C_WHITE),
("TOPPADDING", (0, 0), (-1, -1), 6),
("BOTTOMPADDING",(0, 0), (-1, -1), 6),
("LEFTPADDING", (0, 0), (-1, -1), 10),
("RIGHTPADDING", (0, 0), (-1, -1), 10),
("GRID", (0, 0), (-1, -1), 0.5, C_RULE),
("VALIGN", (0, 0), (-1, -1), "TOP"),
]))
story.append(limits_bar)
story.append(Spacer(1, 0.25 * cm))
# Features: 2-column grid
FEATURES = [
("LinkedIn-Profil-Analyse", "KI-gestützt, einmalig pro Profil Basis für individuelle Content-Strategie"),
("Post-Typen & Strategie", "Definierbare Typen mit Gewichtung, KI-Analyse & Strategieanpassung"),
("KI-Post-Erstellung", "Chat-basiert, iterativ, mit Qualitätsprüfung & Stilvalidierung"),
("Mitarbeiterverwaltung", "Posts verwalten, kommentieren, freigeben oder ablehnen per UI"),
("Research-Funktion", "Automatische Themenrecherche zu aktuellen Branchentrends"),
("Posting-Kalender", "Planung & Terminierung von Posts über alle Mitarbeiter hinweg"),
("E-Mail-Workflows", "Freigabe-Anfragen & Entscheidungsbenachrichtigungen per E-Mail"),
]
def feat_cell(name, desc):
return Paragraph(
f'<font color="#2d3838"><b>✓ {name}</b></font><br/>'
f'<font size="7.5" color="#888888"> {desc}</font>',
ps(f"fc_{name[:4]}", fontSize=8.5, leading=13),
)
# Pair features into rows of 2
feat_rows = []
for i in range(0, len(FEATURES), 2):
left = feat_cell(*FEATURES[i])
right = feat_cell(*FEATURES[i + 1]) if i + 1 < len(FEATURES) else Paragraph("", s_small)
feat_rows.append([left, right])
feat_t = Table(feat_rows, colWidths=[HALF_W, HALF_W], hAlign="LEFT")
feat_t.setStyle(TableStyle([
("BACKGROUND", (0, 0), (-1, -1), C_WHITE),
("GRID", (0, 0), (-1, -1), 0.4, C_RULE),
("TOPPADDING", (0, 0), (-1, -1), 5),
("BOTTOMPADDING", (0, 0), (-1, -1), 5),
("LEFTPADDING", (0, 0), (-1, -1), 8),
("RIGHTPADDING", (0, 0), (-1, -1), 8),
("VALIGN", (0, 0), (-1, -1), "TOP"),
]))
story.append(feat_t)
story.append(Spacer(1, 0.4 * cm))
# ── Section: Preisgestaltung ──────────────────────────────────────────────
story.append(Paragraph("PREISGESTALTUNG", s_sec))
story.append(Spacer(1, 0.15 * cm))
price_rows = [
[Paragraph("Preis", s_label), Paragraph(price_str, s_price)],
[Paragraph("Zahlungsweise", s_label), Paragraph(freq_cap, s_body)],
]
if payment_frequency == "jährlich":
price_rows.append([Paragraph("Jahresbetrag", s_label), Paragraph(_fmt_eur(price * 12), s_body)])
price_t = Table(price_rows, colWidths=[4 * cm, CONTENT_W - 4 * cm])
price_t.setStyle(TableStyle([
("BACKGROUND", (0, 0), (-1, -1), C_WHITE),
("GRID", (0, 0), (-1, -1), 0.5, C_RULE),
("TOPPADDING", (0, 0), (-1, -1), 6),
("BOTTOMPADDING", (0, 0), (-1, -1), 6),
("LEFTPADDING", (0, 0), (-1, -1), 10),
("RIGHTPADDING", (0, 0), (-1, -1), 10),
("VALIGN", (0, 0), (-1, -1), "MIDDLE"),
]))
story.append(price_t)
story.append(Spacer(1, 0.2 * cm))
story.append(Paragraph("Alle Preise zzgl. gesetzlicher Mehrwertsteuer.", s_small))
story.append(Spacer(1, 0.5 * cm))
# ── Footer ────────────────────────────────────────────────────────────────
story.append(HRFlowable(width="100%", thickness=0.5, color=C_RULE, spaceAfter=0.25 * cm))
story.append(Paragraph(
"Bei Fragen stehen wir Ihnen gerne zur Verfügung Kontakt: <b>team@onyva.de</b>",
s_small,
))
story.append(Spacer(1, 0.35 * cm))
story.append(Paragraph("Mit freundlichen Grüßen,<br/><b>Onyva</b>", s_body))
doc.build(story)
return buffer.getvalue()
@admin_router.post("/api/license-keys/{key_id}/send-offer")
async def send_license_offer(
@admin_router.post("/api/license-keys/{key_id}/create-offer")
async def create_moco_offer_endpoint(
request: Request,
key_id: str,
email: str = Form(...),
company_id: int = Form(...),
company_name: str = Form(...),
price: float = Form(...),
payment_frequency: str = Form(...),
):
"""Generate a PDF offer and send it via email."""
"""Create an offer in MOCO for the given license key."""
if not verify_auth(request):
raise HTTPException(status_code=401)
@@ -883,74 +665,66 @@ async def send_license_offer(
if not key:
raise HTTPException(status_code=404, detail="Lizenzschlüssel nicht gefunden")
pdf_bytes = _build_offer_pdf(
key_description=key.description or "",
max_employees=key.max_employees,
daily_token_limit=key.daily_token_limit,
plan_name = key.description or "Standard Plan"
offer = await moco_service.create_moco_offer(
company_id=company_id,
company_name=company_name,
price=price,
payment_frequency=payment_frequency,
recipient_email=email,
plan_name=plan_name,
max_employees=key.max_employees,
daily_token_limit=key.daily_token_limit,
)
posts_note = ""
if key.daily_token_limit:
posts_per_month = int(key.daily_token_limit * 30 / AVG_TOKENS_PER_POST)
token_str = f"{key.daily_token_limit:,}".replace(",", ".") + f" Tokens/Tag (ca. {posts_per_month} Posts/Monat)"
else:
token_str = "Unbegrenzt"
offer_url = offer.get("web_url") or offer.get("url") or f"https://{settings.moco_domain}.mocoapp.com/offers/{offer.get('id', '')}"
price_str = f"{price:,.2f}".replace(",", "X").replace(".", ",").replace("X", ".")
plan_name = key.description or "Standard Plan"
html_body = f"""
<div style="font-family: Arial, sans-serif; max-width: 600px; margin: 0 auto; background: #ffffff;">
<div style="background: #2d3838; padding: 24px 32px; border-radius: 8px 8px 0 0;">
<h1 style="color: #ffc700; margin: 0; font-size: 22px;">LinkedIn Content Automation</h1>
<p style="color: #cccccc; margin: 6px 0 0; font-size: 13px;">Ihr persönliches Angebot</p>
</div>
<div style="background: #f9f9f9; padding: 32px;">
<p style="color: #333; font-size: 15px;">Sehr geehrte Damen und Herren,</p>
<p style="color: #333; font-size: 14px; line-height: 1.6;">
vielen Dank für Ihr Interesse an unserer LinkedIn Content Automation Lösung.<br>
Im Anhang finden Sie Ihr persönliches Angebot als PDF-Dokument.
</p>
<div style="background: #2d3838; border-radius: 8px; padding: 20px 24px; margin: 24px 0;">
<h3 style="color: #ffc700; margin: 0 0 12px; font-size: 14px; text-transform: uppercase; letter-spacing: 1px;">Angebotsübersicht</h3>
<table style="width: 100%; border-collapse: collapse;">
<tr><td style="color: #aaa; font-size: 13px; padding: 4px 0;">Plan</td><td style="color: #fff; font-size: 13px; font-weight: bold;">{plan_name}</td></tr>
<tr><td style="color: #aaa; font-size: 13px; padding: 4px 0;">Mitarbeiter</td><td style="color: #fff; font-size: 13px;">{key.max_employees} Nutzer</td></tr>
<tr><td style="color: #aaa; font-size: 13px; padding: 4px 0;">Token-Limit</td><td style="color: #fff; font-size: 13px;">{token_str}</td></tr>
<tr style="border-top: 1px solid #4a5858;"><td style="color: #aaa; font-size: 13px; padding: 8px 0 4px;">Preis</td><td style="color: #ffc700; font-size: 18px; font-weight: bold; padding-top: 6px;">{price_str}</td></tr>
<tr><td style="color: #aaa; font-size: 13px; padding: 4px 0;">Zahlungsweise</td><td style="color: #fff; font-size: 13px;">{payment_frequency.capitalize()}</td></tr>
</table>
</div>
<p style="color: #666; font-size: 13px;">Alle Preise zzgl. gesetzlicher MwSt. Das Angebot ist 30 Tage gültig.</p>
<p style="color: #333; font-size: 14px;">Bei Fragen stehen wir Ihnen gerne zur Verfügung.</p>
<p style="color: #333; font-size: 14px;">Mit freundlichen Grüßen,<br><strong>Onyva</strong></p>
</div>
<div style="background: #2d3838; padding: 16px 32px; border-radius: 0 0 8px 8px; text-align: center;">
<p style="color: #888; font-size: 11px; margin: 0;">Onyva &bull; team@onyva.de</p>
</div>
</div>
"""
offer_number = datetime.utcnow().strftime('%Y%m%d%H%M')
filename = f"Angebot_{offer_number}.pdf"
success = send_email_with_attachment(
to_email=email,
subject=f"Ihr Angebot LinkedIn Content Automation ({plan_name})",
html_content=html_body,
attachment_bytes=pdf_bytes,
attachment_filename=filename,
# Persist offer in DB
await db.create_license_key_offer(
license_key_id=UUID(key_id),
moco_offer_id=offer["id"],
moco_offer_identifier=offer.get("identifier"),
moco_offer_url=offer_url,
offer_title=offer.get("title") or f"LinkedIn Content Automation {plan_name}",
company_name=company_name,
price=price,
payment_frequency=payment_frequency,
)
if not success:
raise HTTPException(status_code=500, detail="E-Mail konnte nicht gesendet werden. SMTP prüfen.")
return JSONResponse({"success": True, "message": f"Angebot erfolgreich an {email} gesendet."})
return JSONResponse({"success": True, "offer_url": offer_url})
except HTTPException:
raise
except Exception as e:
logger.error(f"Error sending license offer: {e}")
logger.error(f"Error creating MOCO offer: {e}")
raise HTTPException(status_code=500, detail=str(e))
@admin_router.post("/api/license-key-offers/{offer_id}/send")
async def send_license_key_offer(request: Request, offer_id: str):
"""Send a stored MOCO offer via email and mark it as sent."""
if not verify_auth(request):
raise HTTPException(status_code=401)
try:
from src.database.models import LicenseKeyOffer
result = await asyncio.to_thread(
lambda: db.client.table("license_key_offers")
.select("*")
.eq("id", offer_id)
.execute()
)
if not result.data:
raise HTTPException(status_code=404, detail="Angebot nicht gefunden")
stored = LicenseKeyOffer(**result.data[0])
await moco_service.send_moco_offer(stored.moco_offer_id)
await db.update_license_key_offer_status(UUID(offer_id), "sent")
return JSONResponse({"success": True})
except HTTPException:
raise
except Exception as e:
logger.error(f"Error sending offer {offer_id}: {e}")
raise HTTPException(status_code=500, detail=str(e))