feat: corporate planning hub backend — tasks, calendar, ADO, AI reports
- Alembic 0003_corporate: 10 new tables (tasks, planned_blocks, manual_entries, project_budgets, tags, task_tags, azure_integrations, azure_work_items, ai_reports, audit_log) + session.task_id FK - New routers: calendar, tasks, manual_entries, budgets, tags, devops, exports, reports - Services: crypto (Fernet PAT encryption), audit log, Mailgun email, APScheduler (ADO sync every 15 min, daily AI report at 20:00, weekly Sunday 21:00) - Azure DevOps two-way sync: pull assigned work items, push CompletedWork on task complete - AI reports: Anthropic API summaries or plain-stats fallback, sent via Mailgun - structlog JSON/console logging, LoggingMiddleware, updated main.py lifespan - pyproject.toml (ruff/mypy/pytest config), CI workflow, pre-commit hooks - Schemas: CalendarBlock, Task*, ManualEntry*, Budget*, Tag*, AzureIntegration*, AiReport*, SyncReport Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
parent
57655056e3
commit
9d9e8e82d4
34 changed files with 3072 additions and 12 deletions
44
.github/workflows/ci.yml
vendored
Normal file
44
.github/workflows/ci.yml
vendored
Normal file
|
|
@ -0,0 +1,44 @@
|
|||
name: CI
|
||||
on:
|
||||
push:
|
||||
branches: [main, feat/planning-hub]
|
||||
pull_request:
|
||||
branches: [main]
|
||||
|
||||
jobs:
|
||||
backend:
|
||||
runs-on: ubuntu-latest
|
||||
services:
|
||||
postgres:
|
||||
image: postgres:16-alpine
|
||||
env:
|
||||
POSTGRES_DB: cc_dashboard_test
|
||||
POSTGRES_USER: cc_app
|
||||
POSTGRES_PASSWORD: cc_pass
|
||||
ports: ["5433:5432"]
|
||||
options: --health-cmd pg_isready --health-interval 5s --health-timeout 3s --health-retries 10
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/setup-python@v5
|
||||
with:
|
||||
python-version: "3.11"
|
||||
- run: pip install -r requirements.txt pytest pytest-asyncio aiosqlite ruff
|
||||
- run: ruff check .
|
||||
- name: Run tests
|
||||
env:
|
||||
DATABASE_URL: postgresql+asyncpg://cc_app:cc_pass@localhost:5433/cc_dashboard_test
|
||||
SECRET_KEY: test-secret-key
|
||||
run: pytest -q
|
||||
|
||||
frontend:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: "20"
|
||||
- name: Build frontend if present
|
||||
run: |
|
||||
if [ -d web ]; then
|
||||
cd web && npm ci && npm run lint && npm run typecheck && npm run build
|
||||
fi
|
||||
13
.pre-commit-config.yaml
Normal file
13
.pre-commit-config.yaml
Normal file
|
|
@ -0,0 +1,13 @@
|
|||
repos:
|
||||
- repo: https://github.com/astral-sh/ruff-pre-commit
|
||||
rev: v0.8.0
|
||||
hooks:
|
||||
- id: ruff
|
||||
args: [--fix]
|
||||
- id: ruff-format
|
||||
- repo: https://github.com/pre-commit/pre-commit-hooks
|
||||
rev: v5.0.0
|
||||
hooks:
|
||||
- id: end-of-file-fixer
|
||||
- id: trailing-whitespace
|
||||
- id: check-yaml
|
||||
222
alembic/versions/0003_corporate.py
Normal file
222
alembic/versions/0003_corporate.py
Normal file
|
|
@ -0,0 +1,222 @@
|
|||
"""corporate planning hub tables
|
||||
|
||||
Revision ID: 0003
|
||||
Revises: 0002
|
||||
Create Date: 2026-05-06
|
||||
"""
|
||||
import sqlalchemy as sa
|
||||
from alembic import op
|
||||
from sqlalchemy.dialects import postgresql
|
||||
|
||||
revision = "0003"
|
||||
down_revision = "0002"
|
||||
branch_labels = None
|
||||
depends_on = None
|
||||
|
||||
|
||||
def upgrade():
|
||||
# azure_integrations must come before tasks (no dependency but logical order)
|
||||
op.create_table(
|
||||
"azure_integrations",
|
||||
sa.Column("id", postgresql.UUID(as_uuid=False), primary_key=True),
|
||||
sa.Column("user_id", postgresql.UUID(as_uuid=False), sa.ForeignKey("users.id", ondelete="CASCADE"), nullable=False),
|
||||
sa.Column("organization", sa.String(200), nullable=False),
|
||||
sa.Column("project", sa.String(200), nullable=False),
|
||||
sa.Column("pat_encrypted", sa.LargeBinary, nullable=False),
|
||||
sa.Column("pat_hint", sa.String(8), nullable=False, server_default=""),
|
||||
sa.Column("last_synced_at", sa.DateTime(timezone=True), nullable=True),
|
||||
sa.Column("last_sync_error", sa.Text, nullable=False, server_default=""),
|
||||
sa.Column("sync_enabled", sa.Boolean, nullable=False, server_default="true"),
|
||||
sa.Column("created_at", sa.DateTime(timezone=True), server_default=sa.func.now()),
|
||||
sa.UniqueConstraint("user_id", name="uq_azure_integrations_user"),
|
||||
)
|
||||
op.create_index("ix_azure_integrations_user_id", "azure_integrations", ["user_id"])
|
||||
|
||||
# azure_work_items must come before tasks (tasks.azure_work_item_id references it)
|
||||
op.create_table(
|
||||
"azure_work_items",
|
||||
sa.Column("id", postgresql.UUID(as_uuid=False), primary_key=True),
|
||||
sa.Column("user_id", postgresql.UUID(as_uuid=False), sa.ForeignKey("users.id", ondelete="CASCADE"), nullable=False),
|
||||
sa.Column("ado_id", sa.Integer, nullable=False),
|
||||
sa.Column("title", sa.String(500), nullable=False),
|
||||
sa.Column("type", sa.String(50), nullable=False, server_default=""),
|
||||
sa.Column("state", sa.String(50), nullable=False, server_default=""),
|
||||
sa.Column("assigned_to_email", sa.String(255), nullable=False, server_default=""),
|
||||
sa.Column("iteration_path", sa.String(500), nullable=False, server_default=""),
|
||||
sa.Column("area_path", sa.String(500), nullable=False, server_default=""),
|
||||
sa.Column("url", sa.String(1000), nullable=False, server_default=""),
|
||||
sa.Column("fields_json", postgresql.JSONB, nullable=False, server_default="{}"),
|
||||
sa.Column("synced_at", sa.DateTime(timezone=True), server_default=sa.func.now()),
|
||||
sa.UniqueConstraint("user_id", "ado_id", name="uq_ado_user_id"),
|
||||
)
|
||||
op.create_index("ix_azure_work_items_user_id", "azure_work_items", ["user_id"])
|
||||
op.create_index("ix_azure_work_items_user_state", "azure_work_items", ["user_id", "state"])
|
||||
|
||||
# tags must come before task_tags
|
||||
op.create_table(
|
||||
"tags",
|
||||
sa.Column("id", postgresql.UUID(as_uuid=False), primary_key=True),
|
||||
sa.Column("user_id", postgresql.UUID(as_uuid=False), sa.ForeignKey("users.id", ondelete="CASCADE"), nullable=False),
|
||||
sa.Column("name", sa.String(50), nullable=False),
|
||||
sa.Column("color_hex", sa.String(7), nullable=False, server_default="#888888"),
|
||||
sa.Column("created_at", sa.DateTime(timezone=True), server_default=sa.func.now()),
|
||||
sa.UniqueConstraint("user_id", "name", name="uq_tag_user_name"),
|
||||
)
|
||||
op.create_index("ix_tags_user_id", "tags", ["user_id"])
|
||||
|
||||
# tasks references azure_work_items
|
||||
op.create_table(
|
||||
"tasks",
|
||||
sa.Column("id", postgresql.UUID(as_uuid=False), primary_key=True),
|
||||
sa.Column("user_id", postgresql.UUID(as_uuid=False), sa.ForeignKey("users.id", ondelete="CASCADE"), nullable=False),
|
||||
sa.Column("project_id", postgresql.UUID(as_uuid=False), sa.ForeignKey("projects.id", ondelete="SET NULL"), nullable=True),
|
||||
sa.Column("azure_work_item_id", postgresql.UUID(as_uuid=False), sa.ForeignKey("azure_work_items.id", ondelete="SET NULL"), nullable=True),
|
||||
sa.Column("title", sa.String(500), nullable=False),
|
||||
sa.Column("notes", sa.Text, nullable=False, server_default=""),
|
||||
sa.Column("planned_date", sa.Date, nullable=False),
|
||||
sa.Column("estimate_hours", sa.Float, nullable=False, server_default="0"),
|
||||
sa.Column("actual_hours", sa.Float, nullable=False, server_default="0"),
|
||||
sa.Column("status", sa.String(20), nullable=False, server_default="todo"),
|
||||
sa.Column("priority", sa.Integer, nullable=False, server_default="3"),
|
||||
sa.Column("sort_index", sa.Integer, nullable=False, server_default="0"),
|
||||
sa.Column("completed_at", sa.DateTime(timezone=True), nullable=True),
|
||||
sa.Column("ado_synced_at", sa.DateTime(timezone=True), nullable=True),
|
||||
sa.Column("created_at", sa.DateTime(timezone=True), server_default=sa.func.now()),
|
||||
)
|
||||
op.create_index("ix_tasks_user_id", "tasks", ["user_id"])
|
||||
op.create_index("ix_tasks_project_id", "tasks", ["project_id"])
|
||||
op.create_index("ix_tasks_azure_work_item_id", "tasks", ["azure_work_item_id"])
|
||||
op.create_index("ix_tasks_planned_date", "tasks", ["planned_date"])
|
||||
|
||||
# planned_blocks references tasks
|
||||
op.create_table(
|
||||
"planned_blocks",
|
||||
sa.Column("id", postgresql.UUID(as_uuid=False), primary_key=True),
|
||||
sa.Column("user_id", postgresql.UUID(as_uuid=False), sa.ForeignKey("users.id", ondelete="CASCADE"), nullable=False),
|
||||
sa.Column("task_id", postgresql.UUID(as_uuid=False), sa.ForeignKey("tasks.id", ondelete="CASCADE"), nullable=False),
|
||||
sa.Column("project_id", postgresql.UUID(as_uuid=False), sa.ForeignKey("projects.id", ondelete="SET NULL"), nullable=True),
|
||||
sa.Column("start_at", sa.DateTime(timezone=True), nullable=False),
|
||||
sa.Column("end_at", sa.DateTime(timezone=True), nullable=False),
|
||||
sa.Column("created_at", sa.DateTime(timezone=True), server_default=sa.func.now()),
|
||||
)
|
||||
op.create_index("ix_planned_blocks_user_id", "planned_blocks", ["user_id"])
|
||||
op.create_index("ix_planned_blocks_task_id", "planned_blocks", ["task_id"])
|
||||
|
||||
# manual_entries
|
||||
op.create_table(
|
||||
"manual_entries",
|
||||
sa.Column("id", postgresql.UUID(as_uuid=False), primary_key=True),
|
||||
sa.Column("user_id", postgresql.UUID(as_uuid=False), sa.ForeignKey("users.id", ondelete="CASCADE"), nullable=False),
|
||||
sa.Column("project_id", postgresql.UUID(as_uuid=False), sa.ForeignKey("projects.id", ondelete="SET NULL"), nullable=True),
|
||||
sa.Column("task_id", postgresql.UUID(as_uuid=False), sa.ForeignKey("tasks.id", ondelete="SET NULL"), nullable=True),
|
||||
sa.Column("start_at", sa.DateTime(timezone=True), nullable=False),
|
||||
sa.Column("end_at", sa.DateTime(timezone=True), nullable=False),
|
||||
sa.Column("title", sa.String(500), nullable=False),
|
||||
sa.Column("notes", sa.Text, nullable=False, server_default=""),
|
||||
sa.Column("source", sa.String(20), nullable=False, server_default="manual"),
|
||||
sa.Column("created_at", sa.DateTime(timezone=True), server_default=sa.func.now()),
|
||||
)
|
||||
op.create_index("ix_manual_entries_user_id", "manual_entries", ["user_id"])
|
||||
|
||||
# project_budgets
|
||||
op.create_table(
|
||||
"project_budgets",
|
||||
sa.Column("id", postgresql.UUID(as_uuid=False), primary_key=True),
|
||||
sa.Column("user_id", postgresql.UUID(as_uuid=False), sa.ForeignKey("users.id", ondelete="CASCADE"), nullable=False),
|
||||
sa.Column("project_id", postgresql.UUID(as_uuid=False), sa.ForeignKey("projects.id", ondelete="CASCADE"), nullable=False),
|
||||
sa.Column("period", sa.String(10), nullable=False),
|
||||
sa.Column("target_hours", sa.Float, nullable=False),
|
||||
sa.Column("starts_on", sa.Date, nullable=False),
|
||||
sa.Column("created_at", sa.DateTime(timezone=True), server_default=sa.func.now()),
|
||||
sa.UniqueConstraint("user_id", "project_id", "period", "starts_on", name="uq_budget"),
|
||||
)
|
||||
op.create_index("ix_project_budgets_user_id", "project_budgets", ["user_id"])
|
||||
op.create_index("ix_project_budgets_project_id", "project_budgets", ["project_id"])
|
||||
|
||||
# task_tags association table
|
||||
op.create_table(
|
||||
"task_tags",
|
||||
sa.Column("task_id", postgresql.UUID(as_uuid=False), sa.ForeignKey("tasks.id", ondelete="CASCADE"), primary_key=True),
|
||||
sa.Column("tag_id", postgresql.UUID(as_uuid=False), sa.ForeignKey("tags.id", ondelete="CASCADE"), primary_key=True),
|
||||
)
|
||||
|
||||
# ai_reports
|
||||
op.create_table(
|
||||
"ai_reports",
|
||||
sa.Column("id", postgresql.UUID(as_uuid=False), primary_key=True),
|
||||
sa.Column("user_id", postgresql.UUID(as_uuid=False), sa.ForeignKey("users.id", ondelete="CASCADE"), nullable=False),
|
||||
sa.Column("type", sa.String(10), nullable=False),
|
||||
sa.Column("period_date", sa.Date, nullable=False),
|
||||
sa.Column("content_markdown", sa.Text, nullable=False, server_default=""),
|
||||
sa.Column("content_html", sa.Text, nullable=False, server_default=""),
|
||||
sa.Column("email_sent", sa.Boolean, nullable=False, server_default="false"),
|
||||
sa.Column("generated_at", sa.DateTime(timezone=True), server_default=sa.func.now()),
|
||||
)
|
||||
op.create_index("ix_ai_reports_user_id", "ai_reports", ["user_id"])
|
||||
|
||||
# audit_log
|
||||
op.create_table(
|
||||
"audit_log",
|
||||
sa.Column("id", sa.Integer, primary_key=True, autoincrement=True),
|
||||
sa.Column("user_id", postgresql.UUID(as_uuid=False), sa.ForeignKey("users.id", ondelete="SET NULL"), nullable=True),
|
||||
sa.Column("actor_email", sa.String(255), nullable=False, server_default=""),
|
||||
sa.Column("action", sa.String(100), nullable=False),
|
||||
sa.Column("entity_type", sa.String(50), nullable=False, server_default=""),
|
||||
sa.Column("entity_id", sa.String(64), nullable=False, server_default=""),
|
||||
sa.Column("payload_json", postgresql.JSONB, nullable=False, server_default="{}"),
|
||||
sa.Column("created_at", sa.DateTime(timezone=True), server_default=sa.func.now()),
|
||||
)
|
||||
op.create_index("ix_audit_log_user_id", "audit_log", ["user_id"])
|
||||
op.create_index("ix_audit_log_action", "audit_log", ["action"])
|
||||
op.create_index("ix_audit_log_created_at", "audit_log", ["created_at"])
|
||||
op.create_index("ix_audit_log_entity", "audit_log", ["entity_type", "entity_id"])
|
||||
|
||||
# Add task_id to sessions
|
||||
op.add_column(
|
||||
"sessions",
|
||||
sa.Column("task_id", postgresql.UUID(as_uuid=False), sa.ForeignKey("tasks.id", ondelete="SET NULL"), nullable=True),
|
||||
)
|
||||
op.create_index("ix_sessions_task_id", "sessions", ["task_id"])
|
||||
|
||||
|
||||
def downgrade():
|
||||
op.drop_index("ix_sessions_task_id", "sessions")
|
||||
op.drop_column("sessions", "task_id")
|
||||
|
||||
op.drop_index("ix_audit_log_entity", "audit_log")
|
||||
op.drop_index("ix_audit_log_created_at", "audit_log")
|
||||
op.drop_index("ix_audit_log_action", "audit_log")
|
||||
op.drop_index("ix_audit_log_user_id", "audit_log")
|
||||
op.drop_table("audit_log")
|
||||
|
||||
op.drop_index("ix_ai_reports_user_id", "ai_reports")
|
||||
op.drop_table("ai_reports")
|
||||
|
||||
op.drop_table("task_tags")
|
||||
|
||||
op.drop_index("ix_project_budgets_project_id", "project_budgets")
|
||||
op.drop_index("ix_project_budgets_user_id", "project_budgets")
|
||||
op.drop_table("project_budgets")
|
||||
|
||||
op.drop_index("ix_manual_entries_user_id", "manual_entries")
|
||||
op.drop_table("manual_entries")
|
||||
|
||||
op.drop_index("ix_planned_blocks_task_id", "planned_blocks")
|
||||
op.drop_index("ix_planned_blocks_user_id", "planned_blocks")
|
||||
op.drop_table("planned_blocks")
|
||||
|
||||
op.drop_index("ix_tasks_planned_date", "tasks")
|
||||
op.drop_index("ix_tasks_azure_work_item_id", "tasks")
|
||||
op.drop_index("ix_tasks_project_id", "tasks")
|
||||
op.drop_index("ix_tasks_user_id", "tasks")
|
||||
op.drop_table("tasks")
|
||||
|
||||
op.drop_index("ix_tags_user_id", "tags")
|
||||
op.drop_table("tags")
|
||||
|
||||
op.drop_index("ix_azure_work_items_user_state", "azure_work_items")
|
||||
op.drop_index("ix_azure_work_items_user_id", "azure_work_items")
|
||||
op.drop_table("azure_work_items")
|
||||
|
||||
op.drop_index("ix_azure_integrations_user_id", "azure_integrations")
|
||||
op.drop_table("azure_integrations")
|
||||
15
pyproject.toml
Normal file
15
pyproject.toml
Normal file
|
|
@ -0,0 +1,15 @@
|
|||
[tool.pytest.ini_options]
|
||||
asyncio_mode = "auto"
|
||||
testpaths = ["src/tests"]
|
||||
|
||||
[tool.ruff]
|
||||
target-version = "py311"
|
||||
line-length = 100
|
||||
|
||||
[tool.ruff.lint]
|
||||
select = ["E", "F", "W", "I"]
|
||||
ignore = ["E501"]
|
||||
|
||||
[tool.mypy]
|
||||
python_version = "3.11"
|
||||
ignore_missing_imports = true
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -18,6 +18,27 @@ class Settings(BaseSettings):
|
|||
APP_TITLE: str = "CC Dashboard"
|
||||
DEBUG: bool = False
|
||||
|
||||
# Azure DevOps
|
||||
ADO_ORGANIZATION: str = ""
|
||||
ADO_PROJECT: str = ""
|
||||
ADO_PAT: str = ""
|
||||
ADO_SYNC_INTERVAL_MINUTES: int = 15
|
||||
|
||||
# Mailgun
|
||||
MAILGUN_API_KEY: str = ""
|
||||
MAILGUN_DOMAIN: str = ""
|
||||
MAILGUN_FROM: str = "CC Dashboard <noreply@example.com>"
|
||||
|
||||
# AI Reports + Email
|
||||
ANTHROPIC_API_KEY: str = ""
|
||||
REPORT_EMAIL: str = ""
|
||||
DAILY_REPORT_HOUR: int = 20
|
||||
WEEKLY_REPORT_DAY: int = 6 # 0=Mon ... 6=Sun
|
||||
WEEKLY_REPORT_HOUR: int = 21
|
||||
|
||||
# Logging
|
||||
LOG_FORMAT: str = "console" # "json" or "console"
|
||||
|
||||
def model_post_init(self, __context):
|
||||
if not self.DATABASE_URL:
|
||||
object.__setattr__(
|
||||
|
|
|
|||
76
src/main.py
76
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")
|
||||
|
|
|
|||
0
src/middleware/__init__.py
Normal file
0
src/middleware/__init__.py
Normal file
27
src/middleware/logging.py
Normal file
27
src/middleware/logging.py
Normal file
|
|
@ -0,0 +1,27 @@
|
|||
"""structlog middleware for request context binding."""
|
||||
import time
|
||||
import uuid
|
||||
|
||||
import structlog
|
||||
from fastapi import Request, Response
|
||||
from starlette.middleware.base import BaseHTTPMiddleware
|
||||
|
||||
log = structlog.get_logger()
|
||||
|
||||
|
||||
class LoggingMiddleware(BaseHTTPMiddleware):
|
||||
async def dispatch(self, request: Request, call_next) -> Response:
|
||||
request_id = str(uuid.uuid4())[:8]
|
||||
start = time.perf_counter()
|
||||
structlog.contextvars.bind_contextvars(
|
||||
request_id=request_id,
|
||||
method=request.method,
|
||||
path=request.url.path,
|
||||
)
|
||||
try:
|
||||
response = await call_next(request)
|
||||
duration = round((time.perf_counter() - start) * 1000, 1)
|
||||
log.info("request", status=response.status_code, duration_ms=duration)
|
||||
return response
|
||||
finally:
|
||||
structlog.contextvars.unbind_contextvars("request_id", "method", "path")
|
||||
165
src/models.py
165
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)
|
||||
|
|
|
|||
90
src/routers/budgets.py
Normal file
90
src/routers/budgets.py
Normal file
|
|
@ -0,0 +1,90 @@
|
|||
"""Project budget CRUD + progress."""
|
||||
from fastapi import APIRouter, Depends, HTTPException
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from src.auth import CurrentUser
|
||||
from src.database import get_db
|
||||
from src.models import ProjectBudget
|
||||
from src.schemas import BudgetIn, BudgetOut
|
||||
from src.services.budgets import get_budgets_with_progress
|
||||
|
||||
router = APIRouter(prefix="/api/budgets", tags=["budgets"])
|
||||
|
||||
|
||||
@router.get("", response_model=list[BudgetOut])
|
||||
async def list_budgets(
|
||||
user: CurrentUser,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
data = await get_budgets_with_progress(user, db)
|
||||
return [BudgetOut(**d) for d in data]
|
||||
|
||||
|
||||
@router.post("", response_model=BudgetOut, status_code=201)
|
||||
async def create_budget(
|
||||
body: BudgetIn,
|
||||
user: CurrentUser,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
budget = ProjectBudget(
|
||||
user_id=user.id,
|
||||
project_id=body.project_id,
|
||||
period=body.period,
|
||||
target_hours=body.target_hours,
|
||||
starts_on=body.starts_on,
|
||||
)
|
||||
db.add(budget)
|
||||
await db.commit()
|
||||
await db.refresh(budget)
|
||||
return BudgetOut(
|
||||
id=budget.id,
|
||||
project_id=budget.project_id,
|
||||
period=budget.period,
|
||||
target_hours=budget.target_hours,
|
||||
actual_hours=0.0,
|
||||
progress_pct=0.0,
|
||||
starts_on=budget.starts_on,
|
||||
)
|
||||
|
||||
|
||||
@router.patch("/{budget_id}", response_model=BudgetOut)
|
||||
async def update_budget(
|
||||
budget_id: str,
|
||||
target_hours: float,
|
||||
user: CurrentUser,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
budget = await db.get(ProjectBudget, budget_id)
|
||||
if not budget or budget.user_id != user.id:
|
||||
raise HTTPException(status_code=404, detail="Budget not found")
|
||||
budget.target_hours = target_hours
|
||||
await db.commit()
|
||||
# Return with progress recalculated
|
||||
from src.services.budgets import get_budgets_with_progress
|
||||
|
||||
all_budgets = await get_budgets_with_progress(user, db)
|
||||
for b in all_budgets:
|
||||
if b["id"] == budget_id:
|
||||
return BudgetOut(**b)
|
||||
return BudgetOut(
|
||||
id=budget.id,
|
||||
project_id=budget.project_id,
|
||||
period=budget.period,
|
||||
target_hours=budget.target_hours,
|
||||
actual_hours=0.0,
|
||||
progress_pct=0.0,
|
||||
starts_on=budget.starts_on,
|
||||
)
|
||||
|
||||
|
||||
@router.delete("/{budget_id}", status_code=204)
|
||||
async def delete_budget(
|
||||
budget_id: str,
|
||||
user: CurrentUser,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
budget = await db.get(ProjectBudget, budget_id)
|
||||
if not budget or budget.user_id != user.id:
|
||||
raise HTTPException(status_code=404, detail="Budget not found")
|
||||
await db.delete(budget)
|
||||
await db.commit()
|
||||
152
src/routers/calendar.py
Normal file
152
src/routers/calendar.py
Normal file
|
|
@ -0,0 +1,152 @@
|
|||
"""Calendar endpoint — unified session/planned/manual blocks."""
|
||||
from datetime import date, timedelta
|
||||
from typing import Literal
|
||||
|
||||
from fastapi import APIRouter, Depends, Query
|
||||
from pydantic import BaseModel
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from src.auth import CurrentUser
|
||||
from src.database import get_db
|
||||
from src.models import ManualEntry, PlannedBlock, Project, Session, Task
|
||||
|
||||
router = APIRouter(prefix="/api/dashboard", tags=["calendar"])
|
||||
|
||||
|
||||
def _hue_for_project(project_id: str | None) -> int:
|
||||
"""Deterministic hue 0-359 from project UUID."""
|
||||
if not project_id:
|
||||
return 200
|
||||
h = 2166136261
|
||||
for ch in project_id:
|
||||
h ^= ord(ch)
|
||||
h = (h * 16777619) & 0xFFFFFFFF
|
||||
return (h % 330 + 15) % 360
|
||||
|
||||
|
||||
class TagBrief(BaseModel):
|
||||
id: str
|
||||
name: str
|
||||
color_hex: str
|
||||
|
||||
|
||||
class CalendarBlock(BaseModel):
|
||||
kind: Literal["session", "planned", "manual"]
|
||||
id: str
|
||||
project_id: str | None = None
|
||||
job_number: str = ""
|
||||
display_name: str = ""
|
||||
start_at: str # ISO datetime string
|
||||
end_at: str
|
||||
title: str = ""
|
||||
color_hue: int
|
||||
tags: list[TagBrief] = []
|
||||
task_id: str | None = None
|
||||
session_id: str | None = None
|
||||
manual_entry_id: str | None = None
|
||||
|
||||
|
||||
@router.get("/calendar", response_model=list[CalendarBlock])
|
||||
async def get_calendar(
|
||||
user: CurrentUser,
|
||||
from_date: date = Query(alias="from"),
|
||||
to_date: date = Query(alias="to"),
|
||||
view: str = Query(default="week"),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
# Clamp window
|
||||
if view == "day":
|
||||
to_date = from_date
|
||||
else:
|
||||
to_date = min(to_date, from_date + timedelta(days=6))
|
||||
|
||||
from_iso = from_date.isoformat()
|
||||
to_iso = to_date.isoformat()
|
||||
|
||||
blocks: list[CalendarBlock] = []
|
||||
|
||||
# Sessions
|
||||
sessions_result = await db.execute(
|
||||
select(Session, Project.display_name, Project.job_number)
|
||||
.join(Project, Session.project_id == Project.id)
|
||||
.where(
|
||||
Session.user_id == user.id,
|
||||
Session.date >= from_date,
|
||||
Session.date <= to_date,
|
||||
)
|
||||
.order_by(Session.start_at)
|
||||
)
|
||||
for s, display_name, job_number in sessions_result:
|
||||
blocks.append(
|
||||
CalendarBlock(
|
||||
kind="session",
|
||||
id=s.id,
|
||||
project_id=s.project_id,
|
||||
job_number=job_number or "",
|
||||
display_name=display_name,
|
||||
start_at=s.start_at.isoformat(),
|
||||
end_at=s.end_at.isoformat(),
|
||||
title=s.work_summary[:80] if s.work_summary else "",
|
||||
color_hue=_hue_for_project(s.project_id),
|
||||
session_id=s.id,
|
||||
)
|
||||
)
|
||||
|
||||
# Planned blocks
|
||||
planned_result = await db.execute(
|
||||
select(PlannedBlock, Project.display_name, Project.job_number, Task.title)
|
||||
.outerjoin(Project, PlannedBlock.project_id == Project.id)
|
||||
.outerjoin(Task, PlannedBlock.task_id == Task.id)
|
||||
.where(
|
||||
PlannedBlock.user_id == user.id,
|
||||
PlannedBlock.start_at <= to_iso + "T23:59:59+00:00",
|
||||
PlannedBlock.end_at >= from_iso + "T00:00:00+00:00",
|
||||
)
|
||||
.order_by(PlannedBlock.start_at)
|
||||
)
|
||||
for pb, display_name, job_number, task_title in planned_result:
|
||||
blocks.append(
|
||||
CalendarBlock(
|
||||
kind="planned",
|
||||
id=pb.id,
|
||||
project_id=pb.project_id,
|
||||
job_number=job_number or "",
|
||||
display_name=display_name or "",
|
||||
start_at=pb.start_at.isoformat(),
|
||||
end_at=pb.end_at.isoformat(),
|
||||
title=task_title or "",
|
||||
color_hue=_hue_for_project(pb.project_id),
|
||||
task_id=pb.task_id,
|
||||
)
|
||||
)
|
||||
|
||||
# Manual entries
|
||||
manual_result = await db.execute(
|
||||
select(ManualEntry, Project.display_name, Project.job_number)
|
||||
.outerjoin(Project, ManualEntry.project_id == Project.id)
|
||||
.where(
|
||||
ManualEntry.user_id == user.id,
|
||||
ManualEntry.start_at <= to_iso + "T23:59:59+00:00",
|
||||
ManualEntry.end_at >= from_iso + "T00:00:00+00:00",
|
||||
)
|
||||
.order_by(ManualEntry.start_at)
|
||||
)
|
||||
for m, display_name, job_number in manual_result:
|
||||
blocks.append(
|
||||
CalendarBlock(
|
||||
kind="manual",
|
||||
id=m.id,
|
||||
project_id=m.project_id,
|
||||
job_number=job_number or "",
|
||||
display_name=display_name or "",
|
||||
start_at=m.start_at.isoformat(),
|
||||
end_at=m.end_at.isoformat(),
|
||||
title=m.title,
|
||||
color_hue=_hue_for_project(m.project_id),
|
||||
manual_entry_id=m.id,
|
||||
)
|
||||
)
|
||||
|
||||
blocks.sort(key=lambda b: b.start_at)
|
||||
return blocks
|
||||
110
src/routers/devops.py
Normal file
110
src/routers/devops.py
Normal file
|
|
@ -0,0 +1,110 @@
|
|||
"""Azure DevOps integration endpoints."""
|
||||
from fastapi import APIRouter, Depends, HTTPException
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from src.auth import CurrentUser
|
||||
from src.database import get_db
|
||||
from src.models import AzureIntegration, AzureWorkItem
|
||||
from src.schemas import AzureIntegrationIn, AzureIntegrationOut, AzureWorkItemOut, SyncReport
|
||||
from src.services.crypto import encrypt
|
||||
|
||||
router = APIRouter(prefix="/api/devops", tags=["devops"])
|
||||
|
||||
_CLOSED_STATES = {"Closed", "Done", "Removed"}
|
||||
|
||||
|
||||
@router.get("/integration", response_model=AzureIntegrationOut)
|
||||
async def get_integration(
|
||||
user: CurrentUser,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
result = await db.execute(
|
||||
select(AzureIntegration).where(AzureIntegration.user_id == user.id)
|
||||
)
|
||||
integ = result.scalar_one_or_none()
|
||||
if not integ:
|
||||
raise HTTPException(status_code=404, detail="No integration configured")
|
||||
return AzureIntegrationOut.model_validate(integ)
|
||||
|
||||
|
||||
@router.put("/integration", response_model=AzureIntegrationOut)
|
||||
async def upsert_integration(
|
||||
body: AzureIntegrationIn,
|
||||
user: CurrentUser,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
result = await db.execute(
|
||||
select(AzureIntegration).where(AzureIntegration.user_id == user.id)
|
||||
)
|
||||
integ = result.scalar_one_or_none()
|
||||
|
||||
pat_encrypted = encrypt(body.pat)
|
||||
pat_hint = body.pat[-4:] if len(body.pat) >= 4 else body.pat
|
||||
|
||||
if integ is None:
|
||||
integ = AzureIntegration(
|
||||
user_id=user.id,
|
||||
organization=body.organization,
|
||||
project=body.project,
|
||||
pat_encrypted=pat_encrypted,
|
||||
pat_hint=pat_hint,
|
||||
)
|
||||
db.add(integ)
|
||||
else:
|
||||
integ.organization = body.organization
|
||||
integ.project = body.project
|
||||
integ.pat_encrypted = pat_encrypted
|
||||
integ.pat_hint = pat_hint
|
||||
|
||||
await db.commit()
|
||||
await db.refresh(integ)
|
||||
return AzureIntegrationOut.model_validate(integ)
|
||||
|
||||
|
||||
@router.delete("/integration", status_code=204)
|
||||
async def delete_integration(
|
||||
user: CurrentUser,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
result = await db.execute(
|
||||
select(AzureIntegration).where(AzureIntegration.user_id == user.id)
|
||||
)
|
||||
integ = result.scalar_one_or_none()
|
||||
if not integ:
|
||||
raise HTTPException(status_code=404, detail="No integration configured")
|
||||
await db.delete(integ)
|
||||
await db.commit()
|
||||
|
||||
|
||||
@router.post("/sync", response_model=SyncReport)
|
||||
async def trigger_sync(
|
||||
user: CurrentUser,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
from src.services.azure_devops.sync import sync_user_work_items
|
||||
|
||||
count = await sync_user_work_items(user, db)
|
||||
|
||||
# Check for error from integration record
|
||||
result = await db.execute(
|
||||
select(AzureIntegration).where(AzureIntegration.user_id == user.id)
|
||||
)
|
||||
integ = result.scalar_one_or_none()
|
||||
error = integ.last_sync_error if integ else ""
|
||||
|
||||
return SyncReport(synced=count, error=error)
|
||||
|
||||
|
||||
@router.get("/work-items", response_model=list[AzureWorkItemOut])
|
||||
async def list_work_items(
|
||||
user: CurrentUser,
|
||||
state: str = "open",
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
query = select(AzureWorkItem).where(AzureWorkItem.user_id == user.id)
|
||||
if state == "open":
|
||||
query = query.where(AzureWorkItem.state.notin_(list(_CLOSED_STATES)))
|
||||
query = query.order_by(AzureWorkItem.synced_at.desc())
|
||||
result = await db.execute(query)
|
||||
return [AzureWorkItemOut.model_validate(wi) for wi in result.scalars()]
|
||||
44
src/routers/exports.py
Normal file
44
src/routers/exports.py
Normal file
|
|
@ -0,0 +1,44 @@
|
|||
"""Timesheet export endpoints (CSV and ICS)."""
|
||||
from datetime import date
|
||||
|
||||
from fastapi import APIRouter, Depends, Query
|
||||
from fastapi.responses import Response, StreamingResponse
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from src.auth import CurrentUser
|
||||
from src.database import get_db
|
||||
from src.services.exports import export_csv, export_ics
|
||||
|
||||
router = APIRouter(prefix="/api/export", tags=["exports"])
|
||||
|
||||
|
||||
@router.get("/timesheet.csv")
|
||||
async def download_csv(
|
||||
user: CurrentUser,
|
||||
from_date: date = Query(alias="from"),
|
||||
to_date: date = Query(alias="to"),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
csv_content = await export_csv(user, from_date, to_date, db)
|
||||
filename = f"timesheet_{from_date}_{to_date}.csv"
|
||||
return StreamingResponse(
|
||||
iter([csv_content]),
|
||||
media_type="text/csv",
|
||||
headers={"Content-Disposition": f'attachment; filename="{filename}"'},
|
||||
)
|
||||
|
||||
|
||||
@router.get("/timesheet.ics")
|
||||
async def download_ics(
|
||||
user: CurrentUser,
|
||||
from_date: date = Query(alias="from"),
|
||||
to_date: date = Query(alias="to"),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
ics_bytes = await export_ics(user, from_date, to_date, db)
|
||||
filename = f"timesheet_{from_date}_{to_date}.ics"
|
||||
return Response(
|
||||
content=ics_bytes,
|
||||
media_type="text/calendar",
|
||||
headers={"Content-Disposition": f'attachment; filename="{filename}"'},
|
||||
)
|
||||
125
src/routers/manual_entries.py
Normal file
125
src/routers/manual_entries.py
Normal file
|
|
@ -0,0 +1,125 @@
|
|||
"""Manual time entries CRUD."""
|
||||
from datetime import date, datetime, timezone
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException, Query
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from src.auth import CurrentUser
|
||||
from src.database import get_db
|
||||
from src.models import ManualEntry, Project
|
||||
from src.schemas import ManualEntryIn, ManualEntryOut
|
||||
|
||||
router = APIRouter(prefix="/api/manual-entries", tags=["manual-entries"])
|
||||
|
||||
|
||||
async def _entry_out(m: ManualEntry, db: AsyncSession) -> ManualEntryOut:
|
||||
project_name = ""
|
||||
job_number = ""
|
||||
if m.project_id:
|
||||
proj = await db.get(Project, m.project_id)
|
||||
if proj:
|
||||
project_name = proj.display_name
|
||||
job_number = proj.job_number or ""
|
||||
|
||||
duration_hours = round((m.end_at - m.start_at).total_seconds() / 3600, 2)
|
||||
return ManualEntryOut(
|
||||
id=m.id,
|
||||
project_id=m.project_id,
|
||||
task_id=m.task_id,
|
||||
title=m.title,
|
||||
notes=m.notes or "",
|
||||
start_at=m.start_at,
|
||||
end_at=m.end_at,
|
||||
source=m.source,
|
||||
duration_hours=duration_hours,
|
||||
created_at=m.created_at,
|
||||
project_name=project_name,
|
||||
job_number=job_number,
|
||||
)
|
||||
|
||||
|
||||
@router.get("", response_model=list[ManualEntryOut])
|
||||
async def list_entries(
|
||||
user: CurrentUser,
|
||||
from_date: date | None = Query(default=None, alias="from"),
|
||||
to_date: date | None = Query(default=None, alias="to"),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
query = select(ManualEntry).where(ManualEntry.user_id == user.id)
|
||||
if from_date:
|
||||
query = query.where(
|
||||
ManualEntry.start_at
|
||||
>= datetime.combine(from_date, datetime.min.time()).replace(
|
||||
tzinfo=timezone.utc
|
||||
)
|
||||
)
|
||||
if to_date:
|
||||
query = query.where(
|
||||
ManualEntry.start_at
|
||||
<= datetime.combine(to_date, datetime.max.time()).replace(
|
||||
tzinfo=timezone.utc
|
||||
)
|
||||
)
|
||||
query = query.order_by(ManualEntry.start_at)
|
||||
result = await db.execute(query)
|
||||
entries = result.scalars().all()
|
||||
return [await _entry_out(e, db) for e in entries]
|
||||
|
||||
|
||||
@router.post("", response_model=ManualEntryOut, status_code=201)
|
||||
async def create_entry(
|
||||
body: ManualEntryIn,
|
||||
user: CurrentUser,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
entry = ManualEntry(
|
||||
user_id=user.id,
|
||||
project_id=body.project_id,
|
||||
task_id=body.task_id,
|
||||
start_at=body.start_at,
|
||||
end_at=body.end_at,
|
||||
title=body.title,
|
||||
notes=body.notes,
|
||||
source=body.source,
|
||||
)
|
||||
db.add(entry)
|
||||
await db.commit()
|
||||
await db.refresh(entry)
|
||||
return await _entry_out(entry, db)
|
||||
|
||||
|
||||
@router.patch("/{entry_id}", response_model=ManualEntryOut)
|
||||
async def update_entry(
|
||||
entry_id: str,
|
||||
body: ManualEntryIn,
|
||||
user: CurrentUser,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
entry = await db.get(ManualEntry, entry_id)
|
||||
if not entry or entry.user_id != user.id:
|
||||
raise HTTPException(status_code=404, detail="Entry not found")
|
||||
|
||||
entry.project_id = body.project_id
|
||||
entry.task_id = body.task_id
|
||||
entry.start_at = body.start_at
|
||||
entry.end_at = body.end_at
|
||||
entry.title = body.title
|
||||
entry.notes = body.notes
|
||||
entry.source = body.source
|
||||
await db.commit()
|
||||
await db.refresh(entry)
|
||||
return await _entry_out(entry, db)
|
||||
|
||||
|
||||
@router.delete("/{entry_id}", status_code=204)
|
||||
async def delete_entry(
|
||||
entry_id: str,
|
||||
user: CurrentUser,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
entry = await db.get(ManualEntry, entry_id)
|
||||
if not entry or entry.user_id != user.id:
|
||||
raise HTTPException(status_code=404, detail="Entry not found")
|
||||
await db.delete(entry)
|
||||
await db.commit()
|
||||
69
src/routers/reports.py
Normal file
69
src/routers/reports.py
Normal file
|
|
@ -0,0 +1,69 @@
|
|||
"""AI Reports endpoints."""
|
||||
from datetime import date
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException, Query
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from src.auth import CurrentUser
|
||||
from src.database import get_db
|
||||
from src.models import AiReport
|
||||
from src.schemas import AiReportOut, GenerateReportIn
|
||||
|
||||
router = APIRouter(prefix="/api/reports", tags=["reports"])
|
||||
|
||||
|
||||
@router.get("", response_model=list[AiReportOut])
|
||||
async def list_reports(
|
||||
user: CurrentUser,
|
||||
report_type: str | None = Query(default=None, alias="type"),
|
||||
from_date: date | None = Query(default=None, alias="from"),
|
||||
to_date: date | None = Query(default=None, alias="to"),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
query = select(AiReport).where(AiReport.user_id == user.id)
|
||||
if report_type:
|
||||
query = query.where(AiReport.type == report_type)
|
||||
if from_date:
|
||||
query = query.where(AiReport.period_date >= from_date)
|
||||
if to_date:
|
||||
query = query.where(AiReport.period_date <= to_date)
|
||||
query = query.order_by(AiReport.generated_at.desc())
|
||||
result = await db.execute(query)
|
||||
return [AiReportOut.model_validate(r) for r in result.scalars()]
|
||||
|
||||
|
||||
@router.post("/generate", response_model=AiReportOut, status_code=201)
|
||||
async def generate_report(
|
||||
body: GenerateReportIn,
|
||||
user: CurrentUser,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
from src.services.ai_reports import (
|
||||
generate_and_send_daily_report,
|
||||
generate_and_send_weekly_report,
|
||||
)
|
||||
|
||||
if body.type == "daily":
|
||||
report = await generate_and_send_daily_report(user, body.date, db)
|
||||
else:
|
||||
report = await generate_and_send_weekly_report(user, body.date, db)
|
||||
|
||||
if report is None:
|
||||
raise HTTPException(
|
||||
status_code=422,
|
||||
detail="No data found for the given period — report not generated",
|
||||
)
|
||||
return AiReportOut.model_validate(report)
|
||||
|
||||
|
||||
@router.get("/{report_id}", response_model=AiReportOut)
|
||||
async def get_report(
|
||||
report_id: str,
|
||||
user: CurrentUser,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
report = await db.get(AiReport, report_id)
|
||||
if not report or report.user_id != user.id:
|
||||
raise HTTPException(status_code=404, detail="Report not found")
|
||||
return AiReportOut.model_validate(report)
|
||||
113
src/routers/tags.py
Normal file
113
src/routers/tags.py
Normal file
|
|
@ -0,0 +1,113 @@
|
|||
"""Tags CRUD + task-tag assignment."""
|
||||
from fastapi import APIRouter, Depends, HTTPException
|
||||
from sqlalchemy import delete, select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from src.auth import CurrentUser
|
||||
from src.database import get_db
|
||||
from src.models import Tag, Task, task_tags
|
||||
from src.schemas import TagIn, TagOut
|
||||
|
||||
router = APIRouter(tags=["tags"])
|
||||
|
||||
|
||||
@router.get("/api/tags", response_model=list[TagOut])
|
||||
async def list_tags(
|
||||
user: CurrentUser,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
result = await db.execute(
|
||||
select(Tag).where(Tag.user_id == user.id).order_by(Tag.name)
|
||||
)
|
||||
return [TagOut.model_validate(t) for t in result.scalars()]
|
||||
|
||||
|
||||
@router.post("/api/tags", response_model=TagOut, status_code=201)
|
||||
async def create_tag(
|
||||
body: TagIn,
|
||||
user: CurrentUser,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
tag = Tag(user_id=user.id, name=body.name, color_hex=body.color_hex)
|
||||
db.add(tag)
|
||||
await db.commit()
|
||||
await db.refresh(tag)
|
||||
return TagOut.model_validate(tag)
|
||||
|
||||
|
||||
@router.patch("/api/tags/{tag_id}", response_model=TagOut)
|
||||
async def update_tag(
|
||||
tag_id: str,
|
||||
body: TagIn,
|
||||
user: CurrentUser,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
tag = await db.get(Tag, tag_id)
|
||||
if not tag or tag.user_id != user.id:
|
||||
raise HTTPException(status_code=404, detail="Tag not found")
|
||||
tag.name = body.name
|
||||
tag.color_hex = body.color_hex
|
||||
await db.commit()
|
||||
await db.refresh(tag)
|
||||
return TagOut.model_validate(tag)
|
||||
|
||||
|
||||
@router.delete("/api/tags/{tag_id}", status_code=204)
|
||||
async def delete_tag(
|
||||
tag_id: str,
|
||||
user: CurrentUser,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
tag = await db.get(Tag, tag_id)
|
||||
if not tag or tag.user_id != user.id:
|
||||
raise HTTPException(status_code=404, detail="Tag not found")
|
||||
# Cascade removes task_tags rows automatically (FK ondelete=CASCADE)
|
||||
await db.delete(tag)
|
||||
await db.commit()
|
||||
|
||||
|
||||
@router.post("/api/tasks/{task_id}/tags/{tag_id}", status_code=204)
|
||||
async def assign_tag(
|
||||
task_id: str,
|
||||
tag_id: str,
|
||||
user: CurrentUser,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
task = await db.get(Task, task_id)
|
||||
if not task or task.user_id != user.id:
|
||||
raise HTTPException(status_code=404, detail="Task not found")
|
||||
tag = await db.get(Tag, tag_id)
|
||||
if not tag or tag.user_id != user.id:
|
||||
raise HTTPException(status_code=404, detail="Tag not found")
|
||||
|
||||
# Insert ignore duplicate
|
||||
existing = await db.execute(
|
||||
select(task_tags).where(
|
||||
task_tags.c.task_id == task_id,
|
||||
task_tags.c.tag_id == tag_id,
|
||||
)
|
||||
)
|
||||
if not existing.first():
|
||||
await db.execute(
|
||||
task_tags.insert().values(task_id=task_id, tag_id=tag_id)
|
||||
)
|
||||
await db.commit()
|
||||
|
||||
|
||||
@router.delete("/api/tasks/{task_id}/tags/{tag_id}", status_code=204)
|
||||
async def remove_tag(
|
||||
task_id: str,
|
||||
tag_id: str,
|
||||
user: CurrentUser,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
task = await db.get(Task, task_id)
|
||||
if not task or task.user_id != user.id:
|
||||
raise HTTPException(status_code=404, detail="Task not found")
|
||||
await db.execute(
|
||||
delete(task_tags).where(
|
||||
task_tags.c.task_id == task_id,
|
||||
task_tags.c.tag_id == tag_id,
|
||||
)
|
||||
)
|
||||
await db.commit()
|
||||
206
src/routers/tasks.py
Normal file
206
src/routers/tasks.py
Normal file
|
|
@ -0,0 +1,206 @@
|
|||
"""Tasks CRUD + planned blocks."""
|
||||
from datetime import date, datetime, timezone
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from src.auth import CurrentUser
|
||||
from src.database import get_db
|
||||
from src.models import AzureWorkItem, PlannedBlock, Project, Task
|
||||
from src.schemas import PlannedBlockIn, PlannedBlockOut, TaskIn, TaskOut, TaskUpdate
|
||||
|
||||
router = APIRouter(prefix="/api/tasks", tags=["tasks"])
|
||||
|
||||
|
||||
async def _task_out(task: Task, db: AsyncSession) -> TaskOut:
|
||||
"""Build TaskOut from ORM object, resolving join fields."""
|
||||
project_name = ""
|
||||
job_number = ""
|
||||
work_item_title = ""
|
||||
|
||||
if task.project_id:
|
||||
proj = await db.get(Project, task.project_id)
|
||||
if proj:
|
||||
project_name = proj.display_name
|
||||
job_number = proj.job_number or ""
|
||||
|
||||
if task.azure_work_item_id:
|
||||
wi = await db.get(AzureWorkItem, task.azure_work_item_id)
|
||||
if wi:
|
||||
work_item_title = wi.title
|
||||
|
||||
return TaskOut(
|
||||
id=task.id,
|
||||
user_id=task.user_id,
|
||||
project_id=task.project_id,
|
||||
azure_work_item_id=task.azure_work_item_id,
|
||||
title=task.title,
|
||||
notes=task.notes or "",
|
||||
planned_date=task.planned_date,
|
||||
estimate_hours=task.estimate_hours,
|
||||
actual_hours=task.actual_hours,
|
||||
status=task.status,
|
||||
priority=task.priority,
|
||||
sort_index=task.sort_index,
|
||||
completed_at=task.completed_at,
|
||||
ado_synced_at=task.ado_synced_at,
|
||||
created_at=task.created_at,
|
||||
project_name=project_name,
|
||||
job_number=job_number,
|
||||
work_item_title=work_item_title,
|
||||
)
|
||||
|
||||
|
||||
@router.get("", response_model=list[TaskOut])
|
||||
async def list_tasks(
|
||||
user: CurrentUser,
|
||||
task_date: date | None = None,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
query = select(Task).where(Task.user_id == user.id)
|
||||
if task_date is not None:
|
||||
query = query.where(Task.planned_date == task_date)
|
||||
query = query.order_by(Task.planned_date, Task.sort_index, Task.created_at)
|
||||
result = await db.execute(query)
|
||||
tasks = result.scalars().all()
|
||||
return [await _task_out(t, db) for t in tasks]
|
||||
|
||||
|
||||
@router.post("", response_model=TaskOut, status_code=201)
|
||||
async def create_task(
|
||||
body: TaskIn,
|
||||
user: CurrentUser,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
task = Task(
|
||||
user_id=user.id,
|
||||
title=body.title,
|
||||
notes=body.notes,
|
||||
planned_date=body.planned_date,
|
||||
estimate_hours=body.estimate_hours,
|
||||
status=body.status,
|
||||
priority=body.priority,
|
||||
sort_index=body.sort_index,
|
||||
project_id=body.project_id,
|
||||
azure_work_item_id=body.azure_work_item_id,
|
||||
)
|
||||
db.add(task)
|
||||
await db.commit()
|
||||
await db.refresh(task)
|
||||
return await _task_out(task, db)
|
||||
|
||||
|
||||
@router.patch("/{task_id}", response_model=TaskOut)
|
||||
async def update_task(
|
||||
task_id: str,
|
||||
body: TaskUpdate,
|
||||
user: CurrentUser,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
task = await db.get(Task, task_id)
|
||||
if not task or task.user_id != user.id:
|
||||
raise HTTPException(status_code=404, detail="Task not found")
|
||||
|
||||
updates = body.model_dump(exclude_unset=True)
|
||||
for field, value in updates.items():
|
||||
setattr(task, field, value)
|
||||
|
||||
await db.commit()
|
||||
await db.refresh(task)
|
||||
return await _task_out(task, db)
|
||||
|
||||
|
||||
@router.delete("/{task_id}", status_code=204)
|
||||
async def delete_task(
|
||||
task_id: str,
|
||||
user: CurrentUser,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
task = await db.get(Task, task_id)
|
||||
if not task or task.user_id != user.id:
|
||||
raise HTTPException(status_code=404, detail="Task not found")
|
||||
await db.delete(task)
|
||||
await db.commit()
|
||||
|
||||
|
||||
@router.post("/{task_id}/complete", response_model=TaskOut)
|
||||
async def complete_task(
|
||||
task_id: str,
|
||||
user: CurrentUser,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
task = await db.get(Task, task_id)
|
||||
if not task or task.user_id != user.id:
|
||||
raise HTTPException(status_code=404, detail="Task not found")
|
||||
|
||||
task.status = "done"
|
||||
task.completed_at = datetime.now(timezone.utc)
|
||||
await db.commit()
|
||||
await db.refresh(task)
|
||||
|
||||
# Push to ADO if linked
|
||||
if task.azure_work_item_id:
|
||||
from src.services.azure_devops.push import push_completed_work
|
||||
|
||||
await push_completed_work(task, db)
|
||||
await db.refresh(task)
|
||||
|
||||
return await _task_out(task, db)
|
||||
|
||||
|
||||
# ── Planned blocks ────────────────────────────────────────────────────────────
|
||||
|
||||
@router.post("/{task_id}/blocks", response_model=PlannedBlockOut, status_code=201)
|
||||
async def create_block(
|
||||
task_id: str,
|
||||
body: PlannedBlockIn,
|
||||
user: CurrentUser,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
task = await db.get(Task, task_id)
|
||||
if not task or task.user_id != user.id:
|
||||
raise HTTPException(status_code=404, detail="Task not found")
|
||||
|
||||
block = PlannedBlock(
|
||||
user_id=user.id,
|
||||
task_id=task_id,
|
||||
project_id=task.project_id,
|
||||
start_at=body.start_at,
|
||||
end_at=body.end_at,
|
||||
)
|
||||
db.add(block)
|
||||
await db.commit()
|
||||
await db.refresh(block)
|
||||
return PlannedBlockOut.model_validate(block)
|
||||
|
||||
|
||||
@router.patch("/blocks/{block_id}", response_model=PlannedBlockOut)
|
||||
async def update_block(
|
||||
block_id: str,
|
||||
body: PlannedBlockIn,
|
||||
user: CurrentUser,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
block = await db.get(PlannedBlock, block_id)
|
||||
if not block or block.user_id != user.id:
|
||||
raise HTTPException(status_code=404, detail="Block not found")
|
||||
|
||||
block.start_at = body.start_at
|
||||
block.end_at = body.end_at
|
||||
await db.commit()
|
||||
await db.refresh(block)
|
||||
return PlannedBlockOut.model_validate(block)
|
||||
|
||||
|
||||
@router.delete("/blocks/{block_id}", status_code=204)
|
||||
async def delete_block(
|
||||
block_id: str,
|
||||
user: CurrentUser,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
block = await db.get(PlannedBlock, block_id)
|
||||
if not block or block.user_id != user.id:
|
||||
raise HTTPException(status_code=404, detail="Block not found")
|
||||
await db.delete(block)
|
||||
await db.commit()
|
||||
206
src/schemas.py
206
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
|
||||
|
|
|
|||
290
src/services/ai_reports.py
Normal file
290
src/services/ai_reports.py
Normal file
|
|
@ -0,0 +1,290 @@
|
|||
"""AI report generation + email delivery via Mailgun."""
|
||||
import logging
|
||||
from datetime import date, datetime, timedelta, timezone
|
||||
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from src.config import settings
|
||||
from src.models import AiReport, Project, Session, Task, User
|
||||
from src.services.mailgun import send_email
|
||||
|
||||
log = logging.getLogger(__name__)
|
||||
|
||||
|
||||
async def _gather_daily_context(user: User, report_date: date, db: AsyncSession) -> dict:
|
||||
"""Gather data for a daily report."""
|
||||
sessions_result = await db.execute(
|
||||
select(Session, Project.display_name, Project.job_number)
|
||||
.join(Project, Session.project_id == Project.id)
|
||||
.where(Session.user_id == user.id, Session.date == report_date)
|
||||
.order_by(Session.start_at)
|
||||
)
|
||||
rows = sessions_result.all()
|
||||
|
||||
tasks_result = await db.execute(
|
||||
select(Task).where(Task.user_id == user.id, Task.planned_date == report_date)
|
||||
)
|
||||
tasks = tasks_result.scalars().all()
|
||||
|
||||
total_hours = sum(s.active_hours for s, _, _ in rows)
|
||||
projects_worked: dict[str, float] = {}
|
||||
for s, display_name, job_number in rows:
|
||||
label = f"{job_number} {display_name}".strip() if job_number else display_name
|
||||
projects_worked[label] = projects_worked.get(label, 0) + s.active_hours
|
||||
|
||||
return {
|
||||
"date": report_date.isoformat(),
|
||||
"total_hours": round(total_hours, 2),
|
||||
"session_count": len(rows),
|
||||
"projects": {k: round(v, 2) for k, v in projects_worked.items()},
|
||||
"summaries": [s.work_summary for s, _, _ in rows if s.work_summary],
|
||||
"tasks_total": len(tasks),
|
||||
"tasks_done": sum(1 for t in tasks if t.status == "done"),
|
||||
"tasks_todo": [t.title for t in tasks if t.status in ("todo", "doing")],
|
||||
}
|
||||
|
||||
|
||||
async def _gather_weekly_context(user: User, week_start: date, db: AsyncSession) -> dict:
|
||||
"""Gather data for a weekly report (Mon-Sun)."""
|
||||
week_end = week_start + timedelta(days=6)
|
||||
sessions_result = await db.execute(
|
||||
select(Session, Project.display_name, Project.job_number)
|
||||
.join(Project, Session.project_id == Project.id)
|
||||
.where(
|
||||
Session.user_id == user.id,
|
||||
Session.date >= week_start,
|
||||
Session.date <= week_end,
|
||||
)
|
||||
)
|
||||
rows = sessions_result.all()
|
||||
projects_worked: dict[str, float] = {}
|
||||
for s, display_name, job_number in rows:
|
||||
label = f"{job_number} {display_name}".strip() if job_number else display_name
|
||||
projects_worked[label] = projects_worked.get(label, 0) + s.active_hours
|
||||
|
||||
tasks_result = await db.execute(
|
||||
select(Task).where(
|
||||
Task.user_id == user.id,
|
||||
Task.planned_date >= week_start,
|
||||
Task.planned_date <= week_end,
|
||||
)
|
||||
)
|
||||
tasks = tasks_result.scalars().all()
|
||||
|
||||
return {
|
||||
"week_start": week_start.isoformat(),
|
||||
"week_end": week_end.isoformat(),
|
||||
"total_hours": round(sum(s.active_hours for s, _, _ in rows), 2),
|
||||
"working_days": len({s.date for s, _, _ in rows}),
|
||||
"session_count": len(rows),
|
||||
"projects": {
|
||||
k: round(v, 2)
|
||||
for k, v in sorted(projects_worked.items(), key=lambda x: -x[1])
|
||||
},
|
||||
"tasks_done": sum(1 for t in tasks if t.status == "done"),
|
||||
"tasks_total": len(tasks),
|
||||
}
|
||||
|
||||
|
||||
def _build_prompt(context: dict, report_type: str) -> str:
|
||||
if report_type == "daily":
|
||||
return f"""You are a productivity assistant. Generate a concise, motivating daily work report in Markdown.
|
||||
|
||||
Data for {context['date']}:
|
||||
- Total coding hours: {context['total_hours']}h across {context['session_count']} sessions
|
||||
- Projects worked on: {context['projects']}
|
||||
- Tasks completed: {context['tasks_done']} / {context['tasks_total']}
|
||||
- Pending tasks: {context['tasks_todo'][:5]}
|
||||
- Session summaries: {context['summaries'][:5]}
|
||||
|
||||
Write a professional daily summary with:
|
||||
1. Headline with date and total hours
|
||||
2. Key achievements (from session summaries)
|
||||
3. Projects breakdown
|
||||
4. Task completion rate
|
||||
5. One motivating closing remark
|
||||
|
||||
Keep it under 300 words. Use Markdown headers and bullet points."""
|
||||
else:
|
||||
return f"""You are a productivity assistant. Generate a concise weekly work summary in Markdown.
|
||||
|
||||
Week of {context['week_start']} to {context['week_end']}:
|
||||
- Total hours: {context['total_hours']}h across {context['working_days']} working days
|
||||
- Sessions: {context['session_count']}
|
||||
- Tasks: {context['tasks_done']} completed out of {context['tasks_total']} planned
|
||||
- Projects: {context['projects']}
|
||||
|
||||
Write a professional weekly summary with:
|
||||
1. Headline with week range and total hours
|
||||
2. Highlights / key achievements
|
||||
3. Projects breakdown (hours per project)
|
||||
4. Task completion rate
|
||||
5. Brief retrospective and focus for next week
|
||||
|
||||
Keep it under 400 words. Use Markdown."""
|
||||
|
||||
|
||||
async def _generate_content(context: dict, report_type: str) -> tuple[str, str]:
|
||||
"""Returns (markdown, html). Falls back to plain stats if no Anthropic key."""
|
||||
import markdown as md_lib
|
||||
|
||||
if settings.ANTHROPIC_API_KEY:
|
||||
try:
|
||||
import anthropic
|
||||
|
||||
client = anthropic.AsyncAnthropic(api_key=settings.ANTHROPIC_API_KEY)
|
||||
message = await client.messages.create(
|
||||
model="claude-sonnet-4-6",
|
||||
max_tokens=1024,
|
||||
messages=[{"role": "user", "content": _build_prompt(context, report_type)}],
|
||||
)
|
||||
markdown_content = message.content[0].text
|
||||
except Exception as exc:
|
||||
log.warning("ai_report.generation_failed", extra={"error": str(exc)})
|
||||
markdown_content = _fallback_markdown(context, report_type)
|
||||
else:
|
||||
markdown_content = _fallback_markdown(context, report_type)
|
||||
|
||||
html_content = md_lib.markdown(markdown_content, extensions=["extra"])
|
||||
return markdown_content, html_content
|
||||
|
||||
|
||||
def _fallback_markdown(context: dict, report_type: str) -> str:
|
||||
if report_type == "daily":
|
||||
lines = [
|
||||
f"# Daily Report — {context['date']}",
|
||||
f"**Total:** {context['total_hours']}h across {context['session_count']} sessions",
|
||||
"",
|
||||
"## Projects",
|
||||
]
|
||||
for proj, h in context["projects"].items():
|
||||
lines.append(f"- **{proj}**: {h}h")
|
||||
lines += ["", f"**Tasks:** {context['tasks_done']} / {context['tasks_total']} completed"]
|
||||
return "\n".join(lines)
|
||||
else:
|
||||
lines = [
|
||||
f"# Weekly Report — {context['week_start']} to {context['week_end']}",
|
||||
f"**Total:** {context['total_hours']}h across {context['working_days']} days",
|
||||
"",
|
||||
"## Projects",
|
||||
]
|
||||
for proj, h in context["projects"].items():
|
||||
lines.append(f"- **{proj}**: {h}h")
|
||||
lines += ["", f"**Tasks:** {context['tasks_done']} / {context['tasks_total']} completed"]
|
||||
return "\n".join(lines)
|
||||
|
||||
|
||||
def _html_wrapper(body_html: str, title: str) -> str:
|
||||
return f"""<!DOCTYPE html>
|
||||
<html><head><meta charset="utf-8">
|
||||
<style>
|
||||
body{{font-family:system-ui,sans-serif;max-width:600px;margin:0 auto;padding:24px;color:#1a1a1a}}
|
||||
h1{{color:#1a1a1a;border-bottom:2px solid #FFC407;padding-bottom:8px}}
|
||||
h2{{color:#333}}
|
||||
ul{{padding-left:20px}}
|
||||
li{{margin:4px 0}}
|
||||
strong{{color:#000}}
|
||||
.footer{{margin-top:32px;padding-top:16px;border-top:1px solid #eee;font-size:12px;color:#666}}
|
||||
</style>
|
||||
</head><body>
|
||||
{body_html}
|
||||
<div class="footer">Generated by <strong>CC Dashboard</strong></div>
|
||||
</body></html>"""
|
||||
|
||||
|
||||
async def generate_and_send_daily_report(
|
||||
user: User, report_date: date, db: AsyncSession
|
||||
) -> AiReport | None:
|
||||
"""Generate daily report, save to DB, and send via email."""
|
||||
try:
|
||||
context = await _gather_daily_context(user, report_date, db)
|
||||
if context["session_count"] == 0:
|
||||
return None # Skip empty days
|
||||
|
||||
markdown_content, html_content = await _generate_content(context, "daily")
|
||||
full_html = _html_wrapper(html_content, f"Daily Report {report_date}")
|
||||
|
||||
report = AiReport(
|
||||
user_id=user.id,
|
||||
type="daily",
|
||||
period_date=report_date,
|
||||
content_markdown=markdown_content,
|
||||
content_html=full_html,
|
||||
)
|
||||
db.add(report)
|
||||
await db.flush()
|
||||
|
||||
if settings.REPORT_EMAIL:
|
||||
subject = f"CC Dashboard — Daily Report {report_date}"
|
||||
sent = await send_email(settings.REPORT_EMAIL, subject, full_html, markdown_content)
|
||||
report.email_sent = sent
|
||||
|
||||
await db.commit()
|
||||
return report
|
||||
except Exception as exc:
|
||||
await db.rollback()
|
||||
log.error(
|
||||
"daily_report.failed",
|
||||
extra={"user_id": user.id, "date": str(report_date), "error": str(exc)},
|
||||
)
|
||||
return None
|
||||
|
||||
|
||||
async def generate_and_send_weekly_report(
|
||||
user: User, week_start: date, db: AsyncSession
|
||||
) -> AiReport | None:
|
||||
"""Generate weekly report for Mon-Sun week, save to DB, and send via email."""
|
||||
# Ensure week_start is a Monday
|
||||
days_since_monday = week_start.weekday()
|
||||
monday = week_start - timedelta(days=days_since_monday)
|
||||
try:
|
||||
context = await _gather_weekly_context(user, monday, db)
|
||||
if context["session_count"] == 0:
|
||||
return None
|
||||
|
||||
markdown_content, html_content = await _generate_content(context, "weekly")
|
||||
week_end = monday + timedelta(days=6)
|
||||
full_html = _html_wrapper(html_content, f"Weekly Report {monday} – {week_end}")
|
||||
|
||||
report = AiReport(
|
||||
user_id=user.id,
|
||||
type="weekly",
|
||||
period_date=monday,
|
||||
content_markdown=markdown_content,
|
||||
content_html=full_html,
|
||||
)
|
||||
db.add(report)
|
||||
await db.flush()
|
||||
|
||||
if settings.REPORT_EMAIL:
|
||||
subject = f"CC Dashboard — Weekly Report {monday} – {week_end}"
|
||||
sent = await send_email(settings.REPORT_EMAIL, subject, full_html, markdown_content)
|
||||
report.email_sent = sent
|
||||
|
||||
await db.commit()
|
||||
return report
|
||||
except Exception as exc:
|
||||
await db.rollback()
|
||||
log.error(
|
||||
"weekly_report.failed",
|
||||
extra={"user_id": user.id, "week": str(monday), "error": str(exc)},
|
||||
)
|
||||
return None
|
||||
|
||||
|
||||
async def run_daily_reports_all_users(db: AsyncSession) -> None:
|
||||
"""Called by scheduler at DAILY_REPORT_HOUR."""
|
||||
today = datetime.now(timezone.utc).date()
|
||||
result = await db.execute(select(User).where(User.is_active == True)) # noqa: E712
|
||||
for user in result.scalars():
|
||||
await generate_and_send_daily_report(user, today, db)
|
||||
|
||||
|
||||
async def run_weekly_reports_all_users(db: AsyncSession) -> None:
|
||||
"""Called by scheduler on WEEKLY_REPORT_DAY."""
|
||||
today = datetime.now(timezone.utc).date()
|
||||
monday = today - timedelta(days=today.weekday())
|
||||
result = await db.execute(select(User).where(User.is_active == True)) # noqa: E712
|
||||
for user in result.scalars():
|
||||
await generate_and_send_weekly_report(user, monday, db)
|
||||
24
src/services/audit.py
Normal file
24
src/services/audit.py
Normal file
|
|
@ -0,0 +1,24 @@
|
|||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from src.models import AuditLog, User
|
||||
|
||||
|
||||
async def log_action(
|
||||
db: AsyncSession,
|
||||
user: User,
|
||||
action: str,
|
||||
entity_type: str = "",
|
||||
entity_id: str = "",
|
||||
payload: dict | None = None,
|
||||
) -> None:
|
||||
entry = AuditLog(
|
||||
user_id=user.id,
|
||||
actor_email=user.email,
|
||||
action=action,
|
||||
entity_type=entity_type,
|
||||
entity_id=entity_id,
|
||||
payload_json=payload or {},
|
||||
)
|
||||
db.add(entry)
|
||||
# Flush but don't commit — caller manages transaction
|
||||
await db.flush()
|
||||
0
src/services/azure_devops/__init__.py
Normal file
0
src/services/azure_devops/__init__.py
Normal file
179
src/services/azure_devops/client.py
Normal file
179
src/services/azure_devops/client.py
Normal file
|
|
@ -0,0 +1,179 @@
|
|||
"""Azure DevOps HTTP client."""
|
||||
import base64
|
||||
import logging
|
||||
from typing import Any, Optional
|
||||
|
||||
import httpx
|
||||
|
||||
from src.config import settings as _settings
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
ADO_BASE = "https://dev.azure.com"
|
||||
ADO_VSRM_BASE = "https://vsrm.dev.azure.com"
|
||||
|
||||
|
||||
class ADOClient:
|
||||
def __init__(
|
||||
self,
|
||||
org: str | None = None,
|
||||
project: str | None = None,
|
||||
pat: str | None = None,
|
||||
):
|
||||
self.org = org or _settings.ADO_ORGANIZATION
|
||||
self.project = project or _settings.ADO_PROJECT
|
||||
pat_value = pat or _settings.ADO_PAT
|
||||
self.base = f"{ADO_BASE}/{self.org}"
|
||||
token = base64.b64encode(f":{pat_value}".encode()).decode()
|
||||
self.headers = {
|
||||
"Authorization": f"Basic {token}",
|
||||
"Content-Type": "application/json",
|
||||
}
|
||||
|
||||
async def get_work_item(self, work_item_id: int | str) -> dict:
|
||||
url = f"{self.base}/{self.project}/_apis/wit/workitems/{work_item_id}?$expand=all&api-version=7.1"
|
||||
async with httpx.AsyncClient() as client:
|
||||
resp = await client.get(url, headers=self.headers)
|
||||
resp.raise_for_status()
|
||||
return resp.json()
|
||||
|
||||
async def get_work_items_batch(
|
||||
self, ids: list[int], fields: Optional[list[str]] = None
|
||||
) -> list[dict]:
|
||||
if not ids:
|
||||
return []
|
||||
url = f"{self.base}/{self.project}/_apis/wit/workitemsbatch?api-version=7.1"
|
||||
body: dict[str, Any] = {"ids": ids, "$expand": "all"}
|
||||
if fields:
|
||||
body["fields"] = fields
|
||||
async with httpx.AsyncClient() as client:
|
||||
resp = await client.post(url, json=body, headers=self.headers)
|
||||
resp.raise_for_status()
|
||||
return resp.json().get("value", [])
|
||||
|
||||
async def query_work_items(
|
||||
self, wiql: str, project: Optional[str] = None
|
||||
) -> list[dict]:
|
||||
proj = project or self.project
|
||||
url = f"{self.base}/{proj}/_apis/wit/wiql?api-version=7.1"
|
||||
async with httpx.AsyncClient() as client:
|
||||
resp = await client.post(
|
||||
url, json={"query": wiql}, headers=self.headers
|
||||
)
|
||||
resp.raise_for_status()
|
||||
return resp.json().get("workItems", [])
|
||||
|
||||
async def create_work_item(
|
||||
self, work_item_type: str, fields: list[dict], project: Optional[str] = None
|
||||
) -> dict:
|
||||
proj = project or self.project
|
||||
url = f"{self.base}/{proj}/_apis/wit/workitems/${work_item_type}?api-version=7.1"
|
||||
headers = {**self.headers, "Content-Type": "application/json-patch+json"}
|
||||
async with httpx.AsyncClient() as client:
|
||||
resp = await client.post(url, json=fields, headers=headers)
|
||||
resp.raise_for_status()
|
||||
return resp.json()
|
||||
|
||||
async def update_work_item(
|
||||
self, work_item_id: int | str, patch: list[dict]
|
||||
) -> dict:
|
||||
url = f"{self.base}/{self.project}/_apis/wit/workitems/{work_item_id}?api-version=7.1"
|
||||
headers = {**self.headers, "Content-Type": "application/json-patch+json"}
|
||||
async with httpx.AsyncClient() as client:
|
||||
resp = await client.patch(url, json=patch, headers=headers)
|
||||
resp.raise_for_status()
|
||||
return resp.json()
|
||||
|
||||
async def get_work_item_comments(self, work_item_id: int | str) -> list[dict]:
|
||||
url = f"{self.base}/{self.project}/_apis/wit/workitems/{work_item_id}/comments?api-version=7.1-preview.3"
|
||||
async with httpx.AsyncClient() as client:
|
||||
resp = await client.get(url, headers=self.headers)
|
||||
resp.raise_for_status()
|
||||
return resp.json().get("comments", [])
|
||||
|
||||
async def add_comment(self, work_item_id: int | str, text: str) -> dict:
|
||||
url = f"{self.base}/{self.project}/_apis/wit/workitems/{work_item_id}/comments?api-version=7.1-preview.3"
|
||||
async with httpx.AsyncClient() as client:
|
||||
resp = await client.post(
|
||||
url, json={"text": text}, headers=self.headers
|
||||
)
|
||||
resp.raise_for_status()
|
||||
return resp.json()
|
||||
|
||||
async def get_attachments(self, work_item_id: int | str) -> list[dict]:
|
||||
item = await self.get_work_item(work_item_id)
|
||||
relations = item.get("relations") or []
|
||||
return [r for r in relations if r.get("rel") == "AttachedFile"]
|
||||
|
||||
async def upload_attachment(
|
||||
self, filename: str, content: bytes, project: Optional[str] = None
|
||||
) -> dict:
|
||||
proj = project or self.project
|
||||
url = f"{self.base}/{proj}/_apis/wit/attachments?fileName={filename}&api-version=7.1"
|
||||
upload_headers = {
|
||||
"Authorization": self.headers["Authorization"],
|
||||
"Content-Type": "application/octet-stream",
|
||||
}
|
||||
async with httpx.AsyncClient() as client:
|
||||
resp = await client.post(url, content=content, headers=upload_headers)
|
||||
resp.raise_for_status()
|
||||
return resp.json()
|
||||
|
||||
async def add_attachment_link(
|
||||
self, work_item_id: int | str, attachment_url: str, filename: str
|
||||
) -> dict:
|
||||
patch = [
|
||||
{
|
||||
"op": "add",
|
||||
"path": "/relations/-",
|
||||
"value": {
|
||||
"rel": "AttachedFile",
|
||||
"url": attachment_url,
|
||||
"attributes": {"comment": filename},
|
||||
},
|
||||
}
|
||||
]
|
||||
return await self.update_work_item(work_item_id, patch)
|
||||
|
||||
async def get_projects(self) -> list[dict]:
|
||||
url = f"{self.base}/_apis/projects?api-version=7.1"
|
||||
async with httpx.AsyncClient() as client:
|
||||
resp = await client.get(url, headers=self.headers)
|
||||
resp.raise_for_status()
|
||||
return resp.json().get("value", [])
|
||||
|
||||
async def create_service_hook(
|
||||
self, project_id: str, event_type: str, webhook_url: str
|
||||
) -> dict:
|
||||
url = f"{self.base}/_apis/hooks/subscriptions?api-version=7.1"
|
||||
body = {
|
||||
"publisherId": "tfs",
|
||||
"eventType": event_type,
|
||||
"resourceVersion": "1.0",
|
||||
"consumerId": "webHooks",
|
||||
"consumerActionId": "httpRequest",
|
||||
"publisherInputs": {"projectId": project_id},
|
||||
"consumerInputs": {
|
||||
"url": webhook_url,
|
||||
"resourceDetailsToSend": "all",
|
||||
"messagesToSend": "none",
|
||||
"detailedMessagesToSend": "none",
|
||||
},
|
||||
}
|
||||
async with httpx.AsyncClient() as client:
|
||||
resp = await client.post(url, json=body, headers=self.headers)
|
||||
resp.raise_for_status()
|
||||
return resp.json()
|
||||
|
||||
async def list_service_hooks(self) -> list[dict]:
|
||||
url = f"{self.base}/_apis/hooks/subscriptions?api-version=7.1"
|
||||
async with httpx.AsyncClient() as client:
|
||||
resp = await client.get(url, headers=self.headers)
|
||||
resp.raise_for_status()
|
||||
return resp.json().get("value", [])
|
||||
|
||||
async def delete_service_hook(self, subscription_id: str) -> None:
|
||||
url = f"{self.base}/_apis/hooks/subscriptions/{subscription_id}?api-version=7.1"
|
||||
async with httpx.AsyncClient() as client:
|
||||
resp = await client.delete(url, headers=self.headers)
|
||||
resp.raise_for_status()
|
||||
87
src/services/azure_devops/push.py
Normal file
87
src/services/azure_devops/push.py
Normal file
|
|
@ -0,0 +1,87 @@
|
|||
"""Push completed work back to Azure DevOps."""
|
||||
from datetime import datetime, timezone
|
||||
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from src.models import AzureIntegration, AzureWorkItem, ManualEntry, Session, Task
|
||||
from src.services.crypto import decrypt
|
||||
from src.services.azure_devops.client import ADOClient
|
||||
|
||||
|
||||
async def push_completed_work(task: Task, db: AsyncSession) -> bool:
|
||||
"""Post CompletedWork + comment to linked ADO work item. Idempotent."""
|
||||
if not task.azure_work_item_id:
|
||||
return False
|
||||
|
||||
wi_row = await db.get(AzureWorkItem, task.azure_work_item_id)
|
||||
if not wi_row:
|
||||
return False
|
||||
|
||||
integ_result = await db.execute(
|
||||
select(AzureIntegration).where(AzureIntegration.user_id == task.user_id)
|
||||
)
|
||||
integ = integ_result.scalar_one_or_none()
|
||||
if not integ:
|
||||
return False
|
||||
|
||||
# Calculate actual hours from sessions + manual entries linked to this task
|
||||
sessions_result = await db.execute(
|
||||
select(Session).where(Session.task_id == task.id)
|
||||
)
|
||||
sessions = sessions_result.scalars().all()
|
||||
manual_result = await db.execute(
|
||||
select(ManualEntry).where(ManualEntry.task_id == task.id)
|
||||
)
|
||||
manuals = manual_result.scalars().all()
|
||||
|
||||
actual_h = sum(
|
||||
(s.end_at - s.start_at).total_seconds() / 3600 for s in sessions
|
||||
)
|
||||
actual_h += sum(
|
||||
(m.end_at - m.start_at).total_seconds() / 3600 for m in manuals
|
||||
)
|
||||
actual_h = round(actual_h, 2)
|
||||
|
||||
try:
|
||||
pat = decrypt(integ.pat_encrypted)
|
||||
client = ADOClient(org=integ.organization, project=integ.project, pat=pat)
|
||||
|
||||
fields_now = wi_row.fields_json or {}
|
||||
current_completed = float(
|
||||
fields_now.get("Microsoft.VSTS.Scheduling.CompletedWork", 0) or 0
|
||||
)
|
||||
current_remaining = float(
|
||||
fields_now.get("Microsoft.VSTS.Scheduling.RemainingWork", 0) or 0
|
||||
)
|
||||
|
||||
patch = [
|
||||
{
|
||||
"op": "add",
|
||||
"path": "/fields/Microsoft.VSTS.Scheduling.CompletedWork",
|
||||
"value": round(current_completed + actual_h, 2),
|
||||
},
|
||||
{
|
||||
"op": "add",
|
||||
"path": "/fields/Microsoft.VSTS.Scheduling.RemainingWork",
|
||||
"value": max(0.0, round(current_remaining - actual_h, 2)),
|
||||
},
|
||||
]
|
||||
await client.update_work_item(wi_row.ado_id, patch)
|
||||
|
||||
session_lines = [
|
||||
f"- {s.work_summary or s.session_id} ({round((s.end_at - s.start_at).total_seconds() / 3600, 2)}h)"
|
||||
for s in sessions
|
||||
]
|
||||
comment = (
|
||||
f"✅ Task completed via CC Dashboard.\n\n"
|
||||
f"Total time: **{actual_h}h**\n\n" + "\n".join(session_lines)
|
||||
)
|
||||
await client.add_comment(wi_row.ado_id, comment)
|
||||
|
||||
task.ado_synced_at = datetime.now(timezone.utc)
|
||||
await db.commit()
|
||||
return True
|
||||
except Exception:
|
||||
await db.rollback()
|
||||
return False
|
||||
118
src/services/azure_devops/sync.py
Normal file
118
src/services/azure_devops/sync.py
Normal file
|
|
@ -0,0 +1,118 @@
|
|||
"""Pull assigned work items from Azure DevOps into local cache."""
|
||||
import logging
|
||||
from datetime import datetime, timezone
|
||||
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from src.models import AzureIntegration, AzureWorkItem, User
|
||||
from src.services.crypto import decrypt
|
||||
from src.services.azure_devops.client import ADOClient
|
||||
|
||||
log = logging.getLogger(__name__)
|
||||
|
||||
WIQL = """
|
||||
SELECT [System.Id]
|
||||
FROM WorkItems
|
||||
WHERE [System.AssignedTo] = @Me
|
||||
AND [System.State] NOT IN ('Done', 'Closed', 'Removed')
|
||||
ORDER BY [System.ChangedDate] DESC
|
||||
"""
|
||||
|
||||
FIELDS = [
|
||||
"System.Id",
|
||||
"System.Title",
|
||||
"System.WorkItemType",
|
||||
"System.State",
|
||||
"System.AssignedTo",
|
||||
"System.IterationPath",
|
||||
"System.AreaPath",
|
||||
"System.ChangedDate",
|
||||
"System.TeamProject",
|
||||
]
|
||||
|
||||
|
||||
async def sync_user_work_items(user: User, db: AsyncSession) -> int:
|
||||
"""Sync ADO work items for a single user. Returns count of upserted items."""
|
||||
result = await db.execute(
|
||||
select(AzureIntegration).where(
|
||||
AzureIntegration.user_id == user.id,
|
||||
AzureIntegration.sync_enabled == True, # noqa: E712
|
||||
)
|
||||
)
|
||||
integ = result.scalar_one_or_none()
|
||||
if not integ:
|
||||
return 0
|
||||
|
||||
try:
|
||||
pat = decrypt(integ.pat_encrypted)
|
||||
client = ADOClient(org=integ.organization, project=integ.project, pat=pat)
|
||||
|
||||
refs = await client.query_work_items(WIQL)
|
||||
ids = [r["id"] for r in refs]
|
||||
|
||||
items_data: list[dict] = []
|
||||
for chunk in [ids[i : i + 200] for i in range(0, len(ids), 200)]:
|
||||
batch = await client.get_work_items_batch(chunk, fields=FIELDS)
|
||||
items_data.extend(batch)
|
||||
|
||||
seen_ado_ids: set[int] = set()
|
||||
for item in items_data:
|
||||
f = item.get("fields", {})
|
||||
ado_id = item["id"]
|
||||
seen_ado_ids.add(ado_id)
|
||||
assigned_raw = f.get("System.AssignedTo", {})
|
||||
assigned_email = (
|
||||
assigned_raw.get("uniqueName", "")
|
||||
if isinstance(assigned_raw, dict)
|
||||
else str(assigned_raw)
|
||||
)
|
||||
existing = await db.execute(
|
||||
select(AzureWorkItem).where(
|
||||
AzureWorkItem.user_id == user.id,
|
||||
AzureWorkItem.ado_id == ado_id,
|
||||
)
|
||||
)
|
||||
wi = existing.scalar_one_or_none()
|
||||
if wi is None:
|
||||
wi = AzureWorkItem(user_id=user.id, ado_id=ado_id)
|
||||
db.add(wi)
|
||||
wi.title = f.get("System.Title", "")
|
||||
wi.type = f.get("System.WorkItemType", "")
|
||||
wi.state = f.get("System.State", "")
|
||||
wi.assigned_to_email = assigned_email
|
||||
wi.iteration_path = f.get("System.IterationPath", "")
|
||||
wi.area_path = f.get("System.AreaPath", "")
|
||||
wi.url = f"https://dev.azure.com/{integ.organization}/{integ.project}/_workitems/edit/{ado_id}"
|
||||
wi.fields_json = f
|
||||
wi.synced_at = datetime.now(timezone.utc)
|
||||
|
||||
# Soft-archive items no longer in ADO result set
|
||||
stale = await db.execute(
|
||||
select(AzureWorkItem).where(
|
||||
AzureWorkItem.user_id == user.id,
|
||||
AzureWorkItem.state.notin_(["Closed", "Done", "Removed"]),
|
||||
)
|
||||
)
|
||||
for wi in stale.scalars():
|
||||
if wi.ado_id not in seen_ado_ids:
|
||||
wi.state = "Closed"
|
||||
|
||||
integ.last_synced_at = datetime.now(timezone.utc)
|
||||
integ.last_sync_error = ""
|
||||
await db.commit()
|
||||
return len(items_data)
|
||||
|
||||
except Exception as exc:
|
||||
await db.rollback()
|
||||
integ.last_sync_error = str(exc)[:500]
|
||||
await db.commit()
|
||||
log.error("ado.sync.failed", extra={"user_id": user.id, "error": str(exc)})
|
||||
return 0
|
||||
|
||||
|
||||
async def sync_all_users(db: AsyncSession) -> None:
|
||||
"""Called by scheduler — syncs all users with active integrations."""
|
||||
result = await db.execute(select(User).where(User.is_active == True)) # noqa: E712
|
||||
for user in result.scalars():
|
||||
await sync_user_work_items(user, db)
|
||||
55
src/services/budgets.py
Normal file
55
src/services/budgets.py
Normal file
|
|
@ -0,0 +1,55 @@
|
|||
"""Budget progress calculations."""
|
||||
import calendar
|
||||
from datetime import date, timedelta
|
||||
|
||||
from sqlalchemy import func, select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from src.models import DailyStat, ProjectBudget, User
|
||||
|
||||
|
||||
async def get_budgets_with_progress(user: User, db: AsyncSession) -> list[dict]:
|
||||
result = await db.execute(
|
||||
select(ProjectBudget)
|
||||
.where(ProjectBudget.user_id == user.id)
|
||||
.order_by(ProjectBudget.period, ProjectBudget.starts_on.desc())
|
||||
)
|
||||
budgets = result.scalars().all()
|
||||
|
||||
output = []
|
||||
today = date.today()
|
||||
for b in budgets:
|
||||
if b.period == "week":
|
||||
days_since_monday = today.weekday()
|
||||
period_start = today - timedelta(days=days_since_monday)
|
||||
period_end = period_start + timedelta(days=6)
|
||||
else:
|
||||
period_start = today.replace(day=1)
|
||||
last_day = calendar.monthrange(today.year, today.month)[1]
|
||||
period_end = today.replace(day=last_day)
|
||||
|
||||
hours_result = await db.execute(
|
||||
select(func.sum(DailyStat.total_hours)).where(
|
||||
DailyStat.user_id == user.id,
|
||||
DailyStat.project_id == b.project_id,
|
||||
DailyStat.date >= period_start,
|
||||
DailyStat.date <= period_end,
|
||||
)
|
||||
)
|
||||
actual = float(hours_result.scalar() or 0)
|
||||
output.append(
|
||||
{
|
||||
"id": b.id,
|
||||
"project_id": b.project_id,
|
||||
"period": b.period,
|
||||
"target_hours": b.target_hours,
|
||||
"actual_hours": round(actual, 2),
|
||||
"progress_pct": (
|
||||
round(min(100, actual / b.target_hours * 100), 1)
|
||||
if b.target_hours > 0
|
||||
else 0
|
||||
),
|
||||
"starts_on": b.starts_on.isoformat(),
|
||||
}
|
||||
)
|
||||
return output
|
||||
19
src/services/crypto.py
Normal file
19
src/services/crypto.py
Normal file
|
|
@ -0,0 +1,19 @@
|
|||
import base64
|
||||
import hashlib
|
||||
|
||||
from cryptography.fernet import Fernet
|
||||
|
||||
from src.config import settings
|
||||
|
||||
|
||||
def _fernet() -> Fernet:
|
||||
key = base64.urlsafe_b64encode(hashlib.sha256(settings.SECRET_KEY.encode()).digest())
|
||||
return Fernet(key)
|
||||
|
||||
|
||||
def encrypt(value: str) -> bytes:
|
||||
return _fernet().encrypt(value.encode())
|
||||
|
||||
|
||||
def decrypt(blob: bytes) -> str:
|
||||
return _fernet().decrypt(blob).decode()
|
||||
117
src/services/exports.py
Normal file
117
src/services/exports.py
Normal file
|
|
@ -0,0 +1,117 @@
|
|||
"""Timesheet export (CSV and ICS)."""
|
||||
import csv
|
||||
import io
|
||||
from datetime import date, datetime, timezone
|
||||
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from src.models import ManualEntry, Project, Session, User
|
||||
|
||||
|
||||
async def export_csv(
|
||||
user: User, from_date: date, to_date: date, db: AsyncSession
|
||||
) -> str:
|
||||
"""Returns CSV string with all sessions and manual entries in the date range."""
|
||||
rows: list[dict] = []
|
||||
|
||||
# Sessions
|
||||
sessions_result = await db.execute(
|
||||
select(Session, Project.display_name, Project.job_number, Project.client)
|
||||
.join(Project, Session.project_id == Project.id)
|
||||
.where(
|
||||
Session.user_id == user.id,
|
||||
Session.date >= from_date,
|
||||
Session.date <= to_date,
|
||||
)
|
||||
.order_by(Session.start_at)
|
||||
)
|
||||
for s, display_name, job_number, client in sessions_result:
|
||||
rows.append(
|
||||
{
|
||||
"date": s.date.isoformat(),
|
||||
"start": s.start_at.strftime("%H:%M"),
|
||||
"end": s.end_at.strftime("%H:%M"),
|
||||
"hours": round(s.active_hours, 2),
|
||||
"project": display_name,
|
||||
"omg_job": job_number,
|
||||
"client": client,
|
||||
"description": s.work_summary or "Claude Code session",
|
||||
"type": "session",
|
||||
}
|
||||
)
|
||||
|
||||
# Manual entries
|
||||
manual_result = await db.execute(
|
||||
select(ManualEntry, Project.display_name, Project.job_number, Project.client)
|
||||
.outerjoin(Project, ManualEntry.project_id == Project.id)
|
||||
.where(
|
||||
ManualEntry.user_id == user.id,
|
||||
ManualEntry.start_at
|
||||
>= datetime.combine(from_date, datetime.min.time()).replace(
|
||||
tzinfo=timezone.utc
|
||||
),
|
||||
ManualEntry.start_at
|
||||
<= datetime.combine(to_date, datetime.max.time()).replace(
|
||||
tzinfo=timezone.utc
|
||||
),
|
||||
)
|
||||
.order_by(ManualEntry.start_at)
|
||||
)
|
||||
for m, display_name, job_number, client in manual_result:
|
||||
hours = round((m.end_at - m.start_at).total_seconds() / 3600, 2)
|
||||
rows.append(
|
||||
{
|
||||
"date": m.start_at.date().isoformat(),
|
||||
"start": m.start_at.strftime("%H:%M"),
|
||||
"end": m.end_at.strftime("%H:%M"),
|
||||
"hours": hours,
|
||||
"project": display_name or "",
|
||||
"omg_job": job_number or "",
|
||||
"client": client or "",
|
||||
"description": m.title,
|
||||
"type": "manual",
|
||||
}
|
||||
)
|
||||
|
||||
rows.sort(key=lambda r: (r["date"], r["start"]))
|
||||
|
||||
output = io.StringIO()
|
||||
if rows:
|
||||
writer = csv.DictWriter(output, fieldnames=list(rows[0].keys()))
|
||||
writer.writeheader()
|
||||
writer.writerows(rows)
|
||||
return output.getvalue()
|
||||
|
||||
|
||||
async def export_ics(
|
||||
user: User, from_date: date, to_date: date, db: AsyncSession
|
||||
) -> bytes:
|
||||
"""Returns ICS bytes for all sessions in the date range."""
|
||||
from icalendar import Calendar, Event
|
||||
|
||||
cal = Calendar()
|
||||
cal.add("prodid", "-//CC Dashboard//EN")
|
||||
cal.add("version", "2.0")
|
||||
cal.add("x-wr-calname", "CC Dashboard")
|
||||
|
||||
sessions_result = await db.execute(
|
||||
select(Session, Project.display_name, Project.job_number)
|
||||
.join(Project, Session.project_id == Project.id)
|
||||
.where(
|
||||
Session.user_id == user.id,
|
||||
Session.date >= from_date,
|
||||
Session.date <= to_date,
|
||||
)
|
||||
)
|
||||
for s, display_name, job_number in sessions_result:
|
||||
event = Event()
|
||||
label = f"{job_number} — {display_name}" if job_number else display_name
|
||||
event.add("summary", label)
|
||||
event.add("dtstart", s.start_at)
|
||||
event.add("dtend", s.end_at)
|
||||
event.add("description", s.work_summary or "")
|
||||
event.add("uid", f"{s.id}@cc-dashboard")
|
||||
cal.add_component(event)
|
||||
|
||||
return cal.to_ical()
|
||||
26
src/services/mailgun.py
Normal file
26
src/services/mailgun.py
Normal file
|
|
@ -0,0 +1,26 @@
|
|||
import httpx
|
||||
|
||||
from src.config import settings
|
||||
|
||||
|
||||
async def send_email(to: str, subject: str, html: str, text: str = "") -> bool:
|
||||
"""Send email via Mailgun. Returns True on success."""
|
||||
if not settings.MAILGUN_API_KEY or not settings.MAILGUN_DOMAIN:
|
||||
return False
|
||||
url = f"https://api.mailgun.net/v3/{settings.MAILGUN_DOMAIN}/messages"
|
||||
data: dict = {
|
||||
"from": settings.MAILGUN_FROM,
|
||||
"to": to,
|
||||
"subject": subject,
|
||||
"html": html,
|
||||
}
|
||||
if text:
|
||||
data["text"] = text
|
||||
async with httpx.AsyncClient() as client:
|
||||
resp = await client.post(
|
||||
url,
|
||||
auth=("api", settings.MAILGUN_API_KEY),
|
||||
data=data,
|
||||
timeout=30,
|
||||
)
|
||||
return resp.status_code == 200
|
||||
66
src/services/scheduler.py
Normal file
66
src/services/scheduler.py
Normal file
|
|
@ -0,0 +1,66 @@
|
|||
"""APScheduler embedded in FastAPI lifespan."""
|
||||
import logging
|
||||
|
||||
from apscheduler.schedulers.asyncio import AsyncIOScheduler
|
||||
from apscheduler.triggers.cron import CronTrigger
|
||||
|
||||
from src.config import settings
|
||||
|
||||
log = logging.getLogger(__name__)
|
||||
scheduler = AsyncIOScheduler()
|
||||
|
||||
|
||||
async def _ado_sync_job() -> None:
|
||||
from src.database import AsyncSessionLocal
|
||||
from src.services.azure_devops.sync import sync_all_users
|
||||
|
||||
async with AsyncSessionLocal() as db:
|
||||
await sync_all_users(db)
|
||||
log.info("ado.sync.completed")
|
||||
|
||||
|
||||
async def _daily_report_job() -> None:
|
||||
from src.database import AsyncSessionLocal
|
||||
from src.services.ai_reports import run_daily_reports_all_users
|
||||
|
||||
async with AsyncSessionLocal() as db:
|
||||
await run_daily_reports_all_users(db)
|
||||
log.info("daily_reports.completed")
|
||||
|
||||
|
||||
async def _weekly_report_job() -> None:
|
||||
from src.database import AsyncSessionLocal
|
||||
from src.services.ai_reports import run_weekly_reports_all_users
|
||||
|
||||
async with AsyncSessionLocal() as db:
|
||||
await run_weekly_reports_all_users(db)
|
||||
log.info("weekly_reports.completed")
|
||||
|
||||
|
||||
def setup_scheduler() -> None:
|
||||
if settings.ADO_PAT:
|
||||
scheduler.add_job(
|
||||
_ado_sync_job,
|
||||
"interval",
|
||||
minutes=settings.ADO_SYNC_INTERVAL_MINUTES,
|
||||
id="ado_pull",
|
||||
replace_existing=True,
|
||||
)
|
||||
|
||||
scheduler.add_job(
|
||||
_daily_report_job,
|
||||
CronTrigger(hour=settings.DAILY_REPORT_HOUR, minute=0),
|
||||
id="daily_report",
|
||||
replace_existing=True,
|
||||
)
|
||||
|
||||
scheduler.add_job(
|
||||
_weekly_report_job,
|
||||
CronTrigger(
|
||||
day_of_week=settings.WEEKLY_REPORT_DAY,
|
||||
hour=settings.WEEKLY_REPORT_HOUR,
|
||||
minute=0,
|
||||
),
|
||||
id="weekly_report",
|
||||
replace_existing=True,
|
||||
)
|
||||
0
src/tests/__init__.py
Normal file
0
src/tests/__init__.py
Normal file
119
src/tests/conftest.py
Normal file
119
src/tests/conftest.py
Normal file
|
|
@ -0,0 +1,119 @@
|
|||
"""Shared pytest fixtures."""
|
||||
import asyncio
|
||||
from datetime import date, datetime, timezone
|
||||
from typing import AsyncGenerator
|
||||
|
||||
import pytest
|
||||
import pytest_asyncio
|
||||
from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker, create_async_engine
|
||||
from sqlalchemy.pool import StaticPool
|
||||
|
||||
# Use SQLite for fast in-memory tests (JSONB fields not used in these tests)
|
||||
TEST_DATABASE_URL = "sqlite+aiosqlite:///:memory:"
|
||||
|
||||
|
||||
@pytest.fixture(scope="session")
|
||||
def event_loop():
|
||||
"""Session-scoped event loop to avoid re-creation per test."""
|
||||
loop = asyncio.new_event_loop()
|
||||
yield loop
|
||||
loop.close()
|
||||
|
||||
|
||||
@pytest_asyncio.fixture(scope="session")
|
||||
async def engine():
|
||||
"""Create in-memory SQLite engine with all tables."""
|
||||
# Override DATABASE_URL before importing models
|
||||
import os
|
||||
os.environ.setdefault("DATABASE_URL", TEST_DATABASE_URL)
|
||||
|
||||
from src.database import Base
|
||||
|
||||
# Import all models so their tables are registered in Base.metadata
|
||||
import src.models # noqa: F401
|
||||
|
||||
_engine = create_async_engine(
|
||||
TEST_DATABASE_URL,
|
||||
connect_args={"check_same_thread": False},
|
||||
poolclass=StaticPool,
|
||||
)
|
||||
async with _engine.begin() as conn:
|
||||
await conn.run_sync(Base.metadata.create_all)
|
||||
yield _engine
|
||||
await _engine.dispose()
|
||||
|
||||
|
||||
@pytest_asyncio.fixture
|
||||
async def db(engine) -> AsyncGenerator[AsyncSession, None]:
|
||||
"""Provide a transactional session that rolls back after each test."""
|
||||
factory = async_sessionmaker(engine, expire_on_commit=False)
|
||||
async with factory() as session:
|
||||
yield session
|
||||
await session.rollback()
|
||||
|
||||
|
||||
# ── Shared test data factories ─────────────────────────────────────────────────
|
||||
|
||||
def make_user(**kwargs):
|
||||
from src.models import User
|
||||
defaults = dict(
|
||||
email="test@example.com",
|
||||
username="tester",
|
||||
password_hash="hashed",
|
||||
role="user",
|
||||
is_active=True,
|
||||
daily_overhead_hours=0.5,
|
||||
)
|
||||
defaults.update(kwargs)
|
||||
return User(**defaults)
|
||||
|
||||
|
||||
def make_project(user_id: str, **kwargs):
|
||||
from src.models import Project
|
||||
defaults = dict(
|
||||
user_id=user_id,
|
||||
slug="test-project",
|
||||
display_name="Test Project",
|
||||
job_number="JOB-001",
|
||||
client="Acme",
|
||||
)
|
||||
defaults.update(kwargs)
|
||||
return Project(**defaults)
|
||||
|
||||
|
||||
def make_session(user_id: str, project_id: str, **kwargs):
|
||||
from src.models import Session as DbSession
|
||||
now = datetime.now(timezone.utc)
|
||||
defaults = dict(
|
||||
user_id=user_id,
|
||||
project_id=project_id,
|
||||
session_id="sess-abc123",
|
||||
date=date.today(),
|
||||
start_at=now.replace(hour=9, minute=0, second=0, microsecond=0),
|
||||
end_at=now.replace(hour=11, minute=0, second=0, microsecond=0),
|
||||
active_hours=2.0,
|
||||
message_count=50,
|
||||
work_summary="Implemented feature X",
|
||||
commits=[],
|
||||
tools_used={},
|
||||
files_changed=[],
|
||||
raw_stats={},
|
||||
)
|
||||
defaults.update(kwargs)
|
||||
return DbSession(**defaults)
|
||||
|
||||
|
||||
def make_task(user_id: str, **kwargs):
|
||||
from src.models import Task
|
||||
defaults = dict(
|
||||
user_id=user_id,
|
||||
title="Test Task",
|
||||
notes="",
|
||||
planned_date=date.today(),
|
||||
estimate_hours=2.0,
|
||||
status="todo",
|
||||
priority=3,
|
||||
sort_index=0,
|
||||
)
|
||||
defaults.update(kwargs)
|
||||
return Task(**defaults)
|
||||
150
src/tests/test_calendar.py
Normal file
150
src/tests/test_calendar.py
Normal file
|
|
@ -0,0 +1,150 @@
|
|||
"""Smoke tests for calendar utilities and manual entry model."""
|
||||
import pytest
|
||||
from datetime import date, datetime, timezone, timedelta
|
||||
|
||||
from src.tests.conftest import make_user, make_project, make_session, make_task
|
||||
|
||||
|
||||
def test_hue_for_project_deterministic():
|
||||
"""_hue_for_project returns same value for same input."""
|
||||
from src.routers.calendar import _hue_for_project
|
||||
|
||||
pid = "550e8400-e29b-41d4-a716-446655440000"
|
||||
assert _hue_for_project(pid) == _hue_for_project(pid)
|
||||
assert 0 <= _hue_for_project(pid) <= 360
|
||||
|
||||
|
||||
def test_hue_for_project_none():
|
||||
"""None project_id returns default hue 200."""
|
||||
from src.routers.calendar import _hue_for_project
|
||||
|
||||
assert _hue_for_project(None) == 200
|
||||
|
||||
|
||||
def test_hue_for_project_different_ids():
|
||||
"""Different project IDs produce different hues (with high probability)."""
|
||||
from src.routers.calendar import _hue_for_project
|
||||
|
||||
ids = [
|
||||
"aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa",
|
||||
"bbbbbbbb-bbbb-bbbb-bbbb-bbbbbbbbbbbb",
|
||||
"cccccccc-cccc-cccc-cccc-cccccccccccc",
|
||||
]
|
||||
hues = [_hue_for_project(pid) for pid in ids]
|
||||
# Not all the same
|
||||
assert len(set(hues)) > 1
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_manual_entry_create_and_duration(db):
|
||||
"""ManualEntry can be created and duration computed correctly."""
|
||||
user = make_user(email="cal1@example.com", username="cal1")
|
||||
db.add(user)
|
||||
await db.flush()
|
||||
|
||||
project = make_project(user_id=user.id)
|
||||
db.add(project)
|
||||
await db.flush()
|
||||
|
||||
from src.models import ManualEntry
|
||||
|
||||
start = datetime(2026, 5, 6, 9, 0, 0, tzinfo=timezone.utc)
|
||||
end = datetime(2026, 5, 6, 11, 30, 0, tzinfo=timezone.utc)
|
||||
|
||||
entry = ManualEntry(
|
||||
user_id=user.id,
|
||||
project_id=project.id,
|
||||
title="Design review",
|
||||
notes="Review mockups",
|
||||
start_at=start,
|
||||
end_at=end,
|
||||
source="manual",
|
||||
)
|
||||
db.add(entry)
|
||||
await db.flush()
|
||||
|
||||
duration_h = (entry.end_at - entry.start_at).total_seconds() / 3600
|
||||
assert duration_h == pytest.approx(2.5)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_planned_block_linked_to_task(db):
|
||||
"""PlannedBlock correctly links to task and inherits project."""
|
||||
user = make_user(email="cal2@example.com", username="cal2")
|
||||
db.add(user)
|
||||
await db.flush()
|
||||
|
||||
project = make_project(user_id=user.id, slug="proj2")
|
||||
db.add(project)
|
||||
await db.flush()
|
||||
|
||||
task = make_task(user_id=user.id, project_id=project.id, title="Plan sprint")
|
||||
db.add(task)
|
||||
await db.flush()
|
||||
|
||||
from src.models import PlannedBlock
|
||||
|
||||
start = datetime(2026, 5, 6, 14, 0, 0, tzinfo=timezone.utc)
|
||||
end = datetime(2026, 5, 6, 16, 0, 0, tzinfo=timezone.utc)
|
||||
|
||||
block = PlannedBlock(
|
||||
user_id=user.id,
|
||||
task_id=task.id,
|
||||
project_id=project.id,
|
||||
start_at=start,
|
||||
end_at=end,
|
||||
)
|
||||
db.add(block)
|
||||
await db.flush()
|
||||
|
||||
assert block.task_id == task.id
|
||||
assert block.project_id == project.id
|
||||
assert (block.end_at - block.start_at).total_seconds() == 7200
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_session_calendar_date_filter(db):
|
||||
"""Sessions are correctly filtered by date range."""
|
||||
user = make_user(email="cal3@example.com", username="cal3")
|
||||
db.add(user)
|
||||
await db.flush()
|
||||
|
||||
project = make_project(user_id=user.id, slug="proj3")
|
||||
db.add(project)
|
||||
await db.flush()
|
||||
|
||||
# Session on target date
|
||||
s1 = make_session(
|
||||
user_id=user.id,
|
||||
project_id=project.id,
|
||||
session_id="s1",
|
||||
date=date(2026, 5, 6),
|
||||
start_at=datetime(2026, 5, 6, 9, 0, tzinfo=timezone.utc),
|
||||
end_at=datetime(2026, 5, 6, 11, 0, tzinfo=timezone.utc),
|
||||
)
|
||||
# Session outside range
|
||||
s2 = make_session(
|
||||
user_id=user.id,
|
||||
project_id=project.id,
|
||||
session_id="s2",
|
||||
date=date(2026, 5, 10),
|
||||
start_at=datetime(2026, 5, 10, 9, 0, tzinfo=timezone.utc),
|
||||
end_at=datetime(2026, 5, 10, 11, 0, tzinfo=timezone.utc),
|
||||
)
|
||||
db.add(s1)
|
||||
db.add(s2)
|
||||
await db.flush()
|
||||
|
||||
from sqlalchemy import select
|
||||
from src.models import Session as DbSession
|
||||
|
||||
result = await db.execute(
|
||||
select(DbSession).where(
|
||||
DbSession.user_id == user.id,
|
||||
DbSession.date >= date(2026, 5, 6),
|
||||
DbSession.date <= date(2026, 5, 7),
|
||||
)
|
||||
)
|
||||
sessions = result.scalars().all()
|
||||
assert len(sessions) == 1
|
||||
assert sessions[0].session_id == "s1"
|
||||
130
src/tests/test_tasks.py
Normal file
130
src/tests/test_tasks.py
Normal file
|
|
@ -0,0 +1,130 @@
|
|||
"""Smoke tests for task model + CRUD helpers."""
|
||||
import pytest
|
||||
from datetime import date, datetime, timezone
|
||||
|
||||
from src.tests.conftest import make_user, make_project, make_task
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_create_and_fetch_task(db):
|
||||
"""Tasks can be created and retrieved by user_id."""
|
||||
user = make_user()
|
||||
db.add(user)
|
||||
await db.flush()
|
||||
|
||||
task = make_task(user_id=user.id, title="Write tests", status="todo")
|
||||
db.add(task)
|
||||
await db.flush()
|
||||
|
||||
from sqlalchemy import select
|
||||
from src.models import Task
|
||||
|
||||
result = await db.execute(
|
||||
select(Task).where(Task.user_id == user.id)
|
||||
)
|
||||
tasks = result.scalars().all()
|
||||
assert len(tasks) == 1
|
||||
assert tasks[0].title == "Write tests"
|
||||
assert tasks[0].status == "todo"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_task_status_transition(db):
|
||||
"""Task status can transition todo -> doing -> done."""
|
||||
user = make_user(email="u2@example.com", username="u2")
|
||||
db.add(user)
|
||||
await db.flush()
|
||||
|
||||
task = make_task(user_id=user.id, title="Deploy feature", status="todo")
|
||||
db.add(task)
|
||||
await db.flush()
|
||||
|
||||
# Transition to doing
|
||||
task.status = "doing"
|
||||
await db.flush()
|
||||
assert task.status == "doing"
|
||||
|
||||
# Transition to done
|
||||
task.status = "done"
|
||||
task.completed_at = datetime.now(timezone.utc)
|
||||
await db.flush()
|
||||
assert task.status == "done"
|
||||
assert task.completed_at is not None
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_task_with_project(db):
|
||||
"""Task can be linked to a project."""
|
||||
user = make_user(email="u3@example.com", username="u3")
|
||||
db.add(user)
|
||||
await db.flush()
|
||||
|
||||
project = make_project(user_id=user.id)
|
||||
db.add(project)
|
||||
await db.flush()
|
||||
|
||||
task = make_task(user_id=user.id, project_id=project.id, title="Project task")
|
||||
db.add(task)
|
||||
await db.flush()
|
||||
|
||||
assert task.project_id == project.id
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_task_delete_cascades_planned_blocks(db):
|
||||
"""Deleting a task cascades to its planned_blocks."""
|
||||
user = make_user(email="u4@example.com", username="u4")
|
||||
db.add(user)
|
||||
await db.flush()
|
||||
|
||||
task = make_task(user_id=user.id)
|
||||
db.add(task)
|
||||
await db.flush()
|
||||
|
||||
from src.models import PlannedBlock
|
||||
from datetime import timedelta
|
||||
|
||||
now = datetime.now(timezone.utc)
|
||||
block = PlannedBlock(
|
||||
user_id=user.id,
|
||||
task_id=task.id,
|
||||
start_at=now,
|
||||
end_at=now + timedelta(hours=1),
|
||||
)
|
||||
db.add(block)
|
||||
await db.flush()
|
||||
|
||||
await db.delete(task)
|
||||
await db.flush()
|
||||
|
||||
from sqlalchemy import select
|
||||
result = await db.execute(
|
||||
select(PlannedBlock).where(PlannedBlock.task_id == task.id)
|
||||
)
|
||||
assert result.scalars().all() == []
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_task_priority_range(db):
|
||||
"""Task priority can be set from 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]
|
||||
Loading…
Add table
Reference in a new issue