Improved Licensing
This commit is contained in:
@@ -1,7 +1,9 @@
|
||||
"""Admin panel routes (password-protected) - User Management & Statistics."""
|
||||
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
|
||||
@@ -12,11 +14,19 @@ 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
|
||||
|
||||
# Router with /admin prefix
|
||||
admin_router = APIRouter(prefix="/admin", tags=["admin"])
|
||||
@@ -549,8 +559,7 @@ async def license_keys_page(request: Request):
|
||||
async def generate_license_key(
|
||||
request: Request,
|
||||
max_employees: int = Form(...),
|
||||
max_posts_per_day: int = Form(...),
|
||||
max_researches_per_day: int = Form(...),
|
||||
daily_token_limit: Optional[int] = Form(None),
|
||||
description: str = Form(None)
|
||||
):
|
||||
"""Generate new license key."""
|
||||
@@ -567,8 +576,7 @@ async def generate_license_key(
|
||||
license_key = await db.create_license_key(
|
||||
key=key,
|
||||
max_employees=max_employees,
|
||||
max_posts_per_day=max_posts_per_day,
|
||||
max_researches_per_day=max_researches_per_day,
|
||||
daily_token_limit=daily_token_limit,
|
||||
description=description
|
||||
)
|
||||
|
||||
@@ -583,8 +591,7 @@ async def update_license_key_route(
|
||||
request: Request,
|
||||
key_id: str,
|
||||
max_employees: int = Form(...),
|
||||
max_posts_per_day: int = Form(...),
|
||||
max_researches_per_day: int = Form(...),
|
||||
daily_token_limit: Optional[int] = Form(None),
|
||||
description: str = Form(None)
|
||||
):
|
||||
"""Update license key limits."""
|
||||
@@ -594,8 +601,7 @@ async def update_license_key_route(
|
||||
try:
|
||||
updates = {
|
||||
"max_employees": max_employees,
|
||||
"max_posts_per_day": max_posts_per_day,
|
||||
"max_researches_per_day": max_researches_per_day,
|
||||
"daily_token_limit": daily_token_limit,
|
||||
"description": description
|
||||
}
|
||||
|
||||
@@ -618,3 +624,333 @@ async def delete_license_key_route(request: Request, key_id: str):
|
||||
except Exception as e:
|
||||
logger.error(f"Error deleting license key: {e}")
|
||||
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
|
||||
|
||||
|
||||
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", ".")
|
||||
|
||||
|
||||
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(
|
||||
request: Request,
|
||||
key_id: str,
|
||||
email: str = Form(...),
|
||||
price: float = Form(...),
|
||||
payment_frequency: str = Form(...),
|
||||
):
|
||||
"""Generate a PDF offer and send it via email."""
|
||||
if not verify_auth(request):
|
||||
raise HTTPException(status_code=401)
|
||||
|
||||
try:
|
||||
key = await db.get_license_key_by_id(UUID(key_id))
|
||||
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,
|
||||
price=price,
|
||||
payment_frequency=payment_frequency,
|
||||
recipient_email=email,
|
||||
)
|
||||
|
||||
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"
|
||||
|
||||
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 • 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,
|
||||
)
|
||||
|
||||
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."})
|
||||
|
||||
except HTTPException:
|
||||
raise
|
||||
except Exception as e:
|
||||
logger.error(f"Error sending license offer: {e}")
|
||||
raise HTTPException(status_code=500, detail=str(e))
|
||||
|
||||
Reference in New Issue
Block a user