feat: corporate planning hub backend — tasks, calendar, ADO, AI reports

- Alembic 0003_corporate: 10 new tables (tasks, planned_blocks, manual_entries,
  project_budgets, tags, task_tags, azure_integrations, azure_work_items,
  ai_reports, audit_log) + session.task_id FK
- New routers: calendar, tasks, manual_entries, budgets, tags, devops, exports, reports
- Services: crypto (Fernet PAT encryption), audit log, Mailgun email,
  APScheduler (ADO sync every 15 min, daily AI report at 20:00, weekly Sunday 21:00)
- Azure DevOps two-way sync: pull assigned work items, push CompletedWork on task complete
- AI reports: Anthropic API summaries or plain-stats fallback, sent via Mailgun
- structlog JSON/console logging, LoggingMiddleware, updated main.py lifespan
- pyproject.toml (ruff/mypy/pytest config), CI workflow, pre-commit hooks
- Schemas: CalendarBlock, Task*, ManualEntry*, Budget*, Tag*, AzureIntegration*,
  AiReport*, SyncReport

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Vadym Samoilenko 2026-05-06 18:44:26 +01:00
parent 57655056e3
commit 9d9e8e82d4
34 changed files with 3072 additions and 12 deletions

44
.github/workflows/ci.yml vendored Normal file
View file

@ -0,0 +1,44 @@
name: CI
on:
push:
branches: [main, feat/planning-hub]
pull_request:
branches: [main]
jobs:
backend:
runs-on: ubuntu-latest
services:
postgres:
image: postgres:16-alpine
env:
POSTGRES_DB: cc_dashboard_test
POSTGRES_USER: cc_app
POSTGRES_PASSWORD: cc_pass
ports: ["5433:5432"]
options: --health-cmd pg_isready --health-interval 5s --health-timeout 3s --health-retries 10
steps:
- uses: actions/checkout@v4
- uses: actions/setup-python@v5
with:
python-version: "3.11"
- run: pip install -r requirements.txt pytest pytest-asyncio aiosqlite ruff
- run: ruff check .
- name: Run tests
env:
DATABASE_URL: postgresql+asyncpg://cc_app:cc_pass@localhost:5433/cc_dashboard_test
SECRET_KEY: test-secret-key
run: pytest -q
frontend:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: "20"
- name: Build frontend if present
run: |
if [ -d web ]; then
cd web && npm ci && npm run lint && npm run typecheck && npm run build
fi

13
.pre-commit-config.yaml Normal file
View file

@ -0,0 +1,13 @@
repos:
- repo: https://github.com/astral-sh/ruff-pre-commit
rev: v0.8.0
hooks:
- id: ruff
args: [--fix]
- id: ruff-format
- repo: https://github.com/pre-commit/pre-commit-hooks
rev: v5.0.0
hooks:
- id: end-of-file-fixer
- id: trailing-whitespace
- id: check-yaml

View file

@ -0,0 +1,222 @@
"""corporate planning hub tables
Revision ID: 0003
Revises: 0002
Create Date: 2026-05-06
"""
import sqlalchemy as sa
from alembic import op
from sqlalchemy.dialects import postgresql
revision = "0003"
down_revision = "0002"
branch_labels = None
depends_on = None
def upgrade():
# azure_integrations must come before tasks (no dependency but logical order)
op.create_table(
"azure_integrations",
sa.Column("id", postgresql.UUID(as_uuid=False), primary_key=True),
sa.Column("user_id", postgresql.UUID(as_uuid=False), sa.ForeignKey("users.id", ondelete="CASCADE"), nullable=False),
sa.Column("organization", sa.String(200), nullable=False),
sa.Column("project", sa.String(200), nullable=False),
sa.Column("pat_encrypted", sa.LargeBinary, nullable=False),
sa.Column("pat_hint", sa.String(8), nullable=False, server_default=""),
sa.Column("last_synced_at", sa.DateTime(timezone=True), nullable=True),
sa.Column("last_sync_error", sa.Text, nullable=False, server_default=""),
sa.Column("sync_enabled", sa.Boolean, nullable=False, server_default="true"),
sa.Column("created_at", sa.DateTime(timezone=True), server_default=sa.func.now()),
sa.UniqueConstraint("user_id", name="uq_azure_integrations_user"),
)
op.create_index("ix_azure_integrations_user_id", "azure_integrations", ["user_id"])
# azure_work_items must come before tasks (tasks.azure_work_item_id references it)
op.create_table(
"azure_work_items",
sa.Column("id", postgresql.UUID(as_uuid=False), primary_key=True),
sa.Column("user_id", postgresql.UUID(as_uuid=False), sa.ForeignKey("users.id", ondelete="CASCADE"), nullable=False),
sa.Column("ado_id", sa.Integer, nullable=False),
sa.Column("title", sa.String(500), nullable=False),
sa.Column("type", sa.String(50), nullable=False, server_default=""),
sa.Column("state", sa.String(50), nullable=False, server_default=""),
sa.Column("assigned_to_email", sa.String(255), nullable=False, server_default=""),
sa.Column("iteration_path", sa.String(500), nullable=False, server_default=""),
sa.Column("area_path", sa.String(500), nullable=False, server_default=""),
sa.Column("url", sa.String(1000), nullable=False, server_default=""),
sa.Column("fields_json", postgresql.JSONB, nullable=False, server_default="{}"),
sa.Column("synced_at", sa.DateTime(timezone=True), server_default=sa.func.now()),
sa.UniqueConstraint("user_id", "ado_id", name="uq_ado_user_id"),
)
op.create_index("ix_azure_work_items_user_id", "azure_work_items", ["user_id"])
op.create_index("ix_azure_work_items_user_state", "azure_work_items", ["user_id", "state"])
# tags must come before task_tags
op.create_table(
"tags",
sa.Column("id", postgresql.UUID(as_uuid=False), primary_key=True),
sa.Column("user_id", postgresql.UUID(as_uuid=False), sa.ForeignKey("users.id", ondelete="CASCADE"), nullable=False),
sa.Column("name", sa.String(50), nullable=False),
sa.Column("color_hex", sa.String(7), nullable=False, server_default="#888888"),
sa.Column("created_at", sa.DateTime(timezone=True), server_default=sa.func.now()),
sa.UniqueConstraint("user_id", "name", name="uq_tag_user_name"),
)
op.create_index("ix_tags_user_id", "tags", ["user_id"])
# tasks references azure_work_items
op.create_table(
"tasks",
sa.Column("id", postgresql.UUID(as_uuid=False), primary_key=True),
sa.Column("user_id", postgresql.UUID(as_uuid=False), sa.ForeignKey("users.id", ondelete="CASCADE"), nullable=False),
sa.Column("project_id", postgresql.UUID(as_uuid=False), sa.ForeignKey("projects.id", ondelete="SET NULL"), nullable=True),
sa.Column("azure_work_item_id", postgresql.UUID(as_uuid=False), sa.ForeignKey("azure_work_items.id", ondelete="SET NULL"), nullable=True),
sa.Column("title", sa.String(500), nullable=False),
sa.Column("notes", sa.Text, nullable=False, server_default=""),
sa.Column("planned_date", sa.Date, nullable=False),
sa.Column("estimate_hours", sa.Float, nullable=False, server_default="0"),
sa.Column("actual_hours", sa.Float, nullable=False, server_default="0"),
sa.Column("status", sa.String(20), nullable=False, server_default="todo"),
sa.Column("priority", sa.Integer, nullable=False, server_default="3"),
sa.Column("sort_index", sa.Integer, nullable=False, server_default="0"),
sa.Column("completed_at", sa.DateTime(timezone=True), nullable=True),
sa.Column("ado_synced_at", sa.DateTime(timezone=True), nullable=True),
sa.Column("created_at", sa.DateTime(timezone=True), server_default=sa.func.now()),
)
op.create_index("ix_tasks_user_id", "tasks", ["user_id"])
op.create_index("ix_tasks_project_id", "tasks", ["project_id"])
op.create_index("ix_tasks_azure_work_item_id", "tasks", ["azure_work_item_id"])
op.create_index("ix_tasks_planned_date", "tasks", ["planned_date"])
# planned_blocks references tasks
op.create_table(
"planned_blocks",
sa.Column("id", postgresql.UUID(as_uuid=False), primary_key=True),
sa.Column("user_id", postgresql.UUID(as_uuid=False), sa.ForeignKey("users.id", ondelete="CASCADE"), nullable=False),
sa.Column("task_id", postgresql.UUID(as_uuid=False), sa.ForeignKey("tasks.id", ondelete="CASCADE"), nullable=False),
sa.Column("project_id", postgresql.UUID(as_uuid=False), sa.ForeignKey("projects.id", ondelete="SET NULL"), nullable=True),
sa.Column("start_at", sa.DateTime(timezone=True), nullable=False),
sa.Column("end_at", sa.DateTime(timezone=True), nullable=False),
sa.Column("created_at", sa.DateTime(timezone=True), server_default=sa.func.now()),
)
op.create_index("ix_planned_blocks_user_id", "planned_blocks", ["user_id"])
op.create_index("ix_planned_blocks_task_id", "planned_blocks", ["task_id"])
# manual_entries
op.create_table(
"manual_entries",
sa.Column("id", postgresql.UUID(as_uuid=False), primary_key=True),
sa.Column("user_id", postgresql.UUID(as_uuid=False), sa.ForeignKey("users.id", ondelete="CASCADE"), nullable=False),
sa.Column("project_id", postgresql.UUID(as_uuid=False), sa.ForeignKey("projects.id", ondelete="SET NULL"), nullable=True),
sa.Column("task_id", postgresql.UUID(as_uuid=False), sa.ForeignKey("tasks.id", ondelete="SET NULL"), nullable=True),
sa.Column("start_at", sa.DateTime(timezone=True), nullable=False),
sa.Column("end_at", sa.DateTime(timezone=True), nullable=False),
sa.Column("title", sa.String(500), nullable=False),
sa.Column("notes", sa.Text, nullable=False, server_default=""),
sa.Column("source", sa.String(20), nullable=False, server_default="manual"),
sa.Column("created_at", sa.DateTime(timezone=True), server_default=sa.func.now()),
)
op.create_index("ix_manual_entries_user_id", "manual_entries", ["user_id"])
# project_budgets
op.create_table(
"project_budgets",
sa.Column("id", postgresql.UUID(as_uuid=False), primary_key=True),
sa.Column("user_id", postgresql.UUID(as_uuid=False), sa.ForeignKey("users.id", ondelete="CASCADE"), nullable=False),
sa.Column("project_id", postgresql.UUID(as_uuid=False), sa.ForeignKey("projects.id", ondelete="CASCADE"), nullable=False),
sa.Column("period", sa.String(10), nullable=False),
sa.Column("target_hours", sa.Float, nullable=False),
sa.Column("starts_on", sa.Date, nullable=False),
sa.Column("created_at", sa.DateTime(timezone=True), server_default=sa.func.now()),
sa.UniqueConstraint("user_id", "project_id", "period", "starts_on", name="uq_budget"),
)
op.create_index("ix_project_budgets_user_id", "project_budgets", ["user_id"])
op.create_index("ix_project_budgets_project_id", "project_budgets", ["project_id"])
# task_tags association table
op.create_table(
"task_tags",
sa.Column("task_id", postgresql.UUID(as_uuid=False), sa.ForeignKey("tasks.id", ondelete="CASCADE"), primary_key=True),
sa.Column("tag_id", postgresql.UUID(as_uuid=False), sa.ForeignKey("tags.id", ondelete="CASCADE"), primary_key=True),
)
# ai_reports
op.create_table(
"ai_reports",
sa.Column("id", postgresql.UUID(as_uuid=False), primary_key=True),
sa.Column("user_id", postgresql.UUID(as_uuid=False), sa.ForeignKey("users.id", ondelete="CASCADE"), nullable=False),
sa.Column("type", sa.String(10), nullable=False),
sa.Column("period_date", sa.Date, nullable=False),
sa.Column("content_markdown", sa.Text, nullable=False, server_default=""),
sa.Column("content_html", sa.Text, nullable=False, server_default=""),
sa.Column("email_sent", sa.Boolean, nullable=False, server_default="false"),
sa.Column("generated_at", sa.DateTime(timezone=True), server_default=sa.func.now()),
)
op.create_index("ix_ai_reports_user_id", "ai_reports", ["user_id"])
# audit_log
op.create_table(
"audit_log",
sa.Column("id", sa.Integer, primary_key=True, autoincrement=True),
sa.Column("user_id", postgresql.UUID(as_uuid=False), sa.ForeignKey("users.id", ondelete="SET NULL"), nullable=True),
sa.Column("actor_email", sa.String(255), nullable=False, server_default=""),
sa.Column("action", sa.String(100), nullable=False),
sa.Column("entity_type", sa.String(50), nullable=False, server_default=""),
sa.Column("entity_id", sa.String(64), nullable=False, server_default=""),
sa.Column("payload_json", postgresql.JSONB, nullable=False, server_default="{}"),
sa.Column("created_at", sa.DateTime(timezone=True), server_default=sa.func.now()),
)
op.create_index("ix_audit_log_user_id", "audit_log", ["user_id"])
op.create_index("ix_audit_log_action", "audit_log", ["action"])
op.create_index("ix_audit_log_created_at", "audit_log", ["created_at"])
op.create_index("ix_audit_log_entity", "audit_log", ["entity_type", "entity_id"])
# Add task_id to sessions
op.add_column(
"sessions",
sa.Column("task_id", postgresql.UUID(as_uuid=False), sa.ForeignKey("tasks.id", ondelete="SET NULL"), nullable=True),
)
op.create_index("ix_sessions_task_id", "sessions", ["task_id"])
def downgrade():
op.drop_index("ix_sessions_task_id", "sessions")
op.drop_column("sessions", "task_id")
op.drop_index("ix_audit_log_entity", "audit_log")
op.drop_index("ix_audit_log_created_at", "audit_log")
op.drop_index("ix_audit_log_action", "audit_log")
op.drop_index("ix_audit_log_user_id", "audit_log")
op.drop_table("audit_log")
op.drop_index("ix_ai_reports_user_id", "ai_reports")
op.drop_table("ai_reports")
op.drop_table("task_tags")
op.drop_index("ix_project_budgets_project_id", "project_budgets")
op.drop_index("ix_project_budgets_user_id", "project_budgets")
op.drop_table("project_budgets")
op.drop_index("ix_manual_entries_user_id", "manual_entries")
op.drop_table("manual_entries")
op.drop_index("ix_planned_blocks_task_id", "planned_blocks")
op.drop_index("ix_planned_blocks_user_id", "planned_blocks")
op.drop_table("planned_blocks")
op.drop_index("ix_tasks_planned_date", "tasks")
op.drop_index("ix_tasks_azure_work_item_id", "tasks")
op.drop_index("ix_tasks_project_id", "tasks")
op.drop_index("ix_tasks_user_id", "tasks")
op.drop_table("tasks")
op.drop_index("ix_tags_user_id", "tags")
op.drop_table("tags")
op.drop_index("ix_azure_work_items_user_state", "azure_work_items")
op.drop_index("ix_azure_work_items_user_id", "azure_work_items")
op.drop_table("azure_work_items")
op.drop_index("ix_azure_integrations_user_id", "azure_integrations")
op.drop_table("azure_integrations")

