- 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>
407 lines
11 KiB
Python
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
|