Compare commits
15 Commits
45ab4583de
...
multi-stag
| Author | SHA1 | Date | |
|---|---|---|---|
| 8434a5d226 | |||
| a2ab541b73 | |||
| 755c418391 | |||
| 57681c674f | |||
| 79c6c32a78 | |||
| 5e98d1c8f2 | |||
| 5a2b64df70 | |||
| 578493edc1 | |||
| 41bebc862b | |||
| e9529e3cb0 | |||
| 848f5d7596 | |||
| 0448cde765 | |||
| ae353afbed | |||
| 58d3ca6ad0 | |||
| f5bf480c76 |
37
Dockerfile
37
Dockerfile
@@ -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"]
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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")
|
||||
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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"}
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
|
||||
@@ -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}
|
||||
|
||||
|
||||
@@ -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")
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -7,7 +7,7 @@ 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.models import models
|
||||
from app.models.agent import Agent
|
||||
@@ -30,6 +30,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 +58,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 +115,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,6 +204,9 @@ 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)
|
||||
|
||||
@@ -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,6 +183,7 @@ _DEV_PERMISSIONS = {
|
||||
"milestone.read",
|
||||
"task.close", "task.reopen_closed", "task.reopen_completed",
|
||||
"monitor.read",
|
||||
"calendar.read", "calendar.write",
|
||||
"user.reset-self-apikey",
|
||||
}
|
||||
|
||||
|
||||
45
app/main.py
45
app/main.py
@@ -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()
|
||||
|
||||
@@ -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"),
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -407,3 +407,30 @@ class DateListResponse(BaseModel):
|
||||
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
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
|
||||
72
app/services/discord_wakeup.py
Normal file
72
app/services/discord_wakeup.py
Normal 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"),
|
||||
}
|
||||
26
app/services/harborforge_config.py
Normal file
26
app/services/harborforge_config.py
Normal file
@@ -0,0 +1,26 @@
|
||||
import json
|
||||
import os
|
||||
from typing import Any
|
||||
|
||||
CONFIG_DIR = os.getenv("CONFIG_DIR", "/config")
|
||||
CONFIG_FILE = os.getenv("CONFIG_FILE", "harborforge.json")
|
||||
|
||||
|
||||
def load_runtime_config() -> dict[str, Any]:
|
||||
config_path = os.path.join(CONFIG_DIR, CONFIG_FILE)
|
||||
if not os.path.exists(config_path):
|
||||
return {}
|
||||
try:
|
||||
with open(config_path, "r") as f:
|
||||
return json.load(f)
|
||||
except Exception:
|
||||
return {}
|
||||
|
||||
|
||||
def get_discord_wakeup_config() -> dict[str, str | None]:
|
||||
cfg = load_runtime_config()
|
||||
discord_cfg = cfg.get("discord") or {}
|
||||
return {
|
||||
"guild_id": discord_cfg.get("guild_id"),
|
||||
"bot_token": discord_cfg.get("bot_token"),
|
||||
}
|
||||
357
tests/test_calendar_api.py
Normal file
357
tests/test_calendar_api.py
Normal 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
|
||||
848
tests/test_calendar_models.py
Normal file
848
tests/test_calendar_models.py
Normal 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
|
||||
Reference in New Issue
Block a user