- 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>
337 lines
19 KiB
Python
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])
|