From af2c9e7fd8397af6bbfe02d3a7b4b2c8029897c1 Mon Sep 17 00:00:00 2001 From: Ruben Fischer Date: Wed, 18 Feb 2026 00:00:32 +0100 Subject: [PATCH] Improved Licensing --- requirements.txt | 3 + src/agents/base.py | 8 + src/database/client.py | 93 ++--- src/database/models.py | 6 +- src/services/email_service.py | 44 +++ src/web/admin/routes.py | 352 +++++++++++++++++- src/web/templates/admin/license_keys.html | 228 ++++++++++-- src/web/templates/user/base.html | 12 +- src/web/templates/user/chat_create.html | 89 ++++- src/web/templates/user/company_base.html | 13 +- src/web/templates/user/company_dashboard.html | 53 ++- .../templates/user/company_manage_create.html | 17 - .../user/company_manage_research.html | 14 - src/web/templates/user/create_post.html | 17 - src/web/templates/user/post_detail.html | 16 +- src/web/templates/user/research.html | 14 - src/web/user/routes.py | 102 +++-- 17 files changed, 831 insertions(+), 250 deletions(-) diff --git a/requirements.txt b/requirements.txt index 74eb518..a706cd7 100644 --- a/requirements.txt +++ b/requirements.txt @@ -29,6 +29,9 @@ textstat>=0.7.12 scikit-learn==1.5.2 setuptools>=65.0.0 +# PDF Generation +reportlab>=4.0.0 + # Web Frontend fastapi==0.115.0 uvicorn==0.32.0 diff --git a/src/agents/base.py b/src/agents/base.py index c00c8cb..7e9dbb1 100644 --- a/src/agents/base.py +++ b/src/agents/base.py @@ -75,6 +75,14 @@ class BaseAgent(ABC): except Exception as e: logger.warning(f"Failed to log usage to DB: {e}") + # Increment company token quota + if self._company_id: + try: + from uuid import UUID + await db.increment_company_tokens(UUID(self._company_id), total_tokens) + except Exception as e: + logger.warning(f"Failed to increment company tokens: {e}") + async def call_openai( self, system_prompt: str, diff --git a/src/database/client.py b/src/database/client.py index 31e7dee..15d0a84 100644 --- a/src/database/client.py +++ b/src/database/client.py @@ -1184,18 +1184,17 @@ class DatabaseClient: self, key: str, max_employees: int, - max_posts_per_day: int, - max_researches_per_day: int, + daily_token_limit: Optional[int] = None, description: Optional[str] = None ) -> LicenseKey: """Create new license key.""" data = { "key": key, "max_employees": max_employees, - "max_posts_per_day": max_posts_per_day, - "max_researches_per_day": max_researches_per_day, "description": description, } + if daily_token_limit is not None: + data["daily_token_limit"] = daily_token_limit result = await asyncio.to_thread( lambda: self.client.table("license_keys").insert(data).execute() ) @@ -1222,6 +1221,16 @@ class DatabaseClient: logger.info(f"Marked license key as used: {key}") return LicenseKey(**result.data[0]) + async def get_license_key_by_id(self, key_id: UUID) -> Optional[LicenseKey]: + """Get license key by UUID.""" + result = await asyncio.to_thread( + lambda: self.client.table("license_keys") + .select("*") + .eq("id", str(key_id)) + .execute() + ) + return LicenseKey(**result.data[0]) if result.data else None + async def update_license_key(self, key_id: UUID, updates: Dict[str, Any]) -> LicenseKey: """Update license key limits (admin only).""" result = await asyncio.to_thread( @@ -1270,8 +1279,7 @@ class DatabaseClient: data = { "company_id": str(company_id), "date": date_.isoformat(), - "posts_created": 0, - "researches_created": 0 + "tokens_used": 0 } result = await asyncio.to_thread( lambda: self.client.table("company_daily_quotas") @@ -1280,47 +1288,22 @@ class DatabaseClient: ) return CompanyDailyQuota(**result.data[0]) - async def increment_company_posts_quota(self, company_id: UUID) -> None: - """Increment daily posts count for company.""" + async def increment_company_tokens(self, company_id: UUID, tokens: int) -> None: + """Increment daily token usage for company.""" try: quota = await self.get_company_daily_quota(company_id) - new_count = quota.posts_created + 1 + new_count = quota.tokens_used + tokens - result = await asyncio.to_thread( + await asyncio.to_thread( lambda: self.client.table("company_daily_quotas") - .update({"posts_created": new_count}) + .update({"tokens_used": new_count}) .eq("id", str(quota.id)) .execute() ) - logger.info(f"Incremented posts quota for company {company_id}: {quota.posts_created} -> {new_count}") - - if not result.data: - logger.error(f"Failed to increment posts quota - no data returned") + logger.info(f"Incremented token quota for company {company_id}: {quota.tokens_used} -> {new_count}") except Exception as e: - logger.error(f"Error incrementing posts quota for company {company_id}: {e}") - raise - - async def increment_company_researches_quota(self, company_id: UUID) -> None: - """Increment daily researches count for company.""" - try: - quota = await self.get_company_daily_quota(company_id) - new_count = quota.researches_created + 1 - - result = await asyncio.to_thread( - lambda: self.client.table("company_daily_quotas") - .update({"researches_created": new_count}) - .eq("id", str(quota.id)) - .execute() - ) - - logger.info(f"Incremented researches quota for company {company_id}: {quota.researches_created} -> {new_count}") - - if not result.data: - logger.error(f"Failed to increment researches quota - no data returned") - except Exception as e: - logger.error(f"Error incrementing researches quota for company {company_id}: {e}") - raise + logger.warning(f"Error incrementing token quota for company {company_id}: {e}") async def get_company_limits(self, company_id: UUID) -> Optional[LicenseKey]: """Get company limits from associated license key. @@ -1340,39 +1323,21 @@ class DatabaseClient: return LicenseKey(**result.data[0]) if result.data else None - async def check_company_post_limit(self, company_id: UUID) -> tuple[bool, str]: - """Check if company can create more posts today. + async def check_company_token_limit(self, company_id: UUID) -> tuple[bool, str, int, int]: + """Check if company has token budget remaining today. - Returns (can_create: bool, error_message: str) + Returns (can_proceed: bool, error_message: str, tokens_used: int, daily_limit: int) """ license_key = await self.get_company_limits(company_id) - if not license_key: - # No license key, use defaults (unlimited) - return True, "" + if not license_key or license_key.daily_token_limit is None: + return True, "", 0, 0 # no limit = unlimited quota = await self.get_company_daily_quota(company_id) - if quota.posts_created >= license_key.max_posts_per_day: - return False, f"Tageslimit erreicht ({license_key.max_posts_per_day} Posts/Tag). Versuche es morgen wieder." + if quota.tokens_used >= license_key.daily_token_limit: + return False, f"Tageslimit erreicht ({license_key.daily_token_limit:,} Tokens/Tag). Morgen wieder verfügbar.", quota.tokens_used, license_key.daily_token_limit - return True, "" - - async def check_company_research_limit(self, company_id: UUID) -> tuple[bool, str]: - """Check if company can create more researches today. - - Returns (can_create: bool, error_message: str) - """ - license_key = await self.get_company_limits(company_id) - if not license_key: - # No license key, use defaults (unlimited) - return True, "" - - quota = await self.get_company_daily_quota(company_id) - - if quota.researches_created >= license_key.max_researches_per_day: - return False, f"Tageslimit erreicht ({license_key.max_researches_per_day} Researches/Tag). Versuche es morgen wieder." - - return True, "" + return True, "", quota.tokens_used, license_key.daily_token_limit async def check_company_employee_limit(self, company_id: UUID) -> tuple[bool, str]: """Check if company can add more employees. diff --git a/src/database/models.py b/src/database/models.py index 013eaf5..98e2fd1 100644 --- a/src/database/models.py +++ b/src/database/models.py @@ -365,8 +365,7 @@ class LicenseKey(DBModel): # Limits max_employees: int = 5 - max_posts_per_day: int = 10 - max_researches_per_day: int = 5 + daily_token_limit: Optional[int] = None # None = unlimited # Usage used: bool = False @@ -382,7 +381,6 @@ class CompanyDailyQuota(DBModel): id: Optional[UUID] = None company_id: UUID date: date - posts_created: int = 0 - researches_created: int = 0 + tokens_used: int = 0 created_at: Optional[datetime] = None updated_at: Optional[datetime] = None diff --git a/src/services/email_service.py b/src/services/email_service.py index b7dd928..3f9d505 100644 --- a/src/services/email_service.py +++ b/src/services/email_service.py @@ -3,6 +3,8 @@ import smtplib import secrets from email.mime.text import MIMEText from email.mime.multipart import MIMEMultipart +from email.mime.base import MIMEBase +from email import encoders from typing import Optional, Dict, Any from datetime import datetime, timedelta from uuid import UUID @@ -75,6 +77,48 @@ def send_email(to_email: str, subject: str, html_content: str) -> bool: return False +def send_email_with_attachment( + to_email: str, + subject: str, + html_content: str, + attachment_bytes: bytes, + attachment_filename: str, +) -> bool: + """Send an email with a file attachment (e.g. PDF).""" + if not settings.smtp_host or not settings.smtp_user: + logger.warning("SMTP not configured, skipping email send") + return False + + try: + msg = MIMEMultipart("mixed") + msg["Subject"] = subject + msg["From"] = f"{settings.smtp_from_name} <{settings.smtp_user}>" + msg["To"] = to_email + + # HTML body + alt_part = MIMEMultipart("alternative") + alt_part.attach(MIMEText(html_content, "html")) + msg.attach(alt_part) + + # Attachment + part = MIMEBase("application", "octet-stream") + part.set_payload(attachment_bytes) + encoders.encode_base64(part) + part.add_header("Content-Disposition", f'attachment; filename="{attachment_filename}"') + msg.attach(part) + + with smtplib.SMTP(settings.smtp_host, settings.smtp_port) as server: + server.starttls() + server.login(settings.smtp_user, settings.smtp_password) + server.sendmail(settings.smtp_user, to_email, msg.as_string()) + + logger.info(f"Email with attachment sent to {to_email}: {subject}") + return True + except Exception as e: + logger.error(f"Failed to send email with attachment to {to_email}: {e}") + return False + + def send_approval_request_email( to_email: str, post_id: UUID, diff --git a/src/web/admin/routes.py b/src/web/admin/routes.py index e27cf27..f5a4b47 100644 --- a/src/web/admin/routes.py +++ b/src/web/admin/routes.py @@ -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("Onyva", ps("lt", fontSize=13, textColor=C_WHITE, fontName="Helvetica-Bold")) + ) + hdr = Table( + [[ + logo, + [ + Paragraph('ANGEBOT', + ps("ttl", fontSize=20, fontName="Helvetica-Bold", alignment=2, leading=25)), + Spacer(1, 0.1 * cm), + Paragraph(f'{offer_number}', + 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 " + "Onyva LinkedIn Content Automation 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"Mitarbeiter: {max_employees} Nutzer", s_body), + Paragraph(f"KI-Token-Limit: {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'✓ {name}
' + f' {desc}', + 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: team@onyva.de", + s_small, + )) + story.append(Spacer(1, 0.35 * cm)) + story.append(Paragraph("Mit freundlichen Grüßen,
Onyva", 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""" +
+
+