15
pyproject.toml Normal file
View file

@ -0,0 +1,15 @@
[tool.pytest.ini_options]
asyncio_mode = "auto"
testpaths = ["src/tests"]
[tool.ruff]
target-version = "py311"
line-length = 100
[tool.ruff.lint]
select = ["E", "F", "W", "I"]
ignore = ["E501"]
[tool.mypy]
python_version = "3.11"
ignore_missing_imports = true

View file

@ -9,3 +9,9 @@ python-jose[cryptography]==3.3.0
bcrypt==4.2.1
python-multipart==0.0.20
httpx==0.28.1
cryptography==43.0.3
structlog==24.4.0
apscheduler==3.10.4
anthropic==0.40.0
icalendar==6.1.0
markdown==3.7

View file

@ -18,6 +18,27 @@ class Settings(BaseSettings):
APP_TITLE: str = "CC Dashboard"
DEBUG: bool = False
# Azure DevOps
ADO_ORGANIZATION: str = ""
ADO_PROJECT: str = ""
ADO_PAT: str = ""
ADO_SYNC_INTERVAL_MINUTES: int = 15
# Mailgun
MAILGUN_API_KEY: str = ""
MAILGUN_DOMAIN: str = ""
MAILGUN_FROM: str = "CC Dashboard <noreply@example.com>"
# AI Reports + Email
ANTHROPIC_API_KEY: str = ""
REPORT_EMAIL: str = ""
DAILY_REPORT_HOUR: int = 20
WEEKLY_REPORT_DAY: int = 6 # 0=Mon ... 6=Sun
WEEKLY_REPORT_HOUR: int = 21
# Logging
LOG_FORMAT: str = "console" # "json" or "console"
def model_post_init(self, __context):
if not self.DATABASE_URL:
object.__setattr__(

View file

@ -1,17 +1,56 @@
from fastapi import FastAPI
import logging
from contextlib import asynccontextmanager
import structlog
from fastapi import FastAPI, Request
from fastapi.middleware.cors import CORSMiddleware
from fastapi.responses import FileResponse
from fastapi.staticfiles import StaticFiles
from src.config import settings
from src.middleware.logging import LoggingMiddleware
from src.routers import admin, auth, dashboard, events, ingest, keys, projects
from src.routers import calendar, tasks, manual_entries, budgets, tags, devops, exports, reports
from src.services.scheduler import scheduler, setup_scheduler
BASE = settings.BASE_PATH
def _configure_logging() -> None:
shared_processors = [
structlog.contextvars.merge_contextvars,
structlog.stdlib.add_log_level,
structlog.stdlib.add_logger_name,
structlog.processors.TimeStamper(fmt="iso"),
]
if settings.LOG_FORMAT == "json":
renderer = structlog.processors.JSONRenderer()
else:
renderer = structlog.dev.ConsoleRenderer(colors=True)
structlog.configure(
processors=shared_processors + [renderer],
wrapper_class=structlog.make_filtering_bound_logger(logging.INFO),
context_class=dict,
logger_factory=structlog.PrintLoggerFactory(),
cache_logger_on_first_use=True,
)
@asynccontextmanager
async def lifespan(app: FastAPI):
_configure_logging()
setup_scheduler()
scheduler.start()
yield
scheduler.shutdown()
BASE = settings.BASE_PATH # "/cc-dashboard"
app = FastAPI(
title=settings.APP_TITLE,
docs_url=f"{BASE}/docs" if settings.DEBUG else None,
redoc_url=None,
root_path=BASE,
lifespan=lifespan,
)
app.add_middleware(
@ -21,18 +60,35 @@ app.add_middleware(
allow_methods=["*"],
allow_headers=["*"],
)
app.add_middleware(LoggingMiddleware)
# API routers
for router in [auth.router, keys.router, admin.router, ingest.router,
dashboard.router, events.router, projects.router]:
for router in [
auth.router,
keys.router,
admin.router,
ingest.router,
dashboard.router,
events.router,
projects.router,
calendar.router,
tasks.router,
manual_entries.router,
budgets.router,
tags.router,
devops.router,
exports.router,
reports.router,
]:
app.include_router(router)
# Static files — served at /cc-dashboard/static/
@app.get(f"{BASE}/healthz", include_in_schema=False)
async def health():
return {"status": "ok"}
app.mount(f"{BASE}/static", StaticFiles(directory="src/static"), name="static")
# SPA fallback — serve index.html for all non-API routes
from fastapi.responses import FileResponse
from fastapi import Request
@app.get(f"{BASE}/", include_in_schema=False)
@app.get(f"{BASE}", include_in_schema=False)
@ -42,8 +98,8 @@ async def spa_root():
@app.get(f"{BASE}/{{path:path}}", include_in_schema=False)
async def spa_fallback(path: str, request: Request):
# Don't catch API routes
if path.startswith("api/") or path.startswith("static/"):
from fastapi import HTTPException
raise HTTPException(status_code=404)
return FileResponse("src/static/index.html")

View file

27
src/middleware/logging.py Normal file
View file

@ -0,0 +1,27 @@
"""structlog middleware for request context binding."""
import time
import uuid
import structlog
from fastapi import Request, Response
from starlette.middleware.base import BaseHTTPMiddleware
log = structlog.get_logger()
class LoggingMiddleware(BaseHTTPMiddleware):
async def dispatch(self, request: Request, call_next) -> Response:
request_id = str(uuid.uuid4())[:8]
start = time.perf_counter()
structlog.contextvars.bind_contextvars(
request_id=request_id,
method=request.method,
path=request.url.path,
)
try:
response = await call_next(request)
duration = round((time.perf_counter() - start) * 1000, 1)
log.info("request", status=response.status_code, duration_ms=duration)
return response
finally:
structlog.contextvars.unbind_contextvars("request_id", "method", "path")

View file

@ -1,9 +1,10 @@
import uuid
from datetime import date, datetime
import sqlalchemy.types as sa_types
from sqlalchemy import (
Boolean, Date, DateTime, Enum, Float, ForeignKey,
Integer, String, Text, UniqueConstraint,
Boolean, Column, Date, DateTime, Enum, Float, ForeignKey,
Integer, LargeBinary, String, Table, Text, UniqueConstraint,
)
from sqlalchemy.dialects.postgresql import JSONB, UUID
from sqlalchemy.orm import Mapped, mapped_column, relationship
@ -16,6 +17,16 @@ def new_uuid() -> str:
return str(uuid.uuid4())
# ── Association tables ────────────────────────────────────────────────────────
task_tags = Table(
"task_tags",
Base.metadata,
Column("task_id", UUID(as_uuid=False), ForeignKey("tasks.id", ondelete="CASCADE"), primary_key=True),
Column("tag_id", UUID(as_uuid=False), ForeignKey("tags.id", ondelete="CASCADE"), primary_key=True),
)
class User(Base):
__tablename__ = "users"
@ -85,6 +96,7 @@ class Session(Base):
tools_used: Mapped[dict] = mapped_column(JSONB, default=dict)
files_changed: Mapped[list] = mapped_column(JSONB, default=list)
raw_stats: Mapped[dict] = mapped_column(JSONB, default=dict)
task_id: Mapped[str | None] = mapped_column(UUID(as_uuid=False), ForeignKey("tasks.id", ondelete="SET NULL"), nullable=True, index=True)
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now())
user: Mapped["User"] = relationship(back_populates="sessions")
@ -107,3 +119,152 @@ class DailyStat(Base):
top_tools: Mapped[dict] = mapped_column(JSONB, default=dict)
project: Mapped["Project"] = relationship(back_populates="daily_stats")
# ── Tasks (planner) ──────────────────────────────────────────────────────────
class Task(Base):
__tablename__ = "tasks"
id: Mapped[str] = mapped_column(UUID(as_uuid=False), primary_key=True, default=new_uuid)
user_id: Mapped[str] = mapped_column(UUID(as_uuid=False), ForeignKey("users.id", ondelete="CASCADE"), nullable=False, index=True)
project_id: Mapped[str | None] = mapped_column(UUID(as_uuid=False), ForeignKey("projects.id", ondelete="SET NULL"), nullable=True, index=True)
azure_work_item_id: Mapped[str | None] = mapped_column(UUID(as_uuid=False), ForeignKey("azure_work_items.id", ondelete="SET NULL"), nullable=True, index=True)
title: Mapped[str] = mapped_column(String(500), nullable=False)
notes: Mapped[str] = mapped_column(Text, default="")
planned_date: Mapped[date] = mapped_column(Date, nullable=False, index=True)
estimate_hours: Mapped[float] = mapped_column(Float, default=0.0)
actual_hours: Mapped[float] = mapped_column(Float, default=0.0)
status: Mapped[str] = mapped_column(String(20), default="todo")
priority: Mapped[int] = mapped_column(Integer, default=3)
sort_index: Mapped[int] = mapped_column(Integer, default=0)
completed_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True)
ado_synced_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True)
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now())
user: Mapped["User"] = relationship(foreign_keys=[user_id])
project: Mapped["Project | None"] = relationship(foreign_keys=[project_id])
azure_work_item: Mapped["AzureWorkItem | None"] = relationship(foreign_keys=[azure_work_item_id])
planned_blocks: Mapped[list["PlannedBlock"]] = relationship(back_populates="task", cascade="all, delete-orphan")
tags: Mapped[list["Tag"]] = relationship(secondary="task_tags", back_populates="tasks")
class PlannedBlock(Base):
__tablename__ = "planned_blocks"
id: Mapped[str] = mapped_column(UUID(as_uuid=False), primary_key=True, default=new_uuid)
user_id: Mapped[str] = mapped_column(UUID(as_uuid=False), ForeignKey("users.id", ondelete="CASCADE"), nullable=False, index=True)
task_id: Mapped[str] = mapped_column(UUID(as_uuid=False), ForeignKey("tasks.id", ondelete="CASCADE"), nullable=False, index=True)
project_id: Mapped[str | None] = mapped_column(UUID(as_uuid=False), ForeignKey("projects.id", ondelete="SET NULL"), nullable=True)
start_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), nullable=False)
end_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), nullable=False)
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now())
task: Mapped["Task"] = relationship(back_populates="planned_blocks")
project: Mapped["Project | None"] = relationship(foreign_keys=[project_id])
class ManualEntry(Base):
__tablename__ = "manual_entries"
id: Mapped[str] = mapped_column(UUID(as_uuid=False), primary_key=True, default=new_uuid)
user_id: Mapped[str] = mapped_column(UUID(as_uuid=False), ForeignKey("users.id", ondelete="CASCADE"), nullable=False, index=True)
project_id: Mapped[str | None] = mapped_column(UUID(as_uuid=False), ForeignKey("projects.id", ondelete="SET NULL"), nullable=True)
task_id: Mapped[str | None] = mapped_column(UUID(as_uuid=False), ForeignKey("tasks.id", ondelete="SET NULL"), nullable=True)
start_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), nullable=False)
end_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), nullable=False)
title: Mapped[str] = mapped_column(String(500), nullable=False)
notes: Mapped[str] = mapped_column(Text, default="")
source: Mapped[str] = mapped_column(String(20), default="manual")
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now())
project: Mapped["Project | None"] = relationship(foreign_keys=[project_id])
class ProjectBudget(Base):
__tablename__ = "project_budgets"
__table_args__ = (UniqueConstraint("user_id", "project_id", "period", "starts_on", name="uq_budget"),)
id: Mapped[str] = mapped_column(UUID(as_uuid=False), primary_key=True, default=new_uuid)
user_id: Mapped[str] = mapped_column(UUID(as_uuid=False), ForeignKey("users.id", ondelete="CASCADE"), nullable=False, index=True)
project_id: Mapped[str] = mapped_column(UUID(as_uuid=False), ForeignKey("projects.id", ondelete="CASCADE"), nullable=False, index=True)
period: Mapped[str] = mapped_column(String(10), nullable=False)
target_hours: Mapped[float] = mapped_column(Float, nullable=False)
starts_on: Mapped[date] = mapped_column(Date, nullable=False)
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now())
project: Mapped["Project"] = relationship(foreign_keys=[project_id])
class Tag(Base):
__tablename__ = "tags"
__table_args__ = (UniqueConstraint("user_id", "name", name="uq_tag_user_name"),)
id: Mapped[str] = mapped_column(UUID(as_uuid=False), primary_key=True, default=new_uuid)
user_id: Mapped[str] = mapped_column(UUID(as_uuid=False), ForeignKey("users.id", ondelete="CASCADE"), nullable=False, index=True)
name: Mapped[str] = mapped_column(String(50), nullable=False)
color_hex: Mapped[str] = mapped_column(String(7), default="#888888")
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now())
tasks: Mapped[list["Task"]] = relationship(secondary="task_tags", back_populates="tags")
class AzureIntegration(Base):
__tablename__ = "azure_integrations"
id: Mapped[str] = mapped_column(UUID(as_uuid=False), primary_key=True, default=new_uuid)
user_id: Mapped[str] = mapped_column(UUID(as_uuid=False), ForeignKey("users.id", ondelete="CASCADE"), nullable=False, unique=True)
organization: Mapped[str] = mapped_column(String(200), nullable=False)
project: Mapped[str] = mapped_column(String(200), nullable=False)
pat_encrypted: Mapped[bytes] = mapped_column(LargeBinary, nullable=False)
pat_hint: Mapped[str] = mapped_column(String(8), default="")
last_synced_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True)
last_sync_error: Mapped[str] = mapped_column(Text, default="")
sync_enabled: Mapped[bool] = mapped_column(Boolean, default=True)
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now())
user: Mapped["User"] = relationship(foreign_keys=[user_id])
class AzureWorkItem(Base):
__tablename__ = "azure_work_items"
__table_args__ = (UniqueConstraint("user_id", "ado_id", name="uq_ado_user_id"),)
id: Mapped[str] = mapped_column(UUID(as_uuid=False), primary_key=True, default=new_uuid)
user_id: Mapped[str] = mapped_column(UUID(as_uuid=False), ForeignKey("users.id", ondelete="CASCADE"), nullable=False, index=True)
ado_id: Mapped[int] = mapped_column(Integer, nullable=False)
title: Mapped[str] = mapped_column(String(500), nullable=False)
type: Mapped[str] = mapped_column(String(50), default="")
state: Mapped[str] = mapped_column(String(50), default="")
assigned_to_email: Mapped[str] = mapped_column(String(255), default="")
iteration_path: Mapped[str] = mapped_column(String(500), default="")
area_path: Mapped[str] = mapped_column(String(500), default="")
url: Mapped[str] = mapped_column(String(1000), default="")
fields_json: Mapped[dict] = mapped_column(JSONB, default=dict)
synced_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now())
class AiReport(Base):
__tablename__ = "ai_reports"
id: Mapped[str] = mapped_column(UUID(as_uuid=False), primary_key=True, default=new_uuid)
user_id: Mapped[str] = mapped_column(UUID(as_uuid=False), ForeignKey("users.id", ondelete="CASCADE"), nullable=False, index=True)
type: Mapped[str] = mapped_column(String(10), nullable=False) # "daily" | "weekly"
period_date: Mapped[date] = mapped_column(Date, nullable=False)
content_markdown: Mapped[str] = mapped_column(Text, default="")
content_html: Mapped[str] = mapped_column(Text, default="")
email_sent: Mapped[bool] = mapped_column(Boolean, default=False)
generated_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now())
class AuditLog(Base):
__tablename__ = "audit_log"
id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
user_id: Mapped[str | None] = mapped_column(UUID(as_uuid=False), ForeignKey("users.id", ondelete="SET NULL"), nullable=True, index=True)
actor_email: Mapped[str] = mapped_column(String(255), default="")
action: Mapped[str] = mapped_column(String(100), nullable=False, index=True)
entity_type: Mapped[str] = mapped_column(String(50), default="")
entity_id: Mapped[str] = mapped_column(String(64), default="")
payload_json: Mapped[dict] = mapped_column(JSONB, default=dict)
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now(), index=True)

