Compare commits

...

23 Commits

Author SHA1 Message Date
c3199d0cd0 fix: Essential model uses created_by_id not user_id
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-16 23:17:32 +01:00
d3f72962c0 fix: correct ActivityLog import name in user deletion
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-16 23:15:45 +01:00
4643a73c60 feat: add deleted-user builtin and safe user deletion
- Add deleted-user as a built-in account (no permissions, cannot log in)
  created during init_wizard, protected from deletion like acc-mgr
- On user delete, reassign all foreign key references to deleted-user
  then delete the original user, instead of failing on IntegrityError
- API keys, notifications, and project memberships are deleted outright
  since they're meaningless without the real user

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-16 23:08:19 +01:00
h z
eae947d9b6 Merge pull request 'feat(Dockerfile): multi-stage build to reduce image size from 852MB to ~200MB' (#15) from multi-stage into main
Reviewed-on: #15
2026-04-16 21:23:04 +00:00
h z
a2f626557e Merge branch 'main' into multi-stage 2026-04-16 21:22:54 +00:00
h z
c5827db872 Merge pull request 'dev-2026-03-29' (#14) from dev-2026-03-29 into main
Reviewed-on: #14
2026-04-16 21:22:03 +00:00
7326cadfec feat: grant user.reset-apikey permission to account-manager role
Allows acc-mgr to reset user API keys, enabling automated
provisioning workflows via the CLI.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-16 21:19:13 +00:00
1b10c97099 feat: allow API key auth for reset-apikey endpoint
Change dependency from get_current_user (OAuth2 only) to
get_current_user_or_apikey, enabling account-manager API key
to reset user API keys for provisioning workflows.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-16 21:17:13 +00:00
8434a5d226 feat(Dockerfile): multi-stage build to reduce image size from 852MB to ~200MB
Stage 1 (builder): install build deps and pre-download wheels
Stage 2 (runtime): copy only installed packages + runtime deps, no build tools
2026-04-15 01:27:44 +00:00
h z
a2ab541b73 Merge pull request 'HarborForge.Backend: dev-2026-03-29 -> main' (#13) from dev-2026-03-29 into main
Reviewed-on: #13
2026-04-05 22:08:14 +00:00
755c418391 feat: auto-trigger Discord wakeup when slot becomes ONGOING 2026-04-05 09:37:14 +00:00
57681c674f feat: add discord wakeup test endpoint 2026-04-04 21:03:48 +00:00
79c6c32a78 feat: store discord user ids on accounts 2026-04-04 20:16:22 +00:00
5e98d1c8f2 feat: accept post heartbeats for calendar agents 2026-04-04 17:58:57 +00:00
5a2b64df70 fix: use model slot types for agent status updates 2026-04-04 16:49:52 +00:00
578493edc1 feat: expose calendar agent heartbeat api 2026-04-04 16:46:04 +00:00
41bebc862b fix: enforce calendar role permissions 2026-04-04 14:35:42 +00:00
e9529e3cb0 feat: add calendar role permissions 2026-04-04 11:59:21 +00:00
848f5d7596 refactor: replace monitor heartbeat-v2 with heartbeat 2026-04-04 08:05:48 +00:00
0448cde765 fix: make code index migration mysql-compatible 2026-04-03 19:00:45 +00:00
ae353afbed feat: switch backend indexing to code-first identifiers 2026-04-03 16:25:11 +00:00
58d3ca6ad0 fix: allow api key auth for account creation 2026-04-03 13:45:36 +00:00
zhi
f5bf480c76 TEST-BE-CAL-001 add calendar backend model and API tests 2026-04-01 10:35:43 +00:00
22 changed files with 2145 additions and 455 deletions

View File

@@ -1,25 +1,46 @@
FROM python:3.11-slim # Stage 1: build dependencies
FROM python:3.11-slim AS builder
WORKDIR /app WORKDIR /app
# Install system dependencies # Install build dependencies
RUN apt-get update && apt-get install -y \ RUN apt-get update && apt-get install -y \
build-essential \ build-essential \
curl \
default-libmysqlclient-dev \ default-libmysqlclient-dev \
pkg-config \ pkg-config \
&& rm -rf /var/lib/apt/lists/* && rm -rf /var/lib/apt/lists/*
# Pre-download wheels to avoid recompiling bcrypt from source
RUN pip install --no-cache-dir --prefix=/install \
'bcrypt==4.0.1' \
'cffi>=2.0' \
'pycparser>=2.0'
# Install Python dependencies # Install Python dependencies
COPY requirements.txt . COPY requirements.txt .
RUN pip install --no-cache-dir -r requirements.txt RUN pip install --no-cache-dir --prefix=/install -r requirements.txt
# Stage 2: slim runtime
FROM python:3.11-slim
WORKDIR /app
# Install runtime dependencies only (no build tools)
RUN apt-get update && apt-get install -y \
default-libmysqlclient-dev \
curl \
&& rm -rf /var/lib/apt/lists/*
# Copy installed packages from builder
COPY --from=builder /install /usr/local
# Copy application code # Copy application code
COPY . . COPY app/ ./app/
COPY requirements.txt ./
# Make entrypoint
COPY entrypoint.sh .
RUN chmod +x entrypoint.sh RUN chmod +x entrypoint.sh
# Expose port
EXPOSE 8000 EXPOSE 8000
# Wait for wizard config, then start uvicorn
ENTRYPOINT ["./entrypoint.sh"] ENTRYPOINT ["./entrypoint.sh"]

View File

@@ -10,17 +10,20 @@ BE-CAL-API-006: Plan edit / plan cancel endpoints.
BE-CAL-API-007: Date-list endpoint. BE-CAL-API-007: Date-list endpoint.
""" """
from datetime import date as date_type from datetime import date as date_type, datetime, timezone
from typing import Optional from typing import Optional
from fastapi import APIRouter, Depends, HTTPException, Query from fastapi import APIRouter, Depends, Header, HTTPException, Query
from sqlalchemy.orm import Session from sqlalchemy.orm import Session
from app.api.deps import get_current_user from app.api.deps import get_current_user
from app.core.config import get_db from app.core.config import get_db
from app.models.calendar import SchedulePlan, SlotStatus, TimeSlot from app.models.calendar import SchedulePlan, SlotStatus, SlotType, TimeSlot
from app.models.models import User from app.models.models import User
from app.models.agent import Agent, AgentStatus, ExhaustReason
from app.schemas.calendar import ( from app.schemas.calendar import (
AgentHeartbeatResponse,
AgentStatusUpdateRequest,
CalendarDayResponse, CalendarDayResponse,
CalendarSlotItem, CalendarSlotItem,
DateListResponse, DateListResponse,
@@ -32,7 +35,9 @@ from app.schemas.calendar import (
SchedulePlanEdit, SchedulePlanEdit,
SchedulePlanListResponse, SchedulePlanListResponse,
SchedulePlanResponse, SchedulePlanResponse,
SlotStatusEnum,
SlotConflictItem, SlotConflictItem,
SlotAgentUpdate,
TimeSlotCancelResponse, TimeSlotCancelResponse,
TimeSlotCreate, TimeSlotCreate,
TimeSlotCreateResponse, TimeSlotCreateResponse,
@@ -40,6 +45,15 @@ from app.schemas.calendar import (
TimeSlotEditResponse, TimeSlotEditResponse,
TimeSlotResponse, TimeSlotResponse,
) )
from app.services.agent_heartbeat import get_pending_slots_for_agent
from app.services.agent_status import (
record_heartbeat,
transition_to_busy,
transition_to_idle,
transition_to_offline,
transition_to_exhausted,
)
from app.services.discord_wakeup import create_private_wakeup_channel
from app.services.minimum_workload import ( from app.services.minimum_workload import (
get_workload_config, get_workload_config,
get_workload_warnings_for_date, get_workload_warnings_for_date,
@@ -62,10 +76,52 @@ from app.services.slot_immutability import (
guard_plan_cancel_no_past_retroaction, guard_plan_cancel_no_past_retroaction,
guard_plan_edit_no_past_retroaction, guard_plan_edit_no_past_retroaction,
) )
from app.models.role_permission import Permission, RolePermission
router = APIRouter(prefix="/calendar", tags=["Calendar"]) router = APIRouter(prefix="/calendar", tags=["Calendar"])
def _has_global_permission(db: Session, user: User, permission_name: str) -> bool:
if user.is_admin:
return True
if not user.role_id:
return False
perm = db.query(Permission).filter(Permission.name == permission_name).first()
if not perm:
return False
return db.query(RolePermission).filter(
RolePermission.role_id == user.role_id,
RolePermission.permission_id == perm.id,
).first() is not None
def _require_calendar_permission(db: Session, user: User, permission_name: str) -> User:
if _has_global_permission(db, user, permission_name):
return user
raise HTTPException(status_code=403, detail=f"Calendar permission '{permission_name}' required")
def require_calendar_read(
db: Session = Depends(get_db),
current_user: User = Depends(get_current_user),
):
return _require_calendar_permission(db, current_user, "calendar.read")
def require_calendar_write(
db: Session = Depends(get_db),
current_user: User = Depends(get_current_user),
):
return _require_calendar_permission(db, current_user, "calendar.write")
def require_calendar_manage(
db: Session = Depends(get_db),
current_user: User = Depends(get_current_user),
):
return _require_calendar_permission(db, current_user, "calendar.manage")
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
# TimeSlot creation (BE-CAL-API-001) # TimeSlot creation (BE-CAL-API-001)
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
@@ -101,7 +157,7 @@ def _slot_to_response(slot: TimeSlot) -> TimeSlotResponse:
def create_slot( def create_slot(
payload: TimeSlotCreate, payload: TimeSlotCreate,
db: Session = Depends(get_db), db: Session = Depends(get_db),
current_user: User = Depends(get_current_user), current_user: User = Depends(require_calendar_write),
): ):
"""Create a one-off calendar slot. """Create a one-off calendar slot.
@@ -222,6 +278,163 @@ def _virtual_slot_to_item(vs: dict) -> CalendarSlotItem:
) )
def _require_agent(db: Session, agent_id: str, claw_identifier: str) -> Agent:
agent = (
db.query(Agent)
.filter(Agent.agent_id == agent_id, Agent.claw_identifier == claw_identifier)
.first()
)
if agent is None:
raise HTTPException(status_code=404, detail="Agent not found")
return agent
def _apply_agent_slot_update(slot: TimeSlot, payload: SlotAgentUpdate) -> None:
slot.status = payload.status.value
if payload.started_at is not None:
slot.started_at = payload.started_at
slot.attended = True
if payload.actual_duration is not None:
slot.actual_duration = payload.actual_duration
if payload.status == SlotStatusEnum.ONGOING:
slot.attended = True
def _maybe_trigger_discord_wakeup(db: Session, slot: TimeSlot) -> dict | None:
"""Trigger Discord wakeup if slot became ONGOING and not already sent."""
# Only trigger for ONGOING status and if not already sent
if slot.status != SlotStatus.ONGOING or slot.wakeup_sent_at is not None:
return None
# Get user and check for discord_user_id
user = db.query(User).filter(User.id == slot.user_id).first()
if not user or not user.discord_user_id:
return None
# Get agent for this user
agent = db.query(Agent).filter(Agent.user_id == user.id).first()
agent_id_str = agent.agent_id if agent else "unknown"
# Build wakeup message
title = f"HarborForge Slot: {slot.event_type.value if slot.event_type else 'work'}"
message = (
f"🎯 **Slot started**\n"
f"Agent: `{agent_id_str}`\n"
f"Type: {slot.slot_type.value}\n"
f"Duration: {slot.estimated_duration}min\n"
f"Priority: {slot.priority}\n"
f"Use `hf calendar slot {slot.id}` for details."
)
try:
result = create_private_wakeup_channel(
discord_user_id=user.discord_user_id,
title=title,
message=message,
)
slot.wakeup_sent_at = datetime.now(timezone.utc)
return {"ok": True, "channel_id": result.get("channel_id")}
except Exception as e:
# Log but don't fail the slot update
return {"ok": False, "error": str(e)}
@router.api_route(
"/agent/heartbeat",
methods=["GET", "POST"],
response_model=AgentHeartbeatResponse,
summary="Get all due slots for the calling agent",
)
def agent_heartbeat(
x_agent_id: str = Header(..., alias="X-Agent-ID"),
x_claw_identifier: str = Header(..., alias="X-Claw-Identifier"),
db: Session = Depends(get_db),
):
agent = _require_agent(db, x_agent_id, x_claw_identifier)
record_heartbeat(db, agent)
slots = get_pending_slots_for_agent(db, agent.user_id, now=datetime.now(timezone.utc))
db.commit()
return AgentHeartbeatResponse(
slots=[_real_slot_to_item(slot) for slot in slots],
agent_status=agent.status.value if hasattr(agent.status, 'value') else str(agent.status),
message=f"{len(slots)} due slot(s)",
)
@router.patch(
"/slots/{slot_id}/agent-update",
response_model=TimeSlotEditResponse,
summary="Agent updates a real slot status",
)
def agent_update_real_slot(
slot_id: int,
payload: SlotAgentUpdate,
x_agent_id: str = Header(..., alias="X-Agent-ID"),
x_claw_identifier: str = Header(..., alias="X-Claw-Identifier"),
db: Session = Depends(get_db),
):
agent = _require_agent(db, x_agent_id, x_claw_identifier)
slot = db.query(TimeSlot).filter(TimeSlot.id == slot_id, TimeSlot.user_id == agent.user_id).first()
if slot is None:
raise HTTPException(status_code=404, detail="Slot not found")
_apply_agent_slot_update(slot, payload)
_maybe_trigger_discord_wakeup(db, slot)
db.commit()
db.refresh(slot)
return TimeSlotEditResponse(slot=_slot_to_response(slot), warnings=[])
@router.patch(
"/slots/virtual/{virtual_id}/agent-update",
response_model=TimeSlotEditResponse,
summary="Agent materializes and updates a virtual slot status",
)
def agent_update_virtual_slot(
virtual_id: str,
payload: SlotAgentUpdate,
x_agent_id: str = Header(..., alias="X-Agent-ID"),
x_claw_identifier: str = Header(..., alias="X-Claw-Identifier"),
db: Session = Depends(get_db),
):
agent = _require_agent(db, x_agent_id, x_claw_identifier)
slot = materialize_from_virtual_id(db, virtual_id)
if slot.user_id != agent.user_id:
db.rollback()
raise HTTPException(status_code=404, detail="Slot not found")
_apply_agent_slot_update(slot, payload)
_maybe_trigger_discord_wakeup(db, slot)
db.commit()
db.refresh(slot)
return TimeSlotEditResponse(slot=_slot_to_response(slot), warnings=[])
@router.post(
"/agent/status",
summary="Update agent runtime status from plugin",
)
def update_agent_status(
payload: AgentStatusUpdateRequest,
db: Session = Depends(get_db),
):
agent = _require_agent(db, payload.agent_id, payload.claw_identifier)
target = (payload.status or '').lower().strip()
if target == AgentStatus.IDLE.value:
transition_to_idle(db, agent)
elif target == AgentStatus.BUSY.value:
transition_to_busy(db, agent, slot_type=SlotType.WORK)
elif target == AgentStatus.ON_CALL.value:
transition_to_busy(db, agent, slot_type=SlotType.ON_CALL)
elif target == AgentStatus.OFFLINE.value:
transition_to_offline(db, agent)
elif target == AgentStatus.EXHAUSTED.value:
reason = ExhaustReason.BILLING if payload.exhaust_reason == 'billing' else ExhaustReason.RATE_LIMIT
transition_to_exhausted(db, agent, reason=reason, recovery_at=payload.recovery_at)
else:
raise HTTPException(status_code=400, detail="Unsupported agent status")
db.commit()
return {"ok": True, "agent_id": agent.agent_id, "status": agent.status.value if hasattr(agent.status, 'value') else str(agent.status)}
@router.get( @router.get(
"/day", "/day",
response_model=CalendarDayResponse, response_model=CalendarDayResponse,
@@ -230,7 +443,7 @@ def _virtual_slot_to_item(vs: dict) -> CalendarSlotItem:
def get_calendar_day( def get_calendar_day(
date: Optional[date_type] = Query(None, description="Target date (defaults to today)"), date: Optional[date_type] = Query(None, description="Target date (defaults to today)"),
db: Session = Depends(get_db), db: Session = Depends(get_db),
current_user: User = Depends(get_current_user), current_user: User = Depends(require_calendar_read),
): ):
"""Return all calendar slots for the authenticated user on the given date. """Return all calendar slots for the authenticated user on the given date.
@@ -301,7 +514,7 @@ def edit_real_slot(
slot_id: int, slot_id: int,
payload: TimeSlotEdit, payload: TimeSlotEdit,
db: Session = Depends(get_db), db: Session = Depends(get_db),
current_user: User = Depends(get_current_user), current_user: User = Depends(require_calendar_write),
): ):
"""Edit an existing real (materialized) slot. """Edit an existing real (materialized) slot.
@@ -380,7 +593,7 @@ def edit_virtual_slot(
virtual_id: str, virtual_id: str,
payload: TimeSlotEdit, payload: TimeSlotEdit,
db: Session = Depends(get_db), db: Session = Depends(get_db),
current_user: User = Depends(get_current_user), current_user: User = Depends(require_calendar_write),
): ):
"""Edit a virtual (plan-generated) slot. """Edit a virtual (plan-generated) slot.
@@ -469,7 +682,7 @@ def edit_virtual_slot(
def cancel_real_slot( def cancel_real_slot(
slot_id: int, slot_id: int,
db: Session = Depends(get_db), db: Session = Depends(get_db),
current_user: User = Depends(get_current_user), current_user: User = Depends(require_calendar_write),
): ):
"""Cancel an existing real (materialized) slot. """Cancel an existing real (materialized) slot.
@@ -516,7 +729,7 @@ def cancel_real_slot(
def cancel_virtual_slot( def cancel_virtual_slot(
virtual_id: str, virtual_id: str,
db: Session = Depends(get_db), db: Session = Depends(get_db),
current_user: User = Depends(get_current_user), current_user: User = Depends(require_calendar_write),
): ):
"""Cancel a virtual (plan-generated) slot. """Cancel a virtual (plan-generated) slot.
@@ -596,7 +809,7 @@ def _plan_to_response(plan: SchedulePlan) -> SchedulePlanResponse:
def create_plan( def create_plan(
payload: SchedulePlanCreate, payload: SchedulePlanCreate,
db: Session = Depends(get_db), db: Session = Depends(get_db),
current_user: User = Depends(get_current_user), current_user: User = Depends(require_calendar_write),
): ):
"""Create a new recurring schedule plan. """Create a new recurring schedule plan.
@@ -632,7 +845,7 @@ def create_plan(
def list_plans( def list_plans(
include_inactive: bool = Query(False, description="Include cancelled/inactive plans"), include_inactive: bool = Query(False, description="Include cancelled/inactive plans"),
db: Session = Depends(get_db), db: Session = Depends(get_db),
current_user: User = Depends(get_current_user), current_user: User = Depends(require_calendar_read),
): ):
"""Return all schedule plans for the authenticated user. """Return all schedule plans for the authenticated user.
@@ -658,7 +871,7 @@ def list_plans(
def get_plan( def get_plan(
plan_id: int, plan_id: int,
db: Session = Depends(get_db), db: Session = Depends(get_db),
current_user: User = Depends(get_current_user), current_user: User = Depends(require_calendar_read),
): ):
"""Return a single schedule plan owned by the authenticated user.""" """Return a single schedule plan owned by the authenticated user."""
plan = ( plan = (
@@ -705,7 +918,7 @@ def edit_plan(
plan_id: int, plan_id: int,
payload: SchedulePlanEdit, payload: SchedulePlanEdit,
db: Session = Depends(get_db), db: Session = Depends(get_db),
current_user: User = Depends(get_current_user), current_user: User = Depends(require_calendar_write),
): ):
"""Edit an existing schedule plan. """Edit an existing schedule plan.
@@ -792,7 +1005,7 @@ def edit_plan(
def cancel_plan( def cancel_plan(
plan_id: int, plan_id: int,
db: Session = Depends(get_db), db: Session = Depends(get_db),
current_user: User = Depends(get_current_user), current_user: User = Depends(require_calendar_write),
): ):
"""Cancel (soft-delete) a schedule plan. """Cancel (soft-delete) a schedule plan.
@@ -859,7 +1072,7 @@ _DATE_LIST_EXCLUDED_STATUSES = {SlotStatus.SKIPPED.value, SlotStatus.ABORTED.val
) )
def list_dates( def list_dates(
db: Session = Depends(get_db), db: Session = Depends(get_db),
current_user: User = Depends(get_current_user), current_user: User = Depends(require_calendar_read),
): ):
"""Return a sorted list of future dates that have at least one """Return a sorted list of future dates that have at least one
materialized (real) slot. materialized (real) slot.
@@ -897,7 +1110,7 @@ def list_dates(
) )
def get_my_workload_config( def get_my_workload_config(
db: Session = Depends(get_db), db: Session = Depends(get_db),
current_user: User = Depends(get_current_user), current_user: User = Depends(require_calendar_manage),
): ):
"""Return the workload thresholds for the authenticated user. """Return the workload thresholds for the authenticated user.
@@ -916,7 +1129,7 @@ def get_my_workload_config(
def put_my_workload_config( def put_my_workload_config(
payload: MinimumWorkloadConfig, payload: MinimumWorkloadConfig,
db: Session = Depends(get_db), db: Session = Depends(get_db),
current_user: User = Depends(get_current_user), current_user: User = Depends(require_calendar_manage),
): ):
"""Full replacement of the workload configuration.""" """Full replacement of the workload configuration."""
row = replace_workload_config(db, current_user.id, payload) row = replace_workload_config(db, current_user.id, payload)
@@ -933,7 +1146,7 @@ def put_my_workload_config(
def patch_my_workload_config( def patch_my_workload_config(
payload: MinimumWorkloadUpdate, payload: MinimumWorkloadUpdate,
db: Session = Depends(get_db), db: Session = Depends(get_db),
current_user: User = Depends(get_current_user), current_user: User = Depends(require_calendar_manage),
): ):
"""Partial update — only the provided periods are overwritten.""" """Partial update — only the provided periods are overwritten."""
row = upsert_workload_config(db, current_user.id, payload) row = upsert_workload_config(db, current_user.id, payload)

View File

@@ -1,7 +1,7 @@
"""Essentials API router — CRUD for Essentials nested under a Proposal. """Essentials API router — CRUD for Essentials nested under a Proposal.
Endpoints are scoped to a project and proposal: Endpoints are scoped to a project and proposal:
/projects/{project_id}/proposals/{proposal_id}/essentials /projects/{project_code}/proposals/{proposal_code}/essentials
Only open Proposals allow Essential mutations. Only open Proposals allow Essential mutations.
""" """
@@ -26,7 +26,7 @@ from app.services.activity import log_activity
from app.services.essential_code import generate_essential_code from app.services.essential_code import generate_essential_code
router = APIRouter( router = APIRouter(
prefix="/projects/{project_id}/proposals/{proposal_id}/essentials", prefix="/projects/{project_code}/proposals/{proposal_code}/essentials",
tags=["Essentials"], tags=["Essentials"],
) )
@@ -35,53 +35,27 @@ router = APIRouter(
# Helpers # Helpers
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
def _find_project(db: Session, identifier: str): def _find_project(db: Session, project_code: str):
"""Look up project by numeric id or project_code.""" """Look up project by project_code."""
try:
pid = int(identifier)
p = db.query(models.Project).filter(models.Project.id == pid).first()
if p:
return p
except (ValueError, TypeError):
pass
return db.query(models.Project).filter( return db.query(models.Project).filter(
models.Project.project_code == str(identifier) models.Project.project_code == str(project_code)
).first() ).first()
def _find_proposal(db: Session, identifier: str, project_id: int) -> Proposal | None: def _find_proposal(db: Session, proposal_code: str, project_id: int) -> Proposal | None:
"""Look up proposal by numeric id or propose_code within a project.""" """Look up proposal by propose_code within a project."""
try:
pid = int(identifier)
q = db.query(Proposal).filter(Proposal.id == pid, Proposal.project_id == project_id)
p = q.first()
if p:
return p
except (ValueError, TypeError):
pass
return ( return (
db.query(Proposal) db.query(Proposal)
.filter(Proposal.propose_code == str(identifier), Proposal.project_id == project_id) .filter(Proposal.propose_code == str(proposal_code), Proposal.project_id == project_id)
.first() .first()
) )
def _find_essential(db: Session, identifier: str, proposal_id: int) -> Essential | None: def _find_essential(db: Session, essential_code: str, proposal_id: int) -> Essential | None:
"""Look up essential by numeric id or essential_code within a proposal.""" """Look up essential by essential_code within a proposal."""
try:
eid = int(identifier)
e = (
db.query(Essential)
.filter(Essential.id == eid, Essential.proposal_id == proposal_id)
.first()
)
if e:
return e
except (ValueError, TypeError):
pass
return ( return (
db.query(Essential) db.query(Essential)
.filter(Essential.essential_code == str(identifier), Essential.proposal_id == proposal_id) .filter(Essential.essential_code == str(essential_code), Essential.proposal_id == proposal_id)
.first() .first()
) )
@@ -108,12 +82,11 @@ def _can_edit_proposal(db: Session, user_id: int, proposal: Proposal) -> bool:
return False return False
def _serialize_essential(e: Essential) -> dict: def _serialize_essential(e: Essential, proposal_code: str | None) -> dict:
"""Return a dict matching EssentialResponse.""" """Return a dict matching EssentialResponse."""
return { return {
"id": e.id,
"essential_code": e.essential_code, "essential_code": e.essential_code,
"proposal_id": e.proposal_id, "proposal_code": proposal_code,
"type": e.type.value if hasattr(e.type, "value") else e.type, "type": e.type.value if hasattr(e.type, "value") else e.type,
"title": e.title, "title": e.title,
"description": e.description, "description": e.description,
@@ -129,18 +102,18 @@ def _serialize_essential(e: Essential) -> dict:
@router.get("", response_model=List[EssentialResponse]) @router.get("", response_model=List[EssentialResponse])
def list_essentials( def list_essentials(
project_id: str, project_code: str,
proposal_id: str, proposal_code: str,
db: Session = Depends(get_db), db: Session = Depends(get_db),
current_user: models.User = Depends(get_current_user_or_apikey), current_user: models.User = Depends(get_current_user_or_apikey),
): ):
"""List all Essentials under a Proposal.""" """List all Essentials under a Proposal."""
project = _find_project(db, project_id) project = _find_project(db, project_code)
if not project: if not project:
raise HTTPException(status_code=404, detail="Project not found") raise HTTPException(status_code=404, detail="Project not found")
check_project_role(db, current_user.id, project.id, min_role="viewer") check_project_role(db, current_user.id, project.id, min_role="viewer")
proposal = _find_proposal(db, proposal_id, project.id) proposal = _find_proposal(db, proposal_code, project.id)
if not proposal: if not proposal:
raise HTTPException(status_code=404, detail="Proposal not found") raise HTTPException(status_code=404, detail="Proposal not found")
@@ -150,24 +123,24 @@ def list_essentials(
.order_by(Essential.id.asc()) .order_by(Essential.id.asc())
.all() .all()
) )
return [_serialize_essential(e) for e in essentials] return [_serialize_essential(e, proposal.propose_code) for e in essentials]
@router.post("", response_model=EssentialResponse, status_code=status.HTTP_201_CREATED) @router.post("", response_model=EssentialResponse, status_code=status.HTTP_201_CREATED)
def create_essential( def create_essential(
project_id: str, project_code: str,
proposal_id: str, proposal_code: str,
body: EssentialCreate, body: EssentialCreate,
db: Session = Depends(get_db), db: Session = Depends(get_db),
current_user: models.User = Depends(get_current_user_or_apikey), current_user: models.User = Depends(get_current_user_or_apikey),
): ):
"""Create a new Essential under an open Proposal.""" """Create a new Essential under an open Proposal."""
project = _find_project(db, project_id) project = _find_project(db, project_code)
if not project: if not project:
raise HTTPException(status_code=404, detail="Project not found") raise HTTPException(status_code=404, detail="Project not found")
check_project_role(db, current_user.id, project.id, min_role="dev") check_project_role(db, current_user.id, project.id, min_role="dev")
proposal = _find_proposal(db, proposal_id, project.id) proposal = _find_proposal(db, proposal_code, project.id)
if not proposal: if not proposal:
raise HTTPException(status_code=404, detail="Proposal not found") raise HTTPException(status_code=404, detail="Proposal not found")
@@ -196,50 +169,50 @@ def create_essential(
details={"title": essential.title, "type": body.type.value, "proposal_id": proposal.id}, details={"title": essential.title, "type": body.type.value, "proposal_id": proposal.id},
) )
return _serialize_essential(essential) return _serialize_essential(essential, proposal.propose_code)
@router.get("/{essential_id}", response_model=EssentialResponse) @router.get("/{essential_id}", response_model=EssentialResponse)
def get_essential( def get_essential(
project_id: str, project_code: str,
proposal_id: str, proposal_code: str,
essential_id: str, essential_code: str,
db: Session = Depends(get_db), db: Session = Depends(get_db),
current_user: models.User = Depends(get_current_user_or_apikey), current_user: models.User = Depends(get_current_user_or_apikey),
): ):
"""Get a single Essential by id or essential_code.""" """Get a single Essential by essential_code."""
project = _find_project(db, project_id) project = _find_project(db, project_code)
if not project: if not project:
raise HTTPException(status_code=404, detail="Project not found") raise HTTPException(status_code=404, detail="Project not found")
check_project_role(db, current_user.id, project.id, min_role="viewer") check_project_role(db, current_user.id, project.id, min_role="viewer")
proposal = _find_proposal(db, proposal_id, project.id) proposal = _find_proposal(db, proposal_code, project.id)
if not proposal: if not proposal:
raise HTTPException(status_code=404, detail="Proposal not found") raise HTTPException(status_code=404, detail="Proposal not found")
essential = _find_essential(db, essential_id, proposal.id) essential = _find_essential(db, essential_code, proposal.id)
if not essential: if not essential:
raise HTTPException(status_code=404, detail="Essential not found") raise HTTPException(status_code=404, detail="Essential not found")
return _serialize_essential(essential) return _serialize_essential(essential, proposal.propose_code)
@router.patch("/{essential_id}", response_model=EssentialResponse) @router.patch("/{essential_id}", response_model=EssentialResponse)
def update_essential( def update_essential(
project_id: str, project_code: str,
proposal_id: str, proposal_code: str,
essential_id: str, essential_code: str,
body: EssentialUpdate, body: EssentialUpdate,
db: Session = Depends(get_db), db: Session = Depends(get_db),
current_user: models.User = Depends(get_current_user_or_apikey), current_user: models.User = Depends(get_current_user_or_apikey),
): ):
"""Update an Essential (only on open Proposals).""" """Update an Essential (only on open Proposals)."""
project = _find_project(db, project_id) project = _find_project(db, project_code)
if not project: if not project:
raise HTTPException(status_code=404, detail="Project not found") raise HTTPException(status_code=404, detail="Project not found")
check_project_role(db, current_user.id, project.id, min_role="dev") check_project_role(db, current_user.id, project.id, min_role="dev")
proposal = _find_proposal(db, proposal_id, project.id) proposal = _find_proposal(db, proposal_code, project.id)
if not proposal: if not proposal:
raise HTTPException(status_code=404, detail="Proposal not found") raise HTTPException(status_code=404, detail="Proposal not found")
@@ -248,7 +221,7 @@ def update_essential(
if not _can_edit_proposal(db, current_user.id, proposal): if not _can_edit_proposal(db, current_user.id, proposal):
raise HTTPException(status_code=403, detail="Permission denied") raise HTTPException(status_code=403, detail="Permission denied")
essential = _find_essential(db, essential_id, proposal.id) essential = _find_essential(db, essential_code, proposal.id)
if not essential: if not essential:
raise HTTPException(status_code=404, detail="Essential not found") raise HTTPException(status_code=404, detail="Essential not found")
@@ -265,24 +238,24 @@ def update_essential(
details=data, details=data,
) )
return _serialize_essential(essential) return _serialize_essential(essential, proposal.propose_code)
@router.delete("/{essential_id}", status_code=status.HTTP_204_NO_CONTENT) @router.delete("/{essential_id}", status_code=status.HTTP_204_NO_CONTENT)
def delete_essential( def delete_essential(
project_id: str, project_code: str,
proposal_id: str, proposal_code: str,
essential_id: str, essential_code: str,
db: Session = Depends(get_db), db: Session = Depends(get_db),
current_user: models.User = Depends(get_current_user_or_apikey), current_user: models.User = Depends(get_current_user_or_apikey),
): ):
"""Delete an Essential (only on open Proposals).""" """Delete an Essential (only on open Proposals)."""
project = _find_project(db, project_id) project = _find_project(db, project_code)
if not project: if not project:
raise HTTPException(status_code=404, detail="Project not found") raise HTTPException(status_code=404, detail="Project not found")
check_project_role(db, current_user.id, project.id, min_role="dev") check_project_role(db, current_user.id, project.id, min_role="dev")
proposal = _find_proposal(db, proposal_id, project.id) proposal = _find_proposal(db, proposal_code, project.id)
if not proposal: if not proposal:
raise HTTPException(status_code=404, detail="Proposal not found") raise HTTPException(status_code=404, detail="Proposal not found")
@@ -291,7 +264,7 @@ def delete_essential(
if not _can_edit_proposal(db, current_user.id, proposal): if not _can_edit_proposal(db, current_user.id, proposal):
raise HTTPException(status_code=403, detail="Permission denied") raise HTTPException(status_code=403, detail="Permission denied")
essential = _find_essential(db, essential_id, proposal.id) essential = _find_essential(db, essential_code, proposal.id)
if not essential: if not essential:
raise HTTPException(status_code=404, detail="Essential not found") raise HTTPException(status_code=404, detail="Essential not found")

View File

@@ -18,15 +18,8 @@ router = APIRouter(tags=["Meetings"])
# ---- helpers ---- # ---- helpers ----
def _find_meeting_by_id_or_code(db: Session, identifier: str) -> Meeting | None: def _find_meeting_by_code(db: Session, meeting_code: str) -> Meeting | None:
try: return db.query(Meeting).filter(Meeting.meeting_code == str(meeting_code)).first()
mid = int(identifier)
meeting = db.query(Meeting).filter(Meeting.id == mid).first()
if meeting:
return meeting
except (ValueError, TypeError):
pass
return db.query(Meeting).filter(Meeting.meeting_code == str(identifier)).first()
def _resolve_project_id(db: Session, project_code: str | None) -> int | None: def _resolve_project_id(db: Session, project_code: str | None) -> int | None:
@@ -64,16 +57,13 @@ def _serialize_meeting(db: Session, meeting: Meeting) -> dict:
project = db.query(models.Project).filter(models.Project.id == meeting.project_id).first() project = db.query(models.Project).filter(models.Project.id == meeting.project_id).first()
milestone = db.query(Milestone).filter(Milestone.id == meeting.milestone_id).first() milestone = db.query(Milestone).filter(Milestone.id == meeting.milestone_id).first()
return { return {
"id": meeting.id,
"code": meeting.meeting_code, "code": meeting.meeting_code,
"meeting_code": meeting.meeting_code, "meeting_code": meeting.meeting_code,
"title": meeting.title, "title": meeting.title,
"description": meeting.description, "description": meeting.description,
"status": meeting.status.value if hasattr(meeting.status, "value") else meeting.status, "status": meeting.status.value if hasattr(meeting.status, "value") else meeting.status,
"priority": meeting.priority.value if hasattr(meeting.priority, "value") else meeting.priority, "priority": meeting.priority.value if hasattr(meeting.priority, "value") else meeting.priority,
"project_id": meeting.project_id,
"project_code": project.project_code if project else None, "project_code": project.project_code if project else None,
"milestone_id": meeting.milestone_id,
"milestone_code": milestone.milestone_code if milestone else None, "milestone_code": milestone.milestone_code if milestone else None,
"reporter_id": meeting.reporter_id, "reporter_id": meeting.reporter_id,
"meeting_time": meeting.scheduled_at.isoformat() if meeting.scheduled_at else None, "meeting_time": meeting.scheduled_at.isoformat() if meeting.scheduled_at else None,
@@ -155,6 +145,7 @@ def create_meeting(
@router.get("/meetings") @router.get("/meetings")
def list_meetings( def list_meetings(
project: str = None, project: str = None,
project_code: str = None,
status_value: str = Query(None, alias="status"), status_value: str = Query(None, alias="status"),
order_by: str = None, order_by: str = None,
page: int = 1, page: int = 1,
@@ -163,8 +154,9 @@ def list_meetings(
): ):
query = db.query(Meeting) query = db.query(Meeting)
if project: effective_project = project_code or project
project_id = _resolve_project_id(db, project) if effective_project:
project_id = _resolve_project_id(db, effective_project)
if project_id: if project_id:
query = query.filter(Meeting.project_id == project_id) query = query.filter(Meeting.project_id == project_id)
@@ -197,9 +189,9 @@ def list_meetings(
} }
@router.get("/meetings/{meeting_id}") @router.get("/meetings/{meeting_code}")
def get_meeting(meeting_id: str, db: Session = Depends(get_db)): def get_meeting(meeting_code: str, db: Session = Depends(get_db)):
meeting = _find_meeting_by_id_or_code(db, meeting_id) meeting = _find_meeting_by_code(db, meeting_code)
if not meeting: if not meeting:
raise HTTPException(status_code=404, detail="Meeting not found") raise HTTPException(status_code=404, detail="Meeting not found")
return _serialize_meeting(db, meeting) return _serialize_meeting(db, meeting)
@@ -213,14 +205,14 @@ class MeetingUpdateBody(BaseModel):
duration_minutes: Optional[int] = None duration_minutes: Optional[int] = None
@router.patch("/meetings/{meeting_id}") @router.patch("/meetings/{meeting_code}")
def update_meeting( def update_meeting(
meeting_id: str, meeting_code: str,
body: MeetingUpdateBody, body: MeetingUpdateBody,
db: Session = Depends(get_db), db: Session = Depends(get_db),
current_user: models.User = Depends(get_current_user_or_apikey), current_user: models.User = Depends(get_current_user_or_apikey),
): ):
meeting = _find_meeting_by_id_or_code(db, meeting_id) meeting = _find_meeting_by_code(db, meeting_code)
if not meeting: if not meeting:
raise HTTPException(status_code=404, detail="Meeting not found") raise HTTPException(status_code=404, detail="Meeting not found")
check_project_role(db, current_user.id, meeting.project_id, min_role="dev") check_project_role(db, current_user.id, meeting.project_id, min_role="dev")
@@ -248,13 +240,13 @@ def update_meeting(
return _serialize_meeting(db, meeting) return _serialize_meeting(db, meeting)
@router.delete("/meetings/{meeting_id}", status_code=status.HTTP_204_NO_CONTENT) @router.delete("/meetings/{meeting_code}", status_code=status.HTTP_204_NO_CONTENT)
def delete_meeting( def delete_meeting(
meeting_id: str, meeting_code: str,
db: Session = Depends(get_db), db: Session = Depends(get_db),
current_user: models.User = Depends(get_current_user_or_apikey), current_user: models.User = Depends(get_current_user_or_apikey),
): ):
meeting = _find_meeting_by_id_or_code(db, meeting_id) meeting = _find_meeting_by_code(db, meeting_code)
if not meeting: if not meeting:
raise HTTPException(status_code=404, detail="Meeting not found") raise HTTPException(status_code=404, detail="Meeting not found")
check_project_role(db, current_user.id, meeting.project_id, min_role="dev") check_project_role(db, current_user.id, meeting.project_id, min_role="dev")
@@ -265,13 +257,13 @@ def delete_meeting(
# ---- Attend ---- # ---- Attend ----
@router.post("/meetings/{meeting_id}/attend") @router.post("/meetings/{meeting_code}/attend")
def attend_meeting( def attend_meeting(
meeting_id: str, meeting_code: str,
db: Session = Depends(get_db), db: Session = Depends(get_db),
current_user: models.User = Depends(get_current_user_or_apikey), current_user: models.User = Depends(get_current_user_or_apikey),
): ):
meeting = _find_meeting_by_id_or_code(db, meeting_id) meeting = _find_meeting_by_code(db, meeting_code)
if not meeting: if not meeting:
raise HTTPException(status_code=404, detail="Meeting not found") raise HTTPException(status_code=404, detail="Meeting not found")
check_project_role(db, current_user.id, meeting.project_id, min_role="viewer") check_project_role(db, current_user.id, meeting.project_id, min_role="viewer")

View File

@@ -20,7 +20,7 @@ from app.services.activity import log_activity
from app.services.dependency_check import check_milestone_deps from app.services.dependency_check import check_milestone_deps
router = APIRouter( router = APIRouter(
prefix="/projects/{project_id}/milestones/{milestone_id}/actions", prefix="/projects/{project_code}/milestones/{milestone_code}/actions",
tags=["Milestone Actions"], tags=["Milestone Actions"],
) )
@@ -29,10 +29,18 @@ router = APIRouter(
# Helpers # Helpers
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
def _get_milestone_or_404(db: Session, project_id: int, milestone_id: int) -> Milestone: def _resolve_project_or_404(db: Session, project_code: str):
project = db.query(models.Project).filter(models.Project.project_code == project_code).first()
if not project:
raise HTTPException(status_code=404, detail="Project not found")
return project
def _get_milestone_or_404(db: Session, project_code: str, milestone_code: str) -> Milestone:
project = _resolve_project_or_404(db, project_code)
ms = ( ms = (
db.query(Milestone) db.query(Milestone)
.filter(Milestone.id == milestone_id, Milestone.project_id == project_id) .filter(Milestone.milestone_code == milestone_code, Milestone.project_id == project.id)
.first() .first()
) )
if not ms: if not ms:
@@ -59,8 +67,8 @@ class CloseBody(BaseModel):
@router.get("/preflight", status_code=200) @router.get("/preflight", status_code=200)
def preflight_milestone_actions( def preflight_milestone_actions(
project_id: int, project_code: str,
milestone_id: int, milestone_code: str,
db: Session = Depends(get_db), db: Session = Depends(get_db),
current_user: models.User = Depends(get_current_user_or_apikey), current_user: models.User = Depends(get_current_user_or_apikey),
): ):
@@ -69,8 +77,9 @@ def preflight_milestone_actions(
The frontend uses this to decide whether to *disable* buttons and what The frontend uses this to decide whether to *disable* buttons and what
hint text to show. This endpoint never mutates data. hint text to show. This endpoint never mutates data.
""" """
check_project_role(db, current_user.id, project_id, min_role="viewer") project = _resolve_project_or_404(db, project_code)
ms = _get_milestone_or_404(db, project_id, milestone_id) check_project_role(db, current_user.id, project.id, min_role="viewer")
ms = _get_milestone_or_404(db, project_code, milestone_code)
ms_status = _ms_status_value(ms) ms_status = _ms_status_value(ms)
result: dict = {"status": ms_status, "freeze": None, "start": None} result: dict = {"status": ms_status, "freeze": None, "start": None}
@@ -80,7 +89,7 @@ def preflight_milestone_actions(
release_tasks = ( release_tasks = (
db.query(Task) db.query(Task)
.filter( .filter(
Task.milestone_id == milestone_id, Task.milestone_id == ms.id,
Task.task_type == "maintenance", Task.task_type == "maintenance",
Task.task_subtype == "release", Task.task_subtype == "release",
) )
@@ -118,8 +127,8 @@ def preflight_milestone_actions(
@router.post("/freeze", status_code=200) @router.post("/freeze", status_code=200)
def freeze_milestone( def freeze_milestone(
project_id: int, project_code: str,
milestone_id: int, milestone_code: str,
db: Session = Depends(get_db), db: Session = Depends(get_db),
current_user: models.User = Depends(get_current_user_or_apikey), current_user: models.User = Depends(get_current_user_or_apikey),
): ):
@@ -130,10 +139,11 @@ def freeze_milestone(
- Milestone must have **exactly one** maintenance task with subtype ``release``. - Milestone must have **exactly one** maintenance task with subtype ``release``.
- Caller must have ``freeze milestone`` permission. - Caller must have ``freeze milestone`` permission.
""" """
check_project_role(db, current_user.id, project_id, min_role="mgr") project = _resolve_project_or_404(db, project_code)
check_permission(db, current_user.id, project_id, "milestone.freeze") check_project_role(db, current_user.id, project.id, min_role="mgr")
check_permission(db, current_user.id, project.id, "milestone.freeze")
ms = _get_milestone_or_404(db, project_id, milestone_id) ms = _get_milestone_or_404(db, project_code, milestone_code)
if _ms_status_value(ms) != "open": if _ms_status_value(ms) != "open":
raise HTTPException( raise HTTPException(
@@ -145,7 +155,7 @@ def freeze_milestone(
release_tasks = ( release_tasks = (
db.query(Task) db.query(Task)
.filter( .filter(
Task.milestone_id == milestone_id, Task.milestone_id == ms.id,
Task.task_type == "maintenance", Task.task_type == "maintenance",
Task.task_subtype == "release", Task.task_subtype == "release",
) )
@@ -184,8 +194,8 @@ def freeze_milestone(
@router.post("/start", status_code=200) @router.post("/start", status_code=200)
def start_milestone( def start_milestone(
project_id: int, project_code: str,
milestone_id: int, milestone_code: str,
db: Session = Depends(get_db), db: Session = Depends(get_db),
current_user: models.User = Depends(get_current_user_or_apikey), current_user: models.User = Depends(get_current_user_or_apikey),
): ):
@@ -196,10 +206,11 @@ def start_milestone(
- All milestone dependencies must be completed. - All milestone dependencies must be completed.
- Caller must have ``start milestone`` permission. - Caller must have ``start milestone`` permission.
""" """
check_project_role(db, current_user.id, project_id, min_role="mgr") project = _resolve_project_or_404(db, project_code)
check_permission(db, current_user.id, project_id, "milestone.start") check_project_role(db, current_user.id, project.id, min_role="mgr")
check_permission(db, current_user.id, project.id, "milestone.start")
ms = _get_milestone_or_404(db, project_id, milestone_id) ms = _get_milestone_or_404(db, project_code, milestone_code)
if _ms_status_value(ms) != "freeze": if _ms_status_value(ms) != "freeze":
raise HTTPException( raise HTTPException(
@@ -240,8 +251,8 @@ def start_milestone(
@router.post("/close", status_code=200) @router.post("/close", status_code=200)
def close_milestone( def close_milestone(
project_id: int, project_code: str,
milestone_id: int, milestone_code: str,
body: CloseBody = CloseBody(), body: CloseBody = CloseBody(),
db: Session = Depends(get_db), db: Session = Depends(get_db),
current_user: models.User = Depends(get_current_user_or_apikey), current_user: models.User = Depends(get_current_user_or_apikey),
@@ -252,10 +263,11 @@ def close_milestone(
- Milestone must be in ``open``, ``freeze``, or ``undergoing`` status. - Milestone must be in ``open``, ``freeze``, or ``undergoing`` status.
- Caller must have ``close milestone`` permission. - Caller must have ``close milestone`` permission.
""" """
check_project_role(db, current_user.id, project_id, min_role="mgr") project = _resolve_project_or_404(db, project_code)
check_permission(db, current_user.id, project_id, "milestone.close") check_project_role(db, current_user.id, project.id, min_role="mgr")
check_permission(db, current_user.id, project.id, "milestone.close")
ms = _get_milestone_or_404(db, project_id, milestone_id) ms = _get_milestone_or_404(db, project_code, milestone_code)
current = _ms_status_value(ms) current = _ms_status_value(ms)
allowed_from = {"open", "freeze", "undergoing"} allowed_from = {"open", "freeze", "undergoing"}

View File

@@ -48,10 +48,10 @@ def _find_milestone(db, identifier, project_id: int = None) -> Milestone | None:
return q.first() return q.first()
def _serialize_milestone(milestone): def _serialize_milestone(db, milestone):
"""Serialize milestone with JSON fields and code.""" """Serialize milestone with JSON fields and code-first identifiers."""
project = db.query(models.Project).filter(models.Project.id == milestone.project_id).first()
return { return {
"id": milestone.id,
"title": milestone.title, "title": milestone.title,
"description": milestone.description, "description": milestone.description,
"status": milestone.status.value if hasattr(milestone.status, 'value') else milestone.status, "status": milestone.status.value if hasattr(milestone.status, 'value') else milestone.status,
@@ -59,9 +59,9 @@ def _serialize_milestone(milestone):
"planned_release_date": milestone.planned_release_date, "planned_release_date": milestone.planned_release_date,
"depend_on_milestones": json.loads(milestone.depend_on_milestones) if milestone.depend_on_milestones else [], "depend_on_milestones": json.loads(milestone.depend_on_milestones) if milestone.depend_on_milestones else [],
"depend_on_tasks": json.loads(milestone.depend_on_tasks) if milestone.depend_on_tasks else [], "depend_on_tasks": json.loads(milestone.depend_on_tasks) if milestone.depend_on_tasks else [],
"project_id": milestone.project_id,
"milestone_code": milestone.milestone_code, "milestone_code": milestone.milestone_code,
"code": milestone.milestone_code, "code": milestone.milestone_code,
"project_code": project.project_code if project else None,
"created_by_id": milestone.created_by_id, "created_by_id": milestone.created_by_id,
"started_at": milestone.started_at, "started_at": milestone.started_at,
"created_at": milestone.created_at, "created_at": milestone.created_at,
@@ -76,7 +76,7 @@ def list_milestones(project_id: str, db: Session = Depends(get_db), current_user
raise HTTPException(status_code=404, detail="Project not found") raise HTTPException(status_code=404, detail="Project not found")
check_project_role(db, current_user.id, project.id, min_role="viewer") check_project_role(db, current_user.id, project.id, min_role="viewer")
milestones = db.query(Milestone).filter(Milestone.project_id == project.id).all() milestones = db.query(Milestone).filter(Milestone.project_id == project.id).all()
return [_serialize_milestone(m) for m in milestones] return [_serialize_milestone(db, m) for m in milestones]
@router.post("", response_model=schemas.MilestoneResponse, status_code=status.HTTP_201_CREATED) @router.post("", response_model=schemas.MilestoneResponse, status_code=status.HTTP_201_CREATED)
@@ -101,7 +101,7 @@ def create_milestone(project_id: str, milestone: schemas.MilestoneCreate, db: Se
db.add(db_milestone) db.add(db_milestone)
db.commit() db.commit()
db.refresh(db_milestone) db.refresh(db_milestone)
return _serialize_milestone(db_milestone) return _serialize_milestone(db, db_milestone)
@router.get("/{milestone_id}", response_model=schemas.MilestoneResponse) @router.get("/{milestone_id}", response_model=schemas.MilestoneResponse)
@@ -113,7 +113,7 @@ def get_milestone(project_id: str, milestone_id: str, db: Session = Depends(get_
milestone = _find_milestone(db, milestone_id, project.id) milestone = _find_milestone(db, milestone_id, project.id)
if not milestone: if not milestone:
raise HTTPException(status_code=404, detail="Milestone not found") raise HTTPException(status_code=404, detail="Milestone not found")
return _serialize_milestone(milestone) return _serialize_milestone(db, milestone)
@router.patch("/{milestone_id}", response_model=schemas.MilestoneResponse) @router.patch("/{milestone_id}", response_model=schemas.MilestoneResponse)
@@ -163,7 +163,7 @@ def update_milestone(project_id: str, milestone_id: str, milestone: schemas.Mile
setattr(db_milestone, key, value) setattr(db_milestone, key, value)
db.commit() db.commit()
db.refresh(db_milestone) db.refresh(db_milestone)
return _serialize_milestone(db_milestone) return _serialize_milestone(db, db_milestone)
@router.delete("/{milestone_id}", status_code=status.HTTP_204_NO_CONTENT) @router.delete("/{milestone_id}", status_code=status.HTTP_204_NO_CONTENT)

View File

@@ -149,18 +149,19 @@ def create_milestone(ms: schemas.MilestoneCreate, db: Session = Depends(get_db),
@router.get("/milestones", response_model=List[schemas.MilestoneResponse], tags=["Milestones"]) @router.get("/milestones", response_model=List[schemas.MilestoneResponse], tags=["Milestones"])
def list_milestones(project_id: str = None, status_filter: str = None, db: Session = Depends(get_db)): def list_milestones(project_id: str = None, project_code: str = None, status_filter: str = None, db: Session = Depends(get_db)):
query = db.query(MilestoneModel) query = db.query(MilestoneModel)
if project_id: effective_project = project_code or project_id
if effective_project:
# Resolve project_id by numeric id or project_code # Resolve project_id by numeric id or project_code
resolved_project = None resolved_project = None
try: try:
pid = int(project_id) pid = int(effective_project)
resolved_project = db.query(models.Project).filter(models.Project.id == pid).first() resolved_project = db.query(models.Project).filter(models.Project.id == pid).first()
except (ValueError, TypeError): except (ValueError, TypeError):
pass pass
if not resolved_project: if not resolved_project:
resolved_project = db.query(models.Project).filter(models.Project.project_code == project_id).first() resolved_project = db.query(models.Project).filter(models.Project.project_code == effective_project).first()
if not resolved_project: if not resolved_project:
raise HTTPException(status_code=404, detail="Project not found") raise HTTPException(status_code=404, detail="Project not found")
query = query.filter(MilestoneModel.project_id == resolved_project.id) query = query.filter(MilestoneModel.project_id == resolved_project.id)
@@ -428,14 +429,21 @@ def dashboard_stats(project_id: int = None, db: Session = Depends(get_db)):
# ============ Milestone-scoped Tasks ============ # ============ Milestone-scoped Tasks ============
@router.get("/tasks/{project_code}/{milestone_id}", tags=["Tasks"]) @router.get("/tasks/{project_code}/{milestone_id}", tags=["Tasks"])
def list_milestone_tasks(project_code: str, milestone_id: int, db: Session = Depends(get_db)): def list_milestone_tasks(project_code: str, milestone_id: str, db: Session = Depends(get_db)):
project = db.query(models.Project).filter(models.Project.project_code == project_code).first() project = db.query(models.Project).filter(models.Project.project_code == project_code).first()
if not project: if not project:
raise HTTPException(status_code=404, detail="Project not found") raise HTTPException(status_code=404, detail="Project not found")
milestone = db.query(MilestoneModel).filter(
MilestoneModel.milestone_code == milestone_id,
MilestoneModel.project_id == project.id,
).first()
if not milestone:
raise HTTPException(status_code=404, detail="Milestone not found")
tasks = db.query(Task).filter( tasks = db.query(Task).filter(
Task.project_id == project.id, Task.project_id == project.id,
Task.milestone_id == milestone_id Task.milestone_id == milestone.id
).all() ).all()
return [{ return [{
@@ -459,12 +467,12 @@ def list_milestone_tasks(project_code: str, milestone_id: int, db: Session = Dep
@router.post("/tasks/{project_code}/{milestone_id}", status_code=status.HTTP_201_CREATED, tags=["Tasks"]) @router.post("/tasks/{project_code}/{milestone_id}", status_code=status.HTTP_201_CREATED, tags=["Tasks"])
def create_milestone_task(project_code: str, milestone_id: int, task_data: dict, db: Session = Depends(get_db), current_user: models.User = Depends(get_current_user_or_apikey)): def create_milestone_task(project_code: str, milestone_id: str, task_data: dict, db: Session = Depends(get_db), current_user: models.User = Depends(get_current_user_or_apikey)):
project = db.query(models.Project).filter(models.Project.project_code == project_code).first() project = db.query(models.Project).filter(models.Project.project_code == project_code).first()
if not project: if not project:
raise HTTPException(status_code=404, detail="Project not found") raise HTTPException(status_code=404, detail="Project not found")
ms = db.query(MilestoneModel).filter(MilestoneModel.id == milestone_id).first() ms = db.query(MilestoneModel).filter(MilestoneModel.milestone_code == milestone_id, MilestoneModel.project_id == project.id).first()
if not ms: if not ms:
raise HTTPException(status_code=404, detail="Milestone not found") raise HTTPException(status_code=404, detail="Milestone not found")
@@ -491,7 +499,7 @@ def create_milestone_task(project_code: str, milestone_id: int, task_data: dict,
task_type=task_data.get("task_type", "issue"), # P7.1: default changed from 'task' to 'issue' task_type=task_data.get("task_type", "issue"), # P7.1: default changed from 'task' to 'issue'
task_subtype=task_data.get("task_subtype"), task_subtype=task_data.get("task_subtype"),
project_id=project.id, project_id=project.id,
milestone_id=milestone_id, milestone_id=ms.id,
reporter_id=current_user.id, reporter_id=current_user.id,
task_code=task_code, task_code=task_code,
estimated_effort=task_data.get("estimated_effort"), estimated_effort=task_data.get("estimated_effort"),
@@ -503,10 +511,10 @@ def create_milestone_task(project_code: str, milestone_id: int, task_data: dict,
db.refresh(task) db.refresh(task)
return { return {
"id": task.id,
"title": task.title, "title": task.title,
"description": task.description, "description": task.description,
"task_code": task.task_code, "task_code": task.task_code,
"code": task.task_code,
"status": task.status.value, "status": task.status.value,
"priority": task.priority.value, "priority": task.priority.value,
"created_at": task.created_at, "created_at": task.created_at,
@@ -516,15 +524,8 @@ def create_milestone_task(project_code: str, milestone_id: int, task_data: dict,
# ============ Supports ============ # ============ Supports ============
def _find_support_by_id_or_code(db: Session, identifier: str) -> Support | None: def _find_support_by_code(db: Session, support_code: str) -> Support | None:
try: return db.query(Support).filter(Support.support_code == str(support_code)).first()
support_id = int(identifier)
support = db.query(Support).filter(Support.id == support_id).first()
if support:
return support
except (TypeError, ValueError):
pass
return db.query(Support).filter(Support.support_code == str(identifier)).first()
@@ -536,16 +537,13 @@ def _serialize_support(db: Session, support: Support) -> dict:
assignee = db.query(models.User).filter(models.User.id == support.assignee_id).first() assignee = db.query(models.User).filter(models.User.id == support.assignee_id).first()
return { return {
"id": support.id,
"code": support.support_code, "code": support.support_code,
"support_code": support.support_code, "support_code": support.support_code,
"title": support.title, "title": support.title,
"description": support.description, "description": support.description,
"status": support.status.value if hasattr(support.status, "value") else support.status, "status": support.status.value if hasattr(support.status, "value") else support.status,
"priority": support.priority.value if hasattr(support.priority, "value") else support.priority, "priority": support.priority.value if hasattr(support.priority, "value") else support.priority,
"project_id": support.project_id,
"project_code": project.project_code if project else None, "project_code": project.project_code if project else None,
"milestone_id": support.milestone_id,
"milestone_code": milestone.milestone_code if milestone else None, "milestone_code": milestone.milestone_code if milestone else None,
"reporter_id": support.reporter_id, "reporter_id": support.reporter_id,
"assignee_id": support.assignee_id, "assignee_id": support.assignee_id,
@@ -585,26 +583,30 @@ def list_all_supports(
@router.get("/supports/{project_code}/{milestone_id}", tags=["Supports"]) @router.get("/supports/{project_code}/{milestone_id}", tags=["Supports"])
def list_supports(project_code: str, milestone_id: int, db: Session = Depends(get_db)): def list_supports(project_code: str, milestone_id: str, db: Session = Depends(get_db)):
project = db.query(models.Project).filter(models.Project.project_code == project_code).first() project = db.query(models.Project).filter(models.Project.project_code == project_code).first()
if not project: if not project:
raise HTTPException(status_code=404, detail="Project not found") raise HTTPException(status_code=404, detail="Project not found")
milestone = db.query(MilestoneModel).filter(MilestoneModel.milestone_code == milestone_id, MilestoneModel.project_id == project.id).first()
if not milestone:
raise HTTPException(status_code=404, detail="Milestone not found")
supports = db.query(Support).filter( supports = db.query(Support).filter(
Support.project_id == project.id, Support.project_id == project.id,
Support.milestone_id == milestone_id Support.milestone_id == milestone.id
).all() ).all()
return [_serialize_support(db, s) for s in supports] return [_serialize_support(db, s) for s in supports]
@router.post("/supports/{project_code}/{milestone_id}", status_code=status.HTTP_201_CREATED, tags=["Supports"]) @router.post("/supports/{project_code}/{milestone_id}", status_code=status.HTTP_201_CREATED, tags=["Supports"])
def create_support(project_code: str, milestone_id: int, support_data: dict, db: Session = Depends(get_db), current_user: models.User = Depends(get_current_user_or_apikey)): def create_support(project_code: str, milestone_id: str, support_data: dict, db: Session = Depends(get_db), current_user: models.User = Depends(get_current_user_or_apikey)):
project = db.query(models.Project).filter(models.Project.project_code == project_code).first() project = db.query(models.Project).filter(models.Project.project_code == project_code).first()
if not project: if not project:
raise HTTPException(status_code=404, detail="Project not found") raise HTTPException(status_code=404, detail="Project not found")
ms = db.query(MilestoneModel).filter(MilestoneModel.id == milestone_id).first() ms = db.query(MilestoneModel).filter(MilestoneModel.milestone_code == milestone_id, MilestoneModel.project_id == project.id).first()
if not ms: if not ms:
raise HTTPException(status_code=404, detail="Milestone not found") raise HTTPException(status_code=404, detail="Milestone not found")
@@ -612,7 +614,7 @@ def create_support(project_code: str, milestone_id: int, support_data: dict, db:
raise HTTPException(status_code=400, detail="Cannot add items to a milestone that is undergoing") raise HTTPException(status_code=400, detail="Cannot add items to a milestone that is undergoing")
milestone_code = ms.milestone_code or f"m{ms.id}" milestone_code = ms.milestone_code or f"m{ms.id}"
max_support = db.query(Support).filter(Support.milestone_id == milestone_id).order_by(Support.id.desc()).first() max_support = db.query(Support).filter(Support.milestone_id == ms.id).order_by(Support.id.desc()).first()
next_num = (max_support.id + 1) if max_support else 1 next_num = (max_support.id + 1) if max_support else 1
support_code = f"{milestone_code}:S{next_num:05x}" support_code = f"{milestone_code}:S{next_num:05x}"
@@ -622,7 +624,7 @@ def create_support(project_code: str, milestone_id: int, support_data: dict, db:
status=SupportStatus.OPEN, status=SupportStatus.OPEN,
priority=SupportPriority.MEDIUM, priority=SupportPriority.MEDIUM,
project_id=project.id, project_id=project.id,
milestone_id=milestone_id, milestone_id=ms.id,
reporter_id=current_user.id, reporter_id=current_user.id,
support_code=support_code, support_code=support_code,
) )
@@ -632,18 +634,18 @@ def create_support(project_code: str, milestone_id: int, support_data: dict, db:
return _serialize_support(db, support) return _serialize_support(db, support)
@router.get("/supports/{support_id}", tags=["Supports"]) @router.get("/supports/{support_code}", tags=["Supports"])
def get_support(support_id: str, db: Session = Depends(get_db), current_user: models.User = Depends(get_current_user_or_apikey)): def get_support(support_code: str, db: Session = Depends(get_db), current_user: models.User = Depends(get_current_user_or_apikey)):
support = _find_support_by_id_or_code(db, support_id) support = _find_support_by_code(db, support_code)
if not support: if not support:
raise HTTPException(status_code=404, detail="Support not found") raise HTTPException(status_code=404, detail="Support not found")
check_project_role(db, current_user.id, support.project_id, min_role="viewer") check_project_role(db, current_user.id, support.project_id, min_role="viewer")
return _serialize_support(db, support) return _serialize_support(db, support)
@router.patch("/supports/{support_id}", tags=["Supports"]) @router.patch("/supports/{support_code}", tags=["Supports"])
def update_support(support_id: str, support_data: dict, db: Session = Depends(get_db), current_user: models.User = Depends(get_current_user_or_apikey)): def update_support(support_code: str, support_data: dict, db: Session = Depends(get_db), current_user: models.User = Depends(get_current_user_or_apikey)):
support = _find_support_by_id_or_code(db, support_id) support = _find_support_by_code(db, support_code)
if not support: if not support:
raise HTTPException(status_code=404, detail="Support not found") raise HTTPException(status_code=404, detail="Support not found")
check_project_role(db, current_user.id, support.project_id, min_role="dev") check_project_role(db, current_user.id, support.project_id, min_role="dev")
@@ -668,9 +670,9 @@ def update_support(support_id: str, support_data: dict, db: Session = Depends(ge
return _serialize_support(db, support) return _serialize_support(db, support)
@router.delete("/supports/{support_id}", status_code=status.HTTP_204_NO_CONTENT, tags=["Supports"]) @router.delete("/supports/{support_code}", status_code=status.HTTP_204_NO_CONTENT, tags=["Supports"])
def delete_support(support_id: str, db: Session = Depends(get_db), current_user: models.User = Depends(get_current_user_or_apikey)): def delete_support(support_code: str, db: Session = Depends(get_db), current_user: models.User = Depends(get_current_user_or_apikey)):
support = _find_support_by_id_or_code(db, support_id) support = _find_support_by_code(db, support_code)
if not support: if not support:
raise HTTPException(status_code=404, detail="Support not found") raise HTTPException(status_code=404, detail="Support not found")
check_project_role(db, current_user.id, support.project_id, min_role="dev") check_project_role(db, current_user.id, support.project_id, min_role="dev")
@@ -679,9 +681,9 @@ def delete_support(support_id: str, db: Session = Depends(get_db), current_user:
return None return None
@router.post("/supports/{support_id}/take", tags=["Supports"]) @router.post("/supports/{support_code}/take", tags=["Supports"])
def take_support(support_id: str, db: Session = Depends(get_db), current_user: models.User = Depends(get_current_user_or_apikey)): def take_support(support_code: str, db: Session = Depends(get_db), current_user: models.User = Depends(get_current_user_or_apikey)):
support = _find_support_by_id_or_code(db, support_id) support = _find_support_by_code(db, support_code)
if not support: if not support:
raise HTTPException(status_code=404, detail="Support not found") raise HTTPException(status_code=404, detail="Support not found")
check_project_role(db, current_user.id, support.project_id, min_role="dev") check_project_role(db, current_user.id, support.project_id, min_role="dev")
@@ -697,9 +699,9 @@ def take_support(support_id: str, db: Session = Depends(get_db), current_user: m
return _serialize_support(db, support) return _serialize_support(db, support)
@router.post("/supports/{support_id}/transition", tags=["Supports"]) @router.post("/supports/{support_code}/transition", tags=["Supports"])
def transition_support(support_id: str, support_data: dict, db: Session = Depends(get_db), current_user: models.User = Depends(get_current_user_or_apikey)): def transition_support(support_code: str, support_data: dict, db: Session = Depends(get_db), current_user: models.User = Depends(get_current_user_or_apikey)):
support = _find_support_by_id_or_code(db, support_id) support = _find_support_by_code(db, support_code)
if not support: if not support:
raise HTTPException(status_code=404, detail="Support not found") raise HTTPException(status_code=404, detail="Support not found")
check_project_role(db, current_user.id, support.project_id, min_role="dev") check_project_role(db, current_user.id, support.project_id, min_role="dev")
@@ -717,20 +719,25 @@ def transition_support(support_id: str, support_data: dict, db: Session = Depend
# ============ Meetings ============ # ============ Meetings ============
@router.get("/meetings/{project_code}/{milestone_id}", tags=["Meetings"]) @router.get("/meetings/{project_code}/{milestone_id}", tags=["Meetings"])
def list_meetings(project_code: str, milestone_id: int, db: Session = Depends(get_db)): def list_meetings(project_code: str, milestone_id: str, db: Session = Depends(get_db)):
project = db.query(models.Project).filter(models.Project.project_code == project_code).first() project = db.query(models.Project).filter(models.Project.project_code == project_code).first()
if not project: if not project:
raise HTTPException(status_code=404, detail="Project not found") raise HTTPException(status_code=404, detail="Project not found")
milestone = db.query(MilestoneModel).filter(MilestoneModel.milestone_code == milestone_id, MilestoneModel.project_id == project.id).first()
if not milestone:
raise HTTPException(status_code=404, detail="Milestone not found")
meetings = db.query(Meeting).filter( meetings = db.query(Meeting).filter(
Meeting.project_id == project.id, Meeting.project_id == project.id,
Meeting.milestone_id == milestone_id Meeting.milestone_id == milestone.id
).all() ).all()
return [{ return [{
"id": m.id,
"title": m.title, "title": m.title,
"description": m.description, "description": m.description,
"meeting_code": m.meeting_code,
"code": m.meeting_code,
"status": m.status.value, "status": m.status.value,
"priority": m.priority.value, "priority": m.priority.value,
"scheduled_at": m.scheduled_at, "scheduled_at": m.scheduled_at,
@@ -740,12 +747,12 @@ def list_meetings(project_code: str, milestone_id: int, db: Session = Depends(ge
@router.post("/meetings/{project_code}/{milestone_id}", status_code=status.HTTP_201_CREATED, tags=["Meetings"]) @router.post("/meetings/{project_code}/{milestone_id}", status_code=status.HTTP_201_CREATED, tags=["Meetings"])
def create_meeting(project_code: str, milestone_id: int, meeting_data: dict, db: Session = Depends(get_db), current_user: models.User = Depends(get_current_user_or_apikey)): def create_meeting(project_code: str, milestone_id: str, meeting_data: dict, db: Session = Depends(get_db), current_user: models.User = Depends(get_current_user_or_apikey)):
project = db.query(models.Project).filter(models.Project.project_code == project_code).first() project = db.query(models.Project).filter(models.Project.project_code == project_code).first()
if not project: if not project:
raise HTTPException(status_code=404, detail="Project not found") raise HTTPException(status_code=404, detail="Project not found")
ms = db.query(MilestoneModel).filter(MilestoneModel.id == milestone_id).first() ms = db.query(MilestoneModel).filter(MilestoneModel.milestone_code == milestone_id, MilestoneModel.project_id == project.id).first()
if not ms: if not ms:
raise HTTPException(status_code=404, detail="Milestone not found") raise HTTPException(status_code=404, detail="Milestone not found")
@@ -753,7 +760,7 @@ def create_meeting(project_code: str, milestone_id: int, meeting_data: dict, db:
raise HTTPException(status_code=400, detail="Cannot add items to a milestone that is undergoing") raise HTTPException(status_code=400, detail="Cannot add items to a milestone that is undergoing")
milestone_code = ms.milestone_code or f"m{ms.id}" milestone_code = ms.milestone_code or f"m{ms.id}"
max_meeting = db.query(Meeting).filter(Meeting.milestone_id == milestone_id).order_by(Meeting.id.desc()).first() max_meeting = db.query(Meeting).filter(Meeting.milestone_id == ms.id).order_by(Meeting.id.desc()).first()
next_num = (max_meeting.id + 1) if max_meeting else 1 next_num = (max_meeting.id + 1) if max_meeting else 1
meeting_code = f"{milestone_code}:M{next_num:05x}" meeting_code = f"{milestone_code}:M{next_num:05x}"
@@ -770,7 +777,7 @@ def create_meeting(project_code: str, milestone_id: int, meeting_data: dict, db:
status=MeetingStatus.SCHEDULED, status=MeetingStatus.SCHEDULED,
priority=MeetingPriority.MEDIUM, priority=MeetingPriority.MEDIUM,
project_id=project.id, project_id=project.id,
milestone_id=milestone_id, milestone_id=ms.id,
reporter_id=current_user.id, reporter_id=current_user.id,
meeting_code=meeting_code, meeting_code=meeting_code,
scheduled_at=scheduled_at, scheduled_at=scheduled_at,
@@ -779,4 +786,14 @@ def create_meeting(project_code: str, milestone_id: int, meeting_data: dict, db:
db.add(meeting) db.add(meeting)
db.commit() db.commit()
db.refresh(meeting) db.refresh(meeting)
return meeting return {
"meeting_code": meeting.meeting_code,
"code": meeting.meeting_code,
"title": meeting.title,
"description": meeting.description,
"status": meeting.status.value,
"priority": meeting.priority.value,
"scheduled_at": meeting.scheduled_at,
"duration_minutes": meeting.duration_minutes,
"created_at": meeting.created_at,
}

View File

@@ -22,6 +22,7 @@ from app.services.monitoring import (
get_server_states_view, get_server_states_view,
test_provider_connection, test_provider_connection,
) )
from app.services.discord_wakeup import create_private_wakeup_channel
router = APIRouter(prefix='/monitor', tags=['Monitor']) router = APIRouter(prefix='/monitor', tags=['Monitor'])
SUPPORTED_PROVIDERS = {'anthropic', 'openai', 'minimax', 'kimi', 'qwen'} SUPPORTED_PROVIDERS = {'anthropic', 'openai', 'minimax', 'kimi', 'qwen'}
@@ -42,6 +43,12 @@ class MonitoredServerCreate(BaseModel):
display_name: str | None = None display_name: str | None = None
class DiscordWakeupTestRequest(BaseModel):
discord_user_id: str
title: str = "HarborForge Wakeup"
message: str = "A HarborForge slot is ready to start."
def require_admin(current_user: models.User = Depends(get_current_user_or_apikey)): def require_admin(current_user: models.User = Depends(get_current_user_or_apikey)):
if not current_user.is_admin: if not current_user.is_admin:
raise HTTPException(status_code=403, detail='Admin required') raise HTTPException(status_code=403, detail='Admin required')
@@ -175,43 +182,11 @@ def revoke_api_key(server_id: int, db: Session = Depends(get_db), _: models.User
return None return None
class ServerHeartbeat(BaseModel): @router.post('/admin/discord-wakeup/test')
identifier: str def discord_wakeup_test(payload: DiscordWakeupTestRequest, _: models.User = Depends(require_admin)):
openclaw_version: str | None = None return create_private_wakeup_channel(payload.discord_user_id, payload.title, payload.message)
plugin_version: str | None = None
agents: List[dict] = []
nginx_installed: bool | None = None
nginx_sites: List[str] = []
cpu_pct: float | None = None
mem_pct: float | None = None
disk_pct: float | None = None
swap_pct: float | None = None
@router.post('/server/heartbeat')
def server_heartbeat(payload: ServerHeartbeat, db: Session = Depends(get_db)):
server = db.query(MonitoredServer).filter(MonitoredServer.identifier == payload.identifier, MonitoredServer.is_enabled == True).first()
if not server:
raise HTTPException(status_code=404, detail='unknown server identifier')
st = db.query(ServerState).filter(ServerState.server_id == server.id).first()
if not st:
st = ServerState(server_id=server.id)
db.add(st)
st.openclaw_version = payload.openclaw_version
st.plugin_version = payload.plugin_version
st.agents_json = json.dumps(payload.agents, ensure_ascii=False)
st.nginx_installed = payload.nginx_installed
st.nginx_sites_json = json.dumps(payload.nginx_sites, ensure_ascii=False)
st.cpu_pct = payload.cpu_pct
st.mem_pct = payload.mem_pct
st.disk_pct = payload.disk_pct
st.swap_pct = payload.swap_pct
st.last_seen_at = datetime.now(timezone.utc)
db.commit()
return {'ok': True, 'server_id': server.id, 'last_seen_at': st.last_seen_at}
# Heartbeat v2 with API Key authentication
class TelemetryPayload(BaseModel): class TelemetryPayload(BaseModel):
identifier: str identifier: str
openclaw_version: str | None = None openclaw_version: str | None = None
@@ -227,13 +202,13 @@ class TelemetryPayload(BaseModel):
uptime_seconds: int | None = None uptime_seconds: int | None = None
@router.post('/server/heartbeat-v2') @router.post('/server/heartbeat')
def server_heartbeat_v2( def server_heartbeat(
payload: TelemetryPayload, payload: TelemetryPayload,
x_api_key: str = Header(..., alias='X-API-Key', description='API Key from /admin/servers/{id}/api-key'), x_api_key: str = Header(..., alias='X-API-Key', description='API Key from /admin/servers/{id}/api-key'),
db: Session = Depends(get_db) db: Session = Depends(get_db)
): ):
"""Server heartbeat using API Key authentication (no challenge_uuid required)""" """Server heartbeat using API Key authentication."""
server = db.query(MonitoredServer).filter( server = db.query(MonitoredServer).filter(
MonitoredServer.api_key == x_api_key, MonitoredServer.api_key == x_api_key,
MonitoredServer.is_enabled == True MonitoredServer.is_enabled == True
@@ -256,4 +231,3 @@ def server_heartbeat_v2(
st.last_seen_at = datetime.now(timezone.utc) st.last_seen_at = datetime.now(timezone.utc)
db.commit() db.commit()
return {'ok': True, 'server_id': server.id, 'identifier': server.identifier, 'last_seen_at': st.last_seen_at} return {'ok': True, 'server_id': server.id, 'identifier': server.identifier, 'last_seen_at': st.last_seen_at}

View File

@@ -19,15 +19,14 @@ from app.models.task import Task, TaskStatus, TaskPriority
from app.schemas import schemas from app.schemas import schemas
from app.services.activity import log_activity from app.services.activity import log_activity
router = APIRouter(prefix="/projects/{project_id}/proposals", tags=["Proposals"]) router = APIRouter(prefix="/projects/{project_code}/proposals", tags=["Proposals"])
def _serialize_essential(e: Essential) -> dict: def _serialize_essential(e: Essential, proposal_code: str | None) -> dict:
"""Serialize an Essential for embedding in Proposal detail.""" """Serialize an Essential for embedding in Proposal detail."""
return { return {
"id": e.id,
"essential_code": e.essential_code, "essential_code": e.essential_code,
"proposal_id": e.proposal_id, "proposal_code": proposal_code,
"type": e.type.value if hasattr(e.type, "value") else e.type, "type": e.type.value if hasattr(e.type, "value") else e.type,
"title": e.title, "title": e.title,
"description": e.description, "description": e.description,
@@ -41,14 +40,14 @@ def _serialize_proposal(db: Session, proposal: Proposal, *, include_essentials:
"""Serialize proposal with created_by_username.""" """Serialize proposal with created_by_username."""
creator = db.query(models.User).filter(models.User.id == proposal.created_by_id).first() if proposal.created_by_id else None creator = db.query(models.User).filter(models.User.id == proposal.created_by_id).first() if proposal.created_by_id else None
code = proposal.propose_code # DB column; also exposed as proposal_code code = proposal.propose_code # DB column; also exposed as proposal_code
project = db.query(models.Project).filter(models.Project.id == proposal.project_id).first()
result = { result = {
"id": proposal.id,
"title": proposal.title, "title": proposal.title,
"description": proposal.description, "description": proposal.description,
"proposal_code": code, # preferred name "proposal_code": code, # preferred name
"propose_code": code, # backward compat "propose_code": code, # backward compat
"status": proposal.status.value if hasattr(proposal.status, "value") else proposal.status, "status": proposal.status.value if hasattr(proposal.status, "value") else proposal.status,
"project_id": proposal.project_id, "project_code": project.project_code if project else None,
"created_by_id": proposal.created_by_id, "created_by_id": proposal.created_by_id,
"created_by_username": creator.username if creator else None, "created_by_username": creator.username if creator else None,
"feat_task_id": proposal.feat_task_id, # DEPRECATED (BE-PR-010): read-only for legacy rows. Clients should use generated_tasks. "feat_task_id": proposal.feat_task_id, # DEPRECATED (BE-PR-010): read-only for legacy rows. Clients should use generated_tasks.
@@ -62,7 +61,7 @@ def _serialize_proposal(db: Session, proposal: Proposal, *, include_essentials:
.order_by(Essential.id.asc()) .order_by(Essential.id.asc())
.all() .all()
) )
result["essentials"] = [_serialize_essential(e) for e in essentials] result["essentials"] = [_serialize_essential(e, code) for e in essentials]
# BE-PR-008: include tasks generated from this Proposal via Accept # BE-PR-008: include tasks generated from this Proposal via Accept
gen_tasks = ( gen_tasks = (
@@ -71,46 +70,34 @@ def _serialize_proposal(db: Session, proposal: Proposal, *, include_essentials:
.order_by(Task.id.asc()) .order_by(Task.id.asc())
.all() .all()
) )
def _lookup_essential_code(essential_id: int | None) -> str | None:
if not essential_id:
return None
essential = db.query(Essential).filter(Essential.id == essential_id).first()
return essential.essential_code if essential else None
result["generated_tasks"] = [ result["generated_tasks"] = [
{ {
"task_id": t.id,
"task_code": t.task_code, "task_code": t.task_code,
"task_type": t.task_type or "story", "task_type": t.task_type or "story",
"task_subtype": t.task_subtype, "task_subtype": t.task_subtype,
"title": t.title, "title": t.title,
"status": t.status.value if hasattr(t.status, "value") else t.status, "status": t.status.value if hasattr(t.status, "value") else t.status,
"source_essential_id": t.source_essential_id, "source_essential_code": _lookup_essential_code(t.source_essential_id),
} }
for t in gen_tasks for t in gen_tasks
] ]
return result return result
def _find_project(db, identifier): def _find_project(db, project_code: str):
"""Look up project by numeric id or project_code.""" """Look up project by project_code."""
try: return db.query(models.Project).filter(models.Project.project_code == str(project_code)).first()
pid = int(identifier)
p = db.query(models.Project).filter(models.Project.id == pid).first()
if p:
return p
except (ValueError, TypeError):
pass
return db.query(models.Project).filter(models.Project.project_code == str(identifier)).first()
def _find_proposal(db, identifier, project_id: int = None) -> Proposal | None: def _find_proposal(db, proposal_code: str, project_id: int = None) -> Proposal | None:
"""Look up proposal by numeric id or propose_code.""" """Look up proposal by propose_code."""
try: q = db.query(Proposal).filter(Proposal.propose_code == str(proposal_code))
pid = int(identifier)
q = db.query(Proposal).filter(Proposal.id == pid)
if project_id:
q = q.filter(Proposal.project_id == project_id)
p = q.first()
if p:
return p
except (ValueError, TypeError):
pass
q = db.query(Proposal).filter(Proposal.propose_code == str(identifier))
if project_id: if project_id:
q = q.filter(Proposal.project_id == project_id) q = q.filter(Proposal.project_id == project_id)
return q.first() return q.first()
@@ -147,11 +134,11 @@ def _can_edit_proposal(db: Session, user_id: int, proposal: Proposal) -> bool:
@router.get("", response_model=List[schemas.ProposalResponse]) @router.get("", response_model=List[schemas.ProposalResponse])
def list_proposals( def list_proposals(
project_id: str, project_code: str,
db: Session = Depends(get_db), db: Session = Depends(get_db),
current_user: models.User = Depends(get_current_user_or_apikey), current_user: models.User = Depends(get_current_user_or_apikey),
): ):
project = _find_project(db, project_id) project = _find_project(db, project_code)
if not project: if not project:
raise HTTPException(status_code=404, detail="Project not found") raise HTTPException(status_code=404, detail="Project not found")
check_project_role(db, current_user.id, project.id, min_role="viewer") check_project_role(db, current_user.id, project.id, min_role="viewer")
@@ -166,12 +153,12 @@ def list_proposals(
@router.post("", response_model=schemas.ProposalResponse, status_code=status.HTTP_201_CREATED) @router.post("", response_model=schemas.ProposalResponse, status_code=status.HTTP_201_CREATED)
def create_proposal( def create_proposal(
project_id: str, project_code: str,
proposal_in: schemas.ProposalCreate, proposal_in: schemas.ProposalCreate,
db: Session = Depends(get_db), db: Session = Depends(get_db),
current_user: models.User = Depends(get_current_user_or_apikey), current_user: models.User = Depends(get_current_user_or_apikey),
): ):
project = _find_project(db, project_id) project = _find_project(db, project_code)
if not project: if not project:
raise HTTPException(status_code=404, detail="Project not found") raise HTTPException(status_code=404, detail="Project not found")
check_project_role(db, current_user.id, project.id, min_role="dev") check_project_role(db, current_user.id, project.id, min_role="dev")
@@ -197,17 +184,17 @@ def create_proposal(
@router.get("/{proposal_id}", response_model=schemas.ProposalDetailResponse) @router.get("/{proposal_id}", response_model=schemas.ProposalDetailResponse)
def get_proposal( def get_proposal(
project_id: str, project_code: str,
proposal_id: str, proposal_code: str,
db: Session = Depends(get_db), db: Session = Depends(get_db),
current_user: models.User = Depends(get_current_user_or_apikey), current_user: models.User = Depends(get_current_user_or_apikey),
): ):
"""Get a single Proposal with its Essentials list embedded.""" """Get a single Proposal with its Essentials list embedded."""
project = _find_project(db, project_id) project = _find_project(db, project_code)
if not project: if not project:
raise HTTPException(status_code=404, detail="Project not found") raise HTTPException(status_code=404, detail="Project not found")
check_project_role(db, current_user.id, project.id, min_role="viewer") check_project_role(db, current_user.id, project.id, min_role="viewer")
proposal = _find_proposal(db, proposal_id, project.id) proposal = _find_proposal(db, proposal_code, project.id)
if not proposal: if not proposal:
raise HTTPException(status_code=404, detail="Proposal not found") raise HTTPException(status_code=404, detail="Proposal not found")
return _serialize_proposal(db, proposal, include_essentials=True) return _serialize_proposal(db, proposal, include_essentials=True)
@@ -215,16 +202,16 @@ def get_proposal(
@router.patch("/{proposal_id}", response_model=schemas.ProposalResponse) @router.patch("/{proposal_id}", response_model=schemas.ProposalResponse)
def update_proposal( def update_proposal(
project_id: str, project_code: str,
proposal_id: str, proposal_code: str,
proposal_in: schemas.ProposalUpdate, proposal_in: schemas.ProposalUpdate,
db: Session = Depends(get_db), db: Session = Depends(get_db),
current_user: models.User = Depends(get_current_user_or_apikey), current_user: models.User = Depends(get_current_user_or_apikey),
): ):
project = _find_project(db, project_id) project = _find_project(db, project_code)
if not project: if not project:
raise HTTPException(status_code=404, detail="Project not found") raise HTTPException(status_code=404, detail="Project not found")
proposal = _find_proposal(db, proposal_id, project.id) proposal = _find_proposal(db, proposal_code, project.id)
if not proposal: if not proposal:
raise HTTPException(status_code=404, detail="Proposal not found") raise HTTPException(status_code=404, detail="Proposal not found")
@@ -253,13 +240,13 @@ def update_proposal(
# ---- Actions ---- # ---- Actions ----
class AcceptRequest(schemas.BaseModel): class AcceptRequest(schemas.BaseModel):
milestone_id: int milestone_code: str
@router.post("/{proposal_id}/accept", response_model=schemas.ProposalAcceptResponse) @router.post("/{proposal_id}/accept", response_model=schemas.ProposalAcceptResponse)
def accept_proposal( def accept_proposal(
project_id: str, project_code: str,
proposal_id: str, proposal_code: str,
body: AcceptRequest, body: AcceptRequest,
db: Session = Depends(get_db), db: Session = Depends(get_db),
current_user: models.User = Depends(get_current_user_or_apikey), current_user: models.User = Depends(get_current_user_or_apikey),
@@ -274,10 +261,10 @@ def accept_proposal(
All tasks are created in a single transaction. The Proposal must have at All tasks are created in a single transaction. The Proposal must have at
least one Essential to be accepted. least one Essential to be accepted.
""" """
project = _find_project(db, project_id) project = _find_project(db, project_code)
if not project: if not project:
raise HTTPException(status_code=404, detail="Project not found") raise HTTPException(status_code=404, detail="Project not found")
proposal = _find_proposal(db, proposal_id, project.id) proposal = _find_proposal(db, proposal_code, project.id)
if not proposal: if not proposal:
raise HTTPException(status_code=404, detail="Proposal not found") raise HTTPException(status_code=404, detail="Proposal not found")
@@ -289,7 +276,7 @@ def accept_proposal(
# Validate milestone # Validate milestone
milestone = db.query(Milestone).filter( milestone = db.query(Milestone).filter(
Milestone.id == body.milestone_id, Milestone.milestone_code == body.milestone_code,
Milestone.project_id == project.id, Milestone.project_id == project.id,
).first() ).first()
if not milestone: if not milestone:
@@ -355,12 +342,10 @@ def accept_proposal(
db.flush() # materialise task.id db.flush() # materialise task.id
generated_tasks.append({ generated_tasks.append({
"task_id": task.id,
"task_code": task_code, "task_code": task_code,
"task_type": "story", "task_type": "story",
"task_subtype": task_subtype, "task_subtype": task_subtype,
"title": essential.title, "title": essential.title,
"essential_id": essential.id,
"essential_code": essential.essential_code, "essential_code": essential.essential_code,
}) })
next_num = task.id + 1 # use real id for next code to stay consistent next_num = task.id + 1 # use real id for next code to stay consistent
@@ -372,9 +357,9 @@ def accept_proposal(
db.refresh(proposal) db.refresh(proposal)
log_activity(db, "accept", "proposal", proposal.id, user_id=current_user.id, details={ log_activity(db, "accept", "proposal", proposal.id, user_id=current_user.id, details={
"milestone_id": milestone.id, "milestone_code": milestone.milestone_code,
"generated_tasks": [ "generated_tasks": [
{"task_id": t["task_id"], "task_code": t["task_code"], "essential_id": t["essential_id"]} {"task_code": t["task_code"], "essential_code": t["essential_code"]}
for t in generated_tasks for t in generated_tasks
], ],
}) })
@@ -390,17 +375,17 @@ class RejectRequest(schemas.BaseModel):
@router.post("/{proposal_id}/reject", response_model=schemas.ProposalResponse) @router.post("/{proposal_id}/reject", response_model=schemas.ProposalResponse)
def reject_proposal( def reject_proposal(
project_id: str, project_code: str,
proposal_id: str, proposal_code: str,
body: RejectRequest | None = None, body: RejectRequest | None = None,
db: Session = Depends(get_db), db: Session = Depends(get_db),
current_user: models.User = Depends(get_current_user_or_apikey), current_user: models.User = Depends(get_current_user_or_apikey),
): ):
"""Reject a proposal.""" """Reject a proposal."""
project = _find_project(db, project_id) project = _find_project(db, project_code)
if not project: if not project:
raise HTTPException(status_code=404, detail="Project not found") raise HTTPException(status_code=404, detail="Project not found")
proposal = _find_proposal(db, proposal_id, project.id) proposal = _find_proposal(db, proposal_code, project.id)
if not proposal: if not proposal:
raise HTTPException(status_code=404, detail="Proposal not found") raise HTTPException(status_code=404, detail="Proposal not found")
@@ -423,16 +408,16 @@ def reject_proposal(
@router.post("/{proposal_id}/reopen", response_model=schemas.ProposalResponse) @router.post("/{proposal_id}/reopen", response_model=schemas.ProposalResponse)
def reopen_proposal( def reopen_proposal(
project_id: str, project_code: str,
proposal_id: str, proposal_code: str,
db: Session = Depends(get_db), db: Session = Depends(get_db),
current_user: models.User = Depends(get_current_user_or_apikey), current_user: models.User = Depends(get_current_user_or_apikey),
): ):
"""Reopen a rejected proposal back to open.""" """Reopen a rejected proposal back to open."""
project = _find_project(db, project_id) project = _find_project(db, project_code)
if not project: if not project:
raise HTTPException(status_code=404, detail="Project not found") raise HTTPException(status_code=404, detail="Project not found")
proposal = _find_proposal(db, proposal_id, project.id) proposal = _find_proposal(db, proposal_code, project.id)
if not proposal: if not proposal:
raise HTTPException(status_code=404, detail="Proposal not found") raise HTTPException(status_code=404, detail="Proposal not found")

View File

@@ -28,83 +28,83 @@ from app.api.rbac import check_project_role, check_permission, is_global_admin
from app.services.activity import log_activity from app.services.activity import log_activity
# Legacy router — same logic, old URL prefix # Legacy router — same logic, old URL prefix
router = APIRouter(prefix="/projects/{project_id}/proposes", tags=["Proposes (legacy)"]) router = APIRouter(prefix="/projects/{project_code}/proposes", tags=["Proposes (legacy)"])
@router.get("", response_model=List[schemas.ProposalResponse]) @router.get("", response_model=List[schemas.ProposalResponse])
def list_proposes( def list_proposes(
project_id: str, project_code: str,
db: Session = Depends(get_db), db: Session = Depends(get_db),
current_user: models.User = Depends(get_current_user_or_apikey), current_user: models.User = Depends(get_current_user_or_apikey),
): ):
from app.api.routers.proposals import list_proposals from app.api.routers.proposals import list_proposals
return list_proposals(project_id=project_id, db=db, current_user=current_user) return list_proposals(project_code=project_code, db=db, current_user=current_user)
@router.post("", response_model=schemas.ProposalResponse, status_code=status.HTTP_201_CREATED) @router.post("", response_model=schemas.ProposalResponse, status_code=status.HTTP_201_CREATED)
def create_propose( def create_propose(
project_id: str, project_code: str,
proposal_in: schemas.ProposalCreate, proposal_in: schemas.ProposalCreate,
db: Session = Depends(get_db), db: Session = Depends(get_db),
current_user: models.User = Depends(get_current_user_or_apikey), current_user: models.User = Depends(get_current_user_or_apikey),
): ):
from app.api.routers.proposals import create_proposal from app.api.routers.proposals import create_proposal
return create_proposal(project_id=project_id, proposal_in=proposal_in, db=db, current_user=current_user) return create_proposal(project_code=project_code, proposal_in=proposal_in, db=db, current_user=current_user)
@router.get("/{propose_id}", response_model=schemas.ProposalResponse) @router.get("/{propose_id}", response_model=schemas.ProposalResponse)
def get_propose( def get_propose(
project_id: str, project_code: str,
propose_id: str, propose_id: str,
db: Session = Depends(get_db), db: Session = Depends(get_db),
current_user: models.User = Depends(get_current_user_or_apikey), current_user: models.User = Depends(get_current_user_or_apikey),
): ):
from app.api.routers.proposals import get_proposal from app.api.routers.proposals import get_proposal
return get_proposal(project_id=project_id, proposal_id=propose_id, db=db, current_user=current_user) return get_proposal(project_code=project_code, proposal_code=propose_id, db=db, current_user=current_user)
@router.patch("/{propose_id}", response_model=schemas.ProposalResponse) @router.patch("/{propose_id}", response_model=schemas.ProposalResponse)
def update_propose( def update_propose(
project_id: str, project_code: str,
propose_id: str, propose_id: str,
proposal_in: schemas.ProposalUpdate, proposal_in: schemas.ProposalUpdate,
db: Session = Depends(get_db), db: Session = Depends(get_db),
current_user: models.User = Depends(get_current_user_or_apikey), current_user: models.User = Depends(get_current_user_or_apikey),
): ):
from app.api.routers.proposals import update_proposal from app.api.routers.proposals import update_proposal
return update_proposal(project_id=project_id, proposal_id=propose_id, proposal_in=proposal_in, db=db, current_user=current_user) return update_proposal(project_code=project_code, proposal_code=propose_id, proposal_in=proposal_in, db=db, current_user=current_user)
@router.post("/{propose_id}/accept", response_model=schemas.ProposalResponse) @router.post("/{propose_id}/accept", response_model=schemas.ProposalResponse)
def accept_propose( def accept_propose(
project_id: str, project_code: str,
propose_id: str, propose_id: str,
body: AcceptRequest, body: AcceptRequest,
db: Session = Depends(get_db), db: Session = Depends(get_db),
current_user: models.User = Depends(get_current_user_or_apikey), current_user: models.User = Depends(get_current_user_or_apikey),
): ):
from app.api.routers.proposals import accept_proposal from app.api.routers.proposals import accept_proposal
return accept_proposal(project_id=project_id, proposal_id=propose_id, body=body, db=db, current_user=current_user) return accept_proposal(project_code=project_code, proposal_code=propose_id, body=body, db=db, current_user=current_user)
@router.post("/{propose_id}/reject", response_model=schemas.ProposalResponse) @router.post("/{propose_id}/reject", response_model=schemas.ProposalResponse)
def reject_propose( def reject_propose(
project_id: str, project_code: str,
propose_id: str, propose_id: str,
body: RejectRequest | None = None, body: RejectRequest | None = None,
db: Session = Depends(get_db), db: Session = Depends(get_db),
current_user: models.User = Depends(get_current_user_or_apikey), current_user: models.User = Depends(get_current_user_or_apikey),
): ):
from app.api.routers.proposals import reject_proposal from app.api.routers.proposals import reject_proposal
return reject_proposal(project_id=project_id, proposal_id=propose_id, body=body, db=db, current_user=current_user) return reject_proposal(project_code=project_code, proposal_code=propose_id, body=body, db=db, current_user=current_user)
@router.post("/{propose_id}/reopen", response_model=schemas.ProposalResponse) @router.post("/{propose_id}/reopen", response_model=schemas.ProposalResponse)
def reopen_propose( def reopen_propose(
project_id: str, project_code: str,
propose_id: str, propose_id: str,
db: Session = Depends(get_db), db: Session = Depends(get_db),
current_user: models.User = Depends(get_current_user_or_apikey), current_user: models.User = Depends(get_current_user_or_apikey),
): ):
from app.api.routers.proposals import reopen_proposal from app.api.routers.proposals import reopen_proposal
return reopen_proposal(project_id=project_id, proposal_id=propose_id, db=db, current_user=current_user) return reopen_proposal(project_code=project_code, proposal_code=propose_id, db=db, current_user=current_user)

View File

@@ -10,6 +10,8 @@ from app.core.config import get_db
from app.models import models from app.models import models
from app.models.task import Task, TaskStatus, TaskPriority from app.models.task import Task, TaskStatus, TaskPriority
from app.models.milestone import Milestone from app.models.milestone import Milestone
from app.models.proposal import Proposal
from app.models.essential import Essential
from app.schemas import schemas from app.schemas import schemas
from app.services.webhook import fire_webhooks_sync from app.services.webhook import fire_webhooks_sync
from app.models.notification import Notification as NotificationModel from app.models.notification import Notification as NotificationModel
@@ -21,14 +23,9 @@ from app.services.dependency_check import check_task_deps
router = APIRouter(tags=["Tasks"]) router = APIRouter(tags=["Tasks"])
def _resolve_task(db: Session, identifier: str) -> Task: def _resolve_task(db: Session, task_code: str) -> Task:
"""Resolve a task by numeric id or task_code string. """Resolve a task by task_code string. Raises 404 if not found."""
Raises 404 if not found.""" task = db.query(Task).filter(Task.task_code == task_code).first()
try:
task_id = int(identifier)
task = db.query(Task).filter(Task.id == task_id).first()
except (ValueError, TypeError):
task = db.query(Task).filter(Task.task_code == identifier).first()
if not task: if not task:
raise HTTPException(status_code=404, detail="Task not found") raise HTTPException(status_code=404, detail="Task not found")
return task return task
@@ -118,9 +115,7 @@ def _notify_user(db, user_id, ntype, title, message=None, entity_type=None, enti
return n return n
def _resolve_project_id(db: Session, project_id: int | None, project_code: str | None) -> int | None: def _resolve_project_id(db: Session, project_code: str | None) -> int | None:
if project_id:
return project_id
if not project_code: if not project_code:
return None return None
project = db.query(models.Project).filter(models.Project.project_code == project_code).first() project = db.query(models.Project).filter(models.Project.project_code == project_code).first()
@@ -129,40 +124,36 @@ def _resolve_project_id(db: Session, project_id: int | None, project_code: str |
return project.id return project.id
def _resolve_milestone(db: Session, milestone_id: int | None, milestone_code: str | None, project_id: int | None) -> Milestone | None: def _resolve_milestone(db: Session, milestone_code: str | None, project_id: int | None) -> Milestone | None:
if milestone_id: if not milestone_code:
query = db.query(Milestone).filter(Milestone.id == milestone_id) return None
if project_id:
query = query.filter(Milestone.project_id == project_id)
milestone = query.first()
elif milestone_code:
query = db.query(Milestone).filter(Milestone.milestone_code == milestone_code) query = db.query(Milestone).filter(Milestone.milestone_code == milestone_code)
if project_id: if project_id:
query = query.filter(Milestone.project_id == project_id) query = query.filter(Milestone.project_id == project_id)
milestone = query.first() milestone = query.first()
else:
return None
if not milestone: if not milestone:
raise HTTPException(status_code=404, detail="Milestone not found") raise HTTPException(status_code=404, detail="Milestone not found")
return milestone return milestone
def _find_task_by_id_or_code(db: Session, identifier: str) -> Task | None: def _find_task_by_code(db: Session, task_code: str) -> Task | None:
try: return db.query(Task).filter(Task.task_code == task_code).first()
task_id = int(identifier)
task = db.query(Task).filter(Task.id == task_id).first()
if task:
return task
except ValueError:
pass
return db.query(Task).filter(Task.task_code == identifier).first()
def _serialize_task(db: Session, task: Task) -> dict: def _serialize_task(db: Session, task: Task) -> dict:
payload = schemas.TaskResponse.model_validate(task).model_dump(mode="json") payload = schemas.TaskResponse.model_validate(task).model_dump(mode="json")
project = db.query(models.Project).filter(models.Project.id == task.project_id).first() project = db.query(models.Project).filter(models.Project.id == task.project_id).first()
milestone = db.query(Milestone).filter(Milestone.id == task.milestone_id).first() milestone = db.query(Milestone).filter(Milestone.id == task.milestone_id).first()
proposal_code = None
essential_code = None
if task.source_proposal_id:
proposal = db.query(Proposal).filter(Proposal.id == task.source_proposal_id).first()
proposal_code = proposal.propose_code if proposal else None
if task.source_essential_id:
essential = db.query(Essential).filter(Essential.id == task.source_essential_id).first()
essential_code = essential.essential_code if essential else None
assignee = None assignee = None
if task.assignee_id: if task.assignee_id:
assignee = db.query(models.User).filter(models.User.id == task.assignee_id).first() assignee = db.query(models.User).filter(models.User.id == task.assignee_id).first()
@@ -174,6 +165,8 @@ def _serialize_task(db: Session, task: Task) -> dict:
"milestone_code": milestone.milestone_code if milestone else None, "milestone_code": milestone.milestone_code if milestone else None,
"taken_by": assignee.username if assignee else None, "taken_by": assignee.username if assignee else None,
"due_date": None, "due_date": None,
"source_proposal_code": proposal_code,
"source_essential_code": essential_code,
}) })
return payload return payload
@@ -191,8 +184,8 @@ def create_task(task_in: schemas.TaskCreate, bg: BackgroundTasks, db: Session =
else: else:
data.pop("type", None) data.pop("type", None)
data["project_id"] = _resolve_project_id(db, data.get("project_id"), data.pop("project_code", None)) data["project_id"] = _resolve_project_id(db, data.pop("project_code", None))
milestone = _resolve_milestone(db, data.get("milestone_id"), data.pop("milestone_code", None), data.get("project_id")) milestone = _resolve_milestone(db, data.pop("milestone_code", None), data.get("project_id"))
if milestone: if milestone:
data["milestone_id"] = milestone.id data["milestone_id"] = milestone.id
data["project_id"] = milestone.project_id data["project_id"] = milestone.project_id
@@ -201,17 +194,12 @@ def create_task(task_in: schemas.TaskCreate, bg: BackgroundTasks, db: Session =
data["created_by_id"] = current_user.id data["created_by_id"] = current_user.id
if not data.get("project_id"): if not data.get("project_id"):
raise HTTPException(status_code=400, detail="project_id or project_code is required") raise HTTPException(status_code=400, detail="project_code is required")
if not data.get("milestone_id"): if not data.get("milestone_id"):
raise HTTPException(status_code=400, detail="milestone_id or milestone_code is required") raise HTTPException(status_code=400, detail="milestone_code is required")
check_project_role(db, current_user.id, data["project_id"], min_role="dev") check_project_role(db, current_user.id, data["project_id"], min_role="dev")
if not milestone:
milestone = db.query(Milestone).filter(
Milestone.id == data["milestone_id"],
Milestone.project_id == data["project_id"],
).first()
if not milestone: if not milestone:
raise HTTPException(status_code=404, detail="Milestone not found") raise HTTPException(status_code=404, detail="Milestone not found")
@@ -237,7 +225,7 @@ def create_task(task_in: schemas.TaskCreate, bg: BackgroundTasks, db: Session =
bg.add_task( bg.add_task(
fire_webhooks_sync, fire_webhooks_sync,
event, event,
{"task_id": db_task.id, "title": db_task.title, "type": db_task.task_type, "status": db_task.status.value}, {"task_code": db_task.task_code, "title": db_task.title, "type": db_task.task_type, "status": db_task.status.value},
db_task.project_id, db_task.project_id,
db, db,
) )
@@ -247,22 +235,22 @@ def create_task(task_in: schemas.TaskCreate, bg: BackgroundTasks, db: Session =
@router.get("/tasks") @router.get("/tasks")
def list_tasks( def list_tasks(
project_id: int = None, task_status: str = None, task_type: str = None, task_subtype: str = None, task_status: str = None, task_type: str = None, task_subtype: str = None,
assignee_id: int = None, tag: str = None, assignee_id: int = None, tag: str = None,
sort_by: str = "created_at", sort_order: str = "desc", sort_by: str = "created_at", sort_order: str = "desc",
page: int = 1, page_size: int = 50, page: int = 1, page_size: int = 50,
project: str = None, milestone: str = None, status_value: str = Query(None, alias="status"), taken_by: str = None, project_code: str = None, milestone_code: str = None, status_value: str = Query(None, alias="status"), taken_by: str = None,
order_by: str = None, order_by: str = None,
db: Session = Depends(get_db) db: Session = Depends(get_db)
): ):
query = db.query(Task) query = db.query(Task)
resolved_project_id = _resolve_project_id(db, project_id, project) resolved_project_id = _resolve_project_id(db, project_code)
if resolved_project_id: if resolved_project_id:
query = query.filter(Task.project_id == resolved_project_id) query = query.filter(Task.project_id == resolved_project_id)
if milestone: if milestone_code:
milestone_obj = _resolve_milestone(db, None, milestone, resolved_project_id) milestone_obj = _resolve_milestone(db, milestone_code, resolved_project_id)
query = query.filter(Task.milestone_id == milestone_obj.id) query = query.filter(Task.milestone_id == milestone_obj.id)
effective_status = status_value or task_status effective_status = status_value or task_status
@@ -316,14 +304,14 @@ def list_tasks(
@router.get("/tasks/search", response_model=List[schemas.TaskResponse]) @router.get("/tasks/search", response_model=List[schemas.TaskResponse])
def search_tasks_alias( def search_tasks_alias(
q: str, q: str,
project: str = None, project_code: str = None,
status: str = None, status: str = None,
db: Session = Depends(get_db), db: Session = Depends(get_db),
): ):
query = db.query(Task).filter( query = db.query(Task).filter(
(Task.title.contains(q)) | (Task.description.contains(q)) (Task.title.contains(q)) | (Task.description.contains(q))
) )
resolved_project_id = _resolve_project_id(db, None, project) resolved_project_id = _resolve_project_id(db, project_code)
if resolved_project_id: if resolved_project_id:
query = query.filter(Task.project_id == resolved_project_id) query = query.filter(Task.project_id == resolved_project_id)
if status: if status:
@@ -332,15 +320,15 @@ def search_tasks_alias(
return [_serialize_task(db, i) for i in items] return [_serialize_task(db, i) for i in items]
@router.get("/tasks/{task_id}", response_model=schemas.TaskResponse) @router.get("/tasks/{task_code}", response_model=schemas.TaskResponse)
def get_task(task_id: str, db: Session = Depends(get_db)): def get_task(task_code: str, db: Session = Depends(get_db)):
task = _resolve_task(db, task_id) task = _resolve_task(db, task_code)
return _serialize_task(db, task) return _serialize_task(db, task)
@router.patch("/tasks/{task_id}", response_model=schemas.TaskResponse) @router.patch("/tasks/{task_code}", response_model=schemas.TaskResponse)
def update_task(task_id: str, task_update: schemas.TaskUpdate, db: Session = Depends(get_db), current_user: models.User = Depends(get_current_user_or_apikey)): def update_task(task_code: str, task_update: schemas.TaskUpdate, db: Session = Depends(get_db), current_user: models.User = Depends(get_current_user_or_apikey)):
task = _resolve_task(db, task_id) task = _resolve_task(db, task_code)
# P5.7: status-based edit restrictions # P5.7: status-based edit restrictions
current_status = task.status.value if hasattr(task.status, 'value') else task.status current_status = task.status.value if hasattr(task.status, 'value') else task.status
@@ -437,9 +425,9 @@ def update_task(task_id: str, task_update: schemas.TaskUpdate, db: Session = Dep
return _serialize_task(db, task) return _serialize_task(db, task)
@router.delete("/tasks/{task_id}", status_code=status.HTTP_204_NO_CONTENT) @router.delete("/tasks/{task_code}", status_code=status.HTTP_204_NO_CONTENT)
def delete_task(task_id: str, db: Session = Depends(get_db), current_user: models.User = Depends(get_current_user_or_apikey)): def delete_task(task_code: str, db: Session = Depends(get_db), current_user: models.User = Depends(get_current_user_or_apikey)):
task = _resolve_task(db, task_id) task = _resolve_task(db, task_code)
check_project_role(db, current_user.id, task.project_id, min_role="mgr") check_project_role(db, current_user.id, task.project_id, min_role="mgr")
log_activity(db, "task.deleted", "task", task.id, current_user.id, {"title": task.title}) log_activity(db, "task.deleted", "task", task.id, current_user.id, {"title": task.title})
db.delete(task) db.delete(task)
@@ -454,9 +442,9 @@ class TransitionBody(BaseModel):
comment: Optional[str] = None comment: Optional[str] = None
@router.post("/tasks/{task_id}/transition", response_model=schemas.TaskResponse) @router.post("/tasks/{task_code}/transition", response_model=schemas.TaskResponse)
def transition_task( def transition_task(
task_id: str, task_code: str,
bg: BackgroundTasks, bg: BackgroundTasks,
new_status: str | None = None, new_status: str | None = None,
body: TransitionBody = None, body: TransitionBody = None,
@@ -467,7 +455,7 @@ def transition_task(
valid_statuses = [s.value for s in TaskStatus] valid_statuses = [s.value for s in TaskStatus]
if new_status not in valid_statuses: if new_status not in valid_statuses:
raise HTTPException(status_code=400, detail=f"Invalid status. Must be one of: {valid_statuses}") raise HTTPException(status_code=400, detail=f"Invalid status. Must be one of: {valid_statuses}")
task = _resolve_task(db, task_id) task = _resolve_task(db, task_code)
old_status = task.status.value if hasattr(task.status, 'value') else task.status old_status = task.status.value if hasattr(task.status, 'value') else task.status
# P5.1: enforce state-machine # P5.1: enforce state-machine
@@ -547,18 +535,18 @@ def transition_task(
event = "task.closed" if new_status == "closed" else "task.updated" event = "task.closed" if new_status == "closed" else "task.updated"
bg.add_task(fire_webhooks_sync, event, bg.add_task(fire_webhooks_sync, event,
{"task_id": task.id, "title": task.title, "old_status": old_status, "new_status": new_status}, {"task_code": task.task_code, "title": task.title, "old_status": old_status, "new_status": new_status},
task.project_id, db) task.project_id, db)
return _serialize_task(db, task) return _serialize_task(db, task)
@router.post("/tasks/{task_id}/take", response_model=schemas.TaskResponse) @router.post("/tasks/{task_code}/take", response_model=schemas.TaskResponse)
def take_task( def take_task(
task_id: str, task_code: str,
db: Session = Depends(get_db), db: Session = Depends(get_db),
current_user: models.User = Depends(get_current_user_or_apikey), current_user: models.User = Depends(get_current_user_or_apikey),
): ):
task = _find_task_by_id_or_code(db, task_id) task = _find_task_by_code(db, task_code)
if not task: if not task:
raise HTTPException(status_code=404, detail="Task not found") raise HTTPException(status_code=404, detail="Task not found")
@@ -577,7 +565,7 @@ def take_task(
db, db,
current_user.id, current_user.id,
"task.assigned", "task.assigned",
f"Task {task.task_code or task.id} assigned to you", f"Task {task.task_code} assigned to you",
f"'{task.title}' has been assigned to you.", f"'{task.title}' has been assigned to you.",
"task", "task",
task.id, task.id,
@@ -587,9 +575,9 @@ def take_task(
# ---- Assignment ---- # ---- Assignment ----
@router.post("/tasks/{task_id}/assign") @router.post("/tasks/{task_code}/assign")
def assign_task(task_id: str, assignee_id: int, db: Session = Depends(get_db)): def assign_task(task_code: str, assignee_id: int, db: Session = Depends(get_db)):
task = _resolve_task(db, task_id) task = _resolve_task(db, task_code)
user = db.query(models.User).filter(models.User.id == assignee_id).first() user = db.query(models.User).filter(models.User.id == assignee_id).first()
if not user: if not user:
raise HTTPException(status_code=404, detail="User not found") raise HTTPException(status_code=404, detail="User not found")
@@ -597,33 +585,33 @@ def assign_task(task_id: str, assignee_id: int, db: Session = Depends(get_db)):
db.commit() db.commit()
db.refresh(task) db.refresh(task)
_notify_user(db, assignee_id, "task.assigned", _notify_user(db, assignee_id, "task.assigned",
f"Task #{task.id} assigned to you", f"Task {task.task_code} assigned to you",
f"'{task.title}' has been assigned to you.", "task", task.id) f"'{task.title}' has been assigned to you.", "task", task.id)
return {"task_id": task.id, "assignee_id": assignee_id, "title": task.title} return {"task_code": task.task_code, "assignee_id": assignee_id, "title": task.title}
# ---- Tags ---- # ---- Tags ----
@router.post("/tasks/{task_id}/tags") @router.post("/tasks/{task_code}/tags")
def add_tag(task_id: str, tag: str, db: Session = Depends(get_db)): def add_tag(task_code: str, tag: str, db: Session = Depends(get_db)):
task = _resolve_task(db, task_id) task = _resolve_task(db, task_code)
current = set(task.tags.split(",")) if task.tags else set() current = set(task.tags.split(",")) if task.tags else set()
current.add(tag.strip()) current.add(tag.strip())
current.discard("") current.discard("")
task.tags = ",".join(sorted(current)) task.tags = ",".join(sorted(current))
db.commit() db.commit()
return {"task_id": task_id, "tags": list(current)} return {"task_code": task.task_code, "tags": list(current)}
@router.delete("/tasks/{task_id}/tags") @router.delete("/tasks/{task_code}/tags")
def remove_tag(task_id: str, tag: str, db: Session = Depends(get_db)): def remove_tag(task_code: str, tag: str, db: Session = Depends(get_db)):
task = _resolve_task(db, task_id) task = _resolve_task(db, task_code)
current = set(task.tags.split(",")) if task.tags else set() current = set(task.tags.split(",")) if task.tags else set()
current.discard(tag.strip()) current.discard(tag.strip())
current.discard("") current.discard("")
task.tags = ",".join(sorted(current)) if current else None task.tags = ",".join(sorted(current)) if current else None
db.commit() db.commit()
return {"task_id": task_id, "tags": list(current)} return {"task_code": task.task_code, "tags": list(current)}
@router.get("/tags") @router.get("/tags")
@@ -643,12 +631,12 @@ def list_all_tags(project_id: int = None, db: Session = Depends(get_db)):
# ---- Batch ---- # ---- Batch ----
class BatchAssign(BaseModel): class BatchAssign(BaseModel):
task_ids: List[int] task_codes: List[str]
assignee_id: int assignee_id: int
class BatchTransitionBody(BaseModel): class BatchTransitionBody(BaseModel):
task_ids: List[int] task_codes: List[str]
new_status: str new_status: str
comment: Optional[str] = None comment: Optional[str] = None
@@ -665,17 +653,17 @@ def batch_transition(
raise HTTPException(status_code=400, detail="Invalid status") raise HTTPException(status_code=400, detail="Invalid status")
updated = [] updated = []
skipped = [] skipped = []
for task_id in data.task_ids: for task_code in data.task_codes:
task = db.query(Task).filter(Task.id == task_id).first() task = db.query(Task).filter(Task.task_code == task_code).first()
if not task: if not task:
skipped.append({"id": task_id, "title": None, "old": None, skipped.append({"task_code": task_code, "title": None, "old": None,
"reason": "Task not found"}) "reason": "Task not found"})
continue continue
old_status = task.status.value if hasattr(task.status, 'value') else task.status old_status = task.status.value if hasattr(task.status, 'value') else task.status
# P5.1: state-machine check # P5.1: state-machine check
allowed = VALID_TRANSITIONS.get(old_status, set()) allowed = VALID_TRANSITIONS.get(old_status, set())
if data.new_status not in allowed: if data.new_status not in allowed:
skipped.append({"id": task.id, "title": task.title, "old": old_status, skipped.append({"task_code": task.task_code, "title": task.title, "old": old_status,
"reason": f"Cannot transition from '{old_status}' to '{data.new_status}'"}) "reason": f"Cannot transition from '{old_status}' to '{data.new_status}'"})
continue continue
@@ -685,23 +673,23 @@ def batch_transition(
if milestone: if milestone:
ms_status = milestone.status.value if hasattr(milestone.status, 'value') else milestone.status ms_status = milestone.status.value if hasattr(milestone.status, 'value') else milestone.status
if ms_status != "undergoing": if ms_status != "undergoing":
skipped.append({"id": task.id, "title": task.title, "old": old_status, skipped.append({"task_code": task.task_code, "title": task.title, "old": old_status,
"reason": f"Milestone is '{ms_status}', must be 'undergoing'"}) "reason": f"Milestone is '{ms_status}', must be 'undergoing'"})
continue continue
dep_result = check_task_deps(db, task.depend_on) dep_result = check_task_deps(db, task.depend_on)
if not dep_result.ok: if not dep_result.ok:
skipped.append({"id": task.id, "title": task.title, "old": old_status, skipped.append({"task_code": task.task_code, "title": task.title, "old": old_status,
"reason": dep_result.reason}) "reason": dep_result.reason})
continue continue
# P5.3: open → undergoing requires assignee == current_user # P5.3: open → undergoing requires assignee == current_user
if old_status == "open" and data.new_status == "undergoing": if old_status == "open" and data.new_status == "undergoing":
if not task.assignee_id: if not task.assignee_id:
skipped.append({"id": task.id, "title": task.title, "old": old_status, skipped.append({"task_code": task.task_code, "title": task.title, "old": old_status,
"reason": "Assignee must be set before starting"}) "reason": "Assignee must be set before starting"})
continue continue
if current_user.id != task.assignee_id: if current_user.id != task.assignee_id:
skipped.append({"id": task.id, "title": task.title, "old": old_status, skipped.append({"task_code": task.task_code, "title": task.title, "old": old_status,
"reason": "Only the assigned user can start this task"}) "reason": "Only the assigned user can start this task"})
continue continue
@@ -709,11 +697,11 @@ def batch_transition(
if old_status == "undergoing" and data.new_status == "completed": if old_status == "undergoing" and data.new_status == "completed":
comment_text = data.comment comment_text = data.comment
if not comment_text or not comment_text.strip(): if not comment_text or not comment_text.strip():
skipped.append({"id": task.id, "title": task.title, "old": old_status, skipped.append({"task_code": task.task_code, "title": task.title, "old": old_status,
"reason": "A completion comment is required"}) "reason": "A completion comment is required"})
continue continue
if task.assignee_id and current_user.id != task.assignee_id: if task.assignee_id and current_user.id != task.assignee_id:
skipped.append({"id": task.id, "title": task.title, "old": old_status, skipped.append({"task_code": task.task_code, "title": task.title, "old": old_status,
"reason": "Only the assigned user can complete this task"}) "reason": "Only the assigned user can complete this task"})
continue continue
@@ -722,7 +710,7 @@ def batch_transition(
try: try:
check_permission(db, current_user.id, task.project_id, "task.close") check_permission(db, current_user.id, task.project_id, "task.close")
except HTTPException: except HTTPException:
skipped.append({"id": task.id, "title": task.title, "old": old_status, skipped.append({"task_code": task.task_code, "title": task.title, "old": old_status,
"reason": "Missing 'task.close' permission"}) "reason": "Missing 'task.close' permission"})
continue continue
@@ -732,7 +720,7 @@ def batch_transition(
try: try:
check_permission(db, current_user.id, task.project_id, perm) check_permission(db, current_user.id, task.project_id, perm)
except HTTPException: except HTTPException:
skipped.append({"id": task.id, "title": task.title, "old": old_status, skipped.append({"task_code": task.task_code, "title": task.title, "old": old_status,
"reason": f"Missing '{perm}' permission"}) "reason": f"Missing '{perm}' permission"})
continue continue
task.finished_on = None task.finished_on = None
@@ -742,7 +730,7 @@ def batch_transition(
if data.new_status in ("closed", "completed") and not task.finished_on: if data.new_status in ("closed", "completed") and not task.finished_on:
task.finished_on = datetime.utcnow() task.finished_on = datetime.utcnow()
task.status = data.new_status task.status = data.new_status
updated.append({"id": task.id, "title": task.title, "old": old_status, "new": data.new_status}) updated.append({"task_code": task.task_code, "title": task.title, "old": old_status, "new": data.new_status})
# Activity log per task # Activity log per task
log_activity(db, f"task.transition.{data.new_status}", "task", task.id, current_user.id, log_activity(db, f"task.transition.{data.new_status}", "task", task.id, current_user.id,
@@ -762,7 +750,7 @@ def batch_transition(
# P3.5: auto-complete milestone for any completed task # P3.5: auto-complete milestone for any completed task
for u in updated: for u in updated:
if u["new"] == "completed": if u["new"] == "completed":
t = db.query(Task).filter(Task.id == u["id"]).first() t = db.query(Task).filter(Task.task_code == u["task_code"]).first()
if t: if t:
from app.api.routers.milestone_actions import try_auto_complete_milestone from app.api.routers.milestone_actions import try_auto_complete_milestone
try_auto_complete_milestone(db, t, user_id=current_user.id) try_auto_complete_milestone(db, t, user_id=current_user.id)
@@ -782,23 +770,25 @@ def batch_assign(data: BatchAssign, db: Session = Depends(get_db)):
if not user: if not user:
raise HTTPException(status_code=404, detail="Assignee not found") raise HTTPException(status_code=404, detail="Assignee not found")
updated = [] updated = []
for task_id in data.task_ids: for task_code in data.task_codes:
task = db.query(Task).filter(Task.id == task_id).first() task = db.query(Task).filter(Task.task_code == task_code).first()
if task: if task:
task.assignee_id = data.assignee_id task.assignee_id = data.assignee_id
updated.append(task_id) updated.append(task.task_code)
db.commit() db.commit()
return {"updated": len(updated), "task_ids": updated, "assignee_id": data.assignee_id} return {"updated": len(updated), "task_codes": updated, "assignee_id": data.assignee_id}
# ---- Search ---- # ---- Search ----
@router.get("/search/tasks") @router.get("/search/tasks")
def search_tasks(q: str, project_id: int = None, page: int = 1, page_size: int = 50, def search_tasks(q: str, project_code: str = None, page: int = 1, page_size: int = 50,
db: Session = Depends(get_db)): db: Session = Depends(get_db)):
query = db.query(Task).filter( query = db.query(Task).filter(
(Task.title.contains(q)) | (Task.description.contains(q)) (Task.title.contains(q)) | (Task.description.contains(q))
) )
if project_code:
project_id = _resolve_project_id(db, project_code)
if project_id: if project_id:
query = query.filter(Task.project_id == project_id) query = query.filter(Task.project_id == project_id)
total = query.count() total = query.count()

View File

@@ -7,8 +7,9 @@ from pydantic import BaseModel
from sqlalchemy.exc import IntegrityError from sqlalchemy.exc import IntegrityError
from sqlalchemy.orm import Session from sqlalchemy.orm import Session
from app.api.deps import get_current_user, get_password_hash from app.api.deps import get_current_user, get_current_user_or_apikey, get_password_hash
from app.core.config import get_db from app.core.config import get_db
from app.init_wizard import DELETED_USER_USERNAME
from app.models import models from app.models import models
from app.models.agent import Agent from app.models.agent import Agent
from app.models.role_permission import Permission, Role, RolePermission from app.models.role_permission import Permission, Role, RolePermission
@@ -30,6 +31,7 @@ def _user_response(user: models.User) -> dict:
"role_id": user.role_id, "role_id": user.role_id,
"role_name": user.role_name, "role_name": user.role_name,
"agent_id": user.agent.agent_id if user.agent else None, "agent_id": user.agent.agent_id if user.agent else None,
"discord_user_id": user.discord_user_id,
"created_at": user.created_at, "created_at": user.created_at,
} }
return data return data
@@ -57,7 +59,7 @@ def _has_global_permission(db: Session, user: models.User, permission_name: str)
def require_account_creator( def require_account_creator(
db: Session = Depends(get_db), db: Session = Depends(get_db),
current_user: models.User = Depends(get_current_user), current_user: models.User = Depends(get_current_user_or_apikey),
): ):
if current_user.is_admin or _has_global_permission(db, current_user, "account.create"): if current_user.is_admin or _has_global_permission(db, current_user, "account.create"):
return current_user return current_user
@@ -114,6 +116,7 @@ def create_user(
username=user.username, username=user.username,
email=user.email, email=user.email,
full_name=user.full_name, full_name=user.full_name,
discord_user_id=user.discord_user_id,
hashed_password=hashed_password, hashed_password=hashed_password,
is_admin=False, is_admin=False,
is_active=True, is_active=True,
@@ -202,11 +205,94 @@ def update_user(
raise HTTPException(status_code=400, detail="You cannot deactivate your own account") raise HTTPException(status_code=400, detail="You cannot deactivate your own account")
user.is_active = payload.is_active user.is_active = payload.is_active
if payload.discord_user_id is not None:
user.discord_user_id = payload.discord_user_id or None
db.commit() db.commit()
db.refresh(user) db.refresh(user)
return _user_response(user) return _user_response(user)
_BUILTIN_USERNAMES = {"acc-mgr", DELETED_USER_USERNAME}
def _reassign_user_references(db: Session, old_id: int, new_id: int) -> None:
"""Reassign all foreign key references from old_id to new_id, then delete
records that would be meaningless under deleted-user (api_keys, notifications,
project memberships)."""
from app.models.apikey import APIKey
from app.models.notification import Notification
from app.models.activity import ActivityLog as Activity
from app.models.worklog import WorkLog as WorkLogModel
from app.models.meeting import Meeting, MeetingParticipant
from app.models.task import Task
from app.models.support import Support
from app.models.proposal import Proposal
from app.models.milestone import Milestone
from app.models.calendar import TimeSlot, SchedulePlan
from app.models.minimum_workload import MinimumWorkload
from app.models.essential import Essential
# Delete records that are meaningless without the real user
db.query(APIKey).filter(APIKey.user_id == old_id).delete()
db.query(Notification).filter(Notification.user_id == old_id).delete()
db.query(models.ProjectMember).filter(models.ProjectMember.user_id == old_id).delete()
# Reassign ownership/authorship references
db.query(models.Project).filter(models.Project.owner_id == old_id).update(
{"owner_id": new_id})
db.query(models.Comment).filter(models.Comment.author_id == old_id).update(
{"author_id": new_id})
db.query(Activity).filter(Activity.user_id == old_id).update(
{"user_id": new_id})
db.query(WorkLogModel).filter(WorkLogModel.user_id == old_id).update(
{"user_id": new_id})
# Tasks
db.query(Task).filter(Task.reporter_id == old_id).update(
{"reporter_id": new_id})
db.query(Task).filter(Task.assignee_id == old_id).update(
{"assignee_id": new_id})
db.query(Task).filter(Task.created_by_id == old_id).update(
{"created_by_id": new_id})
# Meetings
db.query(Meeting).filter(Meeting.reporter_id == old_id).update(
{"reporter_id": new_id})
db.query(MeetingParticipant).filter(MeetingParticipant.user_id == old_id).update(
{"user_id": new_id})
# Support
db.query(Support).filter(Support.reporter_id == old_id).update(
{"reporter_id": new_id})
db.query(Support).filter(Support.assignee_id == old_id).update(
{"assignee_id": new_id})
# Proposals
db.query(Proposal).filter(Proposal.created_by_id == old_id).update(
{"created_by_id": new_id})
# Milestones
db.query(Milestone).filter(Milestone.created_by_id == old_id).update(
{"created_by_id": new_id})
# Calendar
db.query(TimeSlot).filter(TimeSlot.user_id == old_id).update(
{"user_id": new_id})
db.query(SchedulePlan).filter(SchedulePlan.user_id == old_id).update(
{"user_id": new_id})
# Minimum workload / Essential
db.query(MinimumWorkload).filter(MinimumWorkload.user_id == old_id).update(
{"user_id": new_id})
db.query(Essential).filter(Essential.created_by_id == old_id).update(
{"created_by_id": new_id})
# Agent profile
db.query(Agent).filter(Agent.user_id == old_id).update(
{"user_id": new_id})
@router.delete("/{identifier}", status_code=status.HTTP_204_NO_CONTENT) @router.delete("/{identifier}", status_code=status.HTTP_204_NO_CONTENT)
def delete_user( def delete_user(
identifier: str, identifier: str,
@@ -218,17 +304,26 @@ def delete_user(
raise HTTPException(status_code=404, detail="User not found") raise HTTPException(status_code=404, detail="User not found")
if current_user.id == user.id: if current_user.id == user.id:
raise HTTPException(status_code=400, detail="You cannot delete your own account") raise HTTPException(status_code=400, detail="You cannot delete your own account")
# Protect built-in accounts from deletion
if user.is_admin: if user.is_admin:
raise HTTPException(status_code=400, detail="Admin accounts cannot be deleted") raise HTTPException(status_code=400, detail="Admin accounts cannot be deleted")
if user.username == "acc-mgr": if user.username in _BUILTIN_USERNAMES:
raise HTTPException(status_code=400, detail="The acc-mgr account is a built-in account and cannot be deleted") raise HTTPException(
try: status_code=400,
detail=f"The {user.username} account is a built-in account and cannot be deleted",
)
deleted_user = db.query(models.User).filter(
models.User.username == DELETED_USER_USERNAME
).first()
if not deleted_user:
raise HTTPException(
status_code=500,
detail="Built-in deleted-user account not found. Run init_wizard first.",
)
_reassign_user_references(db, user.id, deleted_user.id)
db.delete(user) db.delete(user)
db.commit() db.commit()
except IntegrityError:
db.rollback()
raise HTTPException(status_code=400, detail="User has related records. Deactivate the account instead.")
return None return None
@@ -236,7 +331,7 @@ def delete_user(
def reset_user_apikey( def reset_user_apikey(
identifier: str, identifier: str,
db: Session = Depends(get_db), db: Session = Depends(get_db),
current_user: models.User = Depends(get_current_user), current_user: models.User = Depends(get_current_user_or_apikey),
): ):
"""Reset (regenerate) a user's API key. """Reset (regenerate) a user's API key.
@@ -244,6 +339,8 @@ def reset_user_apikey(
- user.reset-apikey: can reset any user's API key - user.reset-apikey: can reset any user's API key
- user.reset-self-apikey: can reset only own API key - user.reset-self-apikey: can reset only own API key
- admin: can reset any user's API key - admin: can reset any user's API key
Accepts both OAuth2 Bearer token and X-API-Key authentication.
""" """
import secrets import secrets
from app.models.apikey import APIKey from app.models.apikey import APIKey

View File

@@ -132,6 +132,10 @@ DEFAULT_PERMISSIONS = [
# Monitor # Monitor
("monitor.read", "View monitor", "monitor"), ("monitor.read", "View monitor", "monitor"),
("monitor.manage", "Manage monitor", "monitor"), ("monitor.manage", "Manage monitor", "monitor"),
# Calendar
("calendar.read", "View calendar slots and plans", "calendar"),
("calendar.write", "Create and edit calendar slots and plans", "calendar"),
("calendar.manage", "Manage calendar settings and workload policies", "calendar"),
# Webhook # Webhook
("webhook.manage", "Manage webhooks", "admin"), ("webhook.manage", "Manage webhooks", "admin"),
] ]
@@ -168,6 +172,7 @@ _MGR_PERMISSIONS = {
"task.close", "task.reopen_closed", "task.reopen_completed", "task.close", "task.reopen_closed", "task.reopen_completed",
"propose.accept", "propose.reject", "propose.reopen", "propose.accept", "propose.reject", "propose.reopen",
"monitor.read", "monitor.read",
"calendar.read", "calendar.write", "calendar.manage",
"user.reset-self-apikey", "user.reset-self-apikey",
} }
@@ -178,11 +183,13 @@ _DEV_PERMISSIONS = {
"milestone.read", "milestone.read",
"task.close", "task.reopen_closed", "task.reopen_completed", "task.close", "task.reopen_closed", "task.reopen_completed",
"monitor.read", "monitor.read",
"calendar.read", "calendar.write",
"user.reset-self-apikey", "user.reset-self-apikey",
} }
_ACCOUNT_MANAGER_PERMISSIONS = { _ACCOUNT_MANAGER_PERMISSIONS = {
"account.create", "account.create",
"user.reset-apikey",
} }
# Role definitions: (name, description, permission_set) # Role definitions: (name, description, permission_set)
@@ -288,6 +295,39 @@ def init_acc_mgr_user(db: Session) -> models.User | None:
return user return user
DELETED_USER_USERNAME = "deleted-user"
def init_deleted_user(db: Session) -> models.User | None:
"""Create the built-in deleted-user if not exists.
This user serves as a foreign key sink: when a real user is deleted,
all references are reassigned here instead of cascading deletes.
It has no role (no permissions) and cannot log in.
"""
existing = db.query(models.User).filter(
models.User.username == DELETED_USER_USERNAME
).first()
if existing:
logger.info("deleted-user already exists (id=%d), skipping", existing.id)
return existing
user = models.User(
username=DELETED_USER_USERNAME,
email="deleted-user@harborforge.internal",
full_name="Deleted User",
hashed_password=None,
is_admin=False,
is_active=False,
role_id=None,
)
db.add(user)
db.commit()
db.refresh(user)
logger.info("Created deleted-user (id=%d)", user.id)
return user
def run_init(db: Session) -> None: def run_init(db: Session) -> None:
"""Main initialization entry point. Reads config from shared volume.""" """Main initialization entry point. Reads config from shared volume."""
config = load_config() config = load_config()
@@ -312,6 +352,9 @@ def run_init(db: Session) -> None:
# Built-in acc-mgr user (after roles are created) # Built-in acc-mgr user (after roles are created)
init_acc_mgr_user(db) init_acc_mgr_user(db)
# Built-in deleted-user (foreign key sink for deleted accounts)
init_deleted_user(db)
# Default project # Default project
project_cfg = config.get("default_project") project_cfg = config.get("default_project")
if project_cfg and admin_user: if project_cfg and admin_user:

View File

@@ -42,6 +42,7 @@ def config_status():
return { return {
"initialized": cfg.get("initialized", False), "initialized": cfg.get("initialized", False),
"backend_url": cfg.get("backend_url"), "backend_url": cfg.get("backend_url"),
"discord": cfg.get("discord") or {},
} }
except Exception: except Exception:
return {"initialized": False} return {"initialized": False}
@@ -96,6 +97,25 @@ def _migrate_schema():
{"column_name": column_name}, {"column_name": column_name},
).fetchone() is not None ).fetchone() is not None
def _has_index(db, table_name: str, index_name: str) -> bool:
return db.execute(
text(
"""
SELECT 1
FROM information_schema.STATISTICS
WHERE TABLE_SCHEMA = DATABASE()
AND TABLE_NAME = :table_name
AND INDEX_NAME = :index_name
LIMIT 1
"""
),
{"table_name": table_name, "index_name": index_name},
).fetchone() is not None
def _ensure_unique_index(db, table_name: str, index_name: str, columns_sql: str):
if not _has_index(db, table_name, index_name):
db.execute(text(f"CREATE UNIQUE INDEX {index_name} ON {table_name} ({columns_sql})"))
def _drop_fk_constraints(db, table_name: str, referenced_table: str): def _drop_fk_constraints(db, table_name: str, referenced_table: str):
rows = db.execute(text( rows = db.execute(text(
""" """
@@ -139,7 +159,7 @@ def _migrate_schema():
result = db.execute(text("SHOW COLUMNS FROM projects LIKE 'project_code'")) result = db.execute(text("SHOW COLUMNS FROM projects LIKE 'project_code'"))
if not result.fetchone(): if not result.fetchone():
db.execute(text("ALTER TABLE projects ADD COLUMN project_code VARCHAR(16) NULL")) db.execute(text("ALTER TABLE projects ADD COLUMN project_code VARCHAR(16) NULL"))
db.execute(text("CREATE UNIQUE INDEX idx_projects_project_code ON projects (project_code)")) _ensure_unique_index(db, "projects", "idx_projects_project_code", "project_code")
# projects.owner_name # projects.owner_name
result = db.execute(text("SHOW COLUMNS FROM projects LIKE 'owner_name'")) result = db.execute(text("SHOW COLUMNS FROM projects LIKE 'owner_name'"))
@@ -173,6 +193,8 @@ def _migrate_schema():
if not result.fetchone(): if not result.fetchone():
db.execute(text("ALTER TABLE tasks ADD COLUMN created_by_id INTEGER NULL")) db.execute(text("ALTER TABLE tasks ADD COLUMN created_by_id INTEGER NULL"))
_ensure_fk(db, "tasks", "created_by_id", "users", "id", "fk_tasks_created_by_id") _ensure_fk(db, "tasks", "created_by_id", "users", "id", "fk_tasks_created_by_id")
if _has_column(db, "tasks", "task_code"):
_ensure_unique_index(db, "tasks", "idx_tasks_task_code", "task_code")
# milestones creator field # milestones creator field
result = db.execute(text("SHOW COLUMNS FROM milestones LIKE 'created_by_id'")) result = db.execute(text("SHOW COLUMNS FROM milestones LIKE 'created_by_id'"))
@@ -202,6 +224,8 @@ def _migrate_schema():
# --- Milestone status enum migration (old -> new) --- # --- Milestone status enum migration (old -> new) ---
if _has_table(db, "milestones"): if _has_table(db, "milestones"):
if _has_column(db, "milestones", "milestone_code"):
_ensure_unique_index(db, "milestones", "idx_milestones_milestone_code", "milestone_code")
# Alter enum column to accept new values # Alter enum column to accept new values
db.execute(text( db.execute(text(
"ALTER TABLE milestones MODIFY COLUMN status " "ALTER TABLE milestones MODIFY COLUMN status "
@@ -248,6 +272,9 @@ def _migrate_schema():
db.execute(text("ALTER TABLE users ADD COLUMN role_id INTEGER NULL")) db.execute(text("ALTER TABLE users ADD COLUMN role_id INTEGER NULL"))
_ensure_fk(db, "users", "role_id", "roles", "id", "fk_users_role_id") _ensure_fk(db, "users", "role_id", "roles", "id", "fk_users_role_id")
if _has_table(db, "users") and not _has_column(db, "users", "discord_user_id"):
db.execute(text("ALTER TABLE users ADD COLUMN discord_user_id VARCHAR(32) NULL"))
# --- monitored_servers.api_key for heartbeat v2 --- # --- monitored_servers.api_key for heartbeat v2 ---
if _has_table(db, "monitored_servers") and not _has_column(db, "monitored_servers", "api_key"): if _has_table(db, "monitored_servers") and not _has_column(db, "monitored_servers", "api_key"):
db.execute(text("ALTER TABLE monitored_servers ADD COLUMN api_key VARCHAR(64) NULL")) db.execute(text("ALTER TABLE monitored_servers ADD COLUMN api_key VARCHAR(64) NULL"))
@@ -257,6 +284,18 @@ def _migrate_schema():
if _has_table(db, "server_states") and not _has_column(db, "server_states", "plugin_version"): if _has_table(db, "server_states") and not _has_column(db, "server_states", "plugin_version"):
db.execute(text("ALTER TABLE server_states ADD COLUMN plugin_version VARCHAR(64) NULL")) db.execute(text("ALTER TABLE server_states ADD COLUMN plugin_version VARCHAR(64) NULL"))
if _has_table(db, "meetings") and _has_column(db, "meetings", "meeting_code"):
_ensure_unique_index(db, "meetings", "idx_meetings_meeting_code", "meeting_code")
if _has_table(db, "supports") and _has_column(db, "supports", "support_code"):
_ensure_unique_index(db, "supports", "idx_supports_support_code", "support_code")
if _has_table(db, "proposes") and _has_column(db, "proposes", "propose_code"):
_ensure_unique_index(db, "proposes", "idx_proposes_propose_code", "propose_code")
if _has_table(db, "essentials") and _has_column(db, "essentials", "essential_code"):
_ensure_unique_index(db, "essentials", "idx_essentials_essential_code", "essential_code")
# --- server_states nginx telemetry for generic monitor client --- # --- server_states nginx telemetry for generic monitor client ---
if _has_table(db, "server_states") and not _has_column(db, "server_states", "nginx_installed"): if _has_table(db, "server_states") and not _has_column(db, "server_states", "nginx_installed"):
db.execute(text("ALTER TABLE server_states ADD COLUMN nginx_installed BOOLEAN NULL")) db.execute(text("ALTER TABLE server_states ADD COLUMN nginx_installed BOOLEAN NULL"))
@@ -320,6 +359,10 @@ def _migrate_schema():
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4
""")) """))
# --- time_slots: add wakeup_sent_at for Discord wakeup tracking ---
if _has_table(db, "time_slots") and not _has_column(db, "time_slots", "wakeup_sent_at"):
db.execute(text("ALTER TABLE time_slots ADD COLUMN wakeup_sent_at DATETIME NULL"))
db.commit() db.commit()
except Exception as e: except Exception as e:
db.rollback() db.rollback()

View File

@@ -165,6 +165,12 @@ class TimeSlot(Base):
comment="Lifecycle status of this slot", comment="Lifecycle status of this slot",
) )
wakeup_sent_at = Column(
DateTime(timezone=True),
nullable=True,
comment="When Discord wakeup was sent for this slot",
)
plan_id = Column( plan_id = Column(
Integer, Integer,
ForeignKey("schedule_plans.id"), ForeignKey("schedule_plans.id"),

View File

@@ -72,6 +72,7 @@ class User(Base):
email = Column(String(100), unique=True, nullable=False) email = Column(String(100), unique=True, nullable=False)
hashed_password = Column(String(255), nullable=True) hashed_password = Column(String(255), nullable=True)
full_name = Column(String(100), nullable=True) full_name = Column(String(100), nullable=True)
discord_user_id = Column(String(32), nullable=True)
is_active = Column(Boolean, default=True) is_active = Column(Boolean, default=True)
is_admin = Column(Boolean, default=False) is_admin = Column(Boolean, default=False)
role_id = Column(Integer, ForeignKey("roles.id"), nullable=True) role_id = Column(Integer, ForeignKey("roles.id"), nullable=True)

View File

@@ -407,3 +407,30 @@ class DateListResponse(BaseModel):
default_factory=list, default_factory=list,
description="Sorted list of future dates with materialized slots", description="Sorted list of future dates with materialized slots",
) )
# ---------------------------------------------------------------------------
# Agent heartbeat / agent-driven slot updates
# ---------------------------------------------------------------------------
class AgentHeartbeatResponse(BaseModel):
"""Slots that are due for a specific agent plus its current runtime status."""
slots: list[CalendarSlotItem] = Field(default_factory=list)
agent_status: str
message: Optional[str] = None
class SlotAgentUpdate(BaseModel):
"""Plugin-driven slot status update payload."""
status: SlotStatusEnum
started_at: Optional[dt_time] = None
actual_duration: Optional[int] = Field(None, ge=0, le=65535)
class AgentStatusUpdateRequest(BaseModel):
"""Plugin-driven agent status report."""
agent_id: str
claw_identifier: str
status: str
recovery_at: Optional[dt_datetime] = None
exhaust_reason: Optional[str] = None

View File

@@ -43,9 +43,7 @@ class TaskBase(BaseModel):
class TaskCreate(TaskBase): class TaskCreate(TaskBase):
project_id: Optional[int] = None
project_code: Optional[str] = None project_code: Optional[str] = None
milestone_id: Optional[int] = None
milestone_code: Optional[str] = None milestone_code: Optional[str] = None
reporter_id: Optional[int] = None reporter_id: Optional[int] = None
assignee_id: Optional[int] = None assignee_id: Optional[int] = None
@@ -75,15 +73,12 @@ class TaskUpdate(BaseModel):
class TaskResponse(TaskBase): class TaskResponse(TaskBase):
id: int
status: TaskStatusEnum status: TaskStatusEnum
task_code: Optional[str] = None task_code: Optional[str] = None
code: Optional[str] = None code: Optional[str] = None
type: Optional[str] = None type: Optional[str] = None
due_date: Optional[datetime] = None due_date: Optional[datetime] = None
project_id: int
project_code: Optional[str] = None project_code: Optional[str] = None
milestone_id: int
milestone_code: Optional[str] = None milestone_code: Optional[str] = None
reporter_id: int reporter_id: int
assignee_id: Optional[int] = None assignee_id: Optional[int] = None
@@ -94,8 +89,8 @@ class TaskResponse(TaskBase):
positions: Optional[str] = None positions: Optional[str] = None
pending_matters: Optional[str] = None pending_matters: Optional[str] = None
# BE-PR-008: Proposal Accept tracking # BE-PR-008: Proposal Accept tracking
source_proposal_id: Optional[int] = None source_proposal_code: Optional[str] = None
source_essential_id: Optional[int] = None source_essential_code: Optional[str] = None
created_at: datetime created_at: datetime
updated_at: Optional[datetime] = None updated_at: Optional[datetime] = None
@@ -176,6 +171,7 @@ class UserBase(BaseModel):
class UserCreate(UserBase): class UserCreate(UserBase):
password: Optional[str] = None password: Optional[str] = None
role_id: Optional[int] = None role_id: Optional[int] = None
discord_user_id: Optional[str] = None
# Agent binding (both must be provided or both omitted) # Agent binding (both must be provided or both omitted)
agent_id: Optional[str] = None agent_id: Optional[str] = None
claw_identifier: Optional[str] = None claw_identifier: Optional[str] = None
@@ -187,6 +183,7 @@ class UserUpdate(BaseModel):
password: Optional[str] = None password: Optional[str] = None
role_id: Optional[int] = None role_id: Optional[int] = None
is_active: Optional[bool] = None is_active: Optional[bool] = None
discord_user_id: Optional[str] = None
class UserResponse(UserBase): class UserResponse(UserBase):
@@ -196,6 +193,7 @@ class UserResponse(UserBase):
role_id: Optional[int] = None role_id: Optional[int] = None
role_name: Optional[str] = None role_name: Optional[str] = None
agent_id: Optional[str] = None agent_id: Optional[str] = None
discord_user_id: Optional[str] = None
created_at: datetime created_at: datetime
class Config: class Config:
@@ -259,9 +257,9 @@ class MilestoneUpdate(BaseModel):
class MilestoneResponse(MilestoneBase): class MilestoneResponse(MilestoneBase):
id: int
milestone_code: Optional[str] = None milestone_code: Optional[str] = None
project_id: int code: Optional[str] = None
project_code: Optional[str] = None
created_by_id: Optional[int] = None created_by_id: Optional[int] = None
started_at: Optional[datetime] = None started_at: Optional[datetime] = None
created_at: datetime created_at: datetime
@@ -285,7 +283,7 @@ class ProposalBase(BaseModel):
class ProposalCreate(ProposalBase): class ProposalCreate(ProposalBase):
project_id: Optional[int] = None pass
class ProposalUpdate(BaseModel): class ProposalUpdate(BaseModel):
@@ -294,11 +292,10 @@ class ProposalUpdate(BaseModel):
class ProposalResponse(ProposalBase): class ProposalResponse(ProposalBase):
id: int
proposal_code: Optional[str] = None # preferred name proposal_code: Optional[str] = None # preferred name
propose_code: Optional[str] = None # backward compat alias (same value) propose_code: Optional[str] = None # backward compat alias (same value)
status: ProposalStatusEnum status: ProposalStatusEnum
project_id: int project_code: Optional[str] = None
created_by_id: Optional[int] = None created_by_id: Optional[int] = None
created_by_username: Optional[str] = None created_by_username: Optional[str] = None
feat_task_id: Optional[str] = None # DEPRECATED (BE-PR-010): legacy field, read-only. Use generated_tasks instead. feat_task_id: Optional[str] = None # DEPRECATED (BE-PR-010): legacy field, read-only. Use generated_tasks instead.
@@ -340,9 +337,8 @@ class EssentialUpdate(BaseModel):
class EssentialResponse(EssentialBase): class EssentialResponse(EssentialBase):
id: int
essential_code: str essential_code: str
proposal_id: int proposal_code: Optional[str] = None
created_by_id: Optional[int] = None created_by_id: Optional[int] = None
created_at: datetime created_at: datetime
updated_at: Optional[datetime] = None updated_at: Optional[datetime] = None
@@ -353,13 +349,12 @@ class EssentialResponse(EssentialBase):
class GeneratedTaskBrief(BaseModel): class GeneratedTaskBrief(BaseModel):
"""Brief info about a story task generated from Proposal Accept.""" """Brief info about a story task generated from Proposal Accept."""
task_id: int
task_code: Optional[str] = None task_code: Optional[str] = None
task_type: str task_type: str
task_subtype: Optional[str] = None task_subtype: Optional[str] = None
title: str title: str
status: Optional[str] = None status: Optional[str] = None
source_essential_id: Optional[int] = None source_essential_code: Optional[str] = None
class ProposalDetailResponse(ProposalResponse): class ProposalDetailResponse(ProposalResponse):
@@ -374,12 +369,10 @@ class ProposalDetailResponse(ProposalResponse):
class GeneratedTaskSummary(BaseModel): class GeneratedTaskSummary(BaseModel):
"""Brief summary of a task generated from a Proposal Essential.""" """Brief summary of a task generated from a Proposal Essential."""
task_id: int
task_code: str task_code: str
task_type: str task_type: str
task_subtype: str task_subtype: str
title: str title: str
essential_id: int
essential_code: str essential_code: str

View File

@@ -0,0 +1,72 @@
from __future__ import annotations
from datetime import datetime, timezone
from typing import Any
import requests
from fastapi import HTTPException
from app.services.harborforge_config import get_discord_wakeup_config
DISCORD_API_BASE = "https://discord.com/api/v10"
WAKEUP_CATEGORY_NAME = "HarborForge Wakeup"
def _headers(bot_token: str) -> dict[str, str]:
return {
"Authorization": f"Bot {bot_token}",
"Content-Type": "application/json",
}
def _ensure_category(guild_id: str, bot_token: str) -> str | None:
resp = requests.get(f"{DISCORD_API_BASE}/guilds/{guild_id}/channels", headers=_headers(bot_token), timeout=15)
if not resp.ok:
raise HTTPException(status_code=502, detail=f"Discord list channels failed: {resp.text}")
for ch in resp.json():
if ch.get("type") == 4 and ch.get("name") == WAKEUP_CATEGORY_NAME:
return ch.get("id")
payload = {"name": WAKEUP_CATEGORY_NAME, "type": 4}
created = requests.post(f"{DISCORD_API_BASE}/guilds/{guild_id}/channels", headers=_headers(bot_token), json=payload, timeout=15)
if not created.ok:
raise HTTPException(status_code=502, detail=f"Discord create category failed: {created.text}")
return created.json().get("id")
def create_private_wakeup_channel(discord_user_id: str, title: str, message: str) -> dict[str, Any]:
cfg = get_discord_wakeup_config()
guild_id = cfg.get("guild_id")
bot_token = cfg.get("bot_token")
if not guild_id or not bot_token:
raise HTTPException(status_code=400, detail="Discord wakeup config is incomplete")
category_id = _ensure_category(guild_id, bot_token)
channel_name = f"wake-{discord_user_id[-6:]}-{int(datetime.now(timezone.utc).timestamp())}"
payload = {
"name": channel_name,
"type": 0,
"parent_id": category_id,
"permission_overwrites": [
{"id": guild_id, "type": 0, "deny": "1024"},
{"id": discord_user_id, "type": 1, "allow": "1024"},
],
"topic": title,
}
created = requests.post(f"{DISCORD_API_BASE}/guilds/{guild_id}/channels", headers=_headers(bot_token), json=payload, timeout=15)
if not created.ok:
raise HTTPException(status_code=502, detail=f"Discord create channel failed: {created.text}")
channel = created.json()
sent = requests.post(
f"{DISCORD_API_BASE}/channels/{channel['id']}/messages",
headers=_headers(bot_token),
json={"content": message},
timeout=15,
)
if not sent.ok:
raise HTTPException(status_code=502, detail=f"Discord send message failed: {sent.text}")
return {
"guild_id": guild_id,
"channel_id": channel.get("id"),
"channel_name": channel.get("name"),
"message_id": sent.json().get("id"),
}

View File

@@ -0,0 +1,26 @@
import json
import os
from typing import Any
CONFIG_DIR = os.getenv("CONFIG_DIR", "/config")
CONFIG_FILE = os.getenv("CONFIG_FILE", "harborforge.json")
def load_runtime_config() -> dict[str, Any]:
config_path = os.path.join(CONFIG_DIR, CONFIG_FILE)
if not os.path.exists(config_path):
return {}
try:
with open(config_path, "r") as f:
return json.load(f)
except Exception:
return {}
def get_discord_wakeup_config() -> dict[str, str | None]:
cfg = load_runtime_config()
discord_cfg = cfg.get("discord") or {}
return {
"guild_id": discord_cfg.get("guild_id"),
"bot_token": discord_cfg.get("bot_token"),
}

357
tests/test_calendar_api.py Normal file
View File

@@ -0,0 +1,357 @@
"""Tests for TEST-BE-CAL-001: Calendar API coverage.
Covers core API surfaces:
- slot create / day view / edit / cancel
- virtual slot edit / cancel materialization flows
- plan create / list / get / edit / cancel
- date-list
- workload-config user/admin endpoints
"""
from datetime import date, time, timedelta
from app.models.calendar import (
SchedulePlan,
SlotStatus,
SlotType,
TimeSlot,
DayOfWeek,
)
from tests.conftest import auth_header
FUTURE_DATE = date.today() + timedelta(days=30)
FUTURE_DATE_2 = date.today() + timedelta(days=31)
def _create_plan(db, *, user_id: int, slot_type=SlotType.WORK, at_time=time(9, 0), on_day=None, on_week=None):
plan = SchedulePlan(
user_id=user_id,
slot_type=slot_type,
estimated_duration=30,
at_time=at_time,
on_day=on_day,
on_week=on_week,
is_active=True,
)
db.add(plan)
db.commit()
db.refresh(plan)
return plan
def _create_slot(db, *, user_id: int, slot_date: date, scheduled_at=time(9, 0), status=SlotStatus.NOT_STARTED, plan_id=None):
slot = TimeSlot(
user_id=user_id,
date=slot_date,
slot_type=SlotType.WORK,
estimated_duration=30,
scheduled_at=scheduled_at,
status=status,
priority=0,
plan_id=plan_id,
)
db.add(slot)
db.commit()
db.refresh(slot)
return slot
class TestCalendarSlotApi:
def test_create_slot_success(self, client, seed):
r = client.post(
"/calendar/slots",
json={
"date": FUTURE_DATE.isoformat(),
"slot_type": "work",
"scheduled_at": "09:00:00",
"estimated_duration": 30,
"event_type": "job",
"event_data": {"type": "Task", "code": "TASK-42"},
"priority": 3,
},
headers=auth_header(seed["admin_token"]),
)
assert r.status_code == 201, r.text
data = r.json()
assert data["slot"]["date"] == FUTURE_DATE.isoformat()
assert data["slot"]["slot_type"] == "work"
assert data["slot"]["event_type"] == "job"
assert data["slot"]["event_data"]["code"] == "TASK-42"
assert data["warnings"] == []
def test_day_view_returns_real_and_virtual_slots_sorted(self, client, db, seed):
# Real slots
_create_slot(db, user_id=seed["admin_user"].id, slot_date=FUTURE_DATE, scheduled_at=time(11, 0))
skipped = _create_slot(
db,
user_id=seed["admin_user"].id,
slot_date=FUTURE_DATE,
scheduled_at=time(12, 0),
status=SlotStatus.SKIPPED,
)
# Virtual weekly plan matching FUTURE_DATE weekday
weekday_map = {
0: DayOfWeek.MON,
1: DayOfWeek.TUE,
2: DayOfWeek.WED,
3: DayOfWeek.THU,
4: DayOfWeek.FRI,
5: DayOfWeek.SAT,
6: DayOfWeek.SUN,
}
_create_plan(
db,
user_id=seed["admin_user"].id,
at_time=time(8, 0),
on_day=weekday_map[FUTURE_DATE.weekday()],
)
r = client.get(
f"/calendar/day?date={FUTURE_DATE.isoformat()}",
headers=auth_header(seed["admin_token"]),
)
assert r.status_code == 200, r.text
data = r.json()
assert data["date"] == FUTURE_DATE.isoformat()
assert len(data["slots"]) == 2
assert [slot["scheduled_at"] for slot in data["slots"]] == ["08:00:00", "11:00:00"]
assert data["slots"][0]["virtual_id"].startswith("plan-")
assert data["slots"][1]["id"] is not None
# skipped slot hidden
assert all(slot.get("id") != skipped.id for slot in data["slots"])
def test_edit_real_slot_success(self, client, db, seed):
slot = _create_slot(db, user_id=seed["admin_user"].id, slot_date=FUTURE_DATE, scheduled_at=time(9, 0))
r = client.patch(
f"/calendar/slots/{slot.id}",
json={
"scheduled_at": "10:30:00",
"estimated_duration": 40,
"priority": 7,
},
headers=auth_header(seed["admin_token"]),
)
assert r.status_code == 200, r.text
data = r.json()
assert data["slot"]["id"] == slot.id
assert data["slot"]["scheduled_at"] == "10:30:00"
assert data["slot"]["estimated_duration"] == 40
assert data["slot"]["priority"] == 7
def test_edit_virtual_slot_materializes_and_detaches(self, client, db, seed):
weekday_map = {
0: DayOfWeek.MON,
1: DayOfWeek.TUE,
2: DayOfWeek.WED,
3: DayOfWeek.THU,
4: DayOfWeek.FRI,
5: DayOfWeek.SAT,
6: DayOfWeek.SUN,
}
plan = _create_plan(
db,
user_id=seed["admin_user"].id,
at_time=time(8, 0),
on_day=weekday_map[FUTURE_DATE.weekday()],
)
virtual_id = f"plan-{plan.id}-{FUTURE_DATE.isoformat()}"
r = client.patch(
f"/calendar/slots/virtual/{virtual_id}",
json={"scheduled_at": "08:30:00", "priority": 5},
headers=auth_header(seed["admin_token"]),
)
assert r.status_code == 200, r.text
data = r.json()
assert data["slot"]["id"] is not None
assert data["slot"]["scheduled_at"] == "08:30:00"
assert data["slot"]["plan_id"] is None
materialized = db.query(TimeSlot).filter(TimeSlot.id == data["slot"]["id"]).first()
assert materialized is not None
assert materialized.plan_id is None
def test_cancel_real_slot_sets_skipped(self, client, db, seed):
slot = _create_slot(db, user_id=seed["admin_user"].id, slot_date=FUTURE_DATE)
r = client.post(
f"/calendar/slots/{slot.id}/cancel",
headers=auth_header(seed["admin_token"]),
)
assert r.status_code == 200, r.text
data = r.json()
assert data["slot"]["status"] == "skipped"
assert data["message"] == "Slot cancelled successfully"
def test_cancel_virtual_slot_materializes_then_skips(self, client, db, seed):
weekday_map = {
0: DayOfWeek.MON,
1: DayOfWeek.TUE,
2: DayOfWeek.WED,
3: DayOfWeek.THU,
4: DayOfWeek.FRI,
5: DayOfWeek.SAT,
6: DayOfWeek.SUN,
}
plan = _create_plan(
db,
user_id=seed["admin_user"].id,
at_time=time(8, 0),
on_day=weekday_map[FUTURE_DATE.weekday()],
)
virtual_id = f"plan-{plan.id}-{FUTURE_DATE.isoformat()}"
r = client.post(
f"/calendar/slots/virtual/{virtual_id}/cancel",
headers=auth_header(seed["admin_token"]),
)
assert r.status_code == 200, r.text
data = r.json()
assert data["slot"]["status"] == "skipped"
assert data["slot"]["plan_id"] is None
assert "cancelled" in data["message"].lower()
def test_date_list_only_returns_future_materialized_dates(self, client, db, seed):
_create_slot(db, user_id=seed["admin_user"].id, slot_date=FUTURE_DATE)
_create_slot(db, user_id=seed["admin_user"].id, slot_date=FUTURE_DATE_2, status=SlotStatus.SKIPPED)
_create_plan(db, user_id=seed["admin_user"].id, at_time=time(8, 0)) # virtual-only, should not appear
r = client.get("/calendar/dates", headers=auth_header(seed["admin_token"]))
assert r.status_code == 200, r.text
assert r.json()["dates"] == [FUTURE_DATE.isoformat()]
class TestCalendarPlanApi:
def test_create_list_get_plan(self, client, seed):
create = client.post(
"/calendar/plans",
json={
"slot_type": "work",
"estimated_duration": 30,
"at_time": "09:00:00",
"on_day": "mon",
"event_type": "job",
"event_data": {"type": "Task", "code": "TASK-1"},
},
headers=auth_header(seed["admin_token"]),
)
assert create.status_code == 201, create.text
plan = create.json()
assert plan["slot_type"] == "work"
assert plan["on_day"] == "mon"
listing = client.get("/calendar/plans", headers=auth_header(seed["admin_token"]))
assert listing.status_code == 200, listing.text
assert len(listing.json()["plans"]) == 1
assert listing.json()["plans"][0]["id"] == plan["id"]
single = client.get(f"/calendar/plans/{plan['id']}", headers=auth_header(seed["admin_token"]))
assert single.status_code == 200, single.text
assert single.json()["id"] == plan["id"]
assert single.json()["event_data"]["code"] == "TASK-1"
def test_edit_plan_detaches_future_materialized_slots(self, client, db, seed):
plan = _create_plan(db, user_id=seed["admin_user"].id, at_time=time(9, 0))
future_slot = _create_slot(db, user_id=seed["admin_user"].id, slot_date=FUTURE_DATE, plan_id=plan.id)
r = client.patch(
f"/calendar/plans/{plan.id}",
json={"at_time": "10:15:00", "estimated_duration": 25},
headers=auth_header(seed["admin_token"]),
)
assert r.status_code == 200, r.text
data = r.json()
assert data["at_time"] == "10:15:00"
assert data["estimated_duration"] == 25
db.refresh(future_slot)
assert future_slot.plan_id is None
def test_cancel_plan_deactivates_and_preserves_past_ids_list(self, client, db, seed):
plan = _create_plan(db, user_id=seed["admin_user"].id, at_time=time(9, 0))
future_slot = _create_slot(db, user_id=seed["admin_user"].id, slot_date=FUTURE_DATE, plan_id=plan.id)
r = client.post(
f"/calendar/plans/{plan.id}/cancel",
headers=auth_header(seed["admin_token"]),
)
assert r.status_code == 200, r.text
data = r.json()
assert data["plan"]["is_active"] is False
assert isinstance(data["preserved_past_slot_ids"], list)
db.refresh(future_slot)
assert future_slot.plan_id is None
def test_list_plans_include_inactive(self, client, db, seed):
active = _create_plan(db, user_id=seed["admin_user"].id, at_time=time(9, 0))
inactive = _create_plan(db, user_id=seed["admin_user"].id, at_time=time(10, 0))
inactive.is_active = False
db.commit()
active_only = client.get("/calendar/plans", headers=auth_header(seed["admin_token"]))
assert active_only.status_code == 200
assert [p["id"] for p in active_only.json()["plans"]] == [active.id]
with_inactive = client.get(
"/calendar/plans?include_inactive=true",
headers=auth_header(seed["admin_token"]),
)
assert with_inactive.status_code == 200
ids = {p["id"] for p in with_inactive.json()["plans"]}
assert ids == {active.id, inactive.id}
class TestWorkloadConfigApi:
def test_user_workload_config_put_patch_get(self, client, seed):
put = client.put(
"/calendar/workload-config",
json={
"daily": {"work": 60, "on_call": 10, "entertainment": 5},
"weekly": {"work": 300, "on_call": 20, "entertainment": 15},
"monthly": {"work": 900, "on_call": 60, "entertainment": 45},
"yearly": {"work": 10000, "on_call": 200, "entertainment": 100},
},
headers=auth_header(seed["admin_token"]),
)
assert put.status_code == 200, put.text
assert put.json()["config"]["daily"]["work"] == 60
patch = client.patch(
"/calendar/workload-config",
json={"daily": {"work": 90, "on_call": 10, "entertainment": 5}},
headers=auth_header(seed["admin_token"]),
)
assert patch.status_code == 200, patch.text
assert patch.json()["config"]["daily"]["work"] == 90
assert patch.json()["config"]["weekly"]["work"] == 300
get = client.get("/calendar/workload-config", headers=auth_header(seed["admin_token"]))
assert get.status_code == 200, get.text
assert get.json()["config"]["daily"]["work"] == 90
def test_admin_can_manage_other_user_workload_config(self, client, seed):
patch = client.patch(
f"/calendar/workload-config/{seed['dev_user'].id}",
json={"daily": {"work": 45, "on_call": 0, "entertainment": 0}},
headers=auth_header(seed["admin_token"]),
)
assert patch.status_code == 200, patch.text
assert patch.json()["user_id"] == seed["dev_user"].id
assert patch.json()["config"]["daily"]["work"] == 45
get = client.get(
f"/calendar/workload-config/{seed['dev_user'].id}",
headers=auth_header(seed["admin_token"]),
)
assert get.status_code == 200, get.text
assert get.json()["config"]["daily"]["work"] == 45
def test_non_admin_cannot_manage_other_user_workload_config(self, client, seed):
r = client.get(
f"/calendar/workload-config/{seed['admin_user'].id}",
headers=auth_header(seed["dev_token"]),
)
assert r.status_code == 403, r.text

View File

@@ -0,0 +1,848 @@
"""Tests for BE-CAL-001: Calendar model definitions.
Covers:
- TimeSlot model creation and fields
- SchedulePlan model creation and fields
- Enum validations
- Model relationships
- DB constraints (check constraints, foreign keys)
"""
import pytest
from datetime import date, time, datetime
from sqlalchemy.exc import IntegrityError
from app.models.calendar import (
TimeSlot,
SchedulePlan,
SlotType,
SlotStatus,
EventType,
DayOfWeek,
MonthOfYear,
)
# ---------------------------------------------------------------------------
# TimeSlot Model Tests
# ---------------------------------------------------------------------------
class TestTimeSlotModel:
"""Tests for TimeSlot ORM model."""
def test_create_timeslot_basic(self, db, seed):
"""Test creating a basic TimeSlot with required fields."""
slot = TimeSlot(
user_id=seed["admin_user"].id,
date=date(2026, 4, 1),
slot_type=SlotType.WORK,
estimated_duration=30,
scheduled_at=time(9, 0),
status=SlotStatus.NOT_STARTED,
priority=0,
)
db.add(slot)
db.commit()
db.refresh(slot)
assert slot.id is not None
assert slot.user_id == seed["admin_user"].id
assert slot.date == date(2026, 4, 1)
assert slot.slot_type == SlotType.WORK
assert slot.estimated_duration == 30
assert slot.scheduled_at == time(9, 0)
assert slot.status == SlotStatus.NOT_STARTED
assert slot.priority == 0
assert slot.attended is False
assert slot.plan_id is None
def test_create_timeslot_all_fields(self, db, seed):
"""Test creating a TimeSlot with all optional fields."""
slot = TimeSlot(
user_id=seed["dev_user"].id,
date=date(2026, 4, 1),
slot_type=SlotType.ON_CALL,
estimated_duration=45,
scheduled_at=time(14, 30),
started_at=time(14, 35),
attended=True,
actual_duration=40,
event_type=EventType.JOB,
event_data={"type": "Task", "code": "TASK-42"},
priority=5,
status=SlotStatus.FINISHED,
)
db.add(slot)
db.commit()
db.refresh(slot)
assert slot.started_at == time(14, 35)
assert slot.attended is True
assert slot.actual_duration == 40
assert slot.event_type == EventType.JOB
assert slot.event_data == {"type": "Task", "code": "TASK-42"}
assert slot.priority == 5
assert slot.status == SlotStatus.FINISHED
def test_timeslot_slot_type_variants(self, db, seed):
"""Test all SlotType enum variants."""
for idx, slot_type in enumerate(SlotType):
slot = TimeSlot(
user_id=seed["admin_user"].id,
date=date(2026, 4, 1),
slot_type=slot_type,
estimated_duration=10,
scheduled_at=time(idx, 0),
status=SlotStatus.NOT_STARTED,
priority=idx,
)
db.add(slot)
db.commit()
slots = db.query(TimeSlot).filter_by(user_id=seed["admin_user"].id).all()
assert len(slots) == 4
assert {s.slot_type for s in slots} == set(SlotType)
def test_timeslot_status_transitions(self, db, seed):
"""Test all SlotStatus enum variants."""
for idx, status in enumerate(SlotStatus):
slot = TimeSlot(
user_id=seed["admin_user"].id,
date=date(2026, 4, 1),
slot_type=SlotType.WORK,
estimated_duration=10,
scheduled_at=time(idx, 0),
status=status,
priority=0,
)
db.add(slot)
db.commit()
slots = db.query(TimeSlot).filter_by(user_id=seed["admin_user"].id).all()
assert len(slots) == 7
assert {s.status for s in slots} == set(SlotStatus)
def test_timeslot_event_type_variants(self, db, seed):
"""Test all EventType enum variants."""
for idx, event_type in enumerate(EventType):
slot = TimeSlot(
user_id=seed["admin_user"].id,
date=date(2026, 4, 1),
slot_type=SlotType.WORK,
estimated_duration=10,
scheduled_at=time(idx, 0),
status=SlotStatus.NOT_STARTED,
event_type=event_type,
priority=0,
)
db.add(slot)
db.commit()
slots = db.query(TimeSlot).filter_by(user_id=seed["admin_user"].id).all()
assert len(slots) == 3
assert {s.event_type for s in slots} == set(EventType)
def test_timeslot_nullable_event_type(self, db, seed):
"""Test that event_type can be NULL."""
slot = TimeSlot(
user_id=seed["admin_user"].id,
date=date(2026, 4, 1),
slot_type=SlotType.WORK,
estimated_duration=30,
scheduled_at=time(9, 0),
status=SlotStatus.NOT_STARTED,
event_type=None,
priority=0,
)
db.add(slot)
db.commit()
db.refresh(slot)
assert slot.event_type is None
assert slot.event_data is None
def test_timeslot_duration_bounds(self, db, seed):
"""Test duration at boundary values (1-50)."""
# Min duration
slot_min = TimeSlot(
user_id=seed["admin_user"].id,
date=date(2026, 4, 1),
slot_type=SlotType.WORK,
estimated_duration=1,
scheduled_at=time(8, 0),
status=SlotStatus.NOT_STARTED,
priority=0,
)
db.add(slot_min)
# Max duration
slot_max = TimeSlot(
user_id=seed["admin_user"].id,
date=date(2026, 4, 1),
slot_type=SlotType.WORK,
estimated_duration=50,
scheduled_at=time(9, 0),
status=SlotStatus.NOT_STARTED,
priority=0,
)
db.add(slot_max)
db.commit()
assert slot_min.estimated_duration == 1
assert slot_max.estimated_duration == 50
def test_timeslot_priority_bounds(self, db, seed):
"""Test priority at boundary values (0-99)."""
slot_low = TimeSlot(
user_id=seed["admin_user"].id,
date=date(2026, 4, 1),
slot_type=SlotType.WORK,
estimated_duration=10,
scheduled_at=time(8, 0),
status=SlotStatus.NOT_STARTED,
priority=0,
)
db.add(slot_low)
slot_high = TimeSlot(
user_id=seed["admin_user"].id,
date=date(2026, 4, 1),
slot_type=SlotType.WORK,
estimated_duration=10,
scheduled_at=time(9, 0),
status=SlotStatus.NOT_STARTED,
priority=99,
)
db.add(slot_high)
db.commit()
assert slot_low.priority == 0
assert slot_high.priority == 99
def test_timeslot_timestamps_auto_set(self, db, seed):
"""Test that created_at and updated_at are set automatically."""
slot = TimeSlot(
user_id=seed["admin_user"].id,
date=date(2026, 4, 1),
slot_type=SlotType.WORK,
estimated_duration=30,
scheduled_at=time(9, 0),
status=SlotStatus.NOT_STARTED,
priority=0,
)
db.add(slot)
db.commit()
db.refresh(slot)
assert slot.created_at is not None
assert isinstance(slot.created_at, datetime)
def test_timeslot_user_foreign_key(self, db):
"""Test that invalid user_id raises IntegrityError."""
slot = TimeSlot(
user_id=99999, # Non-existent user
date=date(2026, 4, 1),
slot_type=SlotType.WORK,
estimated_duration=30,
scheduled_at=time(9, 0),
status=SlotStatus.NOT_STARTED,
priority=0,
)
db.add(slot)
with pytest.raises(IntegrityError):
db.commit()
def test_timeslot_plan_relationship(self, db, seed):
"""Test relationship between TimeSlot and SchedulePlan."""
# Create a plan first
plan = SchedulePlan(
user_id=seed["admin_user"].id,
slot_type=SlotType.WORK,
estimated_duration=30,
at_time=time(9, 0),
is_active=True,
)
db.add(plan)
db.commit()
db.refresh(plan)
# Create a slot linked to the plan
slot = TimeSlot(
user_id=seed["admin_user"].id,
date=date(2026, 4, 1),
slot_type=SlotType.WORK,
estimated_duration=30,
scheduled_at=time(9, 0),
status=SlotStatus.NOT_STARTED,
priority=0,
plan_id=plan.id,
)
db.add(slot)
db.commit()
db.refresh(slot)
assert slot.plan_id == plan.id
assert slot.plan.id == plan.id
assert slot.plan.user_id == seed["admin_user"].id
def test_timeslot_query_by_date(self, db, seed):
"""Test querying slots by date."""
dates = [date(2026, 4, 1), date(2026, 4, 2), date(2026, 4, 1)]
for idx, d in enumerate(dates):
slot = TimeSlot(
user_id=seed["admin_user"].id,
date=d,
slot_type=SlotType.WORK,
estimated_duration=30,
scheduled_at=time(9 + idx, 0),
status=SlotStatus.NOT_STARTED,
priority=0,
)
db.add(slot)
db.commit()
slots_april_1 = db.query(TimeSlot).filter_by(
user_id=seed["admin_user"].id,
date=date(2026, 4, 1)
).all()
assert len(slots_april_1) == 2
def test_timeslot_query_by_status(self, db, seed):
"""Test querying slots by status."""
for idx, status in enumerate([SlotStatus.NOT_STARTED, SlotStatus.ONGOING, SlotStatus.NOT_STARTED]):
slot = TimeSlot(
user_id=seed["admin_user"].id,
date=date(2026, 4, 1),
slot_type=SlotType.WORK,
estimated_duration=30,
scheduled_at=time(9 + idx, 0),
status=status,
priority=0,
)
db.add(slot)
db.commit()
not_started = db.query(TimeSlot).filter_by(
user_id=seed["admin_user"].id,
status=SlotStatus.NOT_STARTED
).all()
assert len(not_started) == 2
# ---------------------------------------------------------------------------
# SchedulePlan Model Tests
# ---------------------------------------------------------------------------
class TestSchedulePlanModel:
"""Tests for SchedulePlan ORM model."""
def test_create_plan_basic(self, db, seed):
"""Test creating a basic SchedulePlan with required fields."""
plan = SchedulePlan(
user_id=seed["admin_user"].id,
slot_type=SlotType.WORK,
estimated_duration=30,
at_time=time(9, 0),
is_active=True,
)
db.add(plan)
db.commit()
db.refresh(plan)
assert plan.id is not None
assert plan.user_id == seed["admin_user"].id
assert plan.slot_type == SlotType.WORK
assert plan.estimated_duration == 30
assert plan.at_time == time(9, 0)
assert plan.is_active is True
assert plan.on_day is None
assert plan.on_week is None
assert plan.on_month is None
assert plan.event_type is None
assert plan.event_data is None
def test_create_plan_daily(self, db, seed):
"""Test creating a daily plan (--at only)."""
plan = SchedulePlan(
user_id=seed["admin_user"].id,
slot_type=SlotType.WORK,
estimated_duration=25,
at_time=time(10, 0),
is_active=True,
)
db.add(plan)
db.commit()
db.refresh(plan)
assert plan.at_time == time(10, 0)
assert plan.on_day is None
assert plan.on_week is None
assert plan.on_month is None
def test_create_plan_weekly(self, db, seed):
"""Test creating a weekly plan (--at + --on-day)."""
plan = SchedulePlan(
user_id=seed["admin_user"].id,
slot_type=SlotType.ON_CALL,
estimated_duration=45,
at_time=time(14, 0),
on_day=DayOfWeek.MON,
is_active=True,
)
db.add(plan)
db.commit()
db.refresh(plan)
assert plan.on_day == DayOfWeek.MON
assert plan.on_week is None
assert plan.on_month is None
def test_create_plan_monthly(self, db, seed):
"""Test creating a monthly plan (--at + --on-day + --on-week)."""
plan = SchedulePlan(
user_id=seed["admin_user"].id,
slot_type=SlotType.ENTERTAINMENT,
estimated_duration=45,
at_time=time(19, 0),
on_day=DayOfWeek.FRI,
on_week=2,
is_active=True,
)
db.add(plan)
db.commit()
db.refresh(plan)
assert plan.on_day == DayOfWeek.FRI
assert plan.on_week == 2
assert plan.on_month is None
def test_create_plan_yearly(self, db, seed):
"""Test creating a yearly plan (all period params)."""
plan = SchedulePlan(
user_id=seed["admin_user"].id,
slot_type=SlotType.WORK,
estimated_duration=50,
at_time=time(9, 0),
on_day=DayOfWeek.SUN,
on_week=1,
on_month=MonthOfYear.JAN,
is_active=True,
)
db.add(plan)
db.commit()
db.refresh(plan)
assert plan.on_day == DayOfWeek.SUN
assert plan.on_week == 1
assert plan.on_month == MonthOfYear.JAN
def test_create_plan_with_event(self, db, seed):
"""Test creating a plan with event_type and event_data."""
plan = SchedulePlan(
user_id=seed["admin_user"].id,
slot_type=SlotType.WORK,
estimated_duration=30,
at_time=time(9, 0),
event_type=EventType.JOB,
event_data={"type": "Meeting", "participants": ["user1", "user2"]},
is_active=True,
)
db.add(plan)
db.commit()
db.refresh(plan)
assert plan.event_type == EventType.JOB
assert plan.event_data == {"type": "Meeting", "participants": ["user1", "user2"]}
def test_plan_slot_type_variants(self, db, seed):
"""Test all SlotType enum variants for SchedulePlan."""
for idx, slot_type in enumerate(SlotType):
plan = SchedulePlan(
user_id=seed["admin_user"].id,
slot_type=slot_type,
estimated_duration=10,
at_time=time(idx, 0),
is_active=True,
)
db.add(plan)
db.commit()
plans = db.query(SchedulePlan).filter_by(user_id=seed["admin_user"].id).all()
assert len(plans) == 4
assert {p.slot_type for p in plans} == set(SlotType)
def test_plan_on_week_validation(self, db, seed):
"""Test on_week validation (must be 1-4)."""
# Valid values
for week in [1, 2, 3, 4]:
plan = SchedulePlan(
user_id=seed["admin_user"].id,
slot_type=SlotType.WORK,
estimated_duration=30,
at_time=time(9, 0),
on_day=DayOfWeek.MON,
on_week=week,
is_active=True,
)
db.add(plan)
db.commit()
plans = db.query(SchedulePlan).filter_by(user_id=seed["admin_user"].id).all()
assert len(plans) == 4
assert {p.on_week for p in plans} == {1, 2, 3, 4}
def test_plan_on_week_validation_invalid(self, db, seed):
"""Test that invalid on_week values raise ValueError."""
for week in [0, 5, 10, -1]:
with pytest.raises(ValueError):
plan = SchedulePlan(
user_id=seed["admin_user"].id,
slot_type=SlotType.WORK,
estimated_duration=30,
at_time=time(9, 0),
on_day=DayOfWeek.MON,
on_week=week, # Invalid
is_active=True,
)
db.add(plan)
db.commit()
db.rollback()
def test_plan_duration_validation(self, db, seed):
"""Test estimated_duration validation (must be 1-50)."""
# Valid bounds
plan_min = SchedulePlan(
user_id=seed["admin_user"].id,
slot_type=SlotType.WORK,
estimated_duration=1,
at_time=time(8, 0),
is_active=True,
)
db.add(plan_min)
plan_max = SchedulePlan(
user_id=seed["admin_user"].id,
slot_type=SlotType.WORK,
estimated_duration=50,
at_time=time(9, 0),
is_active=True,
)
db.add(plan_max)
db.commit()
assert plan_min.estimated_duration == 1
assert plan_max.estimated_duration == 50
def test_plan_duration_validation_invalid(self, db, seed):
"""Test that invalid estimated_duration raises ValueError."""
for duration in [0, 51, 100, -10]:
with pytest.raises(ValueError):
plan = SchedulePlan(
user_id=seed["admin_user"].id,
slot_type=SlotType.WORK,
estimated_duration=duration,
at_time=time(9, 0),
is_active=True,
)
db.add(plan)
db.commit()
db.rollback()
def test_plan_hierarchy_constraint_month_requires_week(self, db, seed):
"""Test validation: on_month requires on_week."""
with pytest.raises(ValueError, match="on_month requires on_week"):
SchedulePlan(
user_id=seed["admin_user"].id,
slot_type=SlotType.WORK,
estimated_duration=30,
at_time=time(9, 0),
on_month=MonthOfYear.JAN, # Without on_week
is_active=True,
)
def test_plan_hierarchy_constraint_week_requires_day(self, db, seed):
"""Test DB constraint: on_week requires on_day."""
plan = SchedulePlan(
user_id=seed["admin_user"].id,
slot_type=SlotType.WORK,
estimated_duration=30,
at_time=time(9, 0),
on_week=1, # Without on_day
is_active=True,
)
db.add(plan)
with pytest.raises(IntegrityError):
db.commit()
def test_plan_day_of_week_enum(self, db, seed):
"""Test all DayOfWeek enum values."""
for day in DayOfWeek:
plan = SchedulePlan(
user_id=seed["admin_user"].id,
slot_type=SlotType.WORK,
estimated_duration=10,
at_time=time(9, 0),
on_day=day,
is_active=True,
)
db.add(plan)
db.commit()
plans = db.query(SchedulePlan).filter_by(user_id=seed["admin_user"].id).all()
assert len(plans) == 7
assert {p.on_day for p in plans} == set(DayOfWeek)
def test_plan_month_of_year_enum(self, db, seed):
"""Test all MonthOfYear enum values."""
for month in MonthOfYear:
plan = SchedulePlan(
user_id=seed["admin_user"].id,
slot_type=SlotType.WORK,
estimated_duration=10,
at_time=time(9, 0),
on_day=DayOfWeek.MON,
on_week=1,
on_month=month,
is_active=True,
)
db.add(plan)
db.commit()
plans = db.query(SchedulePlan).filter_by(user_id=seed["admin_user"].id).all()
assert len(plans) == 12
assert {p.on_month for p in plans} == set(MonthOfYear)
def test_plan_materialized_slots_relationship(self, db, seed):
"""Test relationship between SchedulePlan and TimeSlot."""
plan = SchedulePlan(
user_id=seed["admin_user"].id,
slot_type=SlotType.WORK,
estimated_duration=30,
at_time=time(9, 0),
is_active=True,
)
db.add(plan)
db.commit()
db.refresh(plan)
# Create slots linked to the plan
for i in range(3):
slot = TimeSlot(
user_id=seed["admin_user"].id,
date=date(2026, 4, 1 + i),
slot_type=SlotType.WORK,
estimated_duration=30,
scheduled_at=time(9, 0),
status=SlotStatus.NOT_STARTED,
priority=0,
plan_id=plan.id,
)
db.add(slot)
db.commit()
# Refresh to get relationship
db.refresh(plan)
materialized = plan.materialized_slots.all()
assert len(materialized) == 3
assert all(s.plan_id == plan.id for s in materialized)
def test_plan_is_active_default_true(self, db, seed):
"""Test that is_active defaults to True."""
plan = SchedulePlan(
user_id=seed["admin_user"].id,
slot_type=SlotType.WORK,
estimated_duration=30,
at_time=time(9, 0),
)
db.add(plan)
db.commit()
db.refresh(plan)
assert plan.is_active is True
def test_plan_soft_delete(self, db, seed):
"""Test soft delete by setting is_active=False."""
plan = SchedulePlan(
user_id=seed["admin_user"].id,
slot_type=SlotType.WORK,
estimated_duration=30,
at_time=time(9, 0),
is_active=True,
)
db.add(plan)
db.commit()
db.refresh(plan)
# Soft delete
plan.is_active = False
db.commit()
db.refresh(plan)
assert plan.is_active is False
def test_plan_timestamps(self, db, seed):
"""Test that created_at is set automatically."""
plan = SchedulePlan(
user_id=seed["admin_user"].id,
slot_type=SlotType.WORK,
estimated_duration=30,
at_time=time(9, 0),
is_active=True,
)
db.add(plan)
db.commit()
db.refresh(plan)
assert plan.created_at is not None
assert isinstance(plan.created_at, datetime)
# ---------------------------------------------------------------------------
# Combined Model Tests
# ---------------------------------------------------------------------------
class TestCalendarModelsCombined:
"""Tests for interactions between TimeSlot and SchedulePlan."""
def test_plan_to_slots_cascade_behavior(self, db, seed):
"""Test that deleting a plan doesn't delete materialized slots."""
plan = SchedulePlan(
user_id=seed["admin_user"].id,
slot_type=SlotType.WORK,
estimated_duration=30,
at_time=time(9, 0),
is_active=True,
)
db.add(plan)
db.commit()
db.refresh(plan)
# Create slots linked to the plan
for i in range(3):
slot = TimeSlot(
user_id=seed["admin_user"].id,
date=date(2026, 4, 1 + i),
slot_type=SlotType.WORK,
estimated_duration=30,
scheduled_at=time(9, 0),
status=SlotStatus.NOT_STARTED,
priority=0,
plan_id=plan.id,
)
db.add(slot)
db.commit()
# Delete the plan (soft delete)
plan.is_active = False
db.commit()
# Slots should still exist
slots = db.query(TimeSlot).filter_by(user_id=seed["admin_user"].id).all()
assert len(slots) == 3
# plan_id should remain (not cascade deleted)
assert all(s.plan_id == plan.id for s in slots)
def test_multiple_plans_per_user(self, db, seed):
"""Test that a user can have multiple plans."""
for i, slot_type in enumerate([SlotType.WORK, SlotType.ON_CALL, SlotType.ENTERTAINMENT]):
plan = SchedulePlan(
user_id=seed["admin_user"].id,
slot_type=slot_type,
estimated_duration=30,
at_time=time(9 + i, 0),
is_active=True,
)
db.add(plan)
db.commit()
plans = db.query(SchedulePlan).filter_by(
user_id=seed["admin_user"].id,
is_active=True
).all()
assert len(plans) == 3
def test_multiple_slots_per_user(self, db, seed):
"""Test that a user can have multiple slots on same day."""
target_date = date(2026, 4, 1)
for i in range(5):
slot = TimeSlot(
user_id=seed["admin_user"].id,
date=target_date,
slot_type=SlotType.WORK,
estimated_duration=10,
scheduled_at=time(9 + i, 0),
status=SlotStatus.NOT_STARTED,
priority=i,
)
db.add(slot)
db.commit()
slots = db.query(TimeSlot).filter_by(
user_id=seed["admin_user"].id,
date=target_date
).all()
assert len(slots) == 5
# Check ordering by scheduled_at
times = [s.scheduled_at for s in sorted(slots, key=lambda x: x.scheduled_at)]
assert times == [time(9, 0), time(10, 0), time(11, 0), time(12, 0), time(13, 0)]
def test_different_users_isolated(self, db, seed):
"""Test that users cannot see each other's slots/plans."""
# Create plan and slot for admin
admin_plan = SchedulePlan(
user_id=seed["admin_user"].id,
slot_type=SlotType.WORK,
estimated_duration=30,
at_time=time(9, 0),
is_active=True,
)
db.add(admin_plan)
admin_slot = TimeSlot(
user_id=seed["admin_user"].id,
date=date(2026, 4, 1),
slot_type=SlotType.WORK,
estimated_duration=30,
scheduled_at=time(9, 0),
status=SlotStatus.NOT_STARTED,
priority=0,
)
db.add(admin_slot)
# Create plan and slot for dev user
dev_plan = SchedulePlan(
user_id=seed["dev_user"].id,
slot_type=SlotType.ON_CALL,
estimated_duration=45,
at_time=time(14, 0),
is_active=True,
)
db.add(dev_plan)
dev_slot = TimeSlot(
user_id=seed["dev_user"].id,
date=date(2026, 4, 1),
slot_type=SlotType.ON_CALL,
estimated_duration=45,
scheduled_at=time(14, 0),
status=SlotStatus.NOT_STARTED,
priority=0,
)
db.add(dev_slot)
db.commit()
# Verify isolation
admin_slots = db.query(TimeSlot).filter_by(user_id=seed["admin_user"].id).all()
dev_slots = db.query(TimeSlot).filter_by(user_id=seed["dev_user"].id).all()
assert len(admin_slots) == 1
assert len(dev_slots) == 1
assert admin_slots[0].slot_type == SlotType.WORK
assert dev_slots[0].slot_type == SlotType.ON_CALL
admin_plans = db.query(SchedulePlan).filter_by(user_id=seed["admin_user"].id).all()
dev_plans = db.query(SchedulePlan).filter_by(user_id=seed["dev_user"].id).all()
assert len(admin_plans) == 1
assert len(dev_plans) == 1