LinkedIn Content Automation

+

Ihr persönliches Angebot

+
+
+

Sehr geehrte Damen und Herren,

+

+ vielen Dank für Ihr Interesse an unserer LinkedIn Content Automation Lösung.
+ Im Anhang finden Sie Ihr persönliches Angebot als PDF-Dokument. +

+
+

Angebotsübersicht

+ + + + + + +
Plan{plan_name}
Mitarbeiter{key.max_employees} Nutzer
Token-Limit{token_str}
Preis{price_str}
Zahlungsweise{payment_frequency.capitalize()}
+
+

Alle Preise zzgl. gesetzlicher MwSt. Das Angebot ist 30 Tage gültig.

+

Bei Fragen stehen wir Ihnen gerne zur Verfügung.

+

Mit freundlichen Grüßen,
Onyva

+
+
+

Onyva • team@onyva.de

+
+
+ """ + + 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)) diff --git a/src/web/templates/admin/license_keys.html b/src/web/templates/admin/license_keys.html index 3aa5458..566d87a 100644 --- a/src/web/templates/admin/license_keys.html +++ b/src/web/templates/admin/license_keys.html @@ -62,8 +62,7 @@
👥 {{ key.max_employees }} Mitarbeiter
-
📝 {{ key.max_posts_per_day }} Posts/Tag
-
🔍 {{ key.max_researches_per_day }} Researches/Tag
+
🪙 {% if key.daily_token_limit %}{{ key.daily_token_limit | int | string }} Tokens/Tag{% else %}Unbegrenzt{% endif %}
@@ -81,13 +80,20 @@ {{ key.created_at.strftime('%d.%m.%Y') if key.created_at else '-' }} - + {% if not key.used %} + + + + + + + + + + +
+ + +
+ + + +
+ + +
+ + + +