90
src/routers/budgets.py Normal file
View file

@ -0,0 +1,90 @@
"""Project budget CRUD + progress."""
from fastapi import APIRouter, Depends, HTTPException
from sqlalchemy.ext.asyncio import AsyncSession
from src.auth import CurrentUser
from src.database import get_db
from src.models import ProjectBudget
from src.schemas import BudgetIn, BudgetOut
from src.services.budgets import get_budgets_with_progress
router = APIRouter(prefix="/api/budgets", tags=["budgets"])
@router.get("", response_model=list[BudgetOut])
async def list_budgets(
user: CurrentUser,
db: AsyncSession = Depends(get_db),
):
data = await get_budgets_with_progress(user, db)
return [BudgetOut(**d) for d in data]
@router.post("", response_model=BudgetOut, status_code=201)
async def create_budget(
body: BudgetIn,
user: CurrentUser,
db: AsyncSession = Depends(get_db),
):
budget = ProjectBudget(
user_id=user.id,
project_id=body.project_id,
period=body.period,
target_hours=body.target_hours,
starts_on=body.starts_on,
)
db.add(budget)
await db.commit()
await db.refresh(budget)
return BudgetOut(
id=budget.id,
project_id=budget.project_id,
period=budget.period,
target_hours=budget.target_hours,
actual_hours=0.0,
progress_pct=0.0,
starts_on=budget.starts_on,
)
@router.patch("/{budget_id}", response_model=BudgetOut)
async def update_budget(
budget_id: str,
target_hours: float,
user: CurrentUser,
db: AsyncSession = Depends(get_db),
):
budget = await db.get(ProjectBudget, budget_id)
if not budget or budget.user_id != user.id:
raise HTTPException(status_code=404, detail="Budget not found")
budget.target_hours = target_hours
await db.commit()
# Return with progress recalculated
from src.services.budgets import get_budgets_with_progress
all_budgets = await get_budgets_with_progress(user, db)
for b in all_budgets:
if b["id"] == budget_id:
return BudgetOut(**b)
return BudgetOut(
id=budget.id,
project_id=budget.project_id,
period=budget.period,
target_hours=budget.target_hours,
actual_hours=0.0,
progress_pct=0.0,
starts_on=budget.starts_on,
)
@router.delete("/{budget_id}", status_code=204)
async def delete_budget(
budget_id: str,
user: CurrentUser,
db: AsyncSession = Depends(get_db),
):
budget = await db.get(ProjectBudget, budget_id)
if not budget or budget.user_id != user.id:
raise HTTPException(status_code=404, detail="Budget not found")
await db.delete(budget)
await db.commit()

152
src/routers/calendar.py Normal file
View file

@ -0,0 +1,152 @@
"""Calendar endpoint — unified session/planned/manual blocks."""
from datetime import date, timedelta
from typing import Literal
from fastapi import APIRouter, Depends, Query
from pydantic import BaseModel
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from src.auth import CurrentUser
from src.database import get_db
from src.models import ManualEntry, PlannedBlock, Project, Session, Task
router = APIRouter(prefix="/api/dashboard", tags=["calendar"])
def _hue_for_project(project_id: str | None) -> int:
"""Deterministic hue 0-359 from project UUID."""
if not project_id:
return 200
h = 2166136261
for ch in project_id:
h ^= ord(ch)
h = (h * 16777619) & 0xFFFFFFFF
return (h % 330 + 15) % 360
class TagBrief(BaseModel):
id: str
name: str
color_hex: str
class CalendarBlock(BaseModel):
kind: Literal["session", "planned", "manual"]
id: str
project_id: str | None = None
job_number: str = ""
display_name: str = ""
start_at: str # ISO datetime string
end_at: str
title: str = ""
color_hue: int
tags: list[TagBrief] = []
task_id: str | None = None
session_id: str | None = None
manual_entry_id: str | None = None
@router.get("/calendar", response_model=list[CalendarBlock])
async def get_calendar(
user: CurrentUser,
from_date: date = Query(alias="from"),
to_date: date = Query(alias="to"),
view: str = Query(default="week"),
db: AsyncSession = Depends(get_db),
):
# Clamp window
if view == "day":
to_date = from_date
else:
to_date = min(to_date, from_date + timedelta(days=6))
from_iso = from_date.isoformat()
to_iso = to_date.isoformat()
blocks: list[CalendarBlock] = []
# Sessions
sessions_result = await db.execute(
select(Session, Project.display_name, Project.job_number)
.join(Project, Session.project_id == Project.id)
.where(
Session.user_id == user.id,
Session.date >= from_date,
Session.date <= to_date,
)
.order_by(Session.start_at)
)
for s, display_name, job_number in sessions_result:
blocks.append(
CalendarBlock(
kind="session",
id=s.id,
project_id=s.project_id,
job_number=job_number or "",
display_name=display_name,
start_at=s.start_at.isoformat(),
end_at=s.end_at.isoformat(),
title=s.work_summary[:80] if s.work_summary else "",
color_hue=_hue_for_project(s.project_id),
session_id=s.id,
)
)
# Planned blocks
planned_result = await db.execute(
select(PlannedBlock, Project.display_name, Project.job_number, Task.title)
.outerjoin(Project, PlannedBlock.project_id == Project.id)
.outerjoin(Task, PlannedBlock.task_id == Task.id)
.where(
PlannedBlock.user_id == user.id,
PlannedBlock.start_at <= to_iso + "T23:59:59+00:00",
PlannedBlock.end_at >= from_iso + "T00:00:00+00:00",
)
.order_by(PlannedBlock.start_at)
)
for pb, display_name, job_number, task_title in planned_result:
blocks.append(
CalendarBlock(
kind="planned",
id=pb.id,
project_id=pb.project_id,
job_number=job_number or "",
display_name=display_name or "",
start_at=pb.start_at.isoformat(),
end_at=pb.end_at.isoformat(),
title=task_title or "",
color_hue=_hue_for_project(pb.project_id),
task_id=pb.task_id,
)
)
# Manual entries
manual_result = await db.execute(
select(ManualEntry, Project.display_name, Project.job_number)
.outerjoin(Project, ManualEntry.project_id == Project.id)
.where(
ManualEntry.user_id == user.id,
ManualEntry.start_at <= to_iso + "T23:59:59+00:00",
ManualEntry.end_at >= from_iso + "T00:00:00+00:00",
)
.order_by(ManualEntry.start_at)
)
for m, display_name, job_number in manual_result:
blocks.append(
CalendarBlock(
kind="manual",
id=m.id,
project_id=m.project_id,
job_number=job_number or "",
display_name=display_name or "",
start_at=m.start_at.isoformat(),
end_at=m.end_at.isoformat(),
title=m.title,
color_hue=_hue_for_project(m.project_id),
manual_entry_id=m.id,
)
)
blocks.sort(key=lambda b: b.start_at)
return blocks

110
src/routers/devops.py Normal file
View file

