Add self-hosted combined timesheet server

This commit is contained in:
2026-09-14 13:14:47 +02:00
commit 25eb7f50e6
17 changed files with 584 additions and 0 deletions
View File
+20
View File
@@ -0,0 +1,20 @@
import os
from sqlalchemy import create_engine
from sqlalchemy.orm import DeclarativeBase, Session, sessionmaker
DATABASE_URL = os.environ.get("DATABASE_URL", "sqlite:///./time_track.db")
engine = create_engine(DATABASE_URL, pool_pre_ping=True)
SessionLocal = sessionmaker(bind=engine, autocommit=False, autoflush=False)
class Base(DeclarativeBase):
pass
def get_db():
db: Session = SessionLocal()
try:
yield db
finally:
db.close()
+221
View File
@@ -0,0 +1,221 @@
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)
+56
View File
@@ -0,0 +1,56 @@
from datetime import datetime, timezone
from sqlalchemy import DateTime, ForeignKey, Integer, JSON, String, Text, UniqueConstraint
from sqlalchemy.orm import Mapped, mapped_column
from .database import Base
def utc_now() -> datetime:
return datetime.now(timezone.utc)
class RawEvent(Base):
__tablename__ = "raw_events"
__table_args__ = (UniqueConstraint("source", "device_id", "external_id", name="uq_raw_event"),)
id: Mapped[int] = mapped_column(primary_key=True)
source: Mapped[str] = mapped_column(String(32), index=True)
device_id: Mapped[str] = mapped_column(String(128), index=True)
external_id: Mapped[str] = mapped_column(String(128))
project_slug: Mapped[str | None] = mapped_column(String(255), nullable=True, index=True)
occurred_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True, index=True)
payload: Mapped[dict] = mapped_column(JSON)
received_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=utc_now)
class TimeEntry(Base):
__tablename__ = "time_entries"
__table_args__ = (UniqueConstraint("source", "device_id", "external_id", name="uq_time_entry"),)
id: Mapped[int] = mapped_column(primary_key=True)
source: Mapped[str] = mapped_column(String(32), index=True)
device_id: Mapped[str] = mapped_column(String(128), default="server", index=True)
external_id: Mapped[str] = mapped_column(String(128))
kind: Mapped[str] = mapped_column(String(32), default="tracked", index=True)
status: Mapped[str] = mapped_column(String(32), default="accepted", index=True)
start_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), index=True)
end_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), index=True)
duration_seconds: Mapped[int] = mapped_column(Integer)
task: Mapped[str] = mapped_column(String(512), default="")
project_slug: Mapped[str | None] = mapped_column(String(255), nullable=True, index=True)
notes: Mapped[str] = mapped_column(Text, default="")
raw_event_id: Mapped[int | None] = mapped_column(ForeignKey("raw_events.id"), nullable=True)
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=utc_now)
updated_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=utc_now, onupdate=utc_now)
class CallImport(Base):
__tablename__ = "call_imports"
id: Mapped[int] = mapped_column(primary_key=True)
filename: Mapped[str] = mapped_column(String(512))
stored_path: Mapped[str] = mapped_column(String(1024))
extraction_status: Mapped[str] = mapped_column(String(32), default="uploaded")
extracted_text: Mapped[str] = mapped_column(Text, default="")
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=utc_now)
+47
View File
@@ -0,0 +1,47 @@
from datetime import datetime
from typing import Any
from pydantic import BaseModel, Field
class TimeTrackEntry(BaseModel):
id: str = Field(min_length=1, max_length=128)
task: str = ""
start: str
end: str
duration: int = Field(ge=0)
class TimeTrackSync(BaseModel):
device_id: str = Field(min_length=1, max_length=128)
entries: list[TimeTrackEntry] = Field(default_factory=list, max_length=1000)
class ZedRecord(BaseModel):
source_id: str = Field(min_length=1, max_length=128)
timestamp: str
zed_active: bool | None = None
type: str | None = None
branch: str | None = None
subject: str | None = None
message: str | None = None
author: str | None = None
source: str | None = None
title: str | None = None
class_: str | None = Field(default=None, alias="class")
model_config = {"extra": "allow", "populate_by_name": True}
class ZedSync(BaseModel):
device_id: str = Field(min_length=1, max_length=128)
project_slug: str = Field(min_length=1, max_length=255)
records: list[ZedRecord] = Field(default_factory=list, max_length=2000)
class EntryUpdate(BaseModel):
task: str | None = Field(default=None, max_length=512)
project_slug: str | None = Field(default=None, max_length=255)
notes: str | None = None
status: str | None = None
start_at: datetime | None = None
end_at: datetime | None = None
+87
View File
@@ -0,0 +1,87 @@
import hashlib
import os
from collections import defaultdict
from datetime import datetime
from zoneinfo import ZoneInfo
from sqlalchemy import delete, select
from sqlalchemy.orm import Session
from .models import RawEvent, TimeEntry
TZ = ZoneInfo(os.environ.get("APP_TIMEZONE", "Europe/Vienna"))
def parse_timestamp(value: str) -> datetime:
value = value.replace("Z", "+00:00")
parsed = datetime.fromisoformat(value)
if parsed.tzinfo is None:
return parsed.replace(tzinfo=TZ)
return parsed
def stable_id(*parts: str) -> str:
return hashlib.sha256("\x1f".join(parts).encode()).hexdigest()
def derive_zed_suggestions(db: Session, device_id: str, project_slug: str, idle_seconds: int = 300) -> int:
events = db.scalars(
select(RawEvent).where(
RawEvent.source == "zed_heartbeat",
RawEvent.device_id == device_id,
RawEvent.project_slug == project_slug,
).order_by(RawEvent.occurred_at)
).all()
active = [event for event in events if event.payload.get("zed_active") is True and event.occurred_at is not None]
# Accepted suggestions become manual entries and are intentionally retained.
db.execute(delete(TimeEntry).where(
TimeEntry.source == "zed",
TimeEntry.device_id == device_id,
TimeEntry.project_slug == project_slug,
TimeEntry.kind == "zed_inferred",
TimeEntry.status == "suggested",
))
blocks: list[tuple[datetime, datetime, int]] = []
start = previous = None
for event in active:
current = event.occurred_at
if current is None:
continue
if previous is None or (current - previous).total_seconds() > idle_seconds:
if start is not None and previous is not None and previous > start:
blocks.append((start, previous, int((previous - start).total_seconds())))
start = current
previous = current
if start is not None and previous is not None and previous > start:
blocks.append((start, previous, int((previous - start).total_seconds())))
for start_at, end_at, duration in blocks:
entry_id = stable_id(device_id, project_slug, start_at.isoformat(), end_at.isoformat())
db.add(TimeEntry(
source="zed", device_id=device_id, external_id=entry_id, kind="zed_inferred",
status="suggested", start_at=start_at, end_at=end_at, duration_seconds=duration,
task="Zed activity", project_slug=project_slug,
))
return len(blocks)
def overlaps_accepted(db: Session, entry: TimeEntry) -> bool:
matching = db.scalars(select(TimeEntry).where(
TimeEntry.status == "accepted", TimeEntry.id != entry.id,
TimeEntry.start_at < entry.end_at, TimeEntry.end_at > entry.start_at,
)).all()
return bool(matching)
def daily_summary(entries: list[TimeEntry]) -> dict:
totals: dict[str, int] = defaultdict(int)
suggestions = 0
for entry in entries:
day = entry.start_at.astimezone(TZ).date().isoformat()
if entry.status == "accepted":
totals[day] += entry.duration_seconds
elif entry.status == "suggested":
suggestions += entry.duration_seconds
return {"by_day": dict(totals), "suggested_seconds": suggestions}
+10
View File
@@ -0,0 +1,10 @@
<!doctype html>
<html lang="en"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width, initial-scale=1"><title>Timesheets · Time Track Server</title><link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/@picocss/pico@2/css/pico.min.css"><style>small.suggested{color:#9a6700}.rejected{opacity:.5}td{vertical-align:top}form.inline{display:inline}</style></head>
<body><main class="container"><nav><ul><li><strong>Time Track Server</strong></li></ul><ul><li>{{ timezone }}</li><li><form class="inline" method="post" action="/logout"><button class="secondary outline">Sign out</button></form></li></ul></nav>
<h1>Combined timesheet</h1><form method="get" class="grid"><label>From <input type="date" name="start" value="{{ start }}"></label><label>To <input type="date" name="end" value="{{ end }}"></label><button type="submit">Filter</button></form>
<section class="grid"><article><header>Accepted total</header><strong>{{ format_seconds(accepted) }}</strong></article><article><header>Suggested, not counted</header><strong>{{ format_seconds(suggested) }}</strong></article></section>
<details><summary>Add manual time</summary><form method="post" action="/entries/manual" class="grid"><label>Start <input name="start_at" type="datetime-local" required></label><label>End <input name="end_at" type="datetime-local" required></label><label>Task <input name="task"></label><label>Project <input name="project_slug"></label><button type="submit">Add accepted time</button></form></details>
<details><summary>Upload mobile-provider call log (PDF)</summary><p>Uploads are extracted and retained for review. A provider-specific parser is added after a redacted sample statement is available.</p><form method="post" action="/call-imports" enctype="multipart/form-data"><input type="file" name="file" accept="application/pdf,.pdf" required><button type="submit">Upload PDF</button></form></details>
<h2>Entries</h2><figure><table><thead><tr><th>Start</th><th>End</th><th>Source</th><th>Task / project</th><th>Duration</th><th>Status</th><th>Actions</th></tr></thead><tbody>
{% for entry in entries %}<tr class="{{ entry.status }}"><td>{{ entry.start_at.astimezone(display_tz).strftime('%Y-%m-%d %H:%M') }}</td><td>{{ entry.end_at.astimezone(display_tz).strftime('%H:%M') }}</td><td>{{ entry.source }}{% if entry.kind == 'zed_inferred' %}<br><small class="suggested">inferred{% if overlaps_accepted(entry) %}; overlaps accepted time{% endif %}</small>{% endif %}</td><td>{{ entry.task or '—' }}{% if entry.project_slug %}<br><small>{{ entry.project_slug }}</small>{% endif %}</td><td>{{ format_seconds(entry.duration_seconds) }}</td><td>{{ entry.status }}</td><td>{% if entry.status == 'suggested' %}<form class="inline" method="post" action="/entries/{{ entry.id }}/status"><input type="hidden" name="status" value="accepted"><button>Accept</button></form><form class="inline" method="post" action="/entries/{{ entry.id }}/status"><input type="hidden" name="status" value="rejected"><button class="secondary">Reject</button></form>{% endif %}<details><summary>Edit</summary><form method="post" action="/entries/{{ entry.id }}/edit"><label>Start <input name="start_at" type="datetime-local" value="{{ entry.start_at.astimezone(display_tz).strftime('%Y-%m-%dT%H:%M') }}" required></label><label>End <input name="end_at" type="datetime-local" value="{{ entry.end_at.astimezone(display_tz).strftime('%Y-%m-%dT%H:%M') }}" required></label><label>Task <input name="task" value="{{ entry.task }}"></label><label>Project <input name="project_slug" value="{{ entry.project_slug or '' }}"></label><label>Notes <textarea name="notes">{{ entry.notes }}</textarea></label><button type="submit">Save</button></form></details></td></tr>
{% else %}<tr><td colspan="7">No entries yet. Sync a tracker or add manual time.</td></tr>{% endfor %}</tbody></table></figure></main></body></html>
+3
View File
@@ -0,0 +1,3 @@
<!doctype html>
<html lang="en"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width, initial-scale=1"><title>Time Track Server</title><link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/@picocss/pico@2/css/pico.min.css"></head>
<body><main class="container" style="max-width: 28rem; padding-top: 5rem"><h1>Time Track Server</h1><p>Sign in to browse and edit your timesheets.</p>{% if error %}<p role="alert">{{ error }}</p>{% endif %}<form method="post" action="/login"><label>Password <input type="password" name="password" required autofocus></label><button type="submit">Sign in</button></form></main></body></html>