cc-dashboard/src/models.py
Vadym Samoilenko 36118cb759 feat: replace Planka with in-app Kanban + add OMG page
- Remove Planka: docker-compose services, apache /board/ proxy, env vars, custom CSS dir
- Add Kanban board at /tasks: 4 columns (To Do / Doing / Testing / Done),
  native HTML5 drag-and-drop, card modal (TaskForm reuse), per-column "+" button
- Add 'testing' status to Task model validator and frontend union type
- Add GET /api/tasks/{id} endpoint (was missing, frontend already called it)
- Enrich DevOps clone: live-fetches description, AC, assignee, iteration,
  comments and attachments from ADO; renders as Markdown in task.notes
- Add /omg page: standalone project/client/job# registry with inline editing
  and create/edit/delete dialog; backed by new omg_entries table (migration 0008)
- Add omg router to main.py; add OMG + Tasks to sidebar and router
- Fix dead /planner link on Dashboard -> /tasks

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-07 14:09:36 +01:00

337 lines
19 KiB
Python

import uuid
from datetime import date, datetime
import sqlalchemy.types as sa_types
from sqlalchemy import (
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
from sqlalchemy.sql import func
from src.database import Base
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"
id: Mapped[str] = mapped_column(UUID(as_uuid=False), primary_key=True, default=new_uuid)
email: Mapped[str] = mapped_column(String(255), unique=True, nullable=False, index=True)
username: Mapped[str] = mapped_column(String(100), unique=True, nullable=False)
password_hash: Mapped[str | None] = mapped_column(String(255), nullable=True)
azure_oid: Mapped[str | None] = mapped_column(String(36), nullable=True, unique=True, index=True)
role: Mapped[str] = mapped_column(Enum("admin", "user", name="user_role"), default="user", nullable=False)
is_active: Mapped[bool] = mapped_column(Boolean, default=True, nullable=False)
daily_overhead_hours: Mapped[float] = mapped_column(Float, default=0.0, nullable=False)
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now())
api_keys: Mapped[list["ApiKey"]] = relationship(back_populates="user", cascade="all, delete-orphan")
projects: Mapped[list["Project"]] = relationship(back_populates="user", cascade="all, delete-orphan")
sessions: Mapped[list["Session"]] = relationship(back_populates="user", cascade="all, delete-orphan")
class ApiKey(Base):
__tablename__ = "api_keys"
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)
key_hash: Mapped[str] = mapped_column(String(255), nullable=False)
key_prefix: Mapped[str] = mapped_column(String(12), nullable=False) # "cc_XXXXXXXX" for display
label: Mapped[str] = mapped_column(String(100), default="My Machine")
is_active: Mapped[bool] = mapped_column(Boolean, default=True, nullable=False)
last_used_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(back_populates="api_keys")
class Project(Base):
__tablename__ = "projects"
__table_args__ = (UniqueConstraint("user_id", "slug", name="uq_project_user_slug"),)
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)
slug: Mapped[str] = mapped_column(String(255), nullable=False)
display_name: Mapped[str] = mapped_column(String(255), nullable=False)
root_path: Mapped[str] = mapped_column(String(500), default="")
client: Mapped[str] = mapped_column(String(255), default="")
job_number: Mapped[str] = mapped_column(String(100), default="")
repo_url: Mapped[str] = mapped_column(String(500), default="")
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now())
user: Mapped["User"] = relationship(back_populates="projects")
sessions: Mapped[list["Session"]] = relationship(back_populates="project", cascade="all, delete-orphan")
daily_stats: Mapped[list["DailyStat"]] = relationship(back_populates="project", cascade="all, delete-orphan")
class Session(Base):
__tablename__ = "sessions"
__table_args__ = (UniqueConstraint("user_id", "session_id", "date", name="uq_session_user_date"),)
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)
session_id: Mapped[str] = mapped_column(String(100), nullable=False)
date: Mapped[date] = mapped_column(Date, nullable=False, index=True)
start_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), nullable=False)
end_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), nullable=False)
active_hours: Mapped[float] = mapped_column(Float, default=0.0)
message_count: Mapped[int] = mapped_column(Integer, default=0)
work_summary: Mapped[str] = mapped_column(Text, default="")
commits: Mapped[list] = mapped_column(JSONB, default=list)
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)
category: Mapped[str | None] = mapped_column(String(20), nullable=True)
ai_title: Mapped[str | None] = mapped_column(String(200), nullable=True)
ai_result: Mapped[str | None] = mapped_column(Text, nullable=True)
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now())
user: Mapped["User"] = relationship(back_populates="sessions")
project: Mapped["Project"] = relationship(back_populates="sessions")
class DailyStat(Base):
__tablename__ = "daily_stats"
__table_args__ = (UniqueConstraint("user_id", "project_id", "date", name="uq_daily_stat"),)
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)
date: Mapped[date] = mapped_column(Date, nullable=False, index=True)
total_hours: Mapped[float] = mapped_column(Float, default=0.0)
session_count: Mapped[int] = mapped_column(Integer, default=0)
message_count: Mapped[int] = mapped_column(Integer, default=0)
commit_count: Mapped[int] = mapped_column(Integer, default=0)
files_changed_count: Mapped[int] = mapped_column(Integer, default=0)
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")
category: Mapped[str | None] = mapped_column(String(20), nullable=True)
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())
@property
def team_project(self) -> str:
return (self.fields_json or {}).get("System.TeamProject", "")
@property
def priority(self) -> int:
try:
return int((self.fields_json or {}).get("Microsoft.VSTS.Common.Priority", 3))
except (TypeError, ValueError):
return 3
@property
def created_date(self) -> str:
return (self.fields_json or {}).get("System.CreatedDate", "")
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)
class AiFlag(Base):
"""Anomaly/suggestion surfaced by the AI assistant."""
__tablename__ = "ai_flags"
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)
flag_date: Mapped[date] = mapped_column(Date, nullable=False)
kind: Mapped[str] = mapped_column(String(30), nullable=False) # gap|long_session|uncategorized|missing_overhead
description: Mapped[str] = mapped_column(Text, default="")
entity_type: Mapped[str | None] = mapped_column(String(20), nullable=True) # session|manual_entry|day
entity_id: Mapped[str | None] = mapped_column(String(36), nullable=True)
resolved: Mapped[bool] = mapped_column(Boolean, default=False, nullable=False)
resolved_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])
class OmgEntry(Base):
__tablename__ = "omg_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)
name: Mapped[str] = mapped_column(String(255), nullable=False)
client: Mapped[str] = mapped_column(String(255), default="")
job_number: Mapped[str] = mapped_column(String(100), default="")
notes: Mapped[str] = mapped_column(Text, default="")
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now())
updated_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now(), onupdate=func.now())
user: Mapped["User"] = relationship(foreign_keys=[user_id])
class AssistantMessage(Base):
"""Persisted chat history for the AI assistant per user."""
__tablename__ = "assistant_messages"
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)
role: Mapped[str] = mapped_column(String(10), nullable=False) # user|assistant
content: Mapped[str] = mapped_column(Text, nullable=False)
tool_calls: Mapped[dict | None] = mapped_column(JSONB, nullable=True)
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now())
user: Mapped["User"] = relationship(foreign_keys=[user_id])