@ -0,0 +1,110 @@
"""Azure DevOps integration endpoints."""
from fastapi import APIRouter, Depends, HTTPException
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from src.auth import CurrentUser
from src.database import get_db
from src.models import AzureIntegration, AzureWorkItem
from src.schemas import AzureIntegrationIn, AzureIntegrationOut, AzureWorkItemOut, SyncReport
from src.services.crypto import encrypt
router = APIRouter(prefix="/api/devops", tags=["devops"])
_CLOSED_STATES = {"Closed", "Done", "Removed"}
@router.get("/integration", response_model=AzureIntegrationOut)
async def get_integration(
user: CurrentUser,
db: AsyncSession = Depends(get_db),
):
result = await db.execute(
select(AzureIntegration).where(AzureIntegration.user_id == user.id)
)
integ = result.scalar_one_or_none()
if not integ:
raise HTTPException(status_code=404, detail="No integration configured")
return AzureIntegrationOut.model_validate(integ)
@router.put("/integration", response_model=AzureIntegrationOut)
async def upsert_integration(
body: AzureIntegrationIn,
user: CurrentUser,
db: AsyncSession = Depends(get_db),
):
result = await db.execute(
select(AzureIntegration).where(AzureIntegration.user_id == user.id)
)
integ = result.scalar_one_or_none()
pat_encrypted = encrypt(body.pat)
pat_hint = body.pat[-4:] if len(body.pat) >= 4 else body.pat
if integ is None:
integ = AzureIntegration(
user_id=user.id,
organization=body.organization,
project=body.project,
pat_encrypted=pat_encrypted,
pat_hint=pat_hint,
)
db.add(integ)
else:
integ.organization = body.organization
integ.project = body.project
integ.pat_encrypted = pat_encrypted
integ.pat_hint = pat_hint
await db.commit()
await db.refresh(integ)
return AzureIntegrationOut.model_validate(integ)
@router.delete("/integration", status_code=204)
async def delete_integration(
user: CurrentUser,
db: AsyncSession = Depends(get_db),
):
result = await db.execute(
select(AzureIntegration).where(AzureIntegration.user_id == user.id)
)
integ = result.scalar_one_or_none()
if not integ:
raise HTTPException(status_code=404, detail="No integration configured")
await db.delete(integ)
await db.commit()
@router.post("/sync", response_model=SyncReport)
async def trigger_sync(
user: CurrentUser,
db: AsyncSession = Depends(get_db),
):
from src.services.azure_devops.sync import sync_user_work_items
count = await sync_user_work_items(user, db)
# Check for error from integration record
result = await db.execute(
select(AzureIntegration).where(AzureIntegration.user_id == user.id)
)
integ = result.scalar_one_or_none()
error = integ.last_sync_error if integ else ""
return SyncReport(synced=count, error=error)
@router.get("/work-items", response_model=list[AzureWorkItemOut])
async def list_work_items(
user: CurrentUser,
state: str = "open",
db: AsyncSession = Depends(get_db),
):
query = select(AzureWorkItem).where(AzureWorkItem.user_id == user.id)
if state == "open":
query = query.where(AzureWorkItem.state.notin_(list(_CLOSED_STATES)))
query = query.order_by(AzureWorkItem.synced_at.desc())
result = await db.execute(query)
return [AzureWorkItemOut.model_validate(wi) for wi in result.scalars()]

44
src/routers/exports.py Normal file
View file

@ -0,0 +1,44 @@
"""Timesheet export endpoints (CSV and ICS)."""
from datetime import date
from fastapi import APIRouter, Depends, Query
from fastapi.responses import Response, StreamingResponse
from sqlalchemy.ext.asyncio import AsyncSession
from src.auth import CurrentUser
from src.database import get_db
from src.services.exports import export_csv, export_ics
router = APIRouter(prefix="/api/export", tags=["exports"])
@router.get("/timesheet.csv")
async def download_csv(
user: CurrentUser,
from_date: date = Query(alias="from"),
to_date: date = Query(alias="to"),
db: AsyncSession = Depends(get_db),
):
csv_content = await export_csv(user, from_date, to_date, db)
filename = f"timesheet_{from_date}_{to_date}.csv"
return StreamingResponse(
iter([csv_content]),
media_type="text/csv",
headers={"Content-Disposition": f'attachment; filename="{filename}"'},
)
@router.get("/timesheet.ics")
async def download_ics(
user: CurrentUser,
from_date: date = Query(alias="from"),
to_date: date = Query(alias="to"),
db: AsyncSession = Depends(get_db),
):
ics_bytes = await export_ics(user, from_date, to_date, db)
filename = f"timesheet_{from_date}_{to_date}.ics"
return Response(
content=ics_bytes,
media_type="text/calendar",
headers={"Content-Disposition": f'attachment; filename="{filename}"'},
)

View file

@ -0,0 +1,125 @@
"""Manual time entries CRUD."""
from datetime import date, datetime, timezone
from fastapi import APIRouter, Depends, HTTPException, Query
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from src.auth import CurrentUser
from src.database import get_db
from src.models import ManualEntry, Project
from src.schemas import ManualEntryIn, ManualEntryOut
router = APIRouter(prefix="/api/manual-entries", tags=["manual-entries"])
async def _entry_out(m: ManualEntry, db: AsyncSession) -> ManualEntryOut:
project_name = ""
job_number = ""
if m.project_id:
proj = await db.get(Project, m.project_id)
if proj:
project_name = proj.display_name
job_number = proj.job_number or ""
duration_hours = round((m.end_at - m.start_at).total_seconds() / 3600, 2)
return ManualEntryOut(
id=m.id,
project_id=m.project_id,
task_id=m.task_id,
title=m.title,
notes=m.notes or "",
start_at=m.start_at,
end_at=m.end_at,
source=m.source,
duration_hours=duration_hours,
created_at=m.created_at,
project_name=project_name,
job_number=job_number,
)
@router.get("", response_model=list[ManualEntryOut])
async def list_entries(
user: CurrentUser,
from_date: date | None = Query(default=None, alias="from"),
to_date: date | None = Query(default=None, alias="to"),
db: AsyncSession = Depends(get_db),
):
query = select(ManualEntry).where(ManualEntry.user_id == user.id)
if from_date:
query = query.where(
ManualEntry.start_at
>= datetime.combine(from_date, datetime.min.time()).replace(
tzinfo=timezone.utc
)
)
if to_date:
query = query.where(
ManualEntry.start_at
<= datetime.combine(to_date, datetime.max.time()).replace(
tzinfo=timezone.utc
)
)
query = query.order_by(ManualEntry.start_at)
result = await db.execute(query)
entries = result.scalars().all()
return [await _entry_out(e, db) for e in entries]
@router.post("", response_model=ManualEntryOut, status_code=201)
async def create_entry(
body: ManualEntryIn,
user: CurrentUser,
db: AsyncSession = Depends(get_db),
):
entry = ManualEntry(
user_id=user.id,
project_id=body.project_id,
task_id=body.task_id,
start_at=body.start_at,
end_at=body.end_at,
title=body.title,
notes=body.notes,
source=body.source,
)
db.add(entry)
await db.commit()
await db.refresh(entry)
return await _entry_out(entry, db)
@router.patch("/{entry_id}", response_model=ManualEntryOut)
async def update_entry(
entry_id: str,
body: ManualEntryIn,
user: CurrentUser,
db: AsyncSession = Depends(get_db),
):
entry = await db.get(ManualEntry, entry_id)
if not entry or entry.user_id != user.id:
raise HTTPException(status_code=404, detail="Entry not found")
entry.project_id = body.project_id
entry.task_id = body.task_id
entry.start_at = body.start_at
entry.end_at = body.end_at
entry.title = body.title
entry.notes = body.notes
entry.source = body.source
await db.commit()
await db.refresh(entry)
return await _entry_out(entry, db)
@router.delete("/{entry_id}", status_code=204)
async def delete_entry(
entry_id: str,
user: CurrentUser,
db: AsyncSession = Depends(get_db),
):
entry = await db.get(ManualEntry, entry_id)
if not entry or entry.user_id != user.id:
raise HTTPException(status_code=404, detail="Entry not found")
await db.delete(entry)
await db.commit()

69
src/routers/reports.py Normal file
View file

@ -0,0 +1,69 @@
"""AI Reports endpoints."""
from datetime import date
from fastapi import APIRouter, Depends, HTTPException, Query
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from src.auth import CurrentUser
from src.database import get_db
from src.models import AiReport
from src.schemas import AiReportOut, GenerateReportIn
router = APIRouter(prefix="/api/reports", tags=["reports"])
@router.get("", response_model=list[AiReportOut])
async def list_reports(
user: CurrentUser,
report_type: str | None = Query(default=None, alias="type"),
from_date: date | None = Query(default=None, alias="from"),
to_date: date | None = Query(default=None, alias="to"),
db: AsyncSession = Depends(get_db),
):
query = select(AiReport).where(AiReport.user_id == user.id)
if report_type:
query = query.where(AiReport.type == report_type)
if from_date:
query = query.where(AiReport.period_date >= from_date)
if to_date:
query = query.where(AiReport.period_date <= to_date)
query = query.order_by(AiReport.generated_at.desc())
result = await db.execute(query)
return [AiReportOut.model_validate(r) for r in result.scalars()]
@router.post("/generate", response_model=AiReportOut, status_code=201)
async def generate_report(
body: GenerateReportIn,
user: CurrentUser,
db: AsyncSession = Depends(get_db),
):
from src.services.ai_reports import (
generate_and_send_daily_report,
generate_and_send_weekly_report,
)
if body.type == "daily":
report = await generate_and_send_daily_report(user, body.date, db)
else:
report = await generate_and_send_weekly_report(user, body.date, db)
if report is None:
raise HTTPException(
status_code=422,
detail="No data found for the given period — report not generated",
)
return AiReportOut.model_validate(report)
@router.get("/{report_id}", response_model=AiReportOut)
async def get_report(
report_id: str,
user: CurrentUser,
db: AsyncSession = Depends(get_db),
):
report = await db.get(AiReport, report_id)
if not report or report.user_id != user.id:
raise HTTPException(status_code=404, detail="Report not found")
return AiReportOut.model_validate(report)

113
src/routers/tags.py Normal file
View file

@ -0,0 +1,113 @@
"""Tags CRUD + task-tag assignment."""
from fastapi import APIRouter, Depends, HTTPException
from sqlalchemy import delete, select
from sqlalchemy.ext.asyncio import AsyncSession
from src.auth import CurrentUser
from src.database import get_db
from src.models import Tag, Task, task_tags
from src.schemas import TagIn, TagOut
router = APIRouter(tags=["tags"])
@router.get("/api/tags", response_model=list[TagOut])
async def list_tags(
user: CurrentUser,
db: AsyncSession = Depends(get_db),
):
result = await db.execute(
select(Tag).where(Tag.user_id == user.id).order_by(Tag.name)
)
return [TagOut.model_validate(t) for t in result.scalars()]
@router.post("/api/tags", response_model=TagOut, status_code=201)
async def create_tag(
body: TagIn,
user: CurrentUser,
db: AsyncSession = Depends(get_db),
):
tag = Tag(user_id=user.id, name=body.name, color_hex=body.color_hex)
db.add(tag)
await db.commit()
await db.refresh(tag)
return TagOut.model_validate(tag)
@router.patch("/api/tags/{tag_id}", response_model=TagOut)
async def update_tag(
tag_id: str,
body: TagIn,
user: CurrentUser,
db: AsyncSession = Depends(get_db),
):
tag = await db.get(Tag, tag_id)
if not tag or tag.user_id != user.id:
raise HTTPException(status_code=404, detail="Tag not found")
tag.name = body.name
tag.color_hex = body.color_hex
await db.commit()
await db.refresh(tag)
return TagOut.model_validate(tag)
@router.delete("/api/tags/{tag_id}", status_code=204)
async def delete_tag(
tag_id: str,
user: CurrentUser,
db: AsyncSession = Depends(get_db),
):
tag = await db.get(Tag, tag_id)
if not tag or tag.user_id != user.id:
raise HTTPException(status_code=404, detail="Tag not found")
# Cascade removes task_tags rows automatically (FK ondelete=CASCADE)
await db.delete(tag)
await db.commit()
@router.post("/api/tasks/{task_id}/tags/{tag_id}", status_code=204)
async def assign_tag(
task_id: str,
tag_id: str,
user: CurrentUser,
db: AsyncSession = Depends(get_db),
):
task = await db.get(Task, task_id)
if not task or task.user_id != user.id:
raise HTTPException(status_code=404, detail="Task not found")
tag = await db.get(Tag, tag_id)
if not tag or tag.user_id != user.id:
raise HTTPException(status_code=404, detail="Tag not found")
# Insert ignore duplicate
existing = await db.execute(
select(task_tags).where(
task_tags.c.task_id == task_id,
task_tags.c.tag_id == tag_id,
)
)
if not existing.first():
await db.execute(
task_tags.insert().values(task_id=task_id, tag_id=tag_id)
)
await db.commit()
@router.delete("/api/tasks/{task_id}/tags/{tag_id}", status_code=204)
async def remove_tag(
task_id: str,
tag_id: str,
user: CurrentUser,
db: AsyncSession = Depends(get_db),
):
task = await db.get(Task, task_id)
if not task or task.user_id != user.id:
raise HTTPException(status_code=404, detail="Task not found")
await db.execute(
delete(task_tags).where(
task_tags.c.task_id == task_id,
task_tags.c.tag_id == tag_id,
)
)
await db.commit()

