Compare commits

...

40 Commits

Author SHA1 Message Date
8434a5d226 feat(Dockerfile): multi-stage build to reduce image size from 852MB to ~200MB
Stage 1 (builder): install build deps and pre-download wheels
Stage 2 (runtime): copy only installed packages + runtime deps, no build tools
2026-04-15 01:27:44 +00:00
h z
a2ab541b73 Merge pull request 'HarborForge.Backend: dev-2026-03-29 -> main' (#13) from dev-2026-03-29 into main
Reviewed-on: #13
2026-04-05 22:08:14 +00:00
755c418391 feat: auto-trigger Discord wakeup when slot becomes ONGOING 2026-04-05 09:37:14 +00:00
57681c674f feat: add discord wakeup test endpoint 2026-04-04 21:03:48 +00:00
79c6c32a78 feat: store discord user ids on accounts 2026-04-04 20:16:22 +00:00
5e98d1c8f2 feat: accept post heartbeats for calendar agents 2026-04-04 17:58:57 +00:00
5a2b64df70 fix: use model slot types for agent status updates 2026-04-04 16:49:52 +00:00
578493edc1 feat: expose calendar agent heartbeat api 2026-04-04 16:46:04 +00:00
41bebc862b fix: enforce calendar role permissions 2026-04-04 14:35:42 +00:00
e9529e3cb0 feat: add calendar role permissions 2026-04-04 11:59:21 +00:00
848f5d7596 refactor: replace monitor heartbeat-v2 with heartbeat 2026-04-04 08:05:48 +00:00
0448cde765 fix: make code index migration mysql-compatible 2026-04-03 19:00:45 +00:00
ae353afbed feat: switch backend indexing to code-first identifiers 2026-04-03 16:25:11 +00:00
58d3ca6ad0 fix: allow api key auth for account creation 2026-04-03 13:45:36 +00:00
zhi
f5bf480c76 TEST-BE-CAL-001 add calendar backend model and API tests 2026-04-01 10:35:43 +00:00
zhi
45ab4583de TEST-BE-PR-001 fix calendar schema import recursion 2026-04-01 10:04:50 +00:00
zhi
2cc07b9c3e BE-AGT-004 parse exhausted recovery hints 2026-04-01 04:18:44 +00:00
zhi
a94ef43974 BE-AGT-003: implement multi-slot competition handling
- resolve_slot_competition: selects highest-priority slot as winner,
  marks remaining as Deferred with priority += 1 (capped at 99)
- defer_all_slots: defers all pending slots when agent is not idle
- CompetitionResult dataclass for structured return
- Full test coverage: winner selection, priority bumping, cap, ties,
  empty input, single slot, already-deferred slots
2026-04-01 02:49:30 +00:00
zhi
70f343fbac BE-AGT-002: implement Agent status transition service
- New service: app/services/agent_status.py
  - transition_to_busy(): Idle → Busy/OnCall based on slot type
  - transition_to_idle(): Busy/OnCall/Exhausted/Offline → Idle
  - transition_to_offline(): Any → Offline (heartbeat timeout)
  - transition_to_exhausted(): Any → Exhausted (rate-limit/billing)
  - check_heartbeat_timeout(): auto-detect >2min heartbeat gap
  - check_exhausted_recovery(): auto-recover when recovery_at reached
  - record_heartbeat(): update timestamp, recover Offline agents
- Tests: tests/test_agent_status.py (22 test cases)
2026-04-01 00:46:16 +00:00
zhi
6c0959f5bb BE-AGT-001: implement heartbeat pending-slot query service
- New service: app/services/agent_heartbeat.py
- get_pending_slots_for_agent(): queries today's NotStarted/Deferred slots
  where scheduled_at <= now, sorted by priority descending
- get_pending_slot_count(): lightweight count-only variant
- Auto-materializes plan virtual slots for today before querying
- Supports injectable 'now' parameter for testing
2026-03-31 23:01:47 +00:00
zhi
22a0097a5d BE-CAL-API-007: implement date-list API endpoint
- Add GET /calendar/dates endpoint that returns sorted future dates
  with at least one materialized (real) slot
- Excludes skipped/aborted slots and pure plan-generated virtual dates
- Add DateListResponse schema
2026-03-31 20:46:34 +00:00
zhi
78d836c71e BE-CAL-API-006: implement plan-edit and plan-cancel API endpoints
- PATCH /calendar/plans/{plan_id}: edit a recurring schedule plan
  - Validates period-parameter hierarchy after merge
  - Rejects edits to inactive (cancelled) plans
  - Detaches future materialized slots so they keep old data
  - Past materialized slots remain untouched

- POST /calendar/plans/{plan_id}/cancel: cancel (soft-delete) a plan
  - Sets is_active=False
  - Detaches future materialized slots (plan_id -> NULL)
  - Preserves past materialized slots, returns their IDs

- Added SchedulePlanEdit and SchedulePlanCancelResponse schemas
2026-03-31 16:46:18 +00:00
zhi
43cf22b654 BE-CAL-API-005: implement plan-schedule / plan-list API
- Add SchedulePlanCreate, SchedulePlanResponse, SchedulePlanListResponse schemas
- Add DayOfWeekEnum, MonthOfYearEnum schema enums
- Add POST /calendar/plans endpoint (create plan with hierarchy validation)
- Add GET /calendar/plans endpoint (list plans, optional include_inactive)
- Add GET /calendar/plans/{plan_id} endpoint (get single plan)
2026-03-31 14:47:09 +00:00
zhi
b00c928148 BE-CAL-API-004: Implement Calendar cancel API for real and virtual slots
- Add POST /calendar/slots/{slot_id}/cancel for real slot cancellation
- Add POST /calendar/slots/virtual/{virtual_id}/cancel for virtual slot cancellation
- Virtual cancel materializes the slot first, then marks as Skipped
- Both endpoints enforce past-slot immutability guard
- Both endpoints detach from plan (set plan_id=NULL)
- Status set to SlotStatus.SKIPPED on cancel
- Add TimeSlotCancelResponse schema
2026-03-31 12:47:38 +00:00
zhi
f7f9ba3aa7 BE-CAL-API-003: implement Calendar edit API for real and virtual slots
- Add TimeSlotEdit schema (partial update, all fields optional)
- Add TimeSlotEditResponse schema
- Add PATCH /calendar/slots/{slot_id} for editing real slots
- Add PATCH /calendar/slots/virtual/{virtual_id} for editing virtual slots
  - Triggers materialization before applying edits
  - Detaches from plan after edit
- Both endpoints enforce past-slot immutability, overlap detection, plan
  detachment, and workload warnings
2026-03-31 10:46:09 +00:00
zhi
c75ded02c8 BE-CAL-API-002: Implement calendar day-view query API
- Add GET /calendar/day endpoint with optional ?date= query param
- Returns unified CalendarDayResponse merging real slots + virtual plan slots
- New CalendarSlotItem schema supports both real (id) and virtual (virtual_id) slots
- Excludes inactive slots (skipped/aborted) from results
- All slots sorted by scheduled_at ascending
- Helper functions for real/virtual slot conversion
2026-03-31 07:18:56 +00:00
zhi
751b3bc574 BE-CAL-API-001: Implement single slot creation API
- Add TimeSlotCreate, TimeSlotResponse, TimeSlotCreateResponse schemas
- Add SlotConflictItem, SlotTypeEnum, EventTypeEnum, SlotStatusEnum to schemas
- Add POST /calendar/slots endpoint with overlap detection and workload warnings
- Add _slot_to_response helper for ORM -> schema conversion
2026-03-31 05:45:58 +00:00
zhi
4f0e933de3 BE-CAL-007: MinimumWorkload warning rules + BE-CAL-008: past-slot immutability
BE-CAL-007: Workload warning computation (already implemented in prior wave,
verified tests pass - 24/24). Computes daily/weekly/monthly/yearly scheduled
minutes and compares against user thresholds. Warnings are advisory only.

BE-CAL-008: New slot_immutability service with guards for:
- Forbid edit/cancel of past real slots (raises ImmutableSlotError)
- Forbid edit/cancel of past virtual slots
- Plan-edit/plan-cancel helper to identify past materialized slot IDs
  that must not be retroactively modified
Tests: 19/19 passing.
2026-03-31 04:16:50 +00:00
zhi
570cfee5cd BE-CAL-006: implement Calendar overlap detection service
- New overlap.py service with check_overlap(), check_overlap_for_create(),
  and check_overlap_for_edit() functions
- Detects same-day time conflicts for a user's calendar
- Checks both real (materialized) TimeSlots and virtual (plan-generated) slots
- Excludes skipped/aborted slots from conflict checks
- Edit scenario excludes the slot being edited from conflict candidates
- Returns structured SlotConflict objects with human-readable messages
- 24 passing tests covering no-conflict, conflict detection, inactive
  exclusion, edit self-exclusion, virtual slot overlap, and message content
2026-03-31 01:17:54 +00:00
zhi
a5b885e8b5 BE-CAL-005: Implement plan virtual-slot identification and materialization
- New service: app/services/plan_slot.py
  - Virtual slot ID: plan-{plan_id}-{YYYY-MM-DD} format with parse/make helpers
  - Plan-date matching: on_month/on_week/on_day hierarchy with week_of_month calc
  - Materialization: convert virtual slot to real TimeSlot row from plan template
  - Detach: clear plan_id after edit/cancel to break plan association
  - Bulk materialization: materialize_all_for_date for daily pre-compute
- New tests: tests/test_plan_slot.py (23 tests, all passing)
2026-03-30 23:47:07 +00:00
zhi
eb57197020 BE-CAL-004: implement MinimumWorkload storage
- New model: minimum_workloads table with JSON config column (per-user)
- Schemas: MinimumWorkloadConfig, MinimumWorkloadUpdate, MinimumWorkloadResponse
- Service: CRUD operations + check_workload_warnings() entry point for BE-CAL-007
- API: GET/PUT/PATCH /calendar/workload-config (self + admin routes)
- Migration: auto-create minimum_workloads table on startup
- Registered calendar router in main.py
2026-03-30 22:27:05 +00:00
zhi
1c062ff4f1 BE-CAL-003: Add Agent model with status/heartbeat/exhausted fields
- New app/models/agent.py with Agent model, AgentStatus & ExhaustReason enums
- Agent has 1-to-1 FK to User, unique agent_id (OpenClaw $AGENT_ID),
  claw_identifier (OpenClaw instance, convention-matches MonitoredServer.identifier)
- Status fields: status (idle/on_call/busy/exhausted/offline), last_heartbeat
- Exhausted tracking: exhausted_at, recovery_at, exhaust_reason (rate_limit/billing)
- User model: added 'agent' back-reference (uselist=False)
- Schemas: AgentResponse, AgentStatusUpdate, UserCreate now accepts agent_id+claw_identifier
- UserResponse: includes agent_id when agent is bound
- Users router: create_user creates Agent record when agent_id+claw_identifier provided
- Auto-migration: CREATE TABLE agents in _migrate_schema()
- Startup imports: agent and calendar models registered
2026-03-30 20:47:44 +00:00
zhi
a9b4fa14b4 BE-CAL-002: Add SchedulePlan model with period hierarchy constraints
- Add DayOfWeek and MonthOfYear enums for plan period parameters
- Add SchedulePlan model with at_time/on_day/on_week/on_month fields
- Add DB-level check constraints enforcing hierarchy:
  on_month requires on_week, on_week requires on_day
- Add application-level @validates for on_week range (1-4),
  on_month hierarchy, and estimated_duration (1-50)
- Add is_active flag for soft-delete (plan-cancel)
- Add bidirectional relationship between SchedulePlan and TimeSlot
- All existing tests pass (29/29)
2026-03-30 19:16:16 +00:00
zhi
3dcd07bdf3 BE-CAL-001: Add TimeSlot model with SlotType/SlotStatus/EventType enums
- New calendar.py model file with TimeSlot table definition
- SlotType enum: work, on_call, entertainment, system
- SlotStatus enum: not_started, ongoing, deferred, skipped, paused, finished, aborted
- EventType enum: job, entertainment, system_event
- All fields per design doc: user_id, date, slot_type, estimated_duration,
  scheduled_at, started_at, attended, actual_duration, event_type, event_data (JSON),
  priority, status, plan_id (FK to schedule_plans)
2026-03-30 17:45:18 +00:00
zhi
1ed7a85e11 BE-PR-011: Fix test infrastructure and add Proposal/Essential/Story restricted tests
- Patched conftest.py to monkey-patch app.core.config engine/SessionLocal
  with SQLite in-memory DB BEFORE importing the FastAPI app, preventing
  startup event from trying to connect to production MySQL
- All 29 tests pass: Essential CRUD (11), Proposal Accept (8),
  Story restricted (6), Legacy compat (4)
2026-03-30 16:17:00 +00:00
zhi
90d1f22267 BE-PR-010: deprecate feat_task_id — retain column, read-only compat
- Updated model docstring with full deprecation strategy
- Updated column comment to mark as deprecated (BE-PR-010)
- Updated schema/router comments for deprecation clarity
- Added deprecation doc: docs/BE-PR-010-feat-task-id-deprecation.md
- feat_task_id superseded by Task.source_proposal_id (BE-PR-008)
2026-03-30 12:49:52 +00:00
zhi
08461dfdd3 BE-PR-009: restrict all story/* task types to Proposal Accept workflow
- Expand RESTRICTED_TYPE_SUBTYPES to include story/feature, story/improvement,
  story/refactor, and story/None (all story subtypes)
- Add FULLY_RESTRICTED_TYPES fast-path set for entire-type blocking
- Update _validate_task_type_subtype to block all story types via general
  create endpoint with clear error message directing to Proposal Accept
- Add type/subtype validation to PATCH /tasks/{id} to prevent changing
  existing tasks to story/* type via update
- Internal Proposal Accept flow unaffected (creates tasks directly via ORM)
2026-03-30 11:46:18 +00:00
zhi
c84884fe64 BE-PR-008: add Proposal Accept tracking fields (source_proposal_id, source_essential_id)
- Add source_proposal_id and source_essential_id FK columns to Task model
- Populate tracking fields during Proposal Accept task generation
- Add generated_tasks relationship on Proposal model for reverse lookup
- Expose source_proposal_id/source_essential_id in TaskResponse schema
- Add GeneratedTaskBrief schema and include generated_tasks in ProposalDetailResponse
- Proposal detail endpoint now returns generated story tasks with status
2026-03-30 10:46:20 +00:00
zhi
cb0be05246 BE-PR-007: refactor Proposal Accept to generate story tasks from all Essentials
- Removed old logic that created a single story/feature task on accept
- Accept now iterates all Essentials under the Proposal
- Each Essential.type maps to a story/* task (feature/improvement/refactor)
- All tasks created in a single transaction
- Added ProposalAcceptResponse and GeneratedTaskSummary schemas
- Proposal must have at least one Essential to be accepted
- No longer writes to deprecated feat_task_id field
2026-03-30 07:46:20 +00:00
zhi
431f4abe5a BE-PR-006: Add Essential CRUD API under Proposals
- New router: /projects/{project_id}/proposals/{proposal_id}/essentials
  - GET (list), POST (create), GET/{id}, PATCH/{id}, DELETE/{id}
- All mutations restricted to open proposals only
- Permission: creator, project owner, or global admin
- Registered essentials router in main.py
- Updated GET /proposals/{id} to return ProposalDetailResponse with
  embedded essentials list
- Activity logging on all CRUD operations
2026-03-30 07:16:30 +00:00
43 changed files with 8813 additions and 400 deletions

View File

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

1222
app/api/routers/calendar.py Normal file

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,284 @@
"""Essentials API router — CRUD for Essentials nested under a Proposal.
Endpoints are scoped to a project and proposal:
/projects/{project_code}/proposals/{proposal_code}/essentials
Only open Proposals allow Essential mutations.
"""
from typing import List
from fastapi import APIRouter, Depends, HTTPException, status
from sqlalchemy.orm import Session
from app.core.config import get_db
from app.api.deps import get_current_user_or_apikey
from app.api.rbac import check_project_role, is_global_admin
from app.models import models
from app.models.proposal import Proposal, ProposalStatus
from app.models.essential import Essential
from app.schemas.schemas import (
EssentialCreate,
EssentialUpdate,
EssentialResponse,
)
from app.services.activity import log_activity
from app.services.essential_code import generate_essential_code
router = APIRouter(
prefix="/projects/{project_code}/proposals/{proposal_code}/essentials",
tags=["Essentials"],
)
# ---------------------------------------------------------------------------
# Helpers
# ---------------------------------------------------------------------------
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(project_code)
).first()
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(proposal_code), Proposal.project_id == project_id)
.first()
)
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(essential_code), Essential.proposal_id == proposal_id)
.first()
)
def _require_open_proposal(proposal: Proposal) -> None:
"""Raise 400 if the proposal is not in open status."""
s = proposal.status.value if hasattr(proposal.status, "value") else proposal.status
if s != "open":
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="Essentials can only be modified on open proposals",
)
def _can_edit_proposal(db: Session, user_id: int, proposal: Proposal) -> bool:
"""Only creator, project owner, or global admin may mutate Essentials."""
if is_global_admin(db, user_id):
return True
if proposal.created_by_id == user_id:
return True
project = db.query(models.Project).filter(models.Project.id == proposal.project_id).first()
if project and project.owner_id == user_id:
return True
return False
def _serialize_essential(e: Essential, proposal_code: str | None) -> dict:
"""Return a dict matching EssentialResponse."""
return {
"essential_code": e.essential_code,
"proposal_code": proposal_code,
"type": e.type.value if hasattr(e.type, "value") else e.type,
"title": e.title,
"description": e.description,
"created_by_id": e.created_by_id,
"created_at": e.created_at,
"updated_at": e.updated_at,
}
# ---------------------------------------------------------------------------
# Endpoints
# ---------------------------------------------------------------------------
@router.get("", response_model=List[EssentialResponse])
def list_essentials(
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_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_code, project.id)
if not proposal:
raise HTTPException(status_code=404, detail="Proposal not found")
essentials = (
db.query(Essential)
.filter(Essential.proposal_id == proposal.id)
.order_by(Essential.id.asc())
.all()
)
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_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_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_code, project.id)
if not proposal:
raise HTTPException(status_code=404, detail="Proposal not found")
_require_open_proposal(proposal)
if not _can_edit_proposal(db, current_user.id, proposal):
raise HTTPException(status_code=403, detail="Permission denied")
code = generate_essential_code(db, proposal)
essential = Essential(
essential_code=code,
proposal_id=proposal.id,
type=body.type,
title=body.title,
description=body.description,
created_by_id=current_user.id,
)
db.add(essential)
db.commit()
db.refresh(essential)
log_activity(
db, "create", "essential", essential.id,
user_id=current_user.id,
details={"title": essential.title, "type": body.type.value, "proposal_id": proposal.id},
)
return _serialize_essential(essential, proposal.propose_code)
@router.get("/{essential_id}", response_model=EssentialResponse)
def get_essential(
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 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_code, project.id)
if not proposal:
raise HTTPException(status_code=404, detail="Proposal not found")
essential = _find_essential(db, essential_code, proposal.id)
if not essential:
raise HTTPException(status_code=404, detail="Essential not found")
return _serialize_essential(essential, proposal.propose_code)
@router.patch("/{essential_id}", response_model=EssentialResponse)
def update_essential(
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_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_code, project.id)
if not proposal:
raise HTTPException(status_code=404, detail="Proposal not found")
_require_open_proposal(proposal)
if not _can_edit_proposal(db, current_user.id, proposal):
raise HTTPException(status_code=403, detail="Permission denied")
essential = _find_essential(db, essential_code, proposal.id)
if not essential:
raise HTTPException(status_code=404, detail="Essential not found")
data = body.model_dump(exclude_unset=True)
for key, value in data.items():
setattr(essential, key, value)
db.commit()
db.refresh(essential)
log_activity(
db, "update", "essential", essential.id,
user_id=current_user.id,
details=data,
)
return _serialize_essential(essential, proposal.propose_code)
@router.delete("/{essential_id}", status_code=status.HTTP_204_NO_CONTENT)
def delete_essential(
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_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_code, project.id)
if not proposal:
raise HTTPException(status_code=404, detail="Proposal not found")
_require_open_proposal(proposal)
if not _can_edit_proposal(db, current_user.id, proposal):
raise HTTPException(status_code=403, detail="Permission denied")
essential = _find_essential(db, essential_code, proposal.id)
if not essential:
raise HTTPException(status_code=404, detail="Essential not found")
essential_data = {
"title": essential.title,
"type": essential.type.value if hasattr(essential.type, "value") else essential.type,
"proposal_id": proposal.id,
}
db.delete(essential)
db.commit()
log_activity(
db, "delete", "essential", essential.id,
user_id=current_user.id,
details=essential_data,
)

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -13,59 +13,91 @@ from app.api.deps import get_current_user_or_apikey
from app.api.rbac import check_project_role, check_permission, is_global_admin
from app.models import models
from app.models.proposal import Proposal, ProposalStatus
from app.models.essential import Essential
from app.models.milestone import Milestone, MilestoneStatus
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_proposal(db: Session, proposal: Proposal) -> dict:
def _serialize_essential(e: Essential, proposal_code: str | None) -> dict:
"""Serialize an Essential for embedding in Proposal detail."""
return {
"essential_code": e.essential_code,
"proposal_code": proposal_code,
"type": e.type.value if hasattr(e.type, "value") else e.type,
"title": e.title,
"description": e.description,
"created_by_id": e.created_by_id,
"created_at": e.created_at,
"updated_at": e.updated_at,
}
def _serialize_proposal(db: Session, proposal: Proposal, *, include_essentials: bool = False) -> dict:
"""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
return {
"id": proposal.id,
project = db.query(models.Project).filter(models.Project.id == proposal.project_id).first()
result = {
"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 — read-only compat
"feat_task_id": proposal.feat_task_id, # DEPRECATED (BE-PR-010): read-only for legacy rows. Clients should use generated_tasks.
"created_at": proposal.created_at,
"updated_at": proposal.updated_at,
}
if include_essentials:
essentials = (
db.query(Essential)
.filter(Essential.proposal_id == proposal.id)
.order_by(Essential.id.asc())
.all()
)
result["essentials"] = [_serialize_essential(e, code) for e in essentials]
# BE-PR-008: include tasks generated from this Proposal via Accept
gen_tasks = (
db.query(Task)
.filter(Task.source_proposal_id == proposal.id)
.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_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_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()
@@ -102,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")
@@ -121,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")
@@ -150,35 +182,36 @@ def create_proposal(
return _serialize_proposal(db, proposal)
@router.get("/{proposal_id}", response_model=schemas.ProposalResponse)
@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),
):
project = _find_project(db, project_id)
"""Get a single Proposal with its Essentials list embedded."""
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)
return _serialize_proposal(db, proposal, include_essentials=True)
@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")
@@ -191,7 +224,7 @@ def update_proposal(
raise HTTPException(status_code=403, detail="Proposal edit permission denied")
data = proposal_in.model_dump(exclude_unset=True)
# Never allow client to set feat_task_id
# DEPRECATED (BE-PR-010): feat_task_id is read-only; strip from client input
data.pop("feat_task_id", None)
for key, value in data.items():
@@ -207,22 +240,31 @@ def update_proposal(
# ---- Actions ----
class AcceptRequest(schemas.BaseModel):
milestone_id: int
milestone_code: str
@router.post("/{proposal_id}/accept", response_model=schemas.ProposalResponse)
@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),
):
"""Accept a proposal: create a feature story task in the chosen milestone."""
project = _find_project(db, project_id)
"""Accept a proposal: generate story tasks from all Essentials into the chosen milestone.
Each Essential under the Proposal produces a corresponding ``story/*`` task:
- feature → story/feature
- improvement → story/improvement
- refactor → story/refactor
All tasks are created in a single transaction. The Proposal must have at
least one Essential to be accepted.
"""
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")
@@ -234,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:
@@ -244,43 +286,87 @@ def accept_proposal(
if ms_status != "open":
raise HTTPException(status_code=400, detail="Target milestone must be in 'open' status")
# Generate task code
milestone_code = milestone.milestone_code or f"m{milestone.id}"
max_task = db.query(Task).filter(Task.milestone_id == milestone.id).order_by(Task.id.desc()).first()
next_num = (max_task.id + 1) if max_task else 1
task_code = f"{milestone_code}:T{next_num:05x}"
# Create feature story task
task = Task(
title=proposal.title,
description=proposal.description,
task_type="story",
task_subtype="feature",
status=TaskStatus.PENDING,
priority=TaskPriority.MEDIUM,
project_id=project.id,
milestone_id=milestone.id,
reporter_id=proposal.created_by_id or current_user.id,
created_by_id=proposal.created_by_id or current_user.id,
task_code=task_code,
# Fetch all Essentials for this Proposal
essentials = (
db.query(Essential)
.filter(Essential.proposal_id == proposal.id)
.order_by(Essential.id.asc())
.all()
)
db.add(task)
db.flush() # get task.id
if not essentials:
raise HTTPException(
status_code=400,
detail="Proposal has no Essentials. Add at least one Essential before accepting.",
)
# Update proposal
# Map Essential type → task subtype
ESSENTIAL_TYPE_TO_SUBTYPE = {
"feature": "feature",
"improvement": "improvement",
"refactor": "refactor",
}
# Determine next task number in this milestone
milestone_code = milestone.milestone_code or f"m{milestone.id}"
max_task = (
db.query(sa_func.max(Task.id))
.filter(Task.milestone_id == milestone.id)
.scalar()
)
next_num = (max_task + 1) if max_task else 1
# Create one story task per Essential — all within the current transaction
generated_tasks = []
for essential in essentials:
etype = essential.type.value if hasattr(essential.type, "value") else essential.type
task_subtype = ESSENTIAL_TYPE_TO_SUBTYPE.get(etype, "feature")
task_code = f"{milestone_code}:T{next_num:05x}"
task = Task(
title=essential.title,
description=essential.description,
task_type="story",
task_subtype=task_subtype,
status=TaskStatus.PENDING,
priority=TaskPriority.MEDIUM,
project_id=project.id,
milestone_id=milestone.id,
reporter_id=proposal.created_by_id or current_user.id,
created_by_id=proposal.created_by_id or current_user.id,
task_code=task_code,
# BE-PR-008: track which Proposal/Essential generated this task
source_proposal_id=proposal.id,
source_essential_id=essential.id,
)
db.add(task)
db.flush() # materialise task.id
generated_tasks.append({
"task_code": task_code,
"task_type": "story",
"task_subtype": task_subtype,
"title": essential.title,
"essential_code": essential.essential_code,
})
next_num = task.id + 1 # use real id for next code to stay consistent
# Update proposal status — feat_task_id is NOT written (deprecated per BE-PR-010)
proposal.status = ProposalStatus.ACCEPTED
proposal.feat_task_id = str(task.id)
db.commit()
db.refresh(proposal)
log_activity(db, "accept", "proposal", proposal.id, user_id=current_user.id, details={
"milestone_id": milestone.id,
"generated_task_id": task.id,
"task_code": task_code,
"milestone_code": milestone.milestone_code,
"generated_tasks": [
{"task_code": t["task_code"], "essential_code": t["essential_code"]}
for t in generated_tasks
],
})
return _serialize_proposal(db, proposal)
result = _serialize_proposal(db, proposal, include_essentials=True)
result["generated_tasks"] = generated_tasks
return result
class RejectRequest(schemas.BaseModel):
@@ -289,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")
@@ -322,16 +408,16 @@ def reject_proposal(
@router.post("/{proposal_id}/reopen", response_model=schemas.ProposalResponse)
def reopen_proposal(
project_id: str,
proposal_id: str,
project_code: str,
proposal_code: str,
db: Session = Depends(get_db),
current_user: models.User = Depends(get_current_user_or_apikey),
):
"""Reopen a rejected proposal back to open."""
project = _find_project(db, project_id)
project = _find_project(db, project_code)
if not project:
raise HTTPException(status_code=404, detail="Project not found")
proposal = _find_proposal(db, proposal_id, project.id)
proposal = _find_proposal(db, proposal_code, project.id)
if not proposal:
raise HTTPException(status_code=404, detail="Proposal not found")

View File

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

View File

@@ -10,6 +10,8 @@ from app.core.config import get_db
from app.models import models
from app.models.task import Task, TaskStatus, TaskPriority
from app.models.milestone import Milestone
from app.models.proposal import Proposal
from app.models.essential import Essential
from app.schemas import schemas
from app.services.webhook import fire_webhooks_sync
from app.models.notification import Notification as NotificationModel
@@ -21,14 +23,9 @@ from app.services.dependency_check import check_task_deps
router = APIRouter(tags=["Tasks"])
def _resolve_task(db: Session, identifier: str) -> Task:
"""Resolve a task by numeric id or task_code string.
Raises 404 if not found."""
try:
task_id = int(identifier)
task = db.query(Task).filter(Task.id == task_id).first()
except (ValueError, TypeError):
task = db.query(Task).filter(Task.task_code == identifier).first()
def _resolve_task(db: Session, task_code: str) -> Task:
"""Resolve a task by task_code string. Raises 404 if not found."""
task = db.query(Task).filter(Task.task_code == task_code).first()
if not task:
raise HTTPException(status_code=404, detail="Task not found")
return task
@@ -66,15 +63,22 @@ TASK_SUBTYPE_MAP = {
ALLOWED_TASK_TYPES = set(TASK_SUBTYPE_MAP.keys())
"""P9.6 — type+subtype combos that may NOT be created via general create endpoints.
feature story → must come from propose accept
release maintenance must come from controlled milestone/release flow
"""P9.6 / BE-PR-009 — type+subtype combos that may NOT be created via general
endpoints. All story/* subtypes are restricted; they must come from Proposal
Accept. maintenance/release must come from the milestone release flow.
"""
RESTRICTED_TYPE_SUBTYPES = {
("story", "feature"),
("story", "improvement"),
("story", "refactor"),
("story", None), # story with no subtype is also blocked
("maintenance", "release"),
}
# Convenience set: task types whose *entire* type is restricted regardless of subtype.
# Used for a fast-path check so we don't need to enumerate every subtype.
FULLY_RESTRICTED_TYPES = {"story"}
def _validate_task_type_subtype(task_type: str | None, task_subtype: str | None, *, allow_restricted: bool = False):
if task_type is None:
@@ -84,13 +88,23 @@ def _validate_task_type_subtype(task_type: str | None, task_subtype: str | None,
allowed = TASK_SUBTYPE_MAP.get(task_type, set())
if task_subtype and task_subtype not in allowed:
raise HTTPException(status_code=400, detail=f'Invalid task_subtype for {task_type}: {task_subtype}')
# P9.6: block restricted combos unless explicitly allowed (e.g. propose accept, internal create)
if not allow_restricted and (task_type, task_subtype) in RESTRICTED_TYPE_SUBTYPES:
raise HTTPException(
status_code=400,
detail=f"Cannot create {task_type}/{task_subtype} task via general create. "
f"Use the appropriate workflow (propose accept / milestone release setup)."
)
# P9.6 / BE-PR-009: block restricted combos unless explicitly allowed
# (e.g. Proposal Accept, internal create)
if not allow_restricted:
# Fast-path: entire type is restricted (all story/* combos)
if task_type in FULLY_RESTRICTED_TYPES:
raise HTTPException(
status_code=400,
detail=f"Cannot create '{task_type}' tasks via general endpoints. "
f"Use the Proposal Accept workflow instead.",
)
# Specific type+subtype combos (e.g. maintenance/release)
if (task_type, task_subtype) in RESTRICTED_TYPE_SUBTYPES:
raise HTTPException(
status_code=400,
detail=f"Cannot create {task_type}/{task_subtype} task via general create. "
f"Use the appropriate workflow (Proposal Accept / milestone release setup).",
)
def _notify_user(db, user_id, ntype, title, message=None, entity_type=None, entity_id=None):
@@ -101,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()
@@ -112,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()
@@ -157,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
@@ -174,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
@@ -184,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")
@@ -220,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,
)
@@ -230,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
@@ -299,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:
@@ -315,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
@@ -383,6 +388,16 @@ def update_task(task_id: str, task_update: schemas.TaskUpdate, db: Session = Dep
detail="Only the current assignee or an admin can edit this task",
)
# BE-PR-009: prevent changing task_type to a restricted type via PATCH
new_task_type = update_data.get("task_type")
new_task_subtype = update_data.get("task_subtype", task.task_subtype)
if new_task_type is not None:
_validate_task_type_subtype(new_task_type, new_task_subtype)
elif "task_subtype" in update_data:
# subtype changed but type unchanged — validate the combo
current_type = task.task_type.value if hasattr(task.task_type, "value") else (task.task_type or "issue")
_validate_task_type_subtype(current_type, new_task_subtype)
# Legacy general permission check (covers project membership etc.)
ensure_can_edit_task(db, current_user.id, task)
if "status" in update_data:
@@ -410,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)
@@ -427,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,
@@ -440,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
@@ -520,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")
@@ -550,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,
@@ -560,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")
@@ -570,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")
@@ -616,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
@@ -638,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
@@ -658,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
@@ -682,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
@@ -695,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
@@ -705,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
@@ -715,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,
@@ -735,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)
@@ -755,25 +770,27 @@ def batch_assign(data: BatchAssign, db: Session = Depends(get_db)):
if not user:
raise HTTPException(status_code=404, detail="Assignee not found")
updated = []
for task_id in data.task_ids:
task = db.query(Task).filter(Task.id == task_id).first()
for task_code in data.task_codes:
task = db.query(Task).filter(Task.task_code == task_code).first()
if task:
task.assignee_id = data.assignee_id
updated.append(task_id)
updated.append(task.task_code)
db.commit()
return {"updated": len(updated), "task_ids": updated, "assignee_id": data.assignee_id}
return {"updated": len(updated), "task_codes": updated, "assignee_id": data.assignee_id}
# ---- Search ----
@router.get("/search/tasks")
def search_tasks(q: str, project_id: int = None, page: int = 1, page_size: int = 50,
def search_tasks(q: str, project_code: str = None, page: int = 1, page_size: int = 50,
db: Session = Depends(get_db)):
query = db.query(Task).filter(
(Task.title.contains(q)) | (Task.description.contains(q))
)
if project_id:
query = query.filter(Task.project_id == project_id)
if project_code:
project_id = _resolve_project_id(db, project_code)
if project_id:
query = query.filter(Task.project_id == project_id)
total = query.count()
page = max(1, page)
page_size = min(max(1, page_size), 200)

View File

@@ -7,9 +7,10 @@ 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
from app.models.role_permission import Permission, Role, RolePermission
from app.models.worklog import WorkLog
from app.schemas import schemas
@@ -17,6 +18,24 @@ from app.schemas import schemas
router = APIRouter(prefix="/users", tags=["Users"])
def _user_response(user: models.User) -> dict:
"""Build a UserResponse-compatible dict that includes the agent_id when present."""
data = {
"id": user.id,
"username": user.username,
"email": user.email,
"full_name": user.full_name,
"is_active": user.is_active,
"is_admin": user.is_admin,
"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
def require_admin(current_user: models.User = Depends(get_current_user)):
if not current_user.is_admin:
raise HTTPException(status_code=403, detail="Admin required")
@@ -39,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
@@ -69,27 +88,54 @@ def create_user(
db: Session = Depends(get_db),
_: models.User = Depends(require_account_creator),
):
# Validate agent_id / claw_identifier: both or neither
has_agent_id = bool(user.agent_id)
has_claw = bool(user.claw_identifier)
if has_agent_id != has_claw:
raise HTTPException(
status_code=400,
detail="agent_id and claw_identifier must both be provided or both omitted",
)
existing = db.query(models.User).filter(
(models.User.username == user.username) | (models.User.email == user.email)
).first()
if existing:
raise HTTPException(status_code=400, detail="Username or email already exists")
# Check agent_id uniqueness
if has_agent_id:
existing_agent = db.query(Agent).filter(Agent.agent_id == user.agent_id).first()
if existing_agent:
raise HTTPException(status_code=400, detail="agent_id already in use")
assigned_role = _resolve_user_role(db, user.role_id)
hashed_password = get_password_hash(user.password) if user.password else None
db_user = models.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,
role_id=assigned_role.id,
)
db.add(db_user)
db.flush() # get db_user.id
# Create Agent record if agent binding is requested (BE-CAL-003)
if has_agent_id:
db_agent = Agent(
user_id=db_user.id,
agent_id=user.agent_id,
claw_identifier=user.claw_identifier,
)
db.add(db_agent)
db.commit()
db.refresh(db_user)
return db_user
return _user_response(db_user)
@router.get("", response_model=List[schemas.UserResponse])
@@ -99,7 +145,8 @@ def list_users(
db: Session = Depends(get_db),
_: models.User = Depends(require_admin),
):
return db.query(models.User).order_by(models.User.created_at.desc()).offset(skip).limit(limit).all()
users = db.query(models.User).order_by(models.User.created_at.desc()).offset(skip).limit(limit).all()
return [_user_response(u) for u in users]
def _find_user_by_id_or_username(db: Session, identifier: str) -> models.User | None:
@@ -120,7 +167,7 @@ def get_user(
user = _find_user_by_id_or_username(db, identifier)
if not user:
raise HTTPException(status_code=404, detail="User not found")
return user
return _user_response(user)
@router.patch("/{identifier}", response_model=schemas.UserResponse)
@@ -157,9 +204,12 @@ 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
return _user_response(user)
@router.delete("/{identifier}", status_code=status.HTTP_204_NO_CONTENT)

View File

@@ -132,6 +132,10 @@ DEFAULT_PERMISSIONS = [
# Monitor
("monitor.read", "View monitor", "monitor"),
("monitor.manage", "Manage monitor", "monitor"),
# Calendar
("calendar.read", "View calendar slots and plans", "calendar"),
("calendar.write", "Create and edit calendar slots and plans", "calendar"),
("calendar.manage", "Manage calendar settings and workload policies", "calendar"),
# Webhook
("webhook.manage", "Manage webhooks", "admin"),
]
@@ -168,6 +172,7 @@ _MGR_PERMISSIONS = {
"task.close", "task.reopen_closed", "task.reopen_completed",
"propose.accept", "propose.reject", "propose.reopen",
"monitor.read",
"calendar.read", "calendar.write", "calendar.manage",
"user.reset-self-apikey",
}
@@ -178,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",
}

View File

@@ -42,6 +42,7 @@ def config_status():
return {
"initialized": cfg.get("initialized", False),
"backend_url": cfg.get("backend_url"),
"discord": cfg.get("discord") or {},
}
except Exception:
return {"initialized": False}
@@ -61,6 +62,8 @@ from app.api.routers.proposals import router as proposals_router
from app.api.routers.proposes import router as proposes_router # legacy compat
from app.api.routers.milestone_actions import router as milestone_actions_router
from app.api.routers.meetings import router as meetings_router
from app.api.routers.essentials import router as essentials_router
from app.api.routers.calendar import router as calendar_router
app.include_router(auth_router)
app.include_router(tasks_router)
@@ -76,6 +79,8 @@ app.include_router(proposals_router)
app.include_router(proposes_router) # legacy compat
app.include_router(milestone_actions_router)
app.include_router(meetings_router)
app.include_router(essentials_router)
app.include_router(calendar_router)
# Auto schema migration for lightweight deployments
@@ -92,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(
"""
@@ -135,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'"))
@@ -169,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'"))
@@ -198,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 "
@@ -244,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"))
@@ -253,12 +284,45 @@ 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"))
if _has_table(db, "server_states") and not _has_column(db, "server_states", "nginx_sites_json"):
db.execute(text("ALTER TABLE server_states ADD COLUMN nginx_sites_json TEXT NULL"))
# --- agents table (BE-CAL-003) ---
if not _has_table(db, "agents"):
db.execute(text("""
CREATE TABLE agents (
id INTEGER NOT NULL AUTO_INCREMENT,
user_id INTEGER NOT NULL,
agent_id VARCHAR(128) NOT NULL,
claw_identifier VARCHAR(128) NOT NULL,
status ENUM('idle','on_call','busy','exhausted','offline') NOT NULL DEFAULT 'idle',
last_heartbeat DATETIME NULL,
exhausted_at DATETIME NULL,
recovery_at DATETIME NULL,
exhaust_reason ENUM('rate_limit','billing') NULL,
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
PRIMARY KEY (id),
UNIQUE INDEX idx_agents_user_id (user_id),
UNIQUE INDEX idx_agents_agent_id (agent_id),
CONSTRAINT fk_agents_user_id FOREIGN KEY (user_id) REFERENCES users(id)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4
"""))
# --- essentials table (BE-PR-003) ---
if not _has_table(db, "essentials"):
db.execute(text("""
@@ -280,6 +344,25 @@ def _migrate_schema():
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4
"""))
# --- minimum_workloads table (BE-CAL-004) ---
if not _has_table(db, "minimum_workloads"):
db.execute(text("""
CREATE TABLE minimum_workloads (
id INTEGER NOT NULL AUTO_INCREMENT,
user_id INTEGER NOT NULL,
config JSON NOT NULL,
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
updated_at DATETIME NULL ON UPDATE CURRENT_TIMESTAMP,
PRIMARY KEY (id),
UNIQUE INDEX idx_minimum_workloads_user_id (user_id),
CONSTRAINT fk_minimum_workloads_user_id FOREIGN KEY (user_id) REFERENCES users(id)
) 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()
@@ -314,7 +397,7 @@ def _sync_default_user_roles(db):
@app.on_event("startup")
def startup():
from app.core.config import Base, engine, SessionLocal
from app.models import models, webhook, apikey, activity, milestone, notification, worklog, monitor, role_permission, task, support, meeting, proposal, propose, essential
from app.models import models, webhook, apikey, activity, milestone, notification, worklog, monitor, role_permission, task, support, meeting, proposal, propose, essential, agent, calendar, minimum_workload
Base.metadata.create_all(bind=engine)
_migrate_schema()

140
app/models/agent.py Normal file
View File

@@ -0,0 +1,140 @@
"""Agent model — tracks OpenClaw agents linked to HarborForge users.
An Agent represents an AI agent (identified by its OpenClaw ``agent_id``)
that is bound to exactly one HarborForge User. The Calendar system uses
Agent status to decide whether to wake an agent for scheduled slots.
See: NEXT_WAVE_DEV_DIRECTION.md §1.4 (Agent table) and §6 (Agent wakeup)
Implements: BE-CAL-003
"""
from sqlalchemy import (
Column,
Integer,
String,
DateTime,
Enum,
ForeignKey,
)
from sqlalchemy.orm import relationship
from sqlalchemy.sql import func
from app.core.config import Base
import enum
# ---------------------------------------------------------------------------
# Enums
# ---------------------------------------------------------------------------
class AgentStatus(str, enum.Enum):
"""Runtime status of an Agent."""
IDLE = "idle"
ON_CALL = "on_call"
BUSY = "busy"
EXHAUSTED = "exhausted"
OFFLINE = "offline"
class ExhaustReason(str, enum.Enum):
"""Why an agent entered the Exhausted state."""
RATE_LIMIT = "rate_limit"
BILLING = "billing"
# ---------------------------------------------------------------------------
# Agent model
# ---------------------------------------------------------------------------
class Agent(Base):
"""An OpenClaw agent bound to a HarborForge user.
Fields
------
user_id : int
One-to-one FK to ``users.id``. Each user has at most one agent.
agent_id : str
The ``$AGENT_ID`` value from OpenClaw (globally unique).
claw_identifier : str
The OpenClaw instance identifier (matches ``MonitoredServer.identifier``
by convention, but has no FK — they are independent concepts).
status : AgentStatus
Current runtime status, managed by heartbeat / calendar wakeup logic.
last_heartbeat : datetime | None
Timestamp of the most recent heartbeat received from this agent.
exhausted_at : datetime | None
When the agent entered the ``EXHAUSTED`` state.
recovery_at : datetime | None
Estimated time the agent will recover from ``EXHAUSTED`` → ``IDLE``.
exhaust_reason : ExhaustReason | None
Why the agent became exhausted (rate-limit vs billing).
"""
__tablename__ = "agents"
id = Column(Integer, primary_key=True, index=True)
user_id = Column(
Integer,
ForeignKey("users.id"),
nullable=False,
unique=True,
index=True,
comment="1-to-1 link to the owning HarborForge user",
)
agent_id = Column(
String(128),
nullable=False,
unique=True,
index=True,
comment="OpenClaw $AGENT_ID",
)
claw_identifier = Column(
String(128),
nullable=False,
comment="OpenClaw instance identifier (same value as MonitoredServer.identifier by convention)",
)
# -- runtime status fields ----------------------------------------------
status = Column(
Enum(AgentStatus, values_callable=lambda x: [e.value for e in x]),
nullable=False,
default=AgentStatus.IDLE,
comment="Current agent status: idle | on_call | busy | exhausted | offline",
)
last_heartbeat = Column(
DateTime(timezone=True),
nullable=True,
comment="Timestamp of the most recent heartbeat",
)
# -- exhausted state detail ---------------------------------------------
exhausted_at = Column(
DateTime(timezone=True),
nullable=True,
comment="When the agent entered EXHAUSTED state",
)
recovery_at = Column(
DateTime(timezone=True),
nullable=True,
comment="Estimated recovery time from EXHAUSTED → IDLE",
)
exhaust_reason = Column(
Enum(ExhaustReason, values_callable=lambda x: [e.value for e in x]),
nullable=True,
comment="rate_limit | billing — why the agent is exhausted",
)
# -- timestamps ---------------------------------------------------------
created_at = Column(DateTime(timezone=True), server_default=func.now())
# -- relationships ------------------------------------------------------
user = relationship("User", back_populates="agent", uselist=False)

321
app/models/calendar.py Normal file
View File

@@ -0,0 +1,321 @@
"""Calendar models — TimeSlot, SchedulePlan and related enums.
TimeSlot represents a single scheduled slot on a user's calendar.
Slots can be created manually or materialized from a SchedulePlan.
SchedulePlan represents a recurring schedule rule that generates
virtual slots on matching dates. Virtual slots are materialized
into real TimeSlot rows on demand (daily pre-compute, or when
edited/cancelled).
See: NEXT_WAVE_DEV_DIRECTION.md §1.1 §1.3
"""
from sqlalchemy import (
Column, Integer, String, Text, DateTime, Date, Time,
ForeignKey, Enum, Boolean, JSON, CheckConstraint,
)
from sqlalchemy.orm import relationship, validates
from sqlalchemy.sql import func
from app.core.config import Base
import enum
# ---------------------------------------------------------------------------
# Enums
# ---------------------------------------------------------------------------
class SlotType(str, enum.Enum):
"""What kind of slot this is."""
WORK = "work"
ON_CALL = "on_call"
ENTERTAINMENT = "entertainment"
SYSTEM = "system"
class SlotStatus(str, enum.Enum):
"""Lifecycle status of a slot."""
NOT_STARTED = "not_started"
ONGOING = "ongoing"
DEFERRED = "deferred"
SKIPPED = "skipped"
PAUSED = "paused"
FINISHED = "finished"
ABORTED = "aborted"
class EventType(str, enum.Enum):
"""High-level event category stored alongside the slot."""
JOB = "job"
ENTERTAINMENT = "entertainment"
SYSTEM_EVENT = "system_event"
class DayOfWeek(str, enum.Enum):
"""Day-of-week for SchedulePlan.on_day."""
SUN = "sun"
MON = "mon"
TUE = "tue"
WED = "wed"
THU = "thu"
FRI = "fri"
SAT = "sat"
class MonthOfYear(str, enum.Enum):
"""Month for SchedulePlan.on_month."""
JAN = "jan"
FEB = "feb"
MAR = "mar"
APR = "apr"
MAY = "may"
JUN = "jun"
JUL = "jul"
AUG = "aug"
SEP = "sep"
OCT = "oct"
NOV = "nov"
DEC = "dec"
# ---------------------------------------------------------------------------
# TimeSlot model
# ---------------------------------------------------------------------------
class TimeSlot(Base):
__tablename__ = "time_slots"
id = Column(Integer, primary_key=True, index=True)
user_id = Column(
Integer,
ForeignKey("users.id"),
nullable=False,
index=True,
comment="Owner of this slot",
)
date = Column(
Date,
nullable=False,
index=True,
comment="Calendar date for this slot",
)
slot_type = Column(
Enum(SlotType, values_callable=lambda x: [e.value for e in x]),
nullable=False,
comment="work | on_call | entertainment | system",
)
estimated_duration = Column(
Integer,
nullable=False,
comment="Estimated duration in minutes (1-50)",
)
scheduled_at = Column(
Time,
nullable=False,
comment="Planned start time (00:00-23:00)",
)
started_at = Column(
Time,
nullable=True,
comment="Actual start time (filled when slot begins)",
)
attended = Column(
Boolean,
default=False,
nullable=False,
comment="Whether the slot has been attended",
)
actual_duration = Column(
Integer,
nullable=True,
comment="Actual duration in minutes (0-65535), no upper design limit",
)
event_type = Column(
Enum(EventType, values_callable=lambda x: [e.value for e in x]),
nullable=True,
comment="job | entertainment | system_event",
)
event_data = Column(
JSON,
nullable=True,
comment="Event details JSON — structure depends on event_type",
)
priority = Column(
Integer,
nullable=False,
default=0,
comment="Priority 0-99, higher = more important",
)
status = Column(
Enum(SlotStatus, values_callable=lambda x: [e.value for e in x]),
nullable=False,
default=SlotStatus.NOT_STARTED,
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"),
nullable=True,
comment="Source plan if materialized from a SchedulePlan; set NULL on edit/cancel",
)
created_at = Column(DateTime(timezone=True), server_default=func.now())
updated_at = Column(DateTime(timezone=True), onupdate=func.now())
# relationship ----------------------------------------------------------
plan = relationship("SchedulePlan", back_populates="materialized_slots")
# ---------------------------------------------------------------------------
# SchedulePlan model
# ---------------------------------------------------------------------------
class SchedulePlan(Base):
"""A recurring schedule rule that generates virtual TimeSlots.
Hierarchy constraint for the period parameters:
• ``at_time`` is always required.
• ``on_month`` requires ``on_week`` (which in turn requires ``on_day``).
• ``on_week`` requires ``on_day``.
Examples:
• ``--at 09:00`` → every day at 09:00
• ``--at 09:00 --on-day sun`` → every Sunday at 09:00
• ``--at 09:00 --on-day sun --on-week 1`` → 1st-week Sunday each month
• ``--at … --on-day sun --on-week 1 --on-month jan`` → Jan 1st-week Sunday
"""
__tablename__ = "schedule_plans"
__table_args__ = (
# on_month requires on_week
CheckConstraint(
"(on_month IS NULL) OR (on_week IS NOT NULL)",
name="ck_plan_month_requires_week",
),
# on_week requires on_day
CheckConstraint(
"(on_week IS NULL) OR (on_day IS NOT NULL)",
name="ck_plan_week_requires_day",
),
)
id = Column(Integer, primary_key=True, index=True)
user_id = Column(
Integer,
ForeignKey("users.id"),
nullable=False,
index=True,
comment="Owner of this plan",
)
# -- slot template fields -----------------------------------------------
slot_type = Column(
Enum(SlotType, values_callable=lambda x: [e.value for e in x]),
nullable=False,
comment="work | on_call | entertainment | system",
)
estimated_duration = Column(
Integer,
nullable=False,
comment="Estimated duration in minutes (1-50)",
)
event_type = Column(
Enum(EventType, values_callable=lambda x: [e.value for e in x]),
nullable=True,
comment="job | entertainment | system_event",
)
event_data = Column(
JSON,
nullable=True,
comment="Event details JSON — copied to materialized slots",
)
# -- period parameters --------------------------------------------------
at_time = Column(
Time,
nullable=False,
comment="Daily scheduled time (--at HH:mm), always required",
)
on_day = Column(
Enum(DayOfWeek, values_callable=lambda x: [e.value for e in x]),
nullable=True,
comment="Day of week (--on-day); NULL = every day",
)
on_week = Column(
Integer,
nullable=True,
comment="Week-of-month 1-4 (--on-week); NULL = every week",
)
on_month = Column(
Enum(MonthOfYear, values_callable=lambda x: [e.value for e in x]),
nullable=True,
comment="Month (--on-month); NULL = every month",
)
is_active = Column(
Boolean,
default=True,
nullable=False,
comment="Soft-delete / plan-cancel flag",
)
created_at = Column(DateTime(timezone=True), server_default=func.now())
updated_at = Column(DateTime(timezone=True), onupdate=func.now())
# relationship ----------------------------------------------------------
materialized_slots = relationship(
"TimeSlot",
back_populates="plan",
lazy="dynamic",
)
# -- application-level validation ---------------------------------------
@validates("on_week")
def _validate_on_week(self, _key: str, value: int | None) -> int | None:
if value is not None and not (1 <= value <= 4):
raise ValueError("on_week must be between 1 and 4")
return value
@validates("on_month")
def _validate_on_month(self, _key: str, value):
"""Enforce: on_month requires on_week (and transitively on_day)."""
if value is not None and self.on_week is None:
raise ValueError(
"on_month requires on_week to be set "
"(hierarchy: on_month → on_week → on_day)"
)
return value
@validates("estimated_duration")
def _validate_estimated_duration(self, _key: str, value: int) -> int:
if not (1 <= value <= 50):
raise ValueError("estimated_duration must be between 1 and 50")
return value

View File

@@ -0,0 +1,66 @@
"""MinimumWorkload model — per-user workload threshold configuration.
Stores the minimum expected workload (in minutes) across four periods
(daily / weekly / monthly / yearly) and three slot categories
(work / on_call / entertainment). Values are advisory: when a
calendar submission would leave the user below these thresholds, the
system returns a *warning* but does not block the operation.
Storage decision (BE-CAL-004): independent table with a JSON column.
This keeps the User model clean while giving each user exactly one
configuration row. The JSON structure matches the design document:
{
"daily": {"work": 0, "on_call": 0, "entertainment": 0},
"weekly": {"work": 0, "on_call": 0, "entertainment": 0},
"monthly": {"work": 0, "on_call": 0, "entertainment": 0},
"yearly": {"work": 0, "on_call": 0, "entertainment": 0}
}
All values are minutes in range [0, 65535].
"""
from sqlalchemy import Column, Integer, ForeignKey, JSON, DateTime
from sqlalchemy.orm import relationship
from sqlalchemy.sql import func
from app.core.config import Base
# Default configuration — all thresholds zeroed out (no warnings).
DEFAULT_WORKLOAD_CONFIG: dict = {
"daily": {"work": 0, "on_call": 0, "entertainment": 0},
"weekly": {"work": 0, "on_call": 0, "entertainment": 0},
"monthly": {"work": 0, "on_call": 0, "entertainment": 0},
"yearly": {"work": 0, "on_call": 0, "entertainment": 0},
}
PERIODS = ("daily", "weekly", "monthly", "yearly")
CATEGORIES = ("work", "on_call", "entertainment")
class MinimumWorkload(Base):
"""Per-user minimum workload configuration."""
__tablename__ = "minimum_workloads"
id = Column(Integer, primary_key=True, index=True)
user_id = Column(
Integer,
ForeignKey("users.id"),
nullable=False,
unique=True,
index=True,
comment="One config row per user",
)
config = Column(
JSON,
nullable=False,
default=lambda: dict(DEFAULT_WORKLOAD_CONFIG),
comment="Workload thresholds JSON — see module docstring for schema",
)
created_at = Column(DateTime(timezone=True), server_default=func.now())
updated_at = Column(DateTime(timezone=True), onupdate=func.now())

View File

@@ -72,6 +72,7 @@ class User(Base):
email = Column(String(100), unique=True, nullable=False)
hashed_password = Column(String(255), nullable=True)
full_name = Column(String(100), nullable=True)
discord_user_id = Column(String(32), nullable=True)
is_active = Column(Boolean, default=True)
is_admin = Column(Boolean, default=False)
role_id = Column(Integer, ForeignKey("roles.id"), nullable=True)
@@ -81,6 +82,7 @@ class User(Base):
owned_projects = relationship("Project", back_populates="owner")
comments = relationship("Comment", back_populates="author")
project_memberships = relationship("ProjectMember", back_populates="user")
agent = relationship("Agent", back_populates="user", uselist=False)
@property
def role_name(self):

View File

@@ -24,11 +24,21 @@ class Proposal(Base):
one Project.
- ``created_by_id`` — FK to ``users.id``; the user who authored the Proposal.
Nullable for legacy rows created before tracking was added.
- ``feat_task_id`` — **DEPRECATED**. Previously stored the single generated
``story/feature`` task id on accept. Will be replaced by
the Essential → story-task mapping (see BE-PR-008).
Kept in the DB column for read-only backward compat; new
code MUST NOT write to this field.
- ``feat_task_id`` — **DEPRECATED (BE-PR-010)**. Previously stored the single
generated ``story/feature`` task id on old-style accept.
Superseded by the Essential → story-task mapping via
``Task.source_proposal_id`` / ``Task.source_essential_id``
(see BE-PR-008).
**Compat strategy:**
- DB column is RETAINED for read-only backward compatibility.
- Existing rows that have a value will continue to expose it
via API responses (read-only).
- New code MUST NOT write to this field.
- Clients SHOULD migrate to ``generated_tasks`` on the
Proposal detail endpoint.
- Column will be dropped in a future migration once all
clients have migrated.
"""
__tablename__ = "proposes" # keep DB table name for compat
@@ -60,11 +70,14 @@ class Proposal(Base):
comment="Author of the proposal (nullable for legacy rows)",
)
# DEPRECATED — see class docstring. Read-only; will be removed once
# Essential-based accept (BE-PR-007 / BE-PR-008) is fully rolled out.
# DEPRECATED (BE-PR-010) — see class docstring for full compat strategy.
# Read-only; column retained for backward compat with legacy rows.
# New accept flow writes Task.source_proposal_id instead.
# Will be dropped in a future schema migration.
feat_task_id = Column(
String(64), nullable=True,
comment="DEPRECATED: id of the single story/feature task generated on old-style accept",
comment="DEPRECATED (BE-PR-010): legacy single story/feature task id. "
"Superseded by Task.source_proposal_id. Read-only; do not write.",
)
created_at = Column(DateTime(timezone=True), server_default=func.now())
@@ -78,6 +91,14 @@ class Proposal(Base):
lazy="select",
)
# BE-PR-008: reverse lookup — story tasks generated from this Proposal
generated_tasks = relationship(
"Task",
foreign_keys="Task.source_proposal_id",
lazy="select",
viewonly=True,
)
# ---- convenience alias ------------------------------------------------
@hybrid_property
def proposal_code(self) -> str | None:

View File

@@ -37,6 +37,17 @@ class Task(Base):
assignee_id = Column(Integer, ForeignKey("users.id"), nullable=True)
created_by_id = Column(Integer, ForeignKey("users.id"), nullable=True)
# Proposal Accept tracking (BE-PR-008)
# When a task is generated from Proposal Accept, these record the source.
source_proposal_id = Column(
Integer, ForeignKey("proposes.id"), nullable=True,
comment="Proposal that generated this task via accept (NULL if manually created)",
)
source_essential_id = Column(
Integer, ForeignKey("essentials.id"), nullable=True,
comment="Essential that generated this task via accept (NULL if manually created)",
)
# Tags (comma-separated)
tags = Column(String(500), nullable=True)

436
app/schemas/calendar.py Normal file
View File

@@ -0,0 +1,436 @@
"""Calendar-related Pydantic schemas.
BE-CAL-004: MinimumWorkload read/write schemas.
BE-CAL-API-001: TimeSlot create / response schemas.
BE-CAL-API-002: Calendar day-view query schemas.
BE-CAL-API-003: TimeSlot edit schemas.
BE-CAL-API-004: TimeSlot cancel schemas.
"""
from __future__ import annotations
from datetime import date as dt_date, time as dt_time, datetime as dt_datetime
from enum import Enum
from pydantic import BaseModel, Field, model_validator, field_validator
from typing import Optional
# ---------------------------------------------------------------------------
# MinimumWorkload
# ---------------------------------------------------------------------------
class WorkloadCategoryThresholds(BaseModel):
"""Minutes thresholds per slot category within a single period."""
work: int = Field(0, ge=0, le=65535, description="Minutes of work-type slots")
on_call: int = Field(0, ge=0, le=65535, description="Minutes of on-call-type slots")
entertainment: int = Field(0, ge=0, le=65535, description="Minutes of entertainment-type slots")
class MinimumWorkloadConfig(BaseModel):
"""Full workload configuration across all four periods."""
daily: WorkloadCategoryThresholds = Field(default_factory=WorkloadCategoryThresholds)
weekly: WorkloadCategoryThresholds = Field(default_factory=WorkloadCategoryThresholds)
monthly: WorkloadCategoryThresholds = Field(default_factory=WorkloadCategoryThresholds)
yearly: WorkloadCategoryThresholds = Field(default_factory=WorkloadCategoryThresholds)
class MinimumWorkloadUpdate(BaseModel):
"""Partial update — only provided periods/categories are overwritten.
Accepts the same shape as ``MinimumWorkloadConfig`` but every field
is optional so callers can PATCH individual periods.
"""
daily: Optional[WorkloadCategoryThresholds] = None
weekly: Optional[WorkloadCategoryThresholds] = None
monthly: Optional[WorkloadCategoryThresholds] = None
yearly: Optional[WorkloadCategoryThresholds] = None
class MinimumWorkloadResponse(BaseModel):
"""API response for workload configuration."""
user_id: int
config: MinimumWorkloadConfig
class Config:
from_attributes = True
# ---------------------------------------------------------------------------
# Workload warning (used by future calendar validation endpoints)
# ---------------------------------------------------------------------------
class WorkloadWarningItem(BaseModel):
"""A single workload warning returned alongside a calendar mutation."""
period: str = Field(..., description="daily | weekly | monthly | yearly")
category: str = Field(..., description="work | on_call | entertainment")
current_minutes: int = Field(..., ge=0, description="Current scheduled minutes in the period")
minimum_minutes: int = Field(..., ge=0, description="Configured minimum threshold")
shortfall_minutes: int = Field(..., ge=0, description="How many minutes below threshold")
message: str = Field(..., description="Human-readable warning")
# ---------------------------------------------------------------------------
# TimeSlot enums (mirror DB enums for schema layer)
# ---------------------------------------------------------------------------
class SlotTypeEnum(str, Enum):
WORK = "work"
ON_CALL = "on_call"
ENTERTAINMENT = "entertainment"
SYSTEM = "system"
class EventTypeEnum(str, Enum):
JOB = "job"
ENTERTAINMENT = "entertainment"
SYSTEM_EVENT = "system_event"
class SlotStatusEnum(str, Enum):
NOT_STARTED = "not_started"
ONGOING = "ongoing"
DEFERRED = "deferred"
SKIPPED = "skipped"
PAUSED = "paused"
FINISHED = "finished"
ABORTED = "aborted"
# ---------------------------------------------------------------------------
# TimeSlot create / response (BE-CAL-API-001)
# ---------------------------------------------------------------------------
class TimeSlotCreate(BaseModel):
"""Request body for creating a single calendar slot."""
date: Optional[dt_date] = Field(None, description="Target date (defaults to today)")
slot_type: SlotTypeEnum = Field(..., description="work | on_call | entertainment | system")
scheduled_at: dt_time = Field(..., description="Planned start time HH:MM (00:00-23:00)")
estimated_duration: int = Field(..., ge=1, le=50, description="Duration in minutes (1-50)")
event_type: Optional[EventTypeEnum] = Field(None, description="job | entertainment | system_event")
event_data: Optional[dict] = Field(None, description="Event details JSON")
priority: int = Field(0, ge=0, le=99, description="Priority 0-99")
@field_validator("scheduled_at")
@classmethod
def _validate_scheduled_at(cls, v: dt_time) -> dt_time:
if v.hour > 23:
raise ValueError("scheduled_at hour must be between 00 and 23")
return v
class SlotConflictItem(BaseModel):
"""Describes a single overlap conflict."""
conflicting_slot_id: Optional[int] = None
conflicting_virtual_id: Optional[str] = None
scheduled_at: str
estimated_duration: int
slot_type: str
message: str
class TimeSlotResponse(BaseModel):
"""Response for a single TimeSlot."""
id: int
user_id: int
date: dt_date
slot_type: str
estimated_duration: int
scheduled_at: str # HH:MM:SS ISO format
started_at: Optional[str] = None
attended: bool
actual_duration: Optional[int] = None
event_type: Optional[str] = None
event_data: Optional[dict] = None
priority: int
status: str
plan_id: Optional[int] = None
created_at: Optional[dt_datetime] = None
updated_at: Optional[dt_datetime] = None
class Config:
from_attributes = True
class TimeSlotCreateResponse(BaseModel):
"""Response after creating a slot — includes the slot and any warnings."""
slot: TimeSlotResponse
warnings: list[WorkloadWarningItem] = Field(default_factory=list)
# ---------------------------------------------------------------------------
# TimeSlot edit (BE-CAL-API-003)
# ---------------------------------------------------------------------------
class TimeSlotEdit(BaseModel):
"""Request body for editing a calendar slot.
All fields are optional — only provided fields are updated.
The caller must supply either ``slot_id`` (for real slots) or
``virtual_id`` (for plan-generated virtual slots) in the URL path.
"""
slot_type: Optional[SlotTypeEnum] = Field(None, description="New slot type")
scheduled_at: Optional[dt_time] = Field(None, description="New start time HH:MM")
estimated_duration: Optional[int] = Field(None, ge=1, le=50, description="New duration in minutes (1-50)")
event_type: Optional[EventTypeEnum] = Field(None, description="New event type")
event_data: Optional[dict] = Field(None, description="New event details JSON")
priority: Optional[int] = Field(None, ge=0, le=99, description="New priority 0-99")
@field_validator("scheduled_at")
@classmethod
def _validate_scheduled_at(cls, v: Optional[dt_time]) -> Optional[dt_time]:
if v is not None and v.hour > 23:
raise ValueError("scheduled_at hour must be between 00 and 23")
return v
@model_validator(mode="after")
def _at_least_one_field(self) -> "TimeSlotEdit":
"""Ensure at least one editable field is provided."""
if all(
getattr(self, f) is None
for f in ("slot_type", "scheduled_at", "estimated_duration",
"event_type", "event_data", "priority")
):
raise ValueError("At least one field must be provided for edit")
return self
class TimeSlotEditResponse(BaseModel):
"""Response after editing a slot — includes the updated slot and any warnings."""
slot: TimeSlotResponse
warnings: list[WorkloadWarningItem] = Field(default_factory=list)
# ---------------------------------------------------------------------------
# Calendar day-view query (BE-CAL-API-002)
# ---------------------------------------------------------------------------
class CalendarSlotItem(BaseModel):
"""Unified slot item for day-view — covers both real and virtual slots.
* For **real** (materialized) slots: ``id`` is set, ``virtual_id`` is None.
* For **virtual** (plan-generated) slots: ``id`` is None, ``virtual_id``
is the ``plan-{plan_id}-{date}`` identifier.
"""
id: Optional[int] = Field(None, description="Real slot DB id (None for virtual)")
virtual_id: Optional[str] = Field(None, description="Virtual slot id (None for real)")
user_id: int
date: dt_date
slot_type: str
estimated_duration: int
scheduled_at: str # HH:MM:SS ISO format
started_at: Optional[str] = None
attended: bool
actual_duration: Optional[int] = None
event_type: Optional[str] = None
event_data: Optional[dict] = None
priority: int
status: str
plan_id: Optional[int] = None
created_at: Optional[dt_datetime] = None
updated_at: Optional[dt_datetime] = None
class Config:
from_attributes = True
class CalendarDayResponse(BaseModel):
"""Response for a single-day calendar query."""
date: dt_date
user_id: int
slots: list[CalendarSlotItem] = Field(
default_factory=list,
description="All slots for the day, sorted by scheduled_at ascending",
)
# ---------------------------------------------------------------------------
# TimeSlot cancel (BE-CAL-API-004)
# ---------------------------------------------------------------------------
class TimeSlotCancelResponse(BaseModel):
"""Response after cancelling a slot — includes the cancelled slot."""
slot: TimeSlotResponse
message: str = Field("Slot cancelled successfully", description="Human-readable result")
# ---------------------------------------------------------------------------
# SchedulePlan enums (mirror DB enums)
# ---------------------------------------------------------------------------
class DayOfWeekEnum(str, Enum):
SUN = "sun"
MON = "mon"
TUE = "tue"
WED = "wed"
THU = "thu"
FRI = "fri"
SAT = "sat"
class MonthOfYearEnum(str, Enum):
JAN = "jan"
FEB = "feb"
MAR = "mar"
APR = "apr"
MAY = "may"
JUN = "jun"
JUL = "jul"
AUG = "aug"
SEP = "sep"
OCT = "oct"
NOV = "nov"
DEC = "dec"
# ---------------------------------------------------------------------------
# SchedulePlan create / response (BE-CAL-API-005)
# ---------------------------------------------------------------------------
class SchedulePlanCreate(BaseModel):
"""Request body for creating a recurring schedule plan."""
slot_type: SlotTypeEnum = Field(..., description="work | on_call | entertainment | system")
estimated_duration: int = Field(..., ge=1, le=50, description="Duration in minutes (1-50)")
at_time: dt_time = Field(..., description="Daily scheduled time (HH:MM)")
on_day: Optional[DayOfWeekEnum] = Field(None, description="Day of week (sun-sat)")
on_week: Optional[int] = Field(None, ge=1, le=4, description="Week of month (1-4)")
on_month: Optional[MonthOfYearEnum] = Field(None, description="Month (jan-dec)")
event_type: Optional[EventTypeEnum] = Field(None, description="job | entertainment | system_event")
event_data: Optional[dict] = Field(None, description="Event details JSON")
@field_validator("at_time")
@classmethod
def _validate_at_time(cls, v: dt_time) -> dt_time:
if v.hour > 23:
raise ValueError("at_time hour must be between 00 and 23")
return v
@model_validator(mode="after")
def _validate_hierarchy(self) -> "SchedulePlanCreate":
"""Enforce period-parameter hierarchy: on_month → on_week → on_day."""
if self.on_month is not None and self.on_week is None:
raise ValueError("on_month requires on_week to be set")
if self.on_week is not None and self.on_day is None:
raise ValueError("on_week requires on_day to be set")
return self
class SchedulePlanResponse(BaseModel):
"""Response for a single SchedulePlan."""
id: int
user_id: int
slot_type: str
estimated_duration: int
at_time: str # HH:MM:SS ISO format
on_day: Optional[str] = None
on_week: Optional[int] = None
on_month: Optional[str] = None
event_type: Optional[str] = None
event_data: Optional[dict] = None
is_active: bool
created_at: Optional[dt_datetime] = None
updated_at: Optional[dt_datetime] = None
class Config:
from_attributes = True
class SchedulePlanListResponse(BaseModel):
"""Response for listing schedule plans."""
plans: list[SchedulePlanResponse] = Field(default_factory=list)
# ---------------------------------------------------------------------------
# SchedulePlan edit / cancel (BE-CAL-API-006)
# ---------------------------------------------------------------------------
class SchedulePlanEdit(BaseModel):
"""Request body for editing a recurring schedule plan.
All fields are optional — only provided fields are updated.
Period-parameter hierarchy (on_month → on_week → on_day) is
validated after merging with existing plan values.
"""
slot_type: Optional[SlotTypeEnum] = Field(None, description="New slot type")
estimated_duration: Optional[int] = Field(None, ge=1, le=50, description="New duration in minutes (1-50)")
at_time: Optional[dt_time] = Field(None, description="New daily time (HH:MM)")
on_day: Optional[DayOfWeekEnum] = Field(None, description="New day of week (sun-sat), use 'clear' param to remove")
on_week: Optional[int] = Field(None, ge=1, le=4, description="New week of month (1-4), use 'clear' param to remove")
on_month: Optional[MonthOfYearEnum] = Field(None, description="New month (jan-dec), use 'clear' param to remove")
event_type: Optional[EventTypeEnum] = Field(None, description="New event type")
event_data: Optional[dict] = Field(None, description="New event details JSON")
clear_on_day: bool = Field(False, description="Clear on_day (set to NULL)")
clear_on_week: bool = Field(False, description="Clear on_week (set to NULL)")
clear_on_month: bool = Field(False, description="Clear on_month (set to NULL)")
@field_validator("at_time")
@classmethod
def _validate_at_time(cls, v: Optional[dt_time]) -> Optional[dt_time]:
if v is not None and v.hour > 23:
raise ValueError("at_time hour must be between 00 and 23")
return v
@model_validator(mode="after")
def _at_least_one_field(self) -> "SchedulePlanEdit":
"""Ensure at least one editable field or clear flag is provided."""
has_value = any(
getattr(self, f) is not None
for f in ("slot_type", "estimated_duration", "at_time", "on_day",
"on_week", "on_month", "event_type", "event_data")
)
has_clear = self.clear_on_day or self.clear_on_week or self.clear_on_month
if not has_value and not has_clear:
raise ValueError("At least one field must be provided for edit")
return self
class SchedulePlanCancelResponse(BaseModel):
"""Response after cancelling a plan."""
plan: SchedulePlanResponse
message: str = Field("Plan cancelled successfully", description="Human-readable result")
preserved_past_slot_ids: list[int] = Field(
default_factory=list,
description="IDs of past materialized slots that were NOT affected",
)
# ---------------------------------------------------------------------------
# Calendar date-list (BE-CAL-API-007)
# ---------------------------------------------------------------------------
class DateListResponse(BaseModel):
"""Response for the date-list endpoint.
Returns only dates that have at least one materialized (real) future
slot. Pure plan-generated (virtual) dates are excluded.
"""
dates: list[dt_date] = Field(
default_factory=list,
description="Sorted list of future dates with materialized slots",
)
# ---------------------------------------------------------------------------
# Agent heartbeat / agent-driven slot updates
# ---------------------------------------------------------------------------
class AgentHeartbeatResponse(BaseModel):
"""Slots that are due for a specific agent plus its current runtime status."""
slots: list[CalendarSlotItem] = Field(default_factory=list)
agent_status: str
message: Optional[str] = None
class SlotAgentUpdate(BaseModel):
"""Plugin-driven slot status update payload."""
status: SlotStatusEnum
started_at: Optional[dt_time] = None
actual_duration: Optional[int] = Field(None, ge=0, le=65535)
class AgentStatusUpdateRequest(BaseModel):
"""Plugin-driven agent status report."""
agent_id: str
claw_identifier: str
status: str
recovery_at: Optional[dt_datetime] = None
exhaust_reason: Optional[str] = None

View File

@@ -43,9 +43,7 @@ class TaskBase(BaseModel):
class TaskCreate(TaskBase):
project_id: Optional[int] = None
project_code: Optional[str] = None
milestone_id: Optional[int] = None
milestone_code: Optional[str] = None
reporter_id: Optional[int] = None
assignee_id: Optional[int] = None
@@ -75,15 +73,12 @@ class TaskUpdate(BaseModel):
class TaskResponse(TaskBase):
id: int
status: TaskStatusEnum
task_code: Optional[str] = None
code: Optional[str] = None
type: Optional[str] = None
due_date: Optional[datetime] = None
project_id: int
project_code: Optional[str] = None
milestone_id: int
milestone_code: Optional[str] = None
reporter_id: int
assignee_id: Optional[int] = None
@@ -93,6 +88,9 @@ class TaskResponse(TaskBase):
resolution_summary: Optional[str] = None
positions: Optional[str] = None
pending_matters: Optional[str] = None
# BE-PR-008: Proposal Accept tracking
source_proposal_code: Optional[str] = None
source_essential_code: Optional[str] = None
created_at: datetime
updated_at: Optional[datetime] = None
@@ -173,6 +171,10 @@ 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
class UserUpdate(BaseModel):
@@ -181,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):
@@ -189,6 +192,8 @@ class UserResponse(UserBase):
is_admin: bool
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:
@@ -252,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
@@ -278,7 +283,7 @@ class ProposalBase(BaseModel):
class ProposalCreate(ProposalBase):
project_id: Optional[int] = None
pass
class ProposalUpdate(BaseModel):
@@ -287,14 +292,13 @@ 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 — will be removed after BE-PR-008
feat_task_id: Optional[str] = None # DEPRECATED (BE-PR-010): legacy field, read-only. Use generated_tasks instead.
created_at: datetime
updated_at: Optional[datetime] = None
@@ -333,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
@@ -344,15 +347,86 @@ class EssentialResponse(EssentialBase):
from_attributes = True
class GeneratedTaskBrief(BaseModel):
"""Brief info about a story task generated from Proposal Accept."""
task_code: Optional[str] = None
task_type: str
task_subtype: Optional[str] = None
title: str
status: Optional[str] = None
source_essential_code: Optional[str] = None
class ProposalDetailResponse(ProposalResponse):
"""Extended Proposal response that embeds its Essential list."""
"""Extended Proposal response that embeds its Essential list and generated tasks."""
essentials: List[EssentialResponse] = []
generated_tasks: List[GeneratedTaskBrief] = []
class Config:
from_attributes = True
class GeneratedTaskSummary(BaseModel):
"""Brief summary of a task generated from a Proposal Essential."""
task_code: str
task_type: str
task_subtype: str
title: str
essential_code: str
class ProposalAcceptResponse(ProposalResponse):
"""Response for Proposal Accept — includes the generated story tasks."""
essentials: List[EssentialResponse] = []
generated_tasks: List[GeneratedTaskSummary] = []
class Config:
from_attributes = True
# ---------------------------------------------------------------------------
# Agent schemas (BE-CAL-003)
# ---------------------------------------------------------------------------
class AgentStatusEnum(str, Enum):
IDLE = "idle"
ON_CALL = "on_call"
BUSY = "busy"
EXHAUSTED = "exhausted"
OFFLINE = "offline"
class ExhaustReasonEnum(str, Enum):
RATE_LIMIT = "rate_limit"
BILLING = "billing"
class AgentResponse(BaseModel):
"""Read-only representation of an Agent."""
id: int
user_id: int
agent_id: str
claw_identifier: str
status: AgentStatusEnum
last_heartbeat: Optional[datetime] = None
exhausted_at: Optional[datetime] = None
recovery_at: Optional[datetime] = None
exhaust_reason: Optional[ExhaustReasonEnum] = None
created_at: datetime
class Config:
from_attributes = True
class AgentStatusUpdate(BaseModel):
"""Payload for updating an agent's runtime status."""
status: AgentStatusEnum
exhaust_reason: Optional[ExhaustReasonEnum] = None
recovery_at: Optional[datetime] = None
# Backward-compatible aliases
ProposeStatusEnum = ProposalStatusEnum
ProposeBase = ProposalBase

View File

@@ -0,0 +1,121 @@
"""Agent heartbeat — query pending slots for execution.
BE-AGT-001: Service layer that the plugin heartbeat endpoint calls to
discover which TimeSlots are ready to be executed by an agent.
Design reference: NEXT_WAVE_DEV_DIRECTION.md §6.1 (Heartbeat flow)
Filtering rules:
1. Only slots for **today** are considered.
2. Only slots with status ``NotStarted`` or ``Deferred``.
3. Only slots whose ``scheduled_at`` time has already passed (i.e. the
slot's scheduled start is at or before the current time).
4. Results are sorted by **priority descending** (higher = more urgent).
The caller (heartbeat API endpoint) receives a list of actionable slots
and decides how to dispatch them to the agent based on agent status.
"""
from __future__ import annotations
from datetime import date, datetime, time, timezone
from typing import Sequence
from sqlalchemy import and_, case
from sqlalchemy.orm import Session
from app.models.calendar import SlotStatus, TimeSlot
from app.services.plan_slot import (
get_virtual_slots_for_date,
materialize_all_for_date,
)
# Statuses that are eligible for heartbeat pickup
_ACTIONABLE_STATUSES = {SlotStatus.NOT_STARTED, SlotStatus.DEFERRED}
def get_pending_slots_for_agent(
db: Session,
user_id: int,
*,
now: datetime | None = None,
) -> list[TimeSlot]:
"""Return today's actionable slots that are due for execution.
Parameters
----------
db : Session
SQLAlchemy database session.
user_id : int
The HarborForge user id linked to the agent.
now : datetime, optional
Override "current time" for testing. Defaults to ``datetime.now(timezone.utc)``.
Returns
-------
list[TimeSlot]
Materialized TimeSlot rows sorted by priority descending (highest first).
Only includes slots where ``scheduled_at <= current_time`` and status
is ``NotStarted`` or ``Deferred``.
"""
if now is None:
now = datetime.now(timezone.utc)
today = now.date() if isinstance(now, datetime) else now
current_time: time = now.time() if isinstance(now, datetime) else now
# --- Step 1: Ensure today's plan-based slots are materialized ----------
# The heartbeat is often the first touch of the day, so we materialize
# all plan-generated virtual slots for today before querying. This is
# idempotent — already-materialized plans are skipped.
materialize_all_for_date(db, user_id, today)
db.flush()
# --- Step 2: Query real (materialized) slots ---------------------------
actionable_status_values = [s.value for s in _ACTIONABLE_STATUSES]
slots: list[TimeSlot] = (
db.query(TimeSlot)
.filter(
TimeSlot.user_id == user_id,
TimeSlot.date == today,
TimeSlot.status.in_(actionable_status_values),
TimeSlot.scheduled_at <= current_time,
)
.order_by(TimeSlot.priority.desc())
.all()
)
return slots
def get_pending_slot_count(
db: Session,
user_id: int,
*,
now: datetime | None = None,
) -> int:
"""Return the count of today's actionable slots that are due.
Lighter alternative to :func:`get_pending_slots_for_agent` when only
the count is needed (e.g. quick heartbeat status check).
"""
if now is None:
now = datetime.now(timezone.utc)
today = now.date() if isinstance(now, datetime) else now
current_time: time = now.time() if isinstance(now, datetime) else now
actionable_status_values = [s.value for s in _ACTIONABLE_STATUSES]
return (
db.query(TimeSlot.id)
.filter(
TimeSlot.user_id == user_id,
TimeSlot.date == today,
TimeSlot.status.in_(actionable_status_values),
TimeSlot.scheduled_at <= current_time,
)
.count()
)

View File

@@ -0,0 +1,364 @@
"""Agent status transitions — BE-AGT-002.
Implements the state machine for Agent runtime status:
Idle ──→ Busy (woken by a Work slot)
Idle ──→ OnCall (woken by an OnCall slot)
Busy ──→ Idle (task finished / no more pending slots)
OnCall──→ Idle (task finished / no more pending slots)
* ──→ Offline (heartbeat timeout — no heartbeat for > 2 min)
* ──→ Exhausted (API quota / rate-limit error)
Exhausted → Idle (recovery_at reached)
Design reference: NEXT_WAVE_DEV_DIRECTION.md §6.4 (Status transitions)
"""
from __future__ import annotations
from datetime import datetime, timedelta, timezone
from email.utils import parsedate_to_datetime
import re
from typing import Mapping, Optional
from sqlalchemy.orm import Session
from app.models.agent import Agent, AgentStatus, ExhaustReason
from app.models.calendar import SlotType
# Heartbeat timeout threshold in seconds (2 minutes per spec §6.4)
HEARTBEAT_TIMEOUT_SECONDS = 120
# Default recovery duration when we can't parse a retry-after header
DEFAULT_RECOVERY_HOURS = 5
# Fallback wording patterns commonly emitted by model providers / gateways.
_RESET_IN_PATTERN = re.compile(
r"(?:reset(?:s)?|retry)(?:\s+again)?\s+(?:in|after)\s+(?P<value>\d+)\s*(?P<unit>seconds?|secs?|s|minutes?|mins?|m|hours?|hrs?|h)",
re.IGNORECASE,
)
_RESET_AT_ISO_PATTERN = re.compile(
r"resets?\s+at\s+(?P<ts>\d{4}-\d{2}-\d{2}[tT ][^\s,;]+(?:Z|[+-]\d{2}:?\d{2})?)",
re.IGNORECASE,
)
_RESET_AT_GENERIC_PATTERN = re.compile(
r"resets?\s+at\s+(?P<ts>[^\n]+?)(?:[.,;]|$)",
re.IGNORECASE,
)
# ---------------------------------------------------------------------------
# Transition helpers
# ---------------------------------------------------------------------------
class AgentStatusError(Exception):
"""Raised when a requested status transition is invalid."""
def _assert_current(agent: Agent, *expected: AgentStatus) -> None:
"""Raise if the agent is not in one of the expected statuses."""
if agent.status not in expected:
allowed = ", ".join(s.value for s in expected)
raise AgentStatusError(
f"Agent '{agent.agent_id}' is {agent.status.value}; "
f"expected one of [{allowed}]"
)
def _to_utc(dt: datetime) -> datetime:
"""Normalize aware / naive datetimes to UTC-aware timestamps."""
if dt.tzinfo is None:
return dt.replace(tzinfo=timezone.utc)
return dt.astimezone(timezone.utc)
def _duration_from_match(value: str, unit: str) -> timedelta:
"""Convert a parsed numeric duration to ``timedelta``."""
amount = int(value)
unit_normalized = unit.lower()
if unit_normalized.startswith(("second", "sec")) or unit_normalized == "s":
return timedelta(seconds=amount)
if unit_normalized.startswith(("minute", "min")) or unit_normalized == "m":
return timedelta(minutes=amount)
if unit_normalized.startswith(("hour", "hr")) or unit_normalized == "h":
return timedelta(hours=amount)
raise ValueError(f"Unsupported duration unit: {unit}")
def parse_exhausted_recovery_at(
*,
now: datetime | None = None,
headers: Mapping[str, str] | None = None,
message: str | None = None,
) -> datetime:
"""Infer the next recovery time for an exhausted agent.
Parsing order follows the design intent in NEXT_WAVE_DEV_DIRECTION.md §6.5:
1. ``Retry-After`` response header
- integer seconds
- HTTP-date
2. Error text like ``reset in 12 mins`` / ``retry after 30 seconds``
3. Error text like ``resets at 2026-04-01T10:00:00Z``
4. Fallback to ``now + DEFAULT_RECOVERY_HOURS``
"""
if now is None:
now = datetime.now(timezone.utc)
now = _to_utc(now)
normalized_headers = {k.lower(): v for k, v in (headers or {}).items()}
retry_after = normalized_headers.get("retry-after")
if retry_after:
retry_after = retry_after.strip()
if retry_after.isdigit():
return now + timedelta(seconds=int(retry_after))
try:
return _to_utc(parsedate_to_datetime(retry_after))
except (TypeError, ValueError, IndexError, OverflowError):
pass
if message:
duration_match = _RESET_IN_PATTERN.search(message)
if duration_match:
return now + _duration_from_match(
duration_match.group("value"),
duration_match.group("unit"),
)
iso_match = _RESET_AT_ISO_PATTERN.search(message)
if iso_match:
ts = iso_match.group("ts")
normalized_ts = ts.replace(" ", "T")
if normalized_ts.endswith("Z"):
normalized_ts = normalized_ts[:-1] + "+00:00"
try:
return _to_utc(datetime.fromisoformat(normalized_ts))
except ValueError:
pass
generic_match = _RESET_AT_GENERIC_PATTERN.search(message)
if generic_match:
ts = generic_match.group("ts").strip()
try:
return _to_utc(parsedate_to_datetime(ts))
except (TypeError, ValueError, IndexError, OverflowError):
pass
return now + timedelta(hours=DEFAULT_RECOVERY_HOURS)
# ---------------------------------------------------------------------------
# Public API
# ---------------------------------------------------------------------------
def transition_to_busy(
db: Session,
agent: Agent,
*,
slot_type: SlotType,
now: datetime | None = None,
) -> Agent:
"""Idle → Busy or OnCall depending on *slot_type*.
Parameters
----------
slot_type : SlotType
The type of the slot that triggered the wakeup.
``SlotType.ON_CALL`` → ``AgentStatus.ON_CALL``, everything else
→ ``AgentStatus.BUSY``.
"""
_assert_current(agent, AgentStatus.IDLE)
if slot_type == SlotType.ON_CALL:
agent.status = AgentStatus.ON_CALL
else:
agent.status = AgentStatus.BUSY
if now is None:
now = datetime.now(timezone.utc)
agent.last_heartbeat = now
db.flush()
return agent
def transition_to_idle(
db: Session,
agent: Agent,
*,
now: datetime | None = None,
) -> Agent:
"""Busy / OnCall / Exhausted (recovered) → Idle.
For Exhausted agents this should only be called when ``recovery_at``
has been reached; the caller is responsible for checking that.
"""
_assert_current(
agent,
AgentStatus.BUSY,
AgentStatus.ON_CALL,
AgentStatus.EXHAUSTED,
AgentStatus.OFFLINE,
)
agent.status = AgentStatus.IDLE
# Clear exhausted metadata if transitioning out of Exhausted
agent.exhausted_at = None
agent.recovery_at = None
agent.exhaust_reason = None
if now is None:
now = datetime.now(timezone.utc)
agent.last_heartbeat = now
db.flush()
return agent
def transition_to_offline(
db: Session,
agent: Agent,
) -> Agent:
"""Any status → Offline (heartbeat timeout).
Typically called by a background check that detects
``last_heartbeat`` is older than ``HEARTBEAT_TIMEOUT_SECONDS``.
"""
# Already offline — no-op
if agent.status == AgentStatus.OFFLINE:
return agent
agent.status = AgentStatus.OFFLINE
db.flush()
return agent
def transition_to_exhausted(
db: Session,
agent: Agent,
*,
reason: ExhaustReason,
recovery_at: datetime | None = None,
headers: Mapping[str, str] | None = None,
message: str | None = None,
now: datetime | None = None,
) -> Agent:
"""Any active status → Exhausted (API quota error).
Parameters
----------
reason : ExhaustReason
``RATE_LIMIT`` or ``BILLING``.
recovery_at : datetime, optional
Explicit recovery timestamp. If omitted, attempts to parse from
``headers`` / ``message``; falls back to ``now + DEFAULT_RECOVERY_HOURS``.
headers : Mapping[str, str], optional
Response headers that may contain ``Retry-After``.
message : str, optional
Error text that may contain ``reset in`` / ``retry after`` /
``resets at`` hints.
"""
if now is None:
now = datetime.now(timezone.utc)
now = _to_utc(now)
agent.status = AgentStatus.EXHAUSTED
agent.exhausted_at = now
agent.exhaust_reason = reason
if recovery_at is not None:
agent.recovery_at = _to_utc(recovery_at)
else:
agent.recovery_at = parse_exhausted_recovery_at(
now=now,
headers=headers,
message=message,
)
db.flush()
return agent
# ---------------------------------------------------------------------------
# Heartbeat-driven checks
# ---------------------------------------------------------------------------
def check_heartbeat_timeout(
db: Session,
agent: Agent,
*,
now: datetime | None = None,
) -> bool:
"""Mark agent Offline if heartbeat has timed out.
Returns ``True`` if the agent was transitioned to Offline.
"""
if agent.status == AgentStatus.OFFLINE:
return False
if now is None:
now = datetime.now(timezone.utc)
if agent.last_heartbeat is None:
# Never sent a heartbeat — treat as offline
transition_to_offline(db, agent)
return True
elapsed = (now - agent.last_heartbeat).total_seconds()
if elapsed > HEARTBEAT_TIMEOUT_SECONDS:
transition_to_offline(db, agent)
return True
return False
def check_exhausted_recovery(
db: Session,
agent: Agent,
*,
now: datetime | None = None,
) -> bool:
"""Recover an Exhausted agent if ``recovery_at`` has been reached.
Returns ``True`` if the agent was transitioned back to Idle.
"""
if agent.status != AgentStatus.EXHAUSTED:
return False
if now is None:
now = datetime.now(timezone.utc)
if agent.recovery_at is not None and now >= agent.recovery_at:
transition_to_idle(db, agent, now=now)
return True
return False
def record_heartbeat(
db: Session,
agent: Agent,
*,
now: datetime | None = None,
) -> Agent:
"""Update ``last_heartbeat`` timestamp.
If the agent was Offline and a heartbeat arrives, transition back to
Idle (the agent has come back online).
"""
if now is None:
now = datetime.now(timezone.utc)
agent.last_heartbeat = now
if agent.status == AgentStatus.OFFLINE:
agent.status = AgentStatus.IDLE
# Clear any stale exhausted metadata
agent.exhausted_at = None
agent.recovery_at = None
agent.exhaust_reason = None
db.flush()
return agent

View File

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

View File

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

View File

@@ -0,0 +1,318 @@
"""MinimumWorkload service — CRUD, workload computation and validation.
BE-CAL-004: user-level workload config read/write.
BE-CAL-007: workload warning rules — compute actual scheduled minutes across
daily/weekly/monthly/yearly periods and compare against thresholds.
"""
from __future__ import annotations
import copy
from datetime import date, timedelta
from typing import Optional
from sqlalchemy import func as sa_func
from sqlalchemy.orm import Session
from app.models.calendar import SlotStatus, SlotType, TimeSlot
from app.models.minimum_workload import (
DEFAULT_WORKLOAD_CONFIG,
CATEGORIES,
PERIODS,
MinimumWorkload,
)
from app.schemas.calendar import (
MinimumWorkloadConfig,
MinimumWorkloadUpdate,
WorkloadWarningItem,
)
from app.services.plan_slot import get_virtual_slots_for_date
# Slot types that map to workload categories. "system" is excluded.
_SLOT_TYPE_TO_CATEGORY = {
SlotType.WORK: "work",
SlotType.ON_CALL: "on_call",
SlotType.ENTERTAINMENT: "entertainment",
}
# Statuses that should NOT count towards workload (cancelled / failed slots).
_EXCLUDED_STATUSES = {SlotStatus.SKIPPED, SlotStatus.ABORTED}
# ---------------------------------------------------------------------------
# Read
# ---------------------------------------------------------------------------
def get_workload_config(db: Session, user_id: int) -> dict:
"""Return the raw config dict for *user_id*, falling back to defaults."""
row = db.query(MinimumWorkload).filter(MinimumWorkload.user_id == user_id).first()
if row is None:
return copy.deepcopy(DEFAULT_WORKLOAD_CONFIG)
return row.config
def get_workload_row(db: Session, user_id: int) -> Optional[MinimumWorkload]:
"""Return the ORM row or None."""
return db.query(MinimumWorkload).filter(MinimumWorkload.user_id == user_id).first()
# ---------------------------------------------------------------------------
# Write (upsert)
# ---------------------------------------------------------------------------
def upsert_workload_config(
db: Session,
user_id: int,
update: MinimumWorkloadUpdate,
) -> MinimumWorkload:
"""Create or update the workload config for *user_id*.
Only the periods present in *update* are overwritten; the rest keep
their current (or default) values.
"""
row = db.query(MinimumWorkload).filter(MinimumWorkload.user_id == user_id).first()
if row is None:
row = MinimumWorkload(
user_id=user_id,
config=copy.deepcopy(DEFAULT_WORKLOAD_CONFIG),
)
db.add(row)
# Merge provided periods into existing config
current = copy.deepcopy(row.config) if row.config else copy.deepcopy(DEFAULT_WORKLOAD_CONFIG)
for period in PERIODS:
period_data = getattr(update, period, None)
if period_data is not None:
current[period] = period_data.model_dump()
# Ensure JSON column is flagged as dirty for SQLAlchemy
row.config = current
db.flush()
return row
def replace_workload_config(
db: Session,
user_id: int,
config: MinimumWorkloadConfig,
) -> MinimumWorkload:
"""Full replace of the workload config for *user_id*."""
row = db.query(MinimumWorkload).filter(MinimumWorkload.user_id == user_id).first()
if row is None:
row = MinimumWorkload(user_id=user_id, config=config.model_dump())
db.add(row)
else:
row.config = config.model_dump()
db.flush()
return row
# ---------------------------------------------------------------------------
# Workload computation (BE-CAL-007)
# ---------------------------------------------------------------------------
def _date_range_for_period(
period: str,
reference_date: date,
) -> tuple[date, date]:
"""Return inclusive ``(start, end)`` date bounds for *period* containing *reference_date*.
- daily → just the reference date itself
- weekly → ISO week (MonSun) containing the reference date
- monthly → calendar month containing the reference date
- yearly → calendar year containing the reference date
"""
if period == "daily":
return reference_date, reference_date
if period == "weekly":
# ISO weekday: Monday=1 … Sunday=7
start = reference_date - timedelta(days=reference_date.weekday()) # Monday
end = start + timedelta(days=6) # Sunday
return start, end
if period == "monthly":
start = reference_date.replace(day=1)
# Last day of month
if reference_date.month == 12:
end = reference_date.replace(month=12, day=31)
else:
end = reference_date.replace(month=reference_date.month + 1, day=1) - timedelta(days=1)
return start, end
if period == "yearly":
start = reference_date.replace(month=1, day=1)
end = reference_date.replace(month=12, day=31)
return start, end
raise ValueError(f"Unknown period: {period}")
def _sum_real_slots(
db: Session,
user_id: int,
start_date: date,
end_date: date,
) -> dict[str, int]:
"""Sum ``estimated_duration`` of real (materialized) slots by category.
Returns ``{"work": N, "on_call": N, "entertainment": N}`` with minutes.
Slots with status in ``_EXCLUDED_STATUSES`` or ``slot_type=system`` are skipped.
"""
excluded = [s.value for s in _EXCLUDED_STATUSES]
rows = (
db.query(
TimeSlot.slot_type,
sa_func.coalesce(sa_func.sum(TimeSlot.estimated_duration), 0),
)
.filter(
TimeSlot.user_id == user_id,
TimeSlot.date >= start_date,
TimeSlot.date <= end_date,
TimeSlot.status.notin_(excluded),
TimeSlot.slot_type != SlotType.SYSTEM.value,
)
.group_by(TimeSlot.slot_type)
.all()
)
totals: dict[str, int] = {"work": 0, "on_call": 0, "entertainment": 0}
for slot_type_val, total in rows:
# slot_type_val may be an enum or a raw string
if hasattr(slot_type_val, "value"):
slot_type_val = slot_type_val.value
cat = _SLOT_TYPE_TO_CATEGORY.get(SlotType(slot_type_val))
if cat:
totals[cat] += int(total)
return totals
def _sum_virtual_slots(
db: Session,
user_id: int,
start_date: date,
end_date: date,
) -> dict[str, int]:
"""Sum ``estimated_duration`` of virtual (plan-generated, not-yet-materialized)
slots by category across a date range.
Iterates day by day — acceptable because periods are at most a year and
the function only queries plans once per day.
"""
totals: dict[str, int] = {"work": 0, "on_call": 0, "entertainment": 0}
current = start_date
while current <= end_date:
for vs in get_virtual_slots_for_date(db, user_id, current):
slot_type = vs["slot_type"]
if hasattr(slot_type, "value"):
slot_type = slot_type.value
cat = _SLOT_TYPE_TO_CATEGORY.get(SlotType(slot_type))
if cat:
totals[cat] += vs["estimated_duration"]
current += timedelta(days=1)
return totals
def compute_scheduled_minutes(
db: Session,
user_id: int,
reference_date: date,
) -> dict[str, dict[str, int]]:
"""Compute total scheduled minutes for each period containing *reference_date*.
Returns the canonical shape consumed by :func:`check_workload_warnings`::
{
"daily": {"work": N, "on_call": N, "entertainment": N},
"weekly": { ... },
"monthly": { ... },
"yearly": { ... },
}
Includes both real (materialized) and virtual (plan-generated) slots.
"""
result: dict[str, dict[str, int]] = {}
for period in PERIODS:
start, end = _date_range_for_period(period, reference_date)
real = _sum_real_slots(db, user_id, start, end)
virtual = _sum_virtual_slots(db, user_id, start, end)
result[period] = {
cat: real.get(cat, 0) + virtual.get(cat, 0)
for cat in CATEGORIES
}
return result
# ---------------------------------------------------------------------------
# Warning comparison
# ---------------------------------------------------------------------------
def check_workload_warnings(
db: Session,
user_id: int,
scheduled_minutes: dict[str, dict[str, int]],
) -> list[WorkloadWarningItem]:
"""Compare *scheduled_minutes* against the user's configured thresholds.
``scheduled_minutes`` has the same shape as the config::
{"daily": {"work": N, ...}, "weekly": {...}, ...}
Returns a list of warnings for every (period, category) where the
scheduled total is below the minimum. An empty list means no warnings.
"""
config = get_workload_config(db, user_id)
warnings: list[WorkloadWarningItem] = []
for period in PERIODS:
cfg_period = config.get(period, {})
sch_period = scheduled_minutes.get(period, {})
for cat in CATEGORIES:
minimum = cfg_period.get(cat, 0)
if minimum <= 0:
continue
current = sch_period.get(cat, 0)
if current < minimum:
shortfall = minimum - current
warnings.append(WorkloadWarningItem(
period=period,
category=cat,
current_minutes=current,
minimum_minutes=minimum,
shortfall_minutes=shortfall,
message=(
f"{period.capitalize()} {cat.replace('_', '-')} workload "
f"is {current} min, below minimum of {minimum} min "
f"(shortfall: {shortfall} min)"
),
))
return warnings
# ---------------------------------------------------------------------------
# High-level convenience: compute + check in one call (BE-CAL-007)
# ---------------------------------------------------------------------------
def get_workload_warnings_for_date(
db: Session,
user_id: int,
reference_date: date,
) -> list[WorkloadWarningItem]:
"""One-shot helper: compute scheduled minutes for *reference_date* and
return any workload warnings.
Calendar API endpoints should call this after a create/edit mutation to
include warnings in the response. Warnings are advisory — they do NOT
prevent the operation.
"""
scheduled = compute_scheduled_minutes(db, user_id, reference_date)
return check_workload_warnings(db, user_id, scheduled)

232
app/services/overlap.py Normal file
View File

@@ -0,0 +1,232 @@
"""Calendar overlap detection service.
BE-CAL-006: Validates that a new or edited TimeSlot does not overlap with
existing slots on the same day for the same user.
Overlap is defined as two time ranges ``[start, start + duration)`` having
a non-empty intersection. Cancelled/aborted slots are excluded from
conflict checks (they no longer occupy calendar time).
For the **create** scenario, all existing non-cancelled slots on the target
date are checked.
For the **edit** scenario, the slot being edited is excluded from the
candidate set so it doesn't conflict with its own previous position.
"""
from __future__ import annotations
from datetime import date, time, timedelta, datetime
from typing import Optional
from sqlalchemy.orm import Session
from app.models.calendar import SlotStatus, TimeSlot
from app.services.plan_slot import get_virtual_slots_for_date
# Statuses that no longer occupy calendar time — excluded from overlap checks.
_INACTIVE_STATUSES = {SlotStatus.SKIPPED, SlotStatus.ABORTED}
# ---------------------------------------------------------------------------
# Internal helpers
# ---------------------------------------------------------------------------
def _time_to_minutes(t: time) -> int:
"""Convert a ``time`` to minutes since midnight."""
return t.hour * 60 + t.minute
def _ranges_overlap(
start_a: int,
end_a: int,
start_b: int,
end_b: int,
) -> bool:
"""Return *True* if two half-open intervals ``[a, a+dur)`` overlap."""
return start_a < end_b and start_b < end_a
# ---------------------------------------------------------------------------
# Conflict data class
# ---------------------------------------------------------------------------
class SlotConflict:
"""Describes a single overlap conflict."""
__slots__ = ("conflicting_slot_id", "conflicting_virtual_id",
"scheduled_at", "estimated_duration", "slot_type", "message")
def __init__(
self,
*,
conflicting_slot_id: Optional[int] = None,
conflicting_virtual_id: Optional[str] = None,
scheduled_at: time,
estimated_duration: int,
slot_type: str,
message: str,
):
self.conflicting_slot_id = conflicting_slot_id
self.conflicting_virtual_id = conflicting_virtual_id
self.scheduled_at = scheduled_at
self.estimated_duration = estimated_duration
self.slot_type = slot_type
self.message = message
def to_dict(self) -> dict:
d: dict = {
"scheduled_at": self.scheduled_at.isoformat(),
"estimated_duration": self.estimated_duration,
"slot_type": self.slot_type,
"message": self.message,
}
if self.conflicting_slot_id is not None:
d["conflicting_slot_id"] = self.conflicting_slot_id
if self.conflicting_virtual_id is not None:
d["conflicting_virtual_id"] = self.conflicting_virtual_id
return d
# ---------------------------------------------------------------------------
# Core overlap detection
# ---------------------------------------------------------------------------
def _format_time_range(start: time, duration: int) -> str:
"""Format a slot time range for human-readable messages."""
start_min = _time_to_minutes(start)
end_min = start_min + duration
end_h, end_m = divmod(end_min, 60)
# Clamp to 23:59 for display purposes
if end_h >= 24:
end_h, end_m = 23, 59
return f"{start.strftime('%H:%M')}-{end_h:02d}:{end_m:02d}"
def check_overlap(
db: Session,
user_id: int,
target_date: date,
scheduled_at: time,
estimated_duration: int,
*,
exclude_slot_id: Optional[int] = None,
) -> list[SlotConflict]:
"""Check for time conflicts on *target_date* for *user_id*.
Parameters
----------
db :
Active database session.
user_id :
The user whose calendar is being checked.
target_date :
The date to check.
scheduled_at :
Proposed start time.
estimated_duration :
Proposed duration in minutes.
exclude_slot_id :
If editing an existing slot, pass its ``id`` so it is not counted
as conflicting with itself.
Returns
-------
list[SlotConflict]
Empty list means no conflicts. Non-empty means the proposed slot
overlaps with one or more existing slots.
"""
new_start = _time_to_minutes(scheduled_at)
new_end = new_start + estimated_duration
conflicts: list[SlotConflict] = []
# ---- 1. Check real (materialized) slots --------------------------------
query = (
db.query(TimeSlot)
.filter(
TimeSlot.user_id == user_id,
TimeSlot.date == target_date,
TimeSlot.status.notin_([s.value for s in _INACTIVE_STATUSES]),
)
)
if exclude_slot_id is not None:
query = query.filter(TimeSlot.id != exclude_slot_id)
existing_slots: list[TimeSlot] = query.all()
for slot in existing_slots:
slot_start = _time_to_minutes(slot.scheduled_at)
slot_end = slot_start + slot.estimated_duration
if _ranges_overlap(new_start, new_end, slot_start, slot_end):
existing_range = _format_time_range(slot.scheduled_at, slot.estimated_duration)
proposed_range = _format_time_range(scheduled_at, estimated_duration)
conflicts.append(SlotConflict(
conflicting_slot_id=slot.id,
scheduled_at=slot.scheduled_at,
estimated_duration=slot.estimated_duration,
slot_type=slot.slot_type.value if hasattr(slot.slot_type, 'value') else str(slot.slot_type),
message=(
f"Proposed slot {proposed_range} overlaps with existing "
f"{slot.slot_type.value if hasattr(slot.slot_type, 'value') else slot.slot_type} "
f"slot (id={slot.id}) at {existing_range}"
),
))
# ---- 2. Check virtual (plan-generated) slots ---------------------------
virtual_slots = get_virtual_slots_for_date(db, user_id, target_date)
for vs in virtual_slots:
vs_start = _time_to_minutes(vs["scheduled_at"])
vs_end = vs_start + vs["estimated_duration"]
if _ranges_overlap(new_start, new_end, vs_start, vs_end):
existing_range = _format_time_range(vs["scheduled_at"], vs["estimated_duration"])
proposed_range = _format_time_range(scheduled_at, estimated_duration)
slot_type_val = vs["slot_type"].value if hasattr(vs["slot_type"], 'value') else str(vs["slot_type"])
conflicts.append(SlotConflict(
conflicting_virtual_id=vs["virtual_id"],
scheduled_at=vs["scheduled_at"],
estimated_duration=vs["estimated_duration"],
slot_type=slot_type_val,
message=(
f"Proposed slot {proposed_range} overlaps with virtual plan "
f"slot ({vs['virtual_id']}) at {existing_range}"
),
))
return conflicts
# ---------------------------------------------------------------------------
# Convenience wrappers for create / edit scenarios
# ---------------------------------------------------------------------------
def check_overlap_for_create(
db: Session,
user_id: int,
target_date: date,
scheduled_at: time,
estimated_duration: int,
) -> list[SlotConflict]:
"""Check overlap when creating a brand-new slot (no exclusion)."""
return check_overlap(
db, user_id, target_date, scheduled_at, estimated_duration,
)
def check_overlap_for_edit(
db: Session,
user_id: int,
slot_id: int,
target_date: date,
scheduled_at: time,
estimated_duration: int,
) -> list[SlotConflict]:
"""Check overlap when editing an existing slot (exclude itself)."""
return check_overlap(
db, user_id, target_date, scheduled_at, estimated_duration,
exclude_slot_id=slot_id,
)

329
app/services/plan_slot.py Normal file
View File

@@ -0,0 +1,329 @@
"""Plan virtual-slot identification and materialization.
BE-CAL-005: Implements the ``plan-{plan_id}-{date}`` virtual slot ID scheme,
matching logic to determine which plans fire on a given date, and
materialization (converting a virtual slot into a real TimeSlot row).
Design references:
- NEXT_WAVE_DEV_DIRECTION.md §2 (Slot ID策略)
- NEXT_WAVE_DEV_DIRECTION.md §3 (存储与缓存策略)
Key rules:
1. A virtual slot is identified by ``plan-{plan_id}-{YYYY-MM-DD}``.
2. A plan matches a date if all its period parameters (on_month, on_week,
on_day, at_time) align with that date.
3. A virtual slot is **not** generated for a date if a materialized
TimeSlot already exists for that (plan_id, date) pair.
4. Materialization creates a real TimeSlot row from the plan template and
returns it.
5. After edit/cancel of a materialized slot, ``plan_id`` is set to NULL so
the plan no longer "claims" that date — but the row persists.
"""
from __future__ import annotations
import calendar as _cal
import re
from datetime import date, datetime, time
from typing import Optional
from sqlalchemy.orm import Session
from app.models.calendar import (
DayOfWeek,
MonthOfYear,
SchedulePlan,
SlotStatus,
TimeSlot,
)
# ---------------------------------------------------------------------------
# Virtual-slot identifier helpers
# ---------------------------------------------------------------------------
_VIRTUAL_ID_RE = re.compile(r"^plan-(\d+)-(\d{4}-\d{2}-\d{2})$")
def make_virtual_slot_id(plan_id: int, slot_date: date) -> str:
"""Build the canonical virtual-slot identifier string."""
return f"plan-{plan_id}-{slot_date.isoformat()}"
def parse_virtual_slot_id(virtual_id: str) -> tuple[int, date] | None:
"""Parse ``plan-{plan_id}-{YYYY-MM-DD}`` → ``(plan_id, date)`` or *None*."""
m = _VIRTUAL_ID_RE.match(virtual_id)
if m is None:
return None
plan_id = int(m.group(1))
slot_date = date.fromisoformat(m.group(2))
return plan_id, slot_date
# ---------------------------------------------------------------------------
# Plan-date matching
# ---------------------------------------------------------------------------
# Mapping from DayOfWeek enum to Python weekday (Mon=0 … Sun=6)
_DOW_TO_WEEKDAY = {
DayOfWeek.MON: 0,
DayOfWeek.TUE: 1,
DayOfWeek.WED: 2,
DayOfWeek.THU: 3,
DayOfWeek.FRI: 4,
DayOfWeek.SAT: 5,
DayOfWeek.SUN: 6,
}
# Mapping from MonthOfYear enum to calendar month number
_MOY_TO_MONTH = {
MonthOfYear.JAN: 1,
MonthOfYear.FEB: 2,
MonthOfYear.MAR: 3,
MonthOfYear.APR: 4,
MonthOfYear.MAY: 5,
MonthOfYear.JUN: 6,
MonthOfYear.JUL: 7,
MonthOfYear.AUG: 8,
MonthOfYear.SEP: 9,
MonthOfYear.OCT: 10,
MonthOfYear.NOV: 11,
MonthOfYear.DEC: 12,
}
def _week_of_month(d: date) -> int:
"""Return the 1-based week-of-month for *d*.
Week 1 contains the first occurrence of the same weekday in that month.
For example, if the month starts on Wednesday:
- Wed 1st → week 1
- Wed 8th → week 2
- Thu 2nd → week 1 (first Thu of month)
"""
first_day = d.replace(day=1)
# How many days from the first occurrence of this weekday?
first_occurrence = 1 + (d.weekday() - first_day.weekday()) % 7
return (d.day - first_occurrence) // 7 + 1
def plan_matches_date(plan: SchedulePlan, target_date: date) -> bool:
"""Return *True* if *plan*'s recurrence rule fires on *target_date*.
Checks (most restrictive first):
1. on_month → target month must match
2. on_week → target week-of-month must match
3. on_day → target weekday must match
4. If none of the above are set → matches every day
"""
if not plan.is_active:
return False
# Month filter
if plan.on_month is not None:
if target_date.month != _MOY_TO_MONTH[plan.on_month]:
return False
# Week-of-month filter
if plan.on_week is not None:
if _week_of_month(target_date) != plan.on_week:
return False
# Day-of-week filter
if plan.on_day is not None:
if target_date.weekday() != _DOW_TO_WEEKDAY[plan.on_day]:
return False
return True
# ---------------------------------------------------------------------------
# Query helpers
# ---------------------------------------------------------------------------
def get_matching_plans(
db: Session,
user_id: int,
target_date: date,
) -> list[SchedulePlan]:
"""Return all active plans for *user_id* that match *target_date*."""
plans = (
db.query(SchedulePlan)
.filter(
SchedulePlan.user_id == user_id,
SchedulePlan.is_active.is_(True),
)
.all()
)
return [p for p in plans if plan_matches_date(p, target_date)]
def get_materialized_plan_dates(
db: Session,
plan_id: int,
target_date: date,
) -> bool:
"""Return *True* if a materialized slot already exists for (plan_id, date)."""
return (
db.query(TimeSlot.id)
.filter(
TimeSlot.plan_id == plan_id,
TimeSlot.date == target_date,
)
.first()
) is not None
def get_virtual_slots_for_date(
db: Session,
user_id: int,
target_date: date,
) -> list[dict]:
"""Return virtual-slot dicts for plans that match *target_date* but have
not yet been materialized.
Each dict mirrors the TimeSlot column structure plus a ``virtual_id``
field, making it easy to merge with real slots in the API layer.
"""
plans = get_matching_plans(db, user_id, target_date)
virtual_slots: list[dict] = []
for plan in plans:
if get_materialized_plan_dates(db, plan.id, target_date):
continue # already materialized — skip
virtual_slots.append({
"virtual_id": make_virtual_slot_id(plan.id, target_date),
"plan_id": plan.id,
"user_id": plan.user_id,
"date": target_date,
"slot_type": plan.slot_type,
"estimated_duration": plan.estimated_duration,
"scheduled_at": plan.at_time,
"started_at": None,
"attended": False,
"actual_duration": None,
"event_type": plan.event_type,
"event_data": plan.event_data,
"priority": 0,
"status": SlotStatus.NOT_STARTED,
})
return virtual_slots
# ---------------------------------------------------------------------------
# Materialization
# ---------------------------------------------------------------------------
def materialize_slot(
db: Session,
plan_id: int,
target_date: date,
) -> TimeSlot:
"""Materialize a virtual slot into a real TimeSlot row.
Copies template fields from the plan. The returned row is flushed
(has an ``id``) but the caller must ``commit()`` the transaction.
Raises ``ValueError`` if the plan does not exist, is inactive, does
not match the target date, or has already been materialized for that date.
"""
plan = db.query(SchedulePlan).filter(SchedulePlan.id == plan_id).first()
if plan is None:
raise ValueError(f"Plan {plan_id} not found")
if not plan.is_active:
raise ValueError(f"Plan {plan_id} is inactive")
if not plan_matches_date(plan, target_date):
raise ValueError(
f"Plan {plan_id} does not match date {target_date.isoformat()}"
)
if get_materialized_plan_dates(db, plan_id, target_date):
raise ValueError(
f"Plan {plan_id} already materialized for {target_date.isoformat()}"
)
slot = TimeSlot(
user_id=plan.user_id,
date=target_date,
slot_type=plan.slot_type,
estimated_duration=plan.estimated_duration,
scheduled_at=plan.at_time,
event_type=plan.event_type,
event_data=plan.event_data,
priority=0,
status=SlotStatus.NOT_STARTED,
plan_id=plan.id,
)
db.add(slot)
db.flush()
return slot
def materialize_from_virtual_id(
db: Session,
virtual_id: str,
) -> TimeSlot:
"""Parse a virtual-slot identifier and materialize it.
Convenience wrapper around :func:`materialize_slot`.
"""
parsed = parse_virtual_slot_id(virtual_id)
if parsed is None:
raise ValueError(f"Invalid virtual slot id: {virtual_id!r}")
plan_id, target_date = parsed
return materialize_slot(db, plan_id, target_date)
# ---------------------------------------------------------------------------
# Disconnect plan after edit/cancel
# ---------------------------------------------------------------------------
def detach_slot_from_plan(slot: TimeSlot) -> None:
"""Clear the ``plan_id`` on a materialized slot.
Called after edit or cancel to ensure the plan no longer "claims"
this date — the row persists with its own lifecycle.
"""
slot.plan_id = None
# ---------------------------------------------------------------------------
# Bulk materialization (daily pre-compute)
# ---------------------------------------------------------------------------
def materialize_all_for_date(
db: Session,
user_id: int,
target_date: date,
) -> list[TimeSlot]:
"""Materialize every matching plan for *user_id* on *target_date*.
Skips plans that are already materialized. Returns the list of
newly created TimeSlot rows (flushed, caller must commit).
"""
plans = get_matching_plans(db, user_id, target_date)
created: list[TimeSlot] = []
for plan in plans:
if get_materialized_plan_dates(db, plan.id, target_date):
continue
slot = TimeSlot(
user_id=plan.user_id,
date=target_date,
slot_type=plan.slot_type,
estimated_duration=plan.estimated_duration,
scheduled_at=plan.at_time,
event_type=plan.event_type,
event_data=plan.event_data,
priority=0,
status=SlotStatus.NOT_STARTED,
plan_id=plan.id,
)
db.add(slot)
created.append(slot)
if created:
db.flush()
return created

View File

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

View File

@@ -0,0 +1,171 @@
"""Past-slot immutability rules.
BE-CAL-008: Prevents editing or cancelling slots whose date is in the past.
Also ensures plan-edit and plan-cancel do not retroactively affect
already-materialized past slots.
Rules:
1. Editing a past slot (real or virtual) → raise ImmutableSlotError
2. Cancelling a past slot (real or virtual) → raise ImmutableSlotError
3. Plan-edit / plan-cancel must NOT retroactively change already-materialized
slots whose date is in the past. The plan_slot.detach_slot_from_plan()
mechanism already ensures this: past materialized slots keep their data.
This module provides guard functions that Calendar API endpoints call
before performing mutations.
"""
from __future__ import annotations
from datetime import date
from typing import Optional
from sqlalchemy.orm import Session
from app.models.calendar import TimeSlot
from app.services.plan_slot import parse_virtual_slot_id
class ImmutableSlotError(Exception):
"""Raised when an operation attempts to modify a past slot."""
def __init__(self, slot_date: date, operation: str, detail: str = ""):
self.slot_date = slot_date
self.operation = operation
self.detail = detail
msg = (
f"Cannot {operation} slot on {slot_date.isoformat()}: "
f"past slots are immutable"
)
if detail:
msg += f" ({detail})"
super().__init__(msg)
# ---------------------------------------------------------------------------
# Core guard: date must not be in the past
# ---------------------------------------------------------------------------
def _assert_not_past(slot_date: date, operation: str, *, today: Optional[date] = None) -> None:
"""Raise :class:`ImmutableSlotError` if *slot_date* is before *today*.
``today`` defaults to ``date.today()`` when not supplied (allows
deterministic testing).
"""
if today is None:
today = date.today()
if slot_date < today:
raise ImmutableSlotError(slot_date, operation)
# ---------------------------------------------------------------------------
# Guards for real (materialized) slots
# ---------------------------------------------------------------------------
def guard_edit_real_slot(
db: Session,
slot: TimeSlot,
*,
today: Optional[date] = None,
) -> None:
"""Raise if the real *slot* is in the past and cannot be edited."""
_assert_not_past(slot.date, "edit", today=today)
def guard_cancel_real_slot(
db: Session,
slot: TimeSlot,
*,
today: Optional[date] = None,
) -> None:
"""Raise if the real *slot* is in the past and cannot be cancelled."""
_assert_not_past(slot.date, "cancel", today=today)
# ---------------------------------------------------------------------------
# Guards for virtual (plan-generated) slots
# ---------------------------------------------------------------------------
def guard_edit_virtual_slot(
virtual_id: str,
*,
today: Optional[date] = None,
) -> None:
"""Raise if the virtual slot identified by *virtual_id* is in the past."""
parsed = parse_virtual_slot_id(virtual_id)
if parsed is None:
raise ValueError(f"Invalid virtual slot id: {virtual_id!r}")
_plan_id, slot_date = parsed
_assert_not_past(slot_date, "edit", today=today)
def guard_cancel_virtual_slot(
virtual_id: str,
*,
today: Optional[date] = None,
) -> None:
"""Raise if the virtual slot identified by *virtual_id* is in the past."""
parsed = parse_virtual_slot_id(virtual_id)
if parsed is None:
raise ValueError(f"Invalid virtual slot id: {virtual_id!r}")
_plan_id, slot_date = parsed
_assert_not_past(slot_date, "cancel", today=today)
# ---------------------------------------------------------------------------
# Guard for plan-edit / plan-cancel: no retroactive changes to past slots
# ---------------------------------------------------------------------------
def get_past_materialized_slot_ids(
db: Session,
plan_id: int,
*,
today: Optional[date] = None,
) -> list[int]:
"""Return IDs of materialized slots for *plan_id* whose date is in the past.
Plan-edit and plan-cancel must NOT modify these rows. The caller can
use this list to exclude them from bulk updates, or simply to verify
that no past data was touched.
"""
if today is None:
today = date.today()
rows = (
db.query(TimeSlot.id)
.filter(
TimeSlot.plan_id == plan_id,
TimeSlot.date < today,
)
.all()
)
return [r[0] for r in rows]
def guard_plan_edit_no_past_retroaction(
db: Session,
plan_id: int,
*,
today: Optional[date] = None,
) -> list[int]:
"""Return past materialized slot IDs that must NOT be modified.
The caller (plan-edit endpoint) should update only future materialized
slots and skip these. This function is informational — it does not
raise, because the plan itself *can* be edited; the restriction is
that past slots remain untouched.
"""
return get_past_materialized_slot_ids(db, plan_id, today=today)
def guard_plan_cancel_no_past_retroaction(
db: Session,
plan_id: int,
*,
today: Optional[date] = None,
) -> list[int]:
"""Return past materialized slot IDs that must NOT be cancelled.
Same semantics as :func:`guard_plan_edit_no_past_retroaction`.
When cancelling a plan, future materialized slots may be removed or
marked cancelled, but past slots remain untouched.
"""
return get_past_materialized_slot_ids(db, plan_id, today=today)

View File

@@ -0,0 +1,62 @@
# BE-PR-010: `feat_task_id` Deprecation & Compatibility Strategy
> Date: 2026-03-30
## Background
The `feat_task_id` column on the `proposes` table was used by the **old** Proposal
Accept flow to store the ID of the single `story/feature` task generated when a
Proposal was accepted.
With the new Essential-based Accept flow (BE-PR-007 / BE-PR-008), accepting a
Proposal now generates **multiple** story tasks (one per Essential), tracked via:
- `Task.source_proposal_id` → FK back to the Proposal
- `Task.source_essential_id` → FK back to the specific Essential
This makes `feat_task_id` obsolete.
## Decision: Retain Column, Deprecate Semantics
| Aspect | Decision |
|--------|----------|
| DB column | **Retained** — no schema migration required now |
| Existing data | Legacy rows with a non-NULL `feat_task_id` continue to expose the value via API |
| New writes | **Prohibited** — new accept flow does NOT write `feat_task_id` |
| API response | Field still present in `ProposalResponse` for backward compat |
| Client guidance | Use `generated_tasks` on the Proposal detail endpoint instead |
| Future removal | Column will be dropped in a future migration once all clients have migrated |
## Read Compatibility
- `GET /projects/{id}/proposals` — returns `feat_task_id` (may be `null`)
- `GET /projects/{id}/proposals/{id}` — returns `feat_task_id` + `generated_tasks[]`
- `PATCH /projects/{id}/proposals/{id}``feat_task_id` in request body is silently ignored
## Migration Path for Clients
### Backend consumers
Use `Proposal.generated_tasks` relationship (or query `Task` by `source_proposal_id`).
### Frontend
Replace `propose.feat_task_id` references with the `generated_tasks` array from
the detail endpoint. The detail page should list all generated tasks, not just one.
### CLI
CLI does not reference `feat_task_id`. No changes needed.
## Files Changed
| File | Change |
|------|--------|
| `app/models/proposal.py` | Updated docstring & column comment with deprecation notice |
| `app/schemas/schemas.py` | Marked `feat_task_id` field as deprecated |
| `app/api/routers/proposals.py` | Updated comments; field still serialized read-only |
| `tests/test_propose.py` | Updated accept tests to assert `feat_task_id is None` |
## Frontend References (to be updated in FE-PR-002+)
- `src/types/index.ts:139``feat_task_id: string | null`
- `src/pages/ProposeDetailPage.tsx:145,180-181` — displays feat_task_id
- `src/pages/ProposesPage.tsx:83` — displays feat_task_id in list
These will be addressed when the frontend Proposal/Essential tasks are implemented.

1
tests/__init__.py Normal file
View File

@@ -0,0 +1 @@
# tests package

191
tests/conftest.py Normal file
View File

@@ -0,0 +1,191 @@
"""Shared test fixtures — SQLite in-memory DB + FastAPI TestClient.
Every test function gets a fresh database with baseline seed data:
- Roles: admin, dev, viewer (+ permissions for propose.accept/reject/reopen)
- An admin user (id=1)
- A dev user (id=2)
- A project (id=1) with both users as members
- An open milestone (id=1) under the project
"""
import pytest
from sqlalchemy import create_engine, event
from sqlalchemy.orm import sessionmaker
from fastapi.testclient import TestClient
# ---------------------------------------------------------------------------
# Patch the production engine/SessionLocal BEFORE importing app so that
# startup events (Base.metadata.create_all, init_wizard, etc.) use the
# in-memory SQLite database instead of trying to connect to MySQL.
# ---------------------------------------------------------------------------
SQLALCHEMY_DATABASE_URL = "sqlite:///file::memory:?cache=shared&uri=true"
test_engine = create_engine(
SQLALCHEMY_DATABASE_URL,
connect_args={"check_same_thread": False},
)
# SQLite foreign-key enforcement
@event.listens_for(test_engine, "connect")
def _set_sqlite_pragma(dbapi_connection, connection_record):
cursor = dbapi_connection.cursor()
cursor.execute("PRAGMA foreign_keys=ON")
cursor.close()
TestingSessionLocal = sessionmaker(autocommit=False, autoflush=False, bind=test_engine)
# Monkey-patch app.core.config so the entire app uses SQLite
import app.core.config as _cfg
_cfg.engine = test_engine
_cfg.SessionLocal = TestingSessionLocal
# Now it's safe to import app and friends
from app.core.config import Base, get_db
from app.main import app
from app.models import models
from app.models.role_permission import Role, Permission, RolePermission
from app.models.milestone import Milestone, MilestoneStatus
from app.api.deps import get_password_hash, create_access_token
# ---------------------------------------------------------------------------
# Fixtures
# ---------------------------------------------------------------------------
@pytest.fixture(autouse=True)
def setup_database():
"""Create all tables before each test, drop after."""
Base.metadata.create_all(bind=test_engine)
yield
Base.metadata.drop_all(bind=test_engine)
@pytest.fixture()
def db():
"""Yield a DB session for direct model manipulation in tests."""
session = TestingSessionLocal()
try:
yield session
finally:
session.close()
def _override_get_db():
session = TestingSessionLocal()
try:
yield session
finally:
session.close()
@pytest.fixture()
def client():
"""FastAPI TestClient wired to the test DB."""
app.dependency_overrides[get_db] = _override_get_db
with TestClient(app, raise_server_exceptions=False) as c:
yield c
app.dependency_overrides.clear()
# ---------------------------------------------------------------------------
# Seed helpers
# ---------------------------------------------------------------------------
def _seed_roles_and_permissions(db_session):
"""Create admin/dev/viewer roles and key permissions."""
admin_role = Role(id=1, name="admin", is_global=True)
dev_role = Role(id=2, name="dev", is_global=False)
viewer_role = Role(id=3, name="viewer", is_global=False)
db_session.add_all([admin_role, dev_role, viewer_role])
db_session.flush()
perms = []
for pname in ["propose.accept", "propose.reject", "propose.reopen",
"task.create", "task.edit", "task.delete"]:
p = Permission(name=pname, category="proposal")
db_session.add(p)
db_session.flush()
perms.append(p)
# Admin gets all permissions
for p in perms:
db_session.add(RolePermission(role_id=admin_role.id, permission_id=p.id))
# Dev gets propose.accept / reject / reopen and task perms
for p in perms:
db_session.add(RolePermission(role_id=dev_role.id, permission_id=p.id))
db_session.flush()
return admin_role, dev_role, viewer_role
def _seed_users(db_session, admin_role, dev_role):
"""Create admin + dev users."""
admin_user = models.User(
id=1, username="admin", email="admin@test.com",
hashed_password=get_password_hash("admin123"),
is_admin=True, role_id=admin_role.id,
)
dev_user = models.User(
id=2, username="developer", email="dev@test.com",
hashed_password=get_password_hash("dev123"),
is_admin=False, role_id=dev_role.id,
)
db_session.add_all([admin_user, dev_user])
db_session.flush()
return admin_user, dev_user
def _seed_project(db_session, admin_user, dev_user, dev_role):
"""Create a project with both users as members."""
project = models.Project(
id=1, name="TestProject", project_code="TPRJ",
owner_name=admin_user.username, owner_id=admin_user.id,
)
db_session.add(project)
db_session.flush()
db_session.add(models.ProjectMember(project_id=project.id, user_id=admin_user.id, role_id=1))
db_session.add(models.ProjectMember(project_id=project.id, user_id=dev_user.id, role_id=dev_role.id))
db_session.flush()
return project
def _seed_milestone(db_session, project):
"""Create an open milestone."""
ms = Milestone(
id=1, title="v1.0", milestone_code="TPRJ:M00001",
status=MilestoneStatus.OPEN, project_id=project.id, created_by_id=1,
)
db_session.add(ms)
db_session.flush()
return ms
@pytest.fixture()
def seed(db):
"""Seed the DB with roles, users, project, milestone. Returns a namespace dict."""
admin_role, dev_role, viewer_role = _seed_roles_and_permissions(db)
admin_user, dev_user = _seed_users(db, admin_role, dev_role)
project = _seed_project(db, admin_user, dev_user, dev_role)
milestone = _seed_milestone(db, project)
db.commit()
admin_token = create_access_token({"sub": str(admin_user.id)})
dev_token = create_access_token({"sub": str(dev_user.id)})
return {
"admin_user": admin_user,
"dev_user": dev_user,
"admin_role": admin_role,
"dev_role": dev_role,
"project": project,
"milestone": milestone,
"admin_token": admin_token,
"dev_token": dev_token,
}
def auth_header(token: str) -> dict:
"""Return Authorization header dict."""
return {"Authorization": f"Bearer {token}"}

373
tests/test_agent_status.py Normal file
View File

@@ -0,0 +1,373 @@
"""Tests for Agent status transition service — BE-AGT-002.
Covers:
- Idle → Busy / OnCall
- Busy / OnCall → Idle
- Heartbeat timeout → Offline
- API quota error → Exhausted
- Exhausted recovery → Idle
- Invalid transition errors
"""
import pytest
from datetime import datetime, timedelta, timezone
from app.models.agent import Agent, AgentStatus, ExhaustReason
from app.models.calendar import SlotType
from app.services.agent_status import (
AgentStatusError,
HEARTBEAT_TIMEOUT_SECONDS,
DEFAULT_RECOVERY_HOURS,
parse_exhausted_recovery_at,
transition_to_busy,
transition_to_idle,
transition_to_offline,
transition_to_exhausted,
check_heartbeat_timeout,
check_exhausted_recovery,
record_heartbeat,
)
# ---------------------------------------------------------------------------
# Helpers
# ---------------------------------------------------------------------------
NOW = datetime(2026, 4, 1, 12, 0, 0, tzinfo=timezone.utc)
def _make_agent(db, *, status=AgentStatus.IDLE, last_hb=None, **kwargs):
"""Insert and return an Agent row with a linked user."""
from app.models import models
from app.api.deps import get_password_hash
# Ensure we have a user
user = db.query(models.User).filter_by(id=99).first()
if user is None:
# Need a role first
from app.models.role_permission import Role
role = db.query(Role).filter_by(id=99).first()
if role is None:
role = Role(id=99, name="agent_test_role", is_global=False)
db.add(role)
db.flush()
user = models.User(
id=99, username="agent_user", email="agent@test.com",
hashed_password=get_password_hash("test123"),
is_admin=False, role_id=role.id,
)
db.add(user)
db.flush()
agent = Agent(
user_id=user.id,
agent_id=kwargs.get("agent_id", "test-agent-001"),
claw_identifier="test-claw",
status=status,
last_heartbeat=last_hb,
**{k: v for k, v in kwargs.items() if k not in ("agent_id",)},
)
db.add(agent)
db.flush()
return agent
# ---------------------------------------------------------------------------
# Idle → Busy / OnCall
# ---------------------------------------------------------------------------
class TestTransitionToBusy:
def test_idle_to_busy_for_work_slot(self, db):
agent = _make_agent(db, status=AgentStatus.IDLE)
result = transition_to_busy(db, agent, slot_type=SlotType.WORK, now=NOW)
assert result.status == AgentStatus.BUSY
assert result.last_heartbeat == NOW
def test_idle_to_on_call_for_on_call_slot(self, db):
agent = _make_agent(db, status=AgentStatus.IDLE)
result = transition_to_busy(db, agent, slot_type=SlotType.ON_CALL, now=NOW)
assert result.status == AgentStatus.ON_CALL
def test_idle_to_busy_for_system_slot(self, db):
agent = _make_agent(db, status=AgentStatus.IDLE)
result = transition_to_busy(db, agent, slot_type=SlotType.SYSTEM, now=NOW)
assert result.status == AgentStatus.BUSY
def test_idle_to_busy_for_entertainment_slot(self, db):
agent = _make_agent(db, status=AgentStatus.IDLE)
result = transition_to_busy(db, agent, slot_type=SlotType.ENTERTAINMENT, now=NOW)
assert result.status == AgentStatus.BUSY
def test_busy_to_busy_raises(self, db):
agent = _make_agent(db, status=AgentStatus.BUSY)
with pytest.raises(AgentStatusError, match="busy"):
transition_to_busy(db, agent, slot_type=SlotType.WORK)
def test_exhausted_to_busy_raises(self, db):
agent = _make_agent(db, status=AgentStatus.EXHAUSTED)
with pytest.raises(AgentStatusError):
transition_to_busy(db, agent, slot_type=SlotType.WORK)
# ---------------------------------------------------------------------------
# Busy / OnCall → Idle
# ---------------------------------------------------------------------------
class TestTransitionToIdle:
def test_busy_to_idle(self, db):
agent = _make_agent(db, status=AgentStatus.BUSY)
result = transition_to_idle(db, agent, now=NOW)
assert result.status == AgentStatus.IDLE
assert result.last_heartbeat == NOW
def test_on_call_to_idle(self, db):
agent = _make_agent(db, status=AgentStatus.ON_CALL)
result = transition_to_idle(db, agent, now=NOW)
assert result.status == AgentStatus.IDLE
def test_exhausted_to_idle_clears_metadata(self, db):
agent = _make_agent(
db,
status=AgentStatus.EXHAUSTED,
exhausted_at=NOW - timedelta(hours=1),
recovery_at=NOW,
exhaust_reason=ExhaustReason.RATE_LIMIT,
)
result = transition_to_idle(db, agent, now=NOW)
assert result.status == AgentStatus.IDLE
assert result.exhausted_at is None
assert result.recovery_at is None
assert result.exhaust_reason is None
def test_offline_to_idle(self, db):
agent = _make_agent(db, status=AgentStatus.OFFLINE)
result = transition_to_idle(db, agent, now=NOW)
assert result.status == AgentStatus.IDLE
def test_idle_to_idle_raises(self, db):
agent = _make_agent(db, status=AgentStatus.IDLE)
with pytest.raises(AgentStatusError, match="idle"):
transition_to_idle(db, agent)
# ---------------------------------------------------------------------------
# * → Offline (heartbeat timeout)
# ---------------------------------------------------------------------------
class TestTransitionToOffline:
def test_idle_to_offline(self, db):
agent = _make_agent(db, status=AgentStatus.IDLE)
result = transition_to_offline(db, agent)
assert result.status == AgentStatus.OFFLINE
def test_busy_to_offline(self, db):
agent = _make_agent(db, status=AgentStatus.BUSY)
result = transition_to_offline(db, agent)
assert result.status == AgentStatus.OFFLINE
def test_already_offline_noop(self, db):
agent = _make_agent(db, status=AgentStatus.OFFLINE)
result = transition_to_offline(db, agent)
assert result.status == AgentStatus.OFFLINE
# ---------------------------------------------------------------------------
# Recovery time parsing
# ---------------------------------------------------------------------------
class TestParseExhaustedRecoveryAt:
def test_parses_retry_after_seconds_header(self):
recovery = parse_exhausted_recovery_at(
now=NOW,
headers={"Retry-After": "120"},
)
assert recovery == NOW + timedelta(seconds=120)
def test_parses_retry_after_http_date_header(self):
recovery = parse_exhausted_recovery_at(
now=NOW,
headers={"Retry-After": "Wed, 01 Apr 2026 12:05:00 GMT"},
)
assert recovery == datetime(2026, 4, 1, 12, 5, 0, tzinfo=timezone.utc)
def test_parses_reset_in_minutes_from_message(self):
recovery = parse_exhausted_recovery_at(
now=NOW,
message="rate limit exceeded, reset in 7 mins",
)
assert recovery == NOW + timedelta(minutes=7)
def test_parses_retry_after_seconds_from_message(self):
recovery = parse_exhausted_recovery_at(
now=NOW,
message="429 too many requests; retry after 45 seconds",
)
assert recovery == NOW + timedelta(seconds=45)
def test_parses_resets_at_iso_timestamp_from_message(self):
recovery = parse_exhausted_recovery_at(
now=NOW,
message="quota exhausted, resets at 2026-04-01T14:30:00Z",
)
assert recovery == datetime(2026, 4, 1, 14, 30, 0, tzinfo=timezone.utc)
def test_falls_back_to_default_when_unparseable(self):
recovery = parse_exhausted_recovery_at(
now=NOW,
headers={"Retry-After": "not-a-date"},
message="please try later maybe soon",
)
assert recovery == NOW + timedelta(hours=DEFAULT_RECOVERY_HOURS)
# ---------------------------------------------------------------------------
# * → Exhausted (API quota)
# ---------------------------------------------------------------------------
class TestTransitionToExhausted:
def test_busy_to_exhausted_with_recovery(self, db):
recovery = NOW + timedelta(hours=1)
agent = _make_agent(db, status=AgentStatus.BUSY)
result = transition_to_exhausted(
db, agent,
reason=ExhaustReason.RATE_LIMIT,
recovery_at=recovery,
now=NOW,
)
assert result.status == AgentStatus.EXHAUSTED
assert result.exhausted_at == NOW
assert result.recovery_at == recovery
assert result.exhaust_reason == ExhaustReason.RATE_LIMIT
def test_exhausted_default_recovery(self, db):
agent = _make_agent(db, status=AgentStatus.BUSY)
result = transition_to_exhausted(
db, agent,
reason=ExhaustReason.BILLING,
now=NOW,
)
expected_recovery = NOW + timedelta(hours=DEFAULT_RECOVERY_HOURS)
assert result.recovery_at == expected_recovery
assert result.exhaust_reason == ExhaustReason.BILLING
def test_idle_to_exhausted(self, db):
"""Edge case: agent gets a rate-limit before even starting work."""
agent = _make_agent(db, status=AgentStatus.IDLE)
result = transition_to_exhausted(
db, agent,
reason=ExhaustReason.RATE_LIMIT,
now=NOW,
)
assert result.status == AgentStatus.EXHAUSTED
def test_parses_recovery_from_headers_when_timestamp_not_explicitly_provided(self, db):
agent = _make_agent(db, status=AgentStatus.BUSY)
result = transition_to_exhausted(
db,
agent,
reason=ExhaustReason.RATE_LIMIT,
headers={"Retry-After": "90"},
now=NOW,
)
assert result.recovery_at == NOW + timedelta(seconds=90)
def test_parses_recovery_from_message_when_timestamp_not_explicitly_provided(self, db):
agent = _make_agent(db, status=AgentStatus.BUSY)
result = transition_to_exhausted(
db,
agent,
reason=ExhaustReason.BILLING,
message="billing quota exhausted, resets at 2026-04-01T15:00:00Z",
now=NOW,
)
assert result.recovery_at == datetime(2026, 4, 1, 15, 0, 0, tzinfo=timezone.utc)
# ---------------------------------------------------------------------------
# Heartbeat timeout check
# ---------------------------------------------------------------------------
class TestCheckHeartbeatTimeout:
def test_timeout_triggers_offline(self, db):
old_hb = NOW - timedelta(seconds=HEARTBEAT_TIMEOUT_SECONDS + 10)
agent = _make_agent(db, status=AgentStatus.IDLE, last_hb=old_hb)
changed = check_heartbeat_timeout(db, agent, now=NOW)
assert changed is True
assert agent.status == AgentStatus.OFFLINE
def test_recent_heartbeat_no_change(self, db):
recent_hb = NOW - timedelta(seconds=30)
agent = _make_agent(db, status=AgentStatus.BUSY, last_hb=recent_hb)
changed = check_heartbeat_timeout(db, agent, now=NOW)
assert changed is False
assert agent.status == AgentStatus.BUSY
def test_no_heartbeat_ever_goes_offline(self, db):
agent = _make_agent(db, status=AgentStatus.IDLE, last_hb=None)
changed = check_heartbeat_timeout(db, agent, now=NOW)
assert changed is True
assert agent.status == AgentStatus.OFFLINE
def test_already_offline_returns_false(self, db):
agent = _make_agent(db, status=AgentStatus.OFFLINE, last_hb=None)
changed = check_heartbeat_timeout(db, agent, now=NOW)
assert changed is False
# ---------------------------------------------------------------------------
# Exhausted recovery check
# ---------------------------------------------------------------------------
class TestCheckExhaustedRecovery:
def test_recovery_at_reached(self, db):
agent = _make_agent(
db,
status=AgentStatus.EXHAUSTED,
exhausted_at=NOW - timedelta(hours=5),
recovery_at=NOW - timedelta(minutes=1),
exhaust_reason=ExhaustReason.RATE_LIMIT,
)
recovered = check_exhausted_recovery(db, agent, now=NOW)
assert recovered is True
assert agent.status == AgentStatus.IDLE
assert agent.exhausted_at is None
def test_recovery_at_not_yet_reached(self, db):
agent = _make_agent(
db,
status=AgentStatus.EXHAUSTED,
exhausted_at=NOW,
recovery_at=NOW + timedelta(hours=1),
exhaust_reason=ExhaustReason.BILLING,
)
recovered = check_exhausted_recovery(db, agent, now=NOW)
assert recovered is False
assert agent.status == AgentStatus.EXHAUSTED
def test_non_exhausted_agent_returns_false(self, db):
agent = _make_agent(db, status=AgentStatus.IDLE)
recovered = check_exhausted_recovery(db, agent, now=NOW)
assert recovered is False
# ---------------------------------------------------------------------------
# Record heartbeat
# ---------------------------------------------------------------------------
class TestRecordHeartbeat:
def test_updates_timestamp(self, db):
agent = _make_agent(db, status=AgentStatus.IDLE, last_hb=NOW - timedelta(minutes=1))
result = record_heartbeat(db, agent, now=NOW)
assert result.last_heartbeat == NOW
def test_offline_agent_recovers_to_idle(self, db):
agent = _make_agent(db, status=AgentStatus.OFFLINE)
result = record_heartbeat(db, agent, now=NOW)
assert result.status == AgentStatus.IDLE
assert result.last_heartbeat == NOW
def test_busy_agent_stays_busy(self, db):
agent = _make_agent(db, status=AgentStatus.BUSY, last_hb=NOW - timedelta(seconds=30))
result = record_heartbeat(db, agent, now=NOW)
assert result.status == AgentStatus.BUSY
assert result.last_heartbeat == NOW

357
tests/test_calendar_api.py Normal file
View File

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

View File

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

View File

@@ -0,0 +1,451 @@
"""Tests for MinimumWorkload warning rules (BE-CAL-007).
Tests cover:
- _date_range_for_period computation
- _sum_real_slots aggregation
- _sum_virtual_slots aggregation
- check_workload_warnings comparison logic
- get_workload_warnings_for_date end-to-end convenience
- Warnings are advisory (non-blocking)
"""
import pytest
from datetime import date, time
from tests.conftest import auth_header
from app.models.calendar import (
SchedulePlan,
SlotStatus,
SlotType,
EventType,
TimeSlot,
DayOfWeek,
)
from app.models.minimum_workload import MinimumWorkload
from app.services.minimum_workload import (
_date_range_for_period,
_sum_real_slots,
_sum_virtual_slots,
check_workload_warnings,
compute_scheduled_minutes,
get_workload_warnings_for_date,
get_workload_config,
)
from app.schemas.calendar import WorkloadWarningItem
# ---------------------------------------------------------------------------
# Unit: _date_range_for_period
# ---------------------------------------------------------------------------
class TestDateRangeForPeriod:
def test_daily(self):
d = date(2026, 3, 15) # Sunday
start, end = _date_range_for_period("daily", d)
assert start == end == d
def test_weekly_midweek(self):
d = date(2026, 3, 18) # Wednesday
start, end = _date_range_for_period("weekly", d)
assert start == date(2026, 3, 16) # Monday
assert end == date(2026, 3, 22) # Sunday
def test_weekly_monday(self):
d = date(2026, 3, 16) # Monday
start, end = _date_range_for_period("weekly", d)
assert start == date(2026, 3, 16)
assert end == date(2026, 3, 22)
def test_weekly_sunday(self):
d = date(2026, 3, 22) # Sunday
start, end = _date_range_for_period("weekly", d)
assert start == date(2026, 3, 16)
assert end == date(2026, 3, 22)
def test_monthly(self):
d = date(2026, 3, 15)
start, end = _date_range_for_period("monthly", d)
assert start == date(2026, 3, 1)
assert end == date(2026, 3, 31)
def test_monthly_february(self):
d = date(2026, 2, 10)
start, end = _date_range_for_period("monthly", d)
assert start == date(2026, 2, 1)
assert end == date(2026, 2, 28)
def test_monthly_december(self):
d = date(2026, 12, 25)
start, end = _date_range_for_period("monthly", d)
assert start == date(2026, 12, 1)
assert end == date(2026, 12, 31)
def test_yearly(self):
d = date(2026, 6, 15)
start, end = _date_range_for_period("yearly", d)
assert start == date(2026, 1, 1)
assert end == date(2026, 12, 31)
def test_unknown_period_raises(self):
with pytest.raises(ValueError, match="Unknown period"):
_date_range_for_period("hourly", date(2026, 1, 1))
# ---------------------------------------------------------------------------
# Unit: check_workload_warnings (pure comparison, no DB)
# ---------------------------------------------------------------------------
class TestCheckWorkloadWarnings:
"""Test the comparison logic with pre-computed scheduled_minutes."""
def test_no_warnings_when_all_zero_config(self, db, seed):
"""Default config (all zeros) never triggers warnings."""
scheduled = {
"daily": {"work": 0, "on_call": 0, "entertainment": 0},
"weekly": {"work": 0, "on_call": 0, "entertainment": 0},
"monthly": {"work": 0, "on_call": 0, "entertainment": 0},
"yearly": {"work": 0, "on_call": 0, "entertainment": 0},
}
warnings = check_workload_warnings(db, seed["admin_user"].id, scheduled)
assert warnings == []
def test_warning_when_below_threshold(self, db, seed):
"""Setting a threshold higher than scheduled triggers a warning."""
# Set daily work minimum to 60 min
cfg = MinimumWorkload(
user_id=seed["admin_user"].id,
config={
"daily": {"work": 60, "on_call": 0, "entertainment": 0},
"weekly": {"work": 0, "on_call": 0, "entertainment": 0},
"monthly": {"work": 0, "on_call": 0, "entertainment": 0},
"yearly": {"work": 0, "on_call": 0, "entertainment": 0},
},
)
db.add(cfg)
db.commit()
scheduled = {
"daily": {"work": 30, "on_call": 0, "entertainment": 0},
"weekly": {"work": 100, "on_call": 0, "entertainment": 0},
"monthly": {"work": 400, "on_call": 0, "entertainment": 0},
"yearly": {"work": 5000, "on_call": 0, "entertainment": 0},
}
warnings = check_workload_warnings(db, seed["admin_user"].id, scheduled)
assert len(warnings) == 1
w = warnings[0]
assert w.period == "daily"
assert w.category == "work"
assert w.current_minutes == 30
assert w.minimum_minutes == 60
assert w.shortfall_minutes == 30
def test_no_warning_when_meeting_threshold(self, db, seed):
cfg = MinimumWorkload(
user_id=seed["admin_user"].id,
config={
"daily": {"work": 30, "on_call": 0, "entertainment": 0},
"weekly": {"work": 0, "on_call": 0, "entertainment": 0},
"monthly": {"work": 0, "on_call": 0, "entertainment": 0},
"yearly": {"work": 0, "on_call": 0, "entertainment": 0},
},
)
db.add(cfg)
db.commit()
scheduled = {
"daily": {"work": 30, "on_call": 0, "entertainment": 0},
"weekly": {"work": 100, "on_call": 0, "entertainment": 0},
"monthly": {"work": 400, "on_call": 0, "entertainment": 0},
"yearly": {"work": 5000, "on_call": 0, "entertainment": 0},
}
warnings = check_workload_warnings(db, seed["admin_user"].id, scheduled)
assert warnings == []
def test_multiple_warnings_across_periods_and_categories(self, db, seed):
cfg = MinimumWorkload(
user_id=seed["admin_user"].id,
config={
"daily": {"work": 50, "on_call": 20, "entertainment": 0},
"weekly": {"work": 300, "on_call": 0, "entertainment": 0},
"monthly": {"work": 0, "on_call": 0, "entertainment": 0},
"yearly": {"work": 0, "on_call": 0, "entertainment": 0},
},
)
db.add(cfg)
db.commit()
scheduled = {
"daily": {"work": 10, "on_call": 5, "entertainment": 0},
"weekly": {"work": 100, "on_call": 0, "entertainment": 0},
"monthly": {"work": 0, "on_call": 0, "entertainment": 0},
"yearly": {"work": 0, "on_call": 0, "entertainment": 0},
}
warnings = check_workload_warnings(db, seed["admin_user"].id, scheduled)
assert len(warnings) == 3
periods_cats = {(w.period, w.category) for w in warnings}
assert ("daily", "work") in periods_cats
assert ("daily", "on_call") in periods_cats
assert ("weekly", "work") in periods_cats
# ---------------------------------------------------------------------------
# Integration: _sum_real_slots
# ---------------------------------------------------------------------------
class TestSumRealSlots:
def test_sums_work_slots(self, db, seed):
"""Real work slots are summed correctly."""
user_id = seed["admin_user"].id
db.add(TimeSlot(
user_id=user_id, date=date(2026, 3, 15),
slot_type=SlotType.WORK, estimated_duration=30,
scheduled_at=time(9, 0), status=SlotStatus.NOT_STARTED,
))
db.add(TimeSlot(
user_id=user_id, date=date(2026, 3, 15),
slot_type=SlotType.WORK, estimated_duration=20,
scheduled_at=time(10, 0), status=SlotStatus.FINISHED,
))
db.commit()
totals = _sum_real_slots(db, user_id, date(2026, 3, 15), date(2026, 3, 15))
assert totals["work"] == 50
assert totals["on_call"] == 0
assert totals["entertainment"] == 0
def test_excludes_skipped_and_aborted(self, db, seed):
user_id = seed["admin_user"].id
db.add(TimeSlot(
user_id=user_id, date=date(2026, 3, 15),
slot_type=SlotType.WORK, estimated_duration=30,
scheduled_at=time(9, 0), status=SlotStatus.SKIPPED,
))
db.add(TimeSlot(
user_id=user_id, date=date(2026, 3, 15),
slot_type=SlotType.WORK, estimated_duration=20,
scheduled_at=time(10, 0), status=SlotStatus.ABORTED,
))
db.commit()
totals = _sum_real_slots(db, user_id, date(2026, 3, 15), date(2026, 3, 15))
assert totals["work"] == 0
def test_excludes_system_slots(self, db, seed):
user_id = seed["admin_user"].id
db.add(TimeSlot(
user_id=user_id, date=date(2026, 3, 15),
slot_type=SlotType.SYSTEM, estimated_duration=10,
scheduled_at=time(8, 0), status=SlotStatus.NOT_STARTED,
))
db.commit()
totals = _sum_real_slots(db, user_id, date(2026, 3, 15), date(2026, 3, 15))
assert totals == {"work": 0, "on_call": 0, "entertainment": 0}
def test_sums_across_date_range(self, db, seed):
user_id = seed["admin_user"].id
for day in [15, 16, 17]:
db.add(TimeSlot(
user_id=user_id, date=date(2026, 3, day),
slot_type=SlotType.WORK, estimated_duration=10,
scheduled_at=time(9, 0), status=SlotStatus.NOT_STARTED,
))
db.commit()
totals = _sum_real_slots(db, user_id, date(2026, 3, 15), date(2026, 3, 17))
assert totals["work"] == 30
def test_multiple_categories(self, db, seed):
user_id = seed["admin_user"].id
db.add(TimeSlot(
user_id=user_id, date=date(2026, 3, 15),
slot_type=SlotType.WORK, estimated_duration=25,
scheduled_at=time(9, 0), status=SlotStatus.NOT_STARTED,
))
db.add(TimeSlot(
user_id=user_id, date=date(2026, 3, 15),
slot_type=SlotType.ON_CALL, estimated_duration=15,
scheduled_at=time(10, 0), status=SlotStatus.NOT_STARTED,
))
db.add(TimeSlot(
user_id=user_id, date=date(2026, 3, 15),
slot_type=SlotType.ENTERTAINMENT, estimated_duration=10,
scheduled_at=time(11, 0), status=SlotStatus.NOT_STARTED,
))
db.commit()
totals = _sum_real_slots(db, user_id, date(2026, 3, 15), date(2026, 3, 15))
assert totals == {"work": 25, "on_call": 15, "entertainment": 10}
# ---------------------------------------------------------------------------
# Integration: _sum_virtual_slots
# ---------------------------------------------------------------------------
class TestSumVirtualSlots:
def test_sums_virtual_plan_slots(self, db, seed):
"""Virtual slots from an active plan are counted."""
user_id = seed["admin_user"].id
plan = SchedulePlan(
user_id=user_id,
slot_type=SlotType.WORK,
estimated_duration=40,
at_time=time(9, 0),
on_day=DayOfWeek.SUN, # 2026-03-15 is a Sunday
is_active=True,
)
db.add(plan)
db.commit()
totals = _sum_virtual_slots(db, user_id, date(2026, 3, 15), date(2026, 3, 15))
assert totals["work"] == 40
def test_skips_materialized_plan_slots(self, db, seed):
"""If a plan slot is already materialized, it shouldn't be double-counted."""
user_id = seed["admin_user"].id
plan = SchedulePlan(
user_id=user_id,
slot_type=SlotType.WORK,
estimated_duration=40,
at_time=time(9, 0),
on_day=DayOfWeek.SUN,
is_active=True,
)
db.add(plan)
db.flush()
# Materialize it
db.add(TimeSlot(
user_id=user_id, date=date(2026, 3, 15),
slot_type=SlotType.WORK, estimated_duration=40,
scheduled_at=time(9, 0), status=SlotStatus.NOT_STARTED,
plan_id=plan.id,
))
db.commit()
totals = _sum_virtual_slots(db, user_id, date(2026, 3, 15), date(2026, 3, 15))
assert totals["work"] == 0 # Already materialized, not double-counted
# ---------------------------------------------------------------------------
# Integration: compute_scheduled_minutes
# ---------------------------------------------------------------------------
class TestComputeScheduledMinutes:
def test_combines_real_and_virtual(self, db, seed):
user_id = seed["admin_user"].id
# Real slot on the 15th
db.add(TimeSlot(
user_id=user_id, date=date(2026, 3, 15),
slot_type=SlotType.WORK, estimated_duration=20,
scheduled_at=time(9, 0), status=SlotStatus.NOT_STARTED,
))
# Plan that fires every day
plan = SchedulePlan(
user_id=user_id,
slot_type=SlotType.ON_CALL,
estimated_duration=10,
at_time=time(14, 0),
is_active=True,
)
db.add(plan)
db.commit()
result = compute_scheduled_minutes(db, user_id, date(2026, 3, 15))
# Daily: 20 work (real) + 10 on_call (virtual)
assert result["daily"]["work"] == 20
assert result["daily"]["on_call"] == 10
# Weekly: the real slot + virtual slots for every day in the week
# 2026-03-15 is Sunday → week is Mon 2026-03-09 to Sun 2026-03-15
assert result["weekly"]["work"] == 20
assert result["weekly"]["on_call"] >= 10 # At least the one day
# ---------------------------------------------------------------------------
# Integration: get_workload_warnings_for_date (end-to-end)
# ---------------------------------------------------------------------------
class TestGetWorkloadWarningsForDate:
def test_returns_warnings_when_below_threshold(self, db, seed):
user_id = seed["admin_user"].id
# Set daily work minimum to 60 min
db.add(MinimumWorkload(
user_id=user_id,
config={
"daily": {"work": 60, "on_call": 0, "entertainment": 0},
"weekly": {"work": 0, "on_call": 0, "entertainment": 0},
"monthly": {"work": 0, "on_call": 0, "entertainment": 0},
"yearly": {"work": 0, "on_call": 0, "entertainment": 0},
},
))
# Only 30 min of work scheduled
db.add(TimeSlot(
user_id=user_id, date=date(2026, 3, 15),
slot_type=SlotType.WORK, estimated_duration=30,
scheduled_at=time(9, 0), status=SlotStatus.NOT_STARTED,
))
db.commit()
warnings = get_workload_warnings_for_date(db, user_id, date(2026, 3, 15))
assert len(warnings) >= 1
daily_work = [w for w in warnings if w.period == "daily" and w.category == "work"]
assert len(daily_work) == 1
assert daily_work[0].shortfall_minutes == 30
def test_no_warnings_when_above_threshold(self, db, seed):
user_id = seed["admin_user"].id
db.add(MinimumWorkload(
user_id=user_id,
config={
"daily": {"work": 30, "on_call": 0, "entertainment": 0},
"weekly": {"work": 0, "on_call": 0, "entertainment": 0},
"monthly": {"work": 0, "on_call": 0, "entertainment": 0},
"yearly": {"work": 0, "on_call": 0, "entertainment": 0},
},
))
db.add(TimeSlot(
user_id=user_id, date=date(2026, 3, 15),
slot_type=SlotType.WORK, estimated_duration=45,
scheduled_at=time(9, 0), status=SlotStatus.NOT_STARTED,
))
db.commit()
warnings = get_workload_warnings_for_date(db, user_id, date(2026, 3, 15))
daily_work = [w for w in warnings if w.period == "daily" and w.category == "work"]
assert len(daily_work) == 0
def test_warning_data_structure(self, db, seed):
"""Ensure warnings contain all required fields with correct types."""
user_id = seed["admin_user"].id
db.add(MinimumWorkload(
user_id=user_id,
config={
"daily": {"work": 100, "on_call": 0, "entertainment": 0},
"weekly": {"work": 0, "on_call": 0, "entertainment": 0},
"monthly": {"work": 0, "on_call": 0, "entertainment": 0},
"yearly": {"work": 0, "on_call": 0, "entertainment": 0},
},
))
db.commit()
warnings = get_workload_warnings_for_date(db, user_id, date(2026, 3, 15))
assert len(warnings) >= 1
w = warnings[0]
assert isinstance(w, WorkloadWarningItem)
assert isinstance(w.period, str)
assert isinstance(w.category, str)
assert isinstance(w.current_minutes, int)
assert isinstance(w.minimum_minutes, int)
assert isinstance(w.shortfall_minutes, int)
assert isinstance(w.message, str)
assert w.shortfall_minutes == w.minimum_minutes - w.current_minutes

374
tests/test_overlap.py Normal file
View File

@@ -0,0 +1,374 @@
"""Tests for BE-CAL-006: Calendar overlap detection.
Covers:
- No conflict when slots don't overlap
- Conflict detected for overlapping time ranges
- Create vs edit scenarios (edit excludes own slot)
- Skipped/aborted slots are not considered
- Virtual (plan-generated) slots are checked
- Edge cases: adjacent slots, exact same time, partial overlap
"""
import pytest
from datetime import date, time
from app.models.calendar import (
SchedulePlan,
SlotStatus,
SlotType,
EventType,
TimeSlot,
DayOfWeek,
)
from app.services.overlap import (
check_overlap,
check_overlap_for_create,
check_overlap_for_edit,
)
# ---------------------------------------------------------------------------
# Helpers
# ---------------------------------------------------------------------------
TARGET_DATE = date(2026, 4, 1) # A Wednesday
USER_ID = 1
USER_ID_2 = 2
def _make_slot(db, *, scheduled_at, duration=30, status=SlotStatus.NOT_STARTED, user_id=USER_ID, slot_date=TARGET_DATE, plan_id=None):
"""Insert a real TimeSlot and return it."""
slot = TimeSlot(
user_id=user_id,
date=slot_date,
slot_type=SlotType.WORK,
estimated_duration=duration,
scheduled_at=scheduled_at,
status=status,
priority=0,
plan_id=plan_id,
)
db.add(slot)
db.flush()
return slot
def _make_plan(db, *, at_time, duration=30, user_id=USER_ID, on_day=None, is_active=True):
"""Insert a SchedulePlan and return it."""
plan = SchedulePlan(
user_id=user_id,
slot_type=SlotType.WORK,
estimated_duration=duration,
at_time=at_time,
on_day=on_day,
is_active=is_active,
)
db.add(plan)
db.flush()
return plan
@pytest.fixture(autouse=True)
def _ensure_users(seed):
"""All overlap tests need seeded users (id=1, id=2) for FK constraints."""
pass
# ---------------------------------------------------------------------------
# No-conflict cases
# ---------------------------------------------------------------------------
class TestNoConflict:
def test_empty_calendar(self, db):
"""No existing slots → no conflicts."""
conflicts = check_overlap_for_create(
db, USER_ID, TARGET_DATE, time(9, 0), 30,
)
assert conflicts == []
def test_adjacent_before(self, db):
"""Existing 09:00-09:30, proposed 09:30-10:00 → no overlap."""
_make_slot(db, scheduled_at=time(9, 0), duration=30)
db.commit()
conflicts = check_overlap_for_create(
db, USER_ID, TARGET_DATE, time(9, 30), 30,
)
assert conflicts == []
def test_adjacent_after(self, db):
"""Existing 10:00-10:30, proposed 09:30-10:00 → no overlap."""
_make_slot(db, scheduled_at=time(10, 0), duration=30)
db.commit()
conflicts = check_overlap_for_create(
db, USER_ID, TARGET_DATE, time(9, 30), 30,
)
assert conflicts == []
def test_different_user(self, db):
"""Slot for user 2 should not conflict with user 1's new slot."""
_make_slot(db, scheduled_at=time(9, 0), duration=30, user_id=USER_ID_2)
db.commit()
conflicts = check_overlap_for_create(
db, USER_ID, TARGET_DATE, time(9, 0), 30,
)
assert conflicts == []
def test_different_date(self, db):
"""Same time on a different date → no conflict."""
_make_slot(db, scheduled_at=time(9, 0), duration=30, slot_date=date(2026, 4, 2))
db.commit()
conflicts = check_overlap_for_create(
db, USER_ID, TARGET_DATE, time(9, 0), 30,
)
assert conflicts == []
# ---------------------------------------------------------------------------
# Conflict detection
# ---------------------------------------------------------------------------
class TestConflictDetected:
def test_exact_same_time(self, db):
"""Same start + same duration = overlap."""
_make_slot(db, scheduled_at=time(9, 0), duration=30)
db.commit()
conflicts = check_overlap_for_create(
db, USER_ID, TARGET_DATE, time(9, 0), 30,
)
assert len(conflicts) == 1
assert conflicts[0].conflicting_slot_id is not None
assert "overlaps" in conflicts[0].message
def test_partial_overlap_start(self, db):
"""Existing 09:00-09:30, proposed 09:15-09:45 → overlap."""
_make_slot(db, scheduled_at=time(9, 0), duration=30)
db.commit()
conflicts = check_overlap_for_create(
db, USER_ID, TARGET_DATE, time(9, 15), 30,
)
assert len(conflicts) == 1
def test_partial_overlap_end(self, db):
"""Existing 09:15-09:45, proposed 09:00-09:30 → overlap."""
_make_slot(db, scheduled_at=time(9, 15), duration=30)
db.commit()
conflicts = check_overlap_for_create(
db, USER_ID, TARGET_DATE, time(9, 0), 30,
)
assert len(conflicts) == 1
def test_proposed_contains_existing(self, db):
"""Proposed 09:00-10:00 contains existing 09:15-09:45."""
_make_slot(db, scheduled_at=time(9, 15), duration=30)
db.commit()
conflicts = check_overlap_for_create(
db, USER_ID, TARGET_DATE, time(9, 0), 50,
)
assert len(conflicts) == 1
def test_existing_contains_proposed(self, db):
"""Existing 09:00-10:00 contains proposed 09:15-09:30."""
_make_slot(db, scheduled_at=time(9, 0), duration=50)
db.commit()
conflicts = check_overlap_for_create(
db, USER_ID, TARGET_DATE, time(9, 15), 15,
)
assert len(conflicts) == 1
def test_multiple_conflicts(self, db):
"""Proposed overlaps with two existing slots."""
_make_slot(db, scheduled_at=time(9, 0), duration=30)
_make_slot(db, scheduled_at=time(9, 20), duration=30)
db.commit()
conflicts = check_overlap_for_create(
db, USER_ID, TARGET_DATE, time(9, 10), 30,
)
assert len(conflicts) == 2
# ---------------------------------------------------------------------------
# Inactive slots excluded
# ---------------------------------------------------------------------------
class TestInactiveExcluded:
def test_skipped_slot_ignored(self, db):
"""Skipped slot at same time should not cause conflict."""
_make_slot(db, scheduled_at=time(9, 0), duration=30, status=SlotStatus.SKIPPED)
db.commit()
conflicts = check_overlap_for_create(
db, USER_ID, TARGET_DATE, time(9, 0), 30,
)
assert conflicts == []
def test_aborted_slot_ignored(self, db):
"""Aborted slot at same time should not cause conflict."""
_make_slot(db, scheduled_at=time(9, 0), duration=30, status=SlotStatus.ABORTED)
db.commit()
conflicts = check_overlap_for_create(
db, USER_ID, TARGET_DATE, time(9, 0), 30,
)
assert conflicts == []
def test_ongoing_slot_conflicts(self, db):
"""Ongoing slot should still cause conflict."""
_make_slot(db, scheduled_at=time(9, 0), duration=30, status=SlotStatus.ONGOING)
db.commit()
conflicts = check_overlap_for_create(
db, USER_ID, TARGET_DATE, time(9, 0), 30,
)
assert len(conflicts) == 1
def test_deferred_slot_conflicts(self, db):
"""Deferred slot should still cause conflict."""
_make_slot(db, scheduled_at=time(9, 0), duration=30, status=SlotStatus.DEFERRED)
db.commit()
conflicts = check_overlap_for_create(
db, USER_ID, TARGET_DATE, time(9, 0), 30,
)
assert len(conflicts) == 1
# ---------------------------------------------------------------------------
# Edit scenario (exclude own slot)
# ---------------------------------------------------------------------------
class TestEditExcludeSelf:
def test_edit_no_self_conflict(self, db):
"""Editing a slot to the same time should not conflict with itself."""
slot = _make_slot(db, scheduled_at=time(9, 0), duration=30)
db.commit()
conflicts = check_overlap_for_edit(
db, USER_ID, slot.id, TARGET_DATE, time(9, 0), 30,
)
assert conflicts == []
def test_edit_still_detects_others(self, db):
"""Editing a slot detects overlap with *other* slots."""
slot = _make_slot(db, scheduled_at=time(9, 0), duration=30)
_make_slot(db, scheduled_at=time(9, 30), duration=30)
db.commit()
# Move slot to overlap with the second one
conflicts = check_overlap_for_edit(
db, USER_ID, slot.id, TARGET_DATE, time(9, 20), 30,
)
assert len(conflicts) == 1
def test_edit_self_excluded_others_fine(self, db):
"""Moving a slot to a free spot should report no conflicts."""
slot = _make_slot(db, scheduled_at=time(9, 0), duration=30)
_make_slot(db, scheduled_at=time(10, 0), duration=30)
db.commit()
# Move to 11:00 — no overlap
conflicts = check_overlap_for_edit(
db, USER_ID, slot.id, TARGET_DATE, time(11, 0), 30,
)
assert conflicts == []
# ---------------------------------------------------------------------------
# Virtual slot (plan-generated) overlap
# ---------------------------------------------------------------------------
class TestVirtualSlotOverlap:
def test_conflict_with_virtual_slot(self, db):
"""A plan that generates a virtual slot at 09:00 should conflict."""
# TARGET_DATE is 2026-04-01 (Wednesday)
_make_plan(db, at_time=time(9, 0), duration=30, on_day=DayOfWeek.WED)
db.commit()
conflicts = check_overlap_for_create(
db, USER_ID, TARGET_DATE, time(9, 0), 30,
)
assert len(conflicts) == 1
assert conflicts[0].conflicting_virtual_id is not None
assert conflicts[0].conflicting_slot_id is None
def test_no_conflict_with_inactive_plan(self, db):
"""Cancelled plan should not generate a virtual slot to conflict with."""
_make_plan(db, at_time=time(9, 0), duration=30, on_day=DayOfWeek.WED, is_active=False)
db.commit()
conflicts = check_overlap_for_create(
db, USER_ID, TARGET_DATE, time(9, 0), 30,
)
assert conflicts == []
def test_no_conflict_with_non_matching_plan(self, db):
"""Plan for Monday should not generate a virtual slot on Wednesday."""
_make_plan(db, at_time=time(9, 0), duration=30, on_day=DayOfWeek.MON)
db.commit()
conflicts = check_overlap_for_create(
db, USER_ID, TARGET_DATE, time(9, 0), 30,
)
assert conflicts == []
def test_materialized_plan_not_double_counted(self, db):
"""A plan that's already materialized should only be counted as a real slot, not also virtual."""
plan = _make_plan(db, at_time=time(9, 0), duration=30, on_day=DayOfWeek.WED)
_make_slot(db, scheduled_at=time(9, 0), duration=30, plan_id=plan.id)
db.commit()
conflicts = check_overlap_for_create(
db, USER_ID, TARGET_DATE, time(9, 0), 30,
)
# Should only have 1 conflict (the real slot), not 2
assert len(conflicts) == 1
assert conflicts[0].conflicting_slot_id is not None
# ---------------------------------------------------------------------------
# Conflict message content
# ---------------------------------------------------------------------------
class TestConflictMessage:
def test_message_has_time_info(self, db):
"""Conflict message should include time range information."""
_make_slot(db, scheduled_at=time(9, 0), duration=30)
db.commit()
conflicts = check_overlap_for_create(
db, USER_ID, TARGET_DATE, time(9, 15), 30,
)
assert len(conflicts) == 1
msg = conflicts[0].message
assert "09:00" in msg
assert "overlaps" in msg
def test_to_dict(self, db):
"""SlotConflict.to_dict() should return a proper dict."""
_make_slot(db, scheduled_at=time(9, 0), duration=30)
db.commit()
conflicts = check_overlap_for_create(
db, USER_ID, TARGET_DATE, time(9, 0), 30,
)
d = conflicts[0].to_dict()
assert "scheduled_at" in d
assert "estimated_duration" in d
assert "slot_type" in d
assert "message" in d
assert "conflicting_slot_id" in d

284
tests/test_plan_slot.py Normal file
View File

@@ -0,0 +1,284 @@
"""Tests for BE-CAL-005: Plan virtual-slot identification & materialization.
Covers:
- Virtual slot ID generation and parsing
- Plan-date matching logic (on_day, on_week, on_month combinations)
- Virtual slot generation (skipping already-materialized dates)
- Materialization (virtual → real TimeSlot)
- Detach (edit/cancel clears plan_id)
- Bulk materialization for a date
"""
import pytest
from datetime import date, time
from tests.conftest import TestingSessionLocal
from app.models.calendar import (
DayOfWeek,
EventType,
MonthOfYear,
SchedulePlan,
SlotStatus,
SlotType,
TimeSlot,
)
from app.services.plan_slot import (
detach_slot_from_plan,
get_virtual_slots_for_date,
make_virtual_slot_id,
materialize_all_for_date,
materialize_from_virtual_id,
materialize_slot,
parse_virtual_slot_id,
plan_matches_date,
_week_of_month,
)
# ---------------------------------------------------------------------------
# Helpers
# ---------------------------------------------------------------------------
def _make_plan(db, **overrides):
"""Create a SchedulePlan with sensible defaults."""
defaults = dict(
user_id=1,
slot_type=SlotType.WORK,
estimated_duration=30,
at_time=time(9, 0),
is_active=True,
)
defaults.update(overrides)
plan = SchedulePlan(**defaults)
db.add(plan)
db.flush()
return plan
# ---------------------------------------------------------------------------
# Virtual-slot ID
# ---------------------------------------------------------------------------
class TestVirtualSlotId:
def test_make_and_parse_roundtrip(self):
vid = make_virtual_slot_id(42, date(2026, 3, 30))
assert vid == "plan-42-2026-03-30"
parsed = parse_virtual_slot_id(vid)
assert parsed == (42, date(2026, 3, 30))
def test_parse_invalid(self):
assert parse_virtual_slot_id("invalid") is None
assert parse_virtual_slot_id("plan-abc-2026-01-01") is None
assert parse_virtual_slot_id("plan-1-not-a-date") is None
assert parse_virtual_slot_id("") is None
# ---------------------------------------------------------------------------
# Week-of-month helper
# ---------------------------------------------------------------------------
class TestWeekOfMonth:
def test_first_week(self):
# 2026-03-01 is Sunday
assert _week_of_month(date(2026, 3, 1)) == 1 # first Sun
assert _week_of_month(date(2026, 3, 2)) == 1 # first Mon
def test_second_week(self):
assert _week_of_month(date(2026, 3, 8)) == 2 # second Sun
def test_fourth_week(self):
assert _week_of_month(date(2026, 3, 22)) == 4 # fourth Sunday
# ---------------------------------------------------------------------------
# Plan-date matching
# ---------------------------------------------------------------------------
class TestPlanMatchesDate:
def test_daily_plan_matches_any_day(self, db, seed):
plan = _make_plan(db)
db.commit()
assert plan_matches_date(plan, date(2026, 3, 30)) # Monday
assert plan_matches_date(plan, date(2026, 4, 5)) # Sunday
def test_weekly_plan(self, db, seed):
plan = _make_plan(db, on_day=DayOfWeek.MON)
db.commit()
assert plan_matches_date(plan, date(2026, 3, 30)) # Monday
assert not plan_matches_date(plan, date(2026, 3, 31)) # Tuesday
def test_monthly_week_day(self, db, seed):
# First Monday of each month
plan = _make_plan(db, on_day=DayOfWeek.MON, on_week=1)
db.commit()
assert plan_matches_date(plan, date(2026, 3, 2)) # 1st Mon Mar
assert not plan_matches_date(plan, date(2026, 3, 9)) # 2nd Mon Mar
def test_yearly_plan(self, db, seed):
# First Sunday in January
plan = _make_plan(
db, on_day=DayOfWeek.SUN, on_week=1, on_month=MonthOfYear.JAN
)
db.commit()
assert plan_matches_date(plan, date(2026, 1, 4)) # 1st Sun Jan 2026
assert not plan_matches_date(plan, date(2026, 2, 1)) # Feb
def test_inactive_plan_never_matches(self, db, seed):
plan = _make_plan(db, is_active=False)
db.commit()
assert not plan_matches_date(plan, date(2026, 3, 30))
# ---------------------------------------------------------------------------
# Virtual slots for date
# ---------------------------------------------------------------------------
class TestVirtualSlotsForDate:
def test_returns_virtual_when_not_materialized(self, db, seed):
plan = _make_plan(db, on_day=DayOfWeek.MON)
db.commit()
vslots = get_virtual_slots_for_date(db, 1, date(2026, 3, 30))
assert len(vslots) == 1
assert vslots[0]["virtual_id"] == make_virtual_slot_id(plan.id, date(2026, 3, 30))
assert vslots[0]["slot_type"] == SlotType.WORK
assert vslots[0]["status"] == SlotStatus.NOT_STARTED
def test_skips_already_materialized(self, db, seed):
plan = _make_plan(db, on_day=DayOfWeek.MON)
db.commit()
# Materialize
materialize_slot(db, plan.id, date(2026, 3, 30))
db.commit()
vslots = get_virtual_slots_for_date(db, 1, date(2026, 3, 30))
assert len(vslots) == 0
def test_non_matching_date_returns_empty(self, db, seed):
_make_plan(db, on_day=DayOfWeek.MON)
db.commit()
vslots = get_virtual_slots_for_date(db, 1, date(2026, 3, 31)) # Tuesday
assert len(vslots) == 0
# ---------------------------------------------------------------------------
# Materialization
# ---------------------------------------------------------------------------
class TestMaterializeSlot:
def test_basic_materialize(self, db, seed):
plan = _make_plan(db, event_type=EventType.JOB, event_data={"type": "Task", "code": "T-1"})
db.commit()
slot = materialize_slot(db, plan.id, date(2026, 3, 30))
db.commit()
assert slot.id is not None
assert slot.plan_id == plan.id
assert slot.date == date(2026, 3, 30)
assert slot.slot_type == SlotType.WORK
assert slot.event_data == {"type": "Task", "code": "T-1"}
def test_double_materialize_raises(self, db, seed):
plan = _make_plan(db)
db.commit()
materialize_slot(db, plan.id, date(2026, 3, 30))
db.commit()
with pytest.raises(ValueError, match="already materialized"):
materialize_slot(db, plan.id, date(2026, 3, 30))
def test_inactive_plan_raises(self, db, seed):
plan = _make_plan(db, is_active=False)
db.commit()
with pytest.raises(ValueError, match="inactive"):
materialize_slot(db, plan.id, date(2026, 3, 30))
def test_non_matching_date_raises(self, db, seed):
plan = _make_plan(db, on_day=DayOfWeek.MON)
db.commit()
with pytest.raises(ValueError, match="does not match"):
materialize_slot(db, plan.id, date(2026, 3, 31)) # Tuesday
def test_materialize_from_virtual_id(self, db, seed):
plan = _make_plan(db)
db.commit()
vid = make_virtual_slot_id(plan.id, date(2026, 3, 30))
slot = materialize_from_virtual_id(db, vid)
db.commit()
assert slot.id is not None
assert slot.plan_id == plan.id
def test_materialize_from_invalid_virtual_id(self, db, seed):
with pytest.raises(ValueError, match="Invalid virtual slot id"):
materialize_from_virtual_id(db, "garbage")
# ---------------------------------------------------------------------------
# Detach (edit/cancel disconnects plan)
# ---------------------------------------------------------------------------
class TestDetachSlot:
def test_detach_clears_plan_id(self, db, seed):
plan = _make_plan(db)
db.commit()
slot = materialize_slot(db, plan.id, date(2026, 3, 30))
db.commit()
assert slot.plan_id == plan.id
detach_slot_from_plan(slot)
db.commit()
db.refresh(slot)
assert slot.plan_id is None
def test_detached_slot_allows_new_virtual(self, db, seed):
"""After detach, the plan should generate a new virtual slot for
that date — but since the materialized row still exists (just with
plan_id=NULL), the plan will NOT generate a duplicate virtual slot
because get_materialized_plan_dates only checks plan_id match.
After detach plan_id is NULL, so the query won't find it and the
virtual slot *will* appear. This is actually correct: the user
cancelled/edited the original occurrence but a new virtual one
from the plan should still show (user can dismiss again).
Wait — per the design doc, edit/cancel should mean the plan no
longer claims that date. But since the materialized row has
plan_id=NULL, our check won't find it, so a virtual slot *will*
reappear. This is a design nuance — for now we document it.
"""
plan = _make_plan(db)
db.commit()
slot = materialize_slot(db, plan.id, date(2026, 3, 30))
db.commit()
detach_slot_from_plan(slot)
db.commit()
# After detach, virtual slot reappears since plan_id is NULL
# This is expected — the cancel only affects the materialized row
vslots = get_virtual_slots_for_date(db, 1, date(2026, 3, 30))
# NOTE: This returns 1 because the plan still matches and no
# plan_id-linked slot exists. The API layer should handle
# this by checking for cancelled/edited slots separately.
assert len(vslots) == 1
# ---------------------------------------------------------------------------
# Bulk materialization
# ---------------------------------------------------------------------------
class TestBulkMaterialize:
def test_materialize_all_creates_slots(self, db, seed):
_make_plan(db, at_time=time(9, 0))
_make_plan(db, at_time=time(14, 0))
db.commit()
created = materialize_all_for_date(db, 1, date(2026, 3, 30))
db.commit()
assert len(created) == 2
assert all(s.id is not None for s in created)
def test_materialize_all_skips_existing(self, db, seed):
p1 = _make_plan(db, at_time=time(9, 0))
_make_plan(db, at_time=time(14, 0))
db.commit()
# Pre-materialize one
materialize_slot(db, p1.id, date(2026, 3, 30))
db.commit()
created = materialize_all_for_date(db, 1, date(2026, 3, 30))
db.commit()
assert len(created) == 1 # only the second plan

View File

@@ -0,0 +1,481 @@
"""BE-PR-011 — Tests for Proposal / Essential / Story restricted.
Covers:
1. Essential CRUD (create, read, update, delete)
2. Proposal Accept — batch generation of story tasks
3. Story restricted — general create endpoint blocks story/* tasks
4. Backward compatibility with legacy proposal data (feat_task_id read-only)
"""
import pytest
from tests.conftest import auth_header
# ===================================================================
# Helper shortcuts
# ===================================================================
PRJ = "1" # project id
def _create_proposal(client, token, title="Test Proposal", description="desc"):
"""Create an open proposal and return its JSON."""
r = client.post(
f"/projects/{PRJ}/proposals",
json={"title": title, "description": description},
headers=auth_header(token),
)
assert r.status_code == 201, r.text
return r.json()
def _create_essential(client, token, proposal_id, etype="feature", title="Ess 1"):
"""Create an Essential under the given proposal and return its JSON."""
r = client.post(
f"/projects/{PRJ}/proposals/{proposal_id}/essentials",
json={"type": etype, "title": title, "description": f"{etype} essential"},
headers=auth_header(token),
)
assert r.status_code == 201, r.text
return r.json()
# ===================================================================
# 1. Essential CRUD
# ===================================================================
class TestEssentialCRUD:
"""Test creating, listing, reading, updating, and deleting Essentials."""
def test_create_essential(self, client, seed):
proposal = _create_proposal(client, seed["admin_token"])
ess = _create_essential(client, seed["admin_token"], proposal["id"])
assert ess["type"] == "feature"
assert ess["title"] == "Ess 1"
assert ess["proposal_id"] == proposal["id"]
assert ess["essential_code"].endswith(":E00001")
def test_create_multiple_essentials_increments_code(self, client, seed):
proposal = _create_proposal(client, seed["admin_token"])
e1 = _create_essential(client, seed["admin_token"], proposal["id"], "feature", "E1")
e2 = _create_essential(client, seed["admin_token"], proposal["id"], "improvement", "E2")
e3 = _create_essential(client, seed["admin_token"], proposal["id"], "refactor", "E3")
assert e1["essential_code"].endswith(":E00001")
assert e2["essential_code"].endswith(":E00002")
assert e3["essential_code"].endswith(":E00003")
def test_list_essentials(self, client, seed):
proposal = _create_proposal(client, seed["admin_token"])
_create_essential(client, seed["admin_token"], proposal["id"], "feature", "A")
_create_essential(client, seed["admin_token"], proposal["id"], "improvement", "B")
r = client.get(
f"/projects/{PRJ}/proposals/{proposal['id']}/essentials",
headers=auth_header(seed["admin_token"]),
)
assert r.status_code == 200
items = r.json()
assert len(items) == 2
assert items[0]["title"] == "A"
assert items[1]["title"] == "B"
def test_get_single_essential(self, client, seed):
proposal = _create_proposal(client, seed["admin_token"])
ess = _create_essential(client, seed["admin_token"], proposal["id"])
r = client.get(
f"/projects/{PRJ}/proposals/{proposal['id']}/essentials/{ess['id']}",
headers=auth_header(seed["admin_token"]),
)
assert r.status_code == 200
assert r.json()["id"] == ess["id"]
def test_get_essential_by_code(self, client, seed):
proposal = _create_proposal(client, seed["admin_token"])
ess = _create_essential(client, seed["admin_token"], proposal["id"])
r = client.get(
f"/projects/{PRJ}/proposals/{proposal['id']}/essentials/{ess['essential_code']}",
headers=auth_header(seed["admin_token"]),
)
assert r.status_code == 200
assert r.json()["id"] == ess["id"]
def test_update_essential(self, client, seed):
proposal = _create_proposal(client, seed["admin_token"])
ess = _create_essential(client, seed["admin_token"], proposal["id"])
r = client.patch(
f"/projects/{PRJ}/proposals/{proposal['id']}/essentials/{ess['id']}",
json={"title": "Updated Title", "type": "refactor"},
headers=auth_header(seed["admin_token"]),
)
assert r.status_code == 200
data = r.json()
assert data["title"] == "Updated Title"
assert data["type"] == "refactor"
def test_delete_essential(self, client, seed):
proposal = _create_proposal(client, seed["admin_token"])
ess = _create_essential(client, seed["admin_token"], proposal["id"])
r = client.delete(
f"/projects/{PRJ}/proposals/{proposal['id']}/essentials/{ess['id']}",
headers=auth_header(seed["admin_token"]),
)
assert r.status_code == 204
# Verify it's gone
r = client.get(
f"/projects/{PRJ}/proposals/{proposal['id']}/essentials/{ess['id']}",
headers=auth_header(seed["admin_token"]),
)
assert r.status_code == 404
def test_cannot_create_essential_on_accepted_proposal(self, client, seed):
"""Essentials can only be added to open proposals."""
proposal = _create_proposal(client, seed["admin_token"])
_create_essential(client, seed["admin_token"], proposal["id"])
# Accept the proposal
client.post(
f"/projects/{PRJ}/proposals/{proposal['id']}/accept",
json={"milestone_id": 1},
headers=auth_header(seed["admin_token"]),
)
# Try to create another essential → should fail
r = client.post(
f"/projects/{PRJ}/proposals/{proposal['id']}/essentials",
json={"type": "feature", "title": "Late essential"},
headers=auth_header(seed["admin_token"]),
)
assert r.status_code == 400
assert "open" in r.json()["detail"].lower()
def test_cannot_update_essential_on_rejected_proposal(self, client, seed):
proposal = _create_proposal(client, seed["admin_token"])
ess = _create_essential(client, seed["admin_token"], proposal["id"])
# Reject the proposal
client.post(
f"/projects/{PRJ}/proposals/{proposal['id']}/reject",
json={"reason": "not now"},
headers=auth_header(seed["admin_token"]),
)
r = client.patch(
f"/projects/{PRJ}/proposals/{proposal['id']}/essentials/{ess['id']}",
json={"title": "Should fail"},
headers=auth_header(seed["admin_token"]),
)
assert r.status_code == 400
def test_essential_not_found(self, client, seed):
proposal = _create_proposal(client, seed["admin_token"])
r = client.get(
f"/projects/{PRJ}/proposals/{proposal['id']}/essentials/9999",
headers=auth_header(seed["admin_token"]),
)
assert r.status_code == 404
def test_essential_types(self, client, seed):
"""All three essential types should be valid."""
proposal = _create_proposal(client, seed["admin_token"])
for etype in ["feature", "improvement", "refactor"]:
ess = _create_essential(client, seed["admin_token"], proposal["id"], etype, f"T-{etype}")
assert ess["type"] == etype
# ===================================================================
# 2. Proposal Accept — batch story task generation
# ===================================================================
class TestProposalAccept:
"""Test that accepting a Proposal generates story tasks from Essentials."""
def test_accept_generates_story_tasks(self, client, seed):
proposal = _create_proposal(client, seed["admin_token"])
_create_essential(client, seed["admin_token"], proposal["id"], "feature", "Feat 1")
_create_essential(client, seed["admin_token"], proposal["id"], "improvement", "Improv 1")
_create_essential(client, seed["admin_token"], proposal["id"], "refactor", "Refac 1")
r = client.post(
f"/projects/{PRJ}/proposals/{proposal['id']}/accept",
json={"milestone_id": 1},
headers=auth_header(seed["admin_token"]),
)
assert r.status_code == 200, r.text
data = r.json()
assert data["status"] == "accepted"
tasks = data["generated_tasks"]
assert len(tasks) == 3
subtypes = {t["task_subtype"] for t in tasks}
assert subtypes == {"feature", "improvement", "refactor"}
for t in tasks:
assert t["task_type"] == "story"
assert t["essential_id"] is not None
def test_accept_requires_milestone(self, client, seed):
proposal = _create_proposal(client, seed["admin_token"])
_create_essential(client, seed["admin_token"], proposal["id"])
# Missing milestone_id
r = client.post(
f"/projects/{PRJ}/proposals/{proposal['id']}/accept",
json={},
headers=auth_header(seed["admin_token"]),
)
assert r.status_code == 422 # validation error
def test_accept_rejects_invalid_milestone(self, client, seed):
proposal = _create_proposal(client, seed["admin_token"])
_create_essential(client, seed["admin_token"], proposal["id"])
r = client.post(
f"/projects/{PRJ}/proposals/{proposal['id']}/accept",
json={"milestone_id": 9999},
headers=auth_header(seed["admin_token"]),
)
assert r.status_code == 404
assert "milestone" in r.json()["detail"].lower()
def test_accept_requires_at_least_one_essential(self, client, seed):
proposal = _create_proposal(client, seed["admin_token"])
r = client.post(
f"/projects/{PRJ}/proposals/{proposal['id']}/accept",
json={"milestone_id": 1},
headers=auth_header(seed["admin_token"]),
)
assert r.status_code == 400
assert "essential" in r.json()["detail"].lower()
def test_accept_only_open_proposals(self, client, seed):
proposal = _create_proposal(client, seed["admin_token"])
_create_essential(client, seed["admin_token"], proposal["id"])
# Reject first
client.post(
f"/projects/{PRJ}/proposals/{proposal['id']}/reject",
json={"reason": "nope"},
headers=auth_header(seed["admin_token"]),
)
r = client.post(
f"/projects/{PRJ}/proposals/{proposal['id']}/accept",
json={"milestone_id": 1},
headers=auth_header(seed["admin_token"]),
)
assert r.status_code == 400
assert "open" in r.json()["detail"].lower()
def test_accept_sets_source_proposal_id_on_tasks(self, client, seed):
"""Generated tasks should have source_proposal_id and source_essential_id set."""
proposal = _create_proposal(client, seed["admin_token"])
ess = _create_essential(client, seed["admin_token"], proposal["id"])
r = client.post(
f"/projects/{PRJ}/proposals/{proposal['id']}/accept",
json={"milestone_id": 1},
headers=auth_header(seed["admin_token"]),
)
assert r.status_code == 200
tasks = r.json()["generated_tasks"]
assert len(tasks) == 1
assert tasks[0]["essential_id"] == ess["id"]
def test_proposal_detail_includes_generated_tasks(self, client, seed):
"""After accept, proposal detail should include generated_tasks."""
proposal = _create_proposal(client, seed["admin_token"])
_create_essential(client, seed["admin_token"], proposal["id"], "feature", "F1")
client.post(
f"/projects/{PRJ}/proposals/{proposal['id']}/accept",
json={"milestone_id": 1},
headers=auth_header(seed["admin_token"]),
)
r = client.get(
f"/projects/{PRJ}/proposals/{proposal['id']}",
headers=auth_header(seed["admin_token"]),
)
assert r.status_code == 200
data = r.json()
assert len(data["essentials"]) == 1
assert len(data["generated_tasks"]) >= 1
assert data["generated_tasks"][0]["task_type"] == "story"
def test_double_accept_fails(self, client, seed):
"""Accepting an already-accepted proposal should fail."""
proposal = _create_proposal(client, seed["admin_token"])
_create_essential(client, seed["admin_token"], proposal["id"])
client.post(
f"/projects/{PRJ}/proposals/{proposal['id']}/accept",
json={"milestone_id": 1},
headers=auth_header(seed["admin_token"]),
)
r = client.post(
f"/projects/{PRJ}/proposals/{proposal['id']}/accept",
json={"milestone_id": 1},
headers=auth_header(seed["admin_token"]),
)
assert r.status_code == 400
# ===================================================================
# 3. Story restricted — general create blocks story/* tasks
# ===================================================================
class TestStoryRestricted:
"""Test that story/* tasks cannot be created via the general task endpoint."""
def test_create_story_feature_blocked(self, client, seed):
r = client.post(
"/tasks",
json={
"title": "Sneaky story",
"task_type": "story",
"task_subtype": "feature",
"project_id": 1,
"milestone_id": 1,
},
headers=auth_header(seed["admin_token"]),
)
assert r.status_code == 400
assert "story" in r.json()["detail"].lower()
def test_create_story_improvement_blocked(self, client, seed):
r = client.post(
"/tasks",
json={
"title": "Sneaky improvement",
"task_type": "story",
"task_subtype": "improvement",
"project_id": 1,
"milestone_id": 1,
},
headers=auth_header(seed["admin_token"]),
)
assert r.status_code == 400
def test_create_story_refactor_blocked(self, client, seed):
r = client.post(
"/tasks",
json={
"title": "Sneaky refactor",
"task_type": "story",
"task_subtype": "refactor",
"project_id": 1,
"milestone_id": 1,
},
headers=auth_header(seed["admin_token"]),
)
assert r.status_code == 400
def test_create_story_no_subtype_blocked(self, client, seed):
r = client.post(
"/tasks",
json={
"title": "Bare story",
"task_type": "story",
"project_id": 1,
"milestone_id": 1,
},
headers=auth_header(seed["admin_token"]),
)
assert r.status_code == 400
def test_create_issue_still_allowed(self, client, seed):
"""Non-restricted types should still work normally."""
r = client.post(
"/tasks",
json={
"title": "Normal issue",
"task_type": "issue",
"task_subtype": "defect",
"project_id": 1,
"milestone_id": 1,
},
headers=auth_header(seed["admin_token"]),
)
# Should succeed (200 or 201)
assert r.status_code in (200, 201), r.text
def test_story_only_via_proposal_accept(self, client, seed):
"""Story tasks should exist only when created via Proposal Accept."""
proposal = _create_proposal(client, seed["admin_token"])
_create_essential(client, seed["admin_token"], proposal["id"], "feature", "Via Accept")
r = client.post(
f"/projects/{PRJ}/proposals/{proposal['id']}/accept",
json={"milestone_id": 1},
headers=auth_header(seed["admin_token"]),
)
assert r.status_code == 200
tasks = r.json()["generated_tasks"]
assert len(tasks) == 1
assert tasks[0]["task_type"] == "story"
assert tasks[0]["task_subtype"] == "feature"
# ===================================================================
# 4. Legacy / backward compatibility
# ===================================================================
class TestLegacyCompat:
"""Test backward compat with old proposal data (feat_task_id read-only)."""
def test_feat_task_id_in_response(self, client, seed):
"""Response should include feat_task_id (even if None)."""
proposal = _create_proposal(client, seed["admin_token"])
r = client.get(
f"/projects/{PRJ}/proposals/{proposal['id']}",
headers=auth_header(seed["admin_token"]),
)
assert r.status_code == 200
data = r.json()
assert "feat_task_id" in data
# New proposals should have None
assert data["feat_task_id"] is None
def test_feat_task_id_not_writable_via_update(self, client, seed):
"""Clients should not be able to set feat_task_id via PATCH."""
proposal = _create_proposal(client, seed["admin_token"])
r = client.patch(
f"/projects/{PRJ}/proposals/{proposal['id']}",
json={"feat_task_id": "FAKE-TASK-123"},
headers=auth_header(seed["admin_token"]),
)
# Should succeed (ignoring the field) or reject
if r.status_code == 200:
assert r.json()["feat_task_id"] is None # not written
def test_new_accept_does_not_write_feat_task_id(self, client, seed):
"""After accept, feat_task_id should remain None; use generated_tasks."""
proposal = _create_proposal(client, seed["admin_token"])
_create_essential(client, seed["admin_token"], proposal["id"])
r = client.post(
f"/projects/{PRJ}/proposals/{proposal['id']}/accept",
json={"milestone_id": 1},
headers=auth_header(seed["admin_token"]),
)
assert r.status_code == 200
assert r.json()["feat_task_id"] is None
def test_propose_code_alias(self, client, seed):
"""Response should include both proposal_code and propose_code for compat."""
proposal = _create_proposal(client, seed["admin_token"])
assert "proposal_code" in proposal
assert "propose_code" in proposal
assert proposal["proposal_code"] == proposal["propose_code"]

View File

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

View File

@@ -0,0 +1,234 @@
"""Tests for past-slot immutability rules (BE-CAL-008).
Tests cover:
- Editing a past real slot is forbidden
- Cancelling a past real slot is forbidden
- Editing a past virtual slot is forbidden
- Cancelling a past virtual slot is forbidden
- Editing/cancelling today's slots is allowed
- Editing/cancelling future slots is allowed
- Plan-edit / plan-cancel do not retroactively affect past materialized slots
"""
import pytest
from datetime import date, time
from app.models.calendar import (
SchedulePlan,
SlotStatus,
SlotType,
TimeSlot,
DayOfWeek,
)
from app.services.slot_immutability import (
ImmutableSlotError,
guard_edit_real_slot,
guard_cancel_real_slot,
guard_edit_virtual_slot,
guard_cancel_virtual_slot,
get_past_materialized_slot_ids,
guard_plan_edit_no_past_retroaction,
guard_plan_cancel_no_past_retroaction,
)
from app.services.plan_slot import make_virtual_slot_id
TODAY = date(2026, 3, 31)
YESTERDAY = date(2026, 3, 30)
LAST_WEEK = date(2026, 3, 24)
TOMORROW = date(2026, 4, 1)
# ---------------------------------------------------------------------------
# Helper
# ---------------------------------------------------------------------------
def _make_slot(db, seed, slot_date, plan_id=None):
"""Create and return a real TimeSlot."""
slot = TimeSlot(
user_id=seed["admin_user"].id,
date=slot_date,
slot_type=SlotType.WORK,
estimated_duration=30,
scheduled_at=time(9, 0),
status=SlotStatus.NOT_STARTED,
plan_id=plan_id,
)
db.add(slot)
db.flush()
return slot
# ---------------------------------------------------------------------------
# Real slot: edit
# ---------------------------------------------------------------------------
class TestGuardEditRealSlot:
def test_past_slot_raises(self, db, seed):
slot = _make_slot(db, seed, YESTERDAY)
db.commit()
with pytest.raises(ImmutableSlotError, match="Cannot edit"):
guard_edit_real_slot(db, slot, today=TODAY)
def test_today_slot_allowed(self, db, seed):
slot = _make_slot(db, seed, TODAY)
db.commit()
# Should not raise
guard_edit_real_slot(db, slot, today=TODAY)
def test_future_slot_allowed(self, db, seed):
slot = _make_slot(db, seed, TOMORROW)
db.commit()
guard_edit_real_slot(db, slot, today=TODAY)
# ---------------------------------------------------------------------------
# Real slot: cancel
# ---------------------------------------------------------------------------
class TestGuardCancelRealSlot:
def test_past_slot_raises(self, db, seed):
slot = _make_slot(db, seed, YESTERDAY)
db.commit()
with pytest.raises(ImmutableSlotError, match="Cannot cancel"):
guard_cancel_real_slot(db, slot, today=TODAY)
def test_today_slot_allowed(self, db, seed):
slot = _make_slot(db, seed, TODAY)
db.commit()
guard_cancel_real_slot(db, slot, today=TODAY)
def test_future_slot_allowed(self, db, seed):
slot = _make_slot(db, seed, TOMORROW)
db.commit()
guard_cancel_real_slot(db, slot, today=TODAY)
# ---------------------------------------------------------------------------
# Virtual slot: edit
# ---------------------------------------------------------------------------
class TestGuardEditVirtualSlot:
def test_past_virtual_raises(self):
vid = make_virtual_slot_id(1, YESTERDAY)
with pytest.raises(ImmutableSlotError, match="Cannot edit"):
guard_edit_virtual_slot(vid, today=TODAY)
def test_today_virtual_allowed(self):
vid = make_virtual_slot_id(1, TODAY)
guard_edit_virtual_slot(vid, today=TODAY)
def test_future_virtual_allowed(self):
vid = make_virtual_slot_id(1, TOMORROW)
guard_edit_virtual_slot(vid, today=TODAY)
def test_invalid_virtual_id_raises_value_error(self):
with pytest.raises(ValueError, match="Invalid virtual slot id"):
guard_edit_virtual_slot("bad-id", today=TODAY)
# ---------------------------------------------------------------------------
# Virtual slot: cancel
# ---------------------------------------------------------------------------
class TestGuardCancelVirtualSlot:
def test_past_virtual_raises(self):
vid = make_virtual_slot_id(1, YESTERDAY)
with pytest.raises(ImmutableSlotError, match="Cannot cancel"):
guard_cancel_virtual_slot(vid, today=TODAY)
def test_today_virtual_allowed(self):
vid = make_virtual_slot_id(1, TODAY)
guard_cancel_virtual_slot(vid, today=TODAY)
def test_future_virtual_allowed(self):
vid = make_virtual_slot_id(1, TOMORROW)
guard_cancel_virtual_slot(vid, today=TODAY)
# ---------------------------------------------------------------------------
# Plan retroaction: past materialized slots are protected
# ---------------------------------------------------------------------------
class TestPlanNoRetroaction:
def _make_plan_with_slots(self, db, seed):
"""Create a plan with materialized slots in the past, today, and future."""
user_id = seed["admin_user"].id
plan = SchedulePlan(
user_id=user_id,
slot_type=SlotType.WORK,
estimated_duration=30,
at_time=time(9, 0),
is_active=True,
)
db.add(plan)
db.flush()
past_slot = _make_slot(db, seed, LAST_WEEK, plan_id=plan.id)
yesterday_slot = _make_slot(db, seed, YESTERDAY, plan_id=plan.id)
today_slot = _make_slot(db, seed, TODAY, plan_id=plan.id)
future_slot = _make_slot(db, seed, TOMORROW, plan_id=plan.id)
db.commit()
return plan, past_slot, yesterday_slot, today_slot, future_slot
def test_get_past_materialized_slot_ids(self, db, seed):
plan, past_slot, yesterday_slot, today_slot, future_slot = (
self._make_plan_with_slots(db, seed)
)
past_ids = get_past_materialized_slot_ids(db, plan.id, today=TODAY)
assert set(past_ids) == {past_slot.id, yesterday_slot.id}
assert today_slot.id not in past_ids
assert future_slot.id not in past_ids
def test_guard_plan_edit_returns_protected_ids(self, db, seed):
plan, past_slot, yesterday_slot, _, _ = (
self._make_plan_with_slots(db, seed)
)
protected = guard_plan_edit_no_past_retroaction(db, plan.id, today=TODAY)
assert set(protected) == {past_slot.id, yesterday_slot.id}
def test_guard_plan_cancel_returns_protected_ids(self, db, seed):
plan, past_slot, yesterday_slot, _, _ = (
self._make_plan_with_slots(db, seed)
)
protected = guard_plan_cancel_no_past_retroaction(db, plan.id, today=TODAY)
assert set(protected) == {past_slot.id, yesterday_slot.id}
def test_no_past_slots_returns_empty(self, db, seed):
"""If all materialized slots are today or later, no past IDs returned."""
user_id = seed["admin_user"].id
plan = SchedulePlan(
user_id=user_id,
slot_type=SlotType.WORK,
estimated_duration=30,
at_time=time(9, 0),
is_active=True,
)
db.add(plan)
db.flush()
_make_slot(db, seed, TODAY, plan_id=plan.id)
_make_slot(db, seed, TOMORROW, plan_id=plan.id)
db.commit()
past_ids = get_past_materialized_slot_ids(db, plan.id, today=TODAY)
assert past_ids == []
# ---------------------------------------------------------------------------
# ImmutableSlotError attributes
# ---------------------------------------------------------------------------
class TestImmutableSlotError:
def test_error_attributes(self):
err = ImmutableSlotError(YESTERDAY, "edit", detail="test detail")
assert err.slot_date == YESTERDAY
assert err.operation == "edit"
assert err.detail == "test detail"
assert "Cannot edit" in str(err)
assert "2026-03-30" in str(err)
assert "test detail" in str(err)
def test_error_without_detail(self):
err = ImmutableSlotError(YESTERDAY, "cancel")
assert "Cannot cancel" in str(err)
assert "test detail" not in str(err)