Add self-hosted combined timesheet server
This commit is contained in:
@@ -0,0 +1,11 @@
|
||||
# Bind directly to the Tailscale address in production.
|
||||
BIND_IP=100.103.83.12
|
||||
POSTGRES_DB=time_track
|
||||
POSTGRES_USER=time_track
|
||||
POSTGRES_PASSWORD=replace-with-a-long-random-password
|
||||
ADMIN_PASSWORD=replace-with-a-long-random-password
|
||||
SYNC_API_TOKEN=replace-with-a-long-random-token
|
||||
SESSION_SECRET=replace-with-a-long-random-session-secret
|
||||
APP_TIMEZONE=Europe/Vienna
|
||||
OLLAMA_BASE_URL=http://100.103.83.12:11345
|
||||
OLLAMA_MODEL=qwen3:8b
|
||||
@@ -0,0 +1,4 @@
|
||||
.env
|
||||
__pycache__/
|
||||
.pytest_cache/
|
||||
*.pyc
|
||||
@@ -0,0 +1,6 @@
|
||||
[submodule "time_track"]
|
||||
path = time_track
|
||||
url = http://100.103.83.12:3003/fegger/waybar_time_track.git
|
||||
[submodule "zed_time_tracker"]
|
||||
path = zed_time_tracker
|
||||
url = http://100.103.83.12:3003/fegger/zed_time_tracker.git
|
||||
+13
@@ -0,0 +1,13 @@
|
||||
FROM python:3.12-slim
|
||||
|
||||
ENV PYTHONDONTWRITEBYTECODE=1 \
|
||||
PYTHONUNBUFFERED=1 \
|
||||
PYTHONPATH=/srv
|
||||
WORKDIR /srv
|
||||
COPY requirements.txt .
|
||||
RUN pip install --no-cache-dir -r requirements.txt
|
||||
COPY app ./app
|
||||
RUN useradd --system --create-home appuser && mkdir -p /data/call-imports && chown -R appuser:appuser /srv /data
|
||||
USER appuser
|
||||
EXPOSE 3000
|
||||
CMD ["uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "3000"]
|
||||
@@ -0,0 +1,56 @@
|
||||
# Time Track Server
|
||||
|
||||
A self-hosted, single-user combined timesheet server for `time_track` manual intervals and `zed_time_tracker` activity. It is designed for private access over Tailscale and groups days in `Europe/Vienna` by default.
|
||||
|
||||
## What is implemented
|
||||
|
||||
- Docker Compose deployment: FastAPI app and private PostgreSQL database.
|
||||
- Password-protected single-user web UI for browsing combined entries, filtering dates, adding manual time, and accepting/rejecting Zed suggestions.
|
||||
- Token-authenticated, idempotent ingestion endpoints for both local trackers.
|
||||
- Explicit `time_track` intervals are accepted time; Zed heartbeat blocks are **suggested only**, never included in accepted totals until approved.
|
||||
- PDF call-log upload with durable source-file storage and text extraction. A provider-specific parser is intentionally deferred until a redacted mobile-provider sample statement establishes the layout.
|
||||
|
||||
## Deploy
|
||||
|
||||
```sh
|
||||
cp .env.example .env
|
||||
# Generate three distinct high-entropy values for POSTGRES_PASSWORD, ADMIN_PASSWORD,
|
||||
# SYNC_API_TOKEN, and SESSION_SECRET. Keep .env private.
|
||||
docker compose up --build -d
|
||||
```
|
||||
|
||||
Set `BIND_IP=100.103.83.12` to listen only on the Tailscale address. Browse to `http://100.103.83.12:3008` and log in using `ADMIN_PASSWORD`.
|
||||
|
||||
`postgres` is not published to the host. Docker volumes `postgres_data` and `call_imports` contain the state that must be backed up.
|
||||
|
||||
## Connect `time_track`
|
||||
|
||||
```sh
|
||||
export TIME_TRACK_SERVER_URL=http://100.103.83.12:3008
|
||||
export TIME_TRACK_SERVER_TOKEN='the SYNC_API_TOKEN value'
|
||||
export TIME_TRACK_DEVICE_ID='desktop-1'
|
||||
python3 time_track/time_track.py sync
|
||||
```
|
||||
|
||||
## Connect the Zed tracker
|
||||
|
||||
```sh
|
||||
export ZED_HOURS_SERVER_URL=http://100.103.83.12:3008
|
||||
export ZED_HOURS_SERVER_TOKEN='the SYNC_API_TOKEN value'
|
||||
export ZED_HOURS_DEVICE_ID='desktop-1'
|
||||
python3 zed_time_tracker/sync.py --project-root /path/to/project --project-slug customer-project
|
||||
```
|
||||
|
||||
The Zed client sends a project slug rather than local absolute project paths. Repeated requests are safe: source IDs and server uniqueness rules prevent duplicate events.
|
||||
|
||||
## Call PDFs and Ollama
|
||||
|
||||
The initial upload path accepts mobile-provider PDF call-detail records and safely records extracted text, including a clear `needs_ocr` status for image-only PDFs. It deliberately does **not** generate timesheet durations until a parser is implemented and tested against redacted sample PDFs from the provider.
|
||||
|
||||
The Compose configuration reserves the local Ollama endpoint `http://100.103.83.12:11345` and recommends `qwen3:8b` for optional, review-only task/project suggestions. No Ollama requests are made in this initial release because deterministic PDF parsing and phone matching must come first.
|
||||
|
||||
## Operational notes
|
||||
|
||||
- Use an application password even on Tailscale; the network is not the sole access-control layer.
|
||||
- Back up the Postgres volume before upgrades. A simple host backup is `docker compose exec -T postgres pg_dump -U time_track time_track > backup.sql`.
|
||||
- HTTPS is not configured: Tailscale encrypts node-to-node traffic. If browser HTTPS is required, add a Tailscale-aware reverse proxy or Tailscale Serve in front of port 3008.
|
||||
@@ -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
@@ -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)
|
||||
@@ -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)
|
||||
@@ -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
|
||||
@@ -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}
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -0,0 +1,39 @@
|
||||
services:
|
||||
postgres:
|
||||
image: postgres:17-alpine
|
||||
environment:
|
||||
POSTGRES_DB: ${POSTGRES_DB:-time_track}
|
||||
POSTGRES_USER: ${POSTGRES_USER:-time_track}
|
||||
POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:?Set POSTGRES_PASSWORD in .env}
|
||||
volumes:
|
||||
- postgres_data:/var/lib/postgresql/data
|
||||
healthcheck:
|
||||
test: ["CMD-SHELL", "pg_isready -U $${POSTGRES_USER} -d $${POSTGRES_DB}"]
|
||||
interval: 10s
|
||||
timeout: 5s
|
||||
retries: 5
|
||||
restart: unless-stopped
|
||||
|
||||
app:
|
||||
build: .
|
||||
environment:
|
||||
DATABASE_URL: postgresql+psycopg://${POSTGRES_USER:-time_track}:${POSTGRES_PASSWORD}@postgres:5432/${POSTGRES_DB:-time_track}
|
||||
ADMIN_PASSWORD: ${ADMIN_PASSWORD:?Set ADMIN_PASSWORD in .env}
|
||||
SYNC_API_TOKEN: ${SYNC_API_TOKEN:?Set SYNC_API_TOKEN in .env}
|
||||
SESSION_SECRET: ${SESSION_SECRET:?Set SESSION_SECRET in .env}
|
||||
APP_TIMEZONE: ${APP_TIMEZONE:-Europe/Vienna}
|
||||
OLLAMA_BASE_URL: ${OLLAMA_BASE_URL:-http://100.103.83.12:11345}
|
||||
OLLAMA_MODEL: ${OLLAMA_MODEL:-qwen3:8b}
|
||||
UPLOAD_DIR: /data/call-imports
|
||||
volumes:
|
||||
- call_imports:/data/call-imports
|
||||
ports:
|
||||
- "${BIND_IP:-127.0.0.1}:3008:3000"
|
||||
depends_on:
|
||||
postgres:
|
||||
condition: service_healthy
|
||||
restart: unless-stopped
|
||||
|
||||
volumes:
|
||||
postgres_data:
|
||||
call_imports:
|
||||
@@ -0,0 +1,9 @@
|
||||
fastapi==0.116.1
|
||||
uvicorn[standard]==0.35.0
|
||||
sqlalchemy==2.0.43
|
||||
psycopg[binary]==3.2.9
|
||||
jinja2==3.1.6
|
||||
python-multipart==0.0.20
|
||||
itsdangerous==2.2.0
|
||||
pypdf==6.0.0
|
||||
pdfplumber==0.11.7
|
||||
Submodule
+1
Submodule time_track added at 74d2bfd3a7
Submodule
+1
Submodule zed_time_tracker added at 26041efd8d
Reference in New Issue
Block a user