206
src/routers/tasks.py Normal file
View file

@ -0,0 +1,206 @@
"""Tasks CRUD + planned blocks."""
from datetime import date, datetime, timezone
from fastapi import APIRouter, Depends, HTTPException
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from src.auth import CurrentUser
from src.database import get_db
from src.models import AzureWorkItem, PlannedBlock, Project, Task
from src.schemas import PlannedBlockIn, PlannedBlockOut, TaskIn, TaskOut, TaskUpdate
router = APIRouter(prefix="/api/tasks", tags=["tasks"])
async def _task_out(task: Task, db: AsyncSession) -> TaskOut:
"""Build TaskOut from ORM object, resolving join fields."""
project_name = ""
job_number = ""
work_item_title = ""
if task.project_id:
proj = await db.get(Project, task.project_id)
if proj:
project_name = proj.display_name
job_number = proj.job_number or ""
if task.azure_work_item_id:
wi = await db.get(AzureWorkItem, task.azure_work_item_id)
if wi:
work_item_title = wi.title
return TaskOut(
id=task.id,
user_id=task.user_id,
project_id=task.project_id,
azure_work_item_id=task.azure_work_item_id,
title=task.title,
notes=task.notes or "",
planned_date=task.planned_date,
estimate_hours=task.estimate_hours,
actual_hours=task.actual_hours,
status=task.status,
priority=task.priority,
sort_index=task.sort_index,
completed_at=task.completed_at,
ado_synced_at=task.ado_synced_at,
created_at=task.created_at,
project_name=project_name,
job_number=job_number,
work_item_title=work_item_title,
)
@router.get("", response_model=list[TaskOut])
async def list_tasks(
user: CurrentUser,
task_date: date | None = None,
db: AsyncSession = Depends(get_db),
):
query = select(Task).where(Task.user_id == user.id)
if task_date is not None:
query = query.where(Task.planned_date == task_date)
query = query.order_by(Task.planned_date, Task.sort_index, Task.created_at)
result = await db.execute(query)
tasks = result.scalars().all()
return [await _task_out(t, db) for t in tasks]
@router.post("", response_model=TaskOut, status_code=201)
async def create_task(
body: TaskIn,
user: CurrentUser,
db: AsyncSession = Depends(get_db),
):
task = Task(
user_id=user.id,
title=body.title,
notes=body.notes,
planned_date=body.planned_date,
estimate_hours=body.estimate_hours,
status=body.status,
priority=body.priority,
sort_index=body.sort_index,
project_id=body.project_id,
azure_work_item_id=body.azure_work_item_id,
)
db.add(task)
await db.commit()
await db.refresh(task)
return await _task_out(task, db)
@router.patch("/{task_id}", response_model=TaskOut)
async def update_task(
task_id: str,
body: TaskUpdate,
user: CurrentUser,
db: AsyncSession = Depends(get_db),
):
task = await db.get(Task, task_id)
if not task or task.user_id != user.id:
raise HTTPException(status_code=404, detail="Task not found")
updates = body.model_dump(exclude_unset=True)
for field, value in updates.items():
setattr(task, field, value)
await db.commit()
await db.refresh(task)
return await _task_out(task, db)
@router.delete("/{task_id}", status_code=204)
async def delete_task(
task_id: str,
user: CurrentUser,
db: AsyncSession = Depends(get_db),
):
task = await db.get(Task, task_id)
if not task or task.user_id != user.id:
raise HTTPException(status_code=404, detail="Task not found")
await db.delete(task)
await db.commit()
@router.post("/{task_id}/complete", response_model=TaskOut)
async def complete_task(
task_id: str,
user: CurrentUser,
db: AsyncSession = Depends(get_db),
):
task = await db.get(Task, task_id)
if not task or task.user_id != user.id:
raise HTTPException(status_code=404, detail="Task not found")
task.status = "done"
task.completed_at = datetime.now(timezone.utc)
await db.commit()
await db.refresh(task)
# Push to ADO if linked
if task.azure_work_item_id:
from src.services.azure_devops.push import push_completed_work
await push_completed_work(task, db)
await db.refresh(task)
return await _task_out(task, db)
# ── Planned blocks ────────────────────────────────────────────────────────────
@router.post("/{task_id}/blocks", response_model=PlannedBlockOut, status_code=201)
async def create_block(
task_id: str,
body: PlannedBlockIn,
user: CurrentUser,
db: AsyncSession = Depends(get_db),
):
task = await db.get(Task, task_id)
if not task or task.user_id != user.id:
raise HTTPException(status_code=404, detail="Task not found")
block = PlannedBlock(
user_id=user.id,
task_id=task_id,
project_id=task.project_id,
start_at=body.start_at,
end_at=body.end_at,
)
db.add(block)
await db.commit()
await db.refresh(block)
return PlannedBlockOut.model_validate(block)
@router.patch("/blocks/{block_id}", response_model=PlannedBlockOut)
async def update_block(
block_id: str,
body: PlannedBlockIn,
user: CurrentUser,
db: AsyncSession = Depends(get_db),
):
block = await db.get(PlannedBlock, block_id)
if not block or block.user_id != user.id:
raise HTTPException(status_code=404, detail="Block not found")
block.start_at = body.start_at
block.end_at = body.end_at
await db.commit()
await db.refresh(block)
return PlannedBlockOut.model_validate(block)
@router.delete("/blocks/{block_id}", status_code=204)
async def delete_block(
block_id: str,
user: CurrentUser,
db: AsyncSession = Depends(get_db),
):
block = await db.get(PlannedBlock, block_id)
if not block or block.user_id != user.id:
raise HTTPException(status_code=404, detail="Block not found")
await db.delete(block)
await db.commit()

View file

@ -199,3 +199,209 @@ class AdminStats(BaseModel):
total_sessions: int
total_hours: float
users: list[UserOut]
# ── Calendar ──────────────────────────────────────────────────────────────────
class CalendarBlockOut(BaseModel):
kind: str # "session" | "planned" | "manual"
id: str
project_id: str | None = None
job_number: str = ""
display_name: str = ""
start_at: datetime
end_at: datetime
title: str = ""
color_hue: int = 200
task_id: str | None = None
session_id: str | None = None
manual_entry_id: str | None = None
# ── Tasks ─────────────────────────────────────────────────────────────────────
class TaskIn(BaseModel):
title: str = Field(max_length=500)
notes: str = ""
planned_date: date
estimate_hours: float = Field(default=0.0, ge=0)
status: str = Field(default="todo", pattern="^(todo|doing|done|cancelled)$")
priority: int = Field(default=3, ge=1, le=5)
sort_index: int = 0
project_id: str | None = None
azure_work_item_id: str | None = None
class TaskUpdate(BaseModel):
title: str | None = Field(default=None, max_length=500)
notes: str | None = None
planned_date: date | None = None
estimate_hours: float | None = Field(default=None, ge=0)
status: str | None = Field(default=None, pattern="^(todo|doing|done|cancelled)$")
priority: int | None = Field(default=None, ge=1, le=5)
sort_index: int | None = None
project_id: str | None = None
azure_work_item_id: str | None = None
class TaskOut(BaseModel):
id: str
user_id: str
project_id: str | None
azure_work_item_id: str | None
title: str
notes: str
planned_date: date
estimate_hours: float
actual_hours: float
status: str
priority: int
sort_index: int
completed_at: datetime | None
ado_synced_at: datetime | None
created_at: datetime
project_name: str = ""
job_number: str = ""
work_item_title: str = ""
model_config = {"from_attributes": True}
class PlannedBlockIn(BaseModel):
start_at: datetime
end_at: datetime
class PlannedBlockOut(BaseModel):
id: str
task_id: str
project_id: str | None
start_at: datetime
end_at: datetime
created_at: datetime
model_config = {"from_attributes": True}
# ── Manual Entries ────────────────────────────────────────────────────────────
class ManualEntryIn(BaseModel):
title: str = Field(max_length=500)
notes: str = ""
start_at: datetime
end_at: datetime
project_id: str | None = None
task_id: str | None = None
source: str = "manual"
class ManualEntryOut(BaseModel):
id: str
project_id: str | None
task_id: str | None
title: str
notes: str
start_at: datetime
end_at: datetime
source: str
duration_hours: float = 0.0
created_at: datetime
project_name: str = ""
job_number: str = ""
model_config = {"from_attributes": True}
# ── Budgets ───────────────────────────────────────────────────────────────────
class BudgetIn(BaseModel):
project_id: str
period: str = Field(pattern="^(week|month)$")
target_hours: float = Field(gt=0)
starts_on: date
class BudgetOut(BaseModel):
id: str
project_id: str
period: str
target_hours: float
actual_hours: float = 0.0
progress_pct: float = 0.0
starts_on: date
model_config = {"from_attributes": True}
# ── Tags ──────────────────────────────────────────────────────────────────────
class TagIn(BaseModel):
name: str = Field(max_length=50)
color_hex: str = Field(default="#888888", pattern="^#[0-9a-fA-F]{6}$")
class TagOut(BaseModel):
id: str
name: str
color_hex: str
created_at: datetime
model_config = {"from_attributes": True}
# ── Azure DevOps ──────────────────────────────────────────────────────────────
class AzureIntegrationIn(BaseModel):
organization: str = Field(max_length=200)
project: str = Field(max_length=200)
pat: str
class AzureIntegrationOut(BaseModel):
id: str
organization: str
project: str
pat_hint: str
last_synced_at: datetime | None
last_sync_error: str
sync_enabled: bool
model_config = {"from_attributes": True}
class AzureWorkItemOut(BaseModel):
id: str
ado_id: int
title: str
type: str
state: str
iteration_path: str
area_path: str
url: str
synced_at: datetime
model_config = {"from_attributes": True}
class SyncReport(BaseModel):
synced: int
error: str = ""
# ── AI Reports ────────────────────────────────────────────────────────────────
class AiReportOut(BaseModel):
id: str
type: str
period_date: date
content_markdown: str
content_html: str
email_sent: bool
generated_at: datetime
model_config = {"from_attributes": True}
class GenerateReportIn(BaseModel):
type: str = Field(pattern="^(daily|weekly)$")
date: date

290
src/services/ai_reports.py Normal file
View file

