Compare commits
53 Commits
751b3bc574
...
feat/user-
| Author | SHA1 | Date | |
|---|---|---|---|
| 6400f7f612 | |||
| 5b59806e38 | |||
| 23632aa073 | |||
| 7017d3483e | |||
| dcaaa4259a | |||
| c6d2ecbf95 | |||
| 5a5e3fa2eb | |||
| 1c91cb32fc | |||
| f64e2a24f8 | |||
| ece2b550fc | |||
| f8126d0cbc | |||
| 54b6103880 | |||
| d2fafdfe9c | |||
| f03bfe9093 | |||
| 801a63f8bb | |||
| b7ae20e43f | |||
| 69c4e17d0f | |||
| 8ab9cae474 | |||
| 5b7169a3cf | |||
| 630c215e62 | |||
| 00846f92df | |||
| 04fa209f22 | |||
| 76c741a7ba | |||
| d92f8c76b2 | |||
| 779854d69f | |||
| 61fcca8aff | |||
| 5696a068e6 | |||
| a3be8380c9 | |||
| beb95f7bbe | |||
| 755c418391 | |||
| 57681c674f | |||
| 79c6c32a78 | |||
| 5e98d1c8f2 | |||
| 5a2b64df70 | |||
| 578493edc1 | |||
| 41bebc862b | |||
| e9529e3cb0 | |||
| 848f5d7596 | |||
| 0448cde765 | |||
| ae353afbed | |||
| 58d3ca6ad0 | |||
| f5bf480c76 | |||
| 45ab4583de | |||
| 2cc07b9c3e | |||
| a94ef43974 | |||
| 70f343fbac | |||
| 6c0959f5bb | |||
| 22a0097a5d | |||
| 78d836c71e | |||
| 43cf22b654 | |||
| b00c928148 | |||
| f7f9ba3aa7 | |||
| c75ded02c8 |
43
Dockerfile
43
Dockerfile
@@ -1,25 +1,52 @@
|
|||||||
FROM python:3.11-slim
|
# Stage 1: build dependencies
|
||||||
|
FROM python:3.11-slim AS builder
|
||||||
|
|
||||||
WORKDIR /app
|
WORKDIR /app
|
||||||
|
|
||||||
# Install system dependencies
|
# Install build dependencies
|
||||||
RUN apt-get update && apt-get install -y \
|
RUN apt-get update && apt-get install -y \
|
||||||
build-essential \
|
build-essential \
|
||||||
curl \
|
|
||||||
default-libmysqlclient-dev \
|
default-libmysqlclient-dev \
|
||||||
pkg-config \
|
pkg-config \
|
||||||
&& rm -rf /var/lib/apt/lists/*
|
&& rm -rf /var/lib/apt/lists/*
|
||||||
|
|
||||||
|
# Pre-download wheels to avoid recompiling bcrypt from source
|
||||||
|
RUN pip install --no-cache-dir --prefix=/install \
|
||||||
|
'bcrypt==4.0.1' \
|
||||||
|
'cffi>=2.0' \
|
||||||
|
'pycparser>=2.0'
|
||||||
|
|
||||||
# Install Python dependencies
|
# Install Python dependencies
|
||||||
COPY requirements.txt .
|
COPY requirements.txt .
|
||||||
RUN pip install --no-cache-dir -r requirements.txt
|
RUN pip install --no-cache-dir --prefix=/install -r requirements.txt
|
||||||
|
|
||||||
|
# Stage 2: slim runtime
|
||||||
|
FROM python:3.11-slim
|
||||||
|
|
||||||
|
WORKDIR /app
|
||||||
|
|
||||||
|
# Install runtime dependencies only (no build tools)
|
||||||
|
RUN apt-get update && apt-get install -y \
|
||||||
|
default-libmysqlclient-dev \
|
||||||
|
curl \
|
||||||
|
&& rm -rf /var/lib/apt/lists/*
|
||||||
|
|
||||||
|
# Copy installed packages from builder
|
||||||
|
COPY --from=builder /install /usr/local
|
||||||
|
|
||||||
# Copy application code
|
# Copy application code
|
||||||
COPY . .
|
COPY app/ ./app/
|
||||||
|
COPY requirements.txt ./
|
||||||
|
|
||||||
|
# Make entrypoint
|
||||||
|
COPY entrypoint.sh .
|
||||||
RUN chmod +x entrypoint.sh
|
RUN chmod +x entrypoint.sh
|
||||||
|
|
||||||
# Expose port
|
# OIDC-only mode: when "true", password login is rejected, user creation
|
||||||
EXPOSE 8000
|
# 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"]
|
ENTRYPOINT ["./entrypoint.sh"]
|
||||||
|
|||||||
232
README.md
232
README.md
@@ -1,100 +1,163 @@
|
|||||||
# HarborForge Backend
|
# 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
|
- **Role:** core REST API — users, projects, tasks, milestones, proposals, RBAC, webhooks, worklogs, notifications, monitor telemetry.
|
||||||
- `POST /auth/token` - 登录获取 JWT token
|
- **Stack:** Python 3.11 · FastAPI · SQLAlchemy · MySQL
|
||||||
- `GET /auth/me` - 获取当前用户信息
|
- **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}`
|
## Run / Build
|
||||||
> Issues 支持排序参数:`sort_by` (created_at/priority/title/due_date/status), `sort_order` (asc/desc)
|
|
||||||
> Issues 支持额外过滤:`assignee_id`, `tag`
|
|
||||||
|
|
||||||
|
### Docker
|
||||||
|
|
||||||
> Issues 和 Search 列表接口返回分页格式:
|
```bash
|
||||||
> Issues 支持排序参数: (created_at/priority/title/due_date/status), (asc/desc)
|
docker build -t harborforge-backend .
|
||||||
> Issues 支持额外过滤:,
|
docker run -p 8000:8000 \
|
||||||
- `POST /issues` - 创建 issue(支持 resolution 决议案类型)
|
-e SECRET_KEY="$(openssl rand -hex 32)" \
|
||||||
- `GET /issues` - 列表(分页、排序、按 assignee/tag 过滤)(支持按 project/status/type 过滤)
|
-v /path/to/config:/config \
|
||||||
- `GET /issues/{id}` - 详情
|
harborforge-backend
|
||||||
- `PATCH /issues/{id}` - 更新
|
```
|
||||||
- `DELETE /issues/{id}` - 删除
|
|
||||||
- `POST /issues/{id}/transition` - 状态变更(触发 webhook)
|
|
||||||
- `GET /search/issues?q=keyword` - 搜索
|
|
||||||
|
|
||||||
### Comments
|
### Local (uvicorn)
|
||||||
- `POST /comments` - 创建评论
|
|
||||||
- `GET /issues/{id}/comments` - 列表
|
|
||||||
- `PATCH /comments/{id}` - 更新
|
|
||||||
- `DELETE /comments/{id}` - 删除
|
|
||||||
|
|
||||||
### Projects
|
```bash
|
||||||
- `POST /projects` - 创建
|
pip install -r requirements.txt
|
||||||
- `GET /projects` - 列表
|
export SECRET_KEY="$(openssl rand -hex 32)"
|
||||||
- `GET /projects/{id}` - 详情
|
uvicorn app.main:app --host 0.0.0.0 --port 8000
|
||||||
- `PATCH /projects/{id}` - 更新
|
```
|
||||||
- `DELETE /projects/{id}` - 删除
|
|
||||||
|
|
||||||
### Project Members
|
On startup the app creates/migrates the schema, runs AbstractWizard
|
||||||
- `POST /projects/{id}/members` - 添加成员
|
initialization (admin user, default project, default roles), and starts a
|
||||||
- `GET /projects/{id}/members` - 列表
|
background monitor-polling thread.
|
||||||
- `DELETE /projects/{id}/members/{user_id}` - 移除
|
|
||||||
|
|
||||||
### Users
|
## Configuration
|
||||||
- `POST /users` - 注册
|
|
||||||
- `GET /users` - 列表
|
|
||||||
- `GET /users/{id}` - 详情
|
|
||||||
- `PATCH /users/{id}` - 更新
|
|
||||||
|
|
||||||
### Webhooks
|
Environment variables (also loadable from a `.env` file):
|
||||||
- `POST /webhooks` - 创建
|
|
||||||
- `GET /webhooks` - 列表
|
|
||||||
- `GET /webhooks/{id}` - 详情
|
|
||||||
- `PATCH /webhooks/{id}` - 更新
|
|
||||||
- `DELETE /webhooks/{id}` - 删除
|
|
||||||
- `GET /webhooks/{id}/logs` - 投递日志
|
|
||||||
|
|
||||||
### System
|
| Variable | Default | Description |
|
||||||
- `GET /health` - 健康检查
|
|----------|---------|-------------|
|
||||||
- `GET /version` - 版本信息
|
| `SECRET_KEY` | *(none — must be set)* | JWT signing key (HS256). The server **refuses to start** with a weak/default/short value. |
|
||||||
- `GET /dashboard/stats` - 统计面板
|
| `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
|
Database resolution order: **wizard config volume** (`$CONFIG_DIR/$CONFIG_FILE` → `database` block) → `DATABASE_URL` env → built-in default.
|
||||||
- `POST /milestones` - 创建里程碑
|
|
||||||
- `GET /milestones` - 列表(支持按 project/status 过滤)
|
|
||||||
- `GET /milestones/{id}` - 详情
|
|
||||||
- `PATCH /milestones/{id}` - 更新
|
|
||||||
- `DELETE /milestones/{id}` - 删除
|
|
||||||
- `GET /milestones/{id}/issues` - 里程碑下的 issue 列表
|
|
||||||
- `GET /milestones/{id}/progress` - 里程碑完成进度
|
|
||||||
|
|
||||||
### Notifications
|
## Security
|
||||||
- `GET /notifications` - 列表(支持 user_id, unread_only 过滤)
|
|
||||||
- `GET /notifications/count` - 未读通知计数
|
|
||||||
- `POST /notifications/{id}/read` - 标记已读
|
|
||||||
- `POST /notifications/read-all` - 全部标记已读
|
|
||||||
|
|
||||||
### Issue Assignment
|
The current code enforces the following security posture. These are
|
||||||
- `POST /issues/{id}/assign` - 指派 issue(自动发送通知)
|
operational requirements, not optional hardening.
|
||||||
|
|
||||||
### Webhook Retry
|
### Mandatory strong `SECRET_KEY`
|
||||||
- `POST /webhooks/{id}/retry/{log_id}` - 重试失败的 webhook 投递
|
|
||||||
|
|
||||||
### Time Tracking (Work Logs)
|
`app/core/config.py` validates `SECRET_KEY` at import time and **raises and
|
||||||
- `POST /worklogs` - 记录工时
|
refuses to start** if the value is empty, shorter than 32 characters, or a
|
||||||
- `GET /issues/{id}/worklogs` - 某 issue 的工时记录
|
known default/placeholder (e.g. `change-me-in-production`, `secret`,
|
||||||
- `GET /issues/{id}/worklogs/summary` - 某 issue 工时汇总
|
`changeme`). Operators **must** provide a strong random key:
|
||||||
- `GET /users/{id}/worklogs` - 某用户的工时记录
|
|
||||||
- `DELETE /worklogs/{id}` - 删除工时记录
|
|
||||||
- `GET /projects/{id}/worklogs/summary` - 项目工时汇总(按用户分组)
|
|
||||||
|
|
||||||
### Export
|
```bash
|
||||||
- `GET /export/issues` - 导出 issues CSV
|
openssl rand -hex 32
|
||||||
- `GET /issues/overdue` - 逾期未完成的 issue
|
```
|
||||||
|
|
||||||
|
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
|
## 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.
|
See [HarborForge.Cli](../HarborForge.Cli/README.md) for installation and usage.
|
||||||
|
|
||||||
## 技术栈
|
## Tech Stack
|
||||||
|
|
||||||
- Python 3.11 + FastAPI
|
- Python 3.11 + FastAPI
|
||||||
- SQLAlchemy + MySQL
|
- SQLAlchemy + MySQL (auto schema create/migrate on startup)
|
||||||
- JWT (python-jose)
|
- JWT (python-jose, HS256) + bcrypt password hashing
|
||||||
- Docker
|
- Docker
|
||||||
|
|
||||||
## Issue Types
|
|
||||||
|
|
||||||
| Type | 用途 |
|
|
||||||
|------|------|
|
|
||||||
| task | 普通任务 |
|
|
||||||
| story | 用户故事 |
|
|
||||||
| test | 测试用例 |
|
|
||||||
| resolution | 决议案(Agent 僵局提交)|
|
|
||||||
|
|||||||
@@ -76,3 +76,10 @@ async def get_current_user_or_apikey(
|
|||||||
if token:
|
if token:
|
||||||
return await get_current_user(token=token, db=db)
|
return await get_current_user(token=token, db=db)
|
||||||
raise HTTPException(status_code=401, detail="Not authenticated")
|
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
|
||||||
|
|||||||
@@ -81,12 +81,28 @@ def check_project_role(db: Session, user_id: int, project_id: int, min_role: str
|
|||||||
detail="Role not found"
|
detail="Role not found"
|
||||||
)
|
)
|
||||||
|
|
||||||
# Legacy compatibility: most current routes use non-hierarchical names like dev/mgr.
|
# Enforce a real role hierarchy. Higher rank == more privilege.
|
||||||
# For now, any valid membership passes those broad checks; strict edit rules are handled
|
_RANK = {
|
||||||
# by the explicit can_edit_* helpers below.
|
"guest": 0,
|
||||||
if min_role in {"dev", "mgr", "viewer", "member", "guest", "admin"}:
|
"viewer": 1,
|
||||||
return True
|
"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
|
return True
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -18,6 +18,8 @@ router = APIRouter(prefix="/auth", tags=["Auth"])
|
|||||||
|
|
||||||
@router.post("/token", response_model=Token)
|
@router.post("/token", response_model=Token)
|
||||||
async def login(form_data: OAuth2PasswordRequestForm = Depends(), db: Session = Depends(get_db)):
|
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()
|
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 ""):
|
if not user or not verify_password(form_data.password, user.hashed_password or ""):
|
||||||
raise HTTPException(status_code=401, detail="Incorrect username or password",
|
raise HTTPException(status_code=401, detail="Incorrect username or password",
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -1,7 +1,7 @@
|
|||||||
"""Essentials API router — CRUD for Essentials nested under a Proposal.
|
"""Essentials API router — CRUD for Essentials nested under a Proposal.
|
||||||
|
|
||||||
Endpoints are scoped to a project and proposal:
|
Endpoints are scoped to a project and proposal:
|
||||||
/projects/{project_id}/proposals/{proposal_id}/essentials
|
/projects/{project_code}/proposals/{proposal_code}/essentials
|
||||||
|
|
||||||
Only open Proposals allow Essential mutations.
|
Only open Proposals allow Essential mutations.
|
||||||
"""
|
"""
|
||||||
@@ -26,7 +26,7 @@ from app.services.activity import log_activity
|
|||||||
from app.services.essential_code import generate_essential_code
|
from app.services.essential_code import generate_essential_code
|
||||||
|
|
||||||
router = APIRouter(
|
router = APIRouter(
|
||||||
prefix="/projects/{project_id}/proposals/{proposal_id}/essentials",
|
prefix="/projects/{project_code}/proposals/{proposal_code}/essentials",
|
||||||
tags=["Essentials"],
|
tags=["Essentials"],
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -35,53 +35,27 @@ router = APIRouter(
|
|||||||
# Helpers
|
# Helpers
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
def _find_project(db: Session, identifier: str):
|
def _find_project(db: Session, project_code: str):
|
||||||
"""Look up project by numeric id or project_code."""
|
"""Look up project by project_code."""
|
||||||
try:
|
|
||||||
pid = int(identifier)
|
|
||||||
p = db.query(models.Project).filter(models.Project.id == pid).first()
|
|
||||||
if p:
|
|
||||||
return p
|
|
||||||
except (ValueError, TypeError):
|
|
||||||
pass
|
|
||||||
return db.query(models.Project).filter(
|
return db.query(models.Project).filter(
|
||||||
models.Project.project_code == str(identifier)
|
models.Project.project_code == str(project_code)
|
||||||
).first()
|
).first()
|
||||||
|
|
||||||
|
|
||||||
def _find_proposal(db: Session, identifier: str, project_id: int) -> Proposal | None:
|
def _find_proposal(db: Session, proposal_code: str, project_id: int) -> Proposal | None:
|
||||||
"""Look up proposal by numeric id or propose_code within a project."""
|
"""Look up proposal by propose_code within a project."""
|
||||||
try:
|
|
||||||
pid = int(identifier)
|
|
||||||
q = db.query(Proposal).filter(Proposal.id == pid, Proposal.project_id == project_id)
|
|
||||||
p = q.first()
|
|
||||||
if p:
|
|
||||||
return p
|
|
||||||
except (ValueError, TypeError):
|
|
||||||
pass
|
|
||||||
return (
|
return (
|
||||||
db.query(Proposal)
|
db.query(Proposal)
|
||||||
.filter(Proposal.propose_code == str(identifier), Proposal.project_id == project_id)
|
.filter(Proposal.propose_code == str(proposal_code), Proposal.project_id == project_id)
|
||||||
.first()
|
.first()
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
def _find_essential(db: Session, identifier: str, proposal_id: int) -> Essential | None:
|
def _find_essential(db: Session, essential_code: str, proposal_id: int) -> Essential | None:
|
||||||
"""Look up essential by numeric id or essential_code within a proposal."""
|
"""Look up essential by essential_code within a proposal."""
|
||||||
try:
|
|
||||||
eid = int(identifier)
|
|
||||||
e = (
|
|
||||||
db.query(Essential)
|
|
||||||
.filter(Essential.id == eid, Essential.proposal_id == proposal_id)
|
|
||||||
.first()
|
|
||||||
)
|
|
||||||
if e:
|
|
||||||
return e
|
|
||||||
except (ValueError, TypeError):
|
|
||||||
pass
|
|
||||||
return (
|
return (
|
||||||
db.query(Essential)
|
db.query(Essential)
|
||||||
.filter(Essential.essential_code == str(identifier), Essential.proposal_id == proposal_id)
|
.filter(Essential.essential_code == str(essential_code), Essential.proposal_id == proposal_id)
|
||||||
.first()
|
.first()
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -108,12 +82,11 @@ def _can_edit_proposal(db: Session, user_id: int, proposal: Proposal) -> bool:
|
|||||||
return False
|
return False
|
||||||
|
|
||||||
|
|
||||||
def _serialize_essential(e: Essential) -> dict:
|
def _serialize_essential(e: Essential, proposal_code: str | None) -> dict:
|
||||||
"""Return a dict matching EssentialResponse."""
|
"""Return a dict matching EssentialResponse."""
|
||||||
return {
|
return {
|
||||||
"id": e.id,
|
|
||||||
"essential_code": e.essential_code,
|
"essential_code": e.essential_code,
|
||||||
"proposal_id": e.proposal_id,
|
"proposal_code": proposal_code,
|
||||||
"type": e.type.value if hasattr(e.type, "value") else e.type,
|
"type": e.type.value if hasattr(e.type, "value") else e.type,
|
||||||
"title": e.title,
|
"title": e.title,
|
||||||
"description": e.description,
|
"description": e.description,
|
||||||
@@ -129,18 +102,18 @@ def _serialize_essential(e: Essential) -> dict:
|
|||||||
|
|
||||||
@router.get("", response_model=List[EssentialResponse])
|
@router.get("", response_model=List[EssentialResponse])
|
||||||
def list_essentials(
|
def list_essentials(
|
||||||
project_id: str,
|
project_code: str,
|
||||||
proposal_id: str,
|
proposal_code: str,
|
||||||
db: Session = Depends(get_db),
|
db: Session = Depends(get_db),
|
||||||
current_user: models.User = Depends(get_current_user_or_apikey),
|
current_user: models.User = Depends(get_current_user_or_apikey),
|
||||||
):
|
):
|
||||||
"""List all Essentials under a Proposal."""
|
"""List all Essentials under a Proposal."""
|
||||||
project = _find_project(db, project_id)
|
project = _find_project(db, project_code)
|
||||||
if not project:
|
if not project:
|
||||||
raise HTTPException(status_code=404, detail="Project not found")
|
raise HTTPException(status_code=404, detail="Project not found")
|
||||||
check_project_role(db, current_user.id, project.id, min_role="viewer")
|
check_project_role(db, current_user.id, project.id, min_role="viewer")
|
||||||
|
|
||||||
proposal = _find_proposal(db, proposal_id, project.id)
|
proposal = _find_proposal(db, proposal_code, project.id)
|
||||||
if not proposal:
|
if not proposal:
|
||||||
raise HTTPException(status_code=404, detail="Proposal not found")
|
raise HTTPException(status_code=404, detail="Proposal not found")
|
||||||
|
|
||||||
@@ -150,24 +123,24 @@ def list_essentials(
|
|||||||
.order_by(Essential.id.asc())
|
.order_by(Essential.id.asc())
|
||||||
.all()
|
.all()
|
||||||
)
|
)
|
||||||
return [_serialize_essential(e) for e in essentials]
|
return [_serialize_essential(e, proposal.propose_code) for e in essentials]
|
||||||
|
|
||||||
|
|
||||||
@router.post("", response_model=EssentialResponse, status_code=status.HTTP_201_CREATED)
|
@router.post("", response_model=EssentialResponse, status_code=status.HTTP_201_CREATED)
|
||||||
def create_essential(
|
def create_essential(
|
||||||
project_id: str,
|
project_code: str,
|
||||||
proposal_id: str,
|
proposal_code: str,
|
||||||
body: EssentialCreate,
|
body: EssentialCreate,
|
||||||
db: Session = Depends(get_db),
|
db: Session = Depends(get_db),
|
||||||
current_user: models.User = Depends(get_current_user_or_apikey),
|
current_user: models.User = Depends(get_current_user_or_apikey),
|
||||||
):
|
):
|
||||||
"""Create a new Essential under an open Proposal."""
|
"""Create a new Essential under an open Proposal."""
|
||||||
project = _find_project(db, project_id)
|
project = _find_project(db, project_code)
|
||||||
if not project:
|
if not project:
|
||||||
raise HTTPException(status_code=404, detail="Project not found")
|
raise HTTPException(status_code=404, detail="Project not found")
|
||||||
check_project_role(db, current_user.id, project.id, min_role="dev")
|
check_project_role(db, current_user.id, project.id, min_role="dev")
|
||||||
|
|
||||||
proposal = _find_proposal(db, proposal_id, project.id)
|
proposal = _find_proposal(db, proposal_code, project.id)
|
||||||
if not proposal:
|
if not proposal:
|
||||||
raise HTTPException(status_code=404, detail="Proposal not found")
|
raise HTTPException(status_code=404, detail="Proposal not found")
|
||||||
|
|
||||||
@@ -196,50 +169,50 @@ def create_essential(
|
|||||||
details={"title": essential.title, "type": body.type.value, "proposal_id": proposal.id},
|
details={"title": essential.title, "type": body.type.value, "proposal_id": proposal.id},
|
||||||
)
|
)
|
||||||
|
|
||||||
return _serialize_essential(essential)
|
return _serialize_essential(essential, proposal.propose_code)
|
||||||
|
|
||||||
|
|
||||||
@router.get("/{essential_id}", response_model=EssentialResponse)
|
@router.get("/{essential_id}", response_model=EssentialResponse)
|
||||||
def get_essential(
|
def get_essential(
|
||||||
project_id: str,
|
project_code: str,
|
||||||
proposal_id: str,
|
proposal_code: str,
|
||||||
essential_id: str,
|
essential_code: str,
|
||||||
db: Session = Depends(get_db),
|
db: Session = Depends(get_db),
|
||||||
current_user: models.User = Depends(get_current_user_or_apikey),
|
current_user: models.User = Depends(get_current_user_or_apikey),
|
||||||
):
|
):
|
||||||
"""Get a single Essential by id or essential_code."""
|
"""Get a single Essential by essential_code."""
|
||||||
project = _find_project(db, project_id)
|
project = _find_project(db, project_code)
|
||||||
if not project:
|
if not project:
|
||||||
raise HTTPException(status_code=404, detail="Project not found")
|
raise HTTPException(status_code=404, detail="Project not found")
|
||||||
check_project_role(db, current_user.id, project.id, min_role="viewer")
|
check_project_role(db, current_user.id, project.id, min_role="viewer")
|
||||||
|
|
||||||
proposal = _find_proposal(db, proposal_id, project.id)
|
proposal = _find_proposal(db, proposal_code, project.id)
|
||||||
if not proposal:
|
if not proposal:
|
||||||
raise HTTPException(status_code=404, detail="Proposal not found")
|
raise HTTPException(status_code=404, detail="Proposal not found")
|
||||||
|
|
||||||
essential = _find_essential(db, essential_id, proposal.id)
|
essential = _find_essential(db, essential_code, proposal.id)
|
||||||
if not essential:
|
if not essential:
|
||||||
raise HTTPException(status_code=404, detail="Essential not found")
|
raise HTTPException(status_code=404, detail="Essential not found")
|
||||||
|
|
||||||
return _serialize_essential(essential)
|
return _serialize_essential(essential, proposal.propose_code)
|
||||||
|
|
||||||
|
|
||||||
@router.patch("/{essential_id}", response_model=EssentialResponse)
|
@router.patch("/{essential_id}", response_model=EssentialResponse)
|
||||||
def update_essential(
|
def update_essential(
|
||||||
project_id: str,
|
project_code: str,
|
||||||
proposal_id: str,
|
proposal_code: str,
|
||||||
essential_id: str,
|
essential_code: str,
|
||||||
body: EssentialUpdate,
|
body: EssentialUpdate,
|
||||||
db: Session = Depends(get_db),
|
db: Session = Depends(get_db),
|
||||||
current_user: models.User = Depends(get_current_user_or_apikey),
|
current_user: models.User = Depends(get_current_user_or_apikey),
|
||||||
):
|
):
|
||||||
"""Update an Essential (only on open Proposals)."""
|
"""Update an Essential (only on open Proposals)."""
|
||||||
project = _find_project(db, project_id)
|
project = _find_project(db, project_code)
|
||||||
if not project:
|
if not project:
|
||||||
raise HTTPException(status_code=404, detail="Project not found")
|
raise HTTPException(status_code=404, detail="Project not found")
|
||||||
check_project_role(db, current_user.id, project.id, min_role="dev")
|
check_project_role(db, current_user.id, project.id, min_role="dev")
|
||||||
|
|
||||||
proposal = _find_proposal(db, proposal_id, project.id)
|
proposal = _find_proposal(db, proposal_code, project.id)
|
||||||
if not proposal:
|
if not proposal:
|
||||||
raise HTTPException(status_code=404, detail="Proposal not found")
|
raise HTTPException(status_code=404, detail="Proposal not found")
|
||||||
|
|
||||||
@@ -248,7 +221,7 @@ def update_essential(
|
|||||||
if not _can_edit_proposal(db, current_user.id, proposal):
|
if not _can_edit_proposal(db, current_user.id, proposal):
|
||||||
raise HTTPException(status_code=403, detail="Permission denied")
|
raise HTTPException(status_code=403, detail="Permission denied")
|
||||||
|
|
||||||
essential = _find_essential(db, essential_id, proposal.id)
|
essential = _find_essential(db, essential_code, proposal.id)
|
||||||
if not essential:
|
if not essential:
|
||||||
raise HTTPException(status_code=404, detail="Essential not found")
|
raise HTTPException(status_code=404, detail="Essential not found")
|
||||||
|
|
||||||
@@ -265,24 +238,24 @@ def update_essential(
|
|||||||
details=data,
|
details=data,
|
||||||
)
|
)
|
||||||
|
|
||||||
return _serialize_essential(essential)
|
return _serialize_essential(essential, proposal.propose_code)
|
||||||
|
|
||||||
|
|
||||||
@router.delete("/{essential_id}", status_code=status.HTTP_204_NO_CONTENT)
|
@router.delete("/{essential_id}", status_code=status.HTTP_204_NO_CONTENT)
|
||||||
def delete_essential(
|
def delete_essential(
|
||||||
project_id: str,
|
project_code: str,
|
||||||
proposal_id: str,
|
proposal_code: str,
|
||||||
essential_id: str,
|
essential_code: str,
|
||||||
db: Session = Depends(get_db),
|
db: Session = Depends(get_db),
|
||||||
current_user: models.User = Depends(get_current_user_or_apikey),
|
current_user: models.User = Depends(get_current_user_or_apikey),
|
||||||
):
|
):
|
||||||
"""Delete an Essential (only on open Proposals)."""
|
"""Delete an Essential (only on open Proposals)."""
|
||||||
project = _find_project(db, project_id)
|
project = _find_project(db, project_code)
|
||||||
if not project:
|
if not project:
|
||||||
raise HTTPException(status_code=404, detail="Project not found")
|
raise HTTPException(status_code=404, detail="Project not found")
|
||||||
check_project_role(db, current_user.id, project.id, min_role="dev")
|
check_project_role(db, current_user.id, project.id, min_role="dev")
|
||||||
|
|
||||||
proposal = _find_proposal(db, proposal_id, project.id)
|
proposal = _find_proposal(db, proposal_code, project.id)
|
||||||
if not proposal:
|
if not proposal:
|
||||||
raise HTTPException(status_code=404, detail="Proposal not found")
|
raise HTTPException(status_code=404, detail="Proposal not found")
|
||||||
|
|
||||||
@@ -291,7 +264,7 @@ def delete_essential(
|
|||||||
if not _can_edit_proposal(db, current_user.id, proposal):
|
if not _can_edit_proposal(db, current_user.id, proposal):
|
||||||
raise HTTPException(status_code=403, detail="Permission denied")
|
raise HTTPException(status_code=403, detail="Permission denied")
|
||||||
|
|
||||||
essential = _find_essential(db, essential_id, proposal.id)
|
essential = _find_essential(db, essential_code, proposal.id)
|
||||||
if not essential:
|
if not essential:
|
||||||
raise HTTPException(status_code=404, detail="Essential not found")
|
raise HTTPException(status_code=404, detail="Essential not found")
|
||||||
|
|
||||||
|
|||||||
@@ -18,15 +18,8 @@ router = APIRouter(tags=["Meetings"])
|
|||||||
|
|
||||||
# ---- helpers ----
|
# ---- helpers ----
|
||||||
|
|
||||||
def _find_meeting_by_id_or_code(db: Session, identifier: str) -> Meeting | None:
|
def _find_meeting_by_code(db: Session, meeting_code: str) -> Meeting | None:
|
||||||
try:
|
return db.query(Meeting).filter(Meeting.meeting_code == str(meeting_code)).first()
|
||||||
mid = int(identifier)
|
|
||||||
meeting = db.query(Meeting).filter(Meeting.id == mid).first()
|
|
||||||
if meeting:
|
|
||||||
return meeting
|
|
||||||
except (ValueError, TypeError):
|
|
||||||
pass
|
|
||||||
return db.query(Meeting).filter(Meeting.meeting_code == str(identifier)).first()
|
|
||||||
|
|
||||||
|
|
||||||
def _resolve_project_id(db: Session, project_code: str | None) -> int | None:
|
def _resolve_project_id(db: Session, project_code: str | None) -> int | None:
|
||||||
@@ -64,16 +57,13 @@ def _serialize_meeting(db: Session, meeting: Meeting) -> dict:
|
|||||||
project = db.query(models.Project).filter(models.Project.id == meeting.project_id).first()
|
project = db.query(models.Project).filter(models.Project.id == meeting.project_id).first()
|
||||||
milestone = db.query(Milestone).filter(Milestone.id == meeting.milestone_id).first()
|
milestone = db.query(Milestone).filter(Milestone.id == meeting.milestone_id).first()
|
||||||
return {
|
return {
|
||||||
"id": meeting.id,
|
|
||||||
"code": meeting.meeting_code,
|
"code": meeting.meeting_code,
|
||||||
"meeting_code": meeting.meeting_code,
|
"meeting_code": meeting.meeting_code,
|
||||||
"title": meeting.title,
|
"title": meeting.title,
|
||||||
"description": meeting.description,
|
"description": meeting.description,
|
||||||
"status": meeting.status.value if hasattr(meeting.status, "value") else meeting.status,
|
"status": meeting.status.value if hasattr(meeting.status, "value") else meeting.status,
|
||||||
"priority": meeting.priority.value if hasattr(meeting.priority, "value") else meeting.priority,
|
"priority": meeting.priority.value if hasattr(meeting.priority, "value") else meeting.priority,
|
||||||
"project_id": meeting.project_id,
|
|
||||||
"project_code": project.project_code if project else None,
|
"project_code": project.project_code if project else None,
|
||||||
"milestone_id": meeting.milestone_id,
|
|
||||||
"milestone_code": milestone.milestone_code if milestone else None,
|
"milestone_code": milestone.milestone_code if milestone else None,
|
||||||
"reporter_id": meeting.reporter_id,
|
"reporter_id": meeting.reporter_id,
|
||||||
"meeting_time": meeting.scheduled_at.isoformat() if meeting.scheduled_at else None,
|
"meeting_time": meeting.scheduled_at.isoformat() if meeting.scheduled_at else None,
|
||||||
@@ -155,6 +145,7 @@ def create_meeting(
|
|||||||
@router.get("/meetings")
|
@router.get("/meetings")
|
||||||
def list_meetings(
|
def list_meetings(
|
||||||
project: str = None,
|
project: str = None,
|
||||||
|
project_code: str = None,
|
||||||
status_value: str = Query(None, alias="status"),
|
status_value: str = Query(None, alias="status"),
|
||||||
order_by: str = None,
|
order_by: str = None,
|
||||||
page: int = 1,
|
page: int = 1,
|
||||||
@@ -163,8 +154,9 @@ def list_meetings(
|
|||||||
):
|
):
|
||||||
query = db.query(Meeting)
|
query = db.query(Meeting)
|
||||||
|
|
||||||
if project:
|
effective_project = project_code or project
|
||||||
project_id = _resolve_project_id(db, project)
|
if effective_project:
|
||||||
|
project_id = _resolve_project_id(db, effective_project)
|
||||||
if project_id:
|
if project_id:
|
||||||
query = query.filter(Meeting.project_id == project_id)
|
query = query.filter(Meeting.project_id == project_id)
|
||||||
|
|
||||||
@@ -197,9 +189,9 @@ def list_meetings(
|
|||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
@router.get("/meetings/{meeting_id}")
|
@router.get("/meetings/{meeting_code}")
|
||||||
def get_meeting(meeting_id: str, db: Session = Depends(get_db)):
|
def get_meeting(meeting_code: str, db: Session = Depends(get_db)):
|
||||||
meeting = _find_meeting_by_id_or_code(db, meeting_id)
|
meeting = _find_meeting_by_code(db, meeting_code)
|
||||||
if not meeting:
|
if not meeting:
|
||||||
raise HTTPException(status_code=404, detail="Meeting not found")
|
raise HTTPException(status_code=404, detail="Meeting not found")
|
||||||
return _serialize_meeting(db, meeting)
|
return _serialize_meeting(db, meeting)
|
||||||
@@ -213,14 +205,14 @@ class MeetingUpdateBody(BaseModel):
|
|||||||
duration_minutes: Optional[int] = None
|
duration_minutes: Optional[int] = None
|
||||||
|
|
||||||
|
|
||||||
@router.patch("/meetings/{meeting_id}")
|
@router.patch("/meetings/{meeting_code}")
|
||||||
def update_meeting(
|
def update_meeting(
|
||||||
meeting_id: str,
|
meeting_code: str,
|
||||||
body: MeetingUpdateBody,
|
body: MeetingUpdateBody,
|
||||||
db: Session = Depends(get_db),
|
db: Session = Depends(get_db),
|
||||||
current_user: models.User = Depends(get_current_user_or_apikey),
|
current_user: models.User = Depends(get_current_user_or_apikey),
|
||||||
):
|
):
|
||||||
meeting = _find_meeting_by_id_or_code(db, meeting_id)
|
meeting = _find_meeting_by_code(db, meeting_code)
|
||||||
if not meeting:
|
if not meeting:
|
||||||
raise HTTPException(status_code=404, detail="Meeting not found")
|
raise HTTPException(status_code=404, detail="Meeting not found")
|
||||||
check_project_role(db, current_user.id, meeting.project_id, min_role="dev")
|
check_project_role(db, current_user.id, meeting.project_id, min_role="dev")
|
||||||
@@ -248,13 +240,13 @@ def update_meeting(
|
|||||||
return _serialize_meeting(db, meeting)
|
return _serialize_meeting(db, meeting)
|
||||||
|
|
||||||
|
|
||||||
@router.delete("/meetings/{meeting_id}", status_code=status.HTTP_204_NO_CONTENT)
|
@router.delete("/meetings/{meeting_code}", status_code=status.HTTP_204_NO_CONTENT)
|
||||||
def delete_meeting(
|
def delete_meeting(
|
||||||
meeting_id: str,
|
meeting_code: str,
|
||||||
db: Session = Depends(get_db),
|
db: Session = Depends(get_db),
|
||||||
current_user: models.User = Depends(get_current_user_or_apikey),
|
current_user: models.User = Depends(get_current_user_or_apikey),
|
||||||
):
|
):
|
||||||
meeting = _find_meeting_by_id_or_code(db, meeting_id)
|
meeting = _find_meeting_by_code(db, meeting_code)
|
||||||
if not meeting:
|
if not meeting:
|
||||||
raise HTTPException(status_code=404, detail="Meeting not found")
|
raise HTTPException(status_code=404, detail="Meeting not found")
|
||||||
check_project_role(db, current_user.id, meeting.project_id, min_role="dev")
|
check_project_role(db, current_user.id, meeting.project_id, min_role="dev")
|
||||||
@@ -265,13 +257,13 @@ def delete_meeting(
|
|||||||
|
|
||||||
# ---- Attend ----
|
# ---- Attend ----
|
||||||
|
|
||||||
@router.post("/meetings/{meeting_id}/attend")
|
@router.post("/meetings/{meeting_code}/attend")
|
||||||
def attend_meeting(
|
def attend_meeting(
|
||||||
meeting_id: str,
|
meeting_code: str,
|
||||||
db: Session = Depends(get_db),
|
db: Session = Depends(get_db),
|
||||||
current_user: models.User = Depends(get_current_user_or_apikey),
|
current_user: models.User = Depends(get_current_user_or_apikey),
|
||||||
):
|
):
|
||||||
meeting = _find_meeting_by_id_or_code(db, meeting_id)
|
meeting = _find_meeting_by_code(db, meeting_code)
|
||||||
if not meeting:
|
if not meeting:
|
||||||
raise HTTPException(status_code=404, detail="Meeting not found")
|
raise HTTPException(status_code=404, detail="Meeting not found")
|
||||||
check_project_role(db, current_user.id, meeting.project_id, min_role="viewer")
|
check_project_role(db, current_user.id, meeting.project_id, min_role="viewer")
|
||||||
|
|||||||
@@ -20,7 +20,7 @@ from app.services.activity import log_activity
|
|||||||
from app.services.dependency_check import check_milestone_deps
|
from app.services.dependency_check import check_milestone_deps
|
||||||
|
|
||||||
router = APIRouter(
|
router = APIRouter(
|
||||||
prefix="/projects/{project_id}/milestones/{milestone_id}/actions",
|
prefix="/projects/{project_code}/milestones/{milestone_code}/actions",
|
||||||
tags=["Milestone Actions"],
|
tags=["Milestone Actions"],
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -29,10 +29,18 @@ router = APIRouter(
|
|||||||
# Helpers
|
# Helpers
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
def _get_milestone_or_404(db: Session, project_id: int, milestone_id: int) -> Milestone:
|
def _resolve_project_or_404(db: Session, project_code: str):
|
||||||
|
project = db.query(models.Project).filter(models.Project.project_code == project_code).first()
|
||||||
|
if not project:
|
||||||
|
raise HTTPException(status_code=404, detail="Project not found")
|
||||||
|
return project
|
||||||
|
|
||||||
|
|
||||||
|
def _get_milestone_or_404(db: Session, project_code: str, milestone_code: str) -> Milestone:
|
||||||
|
project = _resolve_project_or_404(db, project_code)
|
||||||
ms = (
|
ms = (
|
||||||
db.query(Milestone)
|
db.query(Milestone)
|
||||||
.filter(Milestone.id == milestone_id, Milestone.project_id == project_id)
|
.filter(Milestone.milestone_code == milestone_code, Milestone.project_id == project.id)
|
||||||
.first()
|
.first()
|
||||||
)
|
)
|
||||||
if not ms:
|
if not ms:
|
||||||
@@ -59,8 +67,8 @@ class CloseBody(BaseModel):
|
|||||||
|
|
||||||
@router.get("/preflight", status_code=200)
|
@router.get("/preflight", status_code=200)
|
||||||
def preflight_milestone_actions(
|
def preflight_milestone_actions(
|
||||||
project_id: int,
|
project_code: str,
|
||||||
milestone_id: int,
|
milestone_code: str,
|
||||||
db: Session = Depends(get_db),
|
db: Session = Depends(get_db),
|
||||||
current_user: models.User = Depends(get_current_user_or_apikey),
|
current_user: models.User = Depends(get_current_user_or_apikey),
|
||||||
):
|
):
|
||||||
@@ -69,8 +77,9 @@ def preflight_milestone_actions(
|
|||||||
The frontend uses this to decide whether to *disable* buttons and what
|
The frontend uses this to decide whether to *disable* buttons and what
|
||||||
hint text to show. This endpoint never mutates data.
|
hint text to show. This endpoint never mutates data.
|
||||||
"""
|
"""
|
||||||
check_project_role(db, current_user.id, project_id, min_role="viewer")
|
project = _resolve_project_or_404(db, project_code)
|
||||||
ms = _get_milestone_or_404(db, project_id, milestone_id)
|
check_project_role(db, current_user.id, project.id, min_role="viewer")
|
||||||
|
ms = _get_milestone_or_404(db, project_code, milestone_code)
|
||||||
ms_status = _ms_status_value(ms)
|
ms_status = _ms_status_value(ms)
|
||||||
|
|
||||||
result: dict = {"status": ms_status, "freeze": None, "start": None}
|
result: dict = {"status": ms_status, "freeze": None, "start": None}
|
||||||
@@ -80,7 +89,7 @@ def preflight_milestone_actions(
|
|||||||
release_tasks = (
|
release_tasks = (
|
||||||
db.query(Task)
|
db.query(Task)
|
||||||
.filter(
|
.filter(
|
||||||
Task.milestone_id == milestone_id,
|
Task.milestone_id == ms.id,
|
||||||
Task.task_type == "maintenance",
|
Task.task_type == "maintenance",
|
||||||
Task.task_subtype == "release",
|
Task.task_subtype == "release",
|
||||||
)
|
)
|
||||||
@@ -118,8 +127,8 @@ def preflight_milestone_actions(
|
|||||||
|
|
||||||
@router.post("/freeze", status_code=200)
|
@router.post("/freeze", status_code=200)
|
||||||
def freeze_milestone(
|
def freeze_milestone(
|
||||||
project_id: int,
|
project_code: str,
|
||||||
milestone_id: int,
|
milestone_code: str,
|
||||||
db: Session = Depends(get_db),
|
db: Session = Depends(get_db),
|
||||||
current_user: models.User = Depends(get_current_user_or_apikey),
|
current_user: models.User = Depends(get_current_user_or_apikey),
|
||||||
):
|
):
|
||||||
@@ -130,10 +139,11 @@ def freeze_milestone(
|
|||||||
- Milestone must have **exactly one** maintenance task with subtype ``release``.
|
- Milestone must have **exactly one** maintenance task with subtype ``release``.
|
||||||
- Caller must have ``freeze milestone`` permission.
|
- Caller must have ``freeze milestone`` permission.
|
||||||
"""
|
"""
|
||||||
check_project_role(db, current_user.id, project_id, min_role="mgr")
|
project = _resolve_project_or_404(db, project_code)
|
||||||
check_permission(db, current_user.id, project_id, "milestone.freeze")
|
check_project_role(db, current_user.id, project.id, min_role="mgr")
|
||||||
|
check_permission(db, current_user.id, project.id, "milestone.freeze")
|
||||||
|
|
||||||
ms = _get_milestone_or_404(db, project_id, milestone_id)
|
ms = _get_milestone_or_404(db, project_code, milestone_code)
|
||||||
|
|
||||||
if _ms_status_value(ms) != "open":
|
if _ms_status_value(ms) != "open":
|
||||||
raise HTTPException(
|
raise HTTPException(
|
||||||
@@ -145,7 +155,7 @@ def freeze_milestone(
|
|||||||
release_tasks = (
|
release_tasks = (
|
||||||
db.query(Task)
|
db.query(Task)
|
||||||
.filter(
|
.filter(
|
||||||
Task.milestone_id == milestone_id,
|
Task.milestone_id == ms.id,
|
||||||
Task.task_type == "maintenance",
|
Task.task_type == "maintenance",
|
||||||
Task.task_subtype == "release",
|
Task.task_subtype == "release",
|
||||||
)
|
)
|
||||||
@@ -184,8 +194,8 @@ def freeze_milestone(
|
|||||||
|
|
||||||
@router.post("/start", status_code=200)
|
@router.post("/start", status_code=200)
|
||||||
def start_milestone(
|
def start_milestone(
|
||||||
project_id: int,
|
project_code: str,
|
||||||
milestone_id: int,
|
milestone_code: str,
|
||||||
db: Session = Depends(get_db),
|
db: Session = Depends(get_db),
|
||||||
current_user: models.User = Depends(get_current_user_or_apikey),
|
current_user: models.User = Depends(get_current_user_or_apikey),
|
||||||
):
|
):
|
||||||
@@ -196,10 +206,11 @@ def start_milestone(
|
|||||||
- All milestone dependencies must be completed.
|
- All milestone dependencies must be completed.
|
||||||
- Caller must have ``start milestone`` permission.
|
- Caller must have ``start milestone`` permission.
|
||||||
"""
|
"""
|
||||||
check_project_role(db, current_user.id, project_id, min_role="mgr")
|
project = _resolve_project_or_404(db, project_code)
|
||||||
check_permission(db, current_user.id, project_id, "milestone.start")
|
check_project_role(db, current_user.id, project.id, min_role="mgr")
|
||||||
|
check_permission(db, current_user.id, project.id, "milestone.start")
|
||||||
|
|
||||||
ms = _get_milestone_or_404(db, project_id, milestone_id)
|
ms = _get_milestone_or_404(db, project_code, milestone_code)
|
||||||
|
|
||||||
if _ms_status_value(ms) != "freeze":
|
if _ms_status_value(ms) != "freeze":
|
||||||
raise HTTPException(
|
raise HTTPException(
|
||||||
@@ -240,8 +251,8 @@ def start_milestone(
|
|||||||
|
|
||||||
@router.post("/close", status_code=200)
|
@router.post("/close", status_code=200)
|
||||||
def close_milestone(
|
def close_milestone(
|
||||||
project_id: int,
|
project_code: str,
|
||||||
milestone_id: int,
|
milestone_code: str,
|
||||||
body: CloseBody = CloseBody(),
|
body: CloseBody = CloseBody(),
|
||||||
db: Session = Depends(get_db),
|
db: Session = Depends(get_db),
|
||||||
current_user: models.User = Depends(get_current_user_or_apikey),
|
current_user: models.User = Depends(get_current_user_or_apikey),
|
||||||
@@ -252,10 +263,11 @@ def close_milestone(
|
|||||||
- Milestone must be in ``open``, ``freeze``, or ``undergoing`` status.
|
- Milestone must be in ``open``, ``freeze``, or ``undergoing`` status.
|
||||||
- Caller must have ``close milestone`` permission.
|
- Caller must have ``close milestone`` permission.
|
||||||
"""
|
"""
|
||||||
check_project_role(db, current_user.id, project_id, min_role="mgr")
|
project = _resolve_project_or_404(db, project_code)
|
||||||
check_permission(db, current_user.id, project_id, "milestone.close")
|
check_project_role(db, current_user.id, project.id, min_role="mgr")
|
||||||
|
check_permission(db, current_user.id, project.id, "milestone.close")
|
||||||
|
|
||||||
ms = _get_milestone_or_404(db, project_id, milestone_id)
|
ms = _get_milestone_or_404(db, project_code, milestone_code)
|
||||||
current = _ms_status_value(ms)
|
current = _ms_status_value(ms)
|
||||||
|
|
||||||
allowed_from = {"open", "freeze", "undergoing"}
|
allowed_from = {"open", "freeze", "undergoing"}
|
||||||
|
|||||||
@@ -48,10 +48,10 @@ def _find_milestone(db, identifier, project_id: int = None) -> Milestone | None:
|
|||||||
return q.first()
|
return q.first()
|
||||||
|
|
||||||
|
|
||||||
def _serialize_milestone(milestone):
|
def _serialize_milestone(db, milestone):
|
||||||
"""Serialize milestone with JSON fields and code."""
|
"""Serialize milestone with JSON fields and code-first identifiers."""
|
||||||
|
project = db.query(models.Project).filter(models.Project.id == milestone.project_id).first()
|
||||||
return {
|
return {
|
||||||
"id": milestone.id,
|
|
||||||
"title": milestone.title,
|
"title": milestone.title,
|
||||||
"description": milestone.description,
|
"description": milestone.description,
|
||||||
"status": milestone.status.value if hasattr(milestone.status, 'value') else milestone.status,
|
"status": milestone.status.value if hasattr(milestone.status, 'value') else milestone.status,
|
||||||
@@ -59,9 +59,9 @@ def _serialize_milestone(milestone):
|
|||||||
"planned_release_date": milestone.planned_release_date,
|
"planned_release_date": milestone.planned_release_date,
|
||||||
"depend_on_milestones": json.loads(milestone.depend_on_milestones) if milestone.depend_on_milestones else [],
|
"depend_on_milestones": json.loads(milestone.depend_on_milestones) if milestone.depend_on_milestones else [],
|
||||||
"depend_on_tasks": json.loads(milestone.depend_on_tasks) if milestone.depend_on_tasks else [],
|
"depend_on_tasks": json.loads(milestone.depend_on_tasks) if milestone.depend_on_tasks else [],
|
||||||
"project_id": milestone.project_id,
|
|
||||||
"milestone_code": milestone.milestone_code,
|
"milestone_code": milestone.milestone_code,
|
||||||
"code": milestone.milestone_code,
|
"code": milestone.milestone_code,
|
||||||
|
"project_code": project.project_code if project else None,
|
||||||
"created_by_id": milestone.created_by_id,
|
"created_by_id": milestone.created_by_id,
|
||||||
"started_at": milestone.started_at,
|
"started_at": milestone.started_at,
|
||||||
"created_at": milestone.created_at,
|
"created_at": milestone.created_at,
|
||||||
@@ -76,7 +76,7 @@ def list_milestones(project_id: str, db: Session = Depends(get_db), current_user
|
|||||||
raise HTTPException(status_code=404, detail="Project not found")
|
raise HTTPException(status_code=404, detail="Project not found")
|
||||||
check_project_role(db, current_user.id, project.id, min_role="viewer")
|
check_project_role(db, current_user.id, project.id, min_role="viewer")
|
||||||
milestones = db.query(Milestone).filter(Milestone.project_id == project.id).all()
|
milestones = db.query(Milestone).filter(Milestone.project_id == project.id).all()
|
||||||
return [_serialize_milestone(m) for m in milestones]
|
return [_serialize_milestone(db, m) for m in milestones]
|
||||||
|
|
||||||
|
|
||||||
@router.post("", response_model=schemas.MilestoneResponse, status_code=status.HTTP_201_CREATED)
|
@router.post("", response_model=schemas.MilestoneResponse, status_code=status.HTTP_201_CREATED)
|
||||||
@@ -101,7 +101,7 @@ def create_milestone(project_id: str, milestone: schemas.MilestoneCreate, db: Se
|
|||||||
db.add(db_milestone)
|
db.add(db_milestone)
|
||||||
db.commit()
|
db.commit()
|
||||||
db.refresh(db_milestone)
|
db.refresh(db_milestone)
|
||||||
return _serialize_milestone(db_milestone)
|
return _serialize_milestone(db, db_milestone)
|
||||||
|
|
||||||
|
|
||||||
@router.get("/{milestone_id}", response_model=schemas.MilestoneResponse)
|
@router.get("/{milestone_id}", response_model=schemas.MilestoneResponse)
|
||||||
@@ -113,7 +113,7 @@ def get_milestone(project_id: str, milestone_id: str, db: Session = Depends(get_
|
|||||||
milestone = _find_milestone(db, milestone_id, project.id)
|
milestone = _find_milestone(db, milestone_id, project.id)
|
||||||
if not milestone:
|
if not milestone:
|
||||||
raise HTTPException(status_code=404, detail="Milestone not found")
|
raise HTTPException(status_code=404, detail="Milestone not found")
|
||||||
return _serialize_milestone(milestone)
|
return _serialize_milestone(db, milestone)
|
||||||
|
|
||||||
|
|
||||||
@router.patch("/{milestone_id}", response_model=schemas.MilestoneResponse)
|
@router.patch("/{milestone_id}", response_model=schemas.MilestoneResponse)
|
||||||
@@ -163,7 +163,7 @@ def update_milestone(project_id: str, milestone_id: str, milestone: schemas.Mile
|
|||||||
setattr(db_milestone, key, value)
|
setattr(db_milestone, key, value)
|
||||||
db.commit()
|
db.commit()
|
||||||
db.refresh(db_milestone)
|
db.refresh(db_milestone)
|
||||||
return _serialize_milestone(db_milestone)
|
return _serialize_milestone(db, db_milestone)
|
||||||
|
|
||||||
|
|
||||||
@router.delete("/{milestone_id}", status_code=status.HTTP_204_NO_CONTENT)
|
@router.delete("/{milestone_id}", status_code=status.HTTP_204_NO_CONTENT)
|
||||||
|
|||||||
@@ -12,7 +12,7 @@ from sqlalchemy import func as sqlfunc
|
|||||||
from pydantic import BaseModel
|
from pydantic import BaseModel
|
||||||
|
|
||||||
from app.core.config import get_db
|
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.api.rbac import check_project_role, ensure_can_edit_milestone
|
||||||
from app.models import models
|
from app.models import models
|
||||||
from app.models.apikey import APIKey
|
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"])
|
@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()
|
user = db.query(models.User).filter(models.User.id == data.user_id).first()
|
||||||
if not user:
|
if not user:
|
||||||
raise HTTPException(status_code=404, detail="User not found")
|
raise HTTPException(status_code=404, detail="User not found")
|
||||||
@@ -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"])
|
@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)
|
query = db.query(APIKey)
|
||||||
if user_id:
|
if user_id:
|
||||||
query = query.filter(APIKey.user_id == 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"])
|
@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()
|
key_obj = db.query(APIKey).filter(APIKey.id == key_id).first()
|
||||||
if not key_obj:
|
if not key_obj:
|
||||||
raise HTTPException(status_code=404, detail="API key not found")
|
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"])
|
@router.get("/activity", response_model=List[ActivityLogResponse], tags=["Activity"])
|
||||||
def list_activity(entity_type: str = None, entity_id: int = None, user_id: int = None,
|
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)
|
query = db.query(ActivityLog)
|
||||||
if entity_type:
|
if entity_type:
|
||||||
query = query.filter(ActivityLog.entity_type == 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"])
|
@router.get("/milestones", response_model=List[schemas.MilestoneResponse], tags=["Milestones"])
|
||||||
def list_milestones(project_id: str = None, status_filter: str = None, db: Session = Depends(get_db)):
|
def list_milestones(project_id: str = None, project_code: str = None, status_filter: str = None, db: Session = Depends(get_db)):
|
||||||
query = db.query(MilestoneModel)
|
query = db.query(MilestoneModel)
|
||||||
if project_id:
|
effective_project = project_code or project_id
|
||||||
|
if effective_project:
|
||||||
# Resolve project_id by numeric id or project_code
|
# Resolve project_id by numeric id or project_code
|
||||||
resolved_project = None
|
resolved_project = None
|
||||||
try:
|
try:
|
||||||
pid = int(project_id)
|
pid = int(effective_project)
|
||||||
resolved_project = db.query(models.Project).filter(models.Project.id == pid).first()
|
resolved_project = db.query(models.Project).filter(models.Project.id == pid).first()
|
||||||
except (ValueError, TypeError):
|
except (ValueError, TypeError):
|
||||||
pass
|
pass
|
||||||
if not resolved_project:
|
if not resolved_project:
|
||||||
resolved_project = db.query(models.Project).filter(models.Project.project_code == project_id).first()
|
resolved_project = db.query(models.Project).filter(models.Project.project_code == effective_project).first()
|
||||||
if not resolved_project:
|
if not resolved_project:
|
||||||
raise HTTPException(status_code=404, detail="Project not found")
|
raise HTTPException(status_code=404, detail="Project not found")
|
||||||
query = query.filter(MilestoneModel.project_id == resolved_project.id)
|
query = query.filter(MilestoneModel.project_id == resolved_project.id)
|
||||||
@@ -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"])
|
@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)
|
ms = _resolve_milestone(db, milestone_id)
|
||||||
|
ensure_can_edit_milestone(db, current_user.id, ms)
|
||||||
db.delete(ms)
|
db.delete(ms)
|
||||||
db.commit()
|
db.commit()
|
||||||
return None
|
return None
|
||||||
@@ -321,16 +333,18 @@ class WorkLogResponse(BaseModel):
|
|||||||
|
|
||||||
|
|
||||||
@router.post("/worklogs", response_model=WorkLogResponse, status_code=status.HTTP_201_CREATED, tags=["Time Tracking"])
|
@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()
|
task = db.query(Task).filter(Task.id == wl.task_id).first()
|
||||||
if not task:
|
if not task:
|
||||||
raise HTTPException(status_code=404, detail="Task not found")
|
raise HTTPException(status_code=404, detail="Task not found")
|
||||||
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:
|
if wl.hours <= 0:
|
||||||
raise HTTPException(status_code=400, detail="Hours must be positive")
|
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.add(db_wl)
|
||||||
db.commit()
|
db.commit()
|
||||||
db.refresh(db_wl)
|
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"])
|
@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()
|
wl = db.query(WorkLog).filter(WorkLog.id == worklog_id).first()
|
||||||
if not wl:
|
if not wl:
|
||||||
raise HTTPException(status_code=404, detail="Work log not found")
|
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.delete(wl)
|
||||||
db.commit()
|
db.commit()
|
||||||
return None
|
return None
|
||||||
@@ -381,7 +398,8 @@ def delete_worklog(worklog_id: int, db: Session = Depends(get_db)):
|
|||||||
# ============ Export ============
|
# ============ Export ============
|
||||||
|
|
||||||
@router.get("/export/tasks", tags=["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)
|
query = db.query(Task)
|
||||||
if project_id:
|
if project_id:
|
||||||
query = query.filter(Task.project_id == 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 ============
|
# ============ Milestone-scoped Tasks ============
|
||||||
|
|
||||||
@router.get("/tasks/{project_code}/{milestone_id}", tags=["Tasks"])
|
@router.get("/tasks/{project_code}/{milestone_id}", tags=["Tasks"])
|
||||||
def list_milestone_tasks(project_code: str, milestone_id: int, db: Session = Depends(get_db)):
|
def list_milestone_tasks(project_code: str, milestone_id: str, db: Session = Depends(get_db)):
|
||||||
project = db.query(models.Project).filter(models.Project.project_code == project_code).first()
|
project = db.query(models.Project).filter(models.Project.project_code == project_code).first()
|
||||||
if not project:
|
if not project:
|
||||||
raise HTTPException(status_code=404, detail="Project not found")
|
raise HTTPException(status_code=404, detail="Project not found")
|
||||||
|
|
||||||
|
milestone = db.query(MilestoneModel).filter(
|
||||||
|
MilestoneModel.milestone_code == milestone_id,
|
||||||
|
MilestoneModel.project_id == project.id,
|
||||||
|
).first()
|
||||||
|
if not milestone:
|
||||||
|
raise HTTPException(status_code=404, detail="Milestone not found")
|
||||||
|
|
||||||
tasks = db.query(Task).filter(
|
tasks = db.query(Task).filter(
|
||||||
Task.project_id == project.id,
|
Task.project_id == project.id,
|
||||||
Task.milestone_id == milestone_id
|
Task.milestone_id == milestone.id
|
||||||
).all()
|
).all()
|
||||||
|
|
||||||
return [{
|
return [{
|
||||||
@@ -459,12 +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"])
|
@router.post("/tasks/{project_code}/{milestone_id}", status_code=status.HTTP_201_CREATED, tags=["Tasks"])
|
||||||
def create_milestone_task(project_code: str, milestone_id: int, task_data: dict, db: Session = Depends(get_db), current_user: models.User = Depends(get_current_user_or_apikey)):
|
def create_milestone_task(project_code: str, milestone_id: str, task_data: dict, db: Session = Depends(get_db), current_user: models.User = Depends(get_current_user_or_apikey)):
|
||||||
project = db.query(models.Project).filter(models.Project.project_code == project_code).first()
|
project = db.query(models.Project).filter(models.Project.project_code == project_code).first()
|
||||||
if not project:
|
if not project:
|
||||||
raise HTTPException(status_code=404, detail="Project not found")
|
raise HTTPException(status_code=404, detail="Project not found")
|
||||||
|
|
||||||
ms = db.query(MilestoneModel).filter(MilestoneModel.id == milestone_id).first()
|
ms = db.query(MilestoneModel).filter(MilestoneModel.milestone_code == milestone_id, MilestoneModel.project_id == project.id).first()
|
||||||
if not ms:
|
if not ms:
|
||||||
raise HTTPException(status_code=404, detail="Milestone not found")
|
raise HTTPException(status_code=404, detail="Milestone not found")
|
||||||
|
|
||||||
@@ -491,7 +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_type=task_data.get("task_type", "issue"), # P7.1: default changed from 'task' to 'issue'
|
||||||
task_subtype=task_data.get("task_subtype"),
|
task_subtype=task_data.get("task_subtype"),
|
||||||
project_id=project.id,
|
project_id=project.id,
|
||||||
milestone_id=milestone_id,
|
milestone_id=ms.id,
|
||||||
reporter_id=current_user.id,
|
reporter_id=current_user.id,
|
||||||
task_code=task_code,
|
task_code=task_code,
|
||||||
estimated_effort=task_data.get("estimated_effort"),
|
estimated_effort=task_data.get("estimated_effort"),
|
||||||
@@ -503,10 +528,10 @@ def create_milestone_task(project_code: str, milestone_id: int, task_data: dict,
|
|||||||
db.refresh(task)
|
db.refresh(task)
|
||||||
|
|
||||||
return {
|
return {
|
||||||
"id": task.id,
|
|
||||||
"title": task.title,
|
"title": task.title,
|
||||||
"description": task.description,
|
"description": task.description,
|
||||||
"task_code": task.task_code,
|
"task_code": task.task_code,
|
||||||
|
"code": task.task_code,
|
||||||
"status": task.status.value,
|
"status": task.status.value,
|
||||||
"priority": task.priority.value,
|
"priority": task.priority.value,
|
||||||
"created_at": task.created_at,
|
"created_at": task.created_at,
|
||||||
@@ -516,15 +541,8 @@ def create_milestone_task(project_code: str, milestone_id: int, task_data: dict,
|
|||||||
# ============ Supports ============
|
# ============ Supports ============
|
||||||
|
|
||||||
|
|
||||||
def _find_support_by_id_or_code(db: Session, identifier: str) -> Support | None:
|
def _find_support_by_code(db: Session, support_code: str) -> Support | None:
|
||||||
try:
|
return db.query(Support).filter(Support.support_code == str(support_code)).first()
|
||||||
support_id = int(identifier)
|
|
||||||
support = db.query(Support).filter(Support.id == support_id).first()
|
|
||||||
if support:
|
|
||||||
return support
|
|
||||||
except (TypeError, ValueError):
|
|
||||||
pass
|
|
||||||
return db.query(Support).filter(Support.support_code == str(identifier)).first()
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
@@ -536,16 +554,13 @@ def _serialize_support(db: Session, support: Support) -> dict:
|
|||||||
assignee = db.query(models.User).filter(models.User.id == support.assignee_id).first()
|
assignee = db.query(models.User).filter(models.User.id == support.assignee_id).first()
|
||||||
|
|
||||||
return {
|
return {
|
||||||
"id": support.id,
|
|
||||||
"code": support.support_code,
|
"code": support.support_code,
|
||||||
"support_code": support.support_code,
|
"support_code": support.support_code,
|
||||||
"title": support.title,
|
"title": support.title,
|
||||||
"description": support.description,
|
"description": support.description,
|
||||||
"status": support.status.value if hasattr(support.status, "value") else support.status,
|
"status": support.status.value if hasattr(support.status, "value") else support.status,
|
||||||
"priority": support.priority.value if hasattr(support.priority, "value") else support.priority,
|
"priority": support.priority.value if hasattr(support.priority, "value") else support.priority,
|
||||||
"project_id": support.project_id,
|
|
||||||
"project_code": project.project_code if project else None,
|
"project_code": project.project_code if project else None,
|
||||||
"milestone_id": support.milestone_id,
|
|
||||||
"milestone_code": milestone.milestone_code if milestone else None,
|
"milestone_code": milestone.milestone_code if milestone else None,
|
||||||
"reporter_id": support.reporter_id,
|
"reporter_id": support.reporter_id,
|
||||||
"assignee_id": support.assignee_id,
|
"assignee_id": support.assignee_id,
|
||||||
@@ -585,26 +600,30 @@ def list_all_supports(
|
|||||||
|
|
||||||
|
|
||||||
@router.get("/supports/{project_code}/{milestone_id}", tags=["Supports"])
|
@router.get("/supports/{project_code}/{milestone_id}", tags=["Supports"])
|
||||||
def list_supports(project_code: str, milestone_id: int, db: Session = Depends(get_db)):
|
def list_supports(project_code: str, milestone_id: str, db: Session = Depends(get_db)):
|
||||||
project = db.query(models.Project).filter(models.Project.project_code == project_code).first()
|
project = db.query(models.Project).filter(models.Project.project_code == project_code).first()
|
||||||
if not project:
|
if not project:
|
||||||
raise HTTPException(status_code=404, detail="Project not found")
|
raise HTTPException(status_code=404, detail="Project not found")
|
||||||
|
|
||||||
|
milestone = db.query(MilestoneModel).filter(MilestoneModel.milestone_code == milestone_id, MilestoneModel.project_id == project.id).first()
|
||||||
|
if not milestone:
|
||||||
|
raise HTTPException(status_code=404, detail="Milestone not found")
|
||||||
|
|
||||||
supports = db.query(Support).filter(
|
supports = db.query(Support).filter(
|
||||||
Support.project_id == project.id,
|
Support.project_id == project.id,
|
||||||
Support.milestone_id == milestone_id
|
Support.milestone_id == milestone.id
|
||||||
).all()
|
).all()
|
||||||
|
|
||||||
return [_serialize_support(db, s) for s in supports]
|
return [_serialize_support(db, s) for s in supports]
|
||||||
|
|
||||||
|
|
||||||
@router.post("/supports/{project_code}/{milestone_id}", status_code=status.HTTP_201_CREATED, tags=["Supports"])
|
@router.post("/supports/{project_code}/{milestone_id}", status_code=status.HTTP_201_CREATED, tags=["Supports"])
|
||||||
def create_support(project_code: str, milestone_id: int, support_data: dict, db: Session = Depends(get_db), current_user: models.User = Depends(get_current_user_or_apikey)):
|
def create_support(project_code: str, milestone_id: str, support_data: dict, db: Session = Depends(get_db), current_user: models.User = Depends(get_current_user_or_apikey)):
|
||||||
project = db.query(models.Project).filter(models.Project.project_code == project_code).first()
|
project = db.query(models.Project).filter(models.Project.project_code == project_code).first()
|
||||||
if not project:
|
if not project:
|
||||||
raise HTTPException(status_code=404, detail="Project not found")
|
raise HTTPException(status_code=404, detail="Project not found")
|
||||||
|
|
||||||
ms = db.query(MilestoneModel).filter(MilestoneModel.id == milestone_id).first()
|
ms = db.query(MilestoneModel).filter(MilestoneModel.milestone_code == milestone_id, MilestoneModel.project_id == project.id).first()
|
||||||
if not ms:
|
if not ms:
|
||||||
raise HTTPException(status_code=404, detail="Milestone not found")
|
raise HTTPException(status_code=404, detail="Milestone not found")
|
||||||
|
|
||||||
@@ -612,7 +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")
|
raise HTTPException(status_code=400, detail="Cannot add items to a milestone that is undergoing")
|
||||||
|
|
||||||
milestone_code = ms.milestone_code or f"m{ms.id}"
|
milestone_code = ms.milestone_code or f"m{ms.id}"
|
||||||
max_support = db.query(Support).filter(Support.milestone_id == milestone_id).order_by(Support.id.desc()).first()
|
max_support = db.query(Support).filter(Support.milestone_id == ms.id).order_by(Support.id.desc()).first()
|
||||||
next_num = (max_support.id + 1) if max_support else 1
|
next_num = (max_support.id + 1) if max_support else 1
|
||||||
support_code = f"{milestone_code}:S{next_num:05x}"
|
support_code = f"{milestone_code}:S{next_num:05x}"
|
||||||
|
|
||||||
@@ -622,7 +641,7 @@ def create_support(project_code: str, milestone_id: int, support_data: dict, db:
|
|||||||
status=SupportStatus.OPEN,
|
status=SupportStatus.OPEN,
|
||||||
priority=SupportPriority.MEDIUM,
|
priority=SupportPriority.MEDIUM,
|
||||||
project_id=project.id,
|
project_id=project.id,
|
||||||
milestone_id=milestone_id,
|
milestone_id=ms.id,
|
||||||
reporter_id=current_user.id,
|
reporter_id=current_user.id,
|
||||||
support_code=support_code,
|
support_code=support_code,
|
||||||
)
|
)
|
||||||
@@ -632,18 +651,18 @@ def create_support(project_code: str, milestone_id: int, support_data: dict, db:
|
|||||||
return _serialize_support(db, support)
|
return _serialize_support(db, support)
|
||||||
|
|
||||||
|
|
||||||
@router.get("/supports/{support_id}", tags=["Supports"])
|
@router.get("/supports/{support_code}", tags=["Supports"])
|
||||||
def get_support(support_id: str, db: Session = Depends(get_db), current_user: models.User = Depends(get_current_user_or_apikey)):
|
def get_support(support_code: str, db: Session = Depends(get_db), current_user: models.User = Depends(get_current_user_or_apikey)):
|
||||||
support = _find_support_by_id_or_code(db, support_id)
|
support = _find_support_by_code(db, support_code)
|
||||||
if not support:
|
if not support:
|
||||||
raise HTTPException(status_code=404, detail="Support not found")
|
raise HTTPException(status_code=404, detail="Support not found")
|
||||||
check_project_role(db, current_user.id, support.project_id, min_role="viewer")
|
check_project_role(db, current_user.id, support.project_id, min_role="viewer")
|
||||||
return _serialize_support(db, support)
|
return _serialize_support(db, support)
|
||||||
|
|
||||||
|
|
||||||
@router.patch("/supports/{support_id}", tags=["Supports"])
|
@router.patch("/supports/{support_code}", tags=["Supports"])
|
||||||
def update_support(support_id: str, support_data: dict, db: Session = Depends(get_db), current_user: models.User = Depends(get_current_user_or_apikey)):
|
def update_support(support_code: str, support_data: dict, db: Session = Depends(get_db), current_user: models.User = Depends(get_current_user_or_apikey)):
|
||||||
support = _find_support_by_id_or_code(db, support_id)
|
support = _find_support_by_code(db, support_code)
|
||||||
if not support:
|
if not support:
|
||||||
raise HTTPException(status_code=404, detail="Support not found")
|
raise HTTPException(status_code=404, detail="Support not found")
|
||||||
check_project_role(db, current_user.id, support.project_id, min_role="dev")
|
check_project_role(db, current_user.id, support.project_id, min_role="dev")
|
||||||
@@ -668,9 +687,9 @@ def update_support(support_id: str, support_data: dict, db: Session = Depends(ge
|
|||||||
return _serialize_support(db, support)
|
return _serialize_support(db, support)
|
||||||
|
|
||||||
|
|
||||||
@router.delete("/supports/{support_id}", status_code=status.HTTP_204_NO_CONTENT, tags=["Supports"])
|
@router.delete("/supports/{support_code}", status_code=status.HTTP_204_NO_CONTENT, tags=["Supports"])
|
||||||
def delete_support(support_id: str, db: Session = Depends(get_db), current_user: models.User = Depends(get_current_user_or_apikey)):
|
def delete_support(support_code: str, db: Session = Depends(get_db), current_user: models.User = Depends(get_current_user_or_apikey)):
|
||||||
support = _find_support_by_id_or_code(db, support_id)
|
support = _find_support_by_code(db, support_code)
|
||||||
if not support:
|
if not support:
|
||||||
raise HTTPException(status_code=404, detail="Support not found")
|
raise HTTPException(status_code=404, detail="Support not found")
|
||||||
check_project_role(db, current_user.id, support.project_id, min_role="dev")
|
check_project_role(db, current_user.id, support.project_id, min_role="dev")
|
||||||
@@ -679,9 +698,9 @@ def delete_support(support_id: str, db: Session = Depends(get_db), current_user:
|
|||||||
return None
|
return None
|
||||||
|
|
||||||
|
|
||||||
@router.post("/supports/{support_id}/take", tags=["Supports"])
|
@router.post("/supports/{support_code}/take", tags=["Supports"])
|
||||||
def take_support(support_id: str, db: Session = Depends(get_db), current_user: models.User = Depends(get_current_user_or_apikey)):
|
def take_support(support_code: str, db: Session = Depends(get_db), current_user: models.User = Depends(get_current_user_or_apikey)):
|
||||||
support = _find_support_by_id_or_code(db, support_id)
|
support = _find_support_by_code(db, support_code)
|
||||||
if not support:
|
if not support:
|
||||||
raise HTTPException(status_code=404, detail="Support not found")
|
raise HTTPException(status_code=404, detail="Support not found")
|
||||||
check_project_role(db, current_user.id, support.project_id, min_role="dev")
|
check_project_role(db, current_user.id, support.project_id, min_role="dev")
|
||||||
@@ -697,9 +716,9 @@ def take_support(support_id: str, db: Session = Depends(get_db), current_user: m
|
|||||||
return _serialize_support(db, support)
|
return _serialize_support(db, support)
|
||||||
|
|
||||||
|
|
||||||
@router.post("/supports/{support_id}/transition", tags=["Supports"])
|
@router.post("/supports/{support_code}/transition", tags=["Supports"])
|
||||||
def transition_support(support_id: str, support_data: dict, db: Session = Depends(get_db), current_user: models.User = Depends(get_current_user_or_apikey)):
|
def transition_support(support_code: str, support_data: dict, db: Session = Depends(get_db), current_user: models.User = Depends(get_current_user_or_apikey)):
|
||||||
support = _find_support_by_id_or_code(db, support_id)
|
support = _find_support_by_code(db, support_code)
|
||||||
if not support:
|
if not support:
|
||||||
raise HTTPException(status_code=404, detail="Support not found")
|
raise HTTPException(status_code=404, detail="Support not found")
|
||||||
check_project_role(db, current_user.id, support.project_id, min_role="dev")
|
check_project_role(db, current_user.id, support.project_id, min_role="dev")
|
||||||
@@ -717,20 +736,25 @@ def transition_support(support_id: str, support_data: dict, db: Session = Depend
|
|||||||
# ============ Meetings ============
|
# ============ Meetings ============
|
||||||
|
|
||||||
@router.get("/meetings/{project_code}/{milestone_id}", tags=["Meetings"])
|
@router.get("/meetings/{project_code}/{milestone_id}", tags=["Meetings"])
|
||||||
def list_meetings(project_code: str, milestone_id: int, db: Session = Depends(get_db)):
|
def list_meetings(project_code: str, milestone_id: str, db: Session = Depends(get_db)):
|
||||||
project = db.query(models.Project).filter(models.Project.project_code == project_code).first()
|
project = db.query(models.Project).filter(models.Project.project_code == project_code).first()
|
||||||
if not project:
|
if not project:
|
||||||
raise HTTPException(status_code=404, detail="Project not found")
|
raise HTTPException(status_code=404, detail="Project not found")
|
||||||
|
|
||||||
|
milestone = db.query(MilestoneModel).filter(MilestoneModel.milestone_code == milestone_id, MilestoneModel.project_id == project.id).first()
|
||||||
|
if not milestone:
|
||||||
|
raise HTTPException(status_code=404, detail="Milestone not found")
|
||||||
|
|
||||||
meetings = db.query(Meeting).filter(
|
meetings = db.query(Meeting).filter(
|
||||||
Meeting.project_id == project.id,
|
Meeting.project_id == project.id,
|
||||||
Meeting.milestone_id == milestone_id
|
Meeting.milestone_id == milestone.id
|
||||||
).all()
|
).all()
|
||||||
|
|
||||||
return [{
|
return [{
|
||||||
"id": m.id,
|
|
||||||
"title": m.title,
|
"title": m.title,
|
||||||
"description": m.description,
|
"description": m.description,
|
||||||
|
"meeting_code": m.meeting_code,
|
||||||
|
"code": m.meeting_code,
|
||||||
"status": m.status.value,
|
"status": m.status.value,
|
||||||
"priority": m.priority.value,
|
"priority": m.priority.value,
|
||||||
"scheduled_at": m.scheduled_at,
|
"scheduled_at": m.scheduled_at,
|
||||||
@@ -740,12 +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"])
|
@router.post("/meetings/{project_code}/{milestone_id}", status_code=status.HTTP_201_CREATED, tags=["Meetings"])
|
||||||
def create_meeting(project_code: str, milestone_id: int, meeting_data: dict, db: Session = Depends(get_db), current_user: models.User = Depends(get_current_user_or_apikey)):
|
def create_meeting(project_code: str, milestone_id: str, meeting_data: dict, db: Session = Depends(get_db), current_user: models.User = Depends(get_current_user_or_apikey)):
|
||||||
project = db.query(models.Project).filter(models.Project.project_code == project_code).first()
|
project = db.query(models.Project).filter(models.Project.project_code == project_code).first()
|
||||||
if not project:
|
if not project:
|
||||||
raise HTTPException(status_code=404, detail="Project not found")
|
raise HTTPException(status_code=404, detail="Project not found")
|
||||||
|
|
||||||
ms = db.query(MilestoneModel).filter(MilestoneModel.id == milestone_id).first()
|
ms = db.query(MilestoneModel).filter(MilestoneModel.milestone_code == milestone_id, MilestoneModel.project_id == project.id).first()
|
||||||
if not ms:
|
if not ms:
|
||||||
raise HTTPException(status_code=404, detail="Milestone not found")
|
raise HTTPException(status_code=404, detail="Milestone not found")
|
||||||
|
|
||||||
@@ -753,7 +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")
|
raise HTTPException(status_code=400, detail="Cannot add items to a milestone that is undergoing")
|
||||||
|
|
||||||
milestone_code = ms.milestone_code or f"m{ms.id}"
|
milestone_code = ms.milestone_code or f"m{ms.id}"
|
||||||
max_meeting = db.query(Meeting).filter(Meeting.milestone_id == milestone_id).order_by(Meeting.id.desc()).first()
|
max_meeting = db.query(Meeting).filter(Meeting.milestone_id == ms.id).order_by(Meeting.id.desc()).first()
|
||||||
next_num = (max_meeting.id + 1) if max_meeting else 1
|
next_num = (max_meeting.id + 1) if max_meeting else 1
|
||||||
meeting_code = f"{milestone_code}:M{next_num:05x}"
|
meeting_code = f"{milestone_code}:M{next_num:05x}"
|
||||||
|
|
||||||
@@ -770,7 +794,7 @@ def create_meeting(project_code: str, milestone_id: int, meeting_data: dict, db:
|
|||||||
status=MeetingStatus.SCHEDULED,
|
status=MeetingStatus.SCHEDULED,
|
||||||
priority=MeetingPriority.MEDIUM,
|
priority=MeetingPriority.MEDIUM,
|
||||||
project_id=project.id,
|
project_id=project.id,
|
||||||
milestone_id=milestone_id,
|
milestone_id=ms.id,
|
||||||
reporter_id=current_user.id,
|
reporter_id=current_user.id,
|
||||||
meeting_code=meeting_code,
|
meeting_code=meeting_code,
|
||||||
scheduled_at=scheduled_at,
|
scheduled_at=scheduled_at,
|
||||||
@@ -779,4 +803,14 @@ def create_meeting(project_code: str, milestone_id: int, meeting_data: dict, db:
|
|||||||
db.add(meeting)
|
db.add(meeting)
|
||||||
db.commit()
|
db.commit()
|
||||||
db.refresh(meeting)
|
db.refresh(meeting)
|
||||||
return meeting
|
return {
|
||||||
|
"meeting_code": meeting.meeting_code,
|
||||||
|
"code": meeting.meeting_code,
|
||||||
|
"title": meeting.title,
|
||||||
|
"description": meeting.description,
|
||||||
|
"status": meeting.status.value,
|
||||||
|
"priority": meeting.priority.value,
|
||||||
|
"scheduled_at": meeting.scheduled_at,
|
||||||
|
"duration_minutes": meeting.duration_minutes,
|
||||||
|
"created_at": meeting.created_at,
|
||||||
|
}
|
||||||
|
|||||||
@@ -22,6 +22,7 @@ from app.services.monitoring import (
|
|||||||
get_server_states_view,
|
get_server_states_view,
|
||||||
test_provider_connection,
|
test_provider_connection,
|
||||||
)
|
)
|
||||||
|
from app.services.discord_wakeup import create_private_wakeup_channel
|
||||||
router = APIRouter(prefix='/monitor', tags=['Monitor'])
|
router = APIRouter(prefix='/monitor', tags=['Monitor'])
|
||||||
SUPPORTED_PROVIDERS = {'anthropic', 'openai', 'minimax', 'kimi', 'qwen'}
|
SUPPORTED_PROVIDERS = {'anthropic', 'openai', 'minimax', 'kimi', 'qwen'}
|
||||||
|
|
||||||
@@ -42,6 +43,12 @@ class MonitoredServerCreate(BaseModel):
|
|||||||
display_name: str | None = None
|
display_name: str | None = None
|
||||||
|
|
||||||
|
|
||||||
|
class DiscordWakeupTestRequest(BaseModel):
|
||||||
|
discord_user_id: str
|
||||||
|
title: str = "HarborForge Wakeup"
|
||||||
|
message: str = "A HarborForge slot is ready to start."
|
||||||
|
|
||||||
|
|
||||||
def require_admin(current_user: models.User = Depends(get_current_user_or_apikey)):
|
def require_admin(current_user: models.User = Depends(get_current_user_or_apikey)):
|
||||||
if not current_user.is_admin:
|
if not current_user.is_admin:
|
||||||
raise HTTPException(status_code=403, detail='Admin required')
|
raise HTTPException(status_code=403, detail='Admin required')
|
||||||
@@ -175,43 +182,11 @@ def revoke_api_key(server_id: int, db: Session = Depends(get_db), _: models.User
|
|||||||
return None
|
return None
|
||||||
|
|
||||||
|
|
||||||
class ServerHeartbeat(BaseModel):
|
@router.post('/admin/discord-wakeup/test')
|
||||||
identifier: str
|
def discord_wakeup_test(payload: DiscordWakeupTestRequest, _: models.User = Depends(require_admin)):
|
||||||
openclaw_version: str | None = None
|
return create_private_wakeup_channel(payload.discord_user_id, payload.title, payload.message)
|
||||||
plugin_version: str | None = None
|
|
||||||
agents: List[dict] = []
|
|
||||||
nginx_installed: bool | None = None
|
|
||||||
nginx_sites: List[str] = []
|
|
||||||
cpu_pct: float | None = None
|
|
||||||
mem_pct: float | None = None
|
|
||||||
disk_pct: float | None = None
|
|
||||||
swap_pct: float | None = None
|
|
||||||
|
|
||||||
|
|
||||||
@router.post('/server/heartbeat')
|
|
||||||
def server_heartbeat(payload: ServerHeartbeat, db: Session = Depends(get_db)):
|
|
||||||
server = db.query(MonitoredServer).filter(MonitoredServer.identifier == payload.identifier, MonitoredServer.is_enabled == True).first()
|
|
||||||
if not server:
|
|
||||||
raise HTTPException(status_code=404, detail='unknown server identifier')
|
|
||||||
st = db.query(ServerState).filter(ServerState.server_id == server.id).first()
|
|
||||||
if not st:
|
|
||||||
st = ServerState(server_id=server.id)
|
|
||||||
db.add(st)
|
|
||||||
st.openclaw_version = payload.openclaw_version
|
|
||||||
st.plugin_version = payload.plugin_version
|
|
||||||
st.agents_json = json.dumps(payload.agents, ensure_ascii=False)
|
|
||||||
st.nginx_installed = payload.nginx_installed
|
|
||||||
st.nginx_sites_json = json.dumps(payload.nginx_sites, ensure_ascii=False)
|
|
||||||
st.cpu_pct = payload.cpu_pct
|
|
||||||
st.mem_pct = payload.mem_pct
|
|
||||||
st.disk_pct = payload.disk_pct
|
|
||||||
st.swap_pct = payload.swap_pct
|
|
||||||
st.last_seen_at = datetime.now(timezone.utc)
|
|
||||||
db.commit()
|
|
||||||
return {'ok': True, 'server_id': server.id, 'last_seen_at': st.last_seen_at}
|
|
||||||
|
|
||||||
|
|
||||||
# Heartbeat v2 with API Key authentication
|
|
||||||
class TelemetryPayload(BaseModel):
|
class TelemetryPayload(BaseModel):
|
||||||
identifier: str
|
identifier: str
|
||||||
openclaw_version: str | None = None
|
openclaw_version: str | None = None
|
||||||
@@ -227,13 +202,13 @@ class TelemetryPayload(BaseModel):
|
|||||||
uptime_seconds: int | None = None
|
uptime_seconds: int | None = None
|
||||||
|
|
||||||
|
|
||||||
@router.post('/server/heartbeat-v2')
|
@router.post('/server/heartbeat')
|
||||||
def server_heartbeat_v2(
|
def server_heartbeat(
|
||||||
payload: TelemetryPayload,
|
payload: TelemetryPayload,
|
||||||
x_api_key: str = Header(..., alias='X-API-Key', description='API Key from /admin/servers/{id}/api-key'),
|
x_api_key: str = Header(..., alias='X-API-Key', description='API Key from /admin/servers/{id}/api-key'),
|
||||||
db: Session = Depends(get_db)
|
db: Session = Depends(get_db)
|
||||||
):
|
):
|
||||||
"""Server heartbeat using API Key authentication (no challenge_uuid required)"""
|
"""Server heartbeat using API Key authentication."""
|
||||||
server = db.query(MonitoredServer).filter(
|
server = db.query(MonitoredServer).filter(
|
||||||
MonitoredServer.api_key == x_api_key,
|
MonitoredServer.api_key == x_api_key,
|
||||||
MonitoredServer.is_enabled == True
|
MonitoredServer.is_enabled == True
|
||||||
@@ -256,4 +231,3 @@ def server_heartbeat_v2(
|
|||||||
st.last_seen_at = datetime.now(timezone.utc)
|
st.last_seen_at = datetime.now(timezone.utc)
|
||||||
db.commit()
|
db.commit()
|
||||||
return {'ok': True, 'server_id': server.id, 'identifier': server.identifier, 'last_seen_at': st.last_seen_at}
|
return {'ok': True, 'server_id': server.id, 'identifier': server.identifier, 'last_seen_at': st.last_seen_at}
|
||||||
|
|
||||||
|
|||||||
350
app/api/routers/oidc.py
Normal file
350
app/api/routers/oidc.py
Normal 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, _=_)
|
||||||
@@ -19,15 +19,14 @@ from app.models.task import Task, TaskStatus, TaskPriority
|
|||||||
from app.schemas import schemas
|
from app.schemas import schemas
|
||||||
from app.services.activity import log_activity
|
from app.services.activity import log_activity
|
||||||
|
|
||||||
router = APIRouter(prefix="/projects/{project_id}/proposals", tags=["Proposals"])
|
router = APIRouter(prefix="/projects/{project_code}/proposals", tags=["Proposals"])
|
||||||
|
|
||||||
|
|
||||||
def _serialize_essential(e: Essential) -> dict:
|
def _serialize_essential(e: Essential, proposal_code: str | None) -> dict:
|
||||||
"""Serialize an Essential for embedding in Proposal detail."""
|
"""Serialize an Essential for embedding in Proposal detail."""
|
||||||
return {
|
return {
|
||||||
"id": e.id,
|
|
||||||
"essential_code": e.essential_code,
|
"essential_code": e.essential_code,
|
||||||
"proposal_id": e.proposal_id,
|
"proposal_code": proposal_code,
|
||||||
"type": e.type.value if hasattr(e.type, "value") else e.type,
|
"type": e.type.value if hasattr(e.type, "value") else e.type,
|
||||||
"title": e.title,
|
"title": e.title,
|
||||||
"description": e.description,
|
"description": e.description,
|
||||||
@@ -41,14 +40,14 @@ def _serialize_proposal(db: Session, proposal: Proposal, *, include_essentials:
|
|||||||
"""Serialize proposal with created_by_username."""
|
"""Serialize proposal with created_by_username."""
|
||||||
creator = db.query(models.User).filter(models.User.id == proposal.created_by_id).first() if proposal.created_by_id else None
|
creator = db.query(models.User).filter(models.User.id == proposal.created_by_id).first() if proposal.created_by_id else None
|
||||||
code = proposal.propose_code # DB column; also exposed as proposal_code
|
code = proposal.propose_code # DB column; also exposed as proposal_code
|
||||||
|
project = db.query(models.Project).filter(models.Project.id == proposal.project_id).first()
|
||||||
result = {
|
result = {
|
||||||
"id": proposal.id,
|
|
||||||
"title": proposal.title,
|
"title": proposal.title,
|
||||||
"description": proposal.description,
|
"description": proposal.description,
|
||||||
"proposal_code": code, # preferred name
|
"proposal_code": code, # preferred name
|
||||||
"propose_code": code, # backward compat
|
"propose_code": code, # backward compat
|
||||||
"status": proposal.status.value if hasattr(proposal.status, "value") else proposal.status,
|
"status": proposal.status.value if hasattr(proposal.status, "value") else proposal.status,
|
||||||
"project_id": proposal.project_id,
|
"project_code": project.project_code if project else None,
|
||||||
"created_by_id": proposal.created_by_id,
|
"created_by_id": proposal.created_by_id,
|
||||||
"created_by_username": creator.username if creator else None,
|
"created_by_username": creator.username if creator else None,
|
||||||
"feat_task_id": proposal.feat_task_id, # DEPRECATED (BE-PR-010): read-only for legacy rows. Clients should use generated_tasks.
|
"feat_task_id": proposal.feat_task_id, # DEPRECATED (BE-PR-010): read-only for legacy rows. Clients should use generated_tasks.
|
||||||
@@ -62,7 +61,7 @@ def _serialize_proposal(db: Session, proposal: Proposal, *, include_essentials:
|
|||||||
.order_by(Essential.id.asc())
|
.order_by(Essential.id.asc())
|
||||||
.all()
|
.all()
|
||||||
)
|
)
|
||||||
result["essentials"] = [_serialize_essential(e) for e in essentials]
|
result["essentials"] = [_serialize_essential(e, code) for e in essentials]
|
||||||
|
|
||||||
# BE-PR-008: include tasks generated from this Proposal via Accept
|
# BE-PR-008: include tasks generated from this Proposal via Accept
|
||||||
gen_tasks = (
|
gen_tasks = (
|
||||||
@@ -71,46 +70,34 @@ def _serialize_proposal(db: Session, proposal: Proposal, *, include_essentials:
|
|||||||
.order_by(Task.id.asc())
|
.order_by(Task.id.asc())
|
||||||
.all()
|
.all()
|
||||||
)
|
)
|
||||||
|
def _lookup_essential_code(essential_id: int | None) -> str | None:
|
||||||
|
if not essential_id:
|
||||||
|
return None
|
||||||
|
essential = db.query(Essential).filter(Essential.id == essential_id).first()
|
||||||
|
return essential.essential_code if essential else None
|
||||||
|
|
||||||
result["generated_tasks"] = [
|
result["generated_tasks"] = [
|
||||||
{
|
{
|
||||||
"task_id": t.id,
|
|
||||||
"task_code": t.task_code,
|
"task_code": t.task_code,
|
||||||
"task_type": t.task_type or "story",
|
"task_type": t.task_type or "story",
|
||||||
"task_subtype": t.task_subtype,
|
"task_subtype": t.task_subtype,
|
||||||
"title": t.title,
|
"title": t.title,
|
||||||
"status": t.status.value if hasattr(t.status, "value") else t.status,
|
"status": t.status.value if hasattr(t.status, "value") else t.status,
|
||||||
"source_essential_id": t.source_essential_id,
|
"source_essential_code": _lookup_essential_code(t.source_essential_id),
|
||||||
}
|
}
|
||||||
for t in gen_tasks
|
for t in gen_tasks
|
||||||
]
|
]
|
||||||
return result
|
return result
|
||||||
|
|
||||||
|
|
||||||
def _find_project(db, identifier):
|
def _find_project(db, project_code: str):
|
||||||
"""Look up project by numeric id or project_code."""
|
"""Look up project by project_code."""
|
||||||
try:
|
return db.query(models.Project).filter(models.Project.project_code == str(project_code)).first()
|
||||||
pid = int(identifier)
|
|
||||||
p = db.query(models.Project).filter(models.Project.id == pid).first()
|
|
||||||
if p:
|
|
||||||
return p
|
|
||||||
except (ValueError, TypeError):
|
|
||||||
pass
|
|
||||||
return db.query(models.Project).filter(models.Project.project_code == str(identifier)).first()
|
|
||||||
|
|
||||||
|
|
||||||
def _find_proposal(db, identifier, project_id: int = None) -> Proposal | None:
|
def _find_proposal(db, proposal_code: str, project_id: int = None) -> Proposal | None:
|
||||||
"""Look up proposal by numeric id or propose_code."""
|
"""Look up proposal by propose_code."""
|
||||||
try:
|
q = db.query(Proposal).filter(Proposal.propose_code == str(proposal_code))
|
||||||
pid = int(identifier)
|
|
||||||
q = db.query(Proposal).filter(Proposal.id == pid)
|
|
||||||
if project_id:
|
|
||||||
q = q.filter(Proposal.project_id == project_id)
|
|
||||||
p = q.first()
|
|
||||||
if p:
|
|
||||||
return p
|
|
||||||
except (ValueError, TypeError):
|
|
||||||
pass
|
|
||||||
q = db.query(Proposal).filter(Proposal.propose_code == str(identifier))
|
|
||||||
if project_id:
|
if project_id:
|
||||||
q = q.filter(Proposal.project_id == project_id)
|
q = q.filter(Proposal.project_id == project_id)
|
||||||
return q.first()
|
return q.first()
|
||||||
@@ -147,11 +134,11 @@ def _can_edit_proposal(db: Session, user_id: int, proposal: Proposal) -> bool:
|
|||||||
|
|
||||||
@router.get("", response_model=List[schemas.ProposalResponse])
|
@router.get("", response_model=List[schemas.ProposalResponse])
|
||||||
def list_proposals(
|
def list_proposals(
|
||||||
project_id: str,
|
project_code: str,
|
||||||
db: Session = Depends(get_db),
|
db: Session = Depends(get_db),
|
||||||
current_user: models.User = Depends(get_current_user_or_apikey),
|
current_user: models.User = Depends(get_current_user_or_apikey),
|
||||||
):
|
):
|
||||||
project = _find_project(db, project_id)
|
project = _find_project(db, project_code)
|
||||||
if not project:
|
if not project:
|
||||||
raise HTTPException(status_code=404, detail="Project not found")
|
raise HTTPException(status_code=404, detail="Project not found")
|
||||||
check_project_role(db, current_user.id, project.id, min_role="viewer")
|
check_project_role(db, current_user.id, project.id, min_role="viewer")
|
||||||
@@ -166,12 +153,12 @@ def list_proposals(
|
|||||||
|
|
||||||
@router.post("", response_model=schemas.ProposalResponse, status_code=status.HTTP_201_CREATED)
|
@router.post("", response_model=schemas.ProposalResponse, status_code=status.HTTP_201_CREATED)
|
||||||
def create_proposal(
|
def create_proposal(
|
||||||
project_id: str,
|
project_code: str,
|
||||||
proposal_in: schemas.ProposalCreate,
|
proposal_in: schemas.ProposalCreate,
|
||||||
db: Session = Depends(get_db),
|
db: Session = Depends(get_db),
|
||||||
current_user: models.User = Depends(get_current_user_or_apikey),
|
current_user: models.User = Depends(get_current_user_or_apikey),
|
||||||
):
|
):
|
||||||
project = _find_project(db, project_id)
|
project = _find_project(db, project_code)
|
||||||
if not project:
|
if not project:
|
||||||
raise HTTPException(status_code=404, detail="Project not found")
|
raise HTTPException(status_code=404, detail="Project not found")
|
||||||
check_project_role(db, current_user.id, project.id, min_role="dev")
|
check_project_role(db, current_user.id, project.id, min_role="dev")
|
||||||
@@ -197,17 +184,17 @@ def create_proposal(
|
|||||||
|
|
||||||
@router.get("/{proposal_id}", response_model=schemas.ProposalDetailResponse)
|
@router.get("/{proposal_id}", response_model=schemas.ProposalDetailResponse)
|
||||||
def get_proposal(
|
def get_proposal(
|
||||||
project_id: str,
|
project_code: str,
|
||||||
proposal_id: str,
|
proposal_code: str,
|
||||||
db: Session = Depends(get_db),
|
db: Session = Depends(get_db),
|
||||||
current_user: models.User = Depends(get_current_user_or_apikey),
|
current_user: models.User = Depends(get_current_user_or_apikey),
|
||||||
):
|
):
|
||||||
"""Get a single Proposal with its Essentials list embedded."""
|
"""Get a single Proposal with its Essentials list embedded."""
|
||||||
project = _find_project(db, project_id)
|
project = _find_project(db, project_code)
|
||||||
if not project:
|
if not project:
|
||||||
raise HTTPException(status_code=404, detail="Project not found")
|
raise HTTPException(status_code=404, detail="Project not found")
|
||||||
check_project_role(db, current_user.id, project.id, min_role="viewer")
|
check_project_role(db, current_user.id, project.id, min_role="viewer")
|
||||||
proposal = _find_proposal(db, proposal_id, project.id)
|
proposal = _find_proposal(db, proposal_code, project.id)
|
||||||
if not proposal:
|
if not proposal:
|
||||||
raise HTTPException(status_code=404, detail="Proposal not found")
|
raise HTTPException(status_code=404, detail="Proposal not found")
|
||||||
return _serialize_proposal(db, proposal, include_essentials=True)
|
return _serialize_proposal(db, proposal, include_essentials=True)
|
||||||
@@ -215,16 +202,16 @@ def get_proposal(
|
|||||||
|
|
||||||
@router.patch("/{proposal_id}", response_model=schemas.ProposalResponse)
|
@router.patch("/{proposal_id}", response_model=schemas.ProposalResponse)
|
||||||
def update_proposal(
|
def update_proposal(
|
||||||
project_id: str,
|
project_code: str,
|
||||||
proposal_id: str,
|
proposal_code: str,
|
||||||
proposal_in: schemas.ProposalUpdate,
|
proposal_in: schemas.ProposalUpdate,
|
||||||
db: Session = Depends(get_db),
|
db: Session = Depends(get_db),
|
||||||
current_user: models.User = Depends(get_current_user_or_apikey),
|
current_user: models.User = Depends(get_current_user_or_apikey),
|
||||||
):
|
):
|
||||||
project = _find_project(db, project_id)
|
project = _find_project(db, project_code)
|
||||||
if not project:
|
if not project:
|
||||||
raise HTTPException(status_code=404, detail="Project not found")
|
raise HTTPException(status_code=404, detail="Project not found")
|
||||||
proposal = _find_proposal(db, proposal_id, project.id)
|
proposal = _find_proposal(db, proposal_code, project.id)
|
||||||
if not proposal:
|
if not proposal:
|
||||||
raise HTTPException(status_code=404, detail="Proposal not found")
|
raise HTTPException(status_code=404, detail="Proposal not found")
|
||||||
|
|
||||||
@@ -253,13 +240,13 @@ def update_proposal(
|
|||||||
# ---- Actions ----
|
# ---- Actions ----
|
||||||
|
|
||||||
class AcceptRequest(schemas.BaseModel):
|
class AcceptRequest(schemas.BaseModel):
|
||||||
milestone_id: int
|
milestone_code: str
|
||||||
|
|
||||||
|
|
||||||
@router.post("/{proposal_id}/accept", response_model=schemas.ProposalAcceptResponse)
|
@router.post("/{proposal_id}/accept", response_model=schemas.ProposalAcceptResponse)
|
||||||
def accept_proposal(
|
def accept_proposal(
|
||||||
project_id: str,
|
project_code: str,
|
||||||
proposal_id: str,
|
proposal_code: str,
|
||||||
body: AcceptRequest,
|
body: AcceptRequest,
|
||||||
db: Session = Depends(get_db),
|
db: Session = Depends(get_db),
|
||||||
current_user: models.User = Depends(get_current_user_or_apikey),
|
current_user: models.User = Depends(get_current_user_or_apikey),
|
||||||
@@ -274,10 +261,10 @@ def accept_proposal(
|
|||||||
All tasks are created in a single transaction. The Proposal must have at
|
All tasks are created in a single transaction. The Proposal must have at
|
||||||
least one Essential to be accepted.
|
least one Essential to be accepted.
|
||||||
"""
|
"""
|
||||||
project = _find_project(db, project_id)
|
project = _find_project(db, project_code)
|
||||||
if not project:
|
if not project:
|
||||||
raise HTTPException(status_code=404, detail="Project not found")
|
raise HTTPException(status_code=404, detail="Project not found")
|
||||||
proposal = _find_proposal(db, proposal_id, project.id)
|
proposal = _find_proposal(db, proposal_code, project.id)
|
||||||
if not proposal:
|
if not proposal:
|
||||||
raise HTTPException(status_code=404, detail="Proposal not found")
|
raise HTTPException(status_code=404, detail="Proposal not found")
|
||||||
|
|
||||||
@@ -289,7 +276,7 @@ def accept_proposal(
|
|||||||
|
|
||||||
# Validate milestone
|
# Validate milestone
|
||||||
milestone = db.query(Milestone).filter(
|
milestone = db.query(Milestone).filter(
|
||||||
Milestone.id == body.milestone_id,
|
Milestone.milestone_code == body.milestone_code,
|
||||||
Milestone.project_id == project.id,
|
Milestone.project_id == project.id,
|
||||||
).first()
|
).first()
|
||||||
if not milestone:
|
if not milestone:
|
||||||
@@ -355,12 +342,10 @@ def accept_proposal(
|
|||||||
db.flush() # materialise task.id
|
db.flush() # materialise task.id
|
||||||
|
|
||||||
generated_tasks.append({
|
generated_tasks.append({
|
||||||
"task_id": task.id,
|
|
||||||
"task_code": task_code,
|
"task_code": task_code,
|
||||||
"task_type": "story",
|
"task_type": "story",
|
||||||
"task_subtype": task_subtype,
|
"task_subtype": task_subtype,
|
||||||
"title": essential.title,
|
"title": essential.title,
|
||||||
"essential_id": essential.id,
|
|
||||||
"essential_code": essential.essential_code,
|
"essential_code": essential.essential_code,
|
||||||
})
|
})
|
||||||
next_num = task.id + 1 # use real id for next code to stay consistent
|
next_num = task.id + 1 # use real id for next code to stay consistent
|
||||||
@@ -372,9 +357,9 @@ def accept_proposal(
|
|||||||
db.refresh(proposal)
|
db.refresh(proposal)
|
||||||
|
|
||||||
log_activity(db, "accept", "proposal", proposal.id, user_id=current_user.id, details={
|
log_activity(db, "accept", "proposal", proposal.id, user_id=current_user.id, details={
|
||||||
"milestone_id": milestone.id,
|
"milestone_code": milestone.milestone_code,
|
||||||
"generated_tasks": [
|
"generated_tasks": [
|
||||||
{"task_id": t["task_id"], "task_code": t["task_code"], "essential_id": t["essential_id"]}
|
{"task_code": t["task_code"], "essential_code": t["essential_code"]}
|
||||||
for t in generated_tasks
|
for t in generated_tasks
|
||||||
],
|
],
|
||||||
})
|
})
|
||||||
@@ -390,17 +375,17 @@ class RejectRequest(schemas.BaseModel):
|
|||||||
|
|
||||||
@router.post("/{proposal_id}/reject", response_model=schemas.ProposalResponse)
|
@router.post("/{proposal_id}/reject", response_model=schemas.ProposalResponse)
|
||||||
def reject_proposal(
|
def reject_proposal(
|
||||||
project_id: str,
|
project_code: str,
|
||||||
proposal_id: str,
|
proposal_code: str,
|
||||||
body: RejectRequest | None = None,
|
body: RejectRequest | None = None,
|
||||||
db: Session = Depends(get_db),
|
db: Session = Depends(get_db),
|
||||||
current_user: models.User = Depends(get_current_user_or_apikey),
|
current_user: models.User = Depends(get_current_user_or_apikey),
|
||||||
):
|
):
|
||||||
"""Reject a proposal."""
|
"""Reject a proposal."""
|
||||||
project = _find_project(db, project_id)
|
project = _find_project(db, project_code)
|
||||||
if not project:
|
if not project:
|
||||||
raise HTTPException(status_code=404, detail="Project not found")
|
raise HTTPException(status_code=404, detail="Project not found")
|
||||||
proposal = _find_proposal(db, proposal_id, project.id)
|
proposal = _find_proposal(db, proposal_code, project.id)
|
||||||
if not proposal:
|
if not proposal:
|
||||||
raise HTTPException(status_code=404, detail="Proposal not found")
|
raise HTTPException(status_code=404, detail="Proposal not found")
|
||||||
|
|
||||||
@@ -423,16 +408,16 @@ def reject_proposal(
|
|||||||
|
|
||||||
@router.post("/{proposal_id}/reopen", response_model=schemas.ProposalResponse)
|
@router.post("/{proposal_id}/reopen", response_model=schemas.ProposalResponse)
|
||||||
def reopen_proposal(
|
def reopen_proposal(
|
||||||
project_id: str,
|
project_code: str,
|
||||||
proposal_id: str,
|
proposal_code: str,
|
||||||
db: Session = Depends(get_db),
|
db: Session = Depends(get_db),
|
||||||
current_user: models.User = Depends(get_current_user_or_apikey),
|
current_user: models.User = Depends(get_current_user_or_apikey),
|
||||||
):
|
):
|
||||||
"""Reopen a rejected proposal back to open."""
|
"""Reopen a rejected proposal back to open."""
|
||||||
project = _find_project(db, project_id)
|
project = _find_project(db, project_code)
|
||||||
if not project:
|
if not project:
|
||||||
raise HTTPException(status_code=404, detail="Project not found")
|
raise HTTPException(status_code=404, detail="Project not found")
|
||||||
proposal = _find_proposal(db, proposal_id, project.id)
|
proposal = _find_proposal(db, proposal_code, project.id)
|
||||||
if not proposal:
|
if not proposal:
|
||||||
raise HTTPException(status_code=404, detail="Proposal not found")
|
raise HTTPException(status_code=404, detail="Proposal not found")
|
||||||
|
|
||||||
|
|||||||
@@ -28,83 +28,83 @@ from app.api.rbac import check_project_role, check_permission, is_global_admin
|
|||||||
from app.services.activity import log_activity
|
from app.services.activity import log_activity
|
||||||
|
|
||||||
# Legacy router — same logic, old URL prefix
|
# Legacy router — same logic, old URL prefix
|
||||||
router = APIRouter(prefix="/projects/{project_id}/proposes", tags=["Proposes (legacy)"])
|
router = APIRouter(prefix="/projects/{project_code}/proposes", tags=["Proposes (legacy)"])
|
||||||
|
|
||||||
|
|
||||||
@router.get("", response_model=List[schemas.ProposalResponse])
|
@router.get("", response_model=List[schemas.ProposalResponse])
|
||||||
def list_proposes(
|
def list_proposes(
|
||||||
project_id: str,
|
project_code: str,
|
||||||
db: Session = Depends(get_db),
|
db: Session = Depends(get_db),
|
||||||
current_user: models.User = Depends(get_current_user_or_apikey),
|
current_user: models.User = Depends(get_current_user_or_apikey),
|
||||||
):
|
):
|
||||||
from app.api.routers.proposals import list_proposals
|
from app.api.routers.proposals import list_proposals
|
||||||
return list_proposals(project_id=project_id, db=db, current_user=current_user)
|
return list_proposals(project_code=project_code, db=db, current_user=current_user)
|
||||||
|
|
||||||
|
|
||||||
@router.post("", response_model=schemas.ProposalResponse, status_code=status.HTTP_201_CREATED)
|
@router.post("", response_model=schemas.ProposalResponse, status_code=status.HTTP_201_CREATED)
|
||||||
def create_propose(
|
def create_propose(
|
||||||
project_id: str,
|
project_code: str,
|
||||||
proposal_in: schemas.ProposalCreate,
|
proposal_in: schemas.ProposalCreate,
|
||||||
db: Session = Depends(get_db),
|
db: Session = Depends(get_db),
|
||||||
current_user: models.User = Depends(get_current_user_or_apikey),
|
current_user: models.User = Depends(get_current_user_or_apikey),
|
||||||
):
|
):
|
||||||
from app.api.routers.proposals import create_proposal
|
from app.api.routers.proposals import create_proposal
|
||||||
return create_proposal(project_id=project_id, proposal_in=proposal_in, db=db, current_user=current_user)
|
return create_proposal(project_code=project_code, proposal_in=proposal_in, db=db, current_user=current_user)
|
||||||
|
|
||||||
|
|
||||||
@router.get("/{propose_id}", response_model=schemas.ProposalResponse)
|
@router.get("/{propose_id}", response_model=schemas.ProposalResponse)
|
||||||
def get_propose(
|
def get_propose(
|
||||||
project_id: str,
|
project_code: str,
|
||||||
propose_id: str,
|
propose_id: str,
|
||||||
db: Session = Depends(get_db),
|
db: Session = Depends(get_db),
|
||||||
current_user: models.User = Depends(get_current_user_or_apikey),
|
current_user: models.User = Depends(get_current_user_or_apikey),
|
||||||
):
|
):
|
||||||
from app.api.routers.proposals import get_proposal
|
from app.api.routers.proposals import get_proposal
|
||||||
return get_proposal(project_id=project_id, proposal_id=propose_id, db=db, current_user=current_user)
|
return get_proposal(project_code=project_code, proposal_code=propose_id, db=db, current_user=current_user)
|
||||||
|
|
||||||
|
|
||||||
@router.patch("/{propose_id}", response_model=schemas.ProposalResponse)
|
@router.patch("/{propose_id}", response_model=schemas.ProposalResponse)
|
||||||
def update_propose(
|
def update_propose(
|
||||||
project_id: str,
|
project_code: str,
|
||||||
propose_id: str,
|
propose_id: str,
|
||||||
proposal_in: schemas.ProposalUpdate,
|
proposal_in: schemas.ProposalUpdate,
|
||||||
db: Session = Depends(get_db),
|
db: Session = Depends(get_db),
|
||||||
current_user: models.User = Depends(get_current_user_or_apikey),
|
current_user: models.User = Depends(get_current_user_or_apikey),
|
||||||
):
|
):
|
||||||
from app.api.routers.proposals import update_proposal
|
from app.api.routers.proposals import update_proposal
|
||||||
return update_proposal(project_id=project_id, proposal_id=propose_id, proposal_in=proposal_in, db=db, current_user=current_user)
|
return update_proposal(project_code=project_code, proposal_code=propose_id, proposal_in=proposal_in, db=db, current_user=current_user)
|
||||||
|
|
||||||
|
|
||||||
@router.post("/{propose_id}/accept", response_model=schemas.ProposalResponse)
|
@router.post("/{propose_id}/accept", response_model=schemas.ProposalResponse)
|
||||||
def accept_propose(
|
def accept_propose(
|
||||||
project_id: str,
|
project_code: str,
|
||||||
propose_id: str,
|
propose_id: str,
|
||||||
body: AcceptRequest,
|
body: AcceptRequest,
|
||||||
db: Session = Depends(get_db),
|
db: Session = Depends(get_db),
|
||||||
current_user: models.User = Depends(get_current_user_or_apikey),
|
current_user: models.User = Depends(get_current_user_or_apikey),
|
||||||
):
|
):
|
||||||
from app.api.routers.proposals import accept_proposal
|
from app.api.routers.proposals import accept_proposal
|
||||||
return accept_proposal(project_id=project_id, proposal_id=propose_id, body=body, db=db, current_user=current_user)
|
return accept_proposal(project_code=project_code, proposal_code=propose_id, body=body, db=db, current_user=current_user)
|
||||||
|
|
||||||
|
|
||||||
@router.post("/{propose_id}/reject", response_model=schemas.ProposalResponse)
|
@router.post("/{propose_id}/reject", response_model=schemas.ProposalResponse)
|
||||||
def reject_propose(
|
def reject_propose(
|
||||||
project_id: str,
|
project_code: str,
|
||||||
propose_id: str,
|
propose_id: str,
|
||||||
body: RejectRequest | None = None,
|
body: RejectRequest | None = None,
|
||||||
db: Session = Depends(get_db),
|
db: Session = Depends(get_db),
|
||||||
current_user: models.User = Depends(get_current_user_or_apikey),
|
current_user: models.User = Depends(get_current_user_or_apikey),
|
||||||
):
|
):
|
||||||
from app.api.routers.proposals import reject_proposal
|
from app.api.routers.proposals import reject_proposal
|
||||||
return reject_proposal(project_id=project_id, proposal_id=propose_id, body=body, db=db, current_user=current_user)
|
return reject_proposal(project_code=project_code, proposal_code=propose_id, body=body, db=db, current_user=current_user)
|
||||||
|
|
||||||
|
|
||||||
@router.post("/{propose_id}/reopen", response_model=schemas.ProposalResponse)
|
@router.post("/{propose_id}/reopen", response_model=schemas.ProposalResponse)
|
||||||
def reopen_propose(
|
def reopen_propose(
|
||||||
project_id: str,
|
project_code: str,
|
||||||
propose_id: str,
|
propose_id: str,
|
||||||
db: Session = Depends(get_db),
|
db: Session = Depends(get_db),
|
||||||
current_user: models.User = Depends(get_current_user_or_apikey),
|
current_user: models.User = Depends(get_current_user_or_apikey),
|
||||||
):
|
):
|
||||||
from app.api.routers.proposals import reopen_proposal
|
from app.api.routers.proposals import reopen_proposal
|
||||||
return reopen_proposal(project_id=project_id, proposal_id=propose_id, db=db, current_user=current_user)
|
return reopen_proposal(project_code=project_code, proposal_code=propose_id, db=db, current_user=current_user)
|
||||||
|
|||||||
209
app/api/routers/schedule_type.py
Normal file
209
app/api/routers/schedule_type.py
Normal 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}
|
||||||
223
app/api/routers/schedule_type_special_slot.py
Normal file
223
app/api/routers/schedule_type_special_slot.py
Normal 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}
|
||||||
@@ -10,6 +10,8 @@ from app.core.config import get_db
|
|||||||
from app.models import models
|
from app.models import models
|
||||||
from app.models.task import Task, TaskStatus, TaskPriority
|
from app.models.task import Task, TaskStatus, TaskPriority
|
||||||
from app.models.milestone import Milestone
|
from app.models.milestone import Milestone
|
||||||
|
from app.models.proposal import Proposal
|
||||||
|
from app.models.essential import Essential
|
||||||
from app.schemas import schemas
|
from app.schemas import schemas
|
||||||
from app.services.webhook import fire_webhooks_sync
|
from app.services.webhook import fire_webhooks_sync
|
||||||
from app.models.notification import Notification as NotificationModel
|
from app.models.notification import Notification as NotificationModel
|
||||||
@@ -21,14 +23,9 @@ from app.services.dependency_check import check_task_deps
|
|||||||
router = APIRouter(tags=["Tasks"])
|
router = APIRouter(tags=["Tasks"])
|
||||||
|
|
||||||
|
|
||||||
def _resolve_task(db: Session, identifier: str) -> Task:
|
def _resolve_task(db: Session, task_code: str) -> Task:
|
||||||
"""Resolve a task by numeric id or task_code string.
|
"""Resolve a task by task_code string. Raises 404 if not found."""
|
||||||
Raises 404 if not found."""
|
task = db.query(Task).filter(Task.task_code == task_code).first()
|
||||||
try:
|
|
||||||
task_id = int(identifier)
|
|
||||||
task = db.query(Task).filter(Task.id == task_id).first()
|
|
||||||
except (ValueError, TypeError):
|
|
||||||
task = db.query(Task).filter(Task.task_code == identifier).first()
|
|
||||||
if not task:
|
if not task:
|
||||||
raise HTTPException(status_code=404, detail="Task not found")
|
raise HTTPException(status_code=404, detail="Task not found")
|
||||||
return task
|
return task
|
||||||
@@ -118,9 +115,7 @@ def _notify_user(db, user_id, ntype, title, message=None, entity_type=None, enti
|
|||||||
return n
|
return n
|
||||||
|
|
||||||
|
|
||||||
def _resolve_project_id(db: Session, project_id: int | None, project_code: str | None) -> int | None:
|
def _resolve_project_id(db: Session, project_code: str | None) -> int | None:
|
||||||
if project_id:
|
|
||||||
return project_id
|
|
||||||
if not project_code:
|
if not project_code:
|
||||||
return None
|
return None
|
||||||
project = db.query(models.Project).filter(models.Project.project_code == project_code).first()
|
project = db.query(models.Project).filter(models.Project.project_code == project_code).first()
|
||||||
@@ -129,40 +124,36 @@ def _resolve_project_id(db: Session, project_id: int | None, project_code: str |
|
|||||||
return project.id
|
return project.id
|
||||||
|
|
||||||
|
|
||||||
def _resolve_milestone(db: Session, milestone_id: int | None, milestone_code: str | None, project_id: int | None) -> Milestone | None:
|
def _resolve_milestone(db: Session, milestone_code: str | None, project_id: int | None) -> Milestone | None:
|
||||||
if milestone_id:
|
if not milestone_code:
|
||||||
query = db.query(Milestone).filter(Milestone.id == milestone_id)
|
|
||||||
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:
|
|
||||||
return None
|
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:
|
if not milestone:
|
||||||
raise HTTPException(status_code=404, detail="Milestone not found")
|
raise HTTPException(status_code=404, detail="Milestone not found")
|
||||||
return milestone
|
return milestone
|
||||||
|
|
||||||
|
|
||||||
def _find_task_by_id_or_code(db: Session, identifier: str) -> Task | None:
|
def _find_task_by_code(db: Session, task_code: str) -> Task | None:
|
||||||
try:
|
return db.query(Task).filter(Task.task_code == task_code).first()
|
||||||
task_id = int(identifier)
|
|
||||||
task = db.query(Task).filter(Task.id == task_id).first()
|
|
||||||
if task:
|
|
||||||
return task
|
|
||||||
except ValueError:
|
|
||||||
pass
|
|
||||||
return db.query(Task).filter(Task.task_code == identifier).first()
|
|
||||||
|
|
||||||
|
|
||||||
def _serialize_task(db: Session, task: Task) -> dict:
|
def _serialize_task(db: Session, task: Task) -> dict:
|
||||||
payload = schemas.TaskResponse.model_validate(task).model_dump(mode="json")
|
payload = schemas.TaskResponse.model_validate(task).model_dump(mode="json")
|
||||||
project = db.query(models.Project).filter(models.Project.id == task.project_id).first()
|
project = db.query(models.Project).filter(models.Project.id == task.project_id).first()
|
||||||
milestone = db.query(Milestone).filter(Milestone.id == task.milestone_id).first()
|
milestone = db.query(Milestone).filter(Milestone.id == task.milestone_id).first()
|
||||||
|
proposal_code = None
|
||||||
|
essential_code = None
|
||||||
|
if task.source_proposal_id:
|
||||||
|
proposal = db.query(Proposal).filter(Proposal.id == task.source_proposal_id).first()
|
||||||
|
proposal_code = proposal.propose_code if proposal else None
|
||||||
|
if task.source_essential_id:
|
||||||
|
essential = db.query(Essential).filter(Essential.id == task.source_essential_id).first()
|
||||||
|
essential_code = essential.essential_code if essential else None
|
||||||
assignee = None
|
assignee = None
|
||||||
if task.assignee_id:
|
if task.assignee_id:
|
||||||
assignee = db.query(models.User).filter(models.User.id == task.assignee_id).first()
|
assignee = db.query(models.User).filter(models.User.id == task.assignee_id).first()
|
||||||
@@ -174,6 +165,8 @@ def _serialize_task(db: Session, task: Task) -> dict:
|
|||||||
"milestone_code": milestone.milestone_code if milestone else None,
|
"milestone_code": milestone.milestone_code if milestone else None,
|
||||||
"taken_by": assignee.username if assignee else None,
|
"taken_by": assignee.username if assignee else None,
|
||||||
"due_date": None,
|
"due_date": None,
|
||||||
|
"source_proposal_code": proposal_code,
|
||||||
|
"source_essential_code": essential_code,
|
||||||
})
|
})
|
||||||
return payload
|
return payload
|
||||||
|
|
||||||
@@ -191,8 +184,8 @@ def create_task(task_in: schemas.TaskCreate, bg: BackgroundTasks, db: Session =
|
|||||||
else:
|
else:
|
||||||
data.pop("type", None)
|
data.pop("type", None)
|
||||||
|
|
||||||
data["project_id"] = _resolve_project_id(db, data.get("project_id"), data.pop("project_code", None))
|
data["project_id"] = _resolve_project_id(db, data.pop("project_code", None))
|
||||||
milestone = _resolve_milestone(db, data.get("milestone_id"), data.pop("milestone_code", None), data.get("project_id"))
|
milestone = _resolve_milestone(db, data.pop("milestone_code", None), data.get("project_id"))
|
||||||
if milestone:
|
if milestone:
|
||||||
data["milestone_id"] = milestone.id
|
data["milestone_id"] = milestone.id
|
||||||
data["project_id"] = milestone.project_id
|
data["project_id"] = milestone.project_id
|
||||||
@@ -201,17 +194,12 @@ def create_task(task_in: schemas.TaskCreate, bg: BackgroundTasks, db: Session =
|
|||||||
data["created_by_id"] = current_user.id
|
data["created_by_id"] = current_user.id
|
||||||
|
|
||||||
if not data.get("project_id"):
|
if not data.get("project_id"):
|
||||||
raise HTTPException(status_code=400, detail="project_id or project_code is required")
|
raise HTTPException(status_code=400, detail="project_code is required")
|
||||||
if not data.get("milestone_id"):
|
if not data.get("milestone_id"):
|
||||||
raise HTTPException(status_code=400, detail="milestone_id or milestone_code is required")
|
raise HTTPException(status_code=400, detail="milestone_code is required")
|
||||||
|
|
||||||
check_project_role(db, current_user.id, data["project_id"], min_role="dev")
|
check_project_role(db, current_user.id, data["project_id"], min_role="dev")
|
||||||
|
|
||||||
if not milestone:
|
|
||||||
milestone = db.query(Milestone).filter(
|
|
||||||
Milestone.id == data["milestone_id"],
|
|
||||||
Milestone.project_id == data["project_id"],
|
|
||||||
).first()
|
|
||||||
if not milestone:
|
if not milestone:
|
||||||
raise HTTPException(status_code=404, detail="Milestone not found")
|
raise HTTPException(status_code=404, detail="Milestone not found")
|
||||||
|
|
||||||
@@ -237,7 +225,7 @@ def create_task(task_in: schemas.TaskCreate, bg: BackgroundTasks, db: Session =
|
|||||||
bg.add_task(
|
bg.add_task(
|
||||||
fire_webhooks_sync,
|
fire_webhooks_sync,
|
||||||
event,
|
event,
|
||||||
{"task_id": db_task.id, "title": db_task.title, "type": db_task.task_type, "status": db_task.status.value},
|
{"task_code": db_task.task_code, "title": db_task.title, "type": db_task.task_type, "status": db_task.status.value},
|
||||||
db_task.project_id,
|
db_task.project_id,
|
||||||
db,
|
db,
|
||||||
)
|
)
|
||||||
@@ -247,22 +235,22 @@ def create_task(task_in: schemas.TaskCreate, bg: BackgroundTasks, db: Session =
|
|||||||
|
|
||||||
@router.get("/tasks")
|
@router.get("/tasks")
|
||||||
def list_tasks(
|
def list_tasks(
|
||||||
project_id: int = None, task_status: str = None, task_type: str = None, task_subtype: str = None,
|
task_status: str = None, task_type: str = None, task_subtype: str = None,
|
||||||
assignee_id: int = None, tag: str = None,
|
assignee_id: int = None, tag: str = None,
|
||||||
sort_by: str = "created_at", sort_order: str = "desc",
|
sort_by: str = "created_at", sort_order: str = "desc",
|
||||||
page: int = 1, page_size: int = 50,
|
page: int = 1, page_size: int = 50,
|
||||||
project: str = None, milestone: str = None, status_value: str = Query(None, alias="status"), taken_by: str = None,
|
project_code: str = None, milestone_code: str = None, status_value: str = Query(None, alias="status"), taken_by: str = None,
|
||||||
order_by: str = None,
|
order_by: str = None,
|
||||||
db: Session = Depends(get_db)
|
db: Session = Depends(get_db)
|
||||||
):
|
):
|
||||||
query = db.query(Task)
|
query = db.query(Task)
|
||||||
|
|
||||||
resolved_project_id = _resolve_project_id(db, project_id, project)
|
resolved_project_id = _resolve_project_id(db, project_code)
|
||||||
if resolved_project_id:
|
if resolved_project_id:
|
||||||
query = query.filter(Task.project_id == resolved_project_id)
|
query = query.filter(Task.project_id == resolved_project_id)
|
||||||
|
|
||||||
if milestone:
|
if milestone_code:
|
||||||
milestone_obj = _resolve_milestone(db, None, milestone, resolved_project_id)
|
milestone_obj = _resolve_milestone(db, milestone_code, resolved_project_id)
|
||||||
query = query.filter(Task.milestone_id == milestone_obj.id)
|
query = query.filter(Task.milestone_id == milestone_obj.id)
|
||||||
|
|
||||||
effective_status = status_value or task_status
|
effective_status = status_value or task_status
|
||||||
@@ -316,14 +304,14 @@ def list_tasks(
|
|||||||
@router.get("/tasks/search", response_model=List[schemas.TaskResponse])
|
@router.get("/tasks/search", response_model=List[schemas.TaskResponse])
|
||||||
def search_tasks_alias(
|
def search_tasks_alias(
|
||||||
q: str,
|
q: str,
|
||||||
project: str = None,
|
project_code: str = None,
|
||||||
status: str = None,
|
status: str = None,
|
||||||
db: Session = Depends(get_db),
|
db: Session = Depends(get_db),
|
||||||
):
|
):
|
||||||
query = db.query(Task).filter(
|
query = db.query(Task).filter(
|
||||||
(Task.title.contains(q)) | (Task.description.contains(q))
|
(Task.title.contains(q)) | (Task.description.contains(q))
|
||||||
)
|
)
|
||||||
resolved_project_id = _resolve_project_id(db, None, project)
|
resolved_project_id = _resolve_project_id(db, project_code)
|
||||||
if resolved_project_id:
|
if resolved_project_id:
|
||||||
query = query.filter(Task.project_id == resolved_project_id)
|
query = query.filter(Task.project_id == resolved_project_id)
|
||||||
if status:
|
if status:
|
||||||
@@ -332,15 +320,15 @@ def search_tasks_alias(
|
|||||||
return [_serialize_task(db, i) for i in items]
|
return [_serialize_task(db, i) for i in items]
|
||||||
|
|
||||||
|
|
||||||
@router.get("/tasks/{task_id}", response_model=schemas.TaskResponse)
|
@router.get("/tasks/{task_code}", response_model=schemas.TaskResponse)
|
||||||
def get_task(task_id: str, db: Session = Depends(get_db)):
|
def get_task(task_code: str, db: Session = Depends(get_db)):
|
||||||
task = _resolve_task(db, task_id)
|
task = _resolve_task(db, task_code)
|
||||||
return _serialize_task(db, task)
|
return _serialize_task(db, task)
|
||||||
|
|
||||||
|
|
||||||
@router.patch("/tasks/{task_id}", response_model=schemas.TaskResponse)
|
@router.patch("/tasks/{task_code}", response_model=schemas.TaskResponse)
|
||||||
def update_task(task_id: str, task_update: schemas.TaskUpdate, db: Session = Depends(get_db), current_user: models.User = Depends(get_current_user_or_apikey)):
|
def update_task(task_code: str, task_update: schemas.TaskUpdate, db: Session = Depends(get_db), current_user: models.User = Depends(get_current_user_or_apikey)):
|
||||||
task = _resolve_task(db, task_id)
|
task = _resolve_task(db, task_code)
|
||||||
|
|
||||||
# P5.7: status-based edit restrictions
|
# P5.7: status-based edit restrictions
|
||||||
current_status = task.status.value if hasattr(task.status, 'value') else task.status
|
current_status = task.status.value if hasattr(task.status, 'value') else task.status
|
||||||
@@ -437,9 +425,9 @@ def update_task(task_id: str, task_update: schemas.TaskUpdate, db: Session = Dep
|
|||||||
return _serialize_task(db, task)
|
return _serialize_task(db, task)
|
||||||
|
|
||||||
|
|
||||||
@router.delete("/tasks/{task_id}", status_code=status.HTTP_204_NO_CONTENT)
|
@router.delete("/tasks/{task_code}", status_code=status.HTTP_204_NO_CONTENT)
|
||||||
def delete_task(task_id: str, db: Session = Depends(get_db), current_user: models.User = Depends(get_current_user_or_apikey)):
|
def delete_task(task_code: str, db: Session = Depends(get_db), current_user: models.User = Depends(get_current_user_or_apikey)):
|
||||||
task = _resolve_task(db, task_id)
|
task = _resolve_task(db, task_code)
|
||||||
check_project_role(db, current_user.id, task.project_id, min_role="mgr")
|
check_project_role(db, current_user.id, task.project_id, min_role="mgr")
|
||||||
log_activity(db, "task.deleted", "task", task.id, current_user.id, {"title": task.title})
|
log_activity(db, "task.deleted", "task", task.id, current_user.id, {"title": task.title})
|
||||||
db.delete(task)
|
db.delete(task)
|
||||||
@@ -454,9 +442,9 @@ class TransitionBody(BaseModel):
|
|||||||
comment: Optional[str] = None
|
comment: Optional[str] = None
|
||||||
|
|
||||||
|
|
||||||
@router.post("/tasks/{task_id}/transition", response_model=schemas.TaskResponse)
|
@router.post("/tasks/{task_code}/transition", response_model=schemas.TaskResponse)
|
||||||
def transition_task(
|
def transition_task(
|
||||||
task_id: str,
|
task_code: str,
|
||||||
bg: BackgroundTasks,
|
bg: BackgroundTasks,
|
||||||
new_status: str | None = None,
|
new_status: str | None = None,
|
||||||
body: TransitionBody = None,
|
body: TransitionBody = None,
|
||||||
@@ -467,7 +455,7 @@ def transition_task(
|
|||||||
valid_statuses = [s.value for s in TaskStatus]
|
valid_statuses = [s.value for s in TaskStatus]
|
||||||
if new_status not in valid_statuses:
|
if new_status not in valid_statuses:
|
||||||
raise HTTPException(status_code=400, detail=f"Invalid status. Must be one of: {valid_statuses}")
|
raise HTTPException(status_code=400, detail=f"Invalid status. Must be one of: {valid_statuses}")
|
||||||
task = _resolve_task(db, task_id)
|
task = _resolve_task(db, task_code)
|
||||||
old_status = task.status.value if hasattr(task.status, 'value') else task.status
|
old_status = task.status.value if hasattr(task.status, 'value') else task.status
|
||||||
|
|
||||||
# P5.1: enforce state-machine
|
# P5.1: enforce state-machine
|
||||||
@@ -547,18 +535,18 @@ def transition_task(
|
|||||||
|
|
||||||
event = "task.closed" if new_status == "closed" else "task.updated"
|
event = "task.closed" if new_status == "closed" else "task.updated"
|
||||||
bg.add_task(fire_webhooks_sync, event,
|
bg.add_task(fire_webhooks_sync, event,
|
||||||
{"task_id": task.id, "title": task.title, "old_status": old_status, "new_status": new_status},
|
{"task_code": task.task_code, "title": task.title, "old_status": old_status, "new_status": new_status},
|
||||||
task.project_id, db)
|
task.project_id, db)
|
||||||
return _serialize_task(db, task)
|
return _serialize_task(db, task)
|
||||||
|
|
||||||
|
|
||||||
@router.post("/tasks/{task_id}/take", response_model=schemas.TaskResponse)
|
@router.post("/tasks/{task_code}/take", response_model=schemas.TaskResponse)
|
||||||
def take_task(
|
def take_task(
|
||||||
task_id: str,
|
task_code: str,
|
||||||
db: Session = Depends(get_db),
|
db: Session = Depends(get_db),
|
||||||
current_user: models.User = Depends(get_current_user_or_apikey),
|
current_user: models.User = Depends(get_current_user_or_apikey),
|
||||||
):
|
):
|
||||||
task = _find_task_by_id_or_code(db, task_id)
|
task = _find_task_by_code(db, task_code)
|
||||||
if not task:
|
if not task:
|
||||||
raise HTTPException(status_code=404, detail="Task not found")
|
raise HTTPException(status_code=404, detail="Task not found")
|
||||||
|
|
||||||
@@ -577,7 +565,7 @@ def take_task(
|
|||||||
db,
|
db,
|
||||||
current_user.id,
|
current_user.id,
|
||||||
"task.assigned",
|
"task.assigned",
|
||||||
f"Task {task.task_code or task.id} assigned to you",
|
f"Task {task.task_code} assigned to you",
|
||||||
f"'{task.title}' has been assigned to you.",
|
f"'{task.title}' has been assigned to you.",
|
||||||
"task",
|
"task",
|
||||||
task.id,
|
task.id,
|
||||||
@@ -587,9 +575,11 @@ def take_task(
|
|||||||
|
|
||||||
# ---- Assignment ----
|
# ---- Assignment ----
|
||||||
|
|
||||||
@router.post("/tasks/{task_id}/assign")
|
@router.post("/tasks/{task_code}/assign")
|
||||||
def assign_task(task_id: str, assignee_id: int, db: Session = Depends(get_db)):
|
def assign_task(task_code: str, assignee_id: int, db: Session = Depends(get_db),
|
||||||
task = _resolve_task(db, task_id)
|
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()
|
user = db.query(models.User).filter(models.User.id == assignee_id).first()
|
||||||
if not user:
|
if not user:
|
||||||
raise HTTPException(status_code=404, detail="User not found")
|
raise HTTPException(status_code=404, detail="User not found")
|
||||||
@@ -597,33 +587,33 @@ def assign_task(task_id: str, assignee_id: int, db: Session = Depends(get_db)):
|
|||||||
db.commit()
|
db.commit()
|
||||||
db.refresh(task)
|
db.refresh(task)
|
||||||
_notify_user(db, assignee_id, "task.assigned",
|
_notify_user(db, assignee_id, "task.assigned",
|
||||||
f"Task #{task.id} assigned to you",
|
f"Task {task.task_code} assigned to you",
|
||||||
f"'{task.title}' has been assigned to you.", "task", task.id)
|
f"'{task.title}' has been assigned to you.", "task", task.id)
|
||||||
return {"task_id": task.id, "assignee_id": assignee_id, "title": task.title}
|
return {"task_code": task.task_code, "assignee_id": assignee_id, "title": task.title}
|
||||||
|
|
||||||
|
|
||||||
# ---- Tags ----
|
# ---- Tags ----
|
||||||
|
|
||||||
@router.post("/tasks/{task_id}/tags")
|
@router.post("/tasks/{task_code}/tags")
|
||||||
def add_tag(task_id: str, tag: str, db: Session = Depends(get_db)):
|
def add_tag(task_code: str, tag: str, db: Session = Depends(get_db)):
|
||||||
task = _resolve_task(db, task_id)
|
task = _resolve_task(db, task_code)
|
||||||
current = set(task.tags.split(",")) if task.tags else set()
|
current = set(task.tags.split(",")) if task.tags else set()
|
||||||
current.add(tag.strip())
|
current.add(tag.strip())
|
||||||
current.discard("")
|
current.discard("")
|
||||||
task.tags = ",".join(sorted(current))
|
task.tags = ",".join(sorted(current))
|
||||||
db.commit()
|
db.commit()
|
||||||
return {"task_id": task_id, "tags": list(current)}
|
return {"task_code": task.task_code, "tags": list(current)}
|
||||||
|
|
||||||
|
|
||||||
@router.delete("/tasks/{task_id}/tags")
|
@router.delete("/tasks/{task_code}/tags")
|
||||||
def remove_tag(task_id: str, tag: str, db: Session = Depends(get_db)):
|
def remove_tag(task_code: str, tag: str, db: Session = Depends(get_db)):
|
||||||
task = _resolve_task(db, task_id)
|
task = _resolve_task(db, task_code)
|
||||||
current = set(task.tags.split(",")) if task.tags else set()
|
current = set(task.tags.split(",")) if task.tags else set()
|
||||||
current.discard(tag.strip())
|
current.discard(tag.strip())
|
||||||
current.discard("")
|
current.discard("")
|
||||||
task.tags = ",".join(sorted(current)) if current else None
|
task.tags = ",".join(sorted(current)) if current else None
|
||||||
db.commit()
|
db.commit()
|
||||||
return {"task_id": task_id, "tags": list(current)}
|
return {"task_code": task.task_code, "tags": list(current)}
|
||||||
|
|
||||||
|
|
||||||
@router.get("/tags")
|
@router.get("/tags")
|
||||||
@@ -643,12 +633,12 @@ def list_all_tags(project_id: int = None, db: Session = Depends(get_db)):
|
|||||||
# ---- Batch ----
|
# ---- Batch ----
|
||||||
|
|
||||||
class BatchAssign(BaseModel):
|
class BatchAssign(BaseModel):
|
||||||
task_ids: List[int]
|
task_codes: List[str]
|
||||||
assignee_id: int
|
assignee_id: int
|
||||||
|
|
||||||
|
|
||||||
class BatchTransitionBody(BaseModel):
|
class BatchTransitionBody(BaseModel):
|
||||||
task_ids: List[int]
|
task_codes: List[str]
|
||||||
new_status: str
|
new_status: str
|
||||||
comment: Optional[str] = None
|
comment: Optional[str] = None
|
||||||
|
|
||||||
@@ -665,17 +655,17 @@ def batch_transition(
|
|||||||
raise HTTPException(status_code=400, detail="Invalid status")
|
raise HTTPException(status_code=400, detail="Invalid status")
|
||||||
updated = []
|
updated = []
|
||||||
skipped = []
|
skipped = []
|
||||||
for task_id in data.task_ids:
|
for task_code in data.task_codes:
|
||||||
task = db.query(Task).filter(Task.id == task_id).first()
|
task = db.query(Task).filter(Task.task_code == task_code).first()
|
||||||
if not task:
|
if not task:
|
||||||
skipped.append({"id": task_id, "title": None, "old": None,
|
skipped.append({"task_code": task_code, "title": None, "old": None,
|
||||||
"reason": "Task not found"})
|
"reason": "Task not found"})
|
||||||
continue
|
continue
|
||||||
old_status = task.status.value if hasattr(task.status, 'value') else task.status
|
old_status = task.status.value if hasattr(task.status, 'value') else task.status
|
||||||
# P5.1: state-machine check
|
# P5.1: state-machine check
|
||||||
allowed = VALID_TRANSITIONS.get(old_status, set())
|
allowed = VALID_TRANSITIONS.get(old_status, set())
|
||||||
if data.new_status not in allowed:
|
if data.new_status not in allowed:
|
||||||
skipped.append({"id": task.id, "title": task.title, "old": old_status,
|
skipped.append({"task_code": task.task_code, "title": task.title, "old": old_status,
|
||||||
"reason": f"Cannot transition from '{old_status}' to '{data.new_status}'"})
|
"reason": f"Cannot transition from '{old_status}' to '{data.new_status}'"})
|
||||||
continue
|
continue
|
||||||
|
|
||||||
@@ -685,23 +675,23 @@ def batch_transition(
|
|||||||
if milestone:
|
if milestone:
|
||||||
ms_status = milestone.status.value if hasattr(milestone.status, 'value') else milestone.status
|
ms_status = milestone.status.value if hasattr(milestone.status, 'value') else milestone.status
|
||||||
if ms_status != "undergoing":
|
if ms_status != "undergoing":
|
||||||
skipped.append({"id": task.id, "title": task.title, "old": old_status,
|
skipped.append({"task_code": task.task_code, "title": task.title, "old": old_status,
|
||||||
"reason": f"Milestone is '{ms_status}', must be 'undergoing'"})
|
"reason": f"Milestone is '{ms_status}', must be 'undergoing'"})
|
||||||
continue
|
continue
|
||||||
dep_result = check_task_deps(db, task.depend_on)
|
dep_result = check_task_deps(db, task.depend_on)
|
||||||
if not dep_result.ok:
|
if not dep_result.ok:
|
||||||
skipped.append({"id": task.id, "title": task.title, "old": old_status,
|
skipped.append({"task_code": task.task_code, "title": task.title, "old": old_status,
|
||||||
"reason": dep_result.reason})
|
"reason": dep_result.reason})
|
||||||
continue
|
continue
|
||||||
|
|
||||||
# P5.3: open → undergoing requires assignee == current_user
|
# P5.3: open → undergoing requires assignee == current_user
|
||||||
if old_status == "open" and data.new_status == "undergoing":
|
if old_status == "open" and data.new_status == "undergoing":
|
||||||
if not task.assignee_id:
|
if not task.assignee_id:
|
||||||
skipped.append({"id": task.id, "title": task.title, "old": old_status,
|
skipped.append({"task_code": task.task_code, "title": task.title, "old": old_status,
|
||||||
"reason": "Assignee must be set before starting"})
|
"reason": "Assignee must be set before starting"})
|
||||||
continue
|
continue
|
||||||
if current_user.id != task.assignee_id:
|
if current_user.id != task.assignee_id:
|
||||||
skipped.append({"id": task.id, "title": task.title, "old": old_status,
|
skipped.append({"task_code": task.task_code, "title": task.title, "old": old_status,
|
||||||
"reason": "Only the assigned user can start this task"})
|
"reason": "Only the assigned user can start this task"})
|
||||||
continue
|
continue
|
||||||
|
|
||||||
@@ -709,11 +699,11 @@ def batch_transition(
|
|||||||
if old_status == "undergoing" and data.new_status == "completed":
|
if old_status == "undergoing" and data.new_status == "completed":
|
||||||
comment_text = data.comment
|
comment_text = data.comment
|
||||||
if not comment_text or not comment_text.strip():
|
if not comment_text or not comment_text.strip():
|
||||||
skipped.append({"id": task.id, "title": task.title, "old": old_status,
|
skipped.append({"task_code": task.task_code, "title": task.title, "old": old_status,
|
||||||
"reason": "A completion comment is required"})
|
"reason": "A completion comment is required"})
|
||||||
continue
|
continue
|
||||||
if task.assignee_id and current_user.id != task.assignee_id:
|
if task.assignee_id and current_user.id != task.assignee_id:
|
||||||
skipped.append({"id": task.id, "title": task.title, "old": old_status,
|
skipped.append({"task_code": task.task_code, "title": task.title, "old": old_status,
|
||||||
"reason": "Only the assigned user can complete this task"})
|
"reason": "Only the assigned user can complete this task"})
|
||||||
continue
|
continue
|
||||||
|
|
||||||
@@ -722,7 +712,7 @@ def batch_transition(
|
|||||||
try:
|
try:
|
||||||
check_permission(db, current_user.id, task.project_id, "task.close")
|
check_permission(db, current_user.id, task.project_id, "task.close")
|
||||||
except HTTPException:
|
except HTTPException:
|
||||||
skipped.append({"id": task.id, "title": task.title, "old": old_status,
|
skipped.append({"task_code": task.task_code, "title": task.title, "old": old_status,
|
||||||
"reason": "Missing 'task.close' permission"})
|
"reason": "Missing 'task.close' permission"})
|
||||||
continue
|
continue
|
||||||
|
|
||||||
@@ -732,7 +722,7 @@ def batch_transition(
|
|||||||
try:
|
try:
|
||||||
check_permission(db, current_user.id, task.project_id, perm)
|
check_permission(db, current_user.id, task.project_id, perm)
|
||||||
except HTTPException:
|
except HTTPException:
|
||||||
skipped.append({"id": task.id, "title": task.title, "old": old_status,
|
skipped.append({"task_code": task.task_code, "title": task.title, "old": old_status,
|
||||||
"reason": f"Missing '{perm}' permission"})
|
"reason": f"Missing '{perm}' permission"})
|
||||||
continue
|
continue
|
||||||
task.finished_on = None
|
task.finished_on = None
|
||||||
@@ -742,7 +732,7 @@ def batch_transition(
|
|||||||
if data.new_status in ("closed", "completed") and not task.finished_on:
|
if data.new_status in ("closed", "completed") and not task.finished_on:
|
||||||
task.finished_on = datetime.utcnow()
|
task.finished_on = datetime.utcnow()
|
||||||
task.status = data.new_status
|
task.status = data.new_status
|
||||||
updated.append({"id": task.id, "title": task.title, "old": old_status, "new": data.new_status})
|
updated.append({"task_code": task.task_code, "title": task.title, "old": old_status, "new": data.new_status})
|
||||||
|
|
||||||
# Activity log per task
|
# Activity log per task
|
||||||
log_activity(db, f"task.transition.{data.new_status}", "task", task.id, current_user.id,
|
log_activity(db, f"task.transition.{data.new_status}", "task", task.id, current_user.id,
|
||||||
@@ -762,7 +752,7 @@ def batch_transition(
|
|||||||
# P3.5: auto-complete milestone for any completed task
|
# P3.5: auto-complete milestone for any completed task
|
||||||
for u in updated:
|
for u in updated:
|
||||||
if u["new"] == "completed":
|
if u["new"] == "completed":
|
||||||
t = db.query(Task).filter(Task.id == u["id"]).first()
|
t = db.query(Task).filter(Task.task_code == u["task_code"]).first()
|
||||||
if t:
|
if t:
|
||||||
from app.api.routers.milestone_actions import try_auto_complete_milestone
|
from app.api.routers.milestone_actions import try_auto_complete_milestone
|
||||||
try_auto_complete_milestone(db, t, user_id=current_user.id)
|
try_auto_complete_milestone(db, t, user_id=current_user.id)
|
||||||
@@ -777,30 +767,34 @@ def batch_transition(
|
|||||||
|
|
||||||
|
|
||||||
@router.post("/tasks/batch/assign")
|
@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()
|
user = db.query(models.User).filter(models.User.id == data.assignee_id).first()
|
||||||
if not user:
|
if not user:
|
||||||
raise HTTPException(status_code=404, detail="Assignee not found")
|
raise HTTPException(status_code=404, detail="Assignee not found")
|
||||||
updated = []
|
updated = []
|
||||||
for task_id in data.task_ids:
|
for task_code in data.task_codes:
|
||||||
task = db.query(Task).filter(Task.id == task_id).first()
|
task = db.query(Task).filter(Task.task_code == task_code).first()
|
||||||
if task:
|
if task:
|
||||||
|
ensure_can_edit_task(db, current_user.id, task)
|
||||||
task.assignee_id = data.assignee_id
|
task.assignee_id = data.assignee_id
|
||||||
updated.append(task_id)
|
updated.append(task.task_code)
|
||||||
db.commit()
|
db.commit()
|
||||||
return {"updated": len(updated), "task_ids": updated, "assignee_id": data.assignee_id}
|
return {"updated": len(updated), "task_codes": updated, "assignee_id": data.assignee_id}
|
||||||
|
|
||||||
|
|
||||||
# ---- Search ----
|
# ---- Search ----
|
||||||
|
|
||||||
@router.get("/search/tasks")
|
@router.get("/search/tasks")
|
||||||
def search_tasks(q: str, project_id: int = None, page: int = 1, page_size: int = 50,
|
def search_tasks(q: str, project_code: str = None, page: int = 1, page_size: int = 50,
|
||||||
db: Session = Depends(get_db)):
|
db: Session = Depends(get_db)):
|
||||||
query = db.query(Task).filter(
|
query = db.query(Task).filter(
|
||||||
(Task.title.contains(q)) | (Task.description.contains(q))
|
(Task.title.contains(q)) | (Task.description.contains(q))
|
||||||
)
|
)
|
||||||
if project_id:
|
if project_code:
|
||||||
query = query.filter(Task.project_id == project_id)
|
project_id = _resolve_project_id(db, project_code)
|
||||||
|
if project_id:
|
||||||
|
query = query.filter(Task.project_id == project_id)
|
||||||
total = query.count()
|
total = query.count()
|
||||||
page = max(1, page)
|
page = max(1, page)
|
||||||
page_size = min(max(1, page_size), 200)
|
page_size = min(max(1, page_size), 200)
|
||||||
|
|||||||
@@ -7,8 +7,9 @@ from pydantic import BaseModel
|
|||||||
from sqlalchemy.exc import IntegrityError
|
from sqlalchemy.exc import IntegrityError
|
||||||
from sqlalchemy.orm import Session
|
from sqlalchemy.orm import Session
|
||||||
|
|
||||||
from app.api.deps import get_current_user, get_password_hash
|
from app.api.deps import get_current_user, get_current_user_or_apikey, get_password_hash
|
||||||
from app.core.config import get_db
|
from app.core.config import get_db, settings
|
||||||
|
from app.init_wizard import DELETED_USER_USERNAME
|
||||||
from app.models import models
|
from app.models import models
|
||||||
from app.models.agent import Agent
|
from app.models.agent import Agent
|
||||||
from app.models.role_permission import Permission, Role, RolePermission
|
from app.models.role_permission import Permission, Role, RolePermission
|
||||||
@@ -30,6 +31,9 @@ def _user_response(user: models.User) -> dict:
|
|||||||
"role_id": user.role_id,
|
"role_id": user.role_id,
|
||||||
"role_name": user.role_name,
|
"role_name": user.role_name,
|
||||||
"agent_id": user.agent.agent_id if user.agent else None,
|
"agent_id": user.agent.agent_id if user.agent else None,
|
||||||
|
"discord_user_id": user.discord_user_id,
|
||||||
|
"oidc_issuer": user.oidc_issuer,
|
||||||
|
"oidc_subject": user.oidc_subject,
|
||||||
"created_at": user.created_at,
|
"created_at": user.created_at,
|
||||||
}
|
}
|
||||||
return data
|
return data
|
||||||
@@ -57,7 +61,7 @@ def _has_global_permission(db: Session, user: models.User, permission_name: str)
|
|||||||
|
|
||||||
def require_account_creator(
|
def require_account_creator(
|
||||||
db: Session = Depends(get_db),
|
db: Session = Depends(get_db),
|
||||||
current_user: models.User = Depends(get_current_user),
|
current_user: models.User = Depends(get_current_user_or_apikey),
|
||||||
):
|
):
|
||||||
if current_user.is_admin or _has_global_permission(db, current_user, "account.create"):
|
if current_user.is_admin or _has_global_permission(db, current_user, "account.create"):
|
||||||
return current_user
|
return current_user
|
||||||
@@ -109,11 +113,18 @@ def create_user(
|
|||||||
raise HTTPException(status_code=400, detail="agent_id already in use")
|
raise HTTPException(status_code=400, detail="agent_id already in use")
|
||||||
|
|
||||||
assigned_role = _resolve_user_role(db, user.role_id)
|
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(
|
db_user = models.User(
|
||||||
username=user.username,
|
username=user.username,
|
||||||
email=user.email,
|
email=user.email,
|
||||||
full_name=user.full_name,
|
full_name=user.full_name,
|
||||||
|
discord_user_id=user.discord_user_id,
|
||||||
hashed_password=hashed_password,
|
hashed_password=hashed_password,
|
||||||
is_admin=False,
|
is_admin=False,
|
||||||
is_active=True,
|
is_active=True,
|
||||||
@@ -188,7 +199,7 @@ def update_user(
|
|||||||
if payload.full_name is not None:
|
if payload.full_name is not None:
|
||||||
user.full_name = payload.full_name
|
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)
|
user.hashed_password = get_password_hash(payload.password)
|
||||||
|
|
||||||
if payload.role_id is not None:
|
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")
|
raise HTTPException(status_code=400, detail="You cannot deactivate your own account")
|
||||||
user.is_active = payload.is_active
|
user.is_active = payload.is_active
|
||||||
|
|
||||||
|
if payload.discord_user_id is not None:
|
||||||
|
user.discord_user_id = payload.discord_user_id or None
|
||||||
|
|
||||||
db.commit()
|
db.commit()
|
||||||
db.refresh(user)
|
db.refresh(user)
|
||||||
return _user_response(user)
|
return _user_response(user)
|
||||||
|
|
||||||
|
|
||||||
|
@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)
|
@router.delete("/{identifier}", status_code=status.HTTP_204_NO_CONTENT)
|
||||||
def delete_user(
|
def delete_user(
|
||||||
identifier: str,
|
identifier: str,
|
||||||
@@ -218,17 +377,26 @@ def delete_user(
|
|||||||
raise HTTPException(status_code=404, detail="User not found")
|
raise HTTPException(status_code=404, detail="User not found")
|
||||||
if current_user.id == user.id:
|
if current_user.id == user.id:
|
||||||
raise HTTPException(status_code=400, detail="You cannot delete your own account")
|
raise HTTPException(status_code=400, detail="You cannot delete your own account")
|
||||||
# Protect built-in accounts from deletion
|
|
||||||
if user.is_admin:
|
if user.is_admin:
|
||||||
raise HTTPException(status_code=400, detail="Admin accounts cannot be deleted")
|
raise HTTPException(status_code=400, detail="Admin accounts cannot be deleted")
|
||||||
if user.username == "acc-mgr":
|
if user.username in _BUILTIN_USERNAMES:
|
||||||
raise HTTPException(status_code=400, detail="The acc-mgr account is a built-in account and cannot be deleted")
|
raise HTTPException(
|
||||||
try:
|
status_code=400,
|
||||||
db.delete(user)
|
detail=f"The {user.username} account is a built-in account and cannot be deleted",
|
||||||
db.commit()
|
)
|
||||||
except IntegrityError:
|
|
||||||
db.rollback()
|
deleted_user = db.query(models.User).filter(
|
||||||
raise HTTPException(status_code=400, detail="User has related records. Deactivate the account instead.")
|
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
|
return None
|
||||||
|
|
||||||
|
|
||||||
@@ -236,7 +404,7 @@ def delete_user(
|
|||||||
def reset_user_apikey(
|
def reset_user_apikey(
|
||||||
identifier: str,
|
identifier: str,
|
||||||
db: Session = Depends(get_db),
|
db: Session = Depends(get_db),
|
||||||
current_user: models.User = Depends(get_current_user),
|
current_user: models.User = Depends(get_current_user_or_apikey),
|
||||||
):
|
):
|
||||||
"""Reset (regenerate) a user's API key.
|
"""Reset (regenerate) a user's API key.
|
||||||
|
|
||||||
@@ -244,6 +412,8 @@ def reset_user_apikey(
|
|||||||
- user.reset-apikey: can reset any user's API key
|
- user.reset-apikey: can reset any user's API key
|
||||||
- user.reset-self-apikey: can reset only own API key
|
- user.reset-self-apikey: can reset only own API key
|
||||||
- admin: can reset any user's API key
|
- admin: can reset any user's API key
|
||||||
|
|
||||||
|
Accepts both OAuth2 Bearer token and X-API-Key authentication.
|
||||||
"""
|
"""
|
||||||
import secrets
|
import secrets
|
||||||
from app.models.apikey import APIKey
|
from app.models.apikey import APIKey
|
||||||
@@ -317,3 +487,92 @@ def list_user_worklogs(
|
|||||||
if current_user.id != user.id and not current_user.is_admin:
|
if current_user.id != user.id and not current_user.is_admin:
|
||||||
raise HTTPException(status_code=403, detail="Forbidden")
|
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()
|
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)
|
||||||
|
|||||||
@@ -5,11 +5,13 @@ from fastapi import APIRouter, Depends, HTTPException, status, BackgroundTasks
|
|||||||
from sqlalchemy.orm import Session
|
from sqlalchemy.orm import Session
|
||||||
|
|
||||||
from app.core.config import get_db
|
from app.core.config import get_db
|
||||||
|
from app.api.deps import require_admin
|
||||||
from app.models.webhook import Webhook, WebhookLog
|
from app.models.webhook import Webhook, WebhookLog
|
||||||
from app.schemas.webhook import WebhookCreate, WebhookUpdate, WebhookResponse, WebhookLogResponse
|
from app.schemas.webhook import WebhookCreate, WebhookUpdate, WebhookResponse, WebhookLogResponse
|
||||||
from app.services.webhook import fire_webhooks_sync
|
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)
|
@router.post("", response_model=WebhookResponse, status_code=status.HTTP_201_CREATED)
|
||||||
|
|||||||
@@ -38,12 +38,43 @@ class Settings(BaseSettings):
|
|||||||
ALGORITHM: str = "HS256"
|
ALGORITHM: str = "HS256"
|
||||||
ACCESS_TOKEN_EXPIRE_MINUTES: int = 30
|
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:
|
class Config:
|
||||||
env_file = ".env"
|
env_file = ".env"
|
||||||
|
|
||||||
|
|
||||||
settings = Settings()
|
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
|
# Resolve DB URL: wizard config volume > env > default
|
||||||
_db_url = _resolve_db_url(settings.DATABASE_URL)
|
_db_url = _resolve_db_url(settings.DATABASE_URL)
|
||||||
engine = create_engine(_db_url, pool_pre_ping=True)
|
engine = create_engine(_db_url, pool_pre_ping=True)
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ from sqlalchemy.orm import Session
|
|||||||
|
|
||||||
from app.models import models
|
from app.models import models
|
||||||
from app.models.role_permission import Role, Permission, RolePermission
|
from app.models.role_permission import Role, Permission, RolePermission
|
||||||
|
from app.models.oidc_settings import OidcSettings
|
||||||
from app.api.deps import get_password_hash
|
from app.api.deps import get_password_hash
|
||||||
|
|
||||||
logger = logging.getLogger("harborforge.init")
|
logger = logging.getLogger("harborforge.init")
|
||||||
@@ -132,6 +133,10 @@ DEFAULT_PERMISSIONS = [
|
|||||||
# Monitor
|
# Monitor
|
||||||
("monitor.read", "View monitor", "monitor"),
|
("monitor.read", "View monitor", "monitor"),
|
||||||
("monitor.manage", "Manage monitor", "monitor"),
|
("monitor.manage", "Manage monitor", "monitor"),
|
||||||
|
# Calendar
|
||||||
|
("calendar.read", "View calendar slots and plans", "calendar"),
|
||||||
|
("calendar.write", "Create and edit calendar slots and plans", "calendar"),
|
||||||
|
("calendar.manage", "Manage calendar settings and workload policies", "calendar"),
|
||||||
# Webhook
|
# Webhook
|
||||||
("webhook.manage", "Manage webhooks", "admin"),
|
("webhook.manage", "Manage webhooks", "admin"),
|
||||||
]
|
]
|
||||||
@@ -168,6 +173,7 @@ _MGR_PERMISSIONS = {
|
|||||||
"task.close", "task.reopen_closed", "task.reopen_completed",
|
"task.close", "task.reopen_closed", "task.reopen_completed",
|
||||||
"propose.accept", "propose.reject", "propose.reopen",
|
"propose.accept", "propose.reject", "propose.reopen",
|
||||||
"monitor.read",
|
"monitor.read",
|
||||||
|
"calendar.read", "calendar.write", "calendar.manage",
|
||||||
"user.reset-self-apikey",
|
"user.reset-self-apikey",
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -178,11 +184,13 @@ _DEV_PERMISSIONS = {
|
|||||||
"milestone.read",
|
"milestone.read",
|
||||||
"task.close", "task.reopen_closed", "task.reopen_completed",
|
"task.close", "task.reopen_closed", "task.reopen_completed",
|
||||||
"monitor.read",
|
"monitor.read",
|
||||||
|
"calendar.read", "calendar.write",
|
||||||
"user.reset-self-apikey",
|
"user.reset-self-apikey",
|
||||||
}
|
}
|
||||||
|
|
||||||
_ACCOUNT_MANAGER_PERMISSIONS = {
|
_ACCOUNT_MANAGER_PERMISSIONS = {
|
||||||
"account.create",
|
"account.create",
|
||||||
|
"user.reset-apikey",
|
||||||
}
|
}
|
||||||
|
|
||||||
# Role definitions: (name, description, permission_set)
|
# Role definitions: (name, description, permission_set)
|
||||||
@@ -288,6 +296,83 @@ def init_acc_mgr_user(db: Session) -> models.User | None:
|
|||||||
return user
|
return user
|
||||||
|
|
||||||
|
|
||||||
|
DELETED_USER_USERNAME = "deleted-user"
|
||||||
|
|
||||||
|
|
||||||
|
def init_deleted_user(db: Session) -> models.User | None:
|
||||||
|
"""Create the built-in deleted-user if not exists.
|
||||||
|
|
||||||
|
This user serves as a foreign key sink: when a real user is deleted,
|
||||||
|
all references are reassigned here instead of cascading deletes.
|
||||||
|
It has no role (no permissions) and cannot log in.
|
||||||
|
"""
|
||||||
|
existing = db.query(models.User).filter(
|
||||||
|
models.User.username == DELETED_USER_USERNAME
|
||||||
|
).first()
|
||||||
|
if existing:
|
||||||
|
logger.info("deleted-user already exists (id=%d), skipping", existing.id)
|
||||||
|
return existing
|
||||||
|
|
||||||
|
user = models.User(
|
||||||
|
username=DELETED_USER_USERNAME,
|
||||||
|
email="deleted-user@harborforge.internal",
|
||||||
|
full_name="Deleted User",
|
||||||
|
hashed_password=None,
|
||||||
|
is_admin=False,
|
||||||
|
is_active=False,
|
||||||
|
role_id=None,
|
||||||
|
)
|
||||||
|
db.add(user)
|
||||||
|
db.commit()
|
||||||
|
db.refresh(user)
|
||||||
|
logger.info("Created deleted-user (id=%d)", user.id)
|
||||||
|
return user
|
||||||
|
|
||||||
|
|
||||||
|
def 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:
|
def run_init(db: Session) -> None:
|
||||||
"""Main initialization entry point. Reads config from shared volume."""
|
"""Main initialization entry point. Reads config from shared volume."""
|
||||||
config = load_config()
|
config = load_config()
|
||||||
@@ -312,9 +397,15 @@ def run_init(db: Session) -> None:
|
|||||||
# Built-in acc-mgr user (after roles are created)
|
# Built-in acc-mgr user (after roles are created)
|
||||||
init_acc_mgr_user(db)
|
init_acc_mgr_user(db)
|
||||||
|
|
||||||
|
# Built-in deleted-user (foreign key sink for deleted accounts)
|
||||||
|
init_deleted_user(db)
|
||||||
|
|
||||||
# Default project
|
# Default project
|
||||||
project_cfg = config.get("default_project")
|
project_cfg = config.get("default_project")
|
||||||
if project_cfg and admin_user:
|
if project_cfg and admin_user:
|
||||||
init_default_project(db, project_cfg, admin_user.id, admin_user.username)
|
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")
|
logger.info("Initialization complete")
|
||||||
|
|||||||
117
app/main.py
117
app/main.py
@@ -1,6 +1,9 @@
|
|||||||
"""HarborForge API — Agent/人类协同任务管理平台"""
|
"""HarborForge API — Agent/人类协同任务管理平台"""
|
||||||
from fastapi import FastAPI
|
from fastapi import FastAPI
|
||||||
from fastapi.middleware.cors import CORSMiddleware
|
from fastapi.middleware.cors import CORSMiddleware
|
||||||
|
from starlette.middleware.sessions import SessionMiddleware
|
||||||
|
|
||||||
|
from app.core.config import settings
|
||||||
|
|
||||||
app = FastAPI(
|
app = FastAPI(
|
||||||
title="HarborForge API",
|
title="HarborForge API",
|
||||||
@@ -17,6 +20,17 @@ app.add_middleware(
|
|||||||
allow_headers=["*"],
|
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)
|
# Health & version (kept at top level)
|
||||||
@app.get("/health", tags=["System"])
|
@app.get("/health", tags=["System"])
|
||||||
def health_check():
|
def health_check():
|
||||||
@@ -42,6 +56,7 @@ def config_status():
|
|||||||
return {
|
return {
|
||||||
"initialized": cfg.get("initialized", False),
|
"initialized": cfg.get("initialized", False),
|
||||||
"backend_url": cfg.get("backend_url"),
|
"backend_url": cfg.get("backend_url"),
|
||||||
|
"discord": cfg.get("discord") or {},
|
||||||
}
|
}
|
||||||
except Exception:
|
except Exception:
|
||||||
return {"initialized": False}
|
return {"initialized": False}
|
||||||
@@ -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.milestone_actions import router as milestone_actions_router
|
||||||
from app.api.routers.meetings import router as meetings_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.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.calendar import router as calendar_router
|
||||||
|
from app.api.routers.oidc import router as oidc_router
|
||||||
|
|
||||||
app.include_router(auth_router)
|
app.include_router(auth_router)
|
||||||
|
app.include_router(oidc_router)
|
||||||
app.include_router(tasks_router)
|
app.include_router(tasks_router)
|
||||||
app.include_router(projects_router)
|
app.include_router(projects_router)
|
||||||
app.include_router(users_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(milestone_actions_router)
|
||||||
app.include_router(meetings_router)
|
app.include_router(meetings_router)
|
||||||
app.include_router(essentials_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)
|
app.include_router(calendar_router)
|
||||||
|
|
||||||
|
|
||||||
@@ -96,6 +117,25 @@ def _migrate_schema():
|
|||||||
{"column_name": column_name},
|
{"column_name": column_name},
|
||||||
).fetchone() is not None
|
).fetchone() is not None
|
||||||
|
|
||||||
|
def _has_index(db, table_name: str, index_name: str) -> bool:
|
||||||
|
return db.execute(
|
||||||
|
text(
|
||||||
|
"""
|
||||||
|
SELECT 1
|
||||||
|
FROM information_schema.STATISTICS
|
||||||
|
WHERE TABLE_SCHEMA = DATABASE()
|
||||||
|
AND TABLE_NAME = :table_name
|
||||||
|
AND INDEX_NAME = :index_name
|
||||||
|
LIMIT 1
|
||||||
|
"""
|
||||||
|
),
|
||||||
|
{"table_name": table_name, "index_name": index_name},
|
||||||
|
).fetchone() is not None
|
||||||
|
|
||||||
|
def _ensure_unique_index(db, table_name: str, index_name: str, columns_sql: str):
|
||||||
|
if not _has_index(db, table_name, index_name):
|
||||||
|
db.execute(text(f"CREATE UNIQUE INDEX {index_name} ON {table_name} ({columns_sql})"))
|
||||||
|
|
||||||
def _drop_fk_constraints(db, table_name: str, referenced_table: str):
|
def _drop_fk_constraints(db, table_name: str, referenced_table: str):
|
||||||
rows = db.execute(text(
|
rows = db.execute(text(
|
||||||
"""
|
"""
|
||||||
@@ -139,7 +179,7 @@ def _migrate_schema():
|
|||||||
result = db.execute(text("SHOW COLUMNS FROM projects LIKE 'project_code'"))
|
result = db.execute(text("SHOW COLUMNS FROM projects LIKE 'project_code'"))
|
||||||
if not result.fetchone():
|
if not result.fetchone():
|
||||||
db.execute(text("ALTER TABLE projects ADD COLUMN project_code VARCHAR(16) NULL"))
|
db.execute(text("ALTER TABLE projects ADD COLUMN project_code VARCHAR(16) NULL"))
|
||||||
db.execute(text("CREATE UNIQUE INDEX idx_projects_project_code ON projects (project_code)"))
|
_ensure_unique_index(db, "projects", "idx_projects_project_code", "project_code")
|
||||||
|
|
||||||
# projects.owner_name
|
# projects.owner_name
|
||||||
result = db.execute(text("SHOW COLUMNS FROM projects LIKE 'owner_name'"))
|
result = db.execute(text("SHOW COLUMNS FROM projects LIKE 'owner_name'"))
|
||||||
@@ -173,6 +213,8 @@ def _migrate_schema():
|
|||||||
if not result.fetchone():
|
if not result.fetchone():
|
||||||
db.execute(text("ALTER TABLE tasks ADD COLUMN created_by_id INTEGER NULL"))
|
db.execute(text("ALTER TABLE tasks ADD COLUMN created_by_id INTEGER NULL"))
|
||||||
_ensure_fk(db, "tasks", "created_by_id", "users", "id", "fk_tasks_created_by_id")
|
_ensure_fk(db, "tasks", "created_by_id", "users", "id", "fk_tasks_created_by_id")
|
||||||
|
if _has_column(db, "tasks", "task_code"):
|
||||||
|
_ensure_unique_index(db, "tasks", "idx_tasks_task_code", "task_code")
|
||||||
|
|
||||||
# milestones creator field
|
# milestones creator field
|
||||||
result = db.execute(text("SHOW COLUMNS FROM milestones LIKE 'created_by_id'"))
|
result = db.execute(text("SHOW COLUMNS FROM milestones LIKE 'created_by_id'"))
|
||||||
@@ -202,6 +244,8 @@ def _migrate_schema():
|
|||||||
|
|
||||||
# --- Milestone status enum migration (old -> new) ---
|
# --- Milestone status enum migration (old -> new) ---
|
||||||
if _has_table(db, "milestones"):
|
if _has_table(db, "milestones"):
|
||||||
|
if _has_column(db, "milestones", "milestone_code"):
|
||||||
|
_ensure_unique_index(db, "milestones", "idx_milestones_milestone_code", "milestone_code")
|
||||||
# Alter enum column to accept new values
|
# Alter enum column to accept new values
|
||||||
db.execute(text(
|
db.execute(text(
|
||||||
"ALTER TABLE milestones MODIFY COLUMN status "
|
"ALTER TABLE milestones MODIFY COLUMN status "
|
||||||
@@ -248,6 +292,21 @@ def _migrate_schema():
|
|||||||
db.execute(text("ALTER TABLE users ADD COLUMN role_id INTEGER NULL"))
|
db.execute(text("ALTER TABLE users ADD COLUMN role_id INTEGER NULL"))
|
||||||
_ensure_fk(db, "users", "role_id", "roles", "id", "fk_users_role_id")
|
_ensure_fk(db, "users", "role_id", "roles", "id", "fk_users_role_id")
|
||||||
|
|
||||||
|
if _has_table(db, "users") and not _has_column(db, "users", "discord_user_id"):
|
||||||
|
db.execute(text("ALTER TABLE users ADD COLUMN discord_user_id VARCHAR(32) NULL"))
|
||||||
|
|
||||||
|
# --- 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 ---
|
# --- monitored_servers.api_key for heartbeat v2 ---
|
||||||
if _has_table(db, "monitored_servers") and not _has_column(db, "monitored_servers", "api_key"):
|
if _has_table(db, "monitored_servers") and not _has_column(db, "monitored_servers", "api_key"):
|
||||||
db.execute(text("ALTER TABLE monitored_servers ADD COLUMN api_key VARCHAR(64) NULL"))
|
db.execute(text("ALTER TABLE monitored_servers ADD COLUMN api_key VARCHAR(64) NULL"))
|
||||||
@@ -257,6 +316,18 @@ def _migrate_schema():
|
|||||||
if _has_table(db, "server_states") and not _has_column(db, "server_states", "plugin_version"):
|
if _has_table(db, "server_states") and not _has_column(db, "server_states", "plugin_version"):
|
||||||
db.execute(text("ALTER TABLE server_states ADD COLUMN plugin_version VARCHAR(64) NULL"))
|
db.execute(text("ALTER TABLE server_states ADD COLUMN plugin_version VARCHAR(64) NULL"))
|
||||||
|
|
||||||
|
if _has_table(db, "meetings") and _has_column(db, "meetings", "meeting_code"):
|
||||||
|
_ensure_unique_index(db, "meetings", "idx_meetings_meeting_code", "meeting_code")
|
||||||
|
|
||||||
|
if _has_table(db, "supports") and _has_column(db, "supports", "support_code"):
|
||||||
|
_ensure_unique_index(db, "supports", "idx_supports_support_code", "support_code")
|
||||||
|
|
||||||
|
if _has_table(db, "proposes") and _has_column(db, "proposes", "propose_code"):
|
||||||
|
_ensure_unique_index(db, "proposes", "idx_proposes_propose_code", "propose_code")
|
||||||
|
|
||||||
|
if _has_table(db, "essentials") and _has_column(db, "essentials", "essential_code"):
|
||||||
|
_ensure_unique_index(db, "essentials", "idx_essentials_essential_code", "essential_code")
|
||||||
|
|
||||||
# --- server_states nginx telemetry for generic monitor client ---
|
# --- server_states nginx telemetry for generic monitor client ---
|
||||||
if _has_table(db, "server_states") and not _has_column(db, "server_states", "nginx_installed"):
|
if _has_table(db, "server_states") and not _has_column(db, "server_states", "nginx_installed"):
|
||||||
db.execute(text("ALTER TABLE server_states ADD COLUMN nginx_installed BOOLEAN NULL"))
|
db.execute(text("ALTER TABLE server_states ADD COLUMN nginx_installed BOOLEAN NULL"))
|
||||||
@@ -320,6 +391,48 @@ def _migrate_schema():
|
|||||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4
|
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4
|
||||||
"""))
|
"""))
|
||||||
|
|
||||||
|
# --- time_slots: add wakeup_sent_at for Discord wakeup tracking ---
|
||||||
|
if _has_table(db, "time_slots") and not _has_column(db, "time_slots", "wakeup_sent_at"):
|
||||||
|
db.execute(text("ALTER TABLE time_slots ADD COLUMN wakeup_sent_at DATETIME NULL"))
|
||||||
|
|
||||||
|
# --- 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:00–9: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()
|
db.commit()
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
db.rollback()
|
db.rollback()
|
||||||
@@ -354,7 +467,7 @@ def _sync_default_user_roles(db):
|
|||||||
@app.on_event("startup")
|
@app.on_event("startup")
|
||||||
def startup():
|
def startup():
|
||||||
from app.core.config import Base, engine, SessionLocal
|
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)
|
Base.metadata.create_all(bind=engine)
|
||||||
_migrate_schema()
|
_migrate_schema()
|
||||||
|
|
||||||
|
|||||||
@@ -131,6 +131,15 @@ class Agent(Base):
|
|||||||
comment="rate_limit | billing — why the agent is exhausted",
|
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 ---------------------------------------------------------
|
# -- timestamps ---------------------------------------------------------
|
||||||
|
|
||||||
created_at = Column(DateTime(timezone=True), server_default=func.now())
|
created_at = Column(DateTime(timezone=True), server_default=func.now())
|
||||||
@@ -138,3 +147,4 @@ class Agent(Base):
|
|||||||
# -- relationships ------------------------------------------------------
|
# -- relationships ------------------------------------------------------
|
||||||
|
|
||||||
user = relationship("User", back_populates="agent", uselist=False)
|
user = relationship("User", back_populates="agent", uselist=False)
|
||||||
|
schedule_type = relationship("ScheduleType", lazy="joined")
|
||||||
|
|||||||
@@ -165,6 +165,12 @@ class TimeSlot(Base):
|
|||||||
comment="Lifecycle status of this slot",
|
comment="Lifecycle status of this slot",
|
||||||
)
|
)
|
||||||
|
|
||||||
|
wakeup_sent_at = Column(
|
||||||
|
DateTime(timezone=True),
|
||||||
|
nullable=True,
|
||||||
|
comment="When Discord wakeup was sent for this slot",
|
||||||
|
)
|
||||||
|
|
||||||
plan_id = Column(
|
plan_id = Column(
|
||||||
Integer,
|
Integer,
|
||||||
ForeignKey("schedule_plans.id"),
|
ForeignKey("schedule_plans.id"),
|
||||||
@@ -172,11 +178,37 @@ class TimeSlot(Base):
|
|||||||
comment="Source plan if materialized from a SchedulePlan; set NULL on edit/cancel",
|
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())
|
created_at = Column(DateTime(timezone=True), server_default=func.now())
|
||||||
updated_at = Column(DateTime(timezone=True), onupdate=func.now())
|
updated_at = Column(DateTime(timezone=True), onupdate=func.now())
|
||||||
|
|
||||||
# relationship ----------------------------------------------------------
|
# relationship ----------------------------------------------------------
|
||||||
plan = relationship("SchedulePlan", back_populates="materialized_slots")
|
plan = relationship("SchedulePlan", back_populates="materialized_slots")
|
||||||
|
special_slot = relationship("ScheduleTypeSpecialSlot")
|
||||||
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
|
|||||||
@@ -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.orm import relationship
|
||||||
from sqlalchemy.sql import func
|
from sqlalchemy.sql import func
|
||||||
from app.core.config import Base
|
from app.core.config import Base
|
||||||
@@ -66,12 +66,20 @@ class Project(Base):
|
|||||||
|
|
||||||
class User(Base):
|
class User(Base):
|
||||||
__tablename__ = "users"
|
__tablename__ = "users"
|
||||||
|
__table_args__ = (
|
||||||
|
UniqueConstraint("oidc_issuer", "oidc_subject", name="uq_users_oidc_identity"),
|
||||||
|
)
|
||||||
|
|
||||||
id = Column(Integer, primary_key=True, index=True)
|
id = Column(Integer, primary_key=True, index=True)
|
||||||
username = Column(String(50), unique=True, nullable=False)
|
username = Column(String(50), unique=True, nullable=False)
|
||||||
email = Column(String(100), unique=True, nullable=False)
|
email = Column(String(100), unique=True, nullable=False)
|
||||||
hashed_password = Column(String(255), nullable=True)
|
hashed_password = Column(String(255), nullable=True)
|
||||||
full_name = Column(String(100), nullable=True)
|
full_name = Column(String(100), nullable=True)
|
||||||
|
discord_user_id = Column(String(32), nullable=True)
|
||||||
|
# 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_active = Column(Boolean, default=True)
|
||||||
is_admin = Column(Boolean, default=False)
|
is_admin = Column(Boolean, default=False)
|
||||||
role_id = Column(Integer, ForeignKey("roles.id"), nullable=True)
|
role_id = Column(Integer, ForeignKey("roles.id"), nullable=True)
|
||||||
|
|||||||
25
app/models/oidc_settings.py
Normal file
25
app/models/oidc_settings.py
Normal 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())
|
||||||
85
app/models/schedule_type.py
Normal file
85
app/models/schedule_type.py
Normal 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:00–9: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",
|
||||||
|
)
|
||||||
116
app/models/schedule_type_special_slot.py
Normal file
116
app/models/schedule_type_special_slot.py
Normal 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")
|
||||||
@@ -2,14 +2,17 @@
|
|||||||
|
|
||||||
BE-CAL-004: MinimumWorkload read/write schemas.
|
BE-CAL-004: MinimumWorkload read/write schemas.
|
||||||
BE-CAL-API-001: TimeSlot create / response 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 __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 enum import Enum
|
||||||
from pydantic import BaseModel, Field, model_validator, field_validator
|
from pydantic import BaseModel, Field, model_validator, field_validator
|
||||||
from typing import Any, Optional
|
from typing import Optional
|
||||||
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
@@ -99,17 +102,17 @@ class SlotStatusEnum(str, Enum):
|
|||||||
|
|
||||||
class TimeSlotCreate(BaseModel):
|
class TimeSlotCreate(BaseModel):
|
||||||
"""Request body for creating a single calendar slot."""
|
"""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")
|
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)")
|
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_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")
|
priority: int = Field(0, ge=0, le=99, description="Priority 0-99")
|
||||||
|
|
||||||
@field_validator("scheduled_at")
|
@field_validator("scheduled_at")
|
||||||
@classmethod
|
@classmethod
|
||||||
def _validate_scheduled_at(cls, v: time) -> time:
|
def _validate_scheduled_at(cls, v: dt_time) -> dt_time:
|
||||||
if v.hour > 23:
|
if v.hour > 23:
|
||||||
raise ValueError("scheduled_at hour must be between 00 and 23")
|
raise ValueError("scheduled_at hour must be between 00 and 23")
|
||||||
return v
|
return v
|
||||||
@@ -129,7 +132,7 @@ class TimeSlotResponse(BaseModel):
|
|||||||
"""Response for a single TimeSlot."""
|
"""Response for a single TimeSlot."""
|
||||||
id: int
|
id: int
|
||||||
user_id: int
|
user_id: int
|
||||||
date: date
|
date: dt_date
|
||||||
slot_type: str
|
slot_type: str
|
||||||
estimated_duration: int
|
estimated_duration: int
|
||||||
scheduled_at: str # HH:MM:SS ISO format
|
scheduled_at: str # HH:MM:SS ISO format
|
||||||
@@ -137,12 +140,14 @@ class TimeSlotResponse(BaseModel):
|
|||||||
attended: bool
|
attended: bool
|
||||||
actual_duration: Optional[int] = None
|
actual_duration: Optional[int] = None
|
||||||
event_type: Optional[str] = None
|
event_type: Optional[str] = None
|
||||||
event_data: Optional[dict[str, Any]] = None
|
event_data: Optional[dict] = None
|
||||||
priority: int
|
priority: int
|
||||||
status: str
|
status: str
|
||||||
plan_id: Optional[int] = None
|
plan_id: Optional[int] = None
|
||||||
created_at: Optional[datetime] = None
|
is_admin_locked: bool = False
|
||||||
updated_at: Optional[datetime] = None
|
special_slot_id: Optional[int] = None
|
||||||
|
created_at: Optional[dt_datetime] = None
|
||||||
|
updated_at: Optional[dt_datetime] = None
|
||||||
|
|
||||||
class Config:
|
class Config:
|
||||||
from_attributes = True
|
from_attributes = True
|
||||||
@@ -152,3 +157,284 @@ class TimeSlotCreateResponse(BaseModel):
|
|||||||
"""Response after creating a slot — includes the slot and any warnings."""
|
"""Response after creating a slot — includes the slot and any warnings."""
|
||||||
slot: TimeSlotResponse
|
slot: TimeSlotResponse
|
||||||
warnings: list[WorkloadWarningItem] = Field(default_factory=list)
|
warnings: list[WorkloadWarningItem] = Field(default_factory=list)
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# TimeSlot edit (BE-CAL-API-003)
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
class TimeSlotEdit(BaseModel):
|
||||||
|
"""Request body for editing a calendar slot.
|
||||||
|
|
||||||
|
All fields are optional — only provided fields are updated.
|
||||||
|
The caller must supply either ``slot_id`` (for real slots) or
|
||||||
|
``virtual_id`` (for plan-generated virtual slots) in the URL path.
|
||||||
|
"""
|
||||||
|
slot_type: Optional[SlotTypeEnum] = Field(None, description="New slot type")
|
||||||
|
scheduled_at: Optional[dt_time] = Field(None, description="New start time HH:MM")
|
||||||
|
estimated_duration: Optional[int] = Field(None, ge=1, le=50, description="New duration in minutes (1-50)")
|
||||||
|
event_type: Optional[EventTypeEnum] = Field(None, description="New event type")
|
||||||
|
event_data: Optional[dict] = Field(None, description="New event details JSON")
|
||||||
|
priority: Optional[int] = Field(None, ge=0, le=99, description="New priority 0-99")
|
||||||
|
|
||||||
|
@field_validator("scheduled_at")
|
||||||
|
@classmethod
|
||||||
|
def _validate_scheduled_at(cls, v: Optional[dt_time]) -> Optional[dt_time]:
|
||||||
|
if v is not None and v.hour > 23:
|
||||||
|
raise ValueError("scheduled_at hour must be between 00 and 23")
|
||||||
|
return v
|
||||||
|
|
||||||
|
@model_validator(mode="after")
|
||||||
|
def _at_least_one_field(self) -> "TimeSlotEdit":
|
||||||
|
"""Ensure at least one editable field is provided."""
|
||||||
|
if all(
|
||||||
|
getattr(self, f) is None
|
||||||
|
for f in ("slot_type", "scheduled_at", "estimated_duration",
|
||||||
|
"event_type", "event_data", "priority")
|
||||||
|
):
|
||||||
|
raise ValueError("At least one field must be provided for edit")
|
||||||
|
return self
|
||||||
|
|
||||||
|
|
||||||
|
class TimeSlotEditResponse(BaseModel):
|
||||||
|
"""Response after editing a slot — includes the updated slot and any warnings."""
|
||||||
|
slot: TimeSlotResponse
|
||||||
|
warnings: list[WorkloadWarningItem] = Field(default_factory=list)
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Calendar day-view query (BE-CAL-API-002)
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
class CalendarSlotItem(BaseModel):
|
||||||
|
"""Unified slot item for day-view — covers both real and virtual slots.
|
||||||
|
|
||||||
|
* For **real** (materialized) slots: ``id`` is set, ``virtual_id`` is None.
|
||||||
|
* For **virtual** (plan-generated) slots: ``id`` is None, ``virtual_id``
|
||||||
|
is the ``plan-{plan_id}-{date}`` identifier.
|
||||||
|
"""
|
||||||
|
id: Optional[int] = Field(None, description="Real slot DB id (None for virtual)")
|
||||||
|
virtual_id: Optional[str] = Field(None, description="Virtual slot id (None for real)")
|
||||||
|
user_id: int
|
||||||
|
date: dt_date
|
||||||
|
slot_type: str
|
||||||
|
estimated_duration: int
|
||||||
|
scheduled_at: str # HH:MM:SS ISO format
|
||||||
|
started_at: Optional[str] = None
|
||||||
|
attended: bool
|
||||||
|
actual_duration: Optional[int] = None
|
||||||
|
event_type: Optional[str] = None
|
||||||
|
event_data: Optional[dict] = None
|
||||||
|
priority: int
|
||||||
|
status: str
|
||||||
|
plan_id: Optional[int] = None
|
||||||
|
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
|
||||||
|
|
||||||
|
|
||||||
|
class CalendarDayResponse(BaseModel):
|
||||||
|
"""Response for a single-day calendar query."""
|
||||||
|
date: dt_date
|
||||||
|
user_id: int
|
||||||
|
slots: list[CalendarSlotItem] = Field(
|
||||||
|
default_factory=list,
|
||||||
|
description="All slots for the day, sorted by scheduled_at ascending",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# TimeSlot cancel (BE-CAL-API-004)
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
class TimeSlotCancelResponse(BaseModel):
|
||||||
|
"""Response after cancelling a slot — includes the cancelled slot."""
|
||||||
|
slot: TimeSlotResponse
|
||||||
|
message: str = Field("Slot cancelled successfully", description="Human-readable result")
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# SchedulePlan enums (mirror DB enums)
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
class DayOfWeekEnum(str, Enum):
|
||||||
|
SUN = "sun"
|
||||||
|
MON = "mon"
|
||||||
|
TUE = "tue"
|
||||||
|
WED = "wed"
|
||||||
|
THU = "thu"
|
||||||
|
FRI = "fri"
|
||||||
|
SAT = "sat"
|
||||||
|
|
||||||
|
|
||||||
|
class MonthOfYearEnum(str, Enum):
|
||||||
|
JAN = "jan"
|
||||||
|
FEB = "feb"
|
||||||
|
MAR = "mar"
|
||||||
|
APR = "apr"
|
||||||
|
MAY = "may"
|
||||||
|
JUN = "jun"
|
||||||
|
JUL = "jul"
|
||||||
|
AUG = "aug"
|
||||||
|
SEP = "sep"
|
||||||
|
OCT = "oct"
|
||||||
|
NOV = "nov"
|
||||||
|
DEC = "dec"
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# SchedulePlan create / response (BE-CAL-API-005)
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
class SchedulePlanCreate(BaseModel):
|
||||||
|
"""Request body for creating a recurring schedule plan."""
|
||||||
|
slot_type: SlotTypeEnum = Field(..., description="work | on_call | entertainment | system")
|
||||||
|
estimated_duration: int = Field(..., ge=1, le=50, description="Duration in minutes (1-50)")
|
||||||
|
at_time: dt_time = Field(..., description="Daily scheduled time (HH:MM)")
|
||||||
|
on_day: Optional[DayOfWeekEnum] = Field(None, description="Day of week (sun-sat)")
|
||||||
|
on_week: Optional[int] = Field(None, ge=1, le=4, description="Week of month (1-4)")
|
||||||
|
on_month: Optional[MonthOfYearEnum] = Field(None, description="Month (jan-dec)")
|
||||||
|
event_type: Optional[EventTypeEnum] = Field(None, description="job | entertainment | system_event")
|
||||||
|
event_data: Optional[dict] = Field(None, description="Event details JSON")
|
||||||
|
|
||||||
|
@field_validator("at_time")
|
||||||
|
@classmethod
|
||||||
|
def _validate_at_time(cls, v: dt_time) -> dt_time:
|
||||||
|
if v.hour > 23:
|
||||||
|
raise ValueError("at_time hour must be between 00 and 23")
|
||||||
|
return v
|
||||||
|
|
||||||
|
@model_validator(mode="after")
|
||||||
|
def _validate_hierarchy(self) -> "SchedulePlanCreate":
|
||||||
|
"""Enforce period-parameter hierarchy: on_month → on_week → on_day."""
|
||||||
|
if self.on_month is not None and self.on_week is None:
|
||||||
|
raise ValueError("on_month requires on_week to be set")
|
||||||
|
if self.on_week is not None and self.on_day is None:
|
||||||
|
raise ValueError("on_week requires on_day to be set")
|
||||||
|
return self
|
||||||
|
|
||||||
|
|
||||||
|
class SchedulePlanResponse(BaseModel):
|
||||||
|
"""Response for a single SchedulePlan."""
|
||||||
|
id: int
|
||||||
|
user_id: int
|
||||||
|
slot_type: str
|
||||||
|
estimated_duration: int
|
||||||
|
at_time: str # HH:MM:SS ISO format
|
||||||
|
on_day: Optional[str] = None
|
||||||
|
on_week: Optional[int] = None
|
||||||
|
on_month: Optional[str] = None
|
||||||
|
event_type: Optional[str] = None
|
||||||
|
event_data: Optional[dict] = None
|
||||||
|
is_active: bool
|
||||||
|
created_at: Optional[dt_datetime] = None
|
||||||
|
updated_at: Optional[dt_datetime] = None
|
||||||
|
|
||||||
|
class Config:
|
||||||
|
from_attributes = True
|
||||||
|
|
||||||
|
|
||||||
|
class SchedulePlanListResponse(BaseModel):
|
||||||
|
"""Response for listing schedule plans."""
|
||||||
|
plans: list[SchedulePlanResponse] = Field(default_factory=list)
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# SchedulePlan edit / cancel (BE-CAL-API-006)
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
class SchedulePlanEdit(BaseModel):
|
||||||
|
"""Request body for editing a recurring schedule plan.
|
||||||
|
|
||||||
|
All fields are optional — only provided fields are updated.
|
||||||
|
Period-parameter hierarchy (on_month → on_week → on_day) is
|
||||||
|
validated after merging with existing plan values.
|
||||||
|
"""
|
||||||
|
slot_type: Optional[SlotTypeEnum] = Field(None, description="New slot type")
|
||||||
|
estimated_duration: Optional[int] = Field(None, ge=1, le=50, description="New duration in minutes (1-50)")
|
||||||
|
at_time: Optional[dt_time] = Field(None, description="New daily time (HH:MM)")
|
||||||
|
on_day: Optional[DayOfWeekEnum] = Field(None, description="New day of week (sun-sat), use 'clear' param to remove")
|
||||||
|
on_week: Optional[int] = Field(None, ge=1, le=4, description="New week of month (1-4), use 'clear' param to remove")
|
||||||
|
on_month: Optional[MonthOfYearEnum] = Field(None, description="New month (jan-dec), use 'clear' param to remove")
|
||||||
|
event_type: Optional[EventTypeEnum] = Field(None, description="New event type")
|
||||||
|
event_data: Optional[dict] = Field(None, description="New event details JSON")
|
||||||
|
clear_on_day: bool = Field(False, description="Clear on_day (set to NULL)")
|
||||||
|
clear_on_week: bool = Field(False, description="Clear on_week (set to NULL)")
|
||||||
|
clear_on_month: bool = Field(False, description="Clear on_month (set to NULL)")
|
||||||
|
|
||||||
|
@field_validator("at_time")
|
||||||
|
@classmethod
|
||||||
|
def _validate_at_time(cls, v: Optional[dt_time]) -> Optional[dt_time]:
|
||||||
|
if v is not None and v.hour > 23:
|
||||||
|
raise ValueError("at_time hour must be between 00 and 23")
|
||||||
|
return v
|
||||||
|
|
||||||
|
@model_validator(mode="after")
|
||||||
|
def _at_least_one_field(self) -> "SchedulePlanEdit":
|
||||||
|
"""Ensure at least one editable field or clear flag is provided."""
|
||||||
|
has_value = any(
|
||||||
|
getattr(self, f) is not None
|
||||||
|
for f in ("slot_type", "estimated_duration", "at_time", "on_day",
|
||||||
|
"on_week", "on_month", "event_type", "event_data")
|
||||||
|
)
|
||||||
|
has_clear = self.clear_on_day or self.clear_on_week or self.clear_on_month
|
||||||
|
if not has_value and not has_clear:
|
||||||
|
raise ValueError("At least one field must be provided for edit")
|
||||||
|
return self
|
||||||
|
|
||||||
|
|
||||||
|
class SchedulePlanCancelResponse(BaseModel):
|
||||||
|
"""Response after cancelling a plan."""
|
||||||
|
plan: SchedulePlanResponse
|
||||||
|
message: str = Field("Plan cancelled successfully", description="Human-readable result")
|
||||||
|
preserved_past_slot_ids: list[int] = Field(
|
||||||
|
default_factory=list,
|
||||||
|
description="IDs of past materialized slots that were NOT affected",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Calendar date-list (BE-CAL-API-007)
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
class DateListResponse(BaseModel):
|
||||||
|
"""Response for the date-list endpoint.
|
||||||
|
|
||||||
|
Returns only dates that have at least one materialized (real) future
|
||||||
|
slot. Pure plan-generated (virtual) dates are excluded.
|
||||||
|
"""
|
||||||
|
dates: list[dt_date] = Field(
|
||||||
|
default_factory=list,
|
||||||
|
description="Sorted list of future dates with materialized slots",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Agent heartbeat / agent-driven slot updates
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
class AgentHeartbeatResponse(BaseModel):
|
||||||
|
"""Slots that are due for a specific agent plus its current runtime status."""
|
||||||
|
slots: list[CalendarSlotItem] = Field(default_factory=list)
|
||||||
|
agent_status: str
|
||||||
|
message: Optional[str] = None
|
||||||
|
|
||||||
|
|
||||||
|
class SlotAgentUpdate(BaseModel):
|
||||||
|
"""Plugin-driven slot status update payload."""
|
||||||
|
status: SlotStatusEnum
|
||||||
|
started_at: Optional[dt_time] = None
|
||||||
|
actual_duration: Optional[int] = Field(None, ge=0, le=65535)
|
||||||
|
|
||||||
|
|
||||||
|
class AgentStatusUpdateRequest(BaseModel):
|
||||||
|
"""Plugin-driven agent status report."""
|
||||||
|
agent_id: str
|
||||||
|
claw_identifier: str
|
||||||
|
status: str
|
||||||
|
recovery_at: Optional[dt_datetime] = None
|
||||||
|
exhaust_reason: Optional[str] = None
|
||||||
|
|||||||
65
app/schemas/schedule_type.py
Normal file
65
app/schemas/schedule_type.py
Normal 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")
|
||||||
43
app/schemas/schedule_type_special_slot.py
Normal file
43
app/schemas/schedule_type_special_slot.py
Normal 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
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
from pydantic import BaseModel
|
from pydantic import BaseModel, Field
|
||||||
from typing import Optional, List
|
from typing import Optional, List
|
||||||
from datetime import datetime, time
|
from datetime import datetime, time
|
||||||
from enum import Enum
|
from enum import Enum
|
||||||
@@ -43,9 +43,7 @@ class TaskBase(BaseModel):
|
|||||||
|
|
||||||
|
|
||||||
class TaskCreate(TaskBase):
|
class TaskCreate(TaskBase):
|
||||||
project_id: Optional[int] = None
|
|
||||||
project_code: Optional[str] = None
|
project_code: Optional[str] = None
|
||||||
milestone_id: Optional[int] = None
|
|
||||||
milestone_code: Optional[str] = None
|
milestone_code: Optional[str] = None
|
||||||
reporter_id: Optional[int] = None
|
reporter_id: Optional[int] = None
|
||||||
assignee_id: Optional[int] = None
|
assignee_id: Optional[int] = None
|
||||||
@@ -75,15 +73,12 @@ class TaskUpdate(BaseModel):
|
|||||||
|
|
||||||
|
|
||||||
class TaskResponse(TaskBase):
|
class TaskResponse(TaskBase):
|
||||||
id: int
|
|
||||||
status: TaskStatusEnum
|
status: TaskStatusEnum
|
||||||
task_code: Optional[str] = None
|
task_code: Optional[str] = None
|
||||||
code: Optional[str] = None
|
code: Optional[str] = None
|
||||||
type: Optional[str] = None
|
type: Optional[str] = None
|
||||||
due_date: Optional[datetime] = None
|
due_date: Optional[datetime] = None
|
||||||
project_id: int
|
|
||||||
project_code: Optional[str] = None
|
project_code: Optional[str] = None
|
||||||
milestone_id: int
|
|
||||||
milestone_code: Optional[str] = None
|
milestone_code: Optional[str] = None
|
||||||
reporter_id: int
|
reporter_id: int
|
||||||
assignee_id: Optional[int] = None
|
assignee_id: Optional[int] = None
|
||||||
@@ -94,8 +89,8 @@ class TaskResponse(TaskBase):
|
|||||||
positions: Optional[str] = None
|
positions: Optional[str] = None
|
||||||
pending_matters: Optional[str] = None
|
pending_matters: Optional[str] = None
|
||||||
# BE-PR-008: Proposal Accept tracking
|
# BE-PR-008: Proposal Accept tracking
|
||||||
source_proposal_id: Optional[int] = None
|
source_proposal_code: Optional[str] = None
|
||||||
source_essential_id: Optional[int] = None
|
source_essential_code: Optional[str] = None
|
||||||
created_at: datetime
|
created_at: datetime
|
||||||
updated_at: Optional[datetime] = None
|
updated_at: Optional[datetime] = None
|
||||||
|
|
||||||
@@ -176,6 +171,7 @@ class UserBase(BaseModel):
|
|||||||
class UserCreate(UserBase):
|
class UserCreate(UserBase):
|
||||||
password: Optional[str] = None
|
password: Optional[str] = None
|
||||||
role_id: Optional[int] = None
|
role_id: Optional[int] = None
|
||||||
|
discord_user_id: Optional[str] = None
|
||||||
# Agent binding (both must be provided or both omitted)
|
# Agent binding (both must be provided or both omitted)
|
||||||
agent_id: Optional[str] = None
|
agent_id: Optional[str] = None
|
||||||
claw_identifier: Optional[str] = None
|
claw_identifier: Optional[str] = None
|
||||||
@@ -187,6 +183,20 @@ class UserUpdate(BaseModel):
|
|||||||
password: Optional[str] = None
|
password: Optional[str] = None
|
||||||
role_id: Optional[int] = None
|
role_id: Optional[int] = None
|
||||||
is_active: Optional[bool] = None
|
is_active: Optional[bool] = None
|
||||||
|
discord_user_id: Optional[str] = None
|
||||||
|
|
||||||
|
|
||||||
|
class 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):
|
class UserResponse(UserBase):
|
||||||
@@ -196,8 +206,11 @@ class UserResponse(UserBase):
|
|||||||
role_id: Optional[int] = None
|
role_id: Optional[int] = None
|
||||||
role_name: Optional[str] = None
|
role_name: Optional[str] = None
|
||||||
agent_id: Optional[str] = None
|
agent_id: Optional[str] = None
|
||||||
|
discord_user_id: Optional[str] = None
|
||||||
|
oidc_issuer: Optional[str] = None
|
||||||
|
oidc_subject: Optional[str] = None
|
||||||
created_at: datetime
|
created_at: datetime
|
||||||
|
|
||||||
class Config:
|
class Config:
|
||||||
from_attributes = True
|
from_attributes = True
|
||||||
|
|
||||||
@@ -259,9 +272,9 @@ class MilestoneUpdate(BaseModel):
|
|||||||
|
|
||||||
|
|
||||||
class MilestoneResponse(MilestoneBase):
|
class MilestoneResponse(MilestoneBase):
|
||||||
id: int
|
|
||||||
milestone_code: Optional[str] = None
|
milestone_code: Optional[str] = None
|
||||||
project_id: int
|
code: Optional[str] = None
|
||||||
|
project_code: Optional[str] = None
|
||||||
created_by_id: Optional[int] = None
|
created_by_id: Optional[int] = None
|
||||||
started_at: Optional[datetime] = None
|
started_at: Optional[datetime] = None
|
||||||
created_at: datetime
|
created_at: datetime
|
||||||
@@ -285,7 +298,7 @@ class ProposalBase(BaseModel):
|
|||||||
|
|
||||||
|
|
||||||
class ProposalCreate(ProposalBase):
|
class ProposalCreate(ProposalBase):
|
||||||
project_id: Optional[int] = None
|
pass
|
||||||
|
|
||||||
|
|
||||||
class ProposalUpdate(BaseModel):
|
class ProposalUpdate(BaseModel):
|
||||||
@@ -294,11 +307,10 @@ class ProposalUpdate(BaseModel):
|
|||||||
|
|
||||||
|
|
||||||
class ProposalResponse(ProposalBase):
|
class ProposalResponse(ProposalBase):
|
||||||
id: int
|
|
||||||
proposal_code: Optional[str] = None # preferred name
|
proposal_code: Optional[str] = None # preferred name
|
||||||
propose_code: Optional[str] = None # backward compat alias (same value)
|
propose_code: Optional[str] = None # backward compat alias (same value)
|
||||||
status: ProposalStatusEnum
|
status: ProposalStatusEnum
|
||||||
project_id: int
|
project_code: Optional[str] = None
|
||||||
created_by_id: Optional[int] = None
|
created_by_id: Optional[int] = None
|
||||||
created_by_username: Optional[str] = None
|
created_by_username: Optional[str] = None
|
||||||
feat_task_id: Optional[str] = None # DEPRECATED (BE-PR-010): legacy field, read-only. Use generated_tasks instead.
|
feat_task_id: Optional[str] = None # DEPRECATED (BE-PR-010): legacy field, read-only. Use generated_tasks instead.
|
||||||
@@ -340,9 +352,8 @@ class EssentialUpdate(BaseModel):
|
|||||||
|
|
||||||
|
|
||||||
class EssentialResponse(EssentialBase):
|
class EssentialResponse(EssentialBase):
|
||||||
id: int
|
|
||||||
essential_code: str
|
essential_code: str
|
||||||
proposal_id: int
|
proposal_code: Optional[str] = None
|
||||||
created_by_id: Optional[int] = None
|
created_by_id: Optional[int] = None
|
||||||
created_at: datetime
|
created_at: datetime
|
||||||
updated_at: Optional[datetime] = None
|
updated_at: Optional[datetime] = None
|
||||||
@@ -353,13 +364,12 @@ class EssentialResponse(EssentialBase):
|
|||||||
|
|
||||||
class GeneratedTaskBrief(BaseModel):
|
class GeneratedTaskBrief(BaseModel):
|
||||||
"""Brief info about a story task generated from Proposal Accept."""
|
"""Brief info about a story task generated from Proposal Accept."""
|
||||||
task_id: int
|
|
||||||
task_code: Optional[str] = None
|
task_code: Optional[str] = None
|
||||||
task_type: str
|
task_type: str
|
||||||
task_subtype: Optional[str] = None
|
task_subtype: Optional[str] = None
|
||||||
title: str
|
title: str
|
||||||
status: Optional[str] = None
|
status: Optional[str] = None
|
||||||
source_essential_id: Optional[int] = None
|
source_essential_code: Optional[str] = None
|
||||||
|
|
||||||
|
|
||||||
class ProposalDetailResponse(ProposalResponse):
|
class ProposalDetailResponse(ProposalResponse):
|
||||||
@@ -374,12 +384,10 @@ class ProposalDetailResponse(ProposalResponse):
|
|||||||
|
|
||||||
class GeneratedTaskSummary(BaseModel):
|
class GeneratedTaskSummary(BaseModel):
|
||||||
"""Brief summary of a task generated from a Proposal Essential."""
|
"""Brief summary of a task generated from a Proposal Essential."""
|
||||||
task_id: int
|
|
||||||
task_code: str
|
task_code: str
|
||||||
task_type: str
|
task_type: str
|
||||||
task_subtype: str
|
task_subtype: str
|
||||||
title: str
|
title: str
|
||||||
essential_id: int
|
|
||||||
essential_code: str
|
essential_code: str
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
121
app/services/agent_heartbeat.py
Normal file
121
app/services/agent_heartbeat.py
Normal 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()
|
||||||
|
)
|
||||||
364
app/services/agent_status.py
Normal file
364
app/services/agent_status.py
Normal 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
|
||||||
72
app/services/discord_wakeup.py
Normal file
72
app/services/discord_wakeup.py
Normal file
@@ -0,0 +1,72 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from datetime import datetime, timezone
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
import requests
|
||||||
|
from fastapi import HTTPException
|
||||||
|
|
||||||
|
from app.services.harborforge_config import get_discord_wakeup_config
|
||||||
|
|
||||||
|
DISCORD_API_BASE = "https://discord.com/api/v10"
|
||||||
|
WAKEUP_CATEGORY_NAME = "HarborForge Wakeup"
|
||||||
|
|
||||||
|
|
||||||
|
def _headers(bot_token: str) -> dict[str, str]:
|
||||||
|
return {
|
||||||
|
"Authorization": f"Bot {bot_token}",
|
||||||
|
"Content-Type": "application/json",
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _ensure_category(guild_id: str, bot_token: str) -> str | None:
|
||||||
|
resp = requests.get(f"{DISCORD_API_BASE}/guilds/{guild_id}/channels", headers=_headers(bot_token), timeout=15)
|
||||||
|
if not resp.ok:
|
||||||
|
raise HTTPException(status_code=502, detail=f"Discord list channels failed: {resp.text}")
|
||||||
|
for ch in resp.json():
|
||||||
|
if ch.get("type") == 4 and ch.get("name") == WAKEUP_CATEGORY_NAME:
|
||||||
|
return ch.get("id")
|
||||||
|
payload = {"name": WAKEUP_CATEGORY_NAME, "type": 4}
|
||||||
|
created = requests.post(f"{DISCORD_API_BASE}/guilds/{guild_id}/channels", headers=_headers(bot_token), json=payload, timeout=15)
|
||||||
|
if not created.ok:
|
||||||
|
raise HTTPException(status_code=502, detail=f"Discord create category failed: {created.text}")
|
||||||
|
return created.json().get("id")
|
||||||
|
|
||||||
|
|
||||||
|
def create_private_wakeup_channel(discord_user_id: str, title: str, message: str) -> dict[str, Any]:
|
||||||
|
cfg = get_discord_wakeup_config()
|
||||||
|
guild_id = cfg.get("guild_id")
|
||||||
|
bot_token = cfg.get("bot_token")
|
||||||
|
if not guild_id or not bot_token:
|
||||||
|
raise HTTPException(status_code=400, detail="Discord wakeup config is incomplete")
|
||||||
|
|
||||||
|
category_id = _ensure_category(guild_id, bot_token)
|
||||||
|
channel_name = f"wake-{discord_user_id[-6:]}-{int(datetime.now(timezone.utc).timestamp())}"
|
||||||
|
payload = {
|
||||||
|
"name": channel_name,
|
||||||
|
"type": 0,
|
||||||
|
"parent_id": category_id,
|
||||||
|
"permission_overwrites": [
|
||||||
|
{"id": guild_id, "type": 0, "deny": "1024"},
|
||||||
|
{"id": discord_user_id, "type": 1, "allow": "1024"},
|
||||||
|
],
|
||||||
|
"topic": title,
|
||||||
|
}
|
||||||
|
created = requests.post(f"{DISCORD_API_BASE}/guilds/{guild_id}/channels", headers=_headers(bot_token), json=payload, timeout=15)
|
||||||
|
if not created.ok:
|
||||||
|
raise HTTPException(status_code=502, detail=f"Discord create channel failed: {created.text}")
|
||||||
|
channel = created.json()
|
||||||
|
sent = requests.post(
|
||||||
|
f"{DISCORD_API_BASE}/channels/{channel['id']}/messages",
|
||||||
|
headers=_headers(bot_token),
|
||||||
|
json={"content": message},
|
||||||
|
timeout=15,
|
||||||
|
)
|
||||||
|
if not sent.ok:
|
||||||
|
raise HTTPException(status_code=502, detail=f"Discord send message failed: {sent.text}")
|
||||||
|
return {
|
||||||
|
"guild_id": guild_id,
|
||||||
|
"channel_id": channel.get("id"),
|
||||||
|
"channel_name": channel.get("name"),
|
||||||
|
"message_id": sent.json().get("id"),
|
||||||
|
}
|
||||||
26
app/services/harborforge_config.py
Normal file
26
app/services/harborforge_config.py
Normal file
@@ -0,0 +1,26 @@
|
|||||||
|
import json
|
||||||
|
import os
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
CONFIG_DIR = os.getenv("CONFIG_DIR", "/config")
|
||||||
|
CONFIG_FILE = os.getenv("CONFIG_FILE", "harborforge.json")
|
||||||
|
|
||||||
|
|
||||||
|
def load_runtime_config() -> dict[str, Any]:
|
||||||
|
config_path = os.path.join(CONFIG_DIR, CONFIG_FILE)
|
||||||
|
if not os.path.exists(config_path):
|
||||||
|
return {}
|
||||||
|
try:
|
||||||
|
with open(config_path, "r") as f:
|
||||||
|
return json.load(f)
|
||||||
|
except Exception:
|
||||||
|
return {}
|
||||||
|
|
||||||
|
|
||||||
|
def get_discord_wakeup_config() -> dict[str, str | None]:
|
||||||
|
cfg = load_runtime_config()
|
||||||
|
discord_cfg = cfg.get("discord") or {}
|
||||||
|
return {
|
||||||
|
"guild_id": discord_cfg.get("guild_id"),
|
||||||
|
"bot_token": discord_cfg.get("bot_token"),
|
||||||
|
}
|
||||||
125
app/services/slot_competition.py
Normal file
125
app/services/slot_competition.py
Normal 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
|
||||||
175
app/services/special_slot_materialiser.py
Normal file
175
app/services/special_slot_materialiser.py
Normal 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,
|
||||||
|
)
|
||||||
@@ -2,12 +2,41 @@ import json
|
|||||||
import hmac
|
import hmac
|
||||||
import hashlib
|
import hashlib
|
||||||
import logging
|
import logging
|
||||||
|
import socket
|
||||||
|
import ipaddress
|
||||||
|
from urllib.parse import urlparse
|
||||||
from sqlalchemy.orm import Session
|
from sqlalchemy.orm import Session
|
||||||
from app.models.webhook import Webhook, WebhookLog
|
from app.models.webhook import Webhook, WebhookLog
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
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):
|
def fire_webhooks_sync(event: str, payload: dict, project_id: int, db: Session):
|
||||||
"""Find matching webhooks and send payloads (sync version)."""
|
"""Find matching webhooks and send payloads (sync version)."""
|
||||||
import httpx
|
import httpx
|
||||||
@@ -35,6 +64,8 @@ def fire_webhooks_sync(event: str, payload: dict, project_id: int, db: Session):
|
|||||||
payload=payload_json,
|
payload=payload_json,
|
||||||
)
|
)
|
||||||
try:
|
try:
|
||||||
|
_validate_webhook_url(wh.url)
|
||||||
|
|
||||||
headers = {"Content-Type": "application/json"}
|
headers = {"Content-Type": "application/json"}
|
||||||
if wh.secret:
|
if wh.secret:
|
||||||
sig = hmac.new(
|
sig = hmac.new(
|
||||||
@@ -42,7 +73,7 @@ def fire_webhooks_sync(event: str, payload: dict, project_id: int, db: Session):
|
|||||||
).hexdigest()
|
).hexdigest()
|
||||||
headers["X-Webhook-Signature"] = sig
|
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)
|
resp = client.post(wh.url, content=payload_json, headers=headers)
|
||||||
log.response_status = resp.status_code
|
log.response_status = resp.status_code
|
||||||
log.response_body = resp.text[:1000]
|
log.response_body = resp.text[:1000]
|
||||||
|
|||||||
99
docs/oidc-test-plan.md
Normal file
99
docs/oidc-test-plan.md
Normal 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`(本地验证栈)
|
||||||
|
- IdP:Keycloak 容器,realm `hf`,confidential client `hf-client`
|
||||||
|
- IdP 测试用户:`tester` / `Test123!`(emailVerified=true)
|
||||||
|
- 关键约束:**issuer URL 必须浏览器与后端容器都能用同一地址访问**
|
||||||
|
(否则 token `iss` 校验失败)。本地用宿主机 IP 统一两端。
|
||||||
|
- 配置项(运行时 env 或 DB,DB 覆盖 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)未实现,超出本次范围。
|
||||||
|
- 自助绑定相关 UI(3.7/3.8)建议人工在浏览器复核。
|
||||||
@@ -12,3 +12,5 @@ alembic==1.13.1
|
|||||||
python-dotenv==1.0.0
|
python-dotenv==1.0.0
|
||||||
httpx==0.27.0
|
httpx==0.27.0
|
||||||
requests==2.31.0
|
requests==2.31.0
|
||||||
|
authlib==1.3.2
|
||||||
|
itsdangerous==2.2.0
|
||||||
|
|||||||
373
tests/test_agent_status.py
Normal file
373
tests/test_agent_status.py
Normal 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
357
tests/test_calendar_api.py
Normal file
@@ -0,0 +1,357 @@
|
|||||||
|
"""Tests for TEST-BE-CAL-001: Calendar API coverage.
|
||||||
|
|
||||||
|
Covers core API surfaces:
|
||||||
|
- slot create / day view / edit / cancel
|
||||||
|
- virtual slot edit / cancel materialization flows
|
||||||
|
- plan create / list / get / edit / cancel
|
||||||
|
- date-list
|
||||||
|
- workload-config user/admin endpoints
|
||||||
|
"""
|
||||||
|
|
||||||
|
from datetime import date, time, timedelta
|
||||||
|
|
||||||
|
from app.models.calendar import (
|
||||||
|
SchedulePlan,
|
||||||
|
SlotStatus,
|
||||||
|
SlotType,
|
||||||
|
TimeSlot,
|
||||||
|
DayOfWeek,
|
||||||
|
)
|
||||||
|
from tests.conftest import auth_header
|
||||||
|
|
||||||
|
|
||||||
|
FUTURE_DATE = date.today() + timedelta(days=30)
|
||||||
|
FUTURE_DATE_2 = date.today() + timedelta(days=31)
|
||||||
|
|
||||||
|
|
||||||
|
def _create_plan(db, *, user_id: int, slot_type=SlotType.WORK, at_time=time(9, 0), on_day=None, on_week=None):
|
||||||
|
plan = SchedulePlan(
|
||||||
|
user_id=user_id,
|
||||||
|
slot_type=slot_type,
|
||||||
|
estimated_duration=30,
|
||||||
|
at_time=at_time,
|
||||||
|
on_day=on_day,
|
||||||
|
on_week=on_week,
|
||||||
|
is_active=True,
|
||||||
|
)
|
||||||
|
db.add(plan)
|
||||||
|
db.commit()
|
||||||
|
db.refresh(plan)
|
||||||
|
return plan
|
||||||
|
|
||||||
|
|
||||||
|
def _create_slot(db, *, user_id: int, slot_date: date, scheduled_at=time(9, 0), status=SlotStatus.NOT_STARTED, plan_id=None):
|
||||||
|
slot = TimeSlot(
|
||||||
|
user_id=user_id,
|
||||||
|
date=slot_date,
|
||||||
|
slot_type=SlotType.WORK,
|
||||||
|
estimated_duration=30,
|
||||||
|
scheduled_at=scheduled_at,
|
||||||
|
status=status,
|
||||||
|
priority=0,
|
||||||
|
plan_id=plan_id,
|
||||||
|
)
|
||||||
|
db.add(slot)
|
||||||
|
db.commit()
|
||||||
|
db.refresh(slot)
|
||||||
|
return slot
|
||||||
|
|
||||||
|
|
||||||
|
class TestCalendarSlotApi:
|
||||||
|
def test_create_slot_success(self, client, seed):
|
||||||
|
r = client.post(
|
||||||
|
"/calendar/slots",
|
||||||
|
json={
|
||||||
|
"date": FUTURE_DATE.isoformat(),
|
||||||
|
"slot_type": "work",
|
||||||
|
"scheduled_at": "09:00:00",
|
||||||
|
"estimated_duration": 30,
|
||||||
|
"event_type": "job",
|
||||||
|
"event_data": {"type": "Task", "code": "TASK-42"},
|
||||||
|
"priority": 3,
|
||||||
|
},
|
||||||
|
headers=auth_header(seed["admin_token"]),
|
||||||
|
)
|
||||||
|
assert r.status_code == 201, r.text
|
||||||
|
data = r.json()
|
||||||
|
assert data["slot"]["date"] == FUTURE_DATE.isoformat()
|
||||||
|
assert data["slot"]["slot_type"] == "work"
|
||||||
|
assert data["slot"]["event_type"] == "job"
|
||||||
|
assert data["slot"]["event_data"]["code"] == "TASK-42"
|
||||||
|
assert data["warnings"] == []
|
||||||
|
|
||||||
|
def test_day_view_returns_real_and_virtual_slots_sorted(self, client, db, seed):
|
||||||
|
# Real slots
|
||||||
|
_create_slot(db, user_id=seed["admin_user"].id, slot_date=FUTURE_DATE, scheduled_at=time(11, 0))
|
||||||
|
skipped = _create_slot(
|
||||||
|
db,
|
||||||
|
user_id=seed["admin_user"].id,
|
||||||
|
slot_date=FUTURE_DATE,
|
||||||
|
scheduled_at=time(12, 0),
|
||||||
|
status=SlotStatus.SKIPPED,
|
||||||
|
)
|
||||||
|
|
||||||
|
# Virtual weekly plan matching FUTURE_DATE weekday
|
||||||
|
weekday_map = {
|
||||||
|
0: DayOfWeek.MON,
|
||||||
|
1: DayOfWeek.TUE,
|
||||||
|
2: DayOfWeek.WED,
|
||||||
|
3: DayOfWeek.THU,
|
||||||
|
4: DayOfWeek.FRI,
|
||||||
|
5: DayOfWeek.SAT,
|
||||||
|
6: DayOfWeek.SUN,
|
||||||
|
}
|
||||||
|
_create_plan(
|
||||||
|
db,
|
||||||
|
user_id=seed["admin_user"].id,
|
||||||
|
at_time=time(8, 0),
|
||||||
|
on_day=weekday_map[FUTURE_DATE.weekday()],
|
||||||
|
)
|
||||||
|
|
||||||
|
r = client.get(
|
||||||
|
f"/calendar/day?date={FUTURE_DATE.isoformat()}",
|
||||||
|
headers=auth_header(seed["admin_token"]),
|
||||||
|
)
|
||||||
|
assert r.status_code == 200, r.text
|
||||||
|
data = r.json()
|
||||||
|
assert data["date"] == FUTURE_DATE.isoformat()
|
||||||
|
assert len(data["slots"]) == 2
|
||||||
|
assert [slot["scheduled_at"] for slot in data["slots"]] == ["08:00:00", "11:00:00"]
|
||||||
|
assert data["slots"][0]["virtual_id"].startswith("plan-")
|
||||||
|
assert data["slots"][1]["id"] is not None
|
||||||
|
# skipped slot hidden
|
||||||
|
assert all(slot.get("id") != skipped.id for slot in data["slots"])
|
||||||
|
|
||||||
|
def test_edit_real_slot_success(self, client, db, seed):
|
||||||
|
slot = _create_slot(db, user_id=seed["admin_user"].id, slot_date=FUTURE_DATE, scheduled_at=time(9, 0))
|
||||||
|
|
||||||
|
r = client.patch(
|
||||||
|
f"/calendar/slots/{slot.id}",
|
||||||
|
json={
|
||||||
|
"scheduled_at": "10:30:00",
|
||||||
|
"estimated_duration": 40,
|
||||||
|
"priority": 7,
|
||||||
|
},
|
||||||
|
headers=auth_header(seed["admin_token"]),
|
||||||
|
)
|
||||||
|
assert r.status_code == 200, r.text
|
||||||
|
data = r.json()
|
||||||
|
assert data["slot"]["id"] == slot.id
|
||||||
|
assert data["slot"]["scheduled_at"] == "10:30:00"
|
||||||
|
assert data["slot"]["estimated_duration"] == 40
|
||||||
|
assert data["slot"]["priority"] == 7
|
||||||
|
|
||||||
|
def test_edit_virtual_slot_materializes_and_detaches(self, client, db, seed):
|
||||||
|
weekday_map = {
|
||||||
|
0: DayOfWeek.MON,
|
||||||
|
1: DayOfWeek.TUE,
|
||||||
|
2: DayOfWeek.WED,
|
||||||
|
3: DayOfWeek.THU,
|
||||||
|
4: DayOfWeek.FRI,
|
||||||
|
5: DayOfWeek.SAT,
|
||||||
|
6: DayOfWeek.SUN,
|
||||||
|
}
|
||||||
|
plan = _create_plan(
|
||||||
|
db,
|
||||||
|
user_id=seed["admin_user"].id,
|
||||||
|
at_time=time(8, 0),
|
||||||
|
on_day=weekday_map[FUTURE_DATE.weekday()],
|
||||||
|
)
|
||||||
|
virtual_id = f"plan-{plan.id}-{FUTURE_DATE.isoformat()}"
|
||||||
|
|
||||||
|
r = client.patch(
|
||||||
|
f"/calendar/slots/virtual/{virtual_id}",
|
||||||
|
json={"scheduled_at": "08:30:00", "priority": 5},
|
||||||
|
headers=auth_header(seed["admin_token"]),
|
||||||
|
)
|
||||||
|
assert r.status_code == 200, r.text
|
||||||
|
data = r.json()
|
||||||
|
assert data["slot"]["id"] is not None
|
||||||
|
assert data["slot"]["scheduled_at"] == "08:30:00"
|
||||||
|
assert data["slot"]["plan_id"] is None
|
||||||
|
materialized = db.query(TimeSlot).filter(TimeSlot.id == data["slot"]["id"]).first()
|
||||||
|
assert materialized is not None
|
||||||
|
assert materialized.plan_id is None
|
||||||
|
|
||||||
|
def test_cancel_real_slot_sets_skipped(self, client, db, seed):
|
||||||
|
slot = _create_slot(db, user_id=seed["admin_user"].id, slot_date=FUTURE_DATE)
|
||||||
|
|
||||||
|
r = client.post(
|
||||||
|
f"/calendar/slots/{slot.id}/cancel",
|
||||||
|
headers=auth_header(seed["admin_token"]),
|
||||||
|
)
|
||||||
|
assert r.status_code == 200, r.text
|
||||||
|
data = r.json()
|
||||||
|
assert data["slot"]["status"] == "skipped"
|
||||||
|
assert data["message"] == "Slot cancelled successfully"
|
||||||
|
|
||||||
|
def test_cancel_virtual_slot_materializes_then_skips(self, client, db, seed):
|
||||||
|
weekday_map = {
|
||||||
|
0: DayOfWeek.MON,
|
||||||
|
1: DayOfWeek.TUE,
|
||||||
|
2: DayOfWeek.WED,
|
||||||
|
3: DayOfWeek.THU,
|
||||||
|
4: DayOfWeek.FRI,
|
||||||
|
5: DayOfWeek.SAT,
|
||||||
|
6: DayOfWeek.SUN,
|
||||||
|
}
|
||||||
|
plan = _create_plan(
|
||||||
|
db,
|
||||||
|
user_id=seed["admin_user"].id,
|
||||||
|
at_time=time(8, 0),
|
||||||
|
on_day=weekday_map[FUTURE_DATE.weekday()],
|
||||||
|
)
|
||||||
|
virtual_id = f"plan-{plan.id}-{FUTURE_DATE.isoformat()}"
|
||||||
|
|
||||||
|
r = client.post(
|
||||||
|
f"/calendar/slots/virtual/{virtual_id}/cancel",
|
||||||
|
headers=auth_header(seed["admin_token"]),
|
||||||
|
)
|
||||||
|
assert r.status_code == 200, r.text
|
||||||
|
data = r.json()
|
||||||
|
assert data["slot"]["status"] == "skipped"
|
||||||
|
assert data["slot"]["plan_id"] is None
|
||||||
|
assert "cancelled" in data["message"].lower()
|
||||||
|
|
||||||
|
def test_date_list_only_returns_future_materialized_dates(self, client, db, seed):
|
||||||
|
_create_slot(db, user_id=seed["admin_user"].id, slot_date=FUTURE_DATE)
|
||||||
|
_create_slot(db, user_id=seed["admin_user"].id, slot_date=FUTURE_DATE_2, status=SlotStatus.SKIPPED)
|
||||||
|
_create_plan(db, user_id=seed["admin_user"].id, at_time=time(8, 0)) # virtual-only, should not appear
|
||||||
|
|
||||||
|
r = client.get("/calendar/dates", headers=auth_header(seed["admin_token"]))
|
||||||
|
assert r.status_code == 200, r.text
|
||||||
|
assert r.json()["dates"] == [FUTURE_DATE.isoformat()]
|
||||||
|
|
||||||
|
|
||||||
|
class TestCalendarPlanApi:
|
||||||
|
def test_create_list_get_plan(self, client, seed):
|
||||||
|
create = client.post(
|
||||||
|
"/calendar/plans",
|
||||||
|
json={
|
||||||
|
"slot_type": "work",
|
||||||
|
"estimated_duration": 30,
|
||||||
|
"at_time": "09:00:00",
|
||||||
|
"on_day": "mon",
|
||||||
|
"event_type": "job",
|
||||||
|
"event_data": {"type": "Task", "code": "TASK-1"},
|
||||||
|
},
|
||||||
|
headers=auth_header(seed["admin_token"]),
|
||||||
|
)
|
||||||
|
assert create.status_code == 201, create.text
|
||||||
|
plan = create.json()
|
||||||
|
assert plan["slot_type"] == "work"
|
||||||
|
assert plan["on_day"] == "mon"
|
||||||
|
|
||||||
|
listing = client.get("/calendar/plans", headers=auth_header(seed["admin_token"]))
|
||||||
|
assert listing.status_code == 200, listing.text
|
||||||
|
assert len(listing.json()["plans"]) == 1
|
||||||
|
assert listing.json()["plans"][0]["id"] == plan["id"]
|
||||||
|
|
||||||
|
single = client.get(f"/calendar/plans/{plan['id']}", headers=auth_header(seed["admin_token"]))
|
||||||
|
assert single.status_code == 200, single.text
|
||||||
|
assert single.json()["id"] == plan["id"]
|
||||||
|
assert single.json()["event_data"]["code"] == "TASK-1"
|
||||||
|
|
||||||
|
def test_edit_plan_detaches_future_materialized_slots(self, client, db, seed):
|
||||||
|
plan = _create_plan(db, user_id=seed["admin_user"].id, at_time=time(9, 0))
|
||||||
|
future_slot = _create_slot(db, user_id=seed["admin_user"].id, slot_date=FUTURE_DATE, plan_id=plan.id)
|
||||||
|
|
||||||
|
r = client.patch(
|
||||||
|
f"/calendar/plans/{plan.id}",
|
||||||
|
json={"at_time": "10:15:00", "estimated_duration": 25},
|
||||||
|
headers=auth_header(seed["admin_token"]),
|
||||||
|
)
|
||||||
|
assert r.status_code == 200, r.text
|
||||||
|
data = r.json()
|
||||||
|
assert data["at_time"] == "10:15:00"
|
||||||
|
assert data["estimated_duration"] == 25
|
||||||
|
|
||||||
|
db.refresh(future_slot)
|
||||||
|
assert future_slot.plan_id is None
|
||||||
|
|
||||||
|
def test_cancel_plan_deactivates_and_preserves_past_ids_list(self, client, db, seed):
|
||||||
|
plan = _create_plan(db, user_id=seed["admin_user"].id, at_time=time(9, 0))
|
||||||
|
future_slot = _create_slot(db, user_id=seed["admin_user"].id, slot_date=FUTURE_DATE, plan_id=plan.id)
|
||||||
|
|
||||||
|
r = client.post(
|
||||||
|
f"/calendar/plans/{plan.id}/cancel",
|
||||||
|
headers=auth_header(seed["admin_token"]),
|
||||||
|
)
|
||||||
|
assert r.status_code == 200, r.text
|
||||||
|
data = r.json()
|
||||||
|
assert data["plan"]["is_active"] is False
|
||||||
|
assert isinstance(data["preserved_past_slot_ids"], list)
|
||||||
|
|
||||||
|
db.refresh(future_slot)
|
||||||
|
assert future_slot.plan_id is None
|
||||||
|
|
||||||
|
def test_list_plans_include_inactive(self, client, db, seed):
|
||||||
|
active = _create_plan(db, user_id=seed["admin_user"].id, at_time=time(9, 0))
|
||||||
|
inactive = _create_plan(db, user_id=seed["admin_user"].id, at_time=time(10, 0))
|
||||||
|
inactive.is_active = False
|
||||||
|
db.commit()
|
||||||
|
|
||||||
|
active_only = client.get("/calendar/plans", headers=auth_header(seed["admin_token"]))
|
||||||
|
assert active_only.status_code == 200
|
||||||
|
assert [p["id"] for p in active_only.json()["plans"]] == [active.id]
|
||||||
|
|
||||||
|
with_inactive = client.get(
|
||||||
|
"/calendar/plans?include_inactive=true",
|
||||||
|
headers=auth_header(seed["admin_token"]),
|
||||||
|
)
|
||||||
|
assert with_inactive.status_code == 200
|
||||||
|
ids = {p["id"] for p in with_inactive.json()["plans"]}
|
||||||
|
assert ids == {active.id, inactive.id}
|
||||||
|
|
||||||
|
|
||||||
|
class TestWorkloadConfigApi:
|
||||||
|
def test_user_workload_config_put_patch_get(self, client, seed):
|
||||||
|
put = client.put(
|
||||||
|
"/calendar/workload-config",
|
||||||
|
json={
|
||||||
|
"daily": {"work": 60, "on_call": 10, "entertainment": 5},
|
||||||
|
"weekly": {"work": 300, "on_call": 20, "entertainment": 15},
|
||||||
|
"monthly": {"work": 900, "on_call": 60, "entertainment": 45},
|
||||||
|
"yearly": {"work": 10000, "on_call": 200, "entertainment": 100},
|
||||||
|
},
|
||||||
|
headers=auth_header(seed["admin_token"]),
|
||||||
|
)
|
||||||
|
assert put.status_code == 200, put.text
|
||||||
|
assert put.json()["config"]["daily"]["work"] == 60
|
||||||
|
|
||||||
|
patch = client.patch(
|
||||||
|
"/calendar/workload-config",
|
||||||
|
json={"daily": {"work": 90, "on_call": 10, "entertainment": 5}},
|
||||||
|
headers=auth_header(seed["admin_token"]),
|
||||||
|
)
|
||||||
|
assert patch.status_code == 200, patch.text
|
||||||
|
assert patch.json()["config"]["daily"]["work"] == 90
|
||||||
|
assert patch.json()["config"]["weekly"]["work"] == 300
|
||||||
|
|
||||||
|
get = client.get("/calendar/workload-config", headers=auth_header(seed["admin_token"]))
|
||||||
|
assert get.status_code == 200, get.text
|
||||||
|
assert get.json()["config"]["daily"]["work"] == 90
|
||||||
|
|
||||||
|
def test_admin_can_manage_other_user_workload_config(self, client, seed):
|
||||||
|
patch = client.patch(
|
||||||
|
f"/calendar/workload-config/{seed['dev_user'].id}",
|
||||||
|
json={"daily": {"work": 45, "on_call": 0, "entertainment": 0}},
|
||||||
|
headers=auth_header(seed["admin_token"]),
|
||||||
|
)
|
||||||
|
assert patch.status_code == 200, patch.text
|
||||||
|
assert patch.json()["user_id"] == seed["dev_user"].id
|
||||||
|
assert patch.json()["config"]["daily"]["work"] == 45
|
||||||
|
|
||||||
|
get = client.get(
|
||||||
|
f"/calendar/workload-config/{seed['dev_user'].id}",
|
||||||
|
headers=auth_header(seed["admin_token"]),
|
||||||
|
)
|
||||||
|
assert get.status_code == 200, get.text
|
||||||
|
assert get.json()["config"]["daily"]["work"] == 45
|
||||||
|
|
||||||
|
def test_non_admin_cannot_manage_other_user_workload_config(self, client, seed):
|
||||||
|
r = client.get(
|
||||||
|
f"/calendar/workload-config/{seed['admin_user'].id}",
|
||||||
|
headers=auth_header(seed["dev_token"]),
|
||||||
|
)
|
||||||
|
assert r.status_code == 403, r.text
|
||||||
848
tests/test_calendar_models.py
Normal file
848
tests/test_calendar_models.py
Normal file
@@ -0,0 +1,848 @@
|
|||||||
|
"""Tests for BE-CAL-001: Calendar model definitions.
|
||||||
|
|
||||||
|
Covers:
|
||||||
|
- TimeSlot model creation and fields
|
||||||
|
- SchedulePlan model creation and fields
|
||||||
|
- Enum validations
|
||||||
|
- Model relationships
|
||||||
|
- DB constraints (check constraints, foreign keys)
|
||||||
|
"""
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
from datetime import date, time, datetime
|
||||||
|
from sqlalchemy.exc import IntegrityError
|
||||||
|
|
||||||
|
from app.models.calendar import (
|
||||||
|
TimeSlot,
|
||||||
|
SchedulePlan,
|
||||||
|
SlotType,
|
||||||
|
SlotStatus,
|
||||||
|
EventType,
|
||||||
|
DayOfWeek,
|
||||||
|
MonthOfYear,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# TimeSlot Model Tests
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
class TestTimeSlotModel:
|
||||||
|
"""Tests for TimeSlot ORM model."""
|
||||||
|
|
||||||
|
def test_create_timeslot_basic(self, db, seed):
|
||||||
|
"""Test creating a basic TimeSlot with required fields."""
|
||||||
|
slot = TimeSlot(
|
||||||
|
user_id=seed["admin_user"].id,
|
||||||
|
date=date(2026, 4, 1),
|
||||||
|
slot_type=SlotType.WORK,
|
||||||
|
estimated_duration=30,
|
||||||
|
scheduled_at=time(9, 0),
|
||||||
|
status=SlotStatus.NOT_STARTED,
|
||||||
|
priority=0,
|
||||||
|
)
|
||||||
|
db.add(slot)
|
||||||
|
db.commit()
|
||||||
|
db.refresh(slot)
|
||||||
|
|
||||||
|
assert slot.id is not None
|
||||||
|
assert slot.user_id == seed["admin_user"].id
|
||||||
|
assert slot.date == date(2026, 4, 1)
|
||||||
|
assert slot.slot_type == SlotType.WORK
|
||||||
|
assert slot.estimated_duration == 30
|
||||||
|
assert slot.scheduled_at == time(9, 0)
|
||||||
|
assert slot.status == SlotStatus.NOT_STARTED
|
||||||
|
assert slot.priority == 0
|
||||||
|
assert slot.attended is False
|
||||||
|
assert slot.plan_id is None
|
||||||
|
|
||||||
|
def test_create_timeslot_all_fields(self, db, seed):
|
||||||
|
"""Test creating a TimeSlot with all optional fields."""
|
||||||
|
slot = TimeSlot(
|
||||||
|
user_id=seed["dev_user"].id,
|
||||||
|
date=date(2026, 4, 1),
|
||||||
|
slot_type=SlotType.ON_CALL,
|
||||||
|
estimated_duration=45,
|
||||||
|
scheduled_at=time(14, 30),
|
||||||
|
started_at=time(14, 35),
|
||||||
|
attended=True,
|
||||||
|
actual_duration=40,
|
||||||
|
event_type=EventType.JOB,
|
||||||
|
event_data={"type": "Task", "code": "TASK-42"},
|
||||||
|
priority=5,
|
||||||
|
status=SlotStatus.FINISHED,
|
||||||
|
)
|
||||||
|
db.add(slot)
|
||||||
|
db.commit()
|
||||||
|
db.refresh(slot)
|
||||||
|
|
||||||
|
assert slot.started_at == time(14, 35)
|
||||||
|
assert slot.attended is True
|
||||||
|
assert slot.actual_duration == 40
|
||||||
|
assert slot.event_type == EventType.JOB
|
||||||
|
assert slot.event_data == {"type": "Task", "code": "TASK-42"}
|
||||||
|
assert slot.priority == 5
|
||||||
|
assert slot.status == SlotStatus.FINISHED
|
||||||
|
|
||||||
|
def test_timeslot_slot_type_variants(self, db, seed):
|
||||||
|
"""Test all SlotType enum variants."""
|
||||||
|
for idx, slot_type in enumerate(SlotType):
|
||||||
|
slot = TimeSlot(
|
||||||
|
user_id=seed["admin_user"].id,
|
||||||
|
date=date(2026, 4, 1),
|
||||||
|
slot_type=slot_type,
|
||||||
|
estimated_duration=10,
|
||||||
|
scheduled_at=time(idx, 0),
|
||||||
|
status=SlotStatus.NOT_STARTED,
|
||||||
|
priority=idx,
|
||||||
|
)
|
||||||
|
db.add(slot)
|
||||||
|
db.commit()
|
||||||
|
|
||||||
|
slots = db.query(TimeSlot).filter_by(user_id=seed["admin_user"].id).all()
|
||||||
|
assert len(slots) == 4
|
||||||
|
assert {s.slot_type for s in slots} == set(SlotType)
|
||||||
|
|
||||||
|
def test_timeslot_status_transitions(self, db, seed):
|
||||||
|
"""Test all SlotStatus enum variants."""
|
||||||
|
for idx, status in enumerate(SlotStatus):
|
||||||
|
slot = TimeSlot(
|
||||||
|
user_id=seed["admin_user"].id,
|
||||||
|
date=date(2026, 4, 1),
|
||||||
|
slot_type=SlotType.WORK,
|
||||||
|
estimated_duration=10,
|
||||||
|
scheduled_at=time(idx, 0),
|
||||||
|
status=status,
|
||||||
|
priority=0,
|
||||||
|
)
|
||||||
|
db.add(slot)
|
||||||
|
db.commit()
|
||||||
|
|
||||||
|
slots = db.query(TimeSlot).filter_by(user_id=seed["admin_user"].id).all()
|
||||||
|
assert len(slots) == 7
|
||||||
|
assert {s.status for s in slots} == set(SlotStatus)
|
||||||
|
|
||||||
|
def test_timeslot_event_type_variants(self, db, seed):
|
||||||
|
"""Test all EventType enum variants."""
|
||||||
|
for idx, event_type in enumerate(EventType):
|
||||||
|
slot = TimeSlot(
|
||||||
|
user_id=seed["admin_user"].id,
|
||||||
|
date=date(2026, 4, 1),
|
||||||
|
slot_type=SlotType.WORK,
|
||||||
|
estimated_duration=10,
|
||||||
|
scheduled_at=time(idx, 0),
|
||||||
|
status=SlotStatus.NOT_STARTED,
|
||||||
|
event_type=event_type,
|
||||||
|
priority=0,
|
||||||
|
)
|
||||||
|
db.add(slot)
|
||||||
|
db.commit()
|
||||||
|
|
||||||
|
slots = db.query(TimeSlot).filter_by(user_id=seed["admin_user"].id).all()
|
||||||
|
assert len(slots) == 3
|
||||||
|
assert {s.event_type for s in slots} == set(EventType)
|
||||||
|
|
||||||
|
def test_timeslot_nullable_event_type(self, db, seed):
|
||||||
|
"""Test that event_type can be NULL."""
|
||||||
|
slot = TimeSlot(
|
||||||
|
user_id=seed["admin_user"].id,
|
||||||
|
date=date(2026, 4, 1),
|
||||||
|
slot_type=SlotType.WORK,
|
||||||
|
estimated_duration=30,
|
||||||
|
scheduled_at=time(9, 0),
|
||||||
|
status=SlotStatus.NOT_STARTED,
|
||||||
|
event_type=None,
|
||||||
|
priority=0,
|
||||||
|
)
|
||||||
|
db.add(slot)
|
||||||
|
db.commit()
|
||||||
|
db.refresh(slot)
|
||||||
|
|
||||||
|
assert slot.event_type is None
|
||||||
|
assert slot.event_data is None
|
||||||
|
|
||||||
|
def test_timeslot_duration_bounds(self, db, seed):
|
||||||
|
"""Test duration at boundary values (1-50)."""
|
||||||
|
# Min duration
|
||||||
|
slot_min = TimeSlot(
|
||||||
|
user_id=seed["admin_user"].id,
|
||||||
|
date=date(2026, 4, 1),
|
||||||
|
slot_type=SlotType.WORK,
|
||||||
|
estimated_duration=1,
|
||||||
|
scheduled_at=time(8, 0),
|
||||||
|
status=SlotStatus.NOT_STARTED,
|
||||||
|
priority=0,
|
||||||
|
)
|
||||||
|
db.add(slot_min)
|
||||||
|
|
||||||
|
# Max duration
|
||||||
|
slot_max = TimeSlot(
|
||||||
|
user_id=seed["admin_user"].id,
|
||||||
|
date=date(2026, 4, 1),
|
||||||
|
slot_type=SlotType.WORK,
|
||||||
|
estimated_duration=50,
|
||||||
|
scheduled_at=time(9, 0),
|
||||||
|
status=SlotStatus.NOT_STARTED,
|
||||||
|
priority=0,
|
||||||
|
)
|
||||||
|
db.add(slot_max)
|
||||||
|
db.commit()
|
||||||
|
|
||||||
|
assert slot_min.estimated_duration == 1
|
||||||
|
assert slot_max.estimated_duration == 50
|
||||||
|
|
||||||
|
def test_timeslot_priority_bounds(self, db, seed):
|
||||||
|
"""Test priority at boundary values (0-99)."""
|
||||||
|
slot_low = TimeSlot(
|
||||||
|
user_id=seed["admin_user"].id,
|
||||||
|
date=date(2026, 4, 1),
|
||||||
|
slot_type=SlotType.WORK,
|
||||||
|
estimated_duration=10,
|
||||||
|
scheduled_at=time(8, 0),
|
||||||
|
status=SlotStatus.NOT_STARTED,
|
||||||
|
priority=0,
|
||||||
|
)
|
||||||
|
db.add(slot_low)
|
||||||
|
|
||||||
|
slot_high = TimeSlot(
|
||||||
|
user_id=seed["admin_user"].id,
|
||||||
|
date=date(2026, 4, 1),
|
||||||
|
slot_type=SlotType.WORK,
|
||||||
|
estimated_duration=10,
|
||||||
|
scheduled_at=time(9, 0),
|
||||||
|
status=SlotStatus.NOT_STARTED,
|
||||||
|
priority=99,
|
||||||
|
)
|
||||||
|
db.add(slot_high)
|
||||||
|
db.commit()
|
||||||
|
|
||||||
|
assert slot_low.priority == 0
|
||||||
|
assert slot_high.priority == 99
|
||||||
|
|
||||||
|
def test_timeslot_timestamps_auto_set(self, db, seed):
|
||||||
|
"""Test that created_at and updated_at are set automatically."""
|
||||||
|
slot = TimeSlot(
|
||||||
|
user_id=seed["admin_user"].id,
|
||||||
|
date=date(2026, 4, 1),
|
||||||
|
slot_type=SlotType.WORK,
|
||||||
|
estimated_duration=30,
|
||||||
|
scheduled_at=time(9, 0),
|
||||||
|
status=SlotStatus.NOT_STARTED,
|
||||||
|
priority=0,
|
||||||
|
)
|
||||||
|
db.add(slot)
|
||||||
|
db.commit()
|
||||||
|
db.refresh(slot)
|
||||||
|
|
||||||
|
assert slot.created_at is not None
|
||||||
|
assert isinstance(slot.created_at, datetime)
|
||||||
|
|
||||||
|
def test_timeslot_user_foreign_key(self, db):
|
||||||
|
"""Test that invalid user_id raises IntegrityError."""
|
||||||
|
slot = TimeSlot(
|
||||||
|
user_id=99999, # Non-existent user
|
||||||
|
date=date(2026, 4, 1),
|
||||||
|
slot_type=SlotType.WORK,
|
||||||
|
estimated_duration=30,
|
||||||
|
scheduled_at=time(9, 0),
|
||||||
|
status=SlotStatus.NOT_STARTED,
|
||||||
|
priority=0,
|
||||||
|
)
|
||||||
|
db.add(slot)
|
||||||
|
with pytest.raises(IntegrityError):
|
||||||
|
db.commit()
|
||||||
|
|
||||||
|
def test_timeslot_plan_relationship(self, db, seed):
|
||||||
|
"""Test relationship between TimeSlot and SchedulePlan."""
|
||||||
|
# Create a plan first
|
||||||
|
plan = SchedulePlan(
|
||||||
|
user_id=seed["admin_user"].id,
|
||||||
|
slot_type=SlotType.WORK,
|
||||||
|
estimated_duration=30,
|
||||||
|
at_time=time(9, 0),
|
||||||
|
is_active=True,
|
||||||
|
)
|
||||||
|
db.add(plan)
|
||||||
|
db.commit()
|
||||||
|
db.refresh(plan)
|
||||||
|
|
||||||
|
# Create a slot linked to the plan
|
||||||
|
slot = TimeSlot(
|
||||||
|
user_id=seed["admin_user"].id,
|
||||||
|
date=date(2026, 4, 1),
|
||||||
|
slot_type=SlotType.WORK,
|
||||||
|
estimated_duration=30,
|
||||||
|
scheduled_at=time(9, 0),
|
||||||
|
status=SlotStatus.NOT_STARTED,
|
||||||
|
priority=0,
|
||||||
|
plan_id=plan.id,
|
||||||
|
)
|
||||||
|
db.add(slot)
|
||||||
|
db.commit()
|
||||||
|
db.refresh(slot)
|
||||||
|
|
||||||
|
assert slot.plan_id == plan.id
|
||||||
|
assert slot.plan.id == plan.id
|
||||||
|
assert slot.plan.user_id == seed["admin_user"].id
|
||||||
|
|
||||||
|
def test_timeslot_query_by_date(self, db, seed):
|
||||||
|
"""Test querying slots by date."""
|
||||||
|
dates = [date(2026, 4, 1), date(2026, 4, 2), date(2026, 4, 1)]
|
||||||
|
for idx, d in enumerate(dates):
|
||||||
|
slot = TimeSlot(
|
||||||
|
user_id=seed["admin_user"].id,
|
||||||
|
date=d,
|
||||||
|
slot_type=SlotType.WORK,
|
||||||
|
estimated_duration=30,
|
||||||
|
scheduled_at=time(9 + idx, 0),
|
||||||
|
status=SlotStatus.NOT_STARTED,
|
||||||
|
priority=0,
|
||||||
|
)
|
||||||
|
db.add(slot)
|
||||||
|
db.commit()
|
||||||
|
|
||||||
|
slots_april_1 = db.query(TimeSlot).filter_by(
|
||||||
|
user_id=seed["admin_user"].id,
|
||||||
|
date=date(2026, 4, 1)
|
||||||
|
).all()
|
||||||
|
assert len(slots_april_1) == 2
|
||||||
|
|
||||||
|
def test_timeslot_query_by_status(self, db, seed):
|
||||||
|
"""Test querying slots by status."""
|
||||||
|
for idx, status in enumerate([SlotStatus.NOT_STARTED, SlotStatus.ONGOING, SlotStatus.NOT_STARTED]):
|
||||||
|
slot = TimeSlot(
|
||||||
|
user_id=seed["admin_user"].id,
|
||||||
|
date=date(2026, 4, 1),
|
||||||
|
slot_type=SlotType.WORK,
|
||||||
|
estimated_duration=30,
|
||||||
|
scheduled_at=time(9 + idx, 0),
|
||||||
|
status=status,
|
||||||
|
priority=0,
|
||||||
|
)
|
||||||
|
db.add(slot)
|
||||||
|
db.commit()
|
||||||
|
|
||||||
|
not_started = db.query(TimeSlot).filter_by(
|
||||||
|
user_id=seed["admin_user"].id,
|
||||||
|
status=SlotStatus.NOT_STARTED
|
||||||
|
).all()
|
||||||
|
assert len(not_started) == 2
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# SchedulePlan Model Tests
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
class TestSchedulePlanModel:
|
||||||
|
"""Tests for SchedulePlan ORM model."""
|
||||||
|
|
||||||
|
def test_create_plan_basic(self, db, seed):
|
||||||
|
"""Test creating a basic SchedulePlan with required fields."""
|
||||||
|
plan = SchedulePlan(
|
||||||
|
user_id=seed["admin_user"].id,
|
||||||
|
slot_type=SlotType.WORK,
|
||||||
|
estimated_duration=30,
|
||||||
|
at_time=time(9, 0),
|
||||||
|
is_active=True,
|
||||||
|
)
|
||||||
|
db.add(plan)
|
||||||
|
db.commit()
|
||||||
|
db.refresh(plan)
|
||||||
|
|
||||||
|
assert plan.id is not None
|
||||||
|
assert plan.user_id == seed["admin_user"].id
|
||||||
|
assert plan.slot_type == SlotType.WORK
|
||||||
|
assert plan.estimated_duration == 30
|
||||||
|
assert plan.at_time == time(9, 0)
|
||||||
|
assert plan.is_active is True
|
||||||
|
assert plan.on_day is None
|
||||||
|
assert plan.on_week is None
|
||||||
|
assert plan.on_month is None
|
||||||
|
assert plan.event_type is None
|
||||||
|
assert plan.event_data is None
|
||||||
|
|
||||||
|
def test_create_plan_daily(self, db, seed):
|
||||||
|
"""Test creating a daily plan (--at only)."""
|
||||||
|
plan = SchedulePlan(
|
||||||
|
user_id=seed["admin_user"].id,
|
||||||
|
slot_type=SlotType.WORK,
|
||||||
|
estimated_duration=25,
|
||||||
|
at_time=time(10, 0),
|
||||||
|
is_active=True,
|
||||||
|
)
|
||||||
|
db.add(plan)
|
||||||
|
db.commit()
|
||||||
|
db.refresh(plan)
|
||||||
|
|
||||||
|
assert plan.at_time == time(10, 0)
|
||||||
|
assert plan.on_day is None
|
||||||
|
assert plan.on_week is None
|
||||||
|
assert plan.on_month is None
|
||||||
|
|
||||||
|
def test_create_plan_weekly(self, db, seed):
|
||||||
|
"""Test creating a weekly plan (--at + --on-day)."""
|
||||||
|
plan = SchedulePlan(
|
||||||
|
user_id=seed["admin_user"].id,
|
||||||
|
slot_type=SlotType.ON_CALL,
|
||||||
|
estimated_duration=45,
|
||||||
|
at_time=time(14, 0),
|
||||||
|
on_day=DayOfWeek.MON,
|
||||||
|
is_active=True,
|
||||||
|
)
|
||||||
|
db.add(plan)
|
||||||
|
db.commit()
|
||||||
|
db.refresh(plan)
|
||||||
|
|
||||||
|
assert plan.on_day == DayOfWeek.MON
|
||||||
|
assert plan.on_week is None
|
||||||
|
assert plan.on_month is None
|
||||||
|
|
||||||
|
def test_create_plan_monthly(self, db, seed):
|
||||||
|
"""Test creating a monthly plan (--at + --on-day + --on-week)."""
|
||||||
|
plan = SchedulePlan(
|
||||||
|
user_id=seed["admin_user"].id,
|
||||||
|
slot_type=SlotType.ENTERTAINMENT,
|
||||||
|
estimated_duration=45,
|
||||||
|
at_time=time(19, 0),
|
||||||
|
on_day=DayOfWeek.FRI,
|
||||||
|
on_week=2,
|
||||||
|
is_active=True,
|
||||||
|
)
|
||||||
|
db.add(plan)
|
||||||
|
db.commit()
|
||||||
|
db.refresh(plan)
|
||||||
|
|
||||||
|
assert plan.on_day == DayOfWeek.FRI
|
||||||
|
assert plan.on_week == 2
|
||||||
|
assert plan.on_month is None
|
||||||
|
|
||||||
|
def test_create_plan_yearly(self, db, seed):
|
||||||
|
"""Test creating a yearly plan (all period params)."""
|
||||||
|
plan = SchedulePlan(
|
||||||
|
user_id=seed["admin_user"].id,
|
||||||
|
slot_type=SlotType.WORK,
|
||||||
|
estimated_duration=50,
|
||||||
|
at_time=time(9, 0),
|
||||||
|
on_day=DayOfWeek.SUN,
|
||||||
|
on_week=1,
|
||||||
|
on_month=MonthOfYear.JAN,
|
||||||
|
is_active=True,
|
||||||
|
)
|
||||||
|
db.add(plan)
|
||||||
|
db.commit()
|
||||||
|
db.refresh(plan)
|
||||||
|
|
||||||
|
assert plan.on_day == DayOfWeek.SUN
|
||||||
|
assert plan.on_week == 1
|
||||||
|
assert plan.on_month == MonthOfYear.JAN
|
||||||
|
|
||||||
|
def test_create_plan_with_event(self, db, seed):
|
||||||
|
"""Test creating a plan with event_type and event_data."""
|
||||||
|
plan = SchedulePlan(
|
||||||
|
user_id=seed["admin_user"].id,
|
||||||
|
slot_type=SlotType.WORK,
|
||||||
|
estimated_duration=30,
|
||||||
|
at_time=time(9, 0),
|
||||||
|
event_type=EventType.JOB,
|
||||||
|
event_data={"type": "Meeting", "participants": ["user1", "user2"]},
|
||||||
|
is_active=True,
|
||||||
|
)
|
||||||
|
db.add(plan)
|
||||||
|
db.commit()
|
||||||
|
db.refresh(plan)
|
||||||
|
|
||||||
|
assert plan.event_type == EventType.JOB
|
||||||
|
assert plan.event_data == {"type": "Meeting", "participants": ["user1", "user2"]}
|
||||||
|
|
||||||
|
def test_plan_slot_type_variants(self, db, seed):
|
||||||
|
"""Test all SlotType enum variants for SchedulePlan."""
|
||||||
|
for idx, slot_type in enumerate(SlotType):
|
||||||
|
plan = SchedulePlan(
|
||||||
|
user_id=seed["admin_user"].id,
|
||||||
|
slot_type=slot_type,
|
||||||
|
estimated_duration=10,
|
||||||
|
at_time=time(idx, 0),
|
||||||
|
is_active=True,
|
||||||
|
)
|
||||||
|
db.add(plan)
|
||||||
|
db.commit()
|
||||||
|
|
||||||
|
plans = db.query(SchedulePlan).filter_by(user_id=seed["admin_user"].id).all()
|
||||||
|
assert len(plans) == 4
|
||||||
|
assert {p.slot_type for p in plans} == set(SlotType)
|
||||||
|
|
||||||
|
def test_plan_on_week_validation(self, db, seed):
|
||||||
|
"""Test on_week validation (must be 1-4)."""
|
||||||
|
# Valid values
|
||||||
|
for week in [1, 2, 3, 4]:
|
||||||
|
plan = SchedulePlan(
|
||||||
|
user_id=seed["admin_user"].id,
|
||||||
|
slot_type=SlotType.WORK,
|
||||||
|
estimated_duration=30,
|
||||||
|
at_time=time(9, 0),
|
||||||
|
on_day=DayOfWeek.MON,
|
||||||
|
on_week=week,
|
||||||
|
is_active=True,
|
||||||
|
)
|
||||||
|
db.add(plan)
|
||||||
|
db.commit()
|
||||||
|
|
||||||
|
plans = db.query(SchedulePlan).filter_by(user_id=seed["admin_user"].id).all()
|
||||||
|
assert len(plans) == 4
|
||||||
|
assert {p.on_week for p in plans} == {1, 2, 3, 4}
|
||||||
|
|
||||||
|
def test_plan_on_week_validation_invalid(self, db, seed):
|
||||||
|
"""Test that invalid on_week values raise ValueError."""
|
||||||
|
for week in [0, 5, 10, -1]:
|
||||||
|
with pytest.raises(ValueError):
|
||||||
|
plan = SchedulePlan(
|
||||||
|
user_id=seed["admin_user"].id,
|
||||||
|
slot_type=SlotType.WORK,
|
||||||
|
estimated_duration=30,
|
||||||
|
at_time=time(9, 0),
|
||||||
|
on_day=DayOfWeek.MON,
|
||||||
|
on_week=week, # Invalid
|
||||||
|
is_active=True,
|
||||||
|
)
|
||||||
|
db.add(plan)
|
||||||
|
db.commit()
|
||||||
|
db.rollback()
|
||||||
|
|
||||||
|
def test_plan_duration_validation(self, db, seed):
|
||||||
|
"""Test estimated_duration validation (must be 1-50)."""
|
||||||
|
# Valid bounds
|
||||||
|
plan_min = SchedulePlan(
|
||||||
|
user_id=seed["admin_user"].id,
|
||||||
|
slot_type=SlotType.WORK,
|
||||||
|
estimated_duration=1,
|
||||||
|
at_time=time(8, 0),
|
||||||
|
is_active=True,
|
||||||
|
)
|
||||||
|
db.add(plan_min)
|
||||||
|
|
||||||
|
plan_max = SchedulePlan(
|
||||||
|
user_id=seed["admin_user"].id,
|
||||||
|
slot_type=SlotType.WORK,
|
||||||
|
estimated_duration=50,
|
||||||
|
at_time=time(9, 0),
|
||||||
|
is_active=True,
|
||||||
|
)
|
||||||
|
db.add(plan_max)
|
||||||
|
db.commit()
|
||||||
|
|
||||||
|
assert plan_min.estimated_duration == 1
|
||||||
|
assert plan_max.estimated_duration == 50
|
||||||
|
|
||||||
|
def test_plan_duration_validation_invalid(self, db, seed):
|
||||||
|
"""Test that invalid estimated_duration raises ValueError."""
|
||||||
|
for duration in [0, 51, 100, -10]:
|
||||||
|
with pytest.raises(ValueError):
|
||||||
|
plan = SchedulePlan(
|
||||||
|
user_id=seed["admin_user"].id,
|
||||||
|
slot_type=SlotType.WORK,
|
||||||
|
estimated_duration=duration,
|
||||||
|
at_time=time(9, 0),
|
||||||
|
is_active=True,
|
||||||
|
)
|
||||||
|
db.add(plan)
|
||||||
|
db.commit()
|
||||||
|
db.rollback()
|
||||||
|
|
||||||
|
def test_plan_hierarchy_constraint_month_requires_week(self, db, seed):
|
||||||
|
"""Test validation: on_month requires on_week."""
|
||||||
|
with pytest.raises(ValueError, match="on_month requires on_week"):
|
||||||
|
SchedulePlan(
|
||||||
|
user_id=seed["admin_user"].id,
|
||||||
|
slot_type=SlotType.WORK,
|
||||||
|
estimated_duration=30,
|
||||||
|
at_time=time(9, 0),
|
||||||
|
on_month=MonthOfYear.JAN, # Without on_week
|
||||||
|
is_active=True,
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_plan_hierarchy_constraint_week_requires_day(self, db, seed):
|
||||||
|
"""Test DB constraint: on_week requires on_day."""
|
||||||
|
plan = SchedulePlan(
|
||||||
|
user_id=seed["admin_user"].id,
|
||||||
|
slot_type=SlotType.WORK,
|
||||||
|
estimated_duration=30,
|
||||||
|
at_time=time(9, 0),
|
||||||
|
on_week=1, # Without on_day
|
||||||
|
is_active=True,
|
||||||
|
)
|
||||||
|
db.add(plan)
|
||||||
|
with pytest.raises(IntegrityError):
|
||||||
|
db.commit()
|
||||||
|
|
||||||
|
def test_plan_day_of_week_enum(self, db, seed):
|
||||||
|
"""Test all DayOfWeek enum values."""
|
||||||
|
for day in DayOfWeek:
|
||||||
|
plan = SchedulePlan(
|
||||||
|
user_id=seed["admin_user"].id,
|
||||||
|
slot_type=SlotType.WORK,
|
||||||
|
estimated_duration=10,
|
||||||
|
at_time=time(9, 0),
|
||||||
|
on_day=day,
|
||||||
|
is_active=True,
|
||||||
|
)
|
||||||
|
db.add(plan)
|
||||||
|
db.commit()
|
||||||
|
|
||||||
|
plans = db.query(SchedulePlan).filter_by(user_id=seed["admin_user"].id).all()
|
||||||
|
assert len(plans) == 7
|
||||||
|
assert {p.on_day for p in plans} == set(DayOfWeek)
|
||||||
|
|
||||||
|
def test_plan_month_of_year_enum(self, db, seed):
|
||||||
|
"""Test all MonthOfYear enum values."""
|
||||||
|
for month in MonthOfYear:
|
||||||
|
plan = SchedulePlan(
|
||||||
|
user_id=seed["admin_user"].id,
|
||||||
|
slot_type=SlotType.WORK,
|
||||||
|
estimated_duration=10,
|
||||||
|
at_time=time(9, 0),
|
||||||
|
on_day=DayOfWeek.MON,
|
||||||
|
on_week=1,
|
||||||
|
on_month=month,
|
||||||
|
is_active=True,
|
||||||
|
)
|
||||||
|
db.add(plan)
|
||||||
|
db.commit()
|
||||||
|
|
||||||
|
plans = db.query(SchedulePlan).filter_by(user_id=seed["admin_user"].id).all()
|
||||||
|
assert len(plans) == 12
|
||||||
|
assert {p.on_month for p in plans} == set(MonthOfYear)
|
||||||
|
|
||||||
|
def test_plan_materialized_slots_relationship(self, db, seed):
|
||||||
|
"""Test relationship between SchedulePlan and TimeSlot."""
|
||||||
|
plan = SchedulePlan(
|
||||||
|
user_id=seed["admin_user"].id,
|
||||||
|
slot_type=SlotType.WORK,
|
||||||
|
estimated_duration=30,
|
||||||
|
at_time=time(9, 0),
|
||||||
|
is_active=True,
|
||||||
|
)
|
||||||
|
db.add(plan)
|
||||||
|
db.commit()
|
||||||
|
db.refresh(plan)
|
||||||
|
|
||||||
|
# Create slots linked to the plan
|
||||||
|
for i in range(3):
|
||||||
|
slot = TimeSlot(
|
||||||
|
user_id=seed["admin_user"].id,
|
||||||
|
date=date(2026, 4, 1 + i),
|
||||||
|
slot_type=SlotType.WORK,
|
||||||
|
estimated_duration=30,
|
||||||
|
scheduled_at=time(9, 0),
|
||||||
|
status=SlotStatus.NOT_STARTED,
|
||||||
|
priority=0,
|
||||||
|
plan_id=plan.id,
|
||||||
|
)
|
||||||
|
db.add(slot)
|
||||||
|
db.commit()
|
||||||
|
|
||||||
|
# Refresh to get relationship
|
||||||
|
db.refresh(plan)
|
||||||
|
materialized = plan.materialized_slots.all()
|
||||||
|
assert len(materialized) == 3
|
||||||
|
assert all(s.plan_id == plan.id for s in materialized)
|
||||||
|
|
||||||
|
def test_plan_is_active_default_true(self, db, seed):
|
||||||
|
"""Test that is_active defaults to True."""
|
||||||
|
plan = SchedulePlan(
|
||||||
|
user_id=seed["admin_user"].id,
|
||||||
|
slot_type=SlotType.WORK,
|
||||||
|
estimated_duration=30,
|
||||||
|
at_time=time(9, 0),
|
||||||
|
)
|
||||||
|
db.add(plan)
|
||||||
|
db.commit()
|
||||||
|
db.refresh(plan)
|
||||||
|
|
||||||
|
assert plan.is_active is True
|
||||||
|
|
||||||
|
def test_plan_soft_delete(self, db, seed):
|
||||||
|
"""Test soft delete by setting is_active=False."""
|
||||||
|
plan = SchedulePlan(
|
||||||
|
user_id=seed["admin_user"].id,
|
||||||
|
slot_type=SlotType.WORK,
|
||||||
|
estimated_duration=30,
|
||||||
|
at_time=time(9, 0),
|
||||||
|
is_active=True,
|
||||||
|
)
|
||||||
|
db.add(plan)
|
||||||
|
db.commit()
|
||||||
|
db.refresh(plan)
|
||||||
|
|
||||||
|
# Soft delete
|
||||||
|
plan.is_active = False
|
||||||
|
db.commit()
|
||||||
|
db.refresh(plan)
|
||||||
|
|
||||||
|
assert plan.is_active is False
|
||||||
|
|
||||||
|
def test_plan_timestamps(self, db, seed):
|
||||||
|
"""Test that created_at is set automatically."""
|
||||||
|
plan = SchedulePlan(
|
||||||
|
user_id=seed["admin_user"].id,
|
||||||
|
slot_type=SlotType.WORK,
|
||||||
|
estimated_duration=30,
|
||||||
|
at_time=time(9, 0),
|
||||||
|
is_active=True,
|
||||||
|
)
|
||||||
|
db.add(plan)
|
||||||
|
db.commit()
|
||||||
|
db.refresh(plan)
|
||||||
|
|
||||||
|
assert plan.created_at is not None
|
||||||
|
assert isinstance(plan.created_at, datetime)
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Combined Model Tests
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
class TestCalendarModelsCombined:
|
||||||
|
"""Tests for interactions between TimeSlot and SchedulePlan."""
|
||||||
|
|
||||||
|
def test_plan_to_slots_cascade_behavior(self, db, seed):
|
||||||
|
"""Test that deleting a plan doesn't delete materialized slots."""
|
||||||
|
plan = SchedulePlan(
|
||||||
|
user_id=seed["admin_user"].id,
|
||||||
|
slot_type=SlotType.WORK,
|
||||||
|
estimated_duration=30,
|
||||||
|
at_time=time(9, 0),
|
||||||
|
is_active=True,
|
||||||
|
)
|
||||||
|
db.add(plan)
|
||||||
|
db.commit()
|
||||||
|
db.refresh(plan)
|
||||||
|
|
||||||
|
# Create slots linked to the plan
|
||||||
|
for i in range(3):
|
||||||
|
slot = TimeSlot(
|
||||||
|
user_id=seed["admin_user"].id,
|
||||||
|
date=date(2026, 4, 1 + i),
|
||||||
|
slot_type=SlotType.WORK,
|
||||||
|
estimated_duration=30,
|
||||||
|
scheduled_at=time(9, 0),
|
||||||
|
status=SlotStatus.NOT_STARTED,
|
||||||
|
priority=0,
|
||||||
|
plan_id=plan.id,
|
||||||
|
)
|
||||||
|
db.add(slot)
|
||||||
|
db.commit()
|
||||||
|
|
||||||
|
# Delete the plan (soft delete)
|
||||||
|
plan.is_active = False
|
||||||
|
db.commit()
|
||||||
|
|
||||||
|
# Slots should still exist
|
||||||
|
slots = db.query(TimeSlot).filter_by(user_id=seed["admin_user"].id).all()
|
||||||
|
assert len(slots) == 3
|
||||||
|
# plan_id should remain (not cascade deleted)
|
||||||
|
assert all(s.plan_id == plan.id for s in slots)
|
||||||
|
|
||||||
|
def test_multiple_plans_per_user(self, db, seed):
|
||||||
|
"""Test that a user can have multiple plans."""
|
||||||
|
for i, slot_type in enumerate([SlotType.WORK, SlotType.ON_CALL, SlotType.ENTERTAINMENT]):
|
||||||
|
plan = SchedulePlan(
|
||||||
|
user_id=seed["admin_user"].id,
|
||||||
|
slot_type=slot_type,
|
||||||
|
estimated_duration=30,
|
||||||
|
at_time=time(9 + i, 0),
|
||||||
|
is_active=True,
|
||||||
|
)
|
||||||
|
db.add(plan)
|
||||||
|
db.commit()
|
||||||
|
|
||||||
|
plans = db.query(SchedulePlan).filter_by(
|
||||||
|
user_id=seed["admin_user"].id,
|
||||||
|
is_active=True
|
||||||
|
).all()
|
||||||
|
assert len(plans) == 3
|
||||||
|
|
||||||
|
def test_multiple_slots_per_user(self, db, seed):
|
||||||
|
"""Test that a user can have multiple slots on same day."""
|
||||||
|
target_date = date(2026, 4, 1)
|
||||||
|
for i in range(5):
|
||||||
|
slot = TimeSlot(
|
||||||
|
user_id=seed["admin_user"].id,
|
||||||
|
date=target_date,
|
||||||
|
slot_type=SlotType.WORK,
|
||||||
|
estimated_duration=10,
|
||||||
|
scheduled_at=time(9 + i, 0),
|
||||||
|
status=SlotStatus.NOT_STARTED,
|
||||||
|
priority=i,
|
||||||
|
)
|
||||||
|
db.add(slot)
|
||||||
|
db.commit()
|
||||||
|
|
||||||
|
slots = db.query(TimeSlot).filter_by(
|
||||||
|
user_id=seed["admin_user"].id,
|
||||||
|
date=target_date
|
||||||
|
).all()
|
||||||
|
assert len(slots) == 5
|
||||||
|
# Check ordering by scheduled_at
|
||||||
|
times = [s.scheduled_at for s in sorted(slots, key=lambda x: x.scheduled_at)]
|
||||||
|
assert times == [time(9, 0), time(10, 0), time(11, 0), time(12, 0), time(13, 0)]
|
||||||
|
|
||||||
|
def test_different_users_isolated(self, db, seed):
|
||||||
|
"""Test that users cannot see each other's slots/plans."""
|
||||||
|
# Create plan and slot for admin
|
||||||
|
admin_plan = SchedulePlan(
|
||||||
|
user_id=seed["admin_user"].id,
|
||||||
|
slot_type=SlotType.WORK,
|
||||||
|
estimated_duration=30,
|
||||||
|
at_time=time(9, 0),
|
||||||
|
is_active=True,
|
||||||
|
)
|
||||||
|
db.add(admin_plan)
|
||||||
|
|
||||||
|
admin_slot = TimeSlot(
|
||||||
|
user_id=seed["admin_user"].id,
|
||||||
|
date=date(2026, 4, 1),
|
||||||
|
slot_type=SlotType.WORK,
|
||||||
|
estimated_duration=30,
|
||||||
|
scheduled_at=time(9, 0),
|
||||||
|
status=SlotStatus.NOT_STARTED,
|
||||||
|
priority=0,
|
||||||
|
)
|
||||||
|
db.add(admin_slot)
|
||||||
|
|
||||||
|
# Create plan and slot for dev user
|
||||||
|
dev_plan = SchedulePlan(
|
||||||
|
user_id=seed["dev_user"].id,
|
||||||
|
slot_type=SlotType.ON_CALL,
|
||||||
|
estimated_duration=45,
|
||||||
|
at_time=time(14, 0),
|
||||||
|
is_active=True,
|
||||||
|
)
|
||||||
|
db.add(dev_plan)
|
||||||
|
|
||||||
|
dev_slot = TimeSlot(
|
||||||
|
user_id=seed["dev_user"].id,
|
||||||
|
date=date(2026, 4, 1),
|
||||||
|
slot_type=SlotType.ON_CALL,
|
||||||
|
estimated_duration=45,
|
||||||
|
scheduled_at=time(14, 0),
|
||||||
|
status=SlotStatus.NOT_STARTED,
|
||||||
|
priority=0,
|
||||||
|
)
|
||||||
|
db.add(dev_slot)
|
||||||
|
|
||||||
|
db.commit()
|
||||||
|
|
||||||
|
# Verify isolation
|
||||||
|
admin_slots = db.query(TimeSlot).filter_by(user_id=seed["admin_user"].id).all()
|
||||||
|
dev_slots = db.query(TimeSlot).filter_by(user_id=seed["dev_user"].id).all()
|
||||||
|
|
||||||
|
assert len(admin_slots) == 1
|
||||||
|
assert len(dev_slots) == 1
|
||||||
|
assert admin_slots[0].slot_type == SlotType.WORK
|
||||||
|
assert dev_slots[0].slot_type == SlotType.ON_CALL
|
||||||
|
|
||||||
|
admin_plans = db.query(SchedulePlan).filter_by(user_id=seed["admin_user"].id).all()
|
||||||
|
dev_plans = db.query(SchedulePlan).filter_by(user_id=seed["dev_user"].id).all()
|
||||||
|
|
||||||
|
assert len(admin_plans) == 1
|
||||||
|
assert len(dev_plans) == 1
|
||||||
164
tests/test_slot_competition.py
Normal file
164
tests/test_slot_competition.py
Normal 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
|
||||||
Reference in New Issue
Block a user