52 Commits

Author SHA1 Message Date
6400f7f612 feat(users): PATCH /users/{id}/bind-agent to backfill agents row
Companion endpoint for the cli's upcoming `hf user bind-agent` subcommand.
Lets admin retroactively bind an existing user to (agent_id,
claw_identifier) when that user was created before `hf user create`
supported the binding flags (i.e. all of zhi/lyn/mirror/sherlock/orion/
nav on prod today — agents table has 0 rows even though their user rows
exist).

Schema:
  PATCH /users/{identifier}/bind-agent
  body: {agent_id: str, claw_identifier: str}  // both required
  perm: account.create (admin auto)            // same as POST /users

Behaviour:
  * idempotent: re-bind to the same (agent_id, claw_identifier) → 200
    no-op, no extra row
  * 409 if user is already bound to a different pair
  * 409 if requested agent_id is already in use by another user
  * creates the agents row inline; subsequent /schedule-types/agent/
    {agent_id}/assign etc. then work normally

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-22 19:58:06 +01:00
5b59806e38 Merge pull request 'fix(schedule-type): accept X-API-Key for CRUD' (#19) from feat/schedule-type-apikey-auth into main 2026-05-22 18:36:20 +00:00
23632aa073 fix(schedule-type): accept X-API-Key for CRUD
The /schedule-types/ router was the last surface still gated on
get_current_user (JWT-only). The companion special-slot router
(PR #18) used get_current_user_or_apikey, so the admin flow was:

  * create a schedule_type → DB direct insert (cli can't reach it)
  * add special slot via API → works

Swaps all 5 CRUD endpoints (list / create / patch / delete /
assign-agent) to get_current_user_or_apikey so the same hzhang
admin api_key that works for special-slot creation now works for
schedule_type creation too. /schedule-types/agent/me already uses
X-Agent-ID headers (not user auth), so no change there.

Existing JWT callers are unaffected — get_current_user_or_apikey
tries api_key first then falls back to JWT.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-22 19:35:56 +01:00
7017d3483e Merge pull request 'feat(calendar): maintenance window + schedule_type special slots' (#18) from feat/maintenance-window-and-special-slots into main 2026-05-22 18:19:06 +00:00
dcaaa4259a feat(calendar): maintenance window + schedule_type special slots
## What this adds

1. **Maintenance window on ScheduleType**
   - New columns: maintenance_from / maintenance_to (UTC hours, 0-23)
   - Invariant: window is exactly 1 hour (validated in pydantic;
     maintenance_to must equal (maintenance_from + 1) % 24)
   - Default applied via additive migration: 8:00-9:00 UTC for existing
     rows so deployments don't crash on first boot

2. **ScheduleTypeSpecialSlot** — admin-managed slot template
   - New table schedule_type_special_slots
   - Admin (schedule_type.manage) CRUD via
     /schedule-types/{id}/special-slots
   - Fields: name, description, minute_in_window (0-59 inside the
     parent maintenance window), estimated_duration, priority,
     event_data (JSON merged into materialised slot), is_active
   - Unique constraint (schedule_type_id, name) — name is the stable
     human-readable identifier per cohort

3. **Per-agent materialisation**
   - New service app/services/special_slot_materialiser.py
   - GET /calendar/sync calls materialise_special_slots_for_claw
     (idempotent, one row per agent per template per date)
   - GET /calendar/day calls materialise_special_slots_for_user
   - Materialised rows are slot_type=system, event_type=system_event,
     is_admin_locked=true, special_slot_id pointing back to template
   - Plugin's runSync picks them up like any other due slot via the
     normal real-slots query path

4. **Admin-locked enforcement**
   - New TimeSlot columns: is_admin_locked, special_slot_id (FK to
     schedule_type_special_slots, ON DELETE SET NULL)
   - PATCH /calendar/slots/{id}: refuses any edit on admin-locked
     slots (423)
   - POST /calendar/slots/{id}/cancel: refuses cancel on admin-locked
     (423)
   - PATCH /calendar/slots/{id}/agent-update: admin-locked accept only
     ongoing/paused/finished/aborted statuses (423 on other transitions)

5. **Maintenance-window guard on slot creation**
   - POST /calendar/slots: rejects slot_type=system outright (only
     materialiser may create system slots) and rejects any non-system
     slot whose [scheduled_at, +duration] intersects the calling
     user's schedule_type maintenance window (422). Handles 23->0 wrap

6. **Schema response**
   - TimeSlotResponse / CalendarSlotItem now include is_admin_locked
     and special_slot_id so clients can render the lock indicator and
     trace back to the template

## Migration

Additive only — no destructive changes. Lives in _migrate_schema()
in app/main.py; the new schedule_type_special_slots table is created
by Base.metadata.create_all() on first boot.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-22 19:18:42 +01:00
c6d2ecbf95 Merge pull request 'feature/oidc-login' (#17) from feature/oidc-login into main
Reviewed-on: #17
2026-05-17 21:27:39 +00:00
5a5e3fa2eb fix(security): block OIDC-binding privilege escalation
The oidc-binding PUT/DELETE endpoints allowed any account.create holder
(non-admin role 'account-manager') to bind an attacker-controlled OIDC
identity to the admin account (or unbind admin, reopening the OIDC-only
bootstrap window) — full admin takeover.

Non-admin callers may now only manage bindings of non-privileged
accounts: requests targeting an is_admin user, the built-in
acc-mgr/deleted-user, or any holder of account.create / user.reset-apikey
are rejected with 403. Global admins remain unrestricted, so the
intended "account-manager binds normal users" capability is preserved.

Found by post-feature security audit. Verified locally.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-17 22:07:43 +01:00
1c91cb32fc feat(auth): OIDC-only admin-role bootstrap auto-connect
In OIDC-only mode, before any admin is linked, an IdP user whose token
carries the configured admin role (default "admin"; OIDC_ADMIN_ROLE /
oidc_settings.admin_role) auto-connects to the unbound hf admin on
first OIDC sign-in, then the window self-closes once any admin is
bound. Roles are scanned across userinfo + the (unverified) access
token: realm_access.roles, resource_access.*.roles, roles/role/groups.
Adds admin_role to settings model/env/effective/API and to the wizard
bootstrap config. Replaces the manual admin-subject approach.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-17 21:05:39 +01:00
f64e2a24f8 feat(init): bootstrap OIDC from wizard config
init_wizard applies config['oidc'] on first init: creates the
oidc_settings row and, when admin_subject is given, binds the
bootstrap admin so OIDC-only deployments are reachable. Idempotent —
an existing row / admin binding is preserved (later admin edits via
the API survive restarts).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-17 20:50:59 +01:00
ece2b550fc docs: OIDC feature test plan / test points
Test points for OIDC login, user binding, HARBORFORGE_OIDC_ONLY mode,
and the admin OIDC settings page, with local verification status.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-17 20:40:26 +01:00
f8126d0cbc feat(auth): admin-configurable OIDC provider (oidc_settings)
Persist OIDC config in a single-row oidc_settings table; non-empty DB
fields override the OIDC_* env vars (env = bootstrap default). The
Authlib client is rebuilt when config changes.

- GET/PUT /auth/oidc/settings — admin only, via JWT OR API key. The
  API-key path is the recovery channel when OIDC-only mode is on and
  OIDC is misconfigured (avoids total lockout).
- client_secret is write-only: never returned (has_client_secret bool),
  preserved when the field is left blank on update.
- /auth/config, login/link/callback now use the effective (DB|env)
  config so enabling OIDC needs no redeploy.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-17 20:29:15 +01:00
54b6103880 feat(auth): OIDC login + identity binding + HARBORFORGE_OIDC_ONLY
- Generic OIDC (Authlib discovery) Authorization Code flow; backend
  issues the existing HS256 JWT on success. Unbound identities are
  rejected (no auto-provisioning).
- User.oidc_issuer/oidc_subject (unique together) + startup migration.
- PUT/DELETE /users/{id}/oidc-binding (admin or account-manager;
  JWT or API key; 409 on conflict). Self-link /auth/oidc/link
  (non-OIDC_ONLY only). Public GET /auth/config.
- HARBORFORGE_OIDC_ONLY: /auth/token rejected, create/update ignore
  password (passwordless users; API keys + OIDC still work).
- Dockerfile ARG/ENV HARBORFORGE_OIDC_ONLY; authlib+itsdangerous deps;
  SessionMiddleware for OIDC state. Fixed _user_response to expose
  the new binding fields.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-17 20:22:04 +01:00
d2fafdfe9c Merge security/critical-auth-fixes into main
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-16 17:55:59 +01:00
f03bfe9093 docs: README accuracy pass + Security section
Document the auth/RBAC/SSRF hardening in this branch: mandatory strong
SECRET_KEY (server refuses weak/default), admin-only + masked /api-keys,
admin-only /webhooks with SSRF guard, project role hierarchy, and auth
added to previously-open endpoints. Fixed stale Issues→tasks model.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-16 17:50:25 +01:00
801a63f8bb fix(security): close critical auth/SSRF/RBAC holes
Verified locally end-to-end (before: exploitable, after: blocked).

- config: refuse to start on weak/default/short SECRET_KEY (was
  trivially forgeable JWT -> full admin)
- deps: add reusable require_admin dependency (JWT or API key)
- api-keys: require admin to mint/list/revoke; mask key on list
  (was unauthenticated -> instant admin API key)
- webhooks: whole router now admin-only (was fully unauthenticated
  CRUD + readable logs)
- webhook delivery: validate URL scheme + reject hosts resolving to
  private/loopback/link-local/reserved IPs; disable redirects
  (was a readable SSRF primitive)
- rbac: implement a real project-role hierarchy in check_project_role
  (was a no-op: any member, even guest, passed admin/mgr gates)
- misc: auth on delete_milestone (+ensure_can_edit_milestone),
  worklog create/delete (force caller user_id, owner-only delete),
  /activity and /export/tasks (were unauthenticated data exposure)
- tasks: auth + ensure_can_edit_task on assign_task and batch_assign

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-16 16:53:14 +01:00
b7ae20e43f Merge pull request 'zhi-2026-04-18' (#16) from zhi-2026-04-18 into main
Reviewed-on: #16
2026-05-01 07:24:35 +00:00
69c4e17d0f Merge branch 'main' into zhi-2026-04-18 2026-05-01 07:24:28 +00:00
zhi
8ab9cae474 feat: schedule type system for work/entertainment periods
- New model: ScheduleType (name, work_from/to, entertainment_from/to)
- Agent.schedule_type_id FK to schedule_types
- CRUD API: GET/POST/PATCH/DELETE /schedule-types/
- Agent assignment: PUT /schedule-types/agent/{agent_id}/assign
- Agent self-query: GET /schedule-types/agent/me
- Permissions: schedule_type.read, schedule_type.manage
- Migration: adds schedule_type_id column to agents table

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-21 09:20:51 +00:00
zhi
5b7169a3cf feat: add /calendar/sync endpoint for multi-agent schedule sync
Returns today's slots for all agents on a claw instance, keyed by
agent_id. Used by HF Plugin to maintain a local schedule cache
instead of per-agent heartbeat.

Also records heartbeat for all agents on the instance.

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

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

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

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-16 21:17:13 +00:00
a3be8380c9 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
beb95f7bbe 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
46 changed files with 6355 additions and 581 deletions

View File

@@ -1,25 +1,52 @@
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
# OIDC-only mode: when "true", password login is rejected, user creation
# ignores passwords (passwordless users that sign in via a bound OIDC
# identity / API keys). Overridable at runtime via the same env var.
ARG HARBORFORGE_OIDC_ONLY=false
ENV HARBORFORGE_OIDC_ONLY=${HARBORFORGE_OIDC_ONLY}
# Wait for wizard config, then start uvicorn
EXPOSE 8000
ENTRYPOINT ["./entrypoint.sh"]

232
README.md
View File

@@ -1,100 +1,163 @@
# HarborForge Backend
Agent/人类协同任务管理平台 - FastAPI 后端
The core REST API for HarborForge — an Agent/人类协同任务管理平台 (Agent/Human collaborative task-management platform).
## API Endpoints (38)
Part of the [HarborForge](../README.md) platform.
### Auth
- `POST /auth/token` - 登录获取 JWT token
- `GET /auth/me` - 获取当前用户信息
- **Role:** core REST API — users, projects, tasks, milestones, proposals, RBAC, webhooks, worklogs, notifications, monitor telemetry.
- **Stack:** Python 3.11 · FastAPI · SQLAlchemy · MySQL
- **Port:** `8000`
### Issues
The service reads its database configuration from the AbstractWizard config volume (falling back to env/defaults) and authenticates requests with JWT (HS256) signed by `SECRET_KEY`.
> Issues 和 Search 列表接口返回分页格式:`{items, total, page, page_size, total_pages}`
> Issues 支持排序参数:`sort_by` (created_at/priority/title/due_date/status), `sort_order` (asc/desc)
> Issues 支持额外过滤:`assignee_id`, `tag`
## Run / Build
### Docker
> Issues 和 Search 列表接口返回分页格式:
> Issues 支持排序参数: (created_at/priority/title/due_date/status), (asc/desc)
> Issues 支持额外过滤:,
- `POST /issues` - 创建 issue支持 resolution 决议案类型)
- `GET /issues` - 列表(分页、排序、按 assignee/tag 过滤)(支持按 project/status/type 过滤)
- `GET /issues/{id}` - 详情
- `PATCH /issues/{id}` - 更新
- `DELETE /issues/{id}` - 删除
- `POST /issues/{id}/transition` - 状态变更(触发 webhook
- `GET /search/issues?q=keyword` - 搜索
```bash
docker build -t harborforge-backend .
docker run -p 8000:8000 \
-e SECRET_KEY="$(openssl rand -hex 32)" \
-v /path/to/config:/config \
harborforge-backend
```
### Comments
- `POST /comments` - 创建评论
- `GET /issues/{id}/comments` - 列表
- `PATCH /comments/{id}` - 更新
- `DELETE /comments/{id}` - 删除
### Local (uvicorn)
### Projects
- `POST /projects` - 创建
- `GET /projects` - 列表
- `GET /projects/{id}` - 详情
- `PATCH /projects/{id}` - 更新
- `DELETE /projects/{id}` - 删除
```bash
pip install -r requirements.txt
export SECRET_KEY="$(openssl rand -hex 32)"
uvicorn app.main:app --host 0.0.0.0 --port 8000
```
### Project Members
- `POST /projects/{id}/members` - 添加成员
- `GET /projects/{id}/members` - 列表
- `DELETE /projects/{id}/members/{user_id}` - 移除
On startup the app creates/migrates the schema, runs AbstractWizard
initialization (admin user, default project, default roles), and starts a
background monitor-polling thread.
### Users
- `POST /users` - 注册
- `GET /users` - 列表
- `GET /users/{id}` - 详情
- `PATCH /users/{id}` - 更新
## Configuration
### Webhooks
- `POST /webhooks` - 创建
- `GET /webhooks` - 列表
- `GET /webhooks/{id}` - 详情
- `PATCH /webhooks/{id}` - 更新
- `DELETE /webhooks/{id}` - 删除
- `GET /webhooks/{id}/logs` - 投递日志
Environment variables (also loadable from a `.env` file):
### System
- `GET /health` - 健康检查
- `GET /version` - 版本信息
- `GET /dashboard/stats` - 统计面板
| Variable | Default | Description |
|----------|---------|-------------|
| `SECRET_KEY` | *(none — must be set)* | JWT signing key (HS256). The server **refuses to start** with a weak/default/short value. |
| `DATABASE_URL` | `mysql+pymysql://harborforge:harborforge_pass@mysql:3306/harborforge` | Fallback DB URL when the wizard config volume is absent. |
| `ALGORITHM` | `HS256` | JWT algorithm. |
| `ACCESS_TOKEN_EXPIRE_MINUTES` | `30` | Access-token lifetime. |
| `LOG_LEVEL` | `INFO` | Log level. |
| `CONFIG_DIR` | `/config` | AbstractWizard config volume directory. |
| `CONFIG_FILE` | `harborforge.json` | Config file name within `CONFIG_DIR`. |
### Milestones
- `POST /milestones` - 创建里程碑
- `GET /milestones` - 列表(支持按 project/status 过滤)
- `GET /milestones/{id}` - 详情
- `PATCH /milestones/{id}` - 更新
- `DELETE /milestones/{id}` - 删除
- `GET /milestones/{id}/issues` - 里程碑下的 issue 列表
- `GET /milestones/{id}/progress` - 里程碑完成进度
Database resolution order: **wizard config volume** (`$CONFIG_DIR/$CONFIG_FILE``database` block) → `DATABASE_URL` env → built-in default.
### Notifications
- `GET /notifications` - 列表(支持 user_id, unread_only 过滤)
- `GET /notifications/count` - 未读通知计数
- `POST /notifications/{id}/read` - 标记已读
- `POST /notifications/read-all` - 全部标记已读
## Security
### Issue Assignment
- `POST /issues/{id}/assign` - 指派 issue自动发送通知
The current code enforces the following security posture. These are
operational requirements, not optional hardening.
### Webhook Retry
- `POST /webhooks/{id}/retry/{log_id}` - 重试失败的 webhook 投递
### Mandatory strong `SECRET_KEY`
### Time Tracking (Work Logs)
- `POST /worklogs` - 记录工时
- `GET /issues/{id}/worklogs` - 某 issue 的工时记录
- `GET /issues/{id}/worklogs/summary` - 某 issue 工时汇总
- `GET /users/{id}/worklogs` - 某用户的工时记录
- `DELETE /worklogs/{id}` - 删除工时记录
- `GET /projects/{id}/worklogs/summary` - 项目工时汇总(按用户分组)
`app/core/config.py` validates `SECRET_KEY` at import time and **raises and
refuses to start** if the value is empty, shorter than 32 characters, or a
known default/placeholder (e.g. `change-me-in-production`, `secret`,
`changeme`). Operators **must** provide a strong random key:
### Export
- `GET /export/issues` - 导出 issues CSV
- `GET /issues/overdue` - 逾期未完成的 issue
```bash
openssl rand -hex 32
```
A weak signing key allows JWT forgery and full authentication bypass, so this
check is intentionally fatal.
### API-key management is admin-only and masked
The `/api-keys` endpoints (`POST`, `GET`, `DELETE /api-keys/{id}`) all require
a global admin (`require_admin`). Listing **never returns the full secret**
keys are masked to a short prefix/suffix (e.g. `abc123…9f`). The full key is
only returned once, on creation.
### Webhooks router is admin-only with SSRF protection
The entire `/webhooks` router is mounted with `dependencies=[Depends(require_admin)]`,
so every webhook endpoint (create/list/get/update/delete/logs/retry) requires a
global admin. Webhook delivery (`app/services/webhook.py`) validates the target
URL before sending:
- Only `http`/`https` schemes are allowed.
- The host is DNS-resolved and **every** resolved address is rejected if it is
private, loopback, link-local, multicast, reserved, or unspecified
(SSRF protection).
- HTTP redirects are disabled (`follow_redirects=False`).
### Project role hierarchy enforcement
`check_project_role` in `app/api/rbac.py` enforces a real, ordered role
hierarchy rather than a flat membership check:
```
guest(0) < viewer(1) < member(2) < dev(3) < mgr(4) < admin(5)
```
A caller below the required rank is denied with `403`, and any unknown role on
either side is denied by default. Global admins bypass project-level checks.
### Authentication on previously open endpoints
The following endpoints now require an authenticated caller
(JWT bearer token **or** `X-API-Key`) and enforce ownership/permission:
- `DELETE /milestones/{id}` — requires milestone-edit permission.
- `POST /worklogs` — worklogs are always attributed to the caller; only admins
may log time for another user.
- `DELETE /worklogs/{id}` — caller-scoped; non-admins cannot delete another
user's worklog.
- `POST /tasks/{task_code}/assign` and `POST /tasks/batch/assign`.
- `GET /activity`.
- `GET /export/tasks`.
## Authentication
- `POST /auth/token` — OAuth2 password grant; returns a JWT bearer token.
- Authenticated requests send `Authorization: Bearer <token>` **or**
`X-API-Key: <key>` (API keys map to a user and are created by admins).
- `GET /auth/me` — current user.
- `GET /auth/me/permissions`, `GET /auth/me/apikey-permissions` — permission introspection.
## Key API Areas
| Area | Prefix / Routes | Notes |
|------|-----------------|-------|
| Auth | `/auth/*` | token, current user, permission introspection |
| Users | `/users` | registration, list/detail/update (list & mutate are admin-only) |
| Projects | `/projects` | CRUD, members (`/projects/{id}/members`), worklog summary |
| Project members | `/projects/{id}/members` | add/list/remove with role |
| Milestones | `/projects/{id}/milestones`, `/milestones/{id}` | CRUD, items, progress |
| Milestone actions | preflight / freeze / start / close | lifecycle transitions |
| Tasks | `/tasks` | CRUD, transition, take, assign, batch transition/assign, tags, search |
| Comments | `/comments`, `/tasks/{id}/comments` | CRUD |
| Proposals | `/projects/{code}/proposals` | propose / accept / reject / reopen (legacy `/proposes`) |
| Essentials | proposal essentials | feature/improvement/refactor items |
| Meetings | `/meetings` | create/list/detail/update/delete/attend |
| Roles & RBAC | `/roles` | roles, permissions, role-permission assignment |
| Webhooks | `/webhooks` | **admin-only**; CRUD, logs, retry (SSRF-guarded delivery) |
| API keys | `/api-keys` | **admin-only**; create/list (masked)/revoke |
| Worklogs | `/worklogs`, `/tasks/{id}/worklogs`, `/users/{id}/worklogs` | time tracking & summaries |
| Notifications | `/notifications` | list, unread count, mark read / read-all |
| Activity | `/activity` | activity log (authenticated) |
| Export | `/export/tasks` | CSV export (authenticated) |
| Calendar | `/calendar` | scheduling / time slots |
| Monitor | `/monitor` | public overview, admin providers/servers, heartbeat telemetry |
| Dashboard | `/dashboard/stats` | aggregate statistics |
| System | `/health`, `/version`, `/config/status` | health, version, init status |
## Task Types
| Type | 用途 |
|------|------|
| issue | 普通任务 |
| story | 用户故事 |
| test | 测试用例 |
| resolution | 决议案Agent 僵局提交)|
## CLI
@@ -102,18 +165,9 @@ The legacy Python CLI (`cli.py`) has been retired. Use the Go-based `hf` CLI ins
See [HarborForge.Cli](../HarborForge.Cli/README.md) for installation and usage.
## 技术栈
## Tech Stack
- Python 3.11 + FastAPI
- SQLAlchemy + MySQL
- JWT (python-jose)
- SQLAlchemy + MySQL (auto schema create/migrate on startup)
- JWT (python-jose, HS256) + bcrypt password hashing
- Docker
## Issue Types
| Type | 用途 |
|------|------|
| task | 普通任务 |
| story | 用户故事 |
| test | 测试用例 |
| resolution | 决议案Agent 僵局提交)|

View File

@@ -76,3 +76,10 @@ async def get_current_user_or_apikey(
if token:
return await get_current_user(token=token, db=db)
raise HTTPException(status_code=401, detail="Not authenticated")
def require_admin(current_user: models.User = Depends(get_current_user_or_apikey)) -> models.User:
"""Dependency: caller must be a global admin (JWT or API key)."""
if not getattr(current_user, "is_admin", False):
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="Admin privileges required")
return current_user

View File

@@ -81,12 +81,28 @@ def check_project_role(db: Session, user_id: int, project_id: int, min_role: str
detail="Role not found"
)
# Legacy compatibility: most current routes use non-hierarchical names like dev/mgr.
# For now, any valid membership passes those broad checks; strict edit rules are handled
# by the explicit can_edit_* helpers below.
if min_role in {"dev", "mgr", "viewer", "member", "guest", "admin"}:
return True
# Enforce a real role hierarchy. Higher rank == more privilege.
_RANK = {
"guest": 0,
"viewer": 1,
"member": 2,
"dev": 3,
"mgr": 4,
"admin": 5,
}
role_rank = _RANK.get((role.name or "").lower())
required_rank = _RANK.get((min_role or "member").lower())
if role_rank is None or required_rank is None:
# Unknown role on either side -> deny by default.
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail=f"Insufficient project role (have '{role.name}', need '{min_role}')",
)
if role_rank < required_rank:
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail=f"Insufficient project role (have '{role.name}', need '{min_role}')",
)
return True

View File

@@ -18,6 +18,8 @@ router = APIRouter(prefix="/auth", tags=["Auth"])
@router.post("/token", response_model=Token)
async def login(form_data: OAuth2PasswordRequestForm = Depends(), db: Session = Depends(get_db)):
if settings.HARBORFORGE_OIDC_ONLY:
raise HTTPException(status_code=403, detail="Password login is disabled (OIDC only)")
user = db.query(models.User).filter(models.User.username == form_data.username).first()
if not user or not verify_password(form_data.password, user.hashed_password or ""):
raise HTTPException(status_code=401, detail="Incorrect username or password",

File diff suppressed because it is too large Load Diff

View File

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

View File

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

View File

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

View File

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

View File

@@ -12,7 +12,7 @@ from sqlalchemy import func as sqlfunc
from pydantic import BaseModel
from app.core.config import get_db
from app.api.deps import get_current_user_or_apikey
from app.api.deps import get_current_user_or_apikey, require_admin
from app.api.rbac import check_project_role, ensure_can_edit_milestone
from app.models import models
from app.models.apikey import APIKey
@@ -60,7 +60,8 @@ class APIKeyResponse(BaseModel):
@router.post("/api-keys", response_model=APIKeyResponse, status_code=status.HTTP_201_CREATED, tags=["API Keys"])
def create_api_key(data: APIKeyCreate, db: Session = Depends(get_db)):
def create_api_key(data: APIKeyCreate, db: Session = Depends(get_db),
_: models.User = Depends(require_admin)):
user = db.query(models.User).filter(models.User.id == data.user_id).first()
if not user:
raise HTTPException(status_code=404, detail="User not found")
@@ -73,15 +74,22 @@ def create_api_key(data: APIKeyCreate, db: Session = Depends(get_db)):
@router.get("/api-keys", response_model=List[APIKeyResponse], tags=["API Keys"])
def list_api_keys(user_id: int = None, db: Session = Depends(get_db)):
def list_api_keys(user_id: int = None, db: Session = Depends(get_db),
_: models.User = Depends(require_admin)):
query = db.query(APIKey)
if user_id:
query = query.filter(APIKey.user_id == user_id)
return query.all()
keys = query.all()
# Never expose the full secret on listing; show only a masked prefix.
for k in keys:
if k.key and len(k.key) > 8:
k.key = k.key[:6] + "" + k.key[-2:]
return keys
@router.delete("/api-keys/{key_id}", status_code=status.HTTP_204_NO_CONTENT, tags=["API Keys"])
def revoke_api_key(key_id: int, db: Session = Depends(get_db)):
def revoke_api_key(key_id: int, db: Session = Depends(get_db),
_: models.User = Depends(require_admin)):
key_obj = db.query(APIKey).filter(APIKey.id == key_id).first()
if not key_obj:
raise HTTPException(status_code=404, detail="API key not found")
@@ -106,7 +114,8 @@ class ActivityLogResponse(BaseModel):
@router.get("/activity", response_model=List[ActivityLogResponse], tags=["Activity"])
def list_activity(entity_type: str = None, entity_id: int = None, user_id: int = None,
limit: int = 50, db: Session = Depends(get_db)):
limit: int = 50, db: Session = Depends(get_db),
_: models.User = Depends(get_current_user_or_apikey)):
query = db.query(ActivityLog)
if entity_type:
query = query.filter(ActivityLog.entity_type == entity_type)
@@ -149,18 +158,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)
@@ -198,8 +208,10 @@ def update_milestone(milestone_id: str, ms_update: schemas.MilestoneUpdate, db:
@router.delete("/milestones/{milestone_id}", status_code=status.HTTP_204_NO_CONTENT, tags=["Milestones"])
def delete_milestone(milestone_id: str, db: Session = Depends(get_db)):
def delete_milestone(milestone_id: str, db: Session = Depends(get_db),
current_user: models.User = Depends(get_current_user_or_apikey)):
ms = _resolve_milestone(db, milestone_id)
ensure_can_edit_milestone(db, current_user.id, ms)
db.delete(ms)
db.commit()
return None
@@ -321,16 +333,18 @@ class WorkLogResponse(BaseModel):
@router.post("/worklogs", response_model=WorkLogResponse, status_code=status.HTTP_201_CREATED, tags=["Time Tracking"])
def create_worklog(wl: WorkLogCreate, db: Session = Depends(get_db)):
def create_worklog(wl: WorkLogCreate, db: Session = Depends(get_db),
current_user: models.User = Depends(get_current_user_or_apikey)):
task = db.query(Task).filter(Task.id == wl.task_id).first()
if not task:
raise HTTPException(status_code=404, detail="Task not found")
user = db.query(models.User).filter(models.User.id == wl.user_id).first()
if not user:
raise HTTPException(status_code=404, detail="User not found")
if wl.hours <= 0:
raise HTTPException(status_code=400, detail="Hours must be positive")
db_wl = WorkLog(**wl.model_dump())
data = wl.model_dump()
# Worklogs are always attributed to the caller (non-admins cannot log time for others).
if not current_user.is_admin or not data.get("user_id"):
data["user_id"] = current_user.id
db_wl = WorkLog(**data)
db.add(db_wl)
db.commit()
db.refresh(db_wl)
@@ -369,10 +383,13 @@ def task_worklog_summary(task_id: str, db: Session = Depends(get_db)):
@router.delete("/worklogs/{worklog_id}", status_code=status.HTTP_204_NO_CONTENT, tags=["Time Tracking"])
def delete_worklog(worklog_id: int, db: Session = Depends(get_db)):
def delete_worklog(worklog_id: int, db: Session = Depends(get_db),
current_user: models.User = Depends(get_current_user_or_apikey)):
wl = db.query(WorkLog).filter(WorkLog.id == worklog_id).first()
if not wl:
raise HTTPException(status_code=404, detail="Work log not found")
if not current_user.is_admin and wl.user_id != current_user.id:
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="Cannot delete another user's worklog")
db.delete(wl)
db.commit()
return None
@@ -381,7 +398,8 @@ def delete_worklog(worklog_id: int, db: Session = Depends(get_db)):
# ============ Export ============
@router.get("/export/tasks", tags=["Export"])
def export_tasks_csv(project_id: int = None, db: Session = Depends(get_db)):
def export_tasks_csv(project_id: int = None, db: Session = Depends(get_db),
_: models.User = Depends(get_current_user_or_apikey)):
query = db.query(Task)
if project_id:
query = query.filter(Task.project_id == project_id)
@@ -428,14 +446,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 +484,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 +516,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 +528,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 +541,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 +554,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 +600,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 +631,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 +641,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 +651,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 +687,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 +698,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 +716,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 +736,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 +764,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 +777,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 +794,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 +803,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}

