251 lines
11 KiB
Python
251 lines
11 KiB
Python
import logging
|
|
import os
|
|
import secrets
|
|
import shutil
|
|
import time
|
|
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, Response
|
|
from fastapi.templating import Jinja2Templates
|
|
from pypdf import PdfReader
|
|
from sqlalchemy import select
|
|
from sqlalchemy.exc import OperationalError
|
|
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")
|
|
# Postgres may briefly restart or lag behind its health check at deploy time.
|
|
for attempt in range(1, 11):
|
|
try:
|
|
Base.metadata.create_all(bind=engine)
|
|
break
|
|
except OperationalError as exc:
|
|
if attempt == 10:
|
|
raise
|
|
logging.getLogger("uvicorn.error").warning(
|
|
"Database not ready (attempt %s/10): %s", attempt, exc.orig
|
|
)
|
|
time.sleep(2)
|
|
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")
|
|
|
|
|
|
class LoginRequired(Exception):
|
|
"""A browser endpoint was accessed without an authenticated session."""
|
|
|
|
|
|
@app.exception_handler(LoginRequired)
|
|
def login_required_handler(request: Request, exc: LoginRequired):
|
|
# Browsers expect to be sent to the login page, not shown a bare 401.
|
|
return RedirectResponse("/login", status_code=303)
|
|
|
|
|
|
def require_web(request: Request) -> None:
|
|
if not request.session.get("authenticated"):
|
|
raise LoginRequired()
|
|
|
|
|
|
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("/favicon.ico", include_in_schema=False)
|
|
def favicon():
|
|
return Response(status_code=204)
|
|
|
|
|
|
@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)
|