@ -0,0 +1,290 @@
"""AI report generation + email delivery via Mailgun."""
import logging
from datetime import date, datetime, timedelta, timezone
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from src.config import settings
from src.models import AiReport, Project, Session, Task, User
from src.services.mailgun import send_email
log = logging.getLogger(__name__)
async def _gather_daily_context(user: User, report_date: date, db: AsyncSession) -> dict:
"""Gather data for a daily report."""
sessions_result = await db.execute(
select(Session, Project.display_name, Project.job_number)
.join(Project, Session.project_id == Project.id)
.where(Session.user_id == user.id, Session.date == report_date)
.order_by(Session.start_at)
)
rows = sessions_result.all()
tasks_result = await db.execute(
select(Task).where(Task.user_id == user.id, Task.planned_date == report_date)
)
tasks = tasks_result.scalars().all()
total_hours = sum(s.active_hours for s, _, _ in rows)
projects_worked: dict[str, float] = {}
for s, display_name, job_number in rows:
label = f"{job_number} {display_name}".strip() if job_number else display_name
projects_worked[label] = projects_worked.get(label, 0) + s.active_hours
return {
"date": report_date.isoformat(),
"total_hours": round(total_hours, 2),
"session_count": len(rows),
"projects": {k: round(v, 2) for k, v in projects_worked.items()},
"summaries": [s.work_summary for s, _, _ in rows if s.work_summary],
"tasks_total": len(tasks),
"tasks_done": sum(1 for t in tasks if t.status == "done"),
"tasks_todo": [t.title for t in tasks if t.status in ("todo", "doing")],
}
async def _gather_weekly_context(user: User, week_start: date, db: AsyncSession) -> dict:
"""Gather data for a weekly report (Mon-Sun)."""
week_end = week_start + timedelta(days=6)
sessions_result = await db.execute(
select(Session, Project.display_name, Project.job_number)
.join(Project, Session.project_id == Project.id)
.where(
Session.user_id == user.id,
Session.date >= week_start,
Session.date <= week_end,
)
)
rows = sessions_result.all()
projects_worked: dict[str, float] = {}
for s, display_name, job_number in rows:
label = f"{job_number} {display_name}".strip() if job_number else display_name
projects_worked[label] = projects_worked.get(label, 0) + s.active_hours
tasks_result = await db.execute(
select(Task).where(
Task.user_id == user.id,
Task.planned_date >= week_start,
Task.planned_date <= week_end,
)
)
tasks = tasks_result.scalars().all()
return {
"week_start": week_start.isoformat(),
"week_end": week_end.isoformat(),
"total_hours": round(sum(s.active_hours for s, _, _ in rows), 2),
"working_days": len({s.date for s, _, _ in rows}),
"session_count": len(rows),
"projects": {
k: round(v, 2)
for k, v in sorted(projects_worked.items(), key=lambda x: -x[1])
},
"tasks_done": sum(1 for t in tasks if t.status == "done"),
"tasks_total": len(tasks),
}
def _build_prompt(context: dict, report_type: str) -> str:
if report_type == "daily":
return f"""You are a productivity assistant. Generate a concise, motivating daily work report in Markdown.
Data for {context['date']}:
- Total coding hours: {context['total_hours']}h across {context['session_count']} sessions
- Projects worked on: {context['projects']}
- Tasks completed: {context['tasks_done']} / {context['tasks_total']}
- Pending tasks: {context['tasks_todo'][:5]}
- Session summaries: {context['summaries'][:5]}
Write a professional daily summary with:
1. Headline with date and total hours
2. Key achievements (from session summaries)
3. Projects breakdown
4. Task completion rate
5. One motivating closing remark
Keep it under 300 words. Use Markdown headers and bullet points."""
else:
return f"""You are a productivity assistant. Generate a concise weekly work summary in Markdown.
Week of {context['week_start']} to {context['week_end']}:
- Total hours: {context['total_hours']}h across {context['working_days']} working days
- Sessions: {context['session_count']}
- Tasks: {context['tasks_done']} completed out of {context['tasks_total']} planned
- Projects: {context['projects']}
Write a professional weekly summary with:
1. Headline with week range and total hours
2. Highlights / key achievements
3. Projects breakdown (hours per project)
4. Task completion rate
5. Brief retrospective and focus for next week
Keep it under 400 words. Use Markdown."""
async def _generate_content(context: dict, report_type: str) -> tuple[str, str]:
"""Returns (markdown, html). Falls back to plain stats if no Anthropic key."""
import markdown as md_lib
if settings.ANTHROPIC_API_KEY:
try:
import anthropic
client = anthropic.AsyncAnthropic(api_key=settings.ANTHROPIC_API_KEY)
message = await client.messages.create(
model="claude-sonnet-4-6",
max_tokens=1024,
messages=[{"role": "user", "content": _build_prompt(context, report_type)}],
)
markdown_content = message.content[0].text
except Exception as exc:
log.warning("ai_report.generation_failed", extra={"error": str(exc)})
markdown_content = _fallback_markdown(context, report_type)
else:
markdown_content = _fallback_markdown(context, report_type)
html_content = md_lib.markdown(markdown_content, extensions=["extra"])
return markdown_content, html_content
def _fallback_markdown(context: dict, report_type: str) -> str:
if report_type == "daily":
lines = [
f"# Daily Report — {context['date']}",
f"**Total:** {context['total_hours']}h across {context['session_count']} sessions",
"",
"## Projects",
]
for proj, h in context["projects"].items():
lines.append(f"- **{proj}**: {h}h")
lines += ["", f"**Tasks:** {context['tasks_done']} / {context['tasks_total']} completed"]
return "\n".join(lines)
else:
lines = [
f"# Weekly Report — {context['week_start']} to {context['week_end']}",
f"**Total:** {context['total_hours']}h across {context['working_days']} days",
"",
"## Projects",
]
for proj, h in context["projects"].items():
lines.append(f"- **{proj}**: {h}h")
lines += ["", f"**Tasks:** {context['tasks_done']} / {context['tasks_total']} completed"]
return "\n".join(lines)
def _html_wrapper(body_html: str, title: str) -> str:
return f"""<!DOCTYPE html>
<html><head><meta charset="utf-8">
<style>
body{{font-family:system-ui,sans-serif;max-width:600px;margin:0 auto;padding:24px;color:#1a1a1a}}
h1{{color:#1a1a1a;border-bottom:2px solid #FFC407;padding-bottom:8px}}
h2{{color:#333}}
ul{{padding-left:20px}}
li{{margin:4px 0}}
strong{{color:#000}}
.footer{{margin-top:32px;padding-top:16px;border-top:1px solid #eee;font-size:12px;color:#666}}
</style>
</head><body>
{body_html}
<div class="footer">Generated by <strong>CC Dashboard</strong></div>
</body></html>"""
async def generate_and_send_daily_report(
user: User, report_date: date, db: AsyncSession
) -> AiReport | None:
"""Generate daily report, save to DB, and send via email."""
try:
context = await _gather_daily_context(user, report_date, db)
if context["session_count"] == 0:
return None # Skip empty days
markdown_content, html_content = await _generate_content(context, "daily")
full_html = _html_wrapper(html_content, f"Daily Report {report_date}")
report = AiReport(
user_id=user.id,
type="daily",
period_date=report_date,
content_markdown=markdown_content,
content_html=full_html,
)
db.add(report)
await db.flush()
if settings.REPORT_EMAIL:
subject = f"CC Dashboard — Daily Report {report_date}"
sent = await send_email(settings.REPORT_EMAIL, subject, full_html, markdown_content)
report.email_sent = sent
await db.commit()
return report
except Exception as exc:
await db.rollback()
log.error(
"daily_report.failed",
extra={"user_id": user.id, "date": str(report_date), "error": str(exc)},
)
return None
async def generate_and_send_weekly_report(
user: User, week_start: date, db: AsyncSession
) -> AiReport | None:
"""Generate weekly report for Mon-Sun week, save to DB, and send via email."""
# Ensure week_start is a Monday
days_since_monday = week_start.weekday()
monday = week_start - timedelta(days=days_since_monday)
try:
context = await _gather_weekly_context(user, monday, db)
if context["session_count"] == 0:
return None
markdown_content, html_content = await _generate_content(context, "weekly")
week_end = monday + timedelta(days=6)
full_html = _html_wrapper(html_content, f"Weekly Report {monday} {week_end}")
report = AiReport(
user_id=user.id,
type="weekly",
period_date=monday,
content_markdown=markdown_content,
content_html=full_html,
)
db.add(report)
await db.flush()
if settings.REPORT_EMAIL:
subject = f"CC Dashboard — Weekly Report {monday} {week_end}"
sent = await send_email(settings.REPORT_EMAIL, subject, full_html, markdown_content)
report.email_sent = sent
await db.commit()
return report
except Exception as exc:
await db.rollback()
log.error(
"weekly_report.failed",
extra={"user_id": user.id, "week": str(monday), "error": str(exc)},
)
return None
async def run_daily_reports_all_users(db: AsyncSession) -> None:
"""Called by scheduler at DAILY_REPORT_HOUR."""
today = datetime.now(timezone.utc).date()
result = await db.execute(select(User).where(User.is_active == True)) # noqa: E712
for user in result.scalars():
await generate_and_send_daily_report(user, today, db)
async def run_weekly_reports_all_users(db: AsyncSession) -> None:
"""Called by scheduler on WEEKLY_REPORT_DAY."""
today = datetime.now(timezone.utc).date()
monday = today - timedelta(days=today.weekday())
result = await db.execute(select(User).where(User.is_active == True)) # noqa: E712
for user in result.scalars():
await generate_and_send_weekly_report(user, monday, db)

24
src/services/audit.py Normal file
View file

@ -0,0 +1,24 @@
from sqlalchemy.ext.asyncio import AsyncSession
from src.models import AuditLog, User
async def log_action(
db: AsyncSession,
user: User,
action: str,
entity_type: str = "",
entity_id: str = "",
payload: dict | None = None,
) -> None:
entry = AuditLog(
user_id=user.id,
actor_email=user.email,
action=action,
entity_type=entity_type,
entity_id=entity_id,
payload_json=payload or {},
)
db.add(entry)
# Flush but don't commit — caller manages transaction
await db.flush()

View file

View file

@ -0,0 +1,179 @@
"""Azure DevOps HTTP client."""
import base64
import logging
from typing import Any, Optional
import httpx
from src.config import settings as _settings
logger = logging.getLogger(__name__)
ADO_BASE = "https://dev.azure.com"
ADO_VSRM_BASE = "https://vsrm.dev.azure.com"
class ADOClient:
def __init__(
self,
org: str | None = None,
project: str | None = None,
pat: str | None = None,
):
self.org = org or _settings.ADO_ORGANIZATION
self.project = project or _settings.ADO_PROJECT
pat_value = pat or _settings.ADO_PAT
self.base = f"{ADO_BASE}/{self.org}"
token = base64.b64encode(f":{pat_value}".encode()).decode()
self.headers = {
"Authorization": f"Basic {token}",
"Content-Type": "application/json",
}
async def get_work_item(self, work_item_id: int | str) -> dict:
url = f"{self.base}/{self.project}/_apis/wit/workitems/{work_item_id}?$expand=all&api-version=7.1"
async with httpx.AsyncClient() as client:
resp = await client.get(url, headers=self.headers)
resp.raise_for_status()
return resp.json()
async def get_work_items_batch(
self, ids: list[int], fields: Optional[list[str]] = None
) -> list[dict]:
if not ids:
return []
url = f"{self.base}/{self.project}/_apis/wit/workitemsbatch?api-version=7.1"
body: dict[str, Any] = {"ids": ids, "$expand": "all"}
if fields:
body["fields"] = fields
async with httpx.AsyncClient() as client:
resp = await client.post(url, json=body, headers=self.headers)
resp.raise_for_status()
return resp.json().get("value", [])
async def query_work_items(
self, wiql: str, project: Optional[str] = None
) -> list[dict]:
proj = project or self.project
url = f"{self.base}/{proj}/_apis/wit/wiql?api-version=7.1"
async with httpx.AsyncClient() as client:
resp = await client.post(
url, json={"query": wiql}, headers=self.headers
)
resp.raise_for_status()
return resp.json().get("workItems", [])
async def create_work_item(
self, work_item_type: str, fields: list[dict], project: Optional[str] = None
) -> dict:
proj = project or self.project
url = f"{self.base}/{proj}/_apis/wit/workitems/${work_item_type}?api-version=7.1"
headers = {**self.headers, "Content-Type": "application/json-patch+json"}
async with httpx.AsyncClient() as client:
resp = await client.post(url, json=fields, headers=headers)
resp.raise_for_status()
return resp.json()
async def update_work_item(
self, work_item_id: int | str, patch: list[dict]
) -> dict:
url = f"{self.base}/{self.project}/_apis/wit/workitems/{work_item_id}?api-version=7.1"
headers = {**self.headers, "Content-Type": "application/json-patch+json"}
async with httpx.AsyncClient() as client:
resp = await client.patch(url, json=patch, headers=headers)
resp.raise_for_status()
return resp.json()
async def get_work_item_comments(self, work_item_id: int | str) -> list[dict]:
url = f"{self.base}/{self.project}/_apis/wit/workitems/{work_item_id}/comments?api-version=7.1-preview.3"
async with httpx.AsyncClient() as client:
resp = await client.get(url, headers=self.headers)
resp.raise_for_status()
return resp.json().get("comments", [])
async def add_comment(self, work_item_id: int | str, text: str) -> dict:
url = f"{self.base}/{self.project}/_apis/wit/workitems/{work_item_id}/comments?api-version=7.1-preview.3"
async with httpx.AsyncClient() as client:
resp = await client.post(
url, json={"text": text}, headers=self.headers
)
resp.raise_for_status()
return resp.json()
async def get_attachments(self, work_item_id: int | str) -> list[dict]:
item = await self.get_work_item(work_item_id)
relations = item.get("relations") or []
return [r for r in relations if r.get("rel") == "AttachedFile"]
async def upload_attachment(
self, filename: str, content: bytes, project: Optional[str] = None
) -> dict:
proj = project or self.project
url = f"{self.base}/{proj}/_apis/wit/attachments?fileName={filename}&api-version=7.1"
upload_headers = {
"Authorization": self.headers["Authorization"],
"Content-Type": "application/octet-stream",
}
async with httpx.AsyncClient() as client:
resp = await client.post(url, content=content, headers=upload_headers)
resp.raise_for_status()
return resp.json()
async def add_attachment_link(
self, work_item_id: int | str, attachment_url: str, filename: str
) -> dict:
patch = [
{
"op": "add",
"path": "/relations/-",
"value": {
"rel": "AttachedFile",
"url": attachment_url,
"attributes": {"comment": filename},
},
}
]
return await self.update_work_item(work_item_id, patch)
async def get_projects(self) -> list[dict]:
url = f"{self.base}/_apis/projects?api-version=7.1"
async with httpx.AsyncClient() as client:
resp = await client.get(url, headers=self.headers)
resp.raise_for_status()
return resp.json().get("value", [])
async def create_service_hook(
self, project_id: str, event_type: str, webhook_url: str
) -> dict:
url = f"{self.base}/_apis/hooks/subscriptions?api-version=7.1"
body = {
"publisherId": "tfs",
"eventType": event_type,
"resourceVersion": "1.0",
"consumerId": "webHooks",
"consumerActionId": "httpRequest",
"publisherInputs": {"projectId": project_id},
"consumerInputs": {
"url": webhook_url,
"resourceDetailsToSend": "all",
"messagesToSend": "none",
"detailedMessagesToSend": "none",
},
}
async with httpx.AsyncClient() as client:
resp = await client.post(url, json=body, headers=self.headers)
resp.raise_for_status()
return resp.json()
async def list_service_hooks(self) -> list[dict]:
url = f"{self.base}/_apis/hooks/subscriptions?api-version=7.1"
async with httpx.AsyncClient() as client:
resp = await client.get(url, headers=self.headers)
resp.raise_for_status()
return resp.json().get("value", [])
async def delete_service_hook(self, subscription_id: str) -> None:
url = f"{self.base}/_apis/hooks/subscriptions/{subscription_id}?api-version=7.1"
async with httpx.AsyncClient() as client:
resp = await client.delete(url, headers=self.headers)
resp.raise_for_status()

