diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..405ebf4 --- /dev/null +++ b/.github/workflows/ci.yml @@ -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 diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml new file mode 100644 index 0000000..4df2595 --- /dev/null +++ b/.pre-commit-config.yaml @@ -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 diff --git a/alembic/versions/0003_corporate.py b/alembic/versions/0003_corporate.py new file mode 100644 index 0000000..a5ce4d3 --- /dev/null +++ b/alembic/versions/0003_corporate.py @@ -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") diff --git a/pyproject.toml b/pyproject.toml new file mode 100644 index 0000000..c414efa --- /dev/null +++ b/pyproject.toml @@ -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 diff --git a/requirements.txt b/requirements.txt index 0337228..71a20b1 100644 --- a/requirements.txt +++ b/requirements.txt @@ -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 diff --git a/src/config.py b/src/config.py index f42cef6..407a26e 100644 --- a/src/config.py +++ b/src/config.py @@ -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 " + + # 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__( diff --git a/src/main.py b/src/main.py index f475a96..9c0189f 100644 --- a/src/main.py +++ b/src/main.py @@ -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") diff --git a/src/middleware/__init__.py b/src/middleware/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/src/middleware/logging.py b/src/middleware/logging.py new file mode 100644 index 0000000..5b6cc49 --- /dev/null +++ b/src/middleware/logging.py @@ -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") diff --git a/src/models.py b/src/models.py index 441b184..52fac28 100644 --- a/src/models.py +++ b/src/models.py @@ -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) diff --git a/src/routers/budgets.py b/src/routers/budgets.py new file mode 100644 index 0000000..e32c70e --- /dev/null +++ b/src/routers/budgets.py @@ -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() diff --git a/src/routers/calendar.py b/src/routers/calendar.py new file mode 100644 index 0000000..869dfc3 --- /dev/null +++ b/src/routers/calendar.py @@ -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 diff --git a/src/routers/devops.py b/src/routers/devops.py new file mode 100644 index 0000000..1d52426 --- /dev/null +++ b/src/routers/devops.py @@ -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()] diff --git a/src/routers/exports.py b/src/routers/exports.py new file mode 100644 index 0000000..f8ef4d3 --- /dev/null +++ b/src/routers/exports.py @@ -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}"'}, + ) diff --git a/src/routers/manual_entries.py b/src/routers/manual_entries.py new file mode 100644 index 0000000..44b62a0 --- /dev/null +++ b/src/routers/manual_entries.py @@ -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() diff --git a/src/routers/reports.py b/src/routers/reports.py new file mode 100644 index 0000000..1591544 --- /dev/null +++ b/src/routers/reports.py @@ -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) diff --git a/src/routers/tags.py b/src/routers/tags.py new file mode 100644 index 0000000..7d775be --- /dev/null +++ b/src/routers/tags.py @@ -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() diff --git a/src/routers/tasks.py b/src/routers/tasks.py new file mode 100644 index 0000000..61f9331 --- /dev/null +++ b/src/routers/tasks.py @@ -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() diff --git a/src/schemas.py b/src/schemas.py index d7ae52f..9ef7607 100644 --- a/src/schemas.py +++ b/src/schemas.py @@ -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 diff --git a/src/services/ai_reports.py b/src/services/ai_reports.py new file mode 100644 index 0000000..81794b7 --- /dev/null +++ b/src/services/ai_reports.py @@ -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""" + + + +{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) diff --git a/src/services/audit.py b/src/services/audit.py new file mode 100644 index 0000000..4619448 --- /dev/null +++ b/src/services/audit.py @@ -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() diff --git a/src/services/azure_devops/__init__.py b/src/services/azure_devops/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/src/services/azure_devops/client.py b/src/services/azure_devops/client.py new file mode 100644 index 0000000..d1dc761 --- /dev/null +++ b/src/services/azure_devops/client.py @@ -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() diff --git a/src/services/azure_devops/push.py b/src/services/azure_devops/push.py new file mode 100644 index 0000000..b037f4c --- /dev/null +++ b/src/services/azure_devops/push.py @@ -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 diff --git a/src/services/azure_devops/sync.py b/src/services/azure_devops/sync.py new file mode 100644 index 0000000..39b3323 --- /dev/null +++ b/src/services/azure_devops/sync.py @@ -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) diff --git a/src/services/budgets.py b/src/services/budgets.py new file mode 100644 index 0000000..a9843b9 --- /dev/null +++ b/src/services/budgets.py @@ -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 diff --git a/src/services/crypto.py b/src/services/crypto.py new file mode 100644 index 0000000..46667f1 --- /dev/null +++ b/src/services/crypto.py @@ -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() diff --git a/src/services/exports.py b/src/services/exports.py new file mode 100644 index 0000000..c775bab --- /dev/null +++ b/src/services/exports.py @@ -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() diff --git a/src/services/mailgun.py b/src/services/mailgun.py new file mode 100644 index 0000000..a84c57d --- /dev/null +++ b/src/services/mailgun.py @@ -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 diff --git a/src/services/scheduler.py b/src/services/scheduler.py new file mode 100644 index 0000000..41872e9 --- /dev/null +++ b/src/services/scheduler.py @@ -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, + ) diff --git a/src/tests/__init__.py b/src/tests/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/src/tests/conftest.py b/src/tests/conftest.py new file mode 100644 index 0000000..0d50a0c --- /dev/null +++ b/src/tests/conftest.py @@ -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) diff --git a/src/tests/test_calendar.py b/src/tests/test_calendar.py new file mode 100644 index 0000000..6a71b8c --- /dev/null +++ b/src/tests/test_calendar.py @@ -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" diff --git a/src/tests/test_tasks.py b/src/tests/test_tasks.py new file mode 100644 index 0000000..d3066f8 --- /dev/null +++ b/src/tests/test_tasks.py @@ -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 1–5.""" + 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]