350
app/api/routers/oidc.py Normal file
View File

@@ -0,0 +1,350 @@
"""OIDC (OpenID Connect) login + admin-configurable provider settings.
The OIDC provider can be configured at runtime from the admin UI
(persisted in the oidc_settings table). A stored row's non-empty fields
override the OIDC_* env vars; env values act as bootstrap defaults.
Sign-in policy: an OIDC identity must already be bound to an hf user
(see PUT /users/{id}/oidc-binding). Unbound identities are rejected.
"""
import logging
from datetime import timedelta
from urllib.parse import urlencode
from fastapi import APIRouter, Depends, HTTPException, Request
from fastapi.responses import RedirectResponse
from pydantic import BaseModel
from sqlalchemy.orm import Session
from app.core.config import get_db, settings
from app.models import models
from app.models.oidc_settings import OidcSettings
from app.api.deps import create_access_token, get_current_user, get_current_user_or_apikey
router = APIRouter(prefix="/auth", tags=["Auth"])
logger = logging.getLogger("harborforge.oidc")
# ---- effective config (DB row overrides env) ------------------------------
class EffectiveOidc:
def __init__(self, enabled, issuer, client_id, client_secret,
redirect_uri, scopes, post_login_redirect, admin_role):
self.enabled = enabled
self.issuer = issuer
self.client_id = client_id
self.client_secret = client_secret
self.redirect_uri = redirect_uri
self.scopes = scopes or "openid email profile"
self.post_login_redirect = post_login_redirect
self.admin_role = (admin_role or "admin").strip() or "admin"
@property
def configured(self) -> bool:
return bool(self.enabled and self.issuer and self.client_id)
def fingerprint(self) -> str:
return "|".join([
str(self.enabled), self.issuer or "", self.client_id or "",
self.client_secret or "", self.redirect_uri or "", self.scopes or "",
])
def get_effective_oidc(db: Session) -> EffectiveOidc:
row = db.query(OidcSettings).filter(OidcSettings.id == 1).first()
def pick(db_val, env_val):
return db_val if (db_val is not None and db_val != "") else env_val
if row is None:
return EffectiveOidc(
settings.OIDC_ENABLED, settings.OIDC_ISSUER, settings.OIDC_CLIENT_ID,
settings.OIDC_CLIENT_SECRET, settings.OIDC_REDIRECT_URI,
settings.OIDC_SCOPES, settings.OIDC_POST_LOGIN_REDIRECT,
settings.OIDC_ADMIN_ROLE,
)
return EffectiveOidc(
bool(row.enabled),
pick(row.issuer, settings.OIDC_ISSUER),
pick(row.client_id, settings.OIDC_CLIENT_ID),
pick(row.client_secret, settings.OIDC_CLIENT_SECRET),
pick(row.redirect_uri, settings.OIDC_REDIRECT_URI),
pick(row.scopes, settings.OIDC_SCOPES),
pick(row.post_login_redirect, settings.OIDC_POST_LOGIN_REDIRECT),
pick(getattr(row, "admin_role", None), settings.OIDC_ADMIN_ROLE),
)
# Authlib client cache, rebuilt when the effective config changes.
_oauth = None
_oauth_fp = None
def _client(cfg: EffectiveOidc):
global _oauth, _oauth_fp
if not cfg.configured:
raise HTTPException(status_code=503, detail="OIDC is not configured")
fp = cfg.fingerprint()
if _oauth is None or _oauth_fp != fp:
from authlib.integrations.starlette_client import OAuth
oauth = OAuth()
oauth.register(
name="oidc",
server_metadata_url=cfg.issuer.rstrip("/") + "/.well-known/openid-configuration",
client_id=cfg.client_id,
client_secret=cfg.client_secret,
client_kwargs={"scope": cfg.scopes},
)
_oauth, _oauth_fp = oauth, fp
return _oauth.oidc
def _invalidate_client():
global _oauth, _oauth_fp
_oauth = None
_oauth_fp = None
def _collect_roles(claims: dict, token: dict) -> set[str]:
"""Roles from common OIDC claim shapes, across the ID-token/userinfo
claims and the (unverified) access token — Keycloak puts realm/client
roles in the access token by default."""
pools = [claims if isinstance(claims, dict) else {}]
at = token.get("access_token")
if at:
try:
from jose import jwt as _jwt
pools.append(_jwt.get_unverified_claims(at))
except Exception:
pass
roles: set[str] = set()
for p in pools:
if not isinstance(p, dict):
continue
ra = p.get("realm_access")
if isinstance(ra, dict):
roles.update(ra.get("roles") or [])
res = p.get("resource_access")
if isinstance(res, dict):
for v in res.values():
if isinstance(v, dict):
roles.update(v.get("roles") or [])
for key in ("roles", "role", "groups"):
val = p.get(key)
if isinstance(val, str):
roles.add(val)
elif isinstance(val, (list, tuple)):
roles.update(str(x) for x in val)
return {str(r).strip().lstrip("/").lower() for r in roles if r}
def _frontend(cfg: EffectiveOidc, qs: dict | None = None, fragment: str | None = None) -> str:
base = cfg.post_login_redirect or "/"
url = base
if qs:
url += ("&" if "?" in base else "?") + urlencode(qs)
if fragment:
url += "#" + fragment
return url
# ---- public auth config ---------------------------------------------------
@router.get("/config")
def auth_config(db: Session = Depends(get_db)):
cfg = get_effective_oidc(db)
return {
"oidc_enabled": cfg.configured,
"oidc_only": bool(settings.HARBORFORGE_OIDC_ONLY),
"password_login": not bool(settings.HARBORFORGE_OIDC_ONLY),
"oidc_login_url": "/auth/oidc/login",
}
# ---- sign-in / link flows -------------------------------------------------
@router.get("/oidc/login")
async def oidc_login(request: Request, db: Session = Depends(get_db)):
cfg = get_effective_oidc(db)
oidc = _client(cfg)
request.session.pop("hf_oidc_uid", None)
request.session["hf_oidc_mode"] = "login"
return await oidc.authorize_redirect(request, cfg.redirect_uri)
@router.get("/oidc/link")
async def oidc_link(request: Request, db: Session = Depends(get_db),
current_user: models.User = Depends(get_current_user)):
if settings.HARBORFORGE_OIDC_ONLY:
raise HTTPException(status_code=403, detail="Self-service linking is disabled in OIDC-only mode")
cfg = get_effective_oidc(db)
oidc = _client(cfg)
request.session["hf_oidc_mode"] = "link"
request.session["hf_oidc_uid"] = current_user.id
return await oidc.authorize_redirect(request, cfg.redirect_uri)
@router.get("/oidc/callback")
async def oidc_callback(request: Request, db: Session = Depends(get_db)):
cfg = get_effective_oidc(db)
oidc = _client(cfg)
mode = request.session.pop("hf_oidc_mode", "login")
link_uid = request.session.pop("hf_oidc_uid", None)
try:
token = await oidc.authorize_access_token(request)
except Exception:
return RedirectResponse(_frontend(cfg, {"oidc_error": "exchange_failed"}))
claims = token.get("userinfo") or {}
if not claims:
try:
claims = await oidc.userinfo(token=token)
except Exception:
claims = {}
subject = claims.get("sub")
issuer = claims.get("iss") or cfg.issuer
if not subject:
return RedirectResponse(_frontend(cfg, {"oidc_error": "no_subject"}))
if mode == "link":
if settings.HARBORFORGE_OIDC_ONLY or link_uid is None:
return RedirectResponse(_frontend(cfg, {"oidc_error": "link_not_allowed"}))
user = db.query(models.User).filter(models.User.id == link_uid).first()
if not user:
return RedirectResponse(_frontend(cfg, {"oidc_error": "user_gone"}))
clash = db.query(models.User).filter(
models.User.oidc_issuer == issuer,
models.User.oidc_subject == subject,
models.User.id != user.id,
).first()
if clash:
return RedirectResponse(_frontend(cfg, {"oidc_error": "already_bound"}))
user.oidc_issuer = issuer
user.oidc_subject = subject
db.commit()
return RedirectResponse(_frontend(cfg, {"oidc_linked": "1"}))
user = db.query(models.User).filter(
models.User.oidc_issuer == issuer,
models.User.oidc_subject == subject,
).first()
# OIDC-only bootstrap: before any admin is linked, an IdP user whose
# token carries the configured admin role auto-connects to the unbound
# hf admin. Self-closes once any admin is bound.
if user is None and settings.HARBORFORGE_OIDC_ONLY:
any_admin_bound = db.query(models.User).filter(
models.User.is_admin == True, # noqa: E712
models.User.oidc_subject.isnot(None),
).first()
if not any_admin_bound and cfg.admin_role.lower() in _collect_roles(claims, token):
taken = db.query(models.User).filter(
models.User.oidc_issuer == issuer,
models.User.oidc_subject == subject,
).first()
if taken is None:
boot = db.query(models.User).filter(
models.User.is_admin == True, # noqa: E712
models.User.is_active == True, # noqa: E712
models.User.oidc_subject.is_(None),
).order_by(models.User.id).first()
if boot is not None:
boot.oidc_issuer = issuer
boot.oidc_subject = subject
db.commit()
logger.info("OIDC bootstrap: auto-connected admin '%s' via admin role", boot.username)
user = boot
if not user or not user.is_active or user.username in ("acc-mgr", "deleted-user"):
return RedirectResponse(_frontend(cfg, {"oidc_error": "not_linked"}))
access_token = create_access_token(
data={"sub": str(user.id)},
expires_delta=timedelta(minutes=settings.ACCESS_TOKEN_EXPIRE_MINUTES),
)
return RedirectResponse(_frontend(cfg, fragment=urlencode({"token": access_token})))
# ---- admin: OIDC provider settings ----------------------------------------
def _require_admin_any(current_user: models.User = Depends(get_current_user_or_apikey)) -> models.User:
"""Admin via JWT OR API key. The API-key path is the recovery channel
when OIDC-only mode is on and OIDC is not yet/incorrectly configured."""
if not getattr(current_user, "is_admin", False):
raise HTTPException(status_code=403, detail="Admin privileges required")
return current_user
class OidcSettingsIn(BaseModel):
enabled: bool | None = None
issuer: str | None = None
client_id: str | None = None
client_secret: str | None = None # blank/omitted = keep existing
redirect_uri: str | None = None
scopes: str | None = None
post_login_redirect: str | None = None
admin_role: str | None = None
class OidcSettingsOut(BaseModel):
enabled: bool
issuer: str | None
client_id: str | None
has_client_secret: bool
redirect_uri: str | None
scopes: str | None
post_login_redirect: str | None
admin_role: str
oidc_only: bool # read-only (deploy env)
effective_enabled: bool # provider actually usable
source: str # "db" or "env"
@router.get("/oidc/settings", response_model=OidcSettingsOut)
def get_oidc_settings(db: Session = Depends(get_db), _: models.User = Depends(_require_admin_any)):
row = db.query(OidcSettings).filter(OidcSettings.id == 1).first()
cfg = get_effective_oidc(db)
return OidcSettingsOut(
enabled=bool(row.enabled) if row else bool(settings.OIDC_ENABLED),
issuer=(row.issuer if row else None) or settings.OIDC_ISSUER or None,
client_id=(row.client_id if row else None) or settings.OIDC_CLIENT_ID or None,
has_client_secret=bool((row.client_secret if row else None) or settings.OIDC_CLIENT_SECRET),
redirect_uri=(row.redirect_uri if row else None) or settings.OIDC_REDIRECT_URI or None,
scopes=(row.scopes if row else None) or settings.OIDC_SCOPES or None,
post_login_redirect=(row.post_login_redirect if row else None) or settings.OIDC_POST_LOGIN_REDIRECT or None,
admin_role=cfg.admin_role,
oidc_only=bool(settings.HARBORFORGE_OIDC_ONLY),
effective_enabled=cfg.configured,
source="db" if row else "env",
)
@router.put("/oidc/settings", response_model=OidcSettingsOut)
def update_oidc_settings(payload: OidcSettingsIn, db: Session = Depends(get_db),
_: models.User = Depends(_require_admin_any)):
row = db.query(OidcSettings).filter(OidcSettings.id == 1).first()
if row is None:
row = OidcSettings(id=1, enabled=False)
db.add(row)
if payload.enabled is not None:
row.enabled = payload.enabled
if payload.issuer is not None:
row.issuer = payload.issuer.strip() or None
if payload.client_id is not None:
row.client_id = payload.client_id.strip() or None
# client_secret: only overwrite when a non-empty value is supplied
if payload.client_secret:
row.client_secret = payload.client_secret
if payload.redirect_uri is not None:
row.redirect_uri = payload.redirect_uri.strip() or None
if payload.scopes is not None:
row.scopes = payload.scopes.strip() or None
if payload.post_login_redirect is not None:
row.post_login_redirect = payload.post_login_redirect.strip() or None
if payload.admin_role is not None:
row.admin_role = payload.admin_role.strip() or None
db.commit()
_invalidate_client()
return get_oidc_settings(db=db, _=_)

