490 lines
35 KiB
Plaintext
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>© 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
|
|
|