Files
aza/AzA march 2026 - Kopie (9)/aza_diff_backend_main.txt
2026-04-16 13:32:32 +02:00

490 lines
35 KiB
Plaintext

diff --git a/AzA march 2026/backend_main.py b/AzA march 2026/backend_main.py
index 58e449f..0f75c87 100644
--- a/AzA march 2026/backend_main.py
+++ b/AzA march 2026/backend_main.py
@@ -664,160 +664,167 @@ app = FastAPI(
title="AZA Transkriptions-Backend",
version="0.1.0",
default_response_class=JSONResponse,
)
_CORS_ORIGINS = [
o.strip() for o in os.environ.get("AZA_CORS_ORIGINS", "").split(",") if o.strip()
] or [
"https://aza-medwork.ch",
"https://www.aza-medwork.ch",
"http://127.0.0.1:8000",
"http://localhost:8000",
]
try:
from fastapi.middleware.cors import CORSMiddleware
app.add_middleware(
CORSMiddleware,
allow_origins=_CORS_ORIGINS,
allow_methods=["GET", "POST", "OPTIONS"],
allow_headers=["Content-Type", "X-API-Token", "X-Device-Id"],
allow_credentials=False,
)
except Exception:
pass
_app_root = Path(__file__).resolve().parent
_web_dir = _app_root / "web"
_release_dir = _app_root / "release"
try:
from fastapi.staticfiles import StaticFiles
if _web_dir.is_dir():
app.mount("/web", StaticFiles(directory=str(_web_dir), html=True), name="web_static")
if _release_dir.is_dir():
app.mount("/release", StaticFiles(directory=str(_release_dir)), name="release_static")
except Exception:
pass
MAX_REQUEST_BODY_BYTES = 1 * 1024 * 1024 # 1 MB
MAX_TRANSCRIBE_BODY_BYTES = 500 * 1024 * 1024 # 500 MB
@app.middleware("http")
async def request_size_limit_middleware(request: Request, call_next):
body = await request.body()
max_bytes = MAX_REQUEST_BODY_BYTES
if request.url.path.startswith("/v1/transcribe"):
max_bytes = MAX_TRANSCRIBE_BODY_BYTES
if len(body) > max_bytes:
raise HTTPException(status_code=413, detail="request body too large")
response = await call_next(request)
return response
try:
status_file = Path(__file__).resolve().parent / "project_status.json"
if status_file.is_file():
with open(status_file, "r", encoding="utf-8") as f:
s = json.load(f)
print("\n=== AZA PROJECT STATUS ===")
print("Phase:", s.get("phase"))
print("Step:", s.get("current_step"))
print("Next:", s.get("next_step"))
print("Notes:", s.get("last_update"))
print("==========================\n")
except Exception:
pass
# Stripe routes
try:
from stripe_routes import router as stripe_router
app.include_router(stripe_router, prefix="/stripe")
except Exception:
# Stripe is optional until env + deps are in place
pass
+# Admin monitor routes
+try:
+ from admin_routes import router as admin_router
+ app.include_router(admin_router, prefix="/admin")
+except Exception as _admin_err:
+ print(f"[ADMIN] admin_routes not loaded: {_admin_err}")
+
# Project status route
try:
from project_status_routes import router as project_status_router
app.include_router(project_status_router)
except Exception:
pass
@app.on_event("startup")
def _print_routes():
if not API_TOKEN:
raise RuntimeError("FEHLER: ENV MEDWORK_API_TOKEN ist nicht gesetzt. Server wird nicht gestartet.")
print("=== Aktive Routes im Server-Prozess ===")
for route in app.routes:
methods = getattr(route, "methods", None)
path = getattr(route, "path", "?")
if methods:
print(f" {', '.join(sorted(methods)):8s} {path}")
print("========================================")
class TranscribeResponse(BaseModel):
success: bool
transcript: str
error: str
request_id: str
duration_ms: int
model: str
debug: dict | None = None
model_used: str | None = None
def _read_expected_token() -> str:
# backend_token.txt hat Vorrang (gleiche Logik wie Client)
try:
token_path = Path(__file__).resolve().parent / "backend_token.txt"
if token_path.is_file():
with open(token_path, "r", encoding="utf-8-sig") as f:
t = (f.read() or "").replace("\ufeff", "").strip(" \t\r\n")
if t:
return t
except Exception:
pass
return (os.environ.get("MEDWORK_API_TOKEN", "") or "").strip()
def _extract_request_token(request: Request) -> str:
token = (request.headers.get("X-API-Token", "") or "").strip()
if token:
return token
auth = (request.headers.get("Authorization", "") or "").strip()
if auth.startswith("Bearer "):
return auth[len("Bearer "):].strip()
return ""
def _require_token(request: Request) -> None:
expected = _read_expected_token()
got = _extract_request_token(request)
if not expected or got != expected:
raise HTTPException(status_code=401, detail="Unauthorized")
def _check_token(request: Request) -> bool:
try:
_require_token(request)
return True
except HTTPException:
return False
def _get_user(request: Request) -> Optional[str]:
"""Return X-User header value or None if missing/empty."""
user = request.headers.get("X-User", "").strip()
return user if user else None
_NO_USER_RESPONSE = {"success": False, "error": "X-User header required"}
@@ -1496,292 +1503,306 @@ def telemetry_ping(data: TelemetryPing, request: Request):
recent_hits = [
ts for ts in _telemetry_hits[client_ip]
if now_ts - ts < TELEMETRY_RATE_WINDOW_SECONDS
]
_telemetry_hits[client_ip] = recent_hits
if len(recent_hits) >= TELEMETRY_RATE_LIMIT:
raise HTTPException(status_code=429, detail="telemetry rate limit exceeded")
_telemetry_hits[client_ip].append(now_ts)
if data.event not in ALLOWED_TELEMETRY_EVENTS:
raise HTTPException(status_code=400, detail="invalid telemetry event")
if data.event == "crash":
if not data.crash_type or data.crash_type not in ALLOWED_CRASH_TYPES:
raise HTTPException(status_code=400, detail="invalid crash_type")
if data.event != "update_check" and data.target_version is not None:
raise HTTPException(status_code=400, detail="target_version only allowed for update_check")
# Minimal telemetry ÔÇô no PHI, no persistence yet
print(
"[telemetry]",
{
"time": datetime.utcnow().isoformat(),
"event": data.event,
"version": data.version,
"platform": data.platform,
"app": data.app,
"crash_type": data.crash_type,
"target_version": data.target_version,
},
)
_telemetry_event_counts[data.event] += 1
return {"status": "ok"}
@app.get("/admin/telemetry/stats")
def telemetry_stats():
uptime_seconds = int((datetime.utcnow() - _server_start_time).total_seconds())
return {
"server_start_time": _server_start_time.isoformat() + "Z",
"uptime_seconds": uptime_seconds,
"events": dict(_telemetry_event_counts)
}
@app.get("/license/debug")
def license_debug():
db_path = _stripe_db_path()
exists = db_path.exists()
active_count = 0
current_period_end = None
if exists:
try:
with sqlite3.connect(db_path) as con:
row = con.execute("SELECT COUNT(*) FROM licenses WHERE status='active'").fetchone()
active_count = int(row[0]) if row else 0
row2 = con.execute("SELECT MAX(current_period_end) FROM licenses").fetchone()
current_period_end = int(row2[0]) if row2 and row2[0] is not None else None
except Exception:
active_count = 0
current_period_end = None
return JSONResponse(content={
"stripe_db_path": str(db_path.resolve()),
"exists": exists,
"active_count": active_count,
"current_period_end": current_period_end,
"cwd": os.getcwd(),
})
@app.get("/license/status")
def license_status(
request: Request,
+ email: Optional[str] = Query(None),
_: None = Depends(require_api_token),
):
db_path = _stripe_db_path()
if not db_path.exists():
return {"valid": False, "valid_until": None}
status = None
current_period_end = None
customer_email = None
try:
try:
import stripe_routes # type: ignore
if hasattr(stripe_routes, "_ensure_storage"):
stripe_routes._ensure_storage() # type: ignore
except Exception:
pass
with sqlite3.connect(db_path) as con:
- row = con.execute(
- """
- SELECT status, current_period_end, customer_email
- FROM licenses
- ORDER BY updated_at DESC
- LIMIT 1
- """
- ).fetchone()
+ row = None
+ if email and email.strip():
+ row = con.execute(
+ """
+ SELECT status, current_period_end, customer_email
+ FROM licenses
+ WHERE lower(customer_email) = ?
+ ORDER BY updated_at DESC
+ LIMIT 1
+ """,
+ (email.strip().lower(),),
+ ).fetchone()
+ if row is None:
+ row = con.execute(
+ """
+ SELECT status, current_period_end, customer_email
+ FROM licenses
+ ORDER BY updated_at DESC
+ LIMIT 1
+ """
+ ).fetchone()
if row:
status = row[0]
current_period_end = int(row[1]) if row[1] is not None else None
customer_email = str(row[2]).strip() if row[2] is not None else None
except Exception:
status = None
current_period_end = None
customer_email = None
decision = compute_license_decision(current_period_end=current_period_end, status=status)
# --- Device enforcement (devices_per_user) ---
device_id = request.headers.get("X-Device-Id")
# For now: single-user desktop client -> stable key
user_key = "default"
if not customer_email:
return {"valid": False, "valid_until": None}
# customer_email must be loaded from the license row
# IMPORTANT: Ensure you have customer_email from DB already.
dd = enforce_and_touch_device(customer_email=customer_email, user_key=user_key, device_id=device_id, db_path=str(db_path))
if not dd.allowed:
return {"valid": False, "valid_until": None}
# Keep schema EXACT: valid=false -> valid_until=null
is_valid = bool(decision.valid)
return {
"valid": is_valid,
"valid_until": decision.valid_until if is_valid else None,
}
@app.get("/billing/success")
def billing_success(session_id: Optional[str] = Query(None)) -> HTMLResponse:
customer_email = ""
if session_id:
try:
import stripe as _stripe
_stripe.api_key = os.environ.get("STRIPE_SECRET_KEY", "")
sess = _stripe.checkout.Session.retrieve(session_id)
customer_email = getattr(sess, "customer_email", "") or ""
if not customer_email and getattr(sess, "customer_details", None):
customer_email = sess.customer_details.get("email", "") or ""
except Exception:
pass
download_url = "/download/aza_desktop_setup.exe"
try:
vf = Path(__file__).resolve().parent / "release" / "version.json"
if vf.exists():
with open(vf, "r", encoding="utf-8") as _f:
_vd = json.load(_f)
download_url = _vd.get("download_url", download_url)
except Exception:
pass
email_line = ""
if customer_email:
email_line = f'<p style="margin-top:12px;font-size:14px;color:#555;">Ihr Konto: <strong>{customer_email}</strong></p>'
html = f"""<!DOCTYPE html>
<html lang="de">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>AZA ÔÇô Vielen Dank</title>
<style>
body {{ font-family: 'Segoe UI', system-ui, sans-serif; margin:0; background:#F7F8FA; color:#1a1a2e; }}
.wrap {{ max-width:640px; margin:60px auto; background:#fff; border-radius:12px;
box-shadow:0 2px 12px rgba(0,0,0,.08); padding:48px 40px; }}
h1 {{ font-size:24px; margin:0 0 8px; color:#0078D7; }}
.sub {{ font-size:15px; color:#555; margin-bottom:32px; }}
.dl-btn {{ display:inline-block; padding:14px 32px; background:#0078D7; color:#fff;
text-decoration:none; border-radius:8px; font-size:16px; font-weight:600;
transition:background .2s; }}
.dl-btn:hover {{ background:#005fa3; }}
.steps {{ margin:32px 0 0; padding:0; list-style:none; counter-reset:step; }}
.steps li {{ position:relative; padding:0 0 20px 40px; font-size:14px; line-height:1.6; }}
.steps li::before {{ content:counter(step); counter-increment:step;
position:absolute; left:0; top:0; width:26px; height:26px; border-radius:50%;
background:#E8F4FD; color:#0078D7; font-weight:700; font-size:13px;
display:flex; align-items:center; justify-content:center; }}
.note {{ margin-top:28px; padding:16px 20px; background:#F0F7ED; border-radius:8px;
font-size:13px; color:#2E7D32; line-height:1.5; }}
.footer {{ margin-top:36px; font-size:12px; color:#999; text-align:center; }}
.footer a {{ color:#0078D7; text-decoration:none; }}
</style>
</head>
<body>
<div class="wrap">
<h1>Vielen Dank fuer Ihr Abonnement</h1>
<p class="sub">Ihr Zugang zu AZA Medical AI Assistant ist jetzt aktiv.</p>
{email_line}
<div style="text-align:center; margin:28px 0;">
<a class="dl-btn" href="{download_url}">AZA Desktop herunterladen</a>
</div>
<h2 style="font-size:16px; margin-bottom:12px;">Installation in 3 Schritten</h2>
<ol class="steps">
<li><strong>Installer starten</strong> ÔÇô Doppelklick auf die heruntergeladene Datei.
Falls Windows SmartScreen warnt: ┬½Weitere Informationen┬╗ ÔåÆ ┬½Trotzdem ausfuehren┬╗.</li>
- <li><strong>OpenAI-Schluessel einrichten</strong> ÔÇô Beim ersten Start werden Sie durch die
- Einrichtung gefuehrt. Halten Sie Ihren OpenAI-API-Key bereit.</li>
+ <li><strong>Registrieren</strong> ÔÇô Beim ersten Start erfassen Sie Ihr Profil.
+ Verwenden Sie dieselbe E-Mail-Adresse wie beim Kauf, damit Ihre Lizenz erkannt wird.</li>
<li><strong>Loslegen</strong> ÔÇô Waehlen Sie im Startbildschirm Ihr gewuenschtes Modul
und beginnen Sie mit der Arbeit.</li>
</ol>
<div class="note">
<strong>Hinweis:</strong> Ihr Abonnement ist an Ihre E-Mail-Adresse gebunden.
Die Lizenz wird beim Start automatisch geprueft ÔÇô eine manuelle Aktivierung ist
in der Regel nicht noetig.
</div>
<div class="footer">
<p>Bei Fragen: <a href="mailto:support@aza-medwork.ch">support@aza-medwork.ch</a></p>
<p>&copy; AZA Medical AI Assistant ÔÇô aza-medwork.ch</p>
</div>
</div>
</body>
</html>"""
return HTMLResponse(content=html)
@app.get("/billing/cancel")
def billing_cancel() -> HTMLResponse:
html = """<!DOCTYPE html>
<html lang="de">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>AZA ÔÇô Checkout abgebrochen</title>
<style>
body { font-family: 'Segoe UI', system-ui, sans-serif; margin:0; background:#F7F8FA; color:#1a1a2e; }
.wrap { max-width:520px; margin:80px auto; background:#fff; border-radius:12px;
box-shadow:0 2px 12px rgba(0,0,0,.08); padding:48px 40px; text-align:center; }
h1 { font-size:22px; margin:0 0 12px; }
p { font-size:15px; color:#555; line-height:1.6; }
.btn { display:inline-block; margin-top:24px; padding:12px 28px; background:#0078D7;
color:#fff; text-decoration:none; border-radius:8px; font-size:15px; }
.btn:hover { background:#005fa3; }
</style>
</head>
<body>
<div class="wrap">
<h1>Checkout abgebrochen</h1>
<p>Der Bezahlvorgang wurde nicht abgeschlossen.<br>
Sie koennen jederzeit zurueckkehren und es erneut versuchen.</p>
<a class="btn" href="/">Zurueck zur Startseite</a>
</div>
</body>
</html>"""
return HTMLResponse(content=html)
@app.get("/v1/schedule")
def get_schedule(
request: Request,
start: Optional[str] = None,
end: Optional[str] = None,
employee: Optional[str] = None,
date_from: Optional[str] = Query(None, alias="from"),
date_to: Optional[str] = Query(None, alias="to"),
):
request_id = f"srv_{uuid.uuid4().hex[:12]}"
t0 = time.perf_counter()
if not _check_token(request):
return JSONResponse(status_code=401, content={
"success": False, "items": [], "error": "unauthorized",
"request_id": request_id, "duration_ms": 0,
})
user = _get_user(request)
if not user:
return JSONResponse(status_code=400, content={**_NO_USER_RESPONSE, "items": [],
"request_id": request_id, "duration_ms": 0})
try:
eff_start = date_from or start
eff_end = date_to or end
has_any_filter = employee or eff_start or eff_end