View File

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

View File

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

View File

@@ -0,0 +1,209 @@
"""ScheduleType API router.
CRUD for schedule types (work/entertainment time periods)
and agent schedule type assignment.
"""
from fastapi import APIRouter, Depends, HTTPException, Header
from sqlalchemy.orm import Session
from typing import List
from app.core.config import get_db
from app.api.deps import get_current_user_or_apikey
from app.models.models import User
from app.models.agent import Agent
from app.models.schedule_type import ScheduleType
from app.models.role_permission import Permission, RolePermission
from app.schemas.schedule_type import (
ScheduleTypeCreate,
ScheduleTypeUpdate,
ScheduleTypeResponse,
AgentScheduleTypeAssign,
)
router = APIRouter(prefix="/schedule-types", tags=["ScheduleTypes"])
# ---------------------------------------------------------------------------
# Permission helpers
# ---------------------------------------------------------------------------
def _has_permission(db: Session, user: User, permission_name: str) -> bool:
if user.is_admin:
return True
if not user.role_id:
return False
return (
db.query(RolePermission)
.join(Permission)
.filter(
RolePermission.role_id == user.role_id,
Permission.name == permission_name,
)
.first()
is not None
)
def _require_schedule_read(db: Session, user: User) -> User:
if not _has_permission(db, user, "schedule_type.read"):
raise HTTPException(403, "Permission denied: schedule_type.read")
return user
def _require_schedule_manage(db: Session, user: User) -> User:
if not _has_permission(db, user, "schedule_type.manage"):
raise HTTPException(403, "Permission denied: schedule_type.manage")
return user
# ---------------------------------------------------------------------------
# Schedule Type CRUD
# ---------------------------------------------------------------------------
@router.get(
"/",
response_model=List[ScheduleTypeResponse],
summary="List all schedule types",
)
def list_schedule_types(
db: Session = Depends(get_db),
current_user: User = Depends(get_current_user_or_apikey),
):
_require_schedule_read(db, current_user)
return db.query(ScheduleType).all()
@router.post(
"/",
response_model=ScheduleTypeResponse,
summary="Create a schedule type",
)
def create_schedule_type(
payload: ScheduleTypeCreate,
db: Session = Depends(get_db),
current_user: User = Depends(get_current_user_or_apikey),
):
_require_schedule_manage(db, current_user)
existing = db.query(ScheduleType).filter(ScheduleType.name == payload.name).first()
if existing:
raise HTTPException(409, f"Schedule type '{payload.name}' already exists")
st = ScheduleType(
name=payload.name,
work_from=payload.work_from,
work_to=payload.work_to,
entertainment_from=payload.entertainment_from,
entertainment_to=payload.entertainment_to,
)
db.add(st)
db.commit()
db.refresh(st)
return st
@router.patch(
"/{schedule_type_id}",
response_model=ScheduleTypeResponse,
summary="Update a schedule type",
)
def update_schedule_type(
schedule_type_id: int,
payload: ScheduleTypeUpdate,
db: Session = Depends(get_db),
current_user: User = Depends(get_current_user_or_apikey),
):
_require_schedule_manage(db, current_user)
st = db.query(ScheduleType).filter(ScheduleType.id == schedule_type_id).first()
if not st:
raise HTTPException(404, "Schedule type not found")
for field, value in payload.model_dump(exclude_unset=True).items():
setattr(st, field, value)
db.commit()
db.refresh(st)
return st
@router.delete(
"/{schedule_type_id}",
summary="Delete a schedule type",
)
def delete_schedule_type(
schedule_type_id: int,
db: Session = Depends(get_db),
current_user: User = Depends(get_current_user_or_apikey),
):
_require_schedule_manage(db, current_user)
st = db.query(ScheduleType).filter(ScheduleType.id == schedule_type_id).first()
if not st:
raise HTTPException(404, "Schedule type not found")
# Check if any agents are using this schedule type
agents_using = db.query(Agent).filter(Agent.schedule_type_id == schedule_type_id).count()
if agents_using > 0:
raise HTTPException(
409,
f"Cannot delete: {agents_using} agent(s) are assigned to this schedule type",
)
db.delete(st)
db.commit()
return {"ok": True, "deleted": schedule_type_id}
# ---------------------------------------------------------------------------
# Agent schedule type assignment (agent-facing, uses X-Agent-ID header)
# ---------------------------------------------------------------------------
@router.get(
"/agent/me",
response_model=ScheduleTypeResponse | None,
summary="Get my schedule type",
)
def get_my_schedule_type(
x_agent_id: str = Header(..., alias="X-Agent-ID"),
x_claw_identifier: str = Header(..., alias="X-Claw-Identifier"),
db: Session = Depends(get_db),
):
agent = (
db.query(Agent)
.filter(Agent.agent_id == x_agent_id, Agent.claw_identifier == x_claw_identifier)
.first()
)
if not agent:
raise HTTPException(404, "Agent not found")
if not agent.schedule_type_id:
return None
return db.query(ScheduleType).filter(ScheduleType.id == agent.schedule_type_id).first()
@router.put(
"/agent/{agent_id}/assign",
summary="Assign a schedule type to an agent",
)
def assign_schedule_type(
agent_id: str,
payload: AgentScheduleTypeAssign,
db: Session = Depends(get_db),
current_user: User = Depends(get_current_user_or_apikey),
):
_require_schedule_manage(db, current_user)
agent = db.query(Agent).filter(Agent.agent_id == agent_id).first()
if not agent:
raise HTTPException(404, f"Agent '{agent_id}' not found")
st = db.query(ScheduleType).filter(ScheduleType.name == payload.schedule_type_name).first()
if not st:
raise HTTPException(404, f"Schedule type '{payload.schedule_type_name}' not found")
agent.schedule_type_id = st.id
db.commit()
return {"ok": True, "agent_id": agent_id, "schedule_type": st.name}

