cc-dashboard/src/schemas.py
Vadym Samoilenko 9d9e8e82d4 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>
2026-05-06 18:44:26 +01:00

407 lines
11 KiB
Python

from datetime import date, datetime
from typing import Any
from pydantic import BaseModel, EmailStr, Field
# ── Auth ──────────────────────────────────────────────────────────────────────
class LoginRequest(BaseModel):
email: EmailStr
password: str
class TokenResponse(BaseModel):
access_token: str
refresh_token: str
token_type: str = "bearer"
class RefreshRequest(BaseModel):
refresh_token: str
class ChangePasswordRequest(BaseModel):
current_password: str
new_password: str = Field(min_length=8)
# ── Users ─────────────────────────────────────────────────────────────────────
class UserOut(BaseModel):
id: str
email: str
username: str
role: str
is_active: bool
daily_overhead_hours: float
created_at: datetime
model_config = {"from_attributes": True}
class UserCreate(BaseModel):
email: EmailStr
username: str = Field(min_length=2, max_length=100)
password: str = Field(min_length=8)
role: str = Field(default="user", pattern="^(admin|user)$")
class UserUpdate(BaseModel):
username: str | None = None
role: str | None = Field(default=None, pattern="^(admin|user)$")
is_active: bool | None = None
daily_overhead_hours: float | None = Field(default=None, ge=0, le=12)
# ── API Keys ──────────────────────────────────────────────────────────────────
class ApiKeyOut(BaseModel):
id: str
label: str
key_prefix: str
is_active: bool
last_used_at: datetime | None
created_at: datetime
model_config = {"from_attributes": True}
class ApiKeyCreate(BaseModel):
label: str = Field(default="My Machine", max_length=100)
class ApiKeyCreated(ApiKeyOut):
raw_key: str # shown once
# ── Ingestion ─────────────────────────────────────────────────────────────────
class SessionPayload(BaseModel):
session_id: str
project_slug: str
date: date
start_at: datetime
end_at: datetime
message_count: int = 0
active_hours: float = 0.0
work_summary: str = ""
commits: list[str] = []
tools_used: dict[str, int] = {}
files_changed: list[str] = []
repo_url: str = ""
raw_stats: dict[str, Any] = {}
class IngestPayload(BaseModel):
root_path: str = ""
sessions: list[SessionPayload]
class IngestResponse(BaseModel):
accepted: int
skipped: int
# ── Dashboard ─────────────────────────────────────────────────────────────────
class KpiSummary(BaseModel):
total_hours: float
total_projects: int
working_days: int
total_sessions: int
avg_hours_per_day: float
top_project: str
total_commits: int
total_files_changed: int
period_from: date | None
period_to: date | None
class ProjectHours(BaseModel):
project_id: str
display_name: str
total_hours: float
session_count: int
working_days: int
last_active: date | None
client: str = ""
job_number: str = ""
repo_url: str = ""
class DailyPoint(BaseModel):
date: date
hours: float
sessions: int
class MonthlyPoint(BaseModel):
month: str # "2026-03"
hours: float
class DowPoint(BaseModel):
dow: int # 0=Mon … 6=Sun
label: str
hours: float
class ToolUsage(BaseModel):
tool: str
count: int
class SessionOut(BaseModel):
id: str
session_id: str
project_id: str
project_name: str
date: date
start_at: datetime
end_at: datetime
active_hours: float
message_count: int
work_summary: str
commits: list[str]
tools_used: dict[str, int]
files_changed: list[str]
model_config = {"from_attributes": True}
class ProjectDetail(BaseModel):
project: "ProjectOut"
daily: list[DailyPoint]
sessions: list[SessionOut]
top_files: list[dict]
top_tools: list[ToolUsage]
class ProjectOut(BaseModel):
id: str
slug: str
display_name: str
root_path: str
client: str = ""
job_number: str = ""
repo_url: str = ""
created_at: datetime
model_config = {"from_attributes": True}
# ── Admin ─────────────────────────────────────────────────────────────────────
class AdminStats(BaseModel):
total_users: int
active_users: int
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