View file

@ -0,0 +1,87 @@
"""Push completed work back to Azure DevOps."""
from datetime import datetime, timezone
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from src.models import AzureIntegration, AzureWorkItem, ManualEntry, Session, Task
from src.services.crypto import decrypt
from src.services.azure_devops.client import ADOClient
async def push_completed_work(task: Task, db: AsyncSession) -> bool:
"""Post CompletedWork + comment to linked ADO work item. Idempotent."""
if not task.azure_work_item_id:
return False
wi_row = await db.get(AzureWorkItem, task.azure_work_item_id)
if not wi_row:
return False
integ_result = await db.execute(
select(AzureIntegration).where(AzureIntegration.user_id == task.user_id)
)
integ = integ_result.scalar_one_or_none()
if not integ:
return False
# Calculate actual hours from sessions + manual entries linked to this task
sessions_result = await db.execute(
select(Session).where(Session.task_id == task.id)
)
sessions = sessions_result.scalars().all()
manual_result = await db.execute(
select(ManualEntry).where(ManualEntry.task_id == task.id)
)
manuals = manual_result.scalars().all()
actual_h = sum(
(s.end_at - s.start_at).total_seconds() / 3600 for s in sessions
)
actual_h += sum(
(m.end_at - m.start_at).total_seconds() / 3600 for m in manuals
)
actual_h = round(actual_h, 2)
try:
pat = decrypt(integ.pat_encrypted)
client = ADOClient(org=integ.organization, project=integ.project, pat=pat)
fields_now = wi_row.fields_json or {}
current_completed = float(
fields_now.get("Microsoft.VSTS.Scheduling.CompletedWork", 0) or 0
)
current_remaining = float(
fields_now.get("Microsoft.VSTS.Scheduling.RemainingWork", 0) or 0
)
patch = [
{
"op": "add",
"path": "/fields/Microsoft.VSTS.Scheduling.CompletedWork",
"value": round(current_completed + actual_h, 2),
},
{
"op": "add",
"path": "/fields/Microsoft.VSTS.Scheduling.RemainingWork",
"value": max(0.0, round(current_remaining - actual_h, 2)),
},
]
await client.update_work_item(wi_row.ado_id, patch)
session_lines = [
f"- {s.work_summary or s.session_id} ({round((s.end_at - s.start_at).total_seconds() / 3600, 2)}h)"
for s in sessions
]
comment = (
f"✅ Task completed via CC Dashboard.\n\n"
f"Total time: **{actual_h}h**\n\n" + "\n".join(session_lines)
)
await client.add_comment(wi_row.ado_id, comment)
task.ado_synced_at = datetime.now(timezone.utc)
await db.commit()
return True
except Exception:
await db.rollback()
return False

View file

@ -0,0 +1,118 @@
"""Pull assigned work items from Azure DevOps into local cache."""
import logging
from datetime import datetime, timezone
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from src.models import AzureIntegration, AzureWorkItem, User
from src.services.crypto import decrypt
from src.services.azure_devops.client import ADOClient
log = logging.getLogger(__name__)
WIQL = """
SELECT [System.Id]
FROM WorkItems
WHERE [System.AssignedTo] = @Me
AND [System.State] NOT IN ('Done', 'Closed', 'Removed')
ORDER BY [System.ChangedDate] DESC
"""
FIELDS = [
"System.Id",
"System.Title",
"System.WorkItemType",
"System.State",
"System.AssignedTo",
"System.IterationPath",
"System.AreaPath",
"System.ChangedDate",
"System.TeamProject",
]
async def sync_user_work_items(user: User, db: AsyncSession) -> int:
"""Sync ADO work items for a single user. Returns count of upserted items."""
result = await db.execute(
select(AzureIntegration).where(
AzureIntegration.user_id == user.id,
AzureIntegration.sync_enabled == True, # noqa: E712
)
)
integ = result.scalar_one_or_none()
if not integ:
return 0
try:
pat = decrypt(integ.pat_encrypted)
client = ADOClient(org=integ.organization, project=integ.project, pat=pat)
refs = await client.query_work_items(WIQL)
ids = [r["id"] for r in refs]
items_data: list[dict] = []
for chunk in [ids[i : i + 200] for i in range(0, len(ids), 200)]:
batch = await client.get_work_items_batch(chunk, fields=FIELDS)
items_data.extend(batch)
seen_ado_ids: set[int] = set()
for item in items_data:
f = item.get("fields", {})
ado_id = item["id"]
seen_ado_ids.add(ado_id)
assigned_raw = f.get("System.AssignedTo", {})
assigned_email = (
assigned_raw.get("uniqueName", "")
if isinstance(assigned_raw, dict)
else str(assigned_raw)
)
existing = await db.execute(
select(AzureWorkItem).where(
AzureWorkItem.user_id == user.id,
AzureWorkItem.ado_id == ado_id,
)
)
wi = existing.scalar_one_or_none()
if wi is None:
wi = AzureWorkItem(user_id=user.id, ado_id=ado_id)
db.add(wi)
wi.title = f.get("System.Title", "")
wi.type = f.get("System.WorkItemType", "")
wi.state = f.get("System.State", "")
wi.assigned_to_email = assigned_email
wi.iteration_path = f.get("System.IterationPath", "")
wi.area_path = f.get("System.AreaPath", "")
wi.url = f"https://dev.azure.com/{integ.organization}/{integ.project}/_workitems/edit/{ado_id}"
wi.fields_json = f
wi.synced_at = datetime.now(timezone.utc)
# Soft-archive items no longer in ADO result set
stale = await db.execute(
select(AzureWorkItem).where(
AzureWorkItem.user_id == user.id,
AzureWorkItem.state.notin_(["Closed", "Done", "Removed"]),
)
)
for wi in stale.scalars():
if wi.ado_id not in seen_ado_ids:
wi.state = "Closed"
integ.last_synced_at = datetime.now(timezone.utc)
integ.last_sync_error = ""
await db.commit()
return len(items_data)
except Exception as exc:
await db.rollback()
integ.last_sync_error = str(exc)[:500]
await db.commit()
log.error("ado.sync.failed", extra={"user_id": user.id, "error": str(exc)})
return 0
async def sync_all_users(db: AsyncSession) -> None:
"""Called by scheduler — syncs all users with active integrations."""
result = await db.execute(select(User).where(User.is_active == True)) # noqa: E712
for user in result.scalars():
await sync_user_work_items(user, db)

55
src/services/budgets.py Normal file
View file

@ -0,0 +1,55 @@
"""Budget progress calculations."""
import calendar
from datetime import date, timedelta
from sqlalchemy import func, select
from sqlalchemy.ext.asyncio import AsyncSession
from src.models import DailyStat, ProjectBudget, User
async def get_budgets_with_progress(user: User, db: AsyncSession) -> list[dict]:
result = await db.execute(
select(ProjectBudget)
.where(ProjectBudget.user_id == user.id)
.order_by(ProjectBudget.period, ProjectBudget.starts_on.desc())
)
budgets = result.scalars().all()
output = []
today = date.today()
for b in budgets:
if b.period == "week":
days_since_monday = today.weekday()
period_start = today - timedelta(days=days_since_monday)
period_end = period_start + timedelta(days=6)
else:
period_start = today.replace(day=1)
last_day = calendar.monthrange(today.year, today.month)[1]
period_end = today.replace(day=last_day)
hours_result = await db.execute(
select(func.sum(DailyStat.total_hours)).where(
DailyStat.user_id == user.id,
DailyStat.project_id == b.project_id,
DailyStat.date >= period_start,
DailyStat.date <= period_end,
)
)
actual = float(hours_result.scalar() or 0)
output.append(
{
"id": b.id,
"project_id": b.project_id,
"period": b.period,
"target_hours": b.target_hours,
"actual_hours": round(actual, 2),
"progress_pct": (
round(min(100, actual / b.target_hours * 100), 1)
if b.target_hours > 0
else 0
),
"starts_on": b.starts_on.isoformat(),
}
)
return output

19
src/services/crypto.py Normal file
View file

@ -0,0 +1,19 @@
import base64
import hashlib
from cryptography.fernet import Fernet
from src.config import settings
def _fernet() -> Fernet:
key = base64.urlsafe_b64encode(hashlib.sha256(settings.SECRET_KEY.encode()).digest())
return Fernet(key)
def encrypt(value: str) -> bytes:
return _fernet().encrypt(value.encode())
def decrypt(blob: bytes) -> str:
return _fernet().decrypt(blob).decode()

117
src/services/exports.py Normal file
View file

@ -0,0 +1,117 @@
"""Timesheet export (CSV and ICS)."""
import csv
import io
from datetime import date, datetime, timezone
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from src.models import ManualEntry, Project, Session, User
async def export_csv(
user: User, from_date: date, to_date: date, db: AsyncSession
) -> str:
"""Returns CSV string with all sessions and manual entries in the date range."""
rows: list[dict] = []
# Sessions
sessions_result = await db.execute(
select(Session, Project.display_name, Project.job_number, Project.client)
.join(Project, Session.project_id == Project.id)
.where(
Session.user_id == user.id,
Session.date >= from_date,
Session.date <= to_date,
)
.order_by(Session.start_at)
)
for s, display_name, job_number, client in sessions_result:
rows.append(
{
"date": s.date.isoformat(),
"start": s.start_at.strftime("%H:%M"),
"end": s.end_at.strftime("%H:%M"),
"hours": round(s.active_hours, 2),
"project": display_name,
"omg_job": job_number,
"client": client,
"description": s.work_summary or "Claude Code session",
"type": "session",
}
)
# Manual entries
manual_result = await db.execute(
select(ManualEntry, Project.display_name, Project.job_number, Project.client)
.outerjoin(Project, ManualEntry.project_id == Project.id)
.where(
ManualEntry.user_id == user.id,
ManualEntry.start_at
>= datetime.combine(from_date, datetime.min.time()).replace(
tzinfo=timezone.utc
),
ManualEntry.start_at
<= datetime.combine(to_date, datetime.max.time()).replace(
tzinfo=timezone.utc
),
)
.order_by(ManualEntry.start_at)
)
for m, display_name, job_number, client in manual_result:
hours = round((m.end_at - m.start_at).total_seconds() / 3600, 2)
rows.append(
{
"date": m.start_at.date().isoformat(),
"start": m.start_at.strftime("%H:%M"),
"end": m.end_at.strftime("%H:%M"),
"hours": hours,
"project": display_name or "",
"omg_job": job_number or "",
"client": client or "",
"description": m.title,
"type": "manual",
}
)
rows.sort(key=lambda r: (r["date"], r["start"]))
output = io.StringIO()
if rows:
writer = csv.DictWriter(output, fieldnames=list(rows[0].keys()))
writer.writeheader()
writer.writerows(rows)
return output.getvalue()
async def export_ics(
user: User, from_date: date, to_date: date, db: AsyncSession
) -> bytes:
"""Returns ICS bytes for all sessions in the date range."""
from icalendar import Calendar, Event
cal = Calendar()
cal.add("prodid", "-//CC Dashboard//EN")
cal.add("version", "2.0")
cal.add("x-wr-calname", "CC Dashboard")
sessions_result = await db.execute(
select(Session, Project.display_name, Project.job_number)
.join(Project, Session.project_id == Project.id)
.where(
Session.user_id == user.id,
Session.date >= from_date,
Session.date <= to_date,
)
)
for s, display_name, job_number in sessions_result:
event = Event()
label = f"{job_number}{display_name}" if job_number else display_name
event.add("summary", label)
event.add("dtstart", s.start_at)
event.add("dtend", s.end_at)
event.add("description", s.work_summary or "")
event.add("uid", f"{s.id}@cc-dashboard")
cal.add_component(event)
return cal.to_ical()

26
src/services/mailgun.py Normal file
View file