View File

@@ -0,0 +1,223 @@
"""Special-slot CRUD for a ScheduleType (admin-only).
A "special slot" is a recurring slot template tied to a ScheduleType.
The system materialises one `time_slots` row per agent on that
schedule_type per date, scheduled inside the schedule_type's
maintenance window. Materialised rows are `is_admin_locked=true` —
agents can complete / abort / pause / resume them but cannot move
or cancel them.
All endpoints require `schedule_type.manage` (admin auto-grants).
"""
from typing import List
from fastapi import APIRouter, Depends, HTTPException
from sqlalchemy.orm import Session
from app.core.config import get_db
from app.api.deps import get_current_user_or_apikey
from app.models.models import User
from app.models.role_permission import Permission, RolePermission
from app.models.schedule_type import ScheduleType
from app.models.schedule_type_special_slot import ScheduleTypeSpecialSlot
from app.schemas.schedule_type_special_slot import (
SpecialSlotCreate,
SpecialSlotUpdate,
SpecialSlotResponse,
)
router = APIRouter(prefix="/schedule-types", tags=["ScheduleTypes"])
# ---------------------------------------------------------------------------
# Permission helpers — mirror schedule_type.py's local helpers so this router
# doesn't have to depend on internal symbols of the other router.
# ---------------------------------------------------------------------------
def _has_permission(db: Session, user: User, permission_name: str) -> bool:
if user.is_admin:
return True
if not user.role_id:
return False
return (
db.query(RolePermission)
.join(Permission)
.filter(
RolePermission.role_id == user.role_id,
Permission.name == permission_name,
)
.first()
is not None
)
def _require_schedule_manage(db: Session, user: User) -> User:
if not _has_permission(db, user, "schedule_type.manage"):
raise HTTPException(403, "Permission denied: schedule_type.manage")
return user
def _require_schedule_read(db: Session, user: User) -> User:
if not _has_permission(db, user, "schedule_type.read"):
raise HTTPException(403, "Permission denied: schedule_type.read")
return user
def _fetch_schedule_type(db: Session, schedule_type_id: int) -> ScheduleType:
st = db.query(ScheduleType).filter(ScheduleType.id == schedule_type_id).first()
if not st:
raise HTTPException(404, f"ScheduleType {schedule_type_id} not found")
return st
def _validate_fits_window(
minute_in_window: int,
estimated_duration: int,
) -> None:
"""Reject special slots that wouldn't fit inside the 1-hour window."""
if minute_in_window + estimated_duration > 60:
raise HTTPException(
422,
(
f"special slot does not fit in maintenance window: "
f"minute_in_window={minute_in_window} + "
f"estimated_duration={estimated_duration} > 60"
),
)
# ---------------------------------------------------------------------------
# Endpoints
# ---------------------------------------------------------------------------
@router.get(
"/{schedule_type_id}/special-slots",
response_model=List[SpecialSlotResponse],
summary="List special slots for a schedule type",
)
def list_special_slots(
schedule_type_id: int,
db: Session = Depends(get_db),
current_user: User = Depends(get_current_user_or_apikey),
):
_require_schedule_read(db, current_user)
_fetch_schedule_type(db, schedule_type_id)
return (
db.query(ScheduleTypeSpecialSlot)
.filter(ScheduleTypeSpecialSlot.schedule_type_id == schedule_type_id)
.order_by(
ScheduleTypeSpecialSlot.minute_in_window.asc(),
ScheduleTypeSpecialSlot.id.asc(),
)
.all()
)
@router.post(
"/{schedule_type_id}/special-slots",
response_model=SpecialSlotResponse,
summary="Create a special slot for a schedule type (admin)",
)
def create_special_slot(
schedule_type_id: int,
payload: SpecialSlotCreate,
db: Session = Depends(get_db),
current_user: User = Depends(get_current_user_or_apikey),
):
_require_schedule_manage(db, current_user)
_fetch_schedule_type(db, schedule_type_id)
_validate_fits_window(payload.minute_in_window, payload.estimated_duration)
dup = (
db.query(ScheduleTypeSpecialSlot)
.filter(
ScheduleTypeSpecialSlot.schedule_type_id == schedule_type_id,
ScheduleTypeSpecialSlot.name == payload.name,
)
.first()
)
if dup:
raise HTTPException(
409,
f"special slot '{payload.name}' already exists for schedule_type {schedule_type_id}",
)
slot = ScheduleTypeSpecialSlot(
schedule_type_id=schedule_type_id,
name=payload.name,
description=payload.description,
minute_in_window=payload.minute_in_window,
estimated_duration=payload.estimated_duration,
priority=payload.priority,
event_data=payload.event_data,
is_active=payload.is_active,
created_by_user_id=current_user.id,
)
db.add(slot)
db.commit()
db.refresh(slot)
return slot
@router.patch(
"/{schedule_type_id}/special-slots/{slot_id}",
response_model=SpecialSlotResponse,
summary="Update a special slot (admin)",
)
def update_special_slot(
schedule_type_id: int,
slot_id: int,
payload: SpecialSlotUpdate,
db: Session = Depends(get_db),
current_user: User = Depends(get_current_user_or_apikey),
):
_require_schedule_manage(db, current_user)
slot = (
db.query(ScheduleTypeSpecialSlot)
.filter(
ScheduleTypeSpecialSlot.id == slot_id,
ScheduleTypeSpecialSlot.schedule_type_id == schedule_type_id,
)
.first()
)
if not slot:
raise HTTPException(404, "Special slot not found")
update_fields = payload.model_dump(exclude_unset=True)
next_min = update_fields.get("minute_in_window", slot.minute_in_window)
next_dur = update_fields.get("estimated_duration", slot.estimated_duration)
_validate_fits_window(next_min, next_dur)
for field, value in update_fields.items():
setattr(slot, field, value)
db.commit()
db.refresh(slot)
return slot
@router.delete(
"/{schedule_type_id}/special-slots/{slot_id}",
summary="Delete a special slot (admin)",
)
def delete_special_slot(
schedule_type_id: int,
slot_id: int,
db: Session = Depends(get_db),
current_user: User = Depends(get_current_user_or_apikey),
):
_require_schedule_manage(db, current_user)
slot = (
db.query(ScheduleTypeSpecialSlot)
.filter(
ScheduleTypeSpecialSlot.id == slot_id,
ScheduleTypeSpecialSlot.schedule_type_id == schedule_type_id,
)
.first()
)
if not slot:
raise HTTPException(404, "Special slot not found")
db.delete(slot)
db.commit()
return {"ok": True, "deleted": slot_id}

View File

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

View File

