Compare commits

...

26 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
zhi
45ab4583de TEST-BE-PR-001 fix calendar schema import recursion 2026-04-01 10:04:50 +00:00
zhi
2cc07b9c3e BE-AGT-004 parse exhausted recovery hints 2026-04-01 04:18:44 +00:00
zhi
a94ef43974 BE-AGT-003: implement multi-slot competition handling
- resolve_slot_competition: selects highest-priority slot as winner,
  marks remaining as Deferred with priority += 1 (capped at 99)
- defer_all_slots: defers all pending slots when agent is not idle
- CompetitionResult dataclass for structured return
- Full test coverage: winner selection, priority bumping, cap, ties,
  empty input, single slot, already-deferred slots
2026-04-01 02:49:30 +00:00
26 changed files with 2651 additions and 488 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
# Install system dependencies
# Install build dependencies
RUN apt-get update && apt-get install -y \
build-essential \
curl \
default-libmysqlclient-dev \
pkg-config \
&& 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
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 . .
COPY app/ ./app/
COPY requirements.txt ./
# Make entrypoint
COPY entrypoint.sh .
RUN chmod +x entrypoint.sh
# Expose port
EXPOSE 8000
# Wait for wizard config, then start uvicorn
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.
"""
from datetime import date as date_type
from datetime import date as date_type, datetime, timezone
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 app.api.deps import get_current_user
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.agent import Agent, AgentStatus, ExhaustReason
from app.schemas.calendar import (
AgentHeartbeatResponse,
AgentStatusUpdateRequest,
CalendarDayResponse,
CalendarSlotItem,
DateListResponse,
@@ -32,7 +35,9 @@ from app.schemas.calendar import (
SchedulePlanEdit,
SchedulePlanListResponse,
SchedulePlanResponse,
SlotStatusEnum,
SlotConflictItem,
SlotAgentUpdate,
TimeSlotCancelResponse,
TimeSlotCreate,
TimeSlotCreateResponse,
@@ -40,6 +45,15 @@ from app.schemas.calendar import (
TimeSlotEditResponse,
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 (
get_workload_config,
get_workload_warnings_for_date,
@@ -62,10 +76,52 @@ from app.services.slot_immutability import (
guard_plan_cancel_no_past_retroaction,
guard_plan_edit_no_past_retroaction,
)
from app.models.role_permission import Permission, RolePermission
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)
# ---------------------------------------------------------------------------
@@ -101,7 +157,7 @@ def _slot_to_response(slot: TimeSlot) -> TimeSlotResponse:
def create_slot(
payload: TimeSlotCreate,
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.
@@ -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(
"/day",
response_model=CalendarDayResponse,
@@ -230,7 +443,7 @@ def _virtual_slot_to_item(vs: dict) -> CalendarSlotItem:
def get_calendar_day(
date: Optional[date_type] = Query(None, description="Target date (defaults to today)"),
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.
@@ -301,7 +514,7 @@ def edit_real_slot(
slot_id: int,
payload: TimeSlotEdit,
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.
@@ -380,7 +593,7 @@ def edit_virtual_slot(
virtual_id: str,
payload: TimeSlotEdit,
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.
@@ -469,7 +682,7 @@ def edit_virtual_slot(
def cancel_real_slot(
slot_id: int,
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.
@@ -516,7 +729,7 @@ def cancel_real_slot(
def cancel_virtual_slot(
virtual_id: str,
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.
@@ -596,7 +809,7 @@ def _plan_to_response(plan: SchedulePlan) -> SchedulePlanResponse:
def create_plan(
payload: SchedulePlanCreate,
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.
@@ -632,7 +845,7 @@ def create_plan(
def list_plans(
include_inactive: bool = Query(False, description="Include cancelled/inactive plans"),
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.
@@ -658,7 +871,7 @@ def list_plans(
def get_plan(
plan_id: int,
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."""
plan = (
@@ -705,7 +918,7 @@ def edit_plan(
plan_id: int,
payload: SchedulePlanEdit,
db: Session = Depends(get_db),
current_user: User = Depends(get_current_user),
current_user: User = Depends(require_calendar_write),
):
"""Edit an existing schedule plan.
@@ -792,7 +1005,7 @@ def edit_plan(
def cancel_plan(
plan_id: int,
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.
@@ -859,7 +1072,7 @@ _DATE_LIST_EXCLUDED_STATUSES = {SlotStatus.SKIPPED.value, SlotStatus.ABORTED.val
)
def list_dates(
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
materialized (real) slot.
@@ -897,7 +1110,7 @@ def list_dates(
)
def get_my_workload_config(
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.
@@ -916,7 +1129,7 @@ def get_my_workload_config(
def put_my_workload_config(
payload: MinimumWorkloadConfig,
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."""
row = replace_workload_config(db, current_user.id, payload)
@@ -933,7 +1146,7 @@ def put_my_workload_config(
def patch_my_workload_config(
payload: MinimumWorkloadUpdate,
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."""
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.
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.
"""
@@ -26,7 +26,7 @@ from app.services.activity import log_activity
from app.services.essential_code import generate_essential_code
router = APIRouter(
prefix="/projects/{project_id}/proposals/{proposal_id}/essentials",
prefix="/projects/{project_code}/proposals/{proposal_code}/essentials",
tags=["Essentials"],
)
@@ -35,53 +35,27 @@ router = APIRouter(
# Helpers
# ---------------------------------------------------------------------------
def _find_project(db: Session, identifier: str):
"""Look up project by numeric id or 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
def _find_project(db: Session, project_code: str):
"""Look up project by project_code."""
return db.query(models.Project).filter(
models.Project.project_code == str(identifier)
models.Project.project_code == str(project_code)
).first()
def _find_proposal(db: Session, identifier: str, project_id: int) -> Proposal | None:
"""Look up proposal by numeric id or 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
def _find_proposal(db: Session, proposal_code: str, project_id: int) -> Proposal | None:
"""Look up proposal by propose_code within a project."""
return (
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()
)
def _find_essential(db: Session, identifier: str, proposal_id: int) -> Essential | None:
"""Look up essential by numeric id or 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
def _find_essential(db: Session, essential_code: str, proposal_id: int) -> Essential | None:
"""Look up essential by essential_code within a proposal."""
return (
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()
)
@@ -108,12 +82,11 @@ def _can_edit_proposal(db: Session, user_id: int, proposal: Proposal) -> bool:
return False
def _serialize_essential(e: Essential) -> dict:
def _serialize_essential(e: Essential, proposal_code: str | None) -> dict:
"""Return a dict matching EssentialResponse."""
return {
"id": e.id,
"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,
"title": e.title,
"description": e.description,
@@ -129,18 +102,18 @@ def _serialize_essential(e: Essential) -> dict:
@router.get("", response_model=List[EssentialResponse])
def list_essentials(
project_id: str,
proposal_id: str,
project_code: str,
proposal_code: str,
db: Session = Depends(get_db),
current_user: models.User = Depends(get_current_user_or_apikey),
):
"""List all Essentials under a Proposal."""
project = _find_project(db, project_id)
project = _find_project(db, project_code)
if not project:
raise HTTPException(status_code=404, detail="Project not found")
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:
raise HTTPException(status_code=404, detail="Proposal not found")
@@ -150,24 +123,24 @@ def list_essentials(
.order_by(Essential.id.asc())
.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)
def create_essential(
project_id: str,
proposal_id: str,
project_code: str,
proposal_code: str,
body: EssentialCreate,
db: Session = Depends(get_db),
current_user: models.User = Depends(get_current_user_or_apikey),
):
"""Create a new Essential under an open Proposal."""
project = _find_project(db, project_id)
project = _find_project(db, project_code)
if not project:
raise HTTPException(status_code=404, detail="Project not found")
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:
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},
)
return _serialize_essential(essential)
return _serialize_essential(essential, proposal.propose_code)
@router.get("/{essential_id}", response_model=EssentialResponse)
def get_essential(
project_id: str,
proposal_id: str,
essential_id: str,
project_code: str,
proposal_code: str,
essential_code: str,
db: Session = Depends(get_db),
current_user: models.User = Depends(get_current_user_or_apikey),
):
"""Get a single Essential by id or essential_code."""
project = _find_project(db, project_id)
"""Get a single Essential by essential_code."""
project = _find_project(db, project_code)
if not project:
raise HTTPException(status_code=404, detail="Project not found")
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:
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:
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)
def update_essential(
project_id: str,
proposal_id: str,
essential_id: str,
project_code: str,
proposal_code: str,
essential_code: str,
body: EssentialUpdate,
db: Session = Depends(get_db),
current_user: models.User = Depends(get_current_user_or_apikey),
):
"""Update an Essential (only on open Proposals)."""
project = _find_project(db, project_id)
project = _find_project(db, project_code)
if not project:
raise HTTPException(status_code=404, detail="Project not found")
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:
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):
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:
raise HTTPException(status_code=404, detail="Essential not found")
@@ -265,24 +238,24 @@ def update_essential(
details=data,
)
return _serialize_essential(essential)
return _serialize_essential(essential, proposal.propose_code)
@router.delete("/{essential_id}", status_code=status.HTTP_204_NO_CONTENT)
def delete_essential(
project_id: str,
proposal_id: str,
essential_id: str,
project_code: str,
proposal_code: str,
essential_code: str,
db: Session = Depends(get_db),
current_user: models.User = Depends(get_current_user_or_apikey),
):
"""Delete an Essential (only on open Proposals)."""
project = _find_project(db, project_id)
project = _find_project(db, project_code)
if not project:
raise HTTPException(status_code=404, detail="Project not found")
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:
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):
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:
raise HTTPException(status_code=404, detail="Essential not found")

View File

@@ -18,15 +18,8 @@ router = APIRouter(tags=["Meetings"])
# ---- helpers ----
def _find_meeting_by_id_or_code(db: Session, identifier: str) -> Meeting | None:
try:
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 _find_meeting_by_code(db: Session, meeting_code: str) -> Meeting | None:
return db.query(Meeting).filter(Meeting.meeting_code == str(meeting_code)).first()
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()
milestone = db.query(Milestone).filter(Milestone.id == meeting.milestone_id).first()
return {
"id": meeting.id,
"code": meeting.meeting_code,
"meeting_code": meeting.meeting_code,
"title": meeting.title,
"description": meeting.description,
"status": meeting.status.value if hasattr(meeting.status, "value") else meeting.status,
"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,
"milestone_id": meeting.milestone_id,
"milestone_code": milestone.milestone_code if milestone else None,
"reporter_id": meeting.reporter_id,
"meeting_time": meeting.scheduled_at.isoformat() if meeting.scheduled_at else None,
@@ -155,6 +145,7 @@ def create_meeting(
@router.get("/meetings")
def list_meetings(
project: str = None,
project_code: str = None,
status_value: str = Query(None, alias="status"),
order_by: str = None,
page: int = 1,
@@ -163,8 +154,9 @@ def list_meetings(
):
query = db.query(Meeting)
if project:
project_id = _resolve_project_id(db, project)
effective_project = project_code or project
if effective_project:
project_id = _resolve_project_id(db, effective_project)
if project_id:
query = query.filter(Meeting.project_id == project_id)
@@ -197,9 +189,9 @@ def list_meetings(
}
@router.get("/meetings/{meeting_id}")
def get_meeting(meeting_id: str, db: Session = Depends(get_db)):
meeting = _find_meeting_by_id_or_code(db, meeting_id)
@router.get("/meetings/{meeting_code}")
def get_meeting(meeting_code: str, db: Session = Depends(get_db)):
meeting = _find_meeting_by_code(db, meeting_code)
if not meeting:
raise HTTPException(status_code=404, detail="Meeting not found")
return _serialize_meeting(db, meeting)
@@ -213,14 +205,14 @@ class MeetingUpdateBody(BaseModel):
duration_minutes: Optional[int] = None
@router.patch("/meetings/{meeting_id}")
@router.patch("/meetings/{meeting_code}")
def update_meeting(
meeting_id: str,
meeting_code: str,
body: MeetingUpdateBody,
db: Session = Depends(get_db),
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:
raise HTTPException(status_code=404, detail="Meeting not found")
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)
@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(
meeting_id: str,
meeting_code: str,
db: Session = Depends(get_db),
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:
raise HTTPException(status_code=404, detail="Meeting not found")
check_project_role(db, current_user.id, meeting.project_id, min_role="dev")
@@ -265,13 +257,13 @@ def delete_meeting(
# ---- Attend ----
@router.post("/meetings/{meeting_id}/attend")
@router.post("/meetings/{meeting_code}/attend")
def attend_meeting(
meeting_id: str,
meeting_code: str,
db: Session = Depends(get_db),
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:
raise HTTPException(status_code=404, detail="Meeting not found")
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
router = APIRouter(
prefix="/projects/{project_id}/milestones/{milestone_id}/actions",
prefix="/projects/{project_code}/milestones/{milestone_code}/actions",
tags=["Milestone Actions"],
)
@@ -29,10 +29,18 @@ router = APIRouter(
# 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 = (
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()
)
if not ms:
@@ -59,8 +67,8 @@ class CloseBody(BaseModel):
@router.get("/preflight", status_code=200)
def preflight_milestone_actions(
project_id: int,
milestone_id: int,
project_code: str,
milestone_code: str,
db: Session = Depends(get_db),
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
hint text to show. This endpoint never mutates data.
"""
check_project_role(db, current_user.id, project_id, min_role="viewer")
ms = _get_milestone_or_404(db, project_id, milestone_id)
project = _resolve_project_or_404(db, project_code)
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)
result: dict = {"status": ms_status, "freeze": None, "start": None}
@@ -80,7 +89,7 @@ def preflight_milestone_actions(
release_tasks = (
db.query(Task)
.filter(
Task.milestone_id == milestone_id,
Task.milestone_id == ms.id,
Task.task_type == "maintenance",
Task.task_subtype == "release",
)
@@ -118,8 +127,8 @@ def preflight_milestone_actions(
@router.post("/freeze", status_code=200)
def freeze_milestone(
project_id: int,
milestone_id: int,
project_code: str,
milestone_code: str,
db: Session = Depends(get_db),
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``.
- Caller must have ``freeze milestone`` permission.
"""
check_project_role(db, current_user.id, project_id, min_role="mgr")
check_permission(db, current_user.id, project_id, "milestone.freeze")
project = _resolve_project_or_404(db, project_code)
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":
raise HTTPException(
@@ -145,7 +155,7 @@ def freeze_milestone(
release_tasks = (
db.query(Task)
.filter(
Task.milestone_id == milestone_id,
Task.milestone_id == ms.id,
Task.task_type == "maintenance",
Task.task_subtype == "release",
)
@@ -184,8 +194,8 @@ def freeze_milestone(
@router.post("/start", status_code=200)
def start_milestone(
project_id: int,
milestone_id: int,
project_code: str,
milestone_code: str,
db: Session = Depends(get_db),
current_user: models.User = Depends(get_current_user_or_apikey),
):
@@ -196,10 +206,11 @@ def start_milestone(
- All milestone dependencies must be completed.
- Caller must have ``start milestone`` permission.
"""
check_project_role(db, current_user.id, project_id, min_role="mgr")
check_permission(db, current_user.id, project_id, "milestone.start")
project = _resolve_project_or_404(db, project_code)
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":
raise HTTPException(
@@ -240,8 +251,8 @@ def start_milestone(
@router.post("/close", status_code=200)
def close_milestone(
project_id: int,
milestone_id: int,
project_code: str,
milestone_code: str,
body: CloseBody = CloseBody(),
db: Session = Depends(get_db),
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.
- Caller must have ``close milestone`` permission.
"""
check_project_role(db, current_user.id, project_id, min_role="mgr")
check_permission(db, current_user.id, project_id, "milestone.close")
project = _resolve_project_or_404(db, project_code)
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)
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()
def _serialize_milestone(milestone):
"""Serialize milestone with JSON fields and code."""
def _serialize_milestone(db, milestone):
"""Serialize milestone with JSON fields and code-first identifiers."""
project = db.query(models.Project).filter(models.Project.id == milestone.project_id).first()
return {
"id": milestone.id,
"title": milestone.title,
"description": milestone.description,
"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,
"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 [],
"project_id": milestone.project_id,
"milestone_code": milestone.milestone_code,
"code": milestone.milestone_code,
"project_code": project.project_code if project else None,
"created_by_id": milestone.created_by_id,
"started_at": milestone.started_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")
check_project_role(db, current_user.id, project.id, min_role="viewer")
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)
@@ -101,7 +101,7 @@ def create_milestone(project_id: str, milestone: schemas.MilestoneCreate, db: Se
db.add(db_milestone)
db.commit()
db.refresh(db_milestone)
return _serialize_milestone(db_milestone)
return _serialize_milestone(db, db_milestone)
@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)
if not milestone:
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)
@@ -163,7 +163,7 @@ def update_milestone(project_id: str, milestone_id: str, milestone: schemas.Mile
setattr(db_milestone, key, value)
db.commit()
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)

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"])
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)
if project_id:
effective_project = project_code or project_id
if effective_project:
# Resolve project_id by numeric id or project_code
resolved_project = None
try:
pid = int(project_id)
pid = int(effective_project)
resolved_project = db.query(models.Project).filter(models.Project.id == pid).first()
except (ValueError, TypeError):
pass
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:
raise HTTPException(status_code=404, detail="Project not found")
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 ============
@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()
if not project:
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(
Task.project_id == project.id,
Task.milestone_id == milestone_id
Task.milestone_id == milestone.id
).all()
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"])
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()
if not project:
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:
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_subtype=task_data.get("task_subtype"),
project_id=project.id,
milestone_id=milestone_id,
milestone_id=ms.id,
reporter_id=current_user.id,
task_code=task_code,
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)
return {
"id": task.id,
"title": task.title,
"description": task.description,
"task_code": task.task_code,
"code": task.task_code,
"status": task.status.value,
"priority": task.priority.value,
"created_at": task.created_at,
@@ -516,15 +524,8 @@ def create_milestone_task(project_code: str, milestone_id: int, task_data: dict,
# ============ Supports ============
def _find_support_by_id_or_code(db: Session, identifier: str) -> Support | None:
try:
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()
def _find_support_by_code(db: Session, support_code: str) -> Support | None:
return db.query(Support).filter(Support.support_code == str(support_code)).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()
return {
"id": support.id,
"code": support.support_code,
"support_code": support.support_code,
"title": support.title,
"description": support.description,
"status": support.status.value if hasattr(support.status, "value") else support.status,
"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,
"milestone_id": support.milestone_id,
"milestone_code": milestone.milestone_code if milestone else None,
"reporter_id": support.reporter_id,
"assignee_id": support.assignee_id,
@@ -585,26 +583,30 @@ def list_all_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()
if not project:
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(
Support.project_id == project.id,
Support.milestone_id == milestone_id
Support.milestone_id == milestone.id
).all()
return [_serialize_support(db, s) for s in 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()
if not project:
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:
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")
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
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,
priority=SupportPriority.MEDIUM,
project_id=project.id,
milestone_id=milestone_id,
milestone_id=ms.id,
reporter_id=current_user.id,
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)
@router.get("/supports/{support_id}", tags=["Supports"])
def get_support(support_id: 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)
@router.get("/supports/{support_code}", tags=["Supports"])
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_code(db, support_code)
if not support:
raise HTTPException(status_code=404, detail="Support not found")
check_project_role(db, current_user.id, support.project_id, min_role="viewer")
return _serialize_support(db, support)
@router.patch("/supports/{support_id}", 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)):
support = _find_support_by_id_or_code(db, support_id)
@router.patch("/supports/{support_code}", tags=["Supports"])
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_code(db, support_code)
if not support:
raise HTTPException(status_code=404, detail="Support not found")
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)
@router.delete("/supports/{support_id}", 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)):
support = _find_support_by_id_or_code(db, support_id)
@router.delete("/supports/{support_code}", status_code=status.HTTP_204_NO_CONTENT, tags=["Supports"])
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_code(db, support_code)
if not support:
raise HTTPException(status_code=404, detail="Support not found")
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
@router.post("/supports/{support_id}/take", tags=["Supports"])
def take_support(support_id: 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)
@router.post("/supports/{support_code}/take", tags=["Supports"])
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_code(db, support_code)
if not support:
raise HTTPException(status_code=404, detail="Support not found")
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)
@router.post("/supports/{support_id}/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)):
support = _find_support_by_id_or_code(db, support_id)
@router.post("/supports/{support_code}/transition", tags=["Supports"])
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_code(db, support_code)
if not support:
raise HTTPException(status_code=404, detail="Support not found")
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 ============
@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()
if not project:
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(
Meeting.project_id == project.id,
Meeting.milestone_id == milestone_id
Meeting.milestone_id == milestone.id
).all()
return [{
"id": m.id,
"title": m.title,
"description": m.description,
"meeting_code": m.meeting_code,
"code": m.meeting_code,
"status": m.status.value,
"priority": m.priority.value,
"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"])
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()
if not project:
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:
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")
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
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,
priority=MeetingPriority.MEDIUM,
project_id=project.id,
milestone_id=milestone_id,
milestone_id=ms.id,
reporter_id=current_user.id,
meeting_code=meeting_code,
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.commit()
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,
test_provider_connection,
)
from app.services.discord_wakeup import create_private_wakeup_channel
router = APIRouter(prefix='/monitor', tags=['Monitor'])
SUPPORTED_PROVIDERS = {'anthropic', 'openai', 'minimax', 'kimi', 'qwen'}
@@ -42,6 +43,12 @@ class MonitoredServerCreate(BaseModel):
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)):
if not current_user.is_admin:
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
class ServerHeartbeat(BaseModel):
identifier: str
openclaw_version: str | None = None
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('/admin/discord-wakeup/test')
def discord_wakeup_test(payload: DiscordWakeupTestRequest, _: models.User = Depends(require_admin)):
return create_private_wakeup_channel(payload.discord_user_id, payload.title, payload.message)
@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):
identifier: str
openclaw_version: str | None = None
@@ -227,13 +202,13 @@ class TelemetryPayload(BaseModel):
uptime_seconds: int | None = None
@router.post('/server/heartbeat-v2')
def server_heartbeat_v2(
@router.post('/server/heartbeat')
def server_heartbeat(
payload: TelemetryPayload,
x_api_key: str = Header(..., alias='X-API-Key', description='API Key from /admin/servers/{id}/api-key'),
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(
MonitoredServer.api_key == x_api_key,
MonitoredServer.is_enabled == True
@@ -256,4 +231,3 @@ def server_heartbeat_v2(
st.last_seen_at = datetime.now(timezone.utc)
db.commit()
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.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."""
return {
"id": e.id,
"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,
"title": e.title,
"description": e.description,
@@ -41,14 +40,14 @@ def _serialize_proposal(db: Session, proposal: Proposal, *, include_essentials:
"""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
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 = {
"id": proposal.id,
"title": proposal.title,
"description": proposal.description,
"proposal_code": code, # preferred name
"propose_code": code, # backward compat
"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_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.
@@ -62,7 +61,7 @@ def _serialize_proposal(db: Session, proposal: Proposal, *, include_essentials:
.order_by(Essential.id.asc())
.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
gen_tasks = (
@@ -71,46 +70,34 @@ def _serialize_proposal(db: Session, proposal: Proposal, *, include_essentials:
.order_by(Task.id.asc())
.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"] = [
{
"task_id": t.id,
"task_code": t.task_code,
"task_type": t.task_type or "story",
"task_subtype": t.task_subtype,
"title": t.title,
"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
]
return result
def _find_project(db, identifier):
"""Look up project by numeric id or 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(models.Project.project_code == str(identifier)).first()
def _find_project(db, project_code: str):
"""Look up project by project_code."""
return db.query(models.Project).filter(models.Project.project_code == str(project_code)).first()
def _find_proposal(db, identifier, project_id: int = None) -> Proposal | None:
"""Look up proposal by numeric id or propose_code."""
try:
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))
def _find_proposal(db, proposal_code: str, project_id: int = None) -> Proposal | None:
"""Look up proposal by propose_code."""
q = db.query(Proposal).filter(Proposal.propose_code == str(proposal_code))
if project_id:
q = q.filter(Proposal.project_id == project_id)
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])
def list_proposals(
project_id: str,
project_code: str,
db: Session = Depends(get_db),
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:
raise HTTPException(status_code=404, detail="Project not found")
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)
def create_proposal(
project_id: str,
project_code: str,
proposal_in: schemas.ProposalCreate,
db: Session = Depends(get_db),
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:
raise HTTPException(status_code=404, detail="Project not found")
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)
def get_proposal(
project_id: str,
proposal_id: str,
project_code: str,
proposal_code: str,
db: Session = Depends(get_db),
current_user: models.User = Depends(get_current_user_or_apikey),
):
"""Get a single Proposal with its Essentials list embedded."""
project = _find_project(db, project_id)
project = _find_project(db, project_code)
if not project:
raise HTTPException(status_code=404, detail="Project not found")
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:
raise HTTPException(status_code=404, detail="Proposal not found")
return _serialize_proposal(db, proposal, include_essentials=True)
@@ -215,16 +202,16 @@ def get_proposal(
@router.patch("/{proposal_id}", response_model=schemas.ProposalResponse)
def update_proposal(
project_id: str,
proposal_id: str,
project_code: str,
proposal_code: str,
proposal_in: schemas.ProposalUpdate,
db: Session = Depends(get_db),
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:
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:
raise HTTPException(status_code=404, detail="Proposal not found")
@@ -253,13 +240,13 @@ def update_proposal(
# ---- Actions ----
class AcceptRequest(schemas.BaseModel):
milestone_id: int
milestone_code: str
@router.post("/{proposal_id}/accept", response_model=schemas.ProposalAcceptResponse)
def accept_proposal(
project_id: str,
proposal_id: str,
project_code: str,
proposal_code: str,
body: AcceptRequest,
db: Session = Depends(get_db),
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
least one Essential to be accepted.
"""
project = _find_project(db, project_id)
project = _find_project(db, project_code)
if not project:
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:
raise HTTPException(status_code=404, detail="Proposal not found")
@@ -289,7 +276,7 @@ def accept_proposal(
# Validate milestone
milestone = db.query(Milestone).filter(
Milestone.id == body.milestone_id,
Milestone.milestone_code == body.milestone_code,
Milestone.project_id == project.id,
).first()
if not milestone:
@@ -355,12 +342,10 @@ def accept_proposal(
db.flush() # materialise task.id
generated_tasks.append({
"task_id": task.id,
"task_code": task_code,
"task_type": "story",
"task_subtype": task_subtype,
"title": essential.title,
"essential_id": essential.id,
"essential_code": essential.essential_code,
})
next_num = task.id + 1 # use real id for next code to stay consistent
@@ -372,9 +357,9 @@ def accept_proposal(
db.refresh(proposal)
log_activity(db, "accept", "proposal", proposal.id, user_id=current_user.id, details={
"milestone_id": milestone.id,
"milestone_code": milestone.milestone_code,
"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
],
})
@@ -390,17 +375,17 @@ class RejectRequest(schemas.BaseModel):
@router.post("/{proposal_id}/reject", response_model=schemas.ProposalResponse)
def reject_proposal(
project_id: str,
proposal_id: str,
project_code: str,
proposal_code: str,
body: RejectRequest | None = None,
db: Session = Depends(get_db),
current_user: models.User = Depends(get_current_user_or_apikey),
):
"""Reject a proposal."""
project = _find_project(db, project_id)
project = _find_project(db, project_code)
if not project:
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:
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)
def reopen_proposal(
project_id: str,
proposal_id: str,
project_code: str,
proposal_code: str,
db: Session = Depends(get_db),
current_user: models.User = Depends(get_current_user_or_apikey),
):
"""Reopen a rejected proposal back to open."""
project = _find_project(db, project_id)
project = _find_project(db, project_code)
if not project:
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:
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
# 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])
def list_proposes(
project_id: str,
project_code: str,
db: Session = Depends(get_db),
current_user: models.User = Depends(get_current_user_or_apikey),
):
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)
def create_propose(
project_id: str,
project_code: str,
proposal_in: schemas.ProposalCreate,
db: Session = Depends(get_db),
current_user: models.User = Depends(get_current_user_or_apikey),
):
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)
def get_propose(
project_id: str,
project_code: str,
propose_id: str,
db: Session = Depends(get_db),
current_user: models.User = Depends(get_current_user_or_apikey),
):
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)
def update_propose(
project_id: str,
project_code: str,
propose_id: str,
proposal_in: schemas.ProposalUpdate,
db: Session = Depends(get_db),
current_user: models.User = Depends(get_current_user_or_apikey),
):
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)
def accept_propose(
project_id: str,
project_code: str,
propose_id: str,
body: AcceptRequest,
db: Session = Depends(get_db),
current_user: models.User = Depends(get_current_user_or_apikey),
):
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)
def reject_propose(
project_id: str,
project_code: str,
propose_id: str,
body: RejectRequest | None = None,
db: Session = Depends(get_db),
current_user: models.User = Depends(get_current_user_or_apikey),
):
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)
def reopen_propose(
project_id: str,
project_code: str,
propose_id: str,
db: Session = Depends(get_db),
current_user: models.User = Depends(get_current_user_or_apikey),
):
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.task import Task, TaskStatus, TaskPriority
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.services.webhook import fire_webhooks_sync
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"])
def _resolve_task(db: Session, identifier: str) -> Task:
"""Resolve a task by numeric id or task_code string.
Raises 404 if not found."""
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()
def _resolve_task(db: Session, task_code: str) -> Task:
"""Resolve a task by task_code string. Raises 404 if not found."""
task = db.query(Task).filter(Task.task_code == task_code).first()
if not task:
raise HTTPException(status_code=404, detail="Task not found")
return task
@@ -118,9 +115,7 @@ def _notify_user(db, user_id, ntype, title, message=None, entity_type=None, enti
return n
def _resolve_project_id(db: Session, project_id: int | None, project_code: str | None) -> int | None:
if project_id:
return project_id
def _resolve_project_id(db: Session, project_code: str | None) -> int | None:
if not project_code:
return None
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
def _resolve_milestone(db: Session, milestone_id: int | None, milestone_code: str | None, project_id: int | None) -> Milestone | None:
if milestone_id:
query = db.query(Milestone).filter(Milestone.id == milestone_id)
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)
if project_id:
query = query.filter(Milestone.project_id == project_id)
milestone = query.first()
else:
def _resolve_milestone(db: Session, milestone_code: str | None, project_id: int | None) -> Milestone | None:
if not milestone_code:
return None
query = db.query(Milestone).filter(Milestone.milestone_code == milestone_code)
if project_id:
query = query.filter(Milestone.project_id == project_id)
milestone = query.first()
if not milestone:
raise HTTPException(status_code=404, detail="Milestone not found")
return milestone
def _find_task_by_id_or_code(db: Session, identifier: str) -> Task | None:
try:
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 _find_task_by_code(db: Session, task_code: str) -> Task | None:
return db.query(Task).filter(Task.task_code == task_code).first()
def _serialize_task(db: Session, task: Task) -> dict:
payload = schemas.TaskResponse.model_validate(task).model_dump(mode="json")
project = db.query(models.Project).filter(models.Project.id == task.project_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
if task.assignee_id:
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,
"taken_by": assignee.username if assignee else None,
"due_date": None,
"source_proposal_code": proposal_code,
"source_essential_code": essential_code,
})
return payload
@@ -191,8 +184,8 @@ def create_task(task_in: schemas.TaskCreate, bg: BackgroundTasks, db: Session =
else:
data.pop("type", None)
data["project_id"] = _resolve_project_id(db, data.get("project_id"), data.pop("project_code", None))
milestone = _resolve_milestone(db, data.get("milestone_id"), data.pop("milestone_code", None), data.get("project_id"))
data["project_id"] = _resolve_project_id(db, data.pop("project_code", None))
milestone = _resolve_milestone(db, data.pop("milestone_code", None), data.get("project_id"))
if milestone:
data["milestone_id"] = milestone.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
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"):
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")
if not milestone:
milestone = db.query(Milestone).filter(
Milestone.id == data["milestone_id"],
Milestone.project_id == data["project_id"],
).first()
if not milestone:
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(
fire_webhooks_sync,
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,
)
@@ -247,22 +235,22 @@ def create_task(task_in: schemas.TaskCreate, bg: BackgroundTasks, db: Session =
@router.get("/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,
sort_by: str = "created_at", sort_order: str = "desc",
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,
db: Session = Depends(get_db)
):
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:
query = query.filter(Task.project_id == resolved_project_id)
if milestone:
milestone_obj = _resolve_milestone(db, None, milestone, resolved_project_id)
if milestone_code:
milestone_obj = _resolve_milestone(db, milestone_code, resolved_project_id)
query = query.filter(Task.milestone_id == milestone_obj.id)
effective_status = status_value or task_status
@@ -316,14 +304,14 @@ def list_tasks(
@router.get("/tasks/search", response_model=List[schemas.TaskResponse])
def search_tasks_alias(
q: str,
project: str = None,
project_code: str = None,
status: str = None,
db: Session = Depends(get_db),
):
query = db.query(Task).filter(
(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:
query = query.filter(Task.project_id == resolved_project_id)
if status:
@@ -332,15 +320,15 @@ def search_tasks_alias(
return [_serialize_task(db, i) for i in items]
@router.get("/tasks/{task_id}", response_model=schemas.TaskResponse)
def get_task(task_id: str, db: Session = Depends(get_db)):
task = _resolve_task(db, task_id)
@router.get("/tasks/{task_code}", response_model=schemas.TaskResponse)
def get_task(task_code: str, db: Session = Depends(get_db)):
task = _resolve_task(db, task_code)
return _serialize_task(db, task)
@router.patch("/tasks/{task_id}", 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)):
task = _resolve_task(db, task_id)
@router.patch("/tasks/{task_code}", response_model=schemas.TaskResponse)
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_code)
# P5.7: status-based edit restrictions
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)
@router.delete("/tasks/{task_id}", 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)):
task = _resolve_task(db, task_id)
@router.delete("/tasks/{task_code}", status_code=status.HTTP_204_NO_CONTENT)
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_code)
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})
db.delete(task)
@@ -454,9 +442,9 @@ class TransitionBody(BaseModel):
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(
task_id: str,
task_code: str,
bg: BackgroundTasks,
new_status: str | None = None,
body: TransitionBody = None,
@@ -467,7 +455,7 @@ def transition_task(
valid_statuses = [s.value for s in TaskStatus]
if new_status not in 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
# P5.1: enforce state-machine
@@ -547,18 +535,18 @@ def transition_task(
event = "task.closed" if new_status == "closed" else "task.updated"
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)
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(
task_id: str,
task_code: str,
db: Session = Depends(get_db),
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:
raise HTTPException(status_code=404, detail="Task not found")
@@ -577,7 +565,7 @@ def take_task(
db,
current_user.id,
"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.",
"task",
task.id,
@@ -587,9 +575,9 @@ def take_task(
# ---- Assignment ----
@router.post("/tasks/{task_id}/assign")
def assign_task(task_id: str, assignee_id: int, db: Session = Depends(get_db)):
task = _resolve_task(db, task_id)
@router.post("/tasks/{task_code}/assign")
def assign_task(task_code: str, assignee_id: int, db: Session = Depends(get_db)):
task = _resolve_task(db, task_code)
user = db.query(models.User).filter(models.User.id == assignee_id).first()
if not user:
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.refresh(task)
_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)
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 ----
@router.post("/tasks/{task_id}/tags")
def add_tag(task_id: str, tag: str, db: Session = Depends(get_db)):
task = _resolve_task(db, task_id)
@router.post("/tasks/{task_code}/tags")
def add_tag(task_code: str, tag: str, db: Session = Depends(get_db)):
task = _resolve_task(db, task_code)
current = set(task.tags.split(",")) if task.tags else set()
current.add(tag.strip())
current.discard("")
task.tags = ",".join(sorted(current))
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")
def remove_tag(task_id: str, tag: str, db: Session = Depends(get_db)):
task = _resolve_task(db, task_id)
@router.delete("/tasks/{task_code}/tags")
def remove_tag(task_code: str, tag: str, db: Session = Depends(get_db)):
task = _resolve_task(db, task_code)
current = set(task.tags.split(",")) if task.tags else set()
current.discard(tag.strip())
current.discard("")
task.tags = ",".join(sorted(current)) if current else None
db.commit()
return {"task_id": task_id, "tags": list(current)}
return {"task_code": task.task_code, "tags": list(current)}
@router.get("/tags")
@@ -643,12 +631,12 @@ def list_all_tags(project_id: int = None, db: Session = Depends(get_db)):
# ---- Batch ----
class BatchAssign(BaseModel):
task_ids: List[int]
task_codes: List[str]
assignee_id: int
class BatchTransitionBody(BaseModel):
task_ids: List[int]
task_codes: List[str]
new_status: str
comment: Optional[str] = None
@@ -665,17 +653,17 @@ def batch_transition(
raise HTTPException(status_code=400, detail="Invalid status")
updated = []
skipped = []
for task_id in data.task_ids:
task = db.query(Task).filter(Task.id == task_id).first()
for task_code in data.task_codes:
task = db.query(Task).filter(Task.task_code == task_code).first()
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"})
continue
old_status = task.status.value if hasattr(task.status, 'value') else task.status
# P5.1: state-machine check
allowed = VALID_TRANSITIONS.get(old_status, set())
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}'"})
continue
@@ -685,23 +673,23 @@ def batch_transition(
if milestone:
ms_status = milestone.status.value if hasattr(milestone.status, 'value') else milestone.status
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'"})
continue
dep_result = check_task_deps(db, task.depend_on)
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})
continue
# P5.3: open → undergoing requires assignee == current_user
if old_status == "open" and data.new_status == "undergoing":
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"})
continue
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"})
continue
@@ -709,11 +697,11 @@ def batch_transition(
if old_status == "undergoing" and data.new_status == "completed":
comment_text = data.comment
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"})
continue
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"})
continue
@@ -722,7 +710,7 @@ def batch_transition(
try:
check_permission(db, current_user.id, task.project_id, "task.close")
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"})
continue
@@ -732,7 +720,7 @@ def batch_transition(
try:
check_permission(db, current_user.id, task.project_id, perm)
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"})
continue
task.finished_on = None
@@ -742,7 +730,7 @@ def batch_transition(
if data.new_status in ("closed", "completed") and not task.finished_on:
task.finished_on = datetime.utcnow()
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
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
for u in updated:
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:
from app.api.routers.milestone_actions import try_auto_complete_milestone
try_auto_complete_milestone(db, t, user_id=current_user.id)
@@ -782,25 +770,27 @@ def batch_assign(data: BatchAssign, db: Session = Depends(get_db)):
if not user:
raise HTTPException(status_code=404, detail="Assignee not found")
updated = []
for task_id in data.task_ids:
task = db.query(Task).filter(Task.id == task_id).first()
for task_code in data.task_codes:
task = db.query(Task).filter(Task.task_code == task_code).first()
if task:
task.assignee_id = data.assignee_id
updated.append(task_id)
updated.append(task.task_code)
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 ----
@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)):
query = db.query(Task).filter(
(Task.title.contains(q)) | (Task.description.contains(q))
)
if project_id:
query = query.filter(Task.project_id == project_id)
if project_code:
project_id = _resolve_project_id(db, project_code)
if project_id:
query = query.filter(Task.project_id == project_id)
total = query.count()
page = max(1, page)
page_size = min(max(1, page_size), 200)

View File

@@ -7,8 +7,9 @@ from pydantic import BaseModel
from sqlalchemy.exc import IntegrityError
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.init_wizard import DELETED_USER_USERNAME
from app.models import models
from app.models.agent import Agent
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_name": user.role_name,
"agent_id": user.agent.agent_id if user.agent else None,
"discord_user_id": user.discord_user_id,
"created_at": user.created_at,
}
return data
@@ -57,7 +59,7 @@ def _has_global_permission(db: Session, user: models.User, permission_name: str)
def require_account_creator(
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"):
return current_user
@@ -114,6 +116,7 @@ def create_user(
username=user.username,
email=user.email,
full_name=user.full_name,
discord_user_id=user.discord_user_id,
hashed_password=hashed_password,
is_admin=False,
is_active=True,
@@ -202,11 +205,94 @@ def update_user(
raise HTTPException(status_code=400, detail="You cannot deactivate your own account")
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.refresh(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)
def delete_user(
identifier: str,
@@ -218,17 +304,26 @@ def delete_user(
raise HTTPException(status_code=404, detail="User not found")
if current_user.id == user.id:
raise HTTPException(status_code=400, detail="You cannot delete your own account")
# Protect built-in accounts from deletion
if user.is_admin:
raise HTTPException(status_code=400, detail="Admin accounts cannot be deleted")
if user.username == "acc-mgr":
raise HTTPException(status_code=400, detail="The acc-mgr account is a built-in account and cannot be deleted")
try:
db.delete(user)
db.commit()
except IntegrityError:
db.rollback()
raise HTTPException(status_code=400, detail="User has related records. Deactivate the account instead.")
if user.username in _BUILTIN_USERNAMES:
raise HTTPException(
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.commit()
return None
@@ -236,7 +331,7 @@ def delete_user(
def reset_user_apikey(
identifier: str,
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.
@@ -244,6 +339,8 @@ def reset_user_apikey(
- user.reset-apikey: can reset any user's API key
- user.reset-self-apikey: can reset only own API key
- admin: can reset any user's API key
Accepts both OAuth2 Bearer token and X-API-Key authentication.
"""
import secrets
from app.models.apikey import APIKey

View File

@@ -132,6 +132,10 @@ DEFAULT_PERMISSIONS = [
# Monitor
("monitor.read", "View 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.manage", "Manage webhooks", "admin"),
]
@@ -168,6 +172,7 @@ _MGR_PERMISSIONS = {
"task.close", "task.reopen_closed", "task.reopen_completed",
"propose.accept", "propose.reject", "propose.reopen",
"monitor.read",
"calendar.read", "calendar.write", "calendar.manage",
"user.reset-self-apikey",
}
@@ -178,11 +183,13 @@ _DEV_PERMISSIONS = {
"milestone.read",
"task.close", "task.reopen_closed", "task.reopen_completed",
"monitor.read",
"calendar.read", "calendar.write",
"user.reset-self-apikey",
}
_ACCOUNT_MANAGER_PERMISSIONS = {
"account.create",
"user.reset-apikey",
}
# Role definitions: (name, description, permission_set)
@@ -288,6 +295,39 @@ def init_acc_mgr_user(db: Session) -> models.User | None:
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:
"""Main initialization entry point. Reads config from shared volume."""
config = load_config()
@@ -312,6 +352,9 @@ def run_init(db: Session) -> None:
# Built-in acc-mgr user (after roles are created)
init_acc_mgr_user(db)
# Built-in deleted-user (foreign key sink for deleted accounts)
init_deleted_user(db)
# Default project
project_cfg = config.get("default_project")
if project_cfg and admin_user:

View File

@@ -42,6 +42,7 @@ def config_status():
return {
"initialized": cfg.get("initialized", False),
"backend_url": cfg.get("backend_url"),
"discord": cfg.get("discord") or {},
}
except Exception:
return {"initialized": False}
@@ -96,6 +97,25 @@ def _migrate_schema():
{"column_name": column_name},
).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):
rows = db.execute(text(
"""
@@ -139,7 +159,7 @@ def _migrate_schema():
result = db.execute(text("SHOW COLUMNS FROM projects LIKE 'project_code'"))
if not result.fetchone():
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
result = db.execute(text("SHOW COLUMNS FROM projects LIKE 'owner_name'"))
@@ -173,6 +193,8 @@ def _migrate_schema():
if not result.fetchone():
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")
if _has_column(db, "tasks", "task_code"):
_ensure_unique_index(db, "tasks", "idx_tasks_task_code", "task_code")
# milestones creator field
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) ---
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
db.execute(text(
"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"))
_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 ---
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"))
@@ -257,6 +284,18 @@ def _migrate_schema():
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"))
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 ---
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"))
@@ -320,6 +359,10 @@ def _migrate_schema():
) 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()
except Exception as e:
db.rollback()

View File

@@ -165,6 +165,12 @@ class TimeSlot(Base):
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(
Integer,
ForeignKey("schedule_plans.id"),

View File

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

View File

@@ -9,10 +9,10 @@ BE-CAL-API-004: TimeSlot cancel schemas.
from __future__ import annotations
from datetime import date, time, datetime
from datetime import date as dt_date, time as dt_time, datetime as dt_datetime
from enum import Enum
from pydantic import BaseModel, Field, model_validator, field_validator
from typing import Any, Optional
from typing import Optional
# ---------------------------------------------------------------------------
@@ -102,17 +102,17 @@ class SlotStatusEnum(str, Enum):
class TimeSlotCreate(BaseModel):
"""Request body for creating a single calendar slot."""
date: Optional[date] = Field(None, description="Target date (defaults to today)")
date: Optional[dt_date] = Field(None, description="Target date (defaults to today)")
slot_type: SlotTypeEnum = Field(..., description="work | on_call | entertainment | system")
scheduled_at: time = Field(..., description="Planned start time HH:MM (00:00-23:00)")
scheduled_at: dt_time = Field(..., description="Planned start time HH:MM (00:00-23:00)")
estimated_duration: int = Field(..., ge=1, le=50, description="Duration in minutes (1-50)")
event_type: Optional[EventTypeEnum] = Field(None, description="job | entertainment | system_event")
event_data: Optional[dict[str, Any]] = Field(None, description="Event details JSON")
event_data: Optional[dict] = Field(None, description="Event details JSON")
priority: int = Field(0, ge=0, le=99, description="Priority 0-99")
@field_validator("scheduled_at")
@classmethod
def _validate_scheduled_at(cls, v: time) -> time:
def _validate_scheduled_at(cls, v: dt_time) -> dt_time:
if v.hour > 23:
raise ValueError("scheduled_at hour must be between 00 and 23")
return v
@@ -132,7 +132,7 @@ class TimeSlotResponse(BaseModel):
"""Response for a single TimeSlot."""
id: int
user_id: int
date: date
date: dt_date
slot_type: str
estimated_duration: int
scheduled_at: str # HH:MM:SS ISO format
@@ -140,12 +140,12 @@ class TimeSlotResponse(BaseModel):
attended: bool
actual_duration: Optional[int] = None
event_type: Optional[str] = None
event_data: Optional[dict[str, Any]] = None
event_data: Optional[dict] = None
priority: int
status: str
plan_id: Optional[int] = None
created_at: Optional[datetime] = None
updated_at: Optional[datetime] = None
created_at: Optional[dt_datetime] = None
updated_at: Optional[dt_datetime] = None
class Config:
from_attributes = True
@@ -169,15 +169,15 @@ class TimeSlotEdit(BaseModel):
``virtual_id`` (for plan-generated virtual slots) in the URL path.
"""
slot_type: Optional[SlotTypeEnum] = Field(None, description="New slot type")
scheduled_at: Optional[time] = Field(None, description="New start time HH:MM")
scheduled_at: Optional[dt_time] = Field(None, description="New start time HH:MM")
estimated_duration: Optional[int] = Field(None, ge=1, le=50, description="New duration in minutes (1-50)")
event_type: Optional[EventTypeEnum] = Field(None, description="New event type")
event_data: Optional[dict[str, Any]] = Field(None, description="New event details JSON")
event_data: Optional[dict] = Field(None, description="New event details JSON")
priority: Optional[int] = Field(None, ge=0, le=99, description="New priority 0-99")
@field_validator("scheduled_at")
@classmethod
def _validate_scheduled_at(cls, v: Optional[time]) -> Optional[time]:
def _validate_scheduled_at(cls, v: Optional[dt_time]) -> Optional[dt_time]:
if v is not None and v.hour > 23:
raise ValueError("scheduled_at hour must be between 00 and 23")
return v
@@ -214,7 +214,7 @@ class CalendarSlotItem(BaseModel):
id: Optional[int] = Field(None, description="Real slot DB id (None for virtual)")
virtual_id: Optional[str] = Field(None, description="Virtual slot id (None for real)")
user_id: int
date: date
date: dt_date
slot_type: str
estimated_duration: int
scheduled_at: str # HH:MM:SS ISO format
@@ -222,12 +222,12 @@ class CalendarSlotItem(BaseModel):
attended: bool
actual_duration: Optional[int] = None
event_type: Optional[str] = None
event_data: Optional[dict[str, Any]] = None
event_data: Optional[dict] = None
priority: int
status: str
plan_id: Optional[int] = None
created_at: Optional[datetime] = None
updated_at: Optional[datetime] = None
created_at: Optional[dt_datetime] = None
updated_at: Optional[dt_datetime] = None
class Config:
from_attributes = True
@@ -235,7 +235,7 @@ class CalendarSlotItem(BaseModel):
class CalendarDayResponse(BaseModel):
"""Response for a single-day calendar query."""
date: date
date: dt_date
user_id: int
slots: list[CalendarSlotItem] = Field(
default_factory=list,
@@ -290,16 +290,16 @@ class SchedulePlanCreate(BaseModel):
"""Request body for creating a recurring schedule plan."""
slot_type: SlotTypeEnum = Field(..., description="work | on_call | entertainment | system")
estimated_duration: int = Field(..., ge=1, le=50, description="Duration in minutes (1-50)")
at_time: time = Field(..., description="Daily scheduled time (HH:MM)")
at_time: dt_time = Field(..., description="Daily scheduled time (HH:MM)")
on_day: Optional[DayOfWeekEnum] = Field(None, description="Day of week (sun-sat)")
on_week: Optional[int] = Field(None, ge=1, le=4, description="Week of month (1-4)")
on_month: Optional[MonthOfYearEnum] = Field(None, description="Month (jan-dec)")
event_type: Optional[EventTypeEnum] = Field(None, description="job | entertainment | system_event")
event_data: Optional[dict[str, Any]] = Field(None, description="Event details JSON")
event_data: Optional[dict] = Field(None, description="Event details JSON")
@field_validator("at_time")
@classmethod
def _validate_at_time(cls, v: time) -> time:
def _validate_at_time(cls, v: dt_time) -> dt_time:
if v.hour > 23:
raise ValueError("at_time hour must be between 00 and 23")
return v
@@ -325,10 +325,10 @@ class SchedulePlanResponse(BaseModel):
on_week: Optional[int] = None
on_month: Optional[str] = None
event_type: Optional[str] = None
event_data: Optional[dict[str, Any]] = None
event_data: Optional[dict] = None
is_active: bool
created_at: Optional[datetime] = None
updated_at: Optional[datetime] = None
created_at: Optional[dt_datetime] = None
updated_at: Optional[dt_datetime] = None
class Config:
from_attributes = True
@@ -352,19 +352,19 @@ class SchedulePlanEdit(BaseModel):
"""
slot_type: Optional[SlotTypeEnum] = Field(None, description="New slot type")
estimated_duration: Optional[int] = Field(None, ge=1, le=50, description="New duration in minutes (1-50)")
at_time: Optional[time] = Field(None, description="New daily time (HH:MM)")
at_time: Optional[dt_time] = Field(None, description="New daily time (HH:MM)")
on_day: Optional[DayOfWeekEnum] = Field(None, description="New day of week (sun-sat), use 'clear' param to remove")
on_week: Optional[int] = Field(None, ge=1, le=4, description="New week of month (1-4), use 'clear' param to remove")
on_month: Optional[MonthOfYearEnum] = Field(None, description="New month (jan-dec), use 'clear' param to remove")
event_type: Optional[EventTypeEnum] = Field(None, description="New event type")
event_data: Optional[dict[str, Any]] = Field(None, description="New event details JSON")
event_data: Optional[dict] = Field(None, description="New event details JSON")
clear_on_day: bool = Field(False, description="Clear on_day (set to NULL)")
clear_on_week: bool = Field(False, description="Clear on_week (set to NULL)")
clear_on_month: bool = Field(False, description="Clear on_month (set to NULL)")
@field_validator("at_time")
@classmethod
def _validate_at_time(cls, v: Optional[time]) -> Optional[time]:
def _validate_at_time(cls, v: Optional[dt_time]) -> Optional[dt_time]:
if v is not None and v.hour > 23:
raise ValueError("at_time hour must be between 00 and 23")
return v
@@ -403,7 +403,34 @@ class DateListResponse(BaseModel):
Returns only dates that have at least one materialized (real) future
slot. Pure plan-generated (virtual) dates are excluded.
"""
dates: list[date] = Field(
dates: list[dt_date] = Field(
default_factory=list,
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):
project_id: Optional[int] = None
project_code: Optional[str] = None
milestone_id: Optional[int] = None
milestone_code: Optional[str] = None
reporter_id: Optional[int] = None
assignee_id: Optional[int] = None
@@ -75,15 +73,12 @@ class TaskUpdate(BaseModel):
class TaskResponse(TaskBase):
id: int
status: TaskStatusEnum
task_code: Optional[str] = None
code: Optional[str] = None
type: Optional[str] = None
due_date: Optional[datetime] = None
project_id: int
project_code: Optional[str] = None
milestone_id: int
milestone_code: Optional[str] = None
reporter_id: int
assignee_id: Optional[int] = None
@@ -94,8 +89,8 @@ class TaskResponse(TaskBase):
positions: Optional[str] = None
pending_matters: Optional[str] = None
# BE-PR-008: Proposal Accept tracking
source_proposal_id: Optional[int] = None
source_essential_id: Optional[int] = None
source_proposal_code: Optional[str] = None
source_essential_code: Optional[str] = None
created_at: datetime
updated_at: Optional[datetime] = None
@@ -176,6 +171,7 @@ class UserBase(BaseModel):
class UserCreate(UserBase):
password: Optional[str] = None
role_id: Optional[int] = None
discord_user_id: Optional[str] = None
# Agent binding (both must be provided or both omitted)
agent_id: Optional[str] = None
claw_identifier: Optional[str] = None
@@ -187,6 +183,7 @@ class UserUpdate(BaseModel):
password: Optional[str] = None
role_id: Optional[int] = None
is_active: Optional[bool] = None
discord_user_id: Optional[str] = None
class UserResponse(UserBase):
@@ -196,6 +193,7 @@ class UserResponse(UserBase):
role_id: Optional[int] = None
role_name: Optional[str] = None
agent_id: Optional[str] = None
discord_user_id: Optional[str] = None
created_at: datetime
class Config:
@@ -259,9 +257,9 @@ class MilestoneUpdate(BaseModel):
class MilestoneResponse(MilestoneBase):
id: int
milestone_code: Optional[str] = None
project_id: int
code: Optional[str] = None
project_code: Optional[str] = None
created_by_id: Optional[int] = None
started_at: Optional[datetime] = None
created_at: datetime
@@ -285,7 +283,7 @@ class ProposalBase(BaseModel):
class ProposalCreate(ProposalBase):
project_id: Optional[int] = None
pass
class ProposalUpdate(BaseModel):
@@ -294,11 +292,10 @@ class ProposalUpdate(BaseModel):
class ProposalResponse(ProposalBase):
id: int
proposal_code: Optional[str] = None # preferred name
propose_code: Optional[str] = None # backward compat alias (same value)
status: ProposalStatusEnum
project_id: int
project_code: Optional[str] = None
created_by_id: Optional[int] = 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.
@@ -340,9 +337,8 @@ class EssentialUpdate(BaseModel):
class EssentialResponse(EssentialBase):
id: int
essential_code: str
proposal_id: int
proposal_code: Optional[str] = None
created_by_id: Optional[int] = None
created_at: datetime
updated_at: Optional[datetime] = None
@@ -353,13 +349,12 @@ class EssentialResponse(EssentialBase):
class GeneratedTaskBrief(BaseModel):
"""Brief info about a story task generated from Proposal Accept."""
task_id: int
task_code: Optional[str] = None
task_type: str
task_subtype: Optional[str] = None
title: str
status: Optional[str] = None
source_essential_id: Optional[int] = None
source_essential_code: Optional[str] = None
class ProposalDetailResponse(ProposalResponse):
@@ -374,12 +369,10 @@ class ProposalDetailResponse(ProposalResponse):
class GeneratedTaskSummary(BaseModel):
"""Brief summary of a task generated from a Proposal Essential."""
task_id: int
task_code: str
task_type: str
task_subtype: str
title: str
essential_id: int
essential_code: str

View File

@@ -16,7 +16,9 @@ Design reference: NEXT_WAVE_DEV_DIRECTION.md §6.4 (Status transitions)
from __future__ import annotations
from datetime import datetime, timedelta, timezone
from typing import Optional
from email.utils import parsedate_to_datetime
import re
from typing import Mapping, Optional
from sqlalchemy.orm import Session
@@ -29,6 +31,20 @@ HEARTBEAT_TIMEOUT_SECONDS = 120
# Default recovery duration when we can't parse a retry-after header
DEFAULT_RECOVERY_HOURS = 5
# Fallback wording patterns commonly emitted by model providers / gateways.
_RESET_IN_PATTERN = re.compile(
r"(?:reset(?:s)?|retry)(?:\s+again)?\s+(?:in|after)\s+(?P<value>\d+)\s*(?P<unit>seconds?|secs?|s|minutes?|mins?|m|hours?|hrs?|h)",
re.IGNORECASE,
)
_RESET_AT_ISO_PATTERN = re.compile(
r"resets?\s+at\s+(?P<ts>\d{4}-\d{2}-\d{2}[tT ][^\s,;]+(?:Z|[+-]\d{2}:?\d{2})?)",
re.IGNORECASE,
)
_RESET_AT_GENERIC_PATTERN = re.compile(
r"resets?\s+at\s+(?P<ts>[^\n]+?)(?:[.,;]|$)",
re.IGNORECASE,
)
# ---------------------------------------------------------------------------
# Transition helpers
@@ -48,6 +64,90 @@ def _assert_current(agent: Agent, *expected: AgentStatus) -> None:
)
def _to_utc(dt: datetime) -> datetime:
"""Normalize aware / naive datetimes to UTC-aware timestamps."""
if dt.tzinfo is None:
return dt.replace(tzinfo=timezone.utc)
return dt.astimezone(timezone.utc)
def _duration_from_match(value: str, unit: str) -> timedelta:
"""Convert a parsed numeric duration to ``timedelta``."""
amount = int(value)
unit_normalized = unit.lower()
if unit_normalized.startswith(("second", "sec")) or unit_normalized == "s":
return timedelta(seconds=amount)
if unit_normalized.startswith(("minute", "min")) or unit_normalized == "m":
return timedelta(minutes=amount)
if unit_normalized.startswith(("hour", "hr")) or unit_normalized == "h":
return timedelta(hours=amount)
raise ValueError(f"Unsupported duration unit: {unit}")
def parse_exhausted_recovery_at(
*,
now: datetime | None = None,
headers: Mapping[str, str] | None = None,
message: str | None = None,
) -> datetime:
"""Infer the next recovery time for an exhausted agent.
Parsing order follows the design intent in NEXT_WAVE_DEV_DIRECTION.md §6.5:
1. ``Retry-After`` response header
- integer seconds
- HTTP-date
2. Error text like ``reset in 12 mins`` / ``retry after 30 seconds``
3. Error text like ``resets at 2026-04-01T10:00:00Z``
4. Fallback to ``now + DEFAULT_RECOVERY_HOURS``
"""
if now is None:
now = datetime.now(timezone.utc)
now = _to_utc(now)
normalized_headers = {k.lower(): v for k, v in (headers or {}).items()}
retry_after = normalized_headers.get("retry-after")
if retry_after:
retry_after = retry_after.strip()
if retry_after.isdigit():
return now + timedelta(seconds=int(retry_after))
try:
return _to_utc(parsedate_to_datetime(retry_after))
except (TypeError, ValueError, IndexError, OverflowError):
pass
if message:
duration_match = _RESET_IN_PATTERN.search(message)
if duration_match:
return now + _duration_from_match(
duration_match.group("value"),
duration_match.group("unit"),
)
iso_match = _RESET_AT_ISO_PATTERN.search(message)
if iso_match:
ts = iso_match.group("ts")
normalized_ts = ts.replace(" ", "T")
if normalized_ts.endswith("Z"):
normalized_ts = normalized_ts[:-1] + "+00:00"
try:
return _to_utc(datetime.fromisoformat(normalized_ts))
except ValueError:
pass
generic_match = _RESET_AT_GENERIC_PATTERN.search(message)
if generic_match:
ts = generic_match.group("ts").strip()
try:
return _to_utc(parsedate_to_datetime(ts))
except (TypeError, ValueError, IndexError, OverflowError):
pass
return now + timedelta(hours=DEFAULT_RECOVERY_HOURS)
# ---------------------------------------------------------------------------
# Public API
# ---------------------------------------------------------------------------
@@ -141,6 +241,8 @@ def transition_to_exhausted(
*,
reason: ExhaustReason,
recovery_at: datetime | None = None,
headers: Mapping[str, str] | None = None,
message: str | None = None,
now: datetime | None = None,
) -> Agent:
"""Any active status → Exhausted (API quota error).
@@ -150,20 +252,30 @@ def transition_to_exhausted(
reason : ExhaustReason
``RATE_LIMIT`` or ``BILLING``.
recovery_at : datetime, optional
Parsed from retry-after / reset headers. If *None*, defaults to
``now + DEFAULT_RECOVERY_HOURS``.
Explicit recovery timestamp. If omitted, attempts to parse from
``headers`` / ``message``; falls back to ``now + DEFAULT_RECOVERY_HOURS``.
headers : Mapping[str, str], optional
Response headers that may contain ``Retry-After``.
message : str, optional
Error text that may contain ``reset in`` / ``retry after`` /
``resets at`` hints.
"""
if now is None:
now = datetime.now(timezone.utc)
now = _to_utc(now)
agent.status = AgentStatus.EXHAUSTED
agent.exhausted_at = now
agent.exhaust_reason = reason
if recovery_at is not None:
agent.recovery_at = recovery_at
agent.recovery_at = _to_utc(recovery_at)
else:
agent.recovery_at = now + timedelta(hours=DEFAULT_RECOVERY_HOURS)
agent.recovery_at = parse_exhausted_recovery_at(
now=now,
headers=headers,
message=message,
)
db.flush()
return agent

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"),
}

View File

@@ -0,0 +1,125 @@
"""Multi-slot competition handling — BE-AGT-003.
When multiple slots are pending for an agent at heartbeat time, this
module resolves the competition:
1. Select the **highest priority** slot for execution.
2. Mark all other pending slots as ``Deferred``.
3. Bump ``priority += 1`` on each deferred slot (so deferred slots
gradually gain priority and eventually get executed).
Design reference: NEXT_WAVE_DEV_DIRECTION.md §6.3 (Multi-slot competition)
"""
from __future__ import annotations
from dataclasses import dataclass
from typing import Optional
from sqlalchemy.orm import Session
from app.models.calendar import SlotStatus, TimeSlot
# Maximum priority cap to prevent unbounded growth
MAX_PRIORITY = 99
@dataclass
class CompetitionResult:
"""Outcome of resolving a multi-slot competition.
Attributes
----------
winner : TimeSlot | None
The slot selected for execution (highest priority).
``None`` if the input list was empty.
deferred : list[TimeSlot]
Slots that were marked as ``Deferred`` and had their priority bumped.
"""
winner: Optional[TimeSlot]
deferred: list[TimeSlot]
def resolve_slot_competition(
db: Session,
pending_slots: list[TimeSlot],
) -> CompetitionResult:
"""Resolve competition among multiple pending slots.
Parameters
----------
db : Session
SQLAlchemy database session. Changes are flushed but not committed
— the caller controls the transaction boundary.
pending_slots : list[TimeSlot]
Actionable slots already filtered and sorted by priority descending
(as returned by :func:`agent_heartbeat.get_pending_slots_for_agent`).
Returns
-------
CompetitionResult
Contains the winning slot (or ``None`` if empty) and the list of
deferred slots.
Notes
-----
- The input list is assumed to be sorted by priority descending.
If two slots share the same priority, the first one in the list wins
(stable selection — earlier ``scheduled_at`` or lower id if the
heartbeat query doesn't sub-sort, but the caller controls ordering).
- Deferred slots have ``priority = min(priority + 1, MAX_PRIORITY)``
so they gain urgency over time without exceeding the 0-99 range.
- The winner slot is **not** modified by this function — the caller
is responsible for setting ``attended``, ``started_at``, ``status``,
and transitioning the agent status via ``agent_status.transition_to_busy``.
"""
if not pending_slots:
return CompetitionResult(winner=None, deferred=[])
# The first slot is the winner (highest priority, already sorted)
winner = pending_slots[0]
deferred: list[TimeSlot] = []
for slot in pending_slots[1:]:
slot.status = SlotStatus.DEFERRED
slot.priority = min(slot.priority + 1, MAX_PRIORITY)
deferred.append(slot)
if deferred:
db.flush()
return CompetitionResult(winner=winner, deferred=deferred)
def defer_all_slots(
db: Session,
pending_slots: list[TimeSlot],
) -> list[TimeSlot]:
"""Mark ALL pending slots as Deferred (agent is not Idle).
Used when the agent is busy, exhausted, or otherwise unavailable.
Each slot gets ``priority += 1`` (capped at ``MAX_PRIORITY``).
Parameters
----------
db : Session
SQLAlchemy database session.
pending_slots : list[TimeSlot]
Slots to defer.
Returns
-------
list[TimeSlot]
The deferred slots (same objects, mutated in place).
"""
if not pending_slots:
return []
for slot in pending_slots:
if slot.status != SlotStatus.DEFERRED:
slot.status = SlotStatus.DEFERRED
slot.priority = min(slot.priority + 1, MAX_PRIORITY)
db.flush()
return pending_slots

View File

@@ -18,6 +18,7 @@ from app.services.agent_status import (
AgentStatusError,
HEARTBEAT_TIMEOUT_SECONDS,
DEFAULT_RECOVERY_HOURS,
parse_exhausted_recovery_at,
transition_to_busy,
transition_to_idle,
transition_to_offline,
@@ -170,6 +171,55 @@ class TestTransitionToOffline:
assert result.status == AgentStatus.OFFLINE
# ---------------------------------------------------------------------------
# Recovery time parsing
# ---------------------------------------------------------------------------
class TestParseExhaustedRecoveryAt:
def test_parses_retry_after_seconds_header(self):
recovery = parse_exhausted_recovery_at(
now=NOW,
headers={"Retry-After": "120"},
)
assert recovery == NOW + timedelta(seconds=120)
def test_parses_retry_after_http_date_header(self):
recovery = parse_exhausted_recovery_at(
now=NOW,
headers={"Retry-After": "Wed, 01 Apr 2026 12:05:00 GMT"},
)
assert recovery == datetime(2026, 4, 1, 12, 5, 0, tzinfo=timezone.utc)
def test_parses_reset_in_minutes_from_message(self):
recovery = parse_exhausted_recovery_at(
now=NOW,
message="rate limit exceeded, reset in 7 mins",
)
assert recovery == NOW + timedelta(minutes=7)
def test_parses_retry_after_seconds_from_message(self):
recovery = parse_exhausted_recovery_at(
now=NOW,
message="429 too many requests; retry after 45 seconds",
)
assert recovery == NOW + timedelta(seconds=45)
def test_parses_resets_at_iso_timestamp_from_message(self):
recovery = parse_exhausted_recovery_at(
now=NOW,
message="quota exhausted, resets at 2026-04-01T14:30:00Z",
)
assert recovery == datetime(2026, 4, 1, 14, 30, 0, tzinfo=timezone.utc)
def test_falls_back_to_default_when_unparseable(self):
recovery = parse_exhausted_recovery_at(
now=NOW,
headers={"Retry-After": "not-a-date"},
message="please try later maybe soon",
)
assert recovery == NOW + timedelta(hours=DEFAULT_RECOVERY_HOURS)
# ---------------------------------------------------------------------------
# * → Exhausted (API quota)
# ---------------------------------------------------------------------------
@@ -210,6 +260,28 @@ class TestTransitionToExhausted:
)
assert result.status == AgentStatus.EXHAUSTED
def test_parses_recovery_from_headers_when_timestamp_not_explicitly_provided(self, db):
agent = _make_agent(db, status=AgentStatus.BUSY)
result = transition_to_exhausted(
db,
agent,
reason=ExhaustReason.RATE_LIMIT,
headers={"Retry-After": "90"},
now=NOW,
)
assert result.recovery_at == NOW + timedelta(seconds=90)
def test_parses_recovery_from_message_when_timestamp_not_explicitly_provided(self, db):
agent = _make_agent(db, status=AgentStatus.BUSY)
result = transition_to_exhausted(
db,
agent,
reason=ExhaustReason.BILLING,
message="billing quota exhausted, resets at 2026-04-01T15:00:00Z",
now=NOW,
)
assert result.recovery_at == datetime(2026, 4, 1, 15, 0, 0, tzinfo=timezone.utc)
# ---------------------------------------------------------------------------
# Heartbeat timeout check

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

View File

@@ -0,0 +1,164 @@
"""Tests for BE-AGT-003 — multi-slot competition handling.
Covers:
- Winner selection (highest priority)
- Remaining slots marked Deferred with priority += 1
- Priority capping at MAX_PRIORITY (99)
- Empty input edge case
- Single slot (no competition)
- defer_all_slots when agent is not idle
"""
import pytest
from datetime import date, time
from app.models.calendar import SlotStatus, SlotType, TimeSlot
from app.services.slot_competition import (
CompetitionResult,
MAX_PRIORITY,
defer_all_slots,
resolve_slot_competition,
)
def _make_slot(db, user_id: int, *, priority: int, status=SlotStatus.NOT_STARTED) -> TimeSlot:
"""Helper — create a minimal TimeSlot in the test DB."""
slot = TimeSlot(
user_id=user_id,
date=date(2026, 4, 1),
slot_type=SlotType.WORK,
estimated_duration=30,
scheduled_at=time(9, 0),
priority=priority,
status=status,
)
db.add(slot)
db.flush()
return slot
# ---------------------------------------------------------------------------
# resolve_slot_competition
# ---------------------------------------------------------------------------
class TestResolveSlotCompetition:
"""Tests for resolve_slot_competition."""
def test_empty_input(self, db, seed):
result = resolve_slot_competition(db, [])
assert result.winner is None
assert result.deferred == []
def test_single_slot_no_competition(self, db, seed):
slot = _make_slot(db, 1, priority=50)
result = resolve_slot_competition(db, [slot])
assert result.winner is slot
assert result.deferred == []
# Winner should NOT be modified
assert slot.status == SlotStatus.NOT_STARTED
assert slot.priority == 50
def test_winner_is_first_slot(self, db, seed):
"""Input is pre-sorted by priority desc; first slot wins."""
high = _make_slot(db, 1, priority=80)
mid = _make_slot(db, 1, priority=50)
low = _make_slot(db, 1, priority=10)
slots = [high, mid, low]
result = resolve_slot_competition(db, slots)
assert result.winner is high
assert len(result.deferred) == 2
assert mid in result.deferred
assert low in result.deferred
def test_deferred_slots_status_and_priority(self, db, seed):
"""Deferred slots get status=DEFERRED and priority += 1."""
winner = _make_slot(db, 1, priority=80)
loser1 = _make_slot(db, 1, priority=50)
loser2 = _make_slot(db, 1, priority=10)
resolve_slot_competition(db, [winner, loser1, loser2])
# Winner untouched
assert winner.status == SlotStatus.NOT_STARTED
assert winner.priority == 80
# Losers deferred + bumped
assert loser1.status == SlotStatus.DEFERRED
assert loser1.priority == 51
assert loser2.status == SlotStatus.DEFERRED
assert loser2.priority == 11
def test_priority_capped_at_max(self, db, seed):
"""Priority bump should not exceed MAX_PRIORITY."""
winner = _make_slot(db, 1, priority=99)
at_cap = _make_slot(db, 1, priority=99)
resolve_slot_competition(db, [winner, at_cap])
assert at_cap.status == SlotStatus.DEFERRED
assert at_cap.priority == MAX_PRIORITY # stays at 99, not 100
def test_already_deferred_slots_get_bumped(self, db, seed):
"""Slots that were already DEFERRED still get priority bumped."""
winner = _make_slot(db, 1, priority=90)
already_deferred = _make_slot(db, 1, priority=40, status=SlotStatus.DEFERRED)
result = resolve_slot_competition(db, [winner, already_deferred])
assert already_deferred.status == SlotStatus.DEFERRED
assert already_deferred.priority == 41
def test_tie_breaking_first_wins(self, db, seed):
"""When priorities are equal, the first in the list wins."""
a = _make_slot(db, 1, priority=50)
b = _make_slot(db, 1, priority=50)
result = resolve_slot_competition(db, [a, b])
assert result.winner is a
assert b in result.deferred
assert b.status == SlotStatus.DEFERRED
# ---------------------------------------------------------------------------
# defer_all_slots
# ---------------------------------------------------------------------------
class TestDeferAllSlots:
"""Tests for defer_all_slots (agent not idle)."""
def test_empty_input(self, db, seed):
result = defer_all_slots(db, [])
assert result == []
def test_all_slots_deferred(self, db, seed):
s1 = _make_slot(db, 1, priority=70)
s2 = _make_slot(db, 1, priority=30)
result = defer_all_slots(db, [s1, s2])
assert len(result) == 2
assert s1.status == SlotStatus.DEFERRED
assert s1.priority == 71
assert s2.status == SlotStatus.DEFERRED
assert s2.priority == 31
def test_priority_cap_in_defer_all(self, db, seed):
s = _make_slot(db, 1, priority=99)
defer_all_slots(db, [s])
assert s.priority == MAX_PRIORITY
def test_already_deferred_still_bumped(self, db, seed):
"""Even if already DEFERRED, priority still increases."""
s = _make_slot(db, 1, priority=45, status=SlotStatus.DEFERRED)
defer_all_slots(db, [s])
assert s.status == SlotStatus.DEFERRED
assert s.priority == 46