@ -0,0 +1,26 @@
import httpx
from src.config import settings
async def send_email(to: str, subject: str, html: str, text: str = "") -> bool:
"""Send email via Mailgun. Returns True on success."""
if not settings.MAILGUN_API_KEY or not settings.MAILGUN_DOMAIN:
return False
url = f"https://api.mailgun.net/v3/{settings.MAILGUN_DOMAIN}/messages"
data: dict = {
"from": settings.MAILGUN_FROM,
"to": to,
"subject": subject,
"html": html,
}
if text:
data["text"] = text
async with httpx.AsyncClient() as client:
resp = await client.post(
url,
auth=("api", settings.MAILGUN_API_KEY),
data=data,
timeout=30,
)
return resp.status_code == 200

66
src/services/scheduler.py Normal file
View file

@ -0,0 +1,66 @@
"""APScheduler embedded in FastAPI lifespan."""
import logging
from apscheduler.schedulers.asyncio import AsyncIOScheduler
from apscheduler.triggers.cron import CronTrigger
from src.config import settings
log = logging.getLogger(__name__)
scheduler = AsyncIOScheduler()
async def _ado_sync_job() -> None:
from src.database import AsyncSessionLocal
from src.services.azure_devops.sync import sync_all_users
async with AsyncSessionLocal() as db:
await sync_all_users(db)
log.info("ado.sync.completed")
async def _daily_report_job() -> None:
from src.database import AsyncSessionLocal
from src.services.ai_reports import run_daily_reports_all_users
async with AsyncSessionLocal() as db:
await run_daily_reports_all_users(db)
log.info("daily_reports.completed")
async def _weekly_report_job() -> None:
from src.database import AsyncSessionLocal
from src.services.ai_reports import run_weekly_reports_all_users
async with AsyncSessionLocal() as db:
await run_weekly_reports_all_users(db)
log.info("weekly_reports.completed")
def setup_scheduler() -> None:
if settings.ADO_PAT:
scheduler.add_job(
_ado_sync_job,
"interval",
minutes=settings.ADO_SYNC_INTERVAL_MINUTES,
id="ado_pull",
replace_existing=True,
)
scheduler.add_job(
_daily_report_job,
CronTrigger(hour=settings.DAILY_REPORT_HOUR, minute=0),
id="daily_report",
replace_existing=True,
)
scheduler.add_job(
_weekly_report_job,
CronTrigger(
day_of_week=settings.WEEKLY_REPORT_DAY,
hour=settings.WEEKLY_REPORT_HOUR,
minute=0,
),
id="weekly_report",
replace_existing=True,
)

0
src/tests/__init__.py Normal file
View file

119
src/tests/conftest.py Normal file
View file

@ -0,0 +1,119 @@
"""Shared pytest fixtures."""
import asyncio
from datetime import date, datetime, timezone
from typing import AsyncGenerator
import pytest
import pytest_asyncio
from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker, create_async_engine
from sqlalchemy.pool import StaticPool
# Use SQLite for fast in-memory tests (JSONB fields not used in these tests)
TEST_DATABASE_URL = "sqlite+aiosqlite:///:memory:"
@pytest.fixture(scope="session")
def event_loop():
"""Session-scoped event loop to avoid re-creation per test."""
loop = asyncio.new_event_loop()
yield loop
loop.close()
@pytest_asyncio.fixture(scope="session")
async def engine():
"""Create in-memory SQLite engine with all tables."""
# Override DATABASE_URL before importing models
import os
os.environ.setdefault("DATABASE_URL", TEST_DATABASE_URL)
from src.database import Base
# Import all models so their tables are registered in Base.metadata
import src.models # noqa: F401
_engine = create_async_engine(
TEST_DATABASE_URL,
connect_args={"check_same_thread": False},
poolclass=StaticPool,
)
async with _engine.begin() as conn:
await conn.run_sync(Base.metadata.create_all)
yield _engine
await _engine.dispose()
@pytest_asyncio.fixture
async def db(engine) -> AsyncGenerator[AsyncSession, None]:
"""Provide a transactional session that rolls back after each test."""
factory = async_sessionmaker(engine, expire_on_commit=False)
async with factory() as session:
yield session
await session.rollback()
# ── Shared test data factories ─────────────────────────────────────────────────
def make_user(**kwargs):
from src.models import User
defaults = dict(
email="test@example.com",
username="tester",
password_hash="hashed",
role="user",
is_active=True,
daily_overhead_hours=0.5,
)
defaults.update(kwargs)
return User(**defaults)
def make_project(user_id: str, **kwargs):
from src.models import Project
defaults = dict(
user_id=user_id,
slug="test-project",
display_name="Test Project",
job_number="JOB-001",
client="Acme",
)
defaults.update(kwargs)
return Project(**defaults)
def make_session(user_id: str, project_id: str, **kwargs):
from src.models import Session as DbSession
now = datetime.now(timezone.utc)
defaults = dict(
user_id=user_id,
project_id=project_id,
session_id="sess-abc123",
date=date.today(),
start_at=now.replace(hour=9, minute=0, second=0, microsecond=0),
end_at=now.replace(hour=11, minute=0, second=0, microsecond=0),
active_hours=2.0,
message_count=50,
work_summary="Implemented feature X",
commits=[],
tools_used={},
files_changed=[],
raw_stats={},
)
defaults.update(kwargs)
return DbSession(**defaults)
def make_task(user_id: str, **kwargs):
from src.models import Task
defaults = dict(
user_id=user_id,
title="Test Task",
notes="",
planned_date=date.today(),
estimate_hours=2.0,
status="todo",
priority=3,
sort_index=0,
)
defaults.update(kwargs)
return Task(**defaults)

150
src/tests/test_calendar.py Normal file
View file

@ -0,0 +1,150 @@
"""Smoke tests for calendar utilities and manual entry model."""
import pytest
from datetime import date, datetime, timezone, timedelta
from src.tests.conftest import make_user, make_project, make_session, make_task
def test_hue_for_project_deterministic():
"""_hue_for_project returns same value for same input."""
from src.routers.calendar import _hue_for_project
pid = "550e8400-e29b-41d4-a716-446655440000"
assert _hue_for_project(pid) == _hue_for_project(pid)
assert 0 <= _hue_for_project(pid) <= 360
def test_hue_for_project_none():
"""None project_id returns default hue 200."""
from src.routers.calendar import _hue_for_project
assert _hue_for_project(None) == 200
def test_hue_for_project_different_ids():
"""Different project IDs produce different hues (with high probability)."""
from src.routers.calendar import _hue_for_project
ids = [
"aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa",
"bbbbbbbb-bbbb-bbbb-bbbb-bbbbbbbbbbbb",
"cccccccc-cccc-cccc-cccc-cccccccccccc",
]
hues = [_hue_for_project(pid) for pid in ids]
# Not all the same
assert len(set(hues)) > 1
@pytest.mark.asyncio
async def test_manual_entry_create_and_duration(db):
"""ManualEntry can be created and duration computed correctly."""
user = make_user(email="cal1@example.com", username="cal1")
db.add(user)
await db.flush()
project = make_project(user_id=user.id)
db.add(project)
await db.flush()
from src.models import ManualEntry
start = datetime(2026, 5, 6, 9, 0, 0, tzinfo=timezone.utc)
end = datetime(2026, 5, 6, 11, 30, 0, tzinfo=timezone.utc)
entry = ManualEntry(
user_id=user.id,
project_id=project.id,
title="Design review",
notes="Review mockups",
start_at=start,
end_at=end,
source="manual",
)
db.add(entry)
await db.flush()
duration_h = (entry.end_at - entry.start_at).total_seconds() / 3600
assert duration_h == pytest.approx(2.5)
@pytest.mark.asyncio
async def test_planned_block_linked_to_task(db):
"""PlannedBlock correctly links to task and inherits project."""
user = make_user(email="cal2@example.com", username="cal2")
db.add(user)
await db.flush()
project = make_project(user_id=user.id, slug="proj2")
db.add(project)
await db.flush()
task = make_task(user_id=user.id, project_id=project.id, title="Plan sprint")
db.add(task)
await db.flush()
from src.models import PlannedBlock
start = datetime(2026, 5, 6, 14, 0, 0, tzinfo=timezone.utc)
end = datetime(2026, 5, 6, 16, 0, 0, tzinfo=timezone.utc)
block = PlannedBlock(
user_id=user.id,
task_id=task.id,
project_id=project.id,
start_at=start,
end_at=end,
)
db.add(block)
await db.flush()
assert block.task_id == task.id
assert block.project_id == project.id
assert (block.end_at - block.start_at).total_seconds() == 7200
@pytest.mark.asyncio
async def test_session_calendar_date_filter(db):
"""Sessions are correctly filtered by date range."""
user = make_user(email="cal3@example.com", username="cal3")
db.add(user)
await db.flush()
project = make_project(user_id=user.id, slug="proj3")
db.add(project)
await db.flush()
# Session on target date
s1 = make_session(
user_id=user.id,
project_id=project.id,
session_id="s1",
date=date(2026, 5, 6),
start_at=datetime(2026, 5, 6, 9, 0, tzinfo=timezone.utc),
end_at=datetime(2026, 5, 6, 11, 0, tzinfo=timezone.utc),
)
# Session outside range
s2 = make_session(
user_id=user.id,
project_id=project.id,
session_id="s2",
date=date(2026, 5, 10),
start_at=datetime(2026, 5, 10, 9, 0, tzinfo=timezone.utc),
end_at=datetime(2026, 5, 10, 11, 0, tzinfo=timezone.utc),
)
db.add(s1)
db.add(s2)
await db.flush()
from sqlalchemy import select
from src.models import Session as DbSession
result = await db.execute(
select(DbSession).where(
DbSession.user_id == user.id,
DbSession.date >= date(2026, 5, 6),
DbSession.date <= date(2026, 5, 7),
)
)
sessions = result.scalars().all()
assert len(sessions) == 1
assert sessions[0].session_id == "s1"

130
src/tests/test_tasks.py Normal file
View file

@ -0,0 +1,130 @@
"""Smoke tests for task model + CRUD helpers."""
import pytest
from datetime import date, datetime, timezone
from src.tests.conftest import make_user, make_project, make_task
@pytest.mark.asyncio
async def test_create_and_fetch_task(db):
"""Tasks can be created and retrieved by user_id."""
user = make_user()
db.add(user)
await db.flush()
task = make_task(user_id=user.id, title="Write tests", status="todo")
db.add(task)
await db.flush()
from sqlalchemy import select
from src.models import Task
result = await db.execute(
select(Task).where(Task.user_id == user.id)
)
tasks = result.scalars().all()
assert len(tasks) == 1
assert tasks[0].title == "Write tests"
assert tasks[0].status == "todo"
@pytest.mark.asyncio
async def test_task_status_transition(db):
"""Task status can transition todo -> doing -> done."""
user = make_user(email="u2@example.com", username="u2")
db.add(user)
await db.flush()
task = make_task(user_id=user.id, title="Deploy feature", status="todo")
db.add(task)
await db.flush()
# Transition to doing
task.status = "doing"
await db.flush()
assert task.status == "doing"
# Transition to done
task.status = "done"
task.completed_at = datetime.now(timezone.utc)
await db.flush()
assert task.status == "done"
assert task.completed_at is not None
@pytest.mark.asyncio
async def test_task_with_project(db):
"""Task can be linked to a project."""
user = make_user(email="u3@example.com", username="u3")
db.add(user)
await db.flush()
project = make_project(user_id=user.id)
db.add(project)
await db.flush()
task = make_task(user_id=user.id, project_id=project.id, title="Project task")
db.add(task)
await db.flush()
assert task.project_id == project.id
@pytest.mark.asyncio
async def test_task_delete_cascades_planned_blocks(db):
"""Deleting a task cascades to its planned_blocks."""
user = make_user(email="u4@example.com", username="u4")
db.add(user)
await db.flush()
task = make_task(user_id=user.id)
db.add(task)
await db.flush()
from src.models import PlannedBlock
from datetime import timedelta
now = datetime.now(timezone.utc)
block = PlannedBlock(
user_id=user.id,
task_id=task.id,
start_at=now,
end_at=now + timedelta(hours=1),
)
db.add(block)
await db.flush()
await db.delete(task)
await db.flush()
from sqlalchemy import select
result = await db.execute(
select(PlannedBlock).where(PlannedBlock.task_id == task.id)
)
assert result.scalars().all() == []
@pytest.mark.asyncio
async def test_task_priority_range(db):
"""Task priority can be set from 15."""
user = make_user(email="u5@example.com", username="u5")
db.add(user)
await db.flush()
for priority in range(1, 6):
task = make_task(
user_id=user.id,
title=f"Priority {priority}",
priority=priority,
)
db.add(task)
await db.flush()
from sqlalchemy import select
from src.models import Task
result = await db.execute(
select(Task).where(Task.user_id == user.id).order_by(Task.priority)
)
tasks = result.scalars().all()
assert [t.priority for t in tasks] == [1, 2, 3, 4, 5]