@@ -7,8 +7,9 @@ from pydantic import BaseModel
from sqlalchemy.exc import IntegrityError
from sqlalchemy.orm import Session
from app.api.deps import get_current_user, get_password_hash
from app.core.config import get_db
from app.api.deps import get_current_user, get_current_user_or_apikey, get_password_hash
from app.core.config import get_db, settings
from app.init_wizard import DELETED_USER_USERNAME
from app.models import models
from app.models.agent import Agent
from app.models.role_permission import Permission, Role, RolePermission
@@ -30,6 +31,9 @@ def _user_response(user: models.User) -> dict:
"role_id": user.role_id,
"role_name": user.role_name,
"agent_id": user.agent.agent_id if user.agent else None,
"discord_user_id": user.discord_user_id,
"oidc_issuer": user.oidc_issuer,
"oidc_subject": user.oidc_subject,
"created_at": user.created_at,
}
return data
@@ -57,7 +61,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
@@ -109,11 +113,18 @@ def create_user(
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
# In OIDC-only mode, ignore any supplied password: the user is created
# passwordless (cannot password-login) and is expected to sign in via a
# bound OIDC identity. API keys still work for such users.
if settings.HARBORFORGE_OIDC_ONLY:
hashed_password = None
else:
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,
@@ -188,7 +199,7 @@ def update_user(
if payload.full_name is not None:
user.full_name = payload.full_name
if payload.password is not None and payload.password.strip():
if payload.password is not None and payload.password.strip() and not settings.HARBORFORGE_OIDC_ONLY:
user.hashed_password = get_password_hash(payload.password)
if payload.role_id is not None:
@@ -202,11 +213,159 @@ def update_user(
raise HTTPException(status_code=400, detail="You cannot deactivate your own account")
user.is_active = payload.is_active
if payload.discord_user_id is not None:
user.discord_user_id = payload.discord_user_id or None
db.commit()
db.refresh(user)
return _user_response(user)
@router.patch("/{identifier}/bind-agent", response_model=schemas.UserResponse)
def bind_agent(
identifier: str,
payload: schemas.UserBindAgentRequest,
db: Session = Depends(get_db),
_: models.User = Depends(require_account_creator),
):
"""Bind an existing user to (agent_id, claw_identifier).
Backfill path for users that were created via `hf user create` before
the cli supported `--agent-id` / `--claw-identifier` flags. Creates
the `agents` row that should have been written at user-create time.
Idempotent: if the user is already bound to the same
(agent_id, claw_identifier), returns the user unchanged (200, no-op).
Rejects (409) if:
- the user is bound to a DIFFERENT (agent_id, claw_identifier)
- the requested agent_id is already in use by another user
Permission: account.create (admin auto-grants) — same gate as
POST /users so the surface stays symmetric.
"""
user = _find_user_by_id_or_username(db, identifier)
if not user:
raise HTTPException(status_code=404, detail="User not found")
existing_agent_for_user = db.query(Agent).filter(Agent.user_id == user.id).first()
if existing_agent_for_user:
if (
existing_agent_for_user.agent_id == payload.agent_id
and existing_agent_for_user.claw_identifier == payload.claw_identifier
):
# idempotent re-bind
return _user_response(user)
raise HTTPException(
status_code=409,
detail=(
f"User '{user.username}' is already bound to agent "
f"'{existing_agent_for_user.agent_id}' on claw "
f"'{existing_agent_for_user.claw_identifier}'"
),
)
existing_for_agent_id = (
db.query(Agent).filter(Agent.agent_id == payload.agent_id).first()
)
if existing_for_agent_id:
raise HTTPException(
status_code=409,
detail=f"agent_id '{payload.agent_id}' already in use by another user",
)
db.add(
Agent(
user_id=user.id,
agent_id=payload.agent_id,
claw_identifier=payload.claw_identifier,
)
)
db.commit()
db.refresh(user)
return _user_response(user)
_BUILTIN_USERNAMES = {"acc-mgr", DELETED_USER_USERNAME}
def _reassign_user_references(db: Session, old_id: int, new_id: int) -> None:
"""Reassign all foreign key references from old_id to new_id, then delete
records that would be meaningless under deleted-user (api_keys, notifications,
project memberships)."""
from app.models.apikey import APIKey
from app.models.notification import Notification
from app.models.activity import ActivityLog as Activity
from app.models.worklog import WorkLog as WorkLogModel
from app.models.meeting import Meeting, MeetingParticipant
from app.models.task import Task
from app.models.support import Support
from app.models.proposal import Proposal
from app.models.milestone import Milestone
from app.models.calendar import TimeSlot, SchedulePlan
from app.models.minimum_workload import MinimumWorkload
from app.models.essential import Essential
# Delete records that are meaningless without the real user
db.query(APIKey).filter(APIKey.user_id == old_id).delete()
db.query(Notification).filter(Notification.user_id == old_id).delete()
db.query(models.ProjectMember).filter(models.ProjectMember.user_id == old_id).delete()
# Reassign ownership/authorship references
db.query(models.Project).filter(models.Project.owner_id == old_id).update(
{"owner_id": new_id})
db.query(models.Comment).filter(models.Comment.author_id == old_id).update(
{"author_id": new_id})
db.query(Activity).filter(Activity.user_id == old_id).update(
{"user_id": new_id})
db.query(WorkLogModel).filter(WorkLogModel.user_id == old_id).update(
{"user_id": new_id})
# Tasks
db.query(Task).filter(Task.reporter_id == old_id).update(
{"reporter_id": new_id})
db.query(Task).filter(Task.assignee_id == old_id).update(
{"assignee_id": new_id})
db.query(Task).filter(Task.created_by_id == old_id).update(
{"created_by_id": new_id})
# Meetings
db.query(Meeting).filter(Meeting.reporter_id == old_id).update(
{"reporter_id": new_id})
db.query(MeetingParticipant).filter(MeetingParticipant.user_id == old_id).update(
{"user_id": new_id})
# Support
db.query(Support).filter(Support.reporter_id == old_id).update(
{"reporter_id": new_id})
db.query(Support).filter(Support.assignee_id == old_id).update(
{"assignee_id": new_id})
# Proposals
db.query(Proposal).filter(Proposal.created_by_id == old_id).update(
{"created_by_id": new_id})
# Milestones
db.query(Milestone).filter(Milestone.created_by_id == old_id).update(
{"created_by_id": new_id})
# Calendar
db.query(TimeSlot).filter(TimeSlot.user_id == old_id).update(
{"user_id": new_id})
db.query(SchedulePlan).filter(SchedulePlan.user_id == old_id).update(
{"user_id": new_id})
# Minimum workload / Essential
db.query(MinimumWorkload).filter(MinimumWorkload.user_id == old_id).update(
{"user_id": new_id})
db.query(Essential).filter(Essential.created_by_id == old_id).update(
{"created_by_id": new_id})
# Agent profile
db.query(Agent).filter(Agent.user_id == old_id).update(
{"user_id": new_id})
@router.delete("/{identifier}", status_code=status.HTTP_204_NO_CONTENT)
def delete_user(
identifier: str,
@@ -218,17 +377,26 @@ def delete_user(
raise HTTPException(status_code=404, detail="User not found")
if current_user.id == user.id:
raise HTTPException(status_code=400, detail="You cannot delete your own account")
# Protect built-in accounts from deletion
if user.is_admin:
raise HTTPException(status_code=400, detail="Admin accounts cannot be deleted")
if user.username == "acc-mgr":
raise HTTPException(status_code=400, detail="The acc-mgr account is a built-in account and cannot be deleted")
try:
db.delete(user)
db.commit()
except IntegrityError:
db.rollback()
raise HTTPException(status_code=400, detail="User has related records. Deactivate the account instead.")
if user.username in _BUILTIN_USERNAMES:
raise HTTPException(
status_code=400,
detail=f"The {user.username} account is a built-in account and cannot be deleted",
)
deleted_user = db.query(models.User).filter(
models.User.username == DELETED_USER_USERNAME
).first()
if not deleted_user:
raise HTTPException(
status_code=500,
detail="Built-in deleted-user account not found. Run init_wizard first.",
)
_reassign_user_references(db, user.id, deleted_user.id)
db.delete(user)
db.commit()
return None
@@ -236,7 +404,7 @@ def delete_user(
def reset_user_apikey(
identifier: str,
db: Session = Depends(get_db),
current_user: models.User = Depends(get_current_user),
current_user: models.User = Depends(get_current_user_or_apikey),
):
"""Reset (regenerate) a user's API key.
@@ -244,6 +412,8 @@ def reset_user_apikey(
- user.reset-apikey: can reset any user's API key
- user.reset-self-apikey: can reset only own API key
- admin: can reset any user's API key
Accepts both OAuth2 Bearer token and X-API-Key authentication.
"""
import secrets
from app.models.apikey import APIKey
@@ -317,3 +487,92 @@ def list_user_worklogs(
if current_user.id != user.id and not current_user.is_admin:
raise HTTPException(status_code=403, detail="Forbidden")
return db.query(WorkLog).filter(WorkLog.user_id == user.id).order_by(WorkLog.logged_date.desc()).limit(limit).all()
# ---- OIDC identity binding ------------------------------------------------
class OidcBindingRequest(BaseModel):
issuer: str
subject: str
class OidcBindingResponse(BaseModel):
user_id: int
username: str
oidc_issuer: str | None = None
oidc_subject: str | None = None
def _assert_can_manage_oidc_binding(db: Session, caller: models.User, target: models.User) -> None:
"""Global admins may (un)bind anyone. Non-admin account managers may
only operate on non-privileged accounts — never on an admin or another
privileged account — otherwise binding an attacker-controlled OIDC
identity to an admin would be a privilege-escalation primitive."""
if getattr(caller, "is_admin", False):
return
privileged = (
getattr(target, "is_admin", False)
or target.username in ("acc-mgr", "deleted-user")
or _has_global_permission(db, target, "account.create")
or _has_global_permission(db, target, "user.reset-apikey")
)
if privileged:
raise HTTPException(
status_code=403,
detail="Only a global admin may manage the OIDC binding of a privileged account",
)
@router.put("/{identifier}/oidc-binding", response_model=OidcBindingResponse)
def bind_user_oidc(
identifier: str,
payload: OidcBindingRequest,
db: Session = Depends(get_db),
caller: models.User = Depends(require_account_creator),
):
"""Bind an hf user to an external OIDC identity (issuer + subject).
Admin or account-manager (JWT or API key). Account managers may not
target privileged/admin accounts. One OIDC identity maps to at most
one user."""
issuer = (payload.issuer or "").strip()
subject = (payload.subject or "").strip()
if not issuer or not subject:
raise HTTPException(status_code=400, detail="issuer and subject are required")
user = _find_user_by_id_or_username(db, identifier)
if not user:
raise HTTPException(status_code=404, detail="User not found")
_assert_can_manage_oidc_binding(db, caller, user)
clash = db.query(models.User).filter(
models.User.oidc_issuer == issuer,
models.User.oidc_subject == subject,
models.User.id != user.id,
).first()
if clash:
raise HTTPException(status_code=409, detail=f"OIDC identity already bound to user '{clash.username}'")
user.oidc_issuer = issuer
user.oidc_subject = subject
db.commit()
db.refresh(user)
return OidcBindingResponse(user_id=user.id, username=user.username,
oidc_issuer=user.oidc_issuer, oidc_subject=user.oidc_subject)
@router.delete("/{identifier}/oidc-binding", response_model=OidcBindingResponse)
def unbind_user_oidc(
identifier: str,
db: Session = Depends(get_db),
caller: models.User = Depends(require_account_creator),
):
"""Remove a user's OIDC binding. Admin or account-manager; account
managers may not target privileged/admin accounts."""
user = _find_user_by_id_or_username(db, identifier)
if not user:
raise HTTPException(status_code=404, detail="User not found")
_assert_can_manage_oidc_binding(db, caller, user)
user.oidc_issuer = None
user.oidc_subject = None
db.commit()
db.refresh(user)
return OidcBindingResponse(user_id=user.id, username=user.username,
oidc_issuer=None, oidc_subject=None)

View File

@@ -5,11 +5,13 @@ from fastapi import APIRouter, Depends, HTTPException, status, BackgroundTasks
from sqlalchemy.orm import Session
from app.core.config import get_db
from app.api.deps import require_admin
from app.models.webhook import Webhook, WebhookLog
from app.schemas.webhook import WebhookCreate, WebhookUpdate, WebhookResponse, WebhookLogResponse
from app.services.webhook import fire_webhooks_sync
router = APIRouter(prefix="/webhooks", tags=["Webhooks"])
# Webhook management is admin-only (registration, inspection, retry, logs).
router = APIRouter(prefix="/webhooks", tags=["Webhooks"], dependencies=[Depends(require_admin)])
@router.post("", response_model=WebhookResponse, status_code=status.HTTP_201_CREATED)

View File

@@ -38,12 +38,43 @@ class Settings(BaseSettings):
ALGORITHM: str = "HS256"
ACCESS_TOKEN_EXPIRE_MINUTES: int = 30
# --- OIDC (generic, OpenID Connect discovery) ---
OIDC_ENABLED: bool = False
OIDC_ISSUER: str = "" # e.g. https://idp.example.com (we use {issuer}/.well-known/openid-configuration)
OIDC_CLIENT_ID: str = ""
OIDC_CLIENT_SECRET: str = ""
OIDC_REDIRECT_URI: str = "" # backend callback, e.g. https://hf-api.example.com/auth/oidc/callback
OIDC_SCOPES: str = "openid email profile"
OIDC_POST_LOGIN_REDIRECT: str = "" # frontend URL to return to (token in fragment). Falls back to "/"
OIDC_ADMIN_ROLE: str = "admin" # OIDC role name that bootstraps the unbound hf admin (OIDC-only)
# When true: no password login at all. Password login endpoint rejects,
# user creation ignores any password (passwordless user that can only use
# API keys / OIDC), and the frontend hides all password UI.
HARBORFORGE_OIDC_ONLY: bool = False
class Config:
env_file = ".env"
settings = Settings()
# Fail fast on a weak/default JWT signing key (prevents token forgery).
_WEAK_SECRETS = {
"change-me-in-production",
"change_me_in_production",
"change-me-use-openssl-rand-hex-32",
"secret",
"changeme",
"",
}
if settings.SECRET_KEY in _WEAK_SECRETS or len(settings.SECRET_KEY) < 32:
raise RuntimeError(
"Insecure SECRET_KEY: set a strong random value "
"(e.g. `openssl rand -hex 32`) via the SECRET_KEY env var. "
"Refusing to start with a default/short key."
)
# Resolve DB URL: wizard config volume > env > default
_db_url = _resolve_db_url(settings.DATABASE_URL)
engine = create_engine(_db_url, pool_pre_ping=True)

View File

@@ -11,6 +11,7 @@ from sqlalchemy.orm import Session
from app.models import models
from app.models.role_permission import Role, Permission, RolePermission
from app.models.oidc_settings import OidcSettings
from app.api.deps import get_password_hash
logger = logging.getLogger("harborforge.init")
@@ -132,6 +133,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 +173,7 @@ _MGR_PERMISSIONS = {
"task.close", "task.reopen_closed", "task.reopen_completed",
"propose.accept", "propose.reject", "propose.reopen",
"monitor.read",
"calendar.read", "calendar.write", "calendar.manage",
"user.reset-self-apikey",
}
@@ -178,11 +184,13 @@ _DEV_PERMISSIONS = {
"milestone.read",
"task.close", "task.reopen_closed", "task.reopen_completed",
"monitor.read",
"calendar.read", "calendar.write",
"user.reset-self-apikey",
}
_ACCOUNT_MANAGER_PERMISSIONS = {
"account.create",
"user.reset-apikey",
}
# Role definitions: (name, description, permission_set)
@@ -288,6 +296,83 @@ def init_acc_mgr_user(db: Session) -> models.User | None:
return user
DELETED_USER_USERNAME = "deleted-user"
def init_deleted_user(db: Session) -> models.User | None:
"""Create the built-in deleted-user if not exists.
This user serves as a foreign key sink: when a real user is deleted,
all references are reassigned here instead of cascading deletes.
It has no role (no permissions) and cannot log in.
"""
existing = db.query(models.User).filter(
models.User.username == DELETED_USER_USERNAME
).first()
if existing:
logger.info("deleted-user already exists (id=%d), skipping", existing.id)
return existing
user = models.User(
username=DELETED_USER_USERNAME,
email="deleted-user@harborforge.internal",
full_name="Deleted User",
hashed_password=None,
is_admin=False,
is_active=False,
role_id=None,
)
db.add(user)
db.commit()
db.refresh(user)
logger.info("Created deleted-user (id=%d)", user.id)
return user
def init_oidc_settings(db: Session, oidc_cfg: dict, admin_user: models.User | None) -> None:
"""Bootstrap OIDC from the wizard config (first init only).
Creates the single oidc_settings row if absent so the deployment comes
up with OIDC configured. If admin_subject is given, binds the bootstrap
admin so it can sign in (critical in OIDC-only mode). Idempotent: an
existing row / existing admin binding is left untouched so later admin
edits via the API are not clobbered on restart."""
if not oidc_cfg:
return
existing = db.query(OidcSettings).filter(OidcSettings.id == 1).first()
if existing is None:
db.add(OidcSettings(
id=1,
enabled=bool(oidc_cfg.get("enabled", True)),
issuer=(oidc_cfg.get("issuer") or "").strip() or None,
client_id=(oidc_cfg.get("client_id") or "").strip() or None,
client_secret=oidc_cfg.get("client_secret") or None,
redirect_uri=(oidc_cfg.get("redirect_uri") or "").strip() or None,
scopes=(oidc_cfg.get("scopes") or "").strip() or None,
post_login_redirect=(oidc_cfg.get("post_login_redirect") or "").strip() or None,
admin_role=(oidc_cfg.get("admin_role") or "").strip() or None,
))
db.commit()
logger.info("OIDC settings bootstrapped from wizard config")
admin_subject = (oidc_cfg.get("admin_subject") or "").strip()
issuer = (oidc_cfg.get("issuer") or "").strip()
if admin_user and admin_subject and issuer and not admin_user.oidc_subject:
clash = db.query(models.User).filter(
models.User.oidc_issuer == issuer,
models.User.oidc_subject == admin_subject,
models.User.id != admin_user.id,
).first()
if clash:
logger.warning("Admin OIDC subject already bound to '%s'; skipping admin bind", clash.username)
else:
admin_user.oidc_issuer = issuer
admin_user.oidc_subject = admin_subject
db.commit()
logger.info("Bootstrap admin '%s' bound to OIDC subject", admin_user.username)
def run_init(db: Session) -> None:
"""Main initialization entry point. Reads config from shared volume."""
config = load_config()
@@ -312,9 +397,15 @@ def run_init(db: Session) -> None:
# Built-in acc-mgr user (after roles are created)
init_acc_mgr_user(db)
# Built-in deleted-user (foreign key sink for deleted accounts)
init_deleted_user(db)
# Default project
project_cfg = config.get("default_project")
if project_cfg and admin_user:
init_default_project(db, project_cfg, admin_user.id, admin_user.username)
# OIDC bootstrap (provider config + optional bootstrap-admin binding)
init_oidc_settings(db, config.get("oidc") or {}, admin_user)
logger.info("Initialization complete")

View File

@@ -1,6 +1,9 @@
"""HarborForge API — Agent/人类协同任务管理平台"""
from fastapi import FastAPI
from fastapi.middleware.cors import CORSMiddleware
from starlette.middleware.sessions import SessionMiddleware
from app.core.config import settings
app = FastAPI(
title="HarborForge API",
@@ -17,6 +20,17 @@ app.add_middleware(
allow_headers=["*"],
)
# Short-lived signed session cookie — only used to carry the OIDC
# state/nonce between /auth/oidc/login and the callback.
app.add_middleware(
SessionMiddleware,
secret_key=settings.SECRET_KEY,
session_cookie="hf_oidc",
same_site="lax",
https_only=False,
max_age=600,
)
# Health & version (kept at top level)
@app.get("/health", tags=["System"])
def health_check():
@@ -42,6 +56,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}
@@ -62,9 +77,13 @@ 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.schedule_type import router as schedule_type_router
from app.api.routers.schedule_type_special_slot import router as schedule_type_special_slot_router
from app.api.routers.calendar import router as calendar_router
from app.api.routers.oidc import router as oidc_router
app.include_router(auth_router)
app.include_router(oidc_router)
app.include_router(tasks_router)
app.include_router(projects_router)
app.include_router(users_router)
@@ -79,6 +98,8 @@ 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(schedule_type_router)
app.include_router(schedule_type_special_slot_router)
app.include_router(calendar_router)
@@ -96,6 +117,25 @@ def _migrate_schema():
{"column_name": column_name},
).fetchone() is not None
def _has_index(db, table_name: str, index_name: str) -> bool:
return db.execute(
text(
"""
SELECT 1
FROM information_schema.STATISTICS
WHERE TABLE_SCHEMA = DATABASE()
AND TABLE_NAME = :table_name
AND INDEX_NAME = :index_name
LIMIT 1
"""
),
{"table_name": table_name, "index_name": index_name},
).fetchone() is not None
def _ensure_unique_index(db, table_name: str, index_name: str, columns_sql: str):
if not _has_index(db, table_name, index_name):
db.execute(text(f"CREATE UNIQUE INDEX {index_name} ON {table_name} ({columns_sql})"))
def _drop_fk_constraints(db, table_name: str, referenced_table: str):
rows = db.execute(text(
"""
@@ -139,7 +179,7 @@ def _migrate_schema():
result = db.execute(text("SHOW COLUMNS FROM projects LIKE 'project_code'"))
if not result.fetchone():
db.execute(text("ALTER TABLE projects ADD COLUMN project_code VARCHAR(16) NULL"))
db.execute(text("CREATE UNIQUE INDEX idx_projects_project_code ON projects (project_code)"))
_ensure_unique_index(db, "projects", "idx_projects_project_code", "project_code")
# projects.owner_name
result = db.execute(text("SHOW COLUMNS FROM projects LIKE 'owner_name'"))
@@ -173,6 +213,8 @@ def _migrate_schema():
if not result.fetchone():
db.execute(text("ALTER TABLE tasks ADD COLUMN created_by_id INTEGER NULL"))
_ensure_fk(db, "tasks", "created_by_id", "users", "id", "fk_tasks_created_by_id")
if _has_column(db, "tasks", "task_code"):
_ensure_unique_index(db, "tasks", "idx_tasks_task_code", "task_code")
# milestones creator field
result = db.execute(text("SHOW COLUMNS FROM milestones LIKE 'created_by_id'"))
@@ -202,6 +244,8 @@ def _migrate_schema():
# --- Milestone status enum migration (old -> new) ---
if _has_table(db, "milestones"):
if _has_column(db, "milestones", "milestone_code"):
_ensure_unique_index(db, "milestones", "idx_milestones_milestone_code", "milestone_code")
# Alter enum column to accept new values
db.execute(text(
"ALTER TABLE milestones MODIFY COLUMN status "
@@ -248,6 +292,21 @@ 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"))
# --- users OIDC binding (issuer + subject), unique together ---
if _has_table(db, "users") and not _has_column(db, "users", "oidc_issuer"):
db.execute(text("ALTER TABLE users ADD COLUMN oidc_issuer VARCHAR(255) NULL"))
if _has_table(db, "users") and not _has_column(db, "users", "oidc_subject"):
db.execute(text("ALTER TABLE users ADD COLUMN oidc_subject VARCHAR(255) NULL"))
if _has_table(db, "users") and _has_column(db, "users", "oidc_subject"):
_ensure_unique_index(db, "users", "uq_users_oidc_identity", "oidc_issuer, oidc_subject")
# --- oidc_settings.admin_role (added after the table shipped) ---
if _has_table(db, "oidc_settings") and not _has_column(db, "oidc_settings", "admin_role"):
db.execute(text("ALTER TABLE oidc_settings ADD COLUMN admin_role VARCHAR(128) NULL"))
# --- monitored_servers.api_key for heartbeat v2 ---
if _has_table(db, "monitored_servers") and not _has_column(db, "monitored_servers", "api_key"):
db.execute(text("ALTER TABLE monitored_servers ADD COLUMN api_key VARCHAR(64) NULL"))
@@ -257,6 +316,18 @@ def _migrate_schema():
if _has_table(db, "server_states") and not _has_column(db, "server_states", "plugin_version"):
db.execute(text("ALTER TABLE server_states ADD COLUMN plugin_version VARCHAR(64) NULL"))
if _has_table(db, "meetings") and _has_column(db, "meetings", "meeting_code"):
_ensure_unique_index(db, "meetings", "idx_meetings_meeting_code", "meeting_code")
if _has_table(db, "supports") and _has_column(db, "supports", "support_code"):
_ensure_unique_index(db, "supports", "idx_supports_support_code", "support_code")
if _has_table(db, "proposes") and _has_column(db, "proposes", "propose_code"):
_ensure_unique_index(db, "proposes", "idx_proposes_propose_code", "propose_code")
if _has_table(db, "essentials") and _has_column(db, "essentials", "essential_code"):
_ensure_unique_index(db, "essentials", "idx_essentials_essential_code", "essential_code")
# --- server_states nginx telemetry for generic monitor client ---
if _has_table(db, "server_states") and not _has_column(db, "server_states", "nginx_installed"):
db.execute(text("ALTER TABLE server_states ADD COLUMN nginx_installed BOOLEAN NULL"))
@@ -320,6 +391,48 @@ def _migrate_schema():
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4
"""))
# --- time_slots: add wakeup_sent_at for Discord wakeup tracking ---
if _has_table(db, "time_slots") and not _has_column(db, "time_slots", "wakeup_sent_at"):
db.execute(text("ALTER TABLE time_slots ADD COLUMN wakeup_sent_at DATETIME NULL"))
# --- agents: add schedule_type_id FK ---
if _has_table(db, "agents") and not _has_column(db, "agents", "schedule_type_id"):
db.execute(text("ALTER TABLE agents ADD COLUMN schedule_type_id INTEGER NULL"))
# --- schedule_types: add maintenance_from / maintenance_to ---
# Default 8:009:00 UTC for existing rows; the 1-hour-window
# invariant is enforced at the schema level for any NEW rows by
# the pydantic ScheduleTypeCreate validator.
if _has_table(db, "schedule_types"):
if not _has_column(db, "schedule_types", "maintenance_from"):
db.execute(text(
"ALTER TABLE schedule_types ADD COLUMN maintenance_from INT NOT NULL DEFAULT 8"
))
if not _has_column(db, "schedule_types", "maintenance_to"):
db.execute(text(
"ALTER TABLE schedule_types ADD COLUMN maintenance_to INT NOT NULL DEFAULT 9"
))
# --- time_slots: admin-locked + special_slot pointer ---
if _has_table(db, "time_slots"):
if not _has_column(db, "time_slots", "is_admin_locked"):
db.execute(text(
"ALTER TABLE time_slots ADD COLUMN is_admin_locked TINYINT(1) NOT NULL DEFAULT 0"
))
if not _has_column(db, "time_slots", "special_slot_id"):
db.execute(text(
"ALTER TABLE time_slots ADD COLUMN special_slot_id INTEGER NULL"
))
# Index for the materialiser's idempotency lookup
db.execute(text(
"CREATE INDEX idx_time_slots_special_slot_id ON time_slots (special_slot_id)"
))
# --- schedule_type_special_slots: create-table is handled by
# Base.metadata.create_all on first boot; no migration needed here
# because there is no legacy table to evolve. Future schema bumps
# to that table go in this block.
db.commit()
except Exception as e:
db.rollback()
@@ -354,7 +467,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, agent, calendar, minimum_workload
from app.models import models, webhook, apikey, activity, milestone, notification, worklog, monitor, role_permission, task, support, meeting, proposal, propose, essential, agent, calendar, minimum_workload, schedule_type, schedule_type_special_slot, oidc_settings
Base.metadata.create_all(bind=engine)
_migrate_schema()

View File

@@ -131,6 +131,15 @@ class Agent(Base):
comment="rate_limit | billing — why the agent is exhausted",
)
# -- schedule type ------------------------------------------------------
schedule_type_id = Column(
Integer,
ForeignKey("schedule_types.id"),
nullable=True,
comment="FK to schedule_types — defines work/entertainment periods",
)
# -- timestamps ---------------------------------------------------------
created_at = Column(DateTime(timezone=True), server_default=func.now())
@@ -138,3 +147,4 @@ class Agent(Base):
# -- relationships ------------------------------------------------------
user = relationship("User", back_populates="agent", uselist=False)
schedule_type = relationship("ScheduleType", lazy="joined")

View File

@@ -165,6 +165,12 @@ class TimeSlot(Base):
comment="Lifecycle status of this slot",
)
wakeup_sent_at = Column(
DateTime(timezone=True),
nullable=True,
comment="When Discord wakeup was sent for this slot",
)
plan_id = Column(
Integer,
ForeignKey("schedule_plans.id"),
@@ -172,11 +178,37 @@ class TimeSlot(Base):
comment="Source plan if materialized from a SchedulePlan; set NULL on edit/cancel",
)
# -----------------------------------------------------------------
# Admin-locked slots are materialised from a ScheduleTypeSpecialSlot
# template. The agent can complete / abort / pause / resume them but
# cannot edit their time, type, duration, or cancel them outright —
# the slot exists because admin decided every agent on the parent
# schedule_type should run it. See `_apply_agent_slot_update` for
# the enforcement.
# -----------------------------------------------------------------
is_admin_locked = Column(
Boolean,
nullable=False,
server_default="0",
comment="True for slots materialised from a schedule_type special slot template.",
)
# Pointer back to the template that materialised this slot. NULL for
# all user-created or plan-generated slots. Lets us cascade updates
# and surface 'why is this on my calendar' to the agent.
special_slot_id = Column(
Integer,
ForeignKey("schedule_type_special_slots.id", ondelete="SET NULL"),
nullable=True,
index=True,
)
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")
special_slot = relationship("ScheduleTypeSpecialSlot")
# ---------------------------------------------------------------------------

View File

@@ -1,4 +1,4 @@
from sqlalchemy import Column, Integer, String, Text, DateTime, ForeignKey, Enum, Boolean, JSON, Time
from sqlalchemy import Column, Integer, String, Text, DateTime, ForeignKey, Enum, Boolean, JSON, Time, UniqueConstraint
from sqlalchemy.orm import relationship
from sqlalchemy.sql import func
from app.core.config import Base
@@ -66,12 +66,20 @@ class Project(Base):
class User(Base):
__tablename__ = "users"
__table_args__ = (
UniqueConstraint("oidc_issuer", "oidc_subject", name="uq_users_oidc_identity"),
)
id = Column(Integer, primary_key=True, index=True)
username = Column(String(50), unique=True, nullable=False)
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)
# OIDC binding: an hf user is linked to at most one external OIDC identity
# (issuer + subject). Unique together so one IdP identity maps to one user.
oidc_issuer = Column(String(255), nullable=True)
oidc_subject = Column(String(255), nullable=True)
is_active = Column(Boolean, default=True)
is_admin = Column(Boolean, default=False)
role_id = Column(Integer, ForeignKey("roles.id"), nullable=True)

View File

@@ -0,0 +1,25 @@
from sqlalchemy import Column, Integer, String, Boolean, DateTime
from sqlalchemy.sql import func
from app.core.config import Base
class OidcSettings(Base):
"""Single-row (id=1) runtime OIDC configuration.
When a row exists its non-empty fields override the OIDC_* env vars,
so the provider can be configured from the admin UI without a redeploy.
"""
__tablename__ = "oidc_settings"
id = Column(Integer, primary_key=True, default=1)
enabled = Column(Boolean, default=False, nullable=False)
issuer = Column(String(255), nullable=True)
client_id = Column(String(255), nullable=True)
client_secret = Column(String(512), nullable=True)
redirect_uri = Column(String(512), nullable=True)
scopes = Column(String(255), nullable=True)
post_login_redirect = Column(String(512), nullable=True)
# OIDC role name that, in OIDC-only mode, auto-connects an unbound
# hf admin on first login (bootstrap). Default "admin".
admin_role = Column(String(128), nullable=True)
updated_at = Column(DateTime(timezone=True), server_default=func.now(), onupdate=func.now())

View File

@@ -0,0 +1,85 @@
"""ScheduleType model — defines work/entertainment/maintenance time periods.
Each ScheduleType defines the daily work, entertainment, and maintenance
windows. Agents reference a schedule_type to know when they should be
working, when they can engage in entertainment, and when the system
requires them to surrender control for admin-scheduled special slots.
"""
from sqlalchemy import Column, Integer, String, DateTime
from sqlalchemy.orm import relationship
from sqlalchemy.sql import func
from app.core.config import Base
class ScheduleType(Base):
"""Work/entertainment/maintenance period definition."""
__tablename__ = "schedule_types"
id = Column(Integer, primary_key=True, index=True)
name = Column(
String(64),
nullable=False,
unique=True,
comment="Human-readable schedule type name (e.g., 'standard', 'night-shift')",
)
work_from = Column(
Integer,
nullable=False,
comment="Work period start hour (0-23, UTC)",
)
work_to = Column(
Integer,
nullable=False,
comment="Work period end hour (0-23, UTC)",
)
entertainment_from = Column(
Integer,
nullable=False,
comment="Entertainment period start hour (0-23, UTC)",
)
entertainment_to = Column(
Integer,
nullable=False,
comment="Entertainment period end hour (0-23, UTC)",
)
# -----------------------------------------------------------------
# Maintenance window — every agent on this schedule_type must
# surrender work/entertainment slots during this hour. Admin-created
# special slots tied to this schedule_type can only be scheduled
# inside this window. The window is always exactly 1 hour.
#
# Default (when columns are added via additive migration to existing
# rows) is 8:009:00 UTC so deployments stay sane until an operator
# picks proper hours per schedule_type.
# -----------------------------------------------------------------
maintenance_from = Column(
Integer,
nullable=False,
server_default="8",
comment="Maintenance window start hour (0-23, UTC). Window is exactly 1h.",
)
maintenance_to = Column(
Integer,
nullable=False,
server_default="9",
comment="Maintenance window end hour (0-23, UTC). Must equal (maintenance_from + 1) % 24.",
)
created_at = Column(DateTime(timezone=True), server_default=func.now())
updated_at = Column(DateTime(timezone=True), onupdate=func.now())
# relationship ---------------------------------------------------
special_slots = relationship(
"ScheduleTypeSpecialSlot",
back_populates="schedule_type",
cascade="all, delete-orphan",
)

View File

@@ -0,0 +1,116 @@
"""ScheduleTypeSpecialSlot — admin-managed slot template tied to a ScheduleType.
A "special slot" is a recurring slot template that the system materializes
into every matching agent's `time_slots` row each day. It exists for tasks
that admin wants to enforce across an entire schedule type cohort, e.g.:
* `plan-schedule` — daily planning slot all agents on this type must run
* `secret-rotation-window` — security maintenance
* `policy-update` — read updated agent policies
Rules:
* Only admins (`schedule_type.manage` permission) may create / edit /
delete special slots.
* The slot's `minute_in_window` offset must place it inside the parent
schedule_type's maintenance window (`maintenance_from..maintenance_from+59`).
* Materialised `time_slots` rows from a special slot carry
`is_admin_locked=true` so the agent-side `PATCH .../agent-update`
refuses status/time edits other than complete/abort/pause/resume.
* Materialisation produces one `time_slots` row per agent using this
schedule_type per date, with `slot_type=system`, `event_type=system_event`,
`event_data={"special_slot_id": <id>, "special_slot_name": "<name>",
"source": "schedule_type_special_slot", ...admin-supplied...}`.
"""
from sqlalchemy import Column, Integer, String, ForeignKey, JSON, DateTime, Boolean, UniqueConstraint
from sqlalchemy.orm import relationship
from sqlalchemy.sql import func
from app.core.config import Base
class ScheduleTypeSpecialSlot(Base):
"""Admin-managed daily slot template attached to a ScheduleType."""
__tablename__ = "schedule_type_special_slots"
__table_args__ = (
# One slot template per (schedule_type, name) so admin can use the
# `name` field as a stable, human-readable identifier for the cohort.
UniqueConstraint("schedule_type_id", "name", name="uq_special_slot_type_name"),
)
id = Column(Integer, primary_key=True, index=True)
schedule_type_id = Column(
Integer,
ForeignKey("schedule_types.id", ondelete="CASCADE"),
nullable=False,
index=True,
)
name = Column(
String(64),
nullable=False,
comment="Short identifier, e.g. 'plan-schedule', 'secret-rotation'",
)
description = Column(
String(512),
nullable=True,
comment="Human-readable note on what this slot is for",
)
minute_in_window = Column(
Integer,
nullable=False,
server_default="0",
comment=(
"Minute offset (0-59) inside the schedule_type maintenance window. "
"The materialised time_slot's scheduled_at becomes "
"maintenance_from:minute_in_window:00 UTC."
),
)
estimated_duration = Column(
Integer,
nullable=False,
server_default="15",
comment="Duration in minutes. Must fit inside the maintenance window.",
)
priority = Column(
Integer,
nullable=False,
server_default="50",
comment="Wake priority — higher value wakes first if multiple slots are due.",
)
event_data = Column(
JSON,
nullable=True,
comment=(
"Admin-supplied JSON payload that gets merged into every "
"materialised slot's event_data. Use this to pass a workflow "
"tag, suggested_workload, or any other context the agent "
"should see in its wakeup message."
),
)
is_active = Column(
Boolean,
nullable=False,
server_default="1",
comment="Soft-disable without deleting; inactive templates are skipped during materialisation.",
)
created_by_user_id = Column(
Integer,
ForeignKey("users.id"),
nullable=False,
)
created_at = Column(DateTime(timezone=True), server_default=func.now())
updated_at = Column(DateTime(timezone=True), onupdate=func.now())
# relationship ---------------------------------------------------
schedule_type = relationship("ScheduleType", back_populates="special_slots")

View File

@@ -3,14 +3,16 @@
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, time, datetime
from datetime import date as dt_date, time as dt_time, datetime as dt_datetime
from enum import Enum
from pydantic import BaseModel, Field, model_validator, field_validator
from typing import Any, Optional
from typing import Optional
# ---------------------------------------------------------------------------
@@ -100,17 +102,17 @@ class SlotStatusEnum(str, Enum):
class TimeSlotCreate(BaseModel):
"""Request body for creating a single calendar slot."""
date: Optional[date] = Field(None, description="Target date (defaults to today)")
date: Optional[dt_date] = Field(None, description="Target date (defaults to today)")
slot_type: SlotTypeEnum = Field(..., description="work | on_call | entertainment | system")
scheduled_at: time = Field(..., description="Planned start time HH:MM (00:00-23:00)")
scheduled_at: dt_time = Field(..., description="Planned start time HH:MM (00:00-23:00)")
estimated_duration: int = Field(..., ge=1, le=50, description="Duration in minutes (1-50)")
event_type: Optional[EventTypeEnum] = Field(None, description="job | entertainment | system_event")
event_data: Optional[dict[str, Any]] = Field(None, description="Event details JSON")
event_data: Optional[dict] = Field(None, description="Event details JSON")
priority: int = Field(0, ge=0, le=99, description="Priority 0-99")
@field_validator("scheduled_at")
@classmethod
def _validate_scheduled_at(cls, v: time) -> time:
def _validate_scheduled_at(cls, v: dt_time) -> dt_time:
if v.hour > 23:
raise ValueError("scheduled_at hour must be between 00 and 23")
return v
@@ -130,7 +132,7 @@ class TimeSlotResponse(BaseModel):
"""Response for a single TimeSlot."""
id: int
user_id: int
date: date
date: dt_date
slot_type: str
estimated_duration: int
scheduled_at: str # HH:MM:SS ISO format
@@ -138,12 +140,14 @@ class TimeSlotResponse(BaseModel):
attended: bool
actual_duration: Optional[int] = None
event_type: Optional[str] = None
event_data: Optional[dict[str, Any]] = None
event_data: Optional[dict] = None
priority: int
status: str
plan_id: Optional[int] = None
created_at: Optional[datetime] = None
updated_at: Optional[datetime] = None
is_admin_locked: bool = False
special_slot_id: Optional[int] = None
created_at: Optional[dt_datetime] = None
updated_at: Optional[dt_datetime] = None
class Config:
from_attributes = True
@@ -155,6 +159,49 @@ class TimeSlotCreateResponse(BaseModel):
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)
# ---------------------------------------------------------------------------
@@ -169,7 +216,7 @@ class CalendarSlotItem(BaseModel):
id: Optional[int] = Field(None, description="Real slot DB id (None for virtual)")
virtual_id: Optional[str] = Field(None, description="Virtual slot id (None for real)")
user_id: int
date: date
date: dt_date
slot_type: str
estimated_duration: int
scheduled_at: str # HH:MM:SS ISO format
@@ -177,12 +224,14 @@ class CalendarSlotItem(BaseModel):
attended: bool
actual_duration: Optional[int] = None
event_type: Optional[str] = None
event_data: Optional[dict[str, Any]] = None
event_data: Optional[dict] = None
priority: int
status: str
plan_id: Optional[int] = None
created_at: Optional[datetime] = None
updated_at: Optional[datetime] = None
is_admin_locked: bool = False
special_slot_id: Optional[int] = None
created_at: Optional[dt_datetime] = None
updated_at: Optional[dt_datetime] = None
class Config:
from_attributes = True
@@ -190,9 +239,202 @@ class CalendarSlotItem(BaseModel):
class CalendarDayResponse(BaseModel):
"""Response for a single-day calendar query."""
date: date
date: dt_date
user_id: int
slots: list[CalendarSlotItem] = Field(
default_factory=list,
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

@@ -0,0 +1,65 @@
"""Schemas for ScheduleType CRUD."""
from pydantic import BaseModel, Field, model_validator
from typing import Optional
def _validate_maintenance_window(maintenance_from: int, maintenance_to: int) -> None:
"""Maintenance window must be exactly 1 hour (handles 23→0 wrap)."""
expected_to = (maintenance_from + 1) % 24
if maintenance_to != expected_to:
raise ValueError(
f"maintenance window must be exactly 1 hour: "
f"expected maintenance_to={expected_to}, got {maintenance_to}"
)
class ScheduleTypeCreate(BaseModel):
name: str = Field(..., min_length=1, max_length=64)
work_from: int = Field(..., ge=0, le=23)
work_to: int = Field(..., ge=0, le=23)
entertainment_from: int = Field(..., ge=0, le=23)
entertainment_to: int = Field(..., ge=0, le=23)
maintenance_from: int = Field(8, ge=0, le=23, description="Maintenance window start hour UTC (default 8)")
maintenance_to: int = Field(9, ge=0, le=23, description="Maintenance window end hour UTC; must equal (maintenance_from+1) % 24")
@model_validator(mode="after")
def _check_maintenance(self):
_validate_maintenance_window(self.maintenance_from, self.maintenance_to)
return self
class ScheduleTypeUpdate(BaseModel):
name: Optional[str] = Field(None, min_length=1, max_length=64)
work_from: Optional[int] = Field(None, ge=0, le=23)
work_to: Optional[int] = Field(None, ge=0, le=23)
entertainment_from: Optional[int] = Field(None, ge=0, le=23)
entertainment_to: Optional[int] = Field(None, ge=0, le=23)
maintenance_from: Optional[int] = Field(None, ge=0, le=23)
maintenance_to: Optional[int] = Field(None, ge=0, le=23)
@model_validator(mode="after")
def _check_maintenance(self):
# Only validate when both fields are present together; partial-
# update validation against the merged row happens at apply time.
if self.maintenance_from is not None and self.maintenance_to is not None:
_validate_maintenance_window(self.maintenance_from, self.maintenance_to)
return self
class ScheduleTypeResponse(BaseModel):
id: int
name: str
work_from: int
work_to: int
entertainment_from: int
entertainment_to: int
maintenance_from: int
maintenance_to: int
class Config:
from_attributes = True
class AgentScheduleTypeAssign(BaseModel):
schedule_type_name: str = Field(..., description="Name of the schedule type to assign")

View File

@@ -0,0 +1,43 @@
"""Schemas for ScheduleTypeSpecialSlot CRUD (admin-only)."""
from datetime import datetime
from typing import Any, Optional
from pydantic import BaseModel, Field
class SpecialSlotCreate(BaseModel):
name: str = Field(..., min_length=1, max_length=64)
description: Optional[str] = Field(None, max_length=512)
minute_in_window: int = Field(0, ge=0, le=59, description="Minute offset (0-59) inside the schedule_type maintenance window")
estimated_duration: int = Field(15, ge=1, le=60, description="Duration in minutes; must fit inside the 1-hour maintenance window")
priority: int = Field(50, ge=0, le=99)
event_data: Optional[dict[str, Any]] = Field(None, description="JSON payload merged into every materialised slot's event_data")
is_active: bool = True
class SpecialSlotUpdate(BaseModel):
name: Optional[str] = Field(None, min_length=1, max_length=64)
description: Optional[str] = Field(None, max_length=512)
minute_in_window: Optional[int] = Field(None, ge=0, le=59)
estimated_duration: Optional[int] = Field(None, ge=1, le=60)
priority: Optional[int] = Field(None, ge=0, le=99)
event_data: Optional[dict[str, Any]] = None
is_active: Optional[bool] = None
class SpecialSlotResponse(BaseModel):
id: int
schedule_type_id: int
name: str
description: Optional[str]
minute_in_window: int
estimated_duration: int
priority: int
event_data: Optional[dict[str, Any]]
is_active: bool
created_by_user_id: int
created_at: datetime
class Config:
from_attributes = True

View File

@@ -1,4 +1,4 @@
from pydantic import BaseModel
from pydantic import BaseModel, Field
from typing import Optional, List
from datetime import datetime, time
from enum import Enum
@@ -43,9 +43,7 @@ class TaskBase(BaseModel):
class TaskCreate(TaskBase):
project_id: Optional[int] = None
project_code: Optional[str] = None
milestone_id: Optional[int] = None
milestone_code: Optional[str] = None
reporter_id: Optional[int] = None
assignee_id: Optional[int] = None
@@ -75,15 +73,12 @@ class TaskUpdate(BaseModel):
class TaskResponse(TaskBase):
id: int
status: TaskStatusEnum
task_code: Optional[str] = None
code: Optional[str] = None
type: Optional[str] = None
due_date: Optional[datetime] = None
project_id: int
project_code: Optional[str] = None
milestone_id: int
milestone_code: Optional[str] = None
reporter_id: int
assignee_id: Optional[int] = None
@@ -94,8 +89,8 @@ class TaskResponse(TaskBase):
positions: Optional[str] = None
pending_matters: Optional[str] = None
# BE-PR-008: Proposal Accept tracking
source_proposal_id: Optional[int] = None
source_essential_id: Optional[int] = None
source_proposal_code: Optional[str] = None
source_essential_code: Optional[str] = None
created_at: datetime
updated_at: Optional[datetime] = None
@@ -176,6 +171,7 @@ class UserBase(BaseModel):
class UserCreate(UserBase):
password: Optional[str] = None
role_id: Optional[int] = None
discord_user_id: Optional[str] = None
# Agent binding (both must be provided or both omitted)
agent_id: Optional[str] = None
claw_identifier: Optional[str] = None
@@ -187,6 +183,20 @@ class UserUpdate(BaseModel):
password: Optional[str] = None
role_id: Optional[int] = None
is_active: Optional[bool] = None
discord_user_id: Optional[str] = None
class UserBindAgentRequest(BaseModel):
"""Request body for PATCH /users/{identifier}/bind-agent.
Binds an existing user to (agent_id, claw_identifier) by inserting a
row in the `agents` table. Both fields required (mirrors the
create-time invariant in UserCreate). Idempotent: re-binding the same
user to the same (agent_id, claw_identifier) returns the existing
Agent row instead of 409.
"""
agent_id: str = Field(..., min_length=1, max_length=128)
claw_identifier: str = Field(..., min_length=1, max_length=128)
class UserResponse(UserBase):
@@ -196,8 +206,11 @@ class UserResponse(UserBase):
role_id: Optional[int] = None
role_name: Optional[str] = None
agent_id: Optional[str] = None
discord_user_id: Optional[str] = None
oidc_issuer: Optional[str] = None
oidc_subject: Optional[str] = None
created_at: datetime
class Config:
from_attributes = True
@@ -259,9 +272,9 @@ class MilestoneUpdate(BaseModel):
class MilestoneResponse(MilestoneBase):
id: int
milestone_code: Optional[str] = None
project_id: int
code: Optional[str] = None
project_code: Optional[str] = None
created_by_id: Optional[int] = None
started_at: Optional[datetime] = None
created_at: datetime
@@ -285,7 +298,7 @@ class ProposalBase(BaseModel):
class ProposalCreate(ProposalBase):
project_id: Optional[int] = None
pass
class ProposalUpdate(BaseModel):
@@ -294,11 +307,10 @@ class ProposalUpdate(BaseModel):
class ProposalResponse(ProposalBase):
id: int
proposal_code: Optional[str] = None # preferred name
propose_code: Optional[str] = None # backward compat alias (same value)
status: ProposalStatusEnum
project_id: int
project_code: Optional[str] = None
created_by_id: Optional[int] = None
created_by_username: Optional[str] = None
feat_task_id: Optional[str] = None # DEPRECATED (BE-PR-010): legacy field, read-only. Use generated_tasks instead.
@@ -340,9 +352,8 @@ class EssentialUpdate(BaseModel):
class EssentialResponse(EssentialBase):
id: int
essential_code: str
proposal_id: int
proposal_code: Optional[str] = None
created_by_id: Optional[int] = None
created_at: datetime
updated_at: Optional[datetime] = None
@@ -353,13 +364,12 @@ class EssentialResponse(EssentialBase):
class GeneratedTaskBrief(BaseModel):
"""Brief info about a story task generated from Proposal Accept."""
task_id: int
task_code: Optional[str] = None
task_type: str
task_subtype: Optional[str] = None
title: str
status: Optional[str] = None
source_essential_id: Optional[int] = None
source_essential_code: Optional[str] = None
class ProposalDetailResponse(ProposalResponse):
@@ -374,12 +384,10 @@ class ProposalDetailResponse(ProposalResponse):
class GeneratedTaskSummary(BaseModel):
"""Brief summary of a task generated from a Proposal Essential."""
task_id: int
task_code: str
task_type: str
task_subtype: str
title: str
essential_id: int
essential_code: str

View File

@@ -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,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,175 @@
"""Materialise schedule_type special slots into per-agent time_slots rows.
A ScheduleTypeSpecialSlot is a template — it lives on the schedule_type.
For an agent on that schedule_type to actually be woken, the system must
emit a row in `time_slots` with `slot_type=system`, `is_admin_locked=true`,
`special_slot_id=<template_id>` for the agent's `user_id` on the target
date. This module is the single materialisation point.
Called from:
* GET /calendar/day — before returning slots, materialise today's special
slots for the calling user.
* GET /calendar/sync — before returning per-claw schedules, materialise
today's special slots for every agent on this claw whose schedule_type
has any active special slot template.
Idempotent: re-running on the same (agent, date, special_slot_template)
is a no-op — uniqueness is enforced via SELECT-then-insert. We do not add
a DB-level unique constraint because the time_slots table is already
indexed by (user_id, date) and an extra composite index is overkill for
the low cardinality of (agents × special-slot-templates) per day.
"""
from __future__ import annotations
from datetime import date as date_type, time as time_type
from typing import Iterable
from sqlalchemy.orm import Session
from app.models.agent import Agent
from app.models.calendar import TimeSlot, SlotType, SlotStatus, EventType
from app.models.schedule_type import ScheduleType
from app.models.schedule_type_special_slot import ScheduleTypeSpecialSlot
def materialise_special_slots_for_user(
db: Session,
user_id: int,
target_date: date_type,
commit: bool = True,
) -> list[TimeSlot]:
"""Materialise today's special slots for one agent (identified by user_id).
Returns the list of newly created rows (may be empty if all already exist
or the agent has no schedule_type / no active templates).
"""
agent = db.query(Agent).filter(Agent.user_id == user_id).first()
if not agent or not agent.schedule_type_id:
return []
return _materialise_for_agent(db, agent, target_date, commit=commit)
def materialise_special_slots_for_claw(
db: Session,
claw_identifier: str,
target_date: date_type,
commit: bool = True,
) -> list[TimeSlot]:
"""Materialise today's special slots for every agent on a claw instance.
Used by the multi-agent `/calendar/sync` endpoint so plugin-driven
`runSync` cycles see the special slots without each agent having to
hit `/calendar/day` first.
"""
agents = (
db.query(Agent)
.filter(
Agent.claw_identifier == claw_identifier,
Agent.schedule_type_id.isnot(None),
)
.all()
)
created: list[TimeSlot] = []
for agent in agents:
created.extend(_materialise_for_agent(db, agent, target_date, commit=False))
if commit and created:
db.commit()
return created
def _materialise_for_agent(
db: Session,
agent: Agent,
target_date: date_type,
commit: bool,
) -> list[TimeSlot]:
st: ScheduleType | None = (
db.query(ScheduleType).filter(ScheduleType.id == agent.schedule_type_id).first()
)
if not st:
return []
templates: Iterable[ScheduleTypeSpecialSlot] = (
db.query(ScheduleTypeSpecialSlot)
.filter(
ScheduleTypeSpecialSlot.schedule_type_id == st.id,
ScheduleTypeSpecialSlot.is_active.is_(True),
)
.all()
)
created: list[TimeSlot] = []
for tpl in templates:
if _already_materialised(db, agent.user_id, target_date, tpl.id):
continue
slot = _build_time_slot_from_template(
user_id=agent.user_id,
target_date=target_date,
schedule_type=st,
template=tpl,
)
db.add(slot)
created.append(slot)
if commit and created:
db.commit()
for slot in created:
db.refresh(slot)
return created
def _already_materialised(
db: Session,
user_id: int,
target_date: date_type,
template_id: int,
) -> bool:
return (
db.query(TimeSlot.id)
.filter(
TimeSlot.user_id == user_id,
TimeSlot.date == target_date,
TimeSlot.special_slot_id == template_id,
)
.first()
is not None
)
def _build_time_slot_from_template(
*,
user_id: int,
target_date: date_type,
schedule_type: ScheduleType,
template: ScheduleTypeSpecialSlot,
) -> TimeSlot:
scheduled_at = time_type(
hour=schedule_type.maintenance_from,
minute=template.minute_in_window,
second=0,
)
# Merge admin-supplied event_data with bookkeeping pointers so the
# agent (and ARD) can identify the template at wake time.
merged_event_data = dict(template.event_data or {})
merged_event_data.setdefault("source", "schedule_type_special_slot")
merged_event_data["special_slot_id"] = template.id
merged_event_data["special_slot_name"] = template.name
merged_event_data["schedule_type_id"] = schedule_type.id
merged_event_data["schedule_type_name"] = schedule_type.name
return TimeSlot(
user_id=user_id,
date=target_date,
slot_type=SlotType.SYSTEM,
estimated_duration=template.estimated_duration,
scheduled_at=scheduled_at,
attended=False,
event_type=EventType.SYSTEM_EVENT,
event_data=merged_event_data,
priority=template.priority,
status=SlotStatus.NOT_STARTED,
is_admin_locked=True,
special_slot_id=template.id,
)

View File

@@ -2,12 +2,41 @@ import json
import hmac
import hashlib
import logging
import socket
import ipaddress
from urllib.parse import urlparse
from sqlalchemy.orm import Session
from app.models.webhook import Webhook, WebhookLog
logger = logging.getLogger(__name__)
def _validate_webhook_url(url: str) -> None:
"""Raise ValueError if the URL would target a non-public address (SSRF guard)."""
parsed = urlparse(url)
if parsed.scheme not in ("http", "https"):
raise ValueError(f"unsupported scheme: {parsed.scheme!r}")
host = parsed.hostname
if not host:
raise ValueError("missing host")
# Resolve every address the host maps to and reject non-global ranges.
try:
infos = socket.getaddrinfo(host, parsed.port or (443 if parsed.scheme == "https" else 80))
except socket.gaierror as e:
raise ValueError(f"DNS resolution failed: {e}")
for info in infos:
ip = ipaddress.ip_address(info[4][0])
if (
ip.is_private
or ip.is_loopback
or ip.is_link_local
or ip.is_multicast
or ip.is_reserved
or ip.is_unspecified
):
raise ValueError(f"host resolves to non-public address {ip}")
def fire_webhooks_sync(event: str, payload: dict, project_id: int, db: Session):
"""Find matching webhooks and send payloads (sync version)."""
import httpx
@@ -35,6 +64,8 @@ def fire_webhooks_sync(event: str, payload: dict, project_id: int, db: Session):
payload=payload_json,
)
try:
_validate_webhook_url(wh.url)
headers = {"Content-Type": "application/json"}
if wh.secret:
sig = hmac.new(
@@ -42,7 +73,7 @@ def fire_webhooks_sync(event: str, payload: dict, project_id: int, db: Session):
).hexdigest()
headers["X-Webhook-Signature"] = sig
with httpx.Client(timeout=10.0) as client:
with httpx.Client(timeout=10.0, follow_redirects=False) as client:
resp = client.post(wh.url, content=payload_json, headers=headers)
log.response_status = resp.status_code
log.response_body = resp.text[:1000]

99
docs/oidc-test-plan.md Normal file
View File

@@ -0,0 +1,99 @@
# OIDC 接入 — 测试点描述
覆盖范围OIDC 登录、hf 用户与 OIDC 身份绑定、`HARBORFORGE_OIDC_ONLY`
模式、管理员 OIDC 配置页面。涉及仓库分支 `feature/oidc-login`
`HarborForge.Backend` + `HarborForge.Frontend`)。
“本地状态” 列:✅ 已用真实 Keycloak 在本地栈端到端验证;🟡 已用接口/构建
验证但未走真实 IdP UI⬜ 待测。
## 0. 测试环境
- 后端 `http://127.0.0.1:18000`、前端 `http://127.0.0.1:13000`(本地验证栈)
- IdPKeycloak 容器realm `hf`confidential client `hf-client`
- IdP 测试用户:`tester` / `Test123!`emailVerified=true
- 关键约束:**issuer URL 必须浏览器与后端容器都能用同一地址访问**
(否则 token `iss` 校验失败)。本地用宿主机 IP 统一两端。
- 配置项(运行时 env 或 DBDB 覆盖 env
`OIDC_ENABLED / OIDC_ISSUER / OIDC_CLIENT_ID / OIDC_CLIENT_SECRET /
OIDC_REDIRECT_URI / OIDC_SCOPES / OIDC_POST_LOGIN_REDIRECT`
部署级 `HARBORFORGE_OIDC_ONLY`Docker ARG/ENV前后端均有
## 1. 管理员 OIDC 配置(页面 + API
| # | 测试点 | 步骤 | 预期 | 本地 |
|---|--------|------|------|------|
|1.1|读取初始配置|`GET /auth/oidc/settings`admin|返回 `source=env``has_client_secret=false``effective_enabled` 反映 env|✅|
|1.2|保存配置|`PUT /auth/oidc/settings` 填 issuer/client/secret/redirect/scopes/post_login|200`source=db``effective_enabled=true`|✅|
|1.3|client_secret 只写不回显|保存后再 `GET`|响应无明文 secret`has_client_secret=true`|✅|
|1.4|secret 留空保留原值|`PUT` 不带 `client_secret`|原 secret 不变,登录仍可用|🟡|
|1.5|配置即时生效|保存后 `GET /auth/config`|`oidc_enabled` 立即变化,无需重启|✅|
|1.6|页面仅 admin 可见|非 admin 访问 `/settings/oidc`|被重定向到 `/`侧栏无入口API 返回 401/403|🟡|
|1.7|页面展示 Callback URL|打开 OIDC 设置页|醒目显示需在 IdP 注册的 redirect/callback URL、当前状态与配置来源|🟡|
## 2. OIDC 登录流程(授权码)
| # | 测试点 | 步骤 | 预期 | 本地 |
|---|--------|------|------|------|
|2.1|发起登录|`GET /auth/oidc/login`|302 跳转到 IdP authorize 端点state/nonce 入会话 cookie|✅|
|2.2|IdP 登录成功回跳|在 IdP 输入 `tester/Test123!`|302 回 `…/auth/oidc/callback?code=…&state=…`|✅|
|2.3|回调换码并签发|后端处理 callback|校验 ID token(JWKS)→定位绑定用户→签发 hf JWT→302 到前端 `post_login#token=…`|✅|
|2.4|登录态可用|用返回 token 调 `GET /auth/me`|返回被绑定的 hf 用户|✅|
|2.5|前端按钮|登录页点 “Sign in with SSO”|全页跳转到后端 `/auth/oidc/login`|🟡|
|2.6|未配置 OIDC|`OIDC` 关闭时访问 `/auth/oidc/login`|503 “OIDC is not configured”|🟡|
|2.7|token 交换失败|code 失效/被篡改|回前端 `?oidc_error=exchange_failed`,登录页提示|⬜|
## 3. hf 用户 ↔ OIDC 身份绑定
| # | 测试点 | 步骤 | 预期 | 本地 |
|---|--------|------|------|------|
|3.1|管理员绑定|`PUT /users/{id}/oidc-binding` {issuer,subject}admin 或 acc-manager|200用户响应含 `oidc_issuer/oidc_subject`|✅|
|3.2|未绑定身份拒绝登录|解绑后用该 IdP 账号登录|回 `?oidc_error=not_linked`**不签发 token**(不自动开号)|✅|
|3.3|身份唯一性|把同一 (issuer,subject) 绑到另一用户|409 冲突|✅|
|3.4|解绑|`DELETE /users/{id}/oidc-binding`|200绑定清空|✅|
|3.5|绑定鉴权|无凭据 / 普通用户调用绑定 API|401 / 403|✅|
|3.6|API key 通道|admin 的 API key 调用绑定/配置 API|200支持 JWT 或 API key|✅|
|3.7|自助绑定(非 ONLY|登录用户点侧栏 “Link OIDC account” 走一次 OIDC|回 `?oidc_linked=1`,当前账号绑定成功|🟡|
|3.8|自助绑定冲突|自助绑定到已被占用的身份|`?oidc_error=already_bound`|⬜|
|3.9|OIDC_ONLY 下禁自助|ONLY 模式访问 `/auth/oidc/link`|403仅管理员 API 可绑)|🟡|
## 4. HARBORFORGE_OIDC_ONLY 模式
| # | 测试点 | 步骤 | 预期 | 本地 |
|---|--------|------|------|------|
|4.1|配置反映|ONLY=true 时 `GET /auth/config`|`oidc_only=true``password_login=false`|✅|
|4.2|禁用密码登录|`POST /auth/token`|403 “Password login is disabled (OIDC only)”|✅|
|4.3|建用户忽略密码|`POST /users` 带 password|201但 DB `hashed_password=NULL`(无密码用户)|✅|
|4.4|改用户忽略密码|`PATCH /users/{id}` 带 password|密码不被设置|🟡|
|4.5|无密码用户仍可用|对该用户绑定 OIDC、生成 API key|绑定 200、`reset-apikey` 200可经 OIDC 登录|✅|
|4.6|前端隐藏密码 UI|ONLY 模式打开登录页 / 用户管理页|无用户名密码框;用户管理无密码/重置密码组件|🟡|
|4.7|防锁死恢复|ONLY 模式且 OIDC 配错|管理员 API key 仍可调 `GET/PUT /auth/oidc/settings` 修复|✅|
## 5. 回归(不破坏既有功能)
| # | 测试点 | 步骤 | 预期 | 本地 |
|---|--------|------|------|------|
|5.1|默认模式密码登录|未开 ONLY 时 `POST /auth/token`|正常 200 签发 token|✅|
|5.2|用户响应新增字段|`GET /users` `/users/{id}` `/auth/me`|含 `oidc_issuer/oidc_subject`(未绑定为 null|✅|
|5.3|启动迁移幂等|新旧库重复启动后端|`users.oidc_*` 列与 `oidc_settings` 表存在,无报错|✅|
|5.4|前端构建|`npm run build`Docker 镜像)|TS 编译通过|✅|
|5.5|后端导入|镜像内 `import app.main`|无导入错误OIDC 路由注册|✅|
|5.6|镜像参数|前后端 Dockerfile|含 `ARG/ENV HARBORFORGE_OIDC_ONLY`|✅|
## 6. 安全检查点
- ID token 经 IdP JWKS 校验Authlib discovery + nonce签发的是 hf 原有
HS256 JWT依赖强 `SECRET_KEY`(弱 key 后端拒绝启动)。
- `client_secret` 持久化在 DB接口永不回显。
- 绑定/配置接口强制 admin或 account-manager 绑定),支持 API key 作为
OIDC_ONLY 下的恢复通道。
- 未绑定 OIDC 身份一律拒绝,不自动开号(防越权开户)。
- `redirect_uri` 必须在 IdP 精确注册;后端回跳前端地址来自服务端配置
`post_login_redirect`),不接受客户端传入,避免开放重定向。
## 7. 已知限制 / 待补
- 真实浏览器交互点按钮、IdP 同意页、SameSite cookie 行为)需人工过一遍
(本地已用 curl 无头跑通授权码全流程,逻辑等价)。
- 多 IdP / 多 issuer、token 刷新、登出联动SLO未实现超出本次范围。
- 自助绑定相关 UI3.7/3.8)建议人工在浏览器复核。

View File

@@ -12,3 +12,5 @@ alembic==1.13.1
python-dotenv==1.0.0
httpx==0.27.0
requests==2.31.0
authlib==1.3.2
itsdangerous==2.2.0

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,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