import os import secrets import shutil from datetime import datetime from pathlib import Path from typing import Annotated from uuid import uuid4 import pdfplumber from fastapi import Depends, FastAPI, File, Form, HTTPException, Request, UploadFile from fastapi.responses import HTMLResponse, RedirectResponse from fastapi.templating import Jinja2Templates from pypdf import PdfReader from sqlalchemy import select from sqlalchemy.orm import Session from starlette.middleware.sessions import SessionMiddleware from .database import Base, engine, get_db from .models import CallImport, RawEvent, TimeEntry from .schemas import TimeTrackSync, ZedSync from .services import TZ, derive_zed_suggestions, overlaps_accepted, parse_timestamp APP_TIMEZONE = os.environ.get("APP_TIMEZONE", "Europe/Vienna") ADMIN_PASSWORD = os.environ.get("ADMIN_PASSWORD", "") SYNC_API_TOKEN = os.environ.get("SYNC_API_TOKEN", "") UPLOAD_DIR = Path(os.environ.get("UPLOAD_DIR", "./uploads")) app = FastAPI(title="Time Track Server", version="0.1.0") app.add_middleware(SessionMiddleware, secret_key=os.environ.get("SESSION_SECRET", "development-only-change-me"), https_only=False) templates = Jinja2Templates(directory=str(Path(__file__).parent / "templates")) @app.on_event("startup") def startup() -> None: if not ADMIN_PASSWORD or not SYNC_API_TOKEN: raise RuntimeError("ADMIN_PASSWORD and SYNC_API_TOKEN must be configured") Base.metadata.create_all(bind=engine) UPLOAD_DIR.mkdir(parents=True, exist_ok=True) def require_api(request: Request) -> None: authorization = request.headers.get("Authorization", "") if not authorization.startswith("Bearer ") or not secrets.compare_digest(authorization[7:], SYNC_API_TOKEN): raise HTTPException(status_code=401, detail="Valid bearer token required") def require_web(request: Request) -> None: if not request.session.get("authenticated"): raise HTTPException(status_code=401, detail="Login required") def insert_raw_event(db: Session, source: str, device_id: str, external_id: str, project_slug: str | None, occurred_at: datetime | None, payload: dict) -> bool: existing = db.scalar(select(RawEvent.id).where( RawEvent.source == source, RawEvent.device_id == device_id, RawEvent.external_id == external_id, )) if existing is not None: return False db.add(RawEvent(source=source, device_id=device_id, external_id=external_id, project_slug=project_slug, occurred_at=occurred_at, payload=payload)) return True @app.get("/healthz") def healthz() -> dict: return {"status": "ok", "timezone": APP_TIMEZONE} @app.get("/login", response_class=HTMLResponse) def login_page(request: Request): return templates.TemplateResponse(request, "login.html", {"error": None}) @app.post("/login", response_class=HTMLResponse) def login(request: Request, password: Annotated[str, Form()]): if not secrets.compare_digest(password, ADMIN_PASSWORD): return templates.TemplateResponse(request, "login.html", {"error": "Invalid password"}, status_code=401) request.session["authenticated"] = True return RedirectResponse("/", status_code=303) @app.post("/logout") def logout(request: Request): request.session.clear() return RedirectResponse("/login", status_code=303) @app.post("/api/v1/sync/time-track", dependencies=[Depends(require_api)]) def sync_time_track(payload: TimeTrackSync, db: Session = Depends(get_db)) -> dict: inserted = 0 for item in payload.entries: start_at, end_at = parse_timestamp(item.start), parse_timestamp(item.end) if end_at < start_at: raise HTTPException(422, "Entry end must not precede start") if insert_raw_event(db, "time_track", payload.device_id, item.id, None, start_at, item.model_dump()): db.add(TimeEntry(source="time_track", device_id=payload.device_id, external_id=item.id, kind="tracked", status="accepted", start_at=start_at, end_at=end_at, duration_seconds=item.duration, task=item.task)) inserted += 1 db.commit() return {"received": len(payload.entries), "inserted": inserted} @app.post("/api/v1/sync/zed-heartbeats", dependencies=[Depends(require_api)]) def sync_zed_heartbeats(payload: ZedSync, db: Session = Depends(get_db)) -> dict: inserted = 0 for item in payload.records: occurred_at = parse_timestamp(item.timestamp) record = item.model_dump(by_alias=True) if insert_raw_event(db, "zed_heartbeat", payload.device_id, item.source_id, payload.project_slug, occurred_at, record): inserted += 1 db.flush() blocks = derive_zed_suggestions(db, payload.device_id, payload.project_slug) db.commit() return {"received": len(payload.records), "inserted": inserted, "suggestion_blocks": blocks} @app.post("/api/v1/sync/zed-commits", dependencies=[Depends(require_api)]) def sync_zed_commits(payload: ZedSync, db: Session = Depends(get_db)) -> dict: inserted = 0 for item in payload.records: occurred_at = parse_timestamp(item.timestamp) if insert_raw_event(db, "zed_commit", payload.device_id, item.source_id, payload.project_slug, occurred_at, item.model_dump(by_alias=True)): inserted += 1 db.commit() return {"received": len(payload.records), "inserted": inserted} @app.get("/", response_class=HTMLResponse) def index(request: Request, start: str | None = None, end: str | None = None, db: Session = Depends(get_db)): require_web(request) query = select(TimeEntry).order_by(TimeEntry.start_at.desc()) entries = db.scalars(query).all() if start: start_date = datetime.fromisoformat(start).replace(tzinfo=TZ) entries = [e for e in entries if e.start_at >= start_date] if end: end_date = datetime.fromisoformat(end).replace(tzinfo=TZ).replace(hour=23, minute=59, second=59) entries = [e for e in entries if e.start_at <= end_date] accepted = sum(e.duration_seconds for e in entries if e.status == "accepted") suggested = sum(e.duration_seconds for e in entries if e.status == "suggested") return templates.TemplateResponse(request, "index.html", { "entries": entries, "accepted": accepted, "suggested": suggested, "start": start or "", "end": end or "", "timezone": APP_TIMEZONE, "display_tz": TZ, "format_seconds": lambda seconds: f"{seconds // 3600}h {(seconds % 3600) // 60:02d}m", "overlaps_accepted": lambda entry: overlaps_accepted(db, entry), }) @app.post("/entries/manual") def create_manual(request: Request, start_at: Annotated[str, Form()], end_at: Annotated[str, Form()], task: Annotated[str, Form()] = "", project_slug: Annotated[str, Form()] = "", db: Session = Depends(get_db)): require_web(request) start, end = parse_timestamp(start_at), parse_timestamp(end_at) if end <= start: raise HTTPException(422, "End must be after start") db.add(TimeEntry(source="manual", device_id="server", external_id=str(uuid4()), kind="manual", status="accepted", start_at=start, end_at=end, duration_seconds=int((end - start).total_seconds()), task=task, project_slug=project_slug or None)) db.commit() return RedirectResponse("/", status_code=303) @app.post("/entries/{entry_id}/edit") def edit_entry(entry_id: int, request: Request, start_at: Annotated[str, Form()], end_at: Annotated[str, Form()], task: Annotated[str, Form()] = "", project_slug: Annotated[str, Form()] = "", notes: Annotated[str, Form()] = "", db: Session = Depends(get_db)): require_web(request) entry = db.get(TimeEntry, entry_id) if not entry: raise HTTPException(404, "Entry not found") start, end = parse_timestamp(start_at), parse_timestamp(end_at) if end <= start: raise HTTPException(422, "End must be after start") entry.start_at = start entry.end_at = end entry.duration_seconds = int((end - start).total_seconds()) entry.task = task entry.project_slug = project_slug or None entry.notes = notes # Editing an inference explicitly makes it a durable manual timesheet row. if entry.kind == "zed_inferred": entry.kind = "manual" db.commit() return RedirectResponse("/", status_code=303) @app.post("/entries/{entry_id}/status") def set_entry_status(entry_id: int, request: Request, status: Annotated[str, Form()], db: Session = Depends(get_db)): require_web(request) if status not in {"accepted", "rejected", "suggested"}: raise HTTPException(422, "Invalid status") entry = db.get(TimeEntry, entry_id) if not entry: raise HTTPException(404, "Entry not found") entry.status = status # An accepted inference becomes a durable user-approved manual entry. if status == "accepted" and entry.kind == "zed_inferred": entry.kind = "manual" db.commit() return RedirectResponse("/", status_code=303) @app.post("/call-imports", response_class=HTMLResponse) def upload_call_pdf(request: Request, file: Annotated[UploadFile, File()], db: Session = Depends(get_db)): require_web(request) if not file.filename or not file.filename.lower().endswith(".pdf"): raise HTTPException(422, "Only PDF call-detail logs are accepted") destination = UPLOAD_DIR / f"{uuid4()}.pdf" with destination.open("wb") as output: shutil.copyfileobj(file.file, output) try: with pdfplumber.open(destination) as pdf: text = "\n".join(page.extract_text() or "" for page in pdf.pages) status = "extracted" if text.strip() else "needs_ocr" except Exception: try: reader = PdfReader(destination) text = "\n".join(page.extract_text() or "" for page in reader.pages) status = "extracted" if text.strip() else "needs_ocr" except Exception as exc: text, status = f"Extraction failed: {exc}", "failed" call_import = CallImport(filename=file.filename, stored_path=str(destination), extraction_status=status, extracted_text=text) db.add(call_import) db.commit() return RedirectResponse("/", status_code=303)