Compare commits
53 Commits
ae353afbed
...
feat/knowl
| Author | SHA1 | Date | |
|---|---|---|---|
| 9feff8e008 | |||
| 88779d2db0 | |||
| cacb1d2652 | |||
| d2b83ad58d | |||
| 01f6b562e1 | |||
| 595391b41b | |||
| 54feb9686c | |||
| 5ea2cdfc9e | |||
| 422b2fa7b7 | |||
| e80ead528d | |||
| f1aafb86df | |||
| 65905e4831 | |||
| 345e0f3a04 | |||
| e5e81d418d | |||
| 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 |
29
.env.example
29
.env.example
@@ -1,11 +1,34 @@
|
||||
# HarborForge Environment Variables
|
||||
# HarborForge Backend Environment Variables (v0.4.0+ — wizard removed)
|
||||
|
||||
# Database
|
||||
# --- Database (used by both the mysql container and the backend) -----------
|
||||
MYSQL_ROOT_PASSWORD=harborforge_root
|
||||
MYSQL_DATABASE=harborforge
|
||||
MYSQL_USER=harborforge
|
||||
MYSQL_PASSWORD=harborforge_pass
|
||||
# Full DSN used by the backend container. Default points to a service
|
||||
# named "mysql" on the same docker network. Override if your DB is elsewhere.
|
||||
DATABASE_URL=mysql+pymysql://harborforge:harborforge_pass@mysql:3306/harborforge
|
||||
|
||||
# Application
|
||||
# --- Application ----------------------------------------------------------
|
||||
# Must be 32+ chars and not a placeholder; use: openssl rand -hex 32
|
||||
SECRET_KEY=change-me-use-openssl-rand-hex-32
|
||||
LOG_LEVEL=INFO
|
||||
|
||||
# When true: password login is disabled, all sign-in goes through OIDC,
|
||||
# user creation ignores any password (passwordless users that can only
|
||||
# authenticate via OIDC binding or API keys). Frontend hides password UI.
|
||||
HARBORFORGE_OIDC_ONLY=false
|
||||
|
||||
# --- Discord wakeup (optional; previously in wizard config) ---------------
|
||||
# Used by /agents/{id}/wakeup to spin a private Discord channel + DM.
|
||||
HARBORFORGE_DISCORD_GUILD_ID=
|
||||
HARBORFORGE_DISCORD_BOT_TOKEN=
|
||||
|
||||
# --- OIDC issuer / client_id / client_secret / redirect_uri ---------------
|
||||
# NOT env vars in v0.4.0+. Configure via:
|
||||
# docker exec hf-backend hf-cli config oidc \
|
||||
# --issuer https://login.example.com/realms/foo \
|
||||
# --client-id harborforge --client-secret <s> \
|
||||
# --redirect-uri https://hf-api.example.com/auth/oidc/callback \
|
||||
# --post-login-redirect https://hf.example.com/oidc/callback \
|
||||
# --enabled true
|
||||
|
||||
49
Dockerfile
49
Dockerfile
@@ -1,25 +1,58 @@
|
||||
FROM python:3.11-slim
|
||||
# Stage 1: build dependencies
|
||||
FROM python:3.11-slim AS builder
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
# Install system dependencies
|
||||
# Install build dependencies
|
||||
RUN apt-get update && apt-get install -y \
|
||||
build-essential \
|
||||
curl \
|
||||
default-libmysqlclient-dev \
|
||||
pkg-config \
|
||||
&& rm -rf /var/lib/apt/lists/*
|
||||
|
||||
# Pre-download wheels to avoid recompiling bcrypt from source
|
||||
RUN pip install --no-cache-dir --prefix=/install \
|
||||
'bcrypt==4.0.1' \
|
||||
'cffi>=2.0' \
|
||||
'pycparser>=2.0'
|
||||
|
||||
# Install Python dependencies
|
||||
COPY requirements.txt .
|
||||
RUN pip install --no-cache-dir -r requirements.txt
|
||||
RUN pip install --no-cache-dir --prefix=/install -r requirements.txt
|
||||
|
||||
# Stage 2: slim runtime
|
||||
FROM python:3.11-slim
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
# Install runtime dependencies only (no build tools)
|
||||
RUN apt-get update && apt-get install -y \
|
||||
default-libmysqlclient-dev \
|
||||
curl \
|
||||
&& rm -rf /var/lib/apt/lists/*
|
||||
|
||||
# Copy installed packages from builder
|
||||
COPY --from=builder /install /usr/local
|
||||
|
||||
# Copy application code
|
||||
COPY . .
|
||||
COPY app/ ./app/
|
||||
COPY requirements.txt ./
|
||||
|
||||
# Make entrypoint
|
||||
COPY entrypoint.sh .
|
||||
RUN chmod +x entrypoint.sh
|
||||
|
||||
# Expose port
|
||||
EXPOSE 8000
|
||||
# Install hf-cli as a /usr/local/bin shim that re-enters the app package
|
||||
# (so `docker exec hf-backend hf-cli admin create-user ...` works). The
|
||||
# CLI reads the same DATABASE_URL / SECRET_KEY env as the backend.
|
||||
RUN printf '#!/bin/sh\nexec python -m app.cli "$@"\n' > /usr/local/bin/hf-cli && \
|
||||
chmod +x /usr/local/bin/hf-cli
|
||||
|
||||
# Wait for wizard config, then start uvicorn
|
||||
# OIDC-only mode: when "true", password login is rejected, user creation
|
||||
# ignores passwords (passwordless users that sign in via a bound OIDC
|
||||
# identity / API keys). Overridable at runtime via the same env var.
|
||||
ARG HARBORFORGE_OIDC_ONLY=false
|
||||
ENV HARBORFORGE_OIDC_ONLY=${HARBORFORGE_OIDC_ONLY}
|
||||
|
||||
EXPOSE 8000
|
||||
ENTRYPOINT ["./entrypoint.sh"]
|
||||
|
||||
232
README.md
232
README.md
@@ -1,100 +1,163 @@
|
||||
# HarborForge Backend
|
||||
|
||||
Agent/人类协同任务管理平台 - FastAPI 后端
|
||||
The core REST API for HarborForge — an Agent/人类协同任务管理平台 (Agent/Human collaborative task-management platform).
|
||||
|
||||
## API Endpoints (38)
|
||||
Part of the [HarborForge](../README.md) platform.
|
||||
|
||||
### Auth
|
||||
- `POST /auth/token` - 登录获取 JWT token
|
||||
- `GET /auth/me` - 获取当前用户信息
|
||||
- **Role:** core REST API — users, projects, tasks, milestones, proposals, RBAC, webhooks, worklogs, notifications, monitor telemetry.
|
||||
- **Stack:** Python 3.11 · FastAPI · SQLAlchemy · MySQL
|
||||
- **Port:** `8000`
|
||||
|
||||
### Issues
|
||||
The service reads its database configuration from the AbstractWizard config volume (falling back to env/defaults) and authenticates requests with JWT (HS256) signed by `SECRET_KEY`.
|
||||
|
||||
> Issues 和 Search 列表接口返回分页格式:`{items, total, page, page_size, total_pages}`
|
||||
> Issues 支持排序参数:`sort_by` (created_at/priority/title/due_date/status), `sort_order` (asc/desc)
|
||||
> Issues 支持额外过滤:`assignee_id`, `tag`
|
||||
## Run / Build
|
||||
|
||||
### Docker
|
||||
|
||||
> Issues 和 Search 列表接口返回分页格式:
|
||||
> Issues 支持排序参数: (created_at/priority/title/due_date/status), (asc/desc)
|
||||
> Issues 支持额外过滤:,
|
||||
- `POST /issues` - 创建 issue(支持 resolution 决议案类型)
|
||||
- `GET /issues` - 列表(分页、排序、按 assignee/tag 过滤)(支持按 project/status/type 过滤)
|
||||
- `GET /issues/{id}` - 详情
|
||||
- `PATCH /issues/{id}` - 更新
|
||||
- `DELETE /issues/{id}` - 删除
|
||||
- `POST /issues/{id}/transition` - 状态变更(触发 webhook)
|
||||
- `GET /search/issues?q=keyword` - 搜索
|
||||
```bash
|
||||
docker build -t harborforge-backend .
|
||||
docker run -p 8000:8000 \
|
||||
-e SECRET_KEY="$(openssl rand -hex 32)" \
|
||||
-v /path/to/config:/config \
|
||||
harborforge-backend
|
||||
```
|
||||
|
||||
### Comments
|
||||
- `POST /comments` - 创建评论
|
||||
- `GET /issues/{id}/comments` - 列表
|
||||
- `PATCH /comments/{id}` - 更新
|
||||
- `DELETE /comments/{id}` - 删除
|
||||
### Local (uvicorn)
|
||||
|
||||
### Projects
|
||||
- `POST /projects` - 创建
|
||||
- `GET /projects` - 列表
|
||||
- `GET /projects/{id}` - 详情
|
||||
- `PATCH /projects/{id}` - 更新
|
||||
- `DELETE /projects/{id}` - 删除
|
||||
```bash
|
||||
pip install -r requirements.txt
|
||||
export SECRET_KEY="$(openssl rand -hex 32)"
|
||||
uvicorn app.main:app --host 0.0.0.0 --port 8000
|
||||
```
|
||||
|
||||
### Project Members
|
||||
- `POST /projects/{id}/members` - 添加成员
|
||||
- `GET /projects/{id}/members` - 列表
|
||||
- `DELETE /projects/{id}/members/{user_id}` - 移除
|
||||
On startup the app creates/migrates the schema, runs AbstractWizard
|
||||
initialization (admin user, default project, default roles), and starts a
|
||||
background monitor-polling thread.
|
||||
|
||||
### Users
|
||||
- `POST /users` - 注册
|
||||
- `GET /users` - 列表
|
||||
- `GET /users/{id}` - 详情
|
||||
- `PATCH /users/{id}` - 更新
|
||||
## Configuration
|
||||
|
||||
### Webhooks
|
||||
- `POST /webhooks` - 创建
|
||||
- `GET /webhooks` - 列表
|
||||
- `GET /webhooks/{id}` - 详情
|
||||
- `PATCH /webhooks/{id}` - 更新
|
||||
- `DELETE /webhooks/{id}` - 删除
|
||||
- `GET /webhooks/{id}/logs` - 投递日志
|
||||
Environment variables (also loadable from a `.env` file):
|
||||
|
||||
### System
|
||||
- `GET /health` - 健康检查
|
||||
- `GET /version` - 版本信息
|
||||
- `GET /dashboard/stats` - 统计面板
|
||||
| Variable | Default | Description |
|
||||
|----------|---------|-------------|
|
||||
| `SECRET_KEY` | *(none — must be set)* | JWT signing key (HS256). The server **refuses to start** with a weak/default/short value. |
|
||||
| `DATABASE_URL` | `mysql+pymysql://harborforge:harborforge_pass@mysql:3306/harborforge` | Fallback DB URL when the wizard config volume is absent. |
|
||||
| `ALGORITHM` | `HS256` | JWT algorithm. |
|
||||
| `ACCESS_TOKEN_EXPIRE_MINUTES` | `30` | Access-token lifetime. |
|
||||
| `LOG_LEVEL` | `INFO` | Log level. |
|
||||
| `CONFIG_DIR` | `/config` | AbstractWizard config volume directory. |
|
||||
| `CONFIG_FILE` | `harborforge.json` | Config file name within `CONFIG_DIR`. |
|
||||
|
||||
### Milestones
|
||||
- `POST /milestones` - 创建里程碑
|
||||
- `GET /milestones` - 列表(支持按 project/status 过滤)
|
||||
- `GET /milestones/{id}` - 详情
|
||||
- `PATCH /milestones/{id}` - 更新
|
||||
- `DELETE /milestones/{id}` - 删除
|
||||
- `GET /milestones/{id}/issues` - 里程碑下的 issue 列表
|
||||
- `GET /milestones/{id}/progress` - 里程碑完成进度
|
||||
Database resolution order: **wizard config volume** (`$CONFIG_DIR/$CONFIG_FILE` → `database` block) → `DATABASE_URL` env → built-in default.
|
||||
|
||||
### Notifications
|
||||
- `GET /notifications` - 列表(支持 user_id, unread_only 过滤)
|
||||
- `GET /notifications/count` - 未读通知计数
|
||||
- `POST /notifications/{id}/read` - 标记已读
|
||||
- `POST /notifications/read-all` - 全部标记已读
|
||||
## Security
|
||||
|
||||
### Issue Assignment
|
||||
- `POST /issues/{id}/assign` - 指派 issue(自动发送通知)
|
||||
The current code enforces the following security posture. These are
|
||||
operational requirements, not optional hardening.
|
||||
|
||||
### Webhook Retry
|
||||
- `POST /webhooks/{id}/retry/{log_id}` - 重试失败的 webhook 投递
|
||||
### Mandatory strong `SECRET_KEY`
|
||||
|
||||
### Time Tracking (Work Logs)
|
||||
- `POST /worklogs` - 记录工时
|
||||
- `GET /issues/{id}/worklogs` - 某 issue 的工时记录
|
||||
- `GET /issues/{id}/worklogs/summary` - 某 issue 工时汇总
|
||||
- `GET /users/{id}/worklogs` - 某用户的工时记录
|
||||
- `DELETE /worklogs/{id}` - 删除工时记录
|
||||
- `GET /projects/{id}/worklogs/summary` - 项目工时汇总(按用户分组)
|
||||
`app/core/config.py` validates `SECRET_KEY` at import time and **raises and
|
||||
refuses to start** if the value is empty, shorter than 32 characters, or a
|
||||
known default/placeholder (e.g. `change-me-in-production`, `secret`,
|
||||
`changeme`). Operators **must** provide a strong random key:
|
||||
|
||||
### Export
|
||||
- `GET /export/issues` - 导出 issues CSV
|
||||
- `GET /issues/overdue` - 逾期未完成的 issue
|
||||
```bash
|
||||
openssl rand -hex 32
|
||||
```
|
||||
|
||||
A weak signing key allows JWT forgery and full authentication bypass, so this
|
||||
check is intentionally fatal.
|
||||
|
||||
### API-key management is admin-only and masked
|
||||
|
||||
The `/api-keys` endpoints (`POST`, `GET`, `DELETE /api-keys/{id}`) all require
|
||||
a global admin (`require_admin`). Listing **never returns the full secret** —
|
||||
keys are masked to a short prefix/suffix (e.g. `abc123…9f`). The full key is
|
||||
only returned once, on creation.
|
||||
|
||||
### Webhooks router is admin-only with SSRF protection
|
||||
|
||||
The entire `/webhooks` router is mounted with `dependencies=[Depends(require_admin)]`,
|
||||
so every webhook endpoint (create/list/get/update/delete/logs/retry) requires a
|
||||
global admin. Webhook delivery (`app/services/webhook.py`) validates the target
|
||||
URL before sending:
|
||||
|
||||
- Only `http`/`https` schemes are allowed.
|
||||
- The host is DNS-resolved and **every** resolved address is rejected if it is
|
||||
private, loopback, link-local, multicast, reserved, or unspecified
|
||||
(SSRF protection).
|
||||
- HTTP redirects are disabled (`follow_redirects=False`).
|
||||
|
||||
### Project role hierarchy enforcement
|
||||
|
||||
`check_project_role` in `app/api/rbac.py` enforces a real, ordered role
|
||||
hierarchy rather than a flat membership check:
|
||||
|
||||
```
|
||||
guest(0) < viewer(1) < member(2) < dev(3) < mgr(4) < admin(5)
|
||||
```
|
||||
|
||||
A caller below the required rank is denied with `403`, and any unknown role on
|
||||
either side is denied by default. Global admins bypass project-level checks.
|
||||
|
||||
### Authentication on previously open endpoints
|
||||
|
||||
The following endpoints now require an authenticated caller
|
||||
(JWT bearer token **or** `X-API-Key`) and enforce ownership/permission:
|
||||
|
||||
- `DELETE /milestones/{id}` — requires milestone-edit permission.
|
||||
- `POST /worklogs` — worklogs are always attributed to the caller; only admins
|
||||
may log time for another user.
|
||||
- `DELETE /worklogs/{id}` — caller-scoped; non-admins cannot delete another
|
||||
user's worklog.
|
||||
- `POST /tasks/{task_code}/assign` and `POST /tasks/batch/assign`.
|
||||
- `GET /activity`.
|
||||
- `GET /export/tasks`.
|
||||
|
||||
## Authentication
|
||||
|
||||
- `POST /auth/token` — OAuth2 password grant; returns a JWT bearer token.
|
||||
- Authenticated requests send `Authorization: Bearer <token>` **or**
|
||||
`X-API-Key: <key>` (API keys map to a user and are created by admins).
|
||||
- `GET /auth/me` — current user.
|
||||
- `GET /auth/me/permissions`, `GET /auth/me/apikey-permissions` — permission introspection.
|
||||
|
||||
## Key API Areas
|
||||
|
||||
| Area | Prefix / Routes | Notes |
|
||||
|------|-----------------|-------|
|
||||
| Auth | `/auth/*` | token, current user, permission introspection |
|
||||
| Users | `/users` | registration, list/detail/update (list & mutate are admin-only) |
|
||||
| Projects | `/projects` | CRUD, members (`/projects/{id}/members`), worklog summary |
|
||||
| Project members | `/projects/{id}/members` | add/list/remove with role |
|
||||
| Milestones | `/projects/{id}/milestones`, `/milestones/{id}` | CRUD, items, progress |
|
||||
| Milestone actions | preflight / freeze / start / close | lifecycle transitions |
|
||||
| Tasks | `/tasks` | CRUD, transition, take, assign, batch transition/assign, tags, search |
|
||||
| Comments | `/comments`, `/tasks/{id}/comments` | CRUD |
|
||||
| Proposals | `/projects/{code}/proposals` | propose / accept / reject / reopen (legacy `/proposes`) |
|
||||
| Essentials | proposal essentials | feature/improvement/refactor items |
|
||||
| Meetings | `/meetings` | create/list/detail/update/delete/attend |
|
||||
| Roles & RBAC | `/roles` | roles, permissions, role-permission assignment |
|
||||
| Webhooks | `/webhooks` | **admin-only**; CRUD, logs, retry (SSRF-guarded delivery) |
|
||||
| API keys | `/api-keys` | **admin-only**; create/list (masked)/revoke |
|
||||
| Worklogs | `/worklogs`, `/tasks/{id}/worklogs`, `/users/{id}/worklogs` | time tracking & summaries |
|
||||
| Notifications | `/notifications` | list, unread count, mark read / read-all |
|
||||
| Activity | `/activity` | activity log (authenticated) |
|
||||
| Export | `/export/tasks` | CSV export (authenticated) |
|
||||
| Calendar | `/calendar` | scheduling / time slots |
|
||||
| Monitor | `/monitor` | public overview, admin providers/servers, heartbeat telemetry |
|
||||
| Dashboard | `/dashboard/stats` | aggregate statistics |
|
||||
| System | `/health`, `/version`, `/config/status` | health, version, init status |
|
||||
|
||||
## Task Types
|
||||
|
||||
| Type | 用途 |
|
||||
|------|------|
|
||||
| issue | 普通任务 |
|
||||
| story | 用户故事 |
|
||||
| test | 测试用例 |
|
||||
| resolution | 决议案(Agent 僵局提交)|
|
||||
|
||||
## CLI
|
||||
|
||||
@@ -102,18 +165,9 @@ The legacy Python CLI (`cli.py`) has been retired. Use the Go-based `hf` CLI ins
|
||||
|
||||
See [HarborForge.Cli](../HarborForge.Cli/README.md) for installation and usage.
|
||||
|
||||
## 技术栈
|
||||
## Tech Stack
|
||||
|
||||
- Python 3.11 + FastAPI
|
||||
- SQLAlchemy + MySQL
|
||||
- JWT (python-jose)
|
||||
- SQLAlchemy + MySQL (auto schema create/migrate on startup)
|
||||
- JWT (python-jose, HS256) + bcrypt password hashing
|
||||
- Docker
|
||||
|
||||
## Issue Types
|
||||
|
||||
| Type | 用途 |
|
||||
|------|------|
|
||||
| task | 普通任务 |
|
||||
| story | 用户故事 |
|
||||
| test | 测试用例 |
|
||||
| resolution | 决议案(Agent 僵局提交)|
|
||||
|
||||
@@ -59,20 +59,48 @@ async def get_current_user(token: str = Depends(oauth2_scheme), db: Session = De
|
||||
return user
|
||||
|
||||
|
||||
def _lookup_api_key(db: Session, key: str) -> models.User | None:
|
||||
"""Resolve an API key string to a User; mark last_used_at on hit."""
|
||||
if not key:
|
||||
return None
|
||||
key_obj = db.query(APIKey).filter(APIKey.key == key, APIKey.is_active == True).first() # noqa: E712
|
||||
if not key_obj:
|
||||
return None
|
||||
key_obj.last_used_at = datetime.utcnow()
|
||||
db.commit()
|
||||
return db.query(models.User).filter(models.User.id == key_obj.user_id).first()
|
||||
|
||||
|
||||
async def get_current_user_or_apikey(
|
||||
token: str = Depends(oauth2_scheme),
|
||||
api_key: str = Depends(apikey_header),
|
||||
db: Session = Depends(get_db)
|
||||
):
|
||||
"""Authenticate via JWT token OR API key."""
|
||||
"""Authenticate via JWT token (Authorization: Bearer <jwt>) OR API key
|
||||
(X-API-Key: <key>, OR — as a convenience for CLI clients that only know
|
||||
Bearer — Authorization: Bearer <api-key>; falls back when JWT decode fails).
|
||||
"""
|
||||
# Native X-API-Key header
|
||||
if api_key:
|
||||
key_obj = db.query(APIKey).filter(APIKey.key == api_key, APIKey.is_active == True).first()
|
||||
if key_obj:
|
||||
key_obj.last_used_at = datetime.utcnow()
|
||||
db.commit()
|
||||
user = db.query(models.User).filter(models.User.id == key_obj.user_id).first()
|
||||
user = _lookup_api_key(db, api_key)
|
||||
if user:
|
||||
return user
|
||||
|
||||
# Bearer header — try JWT first, then API key on decode failure
|
||||
if token:
|
||||
try:
|
||||
return await get_current_user(token=token, db=db)
|
||||
except HTTPException:
|
||||
user = _lookup_api_key(db, token)
|
||||
if user:
|
||||
return user
|
||||
if token:
|
||||
return await get_current_user(token=token, db=db)
|
||||
raise
|
||||
|
||||
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"
|
||||
)
|
||||
|
||||
# Legacy compatibility: most current routes use non-hierarchical names like dev/mgr.
|
||||
# For now, any valid membership passes those broad checks; strict edit rules are handled
|
||||
# by the explicit can_edit_* helpers below.
|
||||
if min_role in {"dev", "mgr", "viewer", "member", "guest", "admin"}:
|
||||
return True
|
||||
|
||||
# Enforce a real role hierarchy. Higher rank == more privilege.
|
||||
_RANK = {
|
||||
"guest": 0,
|
||||
"viewer": 1,
|
||||
"member": 2,
|
||||
"dev": 3,
|
||||
"mgr": 4,
|
||||
"admin": 5,
|
||||
}
|
||||
role_rank = _RANK.get((role.name or "").lower())
|
||||
required_rank = _RANK.get((min_role or "member").lower())
|
||||
if role_rank is None or required_rank is None:
|
||||
# Unknown role on either side -> deny by default.
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_403_FORBIDDEN,
|
||||
detail=f"Insufficient project role (have '{role.name}', need '{min_role}')",
|
||||
)
|
||||
if role_rank < required_rank:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_403_FORBIDDEN,
|
||||
detail=f"Insufficient project role (have '{role.name}', need '{min_role}')",
|
||||
)
|
||||
return True
|
||||
|
||||
|
||||
|
||||
@@ -11,13 +11,15 @@ from app.core.config import get_db, settings
|
||||
from app.models import models
|
||||
from app.models.role_permission import Permission, Role, RolePermission
|
||||
from app.schemas import schemas
|
||||
from app.api.deps import Token, verify_password, create_access_token, get_current_user
|
||||
from app.api.deps import Token, verify_password, create_access_token, get_current_user, get_current_user_or_apikey
|
||||
|
||||
router = APIRouter(prefix="/auth", tags=["Auth"])
|
||||
|
||||
|
||||
@router.post("/token", response_model=Token)
|
||||
async def login(form_data: OAuth2PasswordRequestForm = Depends(), db: Session = Depends(get_db)):
|
||||
if settings.HARBORFORGE_OIDC_ONLY:
|
||||
raise HTTPException(status_code=403, detail="Password login is disabled (OIDC only)")
|
||||
user = db.query(models.User).filter(models.User.username == form_data.username).first()
|
||||
if not user or not verify_password(form_data.password, user.hashed_password or ""):
|
||||
raise HTTPException(status_code=401, detail="Incorrect username or password",
|
||||
@@ -78,7 +80,7 @@ class PermissionIntrospectionResponse(BaseModel):
|
||||
|
||||
@router.get("/me/permissions", response_model=PermissionIntrospectionResponse)
|
||||
async def get_my_permissions(
|
||||
current_user: models.User = Depends(get_current_user),
|
||||
current_user: models.User = Depends(get_current_user_or_apikey),
|
||||
db: Session = Depends(get_db),
|
||||
):
|
||||
"""Return the current user's effective permissions for CLI help introspection."""
|
||||
|
||||
@@ -10,17 +10,25 @@ BE-CAL-API-006: Plan edit / plan cancel endpoints.
|
||||
BE-CAL-API-007: Date-list endpoint.
|
||||
"""
|
||||
|
||||
from datetime import date as date_type
|
||||
from datetime import date as date_type, datetime, timezone
|
||||
from typing import Optional
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException, Query
|
||||
from fastapi import APIRouter, Depends, Header, HTTPException, Query
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from app.api.deps import get_current_user
|
||||
from app.core.config import get_db
|
||||
from app.models.calendar import SchedulePlan, SlotStatus, TimeSlot
|
||||
from app.models.calendar import SchedulePlan, SlotStatus, SlotType, TimeSlot
|
||||
from app.models.models import User
|
||||
from app.models.agent import Agent, AgentStatus, ExhaustReason
|
||||
from app.models.schedule_type import ScheduleType
|
||||
from app.services.special_slot_materialiser import (
|
||||
materialise_special_slots_for_claw,
|
||||
materialise_special_slots_for_user,
|
||||
)
|
||||
from app.schemas.calendar import (
|
||||
AgentHeartbeatResponse,
|
||||
AgentStatusUpdateRequest,
|
||||
CalendarDayResponse,
|
||||
CalendarSlotItem,
|
||||
DateListResponse,
|
||||
@@ -32,7 +40,9 @@ from app.schemas.calendar import (
|
||||
SchedulePlanEdit,
|
||||
SchedulePlanListResponse,
|
||||
SchedulePlanResponse,
|
||||
SlotStatusEnum,
|
||||
SlotConflictItem,
|
||||
SlotAgentUpdate,
|
||||
TimeSlotCancelResponse,
|
||||
TimeSlotCreate,
|
||||
TimeSlotCreateResponse,
|
||||
@@ -40,6 +50,16 @@ from app.schemas.calendar import (
|
||||
TimeSlotEditResponse,
|
||||
TimeSlotResponse,
|
||||
)
|
||||
from app.services.agent_heartbeat import get_pending_slots_for_agent
|
||||
from app.services.agent_status import (
|
||||
AgentStatusError,
|
||||
record_heartbeat,
|
||||
transition_to_busy,
|
||||
transition_to_idle,
|
||||
transition_to_offline,
|
||||
transition_to_exhausted,
|
||||
)
|
||||
from app.services.discord_wakeup import create_private_wakeup_channel
|
||||
from app.services.minimum_workload import (
|
||||
get_workload_config,
|
||||
get_workload_warnings_for_date,
|
||||
@@ -62,10 +82,52 @@ from app.services.slot_immutability import (
|
||||
guard_plan_cancel_no_past_retroaction,
|
||||
guard_plan_edit_no_past_retroaction,
|
||||
)
|
||||
from app.models.role_permission import Permission, RolePermission
|
||||
|
||||
router = APIRouter(prefix="/calendar", tags=["Calendar"])
|
||||
|
||||
|
||||
def _has_global_permission(db: Session, user: User, permission_name: str) -> bool:
|
||||
if user.is_admin:
|
||||
return True
|
||||
if not user.role_id:
|
||||
return False
|
||||
perm = db.query(Permission).filter(Permission.name == permission_name).first()
|
||||
if not perm:
|
||||
return False
|
||||
return db.query(RolePermission).filter(
|
||||
RolePermission.role_id == user.role_id,
|
||||
RolePermission.permission_id == perm.id,
|
||||
).first() is not None
|
||||
|
||||
|
||||
def _require_calendar_permission(db: Session, user: User, permission_name: str) -> User:
|
||||
if _has_global_permission(db, user, permission_name):
|
||||
return user
|
||||
raise HTTPException(status_code=403, detail=f"Calendar permission '{permission_name}' required")
|
||||
|
||||
|
||||
def require_calendar_read(
|
||||
db: Session = Depends(get_db),
|
||||
current_user: User = Depends(get_current_user),
|
||||
):
|
||||
return _require_calendar_permission(db, current_user, "calendar.read")
|
||||
|
||||
|
||||
def require_calendar_write(
|
||||
db: Session = Depends(get_db),
|
||||
current_user: User = Depends(get_current_user),
|
||||
):
|
||||
return _require_calendar_permission(db, current_user, "calendar.write")
|
||||
|
||||
|
||||
def require_calendar_manage(
|
||||
db: Session = Depends(get_db),
|
||||
current_user: User = Depends(get_current_user),
|
||||
):
|
||||
return _require_calendar_permission(db, current_user, "calendar.manage")
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# TimeSlot creation (BE-CAL-API-001)
|
||||
# ---------------------------------------------------------------------------
|
||||
@@ -87,6 +149,8 @@ def _slot_to_response(slot: TimeSlot) -> TimeSlotResponse:
|
||||
priority=slot.priority,
|
||||
status=slot.status.value if hasattr(slot.status, "value") else str(slot.status),
|
||||
plan_id=slot.plan_id,
|
||||
is_admin_locked=bool(getattr(slot, "is_admin_locked", False)),
|
||||
special_slot_id=getattr(slot, "special_slot_id", None),
|
||||
created_at=slot.created_at,
|
||||
updated_at=slot.updated_at,
|
||||
)
|
||||
@@ -101,7 +165,7 @@ def _slot_to_response(slot: TimeSlot) -> TimeSlotResponse:
|
||||
def create_slot(
|
||||
payload: TimeSlotCreate,
|
||||
db: Session = Depends(get_db),
|
||||
current_user: User = Depends(get_current_user),
|
||||
current_user: User = Depends(require_calendar_write),
|
||||
):
|
||||
"""Create a one-off calendar slot.
|
||||
|
||||
@@ -112,6 +176,48 @@ def create_slot(
|
||||
"""
|
||||
target_date = payload.date or date_type.today()
|
||||
|
||||
# --- Maintenance-window guard ---
|
||||
# Non-`system` slots may not be placed inside the schedule_type's
|
||||
# 1-hour maintenance window. The window is admin-territory, reserved
|
||||
# for materialised special slots from `schedule_type_special_slots`.
|
||||
# `system` slot_type is itself reserved server-side (the materialiser
|
||||
# is the only legitimate caller) — refuse it here outright so the
|
||||
# public API cannot manufacture a fake admin-locked slot.
|
||||
if payload.slot_type == SlotType.SYSTEM:
|
||||
raise HTTPException(
|
||||
status_code=422,
|
||||
detail=(
|
||||
"slot_type='system' is reserved for schedule_type special slots "
|
||||
"and cannot be created via this endpoint"
|
||||
),
|
||||
)
|
||||
_agent_for_user = (
|
||||
db.query(Agent).filter(Agent.user_id == current_user.id).first()
|
||||
)
|
||||
if _agent_for_user and _agent_for_user.schedule_type_id:
|
||||
st = (
|
||||
db.query(ScheduleType)
|
||||
.filter(ScheduleType.id == _agent_for_user.schedule_type_id)
|
||||
.first()
|
||||
)
|
||||
if st and _scheduled_inside_window(
|
||||
payload.scheduled_at,
|
||||
payload.estimated_duration,
|
||||
st.maintenance_from,
|
||||
st.maintenance_to,
|
||||
):
|
||||
mf_h, mf_m = divmod(st.maintenance_from, 60)
|
||||
mt_h, mt_m = divmod(st.maintenance_to, 60)
|
||||
raise HTTPException(
|
||||
status_code=422,
|
||||
detail=(
|
||||
f"slot at {payload.scheduled_at} duration {payload.estimated_duration}min "
|
||||
f"intersects the maintenance window "
|
||||
f"{mf_h:02d}:{mf_m:02d}-{mt_h:02d}:{mt_m:02d} UTC of "
|
||||
f"schedule_type '{st.name}' — that window is admin-reserved"
|
||||
),
|
||||
)
|
||||
|
||||
# --- Overlap check (hard reject) ---
|
||||
conflicts = check_overlap_for_create(
|
||||
db,
|
||||
@@ -180,6 +286,8 @@ def _real_slot_to_item(slot: TimeSlot) -> CalendarSlotItem:
|
||||
priority=slot.priority,
|
||||
status=slot.status.value if hasattr(slot.status, "value") else str(slot.status),
|
||||
plan_id=slot.plan_id,
|
||||
is_admin_locked=bool(getattr(slot, "is_admin_locked", False)),
|
||||
special_slot_id=getattr(slot, "special_slot_id", None),
|
||||
created_at=slot.created_at,
|
||||
updated_at=slot.updated_at,
|
||||
)
|
||||
@@ -222,6 +330,300 @@ def _virtual_slot_to_item(vs: dict) -> CalendarSlotItem:
|
||||
)
|
||||
|
||||
|
||||
def _require_agent(db: Session, agent_id: str, claw_identifier: str) -> Agent:
|
||||
agent = (
|
||||
db.query(Agent)
|
||||
.filter(Agent.agent_id == agent_id, Agent.claw_identifier == claw_identifier)
|
||||
.first()
|
||||
)
|
||||
if agent is None:
|
||||
raise HTTPException(status_code=404, detail="Agent not found")
|
||||
return agent
|
||||
|
||||
|
||||
def _scheduled_inside_window(
|
||||
scheduled_at,
|
||||
estimated_duration_minutes: int,
|
||||
window_from_min: int,
|
||||
window_to_min: int,
|
||||
) -> bool:
|
||||
"""True if [scheduled_at, scheduled_at+duration] intersects [from, to).
|
||||
|
||||
Window bounds are minutes-since-UTC-midnight (0-1439). Handles the
|
||||
case where the window crosses UTC midnight (e.g. 23:30→01:00).
|
||||
"""
|
||||
start_min = scheduled_at.hour * 60 + scheduled_at.minute
|
||||
end_min = start_min + max(estimated_duration_minutes, 1)
|
||||
if window_to_min > window_from_min:
|
||||
# normal same-day window
|
||||
return start_min < window_to_min and end_min > window_from_min
|
||||
# wrap-around: window = [from..1440) ∪ [0..to)
|
||||
return (start_min < 1440 and end_min > window_from_min) or end_min > window_to_min
|
||||
|
||||
|
||||
# Admin-locked special slots accept only these agent-driven status
|
||||
# transitions; movement / cancellation / arbitrary status edits are
|
||||
# rejected because the schedule_type owner is the source of truth.
|
||||
_ADMIN_LOCKED_ALLOWED_STATUSES = {
|
||||
SlotStatusEnum.ONGOING,
|
||||
SlotStatusEnum.PAUSED,
|
||||
SlotStatusEnum.FINISHED,
|
||||
SlotStatusEnum.ABORTED,
|
||||
}
|
||||
|
||||
|
||||
def _apply_agent_slot_update(slot: TimeSlot, payload: SlotAgentUpdate) -> None:
|
||||
if getattr(slot, "is_admin_locked", False):
|
||||
if payload.status not in _ADMIN_LOCKED_ALLOWED_STATUSES:
|
||||
raise HTTPException(
|
||||
status_code=423,
|
||||
detail=(
|
||||
f"slot {slot.id} is admin-locked (special slot); only "
|
||||
f"ongoing/paused/finished/aborted are allowed via agent-update"
|
||||
),
|
||||
)
|
||||
slot.status = payload.status.value
|
||||
if payload.started_at is not None:
|
||||
slot.started_at = payload.started_at
|
||||
slot.attended = True
|
||||
if payload.actual_duration is not None:
|
||||
slot.actual_duration = payload.actual_duration
|
||||
if payload.status == SlotStatusEnum.ONGOING:
|
||||
slot.attended = True
|
||||
|
||||
|
||||
def _maybe_trigger_discord_wakeup(db: Session, slot: TimeSlot) -> dict | None:
|
||||
"""Trigger Discord wakeup if slot became ONGOING and not already sent."""
|
||||
# Only trigger for ONGOING status and if not already sent
|
||||
if slot.status != SlotStatus.ONGOING or slot.wakeup_sent_at is not None:
|
||||
return None
|
||||
|
||||
# Get user and check for discord_user_id
|
||||
user = db.query(User).filter(User.id == slot.user_id).first()
|
||||
if not user or not user.discord_user_id:
|
||||
return None
|
||||
|
||||
# Get agent for this user
|
||||
agent = db.query(Agent).filter(Agent.user_id == user.id).first()
|
||||
agent_id_str = agent.agent_id if agent else "unknown"
|
||||
|
||||
# Build wakeup message
|
||||
title = f"HarborForge Slot: {slot.event_type.value if slot.event_type else 'work'}"
|
||||
message = (
|
||||
f"🎯 **Slot started**\n"
|
||||
f"Agent: `{agent_id_str}`\n"
|
||||
f"Type: {slot.slot_type.value}\n"
|
||||
f"Duration: {slot.estimated_duration}min\n"
|
||||
f"Priority: {slot.priority}\n"
|
||||
f"Use `hf calendar slot {slot.id}` for details."
|
||||
)
|
||||
|
||||
try:
|
||||
result = create_private_wakeup_channel(
|
||||
discord_user_id=user.discord_user_id,
|
||||
title=title,
|
||||
message=message,
|
||||
)
|
||||
slot.wakeup_sent_at = datetime.now(timezone.utc)
|
||||
return {"ok": True, "channel_id": result.get("channel_id")}
|
||||
except Exception as e:
|
||||
# Log but don't fail the slot update
|
||||
return {"ok": False, "error": str(e)}
|
||||
|
||||
|
||||
@router.api_route(
|
||||
"/agent/heartbeat",
|
||||
methods=["GET", "POST"],
|
||||
response_model=AgentHeartbeatResponse,
|
||||
summary="Get all due slots for the calling agent",
|
||||
)
|
||||
def agent_heartbeat(
|
||||
x_agent_id: str = Header(..., alias="X-Agent-ID"),
|
||||
x_claw_identifier: str = Header(..., alias="X-Claw-Identifier"),
|
||||
db: Session = Depends(get_db),
|
||||
):
|
||||
agent = _require_agent(db, x_agent_id, x_claw_identifier)
|
||||
record_heartbeat(db, agent)
|
||||
slots = get_pending_slots_for_agent(db, agent.user_id, now=datetime.now(timezone.utc))
|
||||
db.commit()
|
||||
return AgentHeartbeatResponse(
|
||||
slots=[_real_slot_to_item(slot) for slot in slots],
|
||||
agent_status=agent.status.value if hasattr(agent.status, 'value') else str(agent.status),
|
||||
message=f"{len(slots)} due slot(s)",
|
||||
)
|
||||
|
||||
|
||||
@router.get(
|
||||
"/sync",
|
||||
summary="Sync today's schedules for all agents on a claw instance",
|
||||
)
|
||||
def sync_schedules(
|
||||
x_claw_identifier: str = Header(..., alias="X-Claw-Identifier"),
|
||||
db: Session = Depends(get_db),
|
||||
):
|
||||
"""Return today's slots for all agents belonging to the given claw instance.
|
||||
|
||||
Used by the HF OpenClaw plugin to maintain a local schedule cache.
|
||||
Returns a dict of { agent_id: [slots] } for all agents with matching
|
||||
claw_identifier.
|
||||
"""
|
||||
today = date_type.today()
|
||||
|
||||
# Materialise today's special slots for every agent on this claw
|
||||
# before reading. This is idempotent — re-runs against an already-
|
||||
# materialised (agent, date, template) are no-ops. Plugin's runSync
|
||||
# picks them up like any other slot via the normal real_slots query.
|
||||
materialise_special_slots_for_claw(db, x_claw_identifier, today, commit=True)
|
||||
|
||||
# Find all agents on this claw instance
|
||||
agents = (
|
||||
db.query(Agent)
|
||||
.filter(Agent.claw_identifier == x_claw_identifier)
|
||||
.all()
|
||||
)
|
||||
|
||||
schedules: dict[str, list[dict]] = {}
|
||||
for agent in agents:
|
||||
# Get real slots for today
|
||||
real_slots = (
|
||||
db.query(TimeSlot)
|
||||
.filter(
|
||||
TimeSlot.user_id == agent.user_id,
|
||||
TimeSlot.date == today,
|
||||
TimeSlot.status.notin_(list(_INACTIVE_STATUSES)),
|
||||
)
|
||||
.all()
|
||||
)
|
||||
items = [_real_slot_to_item(s).model_dump(mode="json") for s in real_slots]
|
||||
|
||||
# Get virtual plan slots
|
||||
virtual_slots = get_virtual_slots_for_date(db, agent.user_id, today)
|
||||
for vs in virtual_slots:
|
||||
items.append(_virtual_slot_to_item(vs).model_dump(mode="json"))
|
||||
|
||||
schedules[agent.agent_id] = items
|
||||
|
||||
# Record heartbeat for liveness
|
||||
for agent in agents:
|
||||
record_heartbeat(db, agent)
|
||||
db.commit()
|
||||
|
||||
return {
|
||||
"schedules": schedules,
|
||||
"date": today.isoformat(),
|
||||
"agent_count": len(agents),
|
||||
}
|
||||
|
||||
|
||||
@router.patch(
|
||||
"/slots/{slot_id}/agent-update",
|
||||
response_model=TimeSlotEditResponse,
|
||||
summary="Agent updates a real slot status",
|
||||
)
|
||||
def agent_update_real_slot(
|
||||
slot_id: int,
|
||||
payload: SlotAgentUpdate,
|
||||
x_agent_id: str = Header(..., alias="X-Agent-ID"),
|
||||
x_claw_identifier: str = Header(..., alias="X-Claw-Identifier"),
|
||||
db: Session = Depends(get_db),
|
||||
):
|
||||
agent = _require_agent(db, x_agent_id, x_claw_identifier)
|
||||
slot = db.query(TimeSlot).filter(TimeSlot.id == slot_id, TimeSlot.user_id == agent.user_id).first()
|
||||
if slot is None:
|
||||
raise HTTPException(status_code=404, detail="Slot not found")
|
||||
_apply_agent_slot_update(slot, payload)
|
||||
_maybe_trigger_discord_wakeup(db, slot)
|
||||
db.commit()
|
||||
db.refresh(slot)
|
||||
return TimeSlotEditResponse(slot=_slot_to_response(slot), warnings=[])
|
||||
|
||||
|
||||
@router.patch(
|
||||
"/slots/virtual/{virtual_id}/agent-update",
|
||||
response_model=TimeSlotEditResponse,
|
||||
summary="Agent materializes and updates a virtual slot status",
|
||||
)
|
||||
def agent_update_virtual_slot(
|
||||
virtual_id: str,
|
||||
payload: SlotAgentUpdate,
|
||||
x_agent_id: str = Header(..., alias="X-Agent-ID"),
|
||||
x_claw_identifier: str = Header(..., alias="X-Claw-Identifier"),
|
||||
db: Session = Depends(get_db),
|
||||
):
|
||||
agent = _require_agent(db, x_agent_id, x_claw_identifier)
|
||||
slot = materialize_from_virtual_id(db, virtual_id)
|
||||
if slot.user_id != agent.user_id:
|
||||
db.rollback()
|
||||
raise HTTPException(status_code=404, detail="Slot not found")
|
||||
_apply_agent_slot_update(slot, payload)
|
||||
_maybe_trigger_discord_wakeup(db, slot)
|
||||
db.commit()
|
||||
db.refresh(slot)
|
||||
return TimeSlotEditResponse(slot=_slot_to_response(slot), warnings=[])
|
||||
|
||||
|
||||
@router.get(
|
||||
"/agent/status",
|
||||
summary="Read an agent's current runtime status (no side effects)",
|
||||
)
|
||||
def get_agent_status(
|
||||
agent_id: str = Query(..., description="Target agent_id"),
|
||||
x_claw_identifier: str = Header(..., alias="X-Claw-Identifier"),
|
||||
db: Session = Depends(get_db),
|
||||
):
|
||||
"""Return `{agent_id, status}` so callers (Fabric.OpenclawPlugin's
|
||||
triage on-call gate, etc.) can decide whether the agent is currently
|
||||
eligible without flipping their state.
|
||||
|
||||
No-op for unknown agents — returns 404 with `{detail: 'Agent not
|
||||
found'}` so the caller can decide whether to fail-open or fail-closed.
|
||||
"""
|
||||
agent = _require_agent(db, agent_id, x_claw_identifier)
|
||||
return {
|
||||
"agent_id": agent.agent_id,
|
||||
"status": agent.status.value if hasattr(agent.status, 'value') else str(agent.status),
|
||||
}
|
||||
|
||||
|
||||
@router.post(
|
||||
"/agent/status",
|
||||
summary="Update agent runtime status from plugin",
|
||||
)
|
||||
def update_agent_status(
|
||||
payload: AgentStatusUpdateRequest,
|
||||
db: Session = Depends(get_db),
|
||||
):
|
||||
agent = _require_agent(db, payload.agent_id, payload.claw_identifier)
|
||||
target = (payload.status or '').lower().strip()
|
||||
# Idempotent same-state transition: a 'busy → busy' request is a
|
||||
# no-op rather than a 500. Lets plugin status gates / cli `--set`
|
||||
# be safe to fire-and-forget without first reading current state.
|
||||
current = agent.status.value if hasattr(agent.status, 'value') else str(agent.status)
|
||||
if current == target:
|
||||
return {"ok": True, "agent_id": agent.agent_id, "status": current, "no_change": True}
|
||||
try:
|
||||
if target == AgentStatus.IDLE.value:
|
||||
transition_to_idle(db, agent)
|
||||
elif target == AgentStatus.BUSY.value:
|
||||
transition_to_busy(db, agent, slot_type=SlotType.WORK)
|
||||
elif target == AgentStatus.ON_CALL.value:
|
||||
transition_to_busy(db, agent, slot_type=SlotType.ON_CALL)
|
||||
elif target == AgentStatus.OFFLINE.value:
|
||||
transition_to_offline(db, agent)
|
||||
elif target == AgentStatus.EXHAUSTED.value:
|
||||
reason = ExhaustReason.BILLING if payload.exhaust_reason == 'billing' else ExhaustReason.RATE_LIMIT
|
||||
transition_to_exhausted(db, agent, reason=reason, recovery_at=payload.recovery_at)
|
||||
else:
|
||||
raise HTTPException(status_code=400, detail="Unsupported agent status")
|
||||
except AgentStatusError as e:
|
||||
# State-machine violation (e.g. busy → busy via wrong precondition)
|
||||
# → 409 with the rejected transition explained, instead of a 500.
|
||||
db.rollback()
|
||||
raise HTTPException(status_code=409, detail=str(e))
|
||||
db.commit()
|
||||
return {"ok": True, "agent_id": agent.agent_id, "status": agent.status.value if hasattr(agent.status, 'value') else str(agent.status)}
|
||||
|
||||
|
||||
@router.get(
|
||||
"/day",
|
||||
response_model=CalendarDayResponse,
|
||||
@@ -230,7 +632,7 @@ def _virtual_slot_to_item(vs: dict) -> CalendarSlotItem:
|
||||
def get_calendar_day(
|
||||
date: Optional[date_type] = Query(None, description="Target date (defaults to today)"),
|
||||
db: Session = Depends(get_db),
|
||||
current_user: User = Depends(get_current_user),
|
||||
current_user: User = Depends(require_calendar_read),
|
||||
):
|
||||
"""Return all calendar slots for the authenticated user on the given date.
|
||||
|
||||
@@ -245,6 +647,10 @@ def get_calendar_day(
|
||||
"""
|
||||
target_date = date or date_type.today()
|
||||
|
||||
# Materialise today's special slots for this user before reading,
|
||||
# so the day-view returns them alongside any user-created slots.
|
||||
materialise_special_slots_for_user(db, current_user.id, target_date, commit=True)
|
||||
|
||||
# 1. Fetch real slots for the day
|
||||
real_slots = (
|
||||
db.query(TimeSlot)
|
||||
@@ -301,7 +707,7 @@ def edit_real_slot(
|
||||
slot_id: int,
|
||||
payload: TimeSlotEdit,
|
||||
db: Session = Depends(get_db),
|
||||
current_user: User = Depends(get_current_user),
|
||||
current_user: User = Depends(require_calendar_write),
|
||||
):
|
||||
"""Edit an existing real (materialized) slot.
|
||||
|
||||
@@ -320,6 +726,20 @@ def edit_real_slot(
|
||||
if slot is None:
|
||||
raise HTTPException(status_code=404, detail="Slot not found")
|
||||
|
||||
# --- Admin-locked guard ---
|
||||
# Special slots materialised from a schedule_type template are
|
||||
# admin-owned; agents may complete/abort/pause/resume via the
|
||||
# plugin-facing agent-update endpoint but cannot edit time/type/
|
||||
# duration/event-data via this user-facing edit endpoint.
|
||||
if getattr(slot, "is_admin_locked", False):
|
||||
raise HTTPException(
|
||||
status_code=423,
|
||||
detail=(
|
||||
f"slot {slot.id} is admin-locked (materialised from a special "
|
||||
f"slot template); only the schedule_type owner can edit it"
|
||||
),
|
||||
)
|
||||
|
||||
# --- Past-slot guard ---
|
||||
try:
|
||||
guard_edit_real_slot(db, slot)
|
||||
@@ -380,7 +800,7 @@ def edit_virtual_slot(
|
||||
virtual_id: str,
|
||||
payload: TimeSlotEdit,
|
||||
db: Session = Depends(get_db),
|
||||
current_user: User = Depends(get_current_user),
|
||||
current_user: User = Depends(require_calendar_write),
|
||||
):
|
||||
"""Edit a virtual (plan-generated) slot.
|
||||
|
||||
@@ -469,7 +889,7 @@ def edit_virtual_slot(
|
||||
def cancel_real_slot(
|
||||
slot_id: int,
|
||||
db: Session = Depends(get_db),
|
||||
current_user: User = Depends(get_current_user),
|
||||
current_user: User = Depends(require_calendar_write),
|
||||
):
|
||||
"""Cancel an existing real (materialized) slot.
|
||||
|
||||
@@ -487,6 +907,16 @@ def cancel_real_slot(
|
||||
if slot is None:
|
||||
raise HTTPException(status_code=404, detail="Slot not found")
|
||||
|
||||
# --- Admin-locked guard ---
|
||||
if getattr(slot, "is_admin_locked", False):
|
||||
raise HTTPException(
|
||||
status_code=423,
|
||||
detail=(
|
||||
f"slot {slot.id} is admin-locked (materialised from a special "
|
||||
f"slot template); only the schedule_type owner can cancel it"
|
||||
),
|
||||
)
|
||||
|
||||
# --- Past-slot guard ---
|
||||
try:
|
||||
guard_cancel_real_slot(db, slot)
|
||||
@@ -516,7 +946,7 @@ def cancel_real_slot(
|
||||
def cancel_virtual_slot(
|
||||
virtual_id: str,
|
||||
db: Session = Depends(get_db),
|
||||
current_user: User = Depends(get_current_user),
|
||||
current_user: User = Depends(require_calendar_write),
|
||||
):
|
||||
"""Cancel a virtual (plan-generated) slot.
|
||||
|
||||
@@ -596,7 +1026,7 @@ def _plan_to_response(plan: SchedulePlan) -> SchedulePlanResponse:
|
||||
def create_plan(
|
||||
payload: SchedulePlanCreate,
|
||||
db: Session = Depends(get_db),
|
||||
current_user: User = Depends(get_current_user),
|
||||
current_user: User = Depends(require_calendar_write),
|
||||
):
|
||||
"""Create a new recurring schedule plan.
|
||||
|
||||
@@ -632,7 +1062,7 @@ def create_plan(
|
||||
def list_plans(
|
||||
include_inactive: bool = Query(False, description="Include cancelled/inactive plans"),
|
||||
db: Session = Depends(get_db),
|
||||
current_user: User = Depends(get_current_user),
|
||||
current_user: User = Depends(require_calendar_read),
|
||||
):
|
||||
"""Return all schedule plans for the authenticated user.
|
||||
|
||||
@@ -658,7 +1088,7 @@ def list_plans(
|
||||
def get_plan(
|
||||
plan_id: int,
|
||||
db: Session = Depends(get_db),
|
||||
current_user: User = Depends(get_current_user),
|
||||
current_user: User = Depends(require_calendar_read),
|
||||
):
|
||||
"""Return a single schedule plan owned by the authenticated user."""
|
||||
plan = (
|
||||
@@ -705,7 +1135,7 @@ def edit_plan(
|
||||
plan_id: int,
|
||||
payload: SchedulePlanEdit,
|
||||
db: Session = Depends(get_db),
|
||||
current_user: User = Depends(get_current_user),
|
||||
current_user: User = Depends(require_calendar_write),
|
||||
):
|
||||
"""Edit an existing schedule plan.
|
||||
|
||||
@@ -792,7 +1222,7 @@ def edit_plan(
|
||||
def cancel_plan(
|
||||
plan_id: int,
|
||||
db: Session = Depends(get_db),
|
||||
current_user: User = Depends(get_current_user),
|
||||
current_user: User = Depends(require_calendar_write),
|
||||
):
|
||||
"""Cancel (soft-delete) a schedule plan.
|
||||
|
||||
@@ -859,7 +1289,7 @@ _DATE_LIST_EXCLUDED_STATUSES = {SlotStatus.SKIPPED.value, SlotStatus.ABORTED.val
|
||||
)
|
||||
def list_dates(
|
||||
db: Session = Depends(get_db),
|
||||
current_user: User = Depends(get_current_user),
|
||||
current_user: User = Depends(require_calendar_read),
|
||||
):
|
||||
"""Return a sorted list of future dates that have at least one
|
||||
materialized (real) slot.
|
||||
@@ -897,7 +1327,7 @@ def list_dates(
|
||||
)
|
||||
def get_my_workload_config(
|
||||
db: Session = Depends(get_db),
|
||||
current_user: User = Depends(get_current_user),
|
||||
current_user: User = Depends(require_calendar_manage),
|
||||
):
|
||||
"""Return the workload thresholds for the authenticated user.
|
||||
|
||||
@@ -916,7 +1346,7 @@ def get_my_workload_config(
|
||||
def put_my_workload_config(
|
||||
payload: MinimumWorkloadConfig,
|
||||
db: Session = Depends(get_db),
|
||||
current_user: User = Depends(get_current_user),
|
||||
current_user: User = Depends(require_calendar_manage),
|
||||
):
|
||||
"""Full replacement of the workload configuration."""
|
||||
row = replace_workload_config(db, current_user.id, payload)
|
||||
@@ -933,7 +1363,7 @@ def put_my_workload_config(
|
||||
def patch_my_workload_config(
|
||||
payload: MinimumWorkloadUpdate,
|
||||
db: Session = Depends(get_db),
|
||||
current_user: User = Depends(get_current_user),
|
||||
current_user: User = Depends(require_calendar_manage),
|
||||
):
|
||||
"""Partial update — only the provided periods are overwritten."""
|
||||
row = upsert_workload_config(db, current_user.id, payload)
|
||||
|
||||
735
app/api/routers/knowledge.py
Normal file
735
app/api/routers/knowledge.py
Normal file
@@ -0,0 +1,735 @@
|
||||
"""Knowledge Base router with global-permission RBAC.
|
||||
|
||||
Permissions (global, granted via the Role Editor; admins auto-pass):
|
||||
knowledge-base.create create a knowledge base
|
||||
knowledge-base.read read any knowledge base / topic / category / fact
|
||||
knowledge-base.update edit a KB and its topic/category/fact structure,
|
||||
and link/unlink knowledge bases to projects
|
||||
knowledge-base.delete delete a knowledge base
|
||||
|
||||
There is no per-KB membership model (unlike projects) — access is purely by
|
||||
the four global permissions above.
|
||||
"""
|
||||
import re
|
||||
from typing import List, Optional
|
||||
from fastapi import APIRouter, Depends, HTTPException, status
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from app.core.config import get_db
|
||||
from app.models import models
|
||||
from app.models import knowledge as kb_models
|
||||
from app.schemas import knowledge as kb_schemas
|
||||
from app.api.deps import get_current_user_or_apikey
|
||||
|
||||
router = APIRouter(tags=["KnowledgeBase"])
|
||||
|
||||
PERM_CREATE = "knowledge-base.create"
|
||||
PERM_READ = "knowledge-base.read"
|
||||
PERM_UPDATE = "knowledge-base.update"
|
||||
PERM_DELETE = "knowledge-base.delete"
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Permission helper (global perms only)
|
||||
# ---------------------------------------------------------------------------
|
||||
def _require_perm(db: Session, user: models.User, perm_name: str) -> None:
|
||||
if user.is_admin:
|
||||
return
|
||||
from app.models.role_permission import Permission, RolePermission
|
||||
has = (
|
||||
db.query(Permission.id)
|
||||
.join(RolePermission, RolePermission.permission_id == Permission.id)
|
||||
.filter(
|
||||
RolePermission.role_id == user.role_id,
|
||||
Permission.name == perm_name,
|
||||
)
|
||||
.first()
|
||||
if user.role_id
|
||||
else None
|
||||
)
|
||||
if not has:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_403_FORBIDDEN,
|
||||
detail=f"Permission denied: {perm_name} required",
|
||||
)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Knowledge-base code generation (same rules as project_code)
|
||||
# ---------------------------------------------------------------------------
|
||||
WORD_SEGMENT_RE = re.compile(r"[A-Za-z]+")
|
||||
CAMEL_RE = re.compile(r"[A-Z]+(?=[A-Z][a-z])|[A-Z]?[a-z]+|[A-Z]+")
|
||||
|
||||
|
||||
def _split_words(name: str):
|
||||
segments = WORD_SEGMENT_RE.findall(name or "")
|
||||
words = []
|
||||
for seg in segments:
|
||||
for part in CAMEL_RE.findall(seg):
|
||||
if part.isupper() and len(part) > 1:
|
||||
words.extend(list(part))
|
||||
else:
|
||||
words.append(part)
|
||||
return words
|
||||
|
||||
|
||||
def _code_exists(db: Session, code: str) -> bool:
|
||||
return (
|
||||
db.query(kb_models.KnowledgeBase)
|
||||
.filter(kb_models.KnowledgeBase.knowledge_base_code == code)
|
||||
.first()
|
||||
is not None
|
||||
)
|
||||
|
||||
|
||||
def _next_counter(db: Session, prefix: str, width: int) -> str:
|
||||
if width <= 0:
|
||||
return ""
|
||||
counter = (
|
||||
db.query(kb_models.KnowledgeBaseCodeCounter)
|
||||
.filter(kb_models.KnowledgeBaseCodeCounter.prefix == prefix)
|
||||
.first()
|
||||
)
|
||||
if not counter:
|
||||
counter = kb_models.KnowledgeBaseCodeCounter(prefix=prefix, next_value=0)
|
||||
db.add(counter)
|
||||
db.flush()
|
||||
value = counter.next_value
|
||||
counter.next_value += 1
|
||||
db.flush()
|
||||
return format(value, "x").upper().zfill(width)
|
||||
|
||||
|
||||
def _generate_with_counter(db: Session, prefix: str, width: int) -> str:
|
||||
while True:
|
||||
suffix = _next_counter(db, prefix, width)
|
||||
code = (prefix + suffix).upper()
|
||||
if not _code_exists(db, code):
|
||||
return code
|
||||
|
||||
|
||||
def _generate_kb_code(db: Session, title: str) -> str:
|
||||
words = _split_words(title)
|
||||
if not words:
|
||||
return _generate_with_counter(db, "UN", 4)
|
||||
|
||||
if len(words) == 1:
|
||||
letters = "".join(c for c in words[0] if c.isalpha()).upper()
|
||||
if not letters:
|
||||
return _generate_with_counter(db, "UN", 4)
|
||||
if len(letters) >= 6:
|
||||
code = letters[:6]
|
||||
if _code_exists(db, code):
|
||||
return _generate_with_counter(db, letters[:2], 4)
|
||||
return code
|
||||
prefix = letters
|
||||
return _generate_with_counter(db, prefix, 6 - len(prefix))
|
||||
|
||||
total_letters = sum(len(w) for w in words)
|
||||
if len(words) > 6:
|
||||
code = "".join(w[0] for w in words[:6]).upper()
|
||||
if _code_exists(db, code):
|
||||
return _generate_with_counter(db, code[:2], 4)
|
||||
return code
|
||||
|
||||
if total_letters < 6:
|
||||
prefix = "".join(words).upper()
|
||||
return _generate_with_counter(db, prefix, 6 - len(prefix))
|
||||
|
||||
if total_letters == 6:
|
||||
code = "".join(words).upper()
|
||||
if _code_exists(db, code):
|
||||
return _generate_with_counter(db, code[:2], 4)
|
||||
return code
|
||||
|
||||
# total_letters > 6: initials, then fill from a counter on collision
|
||||
code = "".join(w[0] for w in words).upper()[:6]
|
||||
if not _code_exists(db, code):
|
||||
return code
|
||||
return _generate_with_counter(db, code[:2], 4)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Resolvers
|
||||
# ---------------------------------------------------------------------------
|
||||
def _resolve_kb(db: Session, identifier: str) -> kb_models.KnowledgeBase:
|
||||
kb = None
|
||||
try:
|
||||
kb = db.query(kb_models.KnowledgeBase).filter(kb_models.KnowledgeBase.id == int(identifier)).first()
|
||||
except (ValueError, TypeError):
|
||||
kb = (
|
||||
db.query(kb_models.KnowledgeBase)
|
||||
.filter(kb_models.KnowledgeBase.knowledge_base_code == str(identifier))
|
||||
.first()
|
||||
)
|
||||
if not kb:
|
||||
raise HTTPException(status_code=404, detail="Knowledge base not found")
|
||||
return kb
|
||||
|
||||
|
||||
def _resolve_project(db: Session, identifier: str) -> models.Project:
|
||||
project = None
|
||||
try:
|
||||
project = db.query(models.Project).filter(models.Project.id == int(identifier)).first()
|
||||
except (ValueError, TypeError):
|
||||
project = db.query(models.Project).filter(models.Project.project_code == str(identifier)).first()
|
||||
if not project:
|
||||
raise HTTPException(status_code=404, detail="Project not found")
|
||||
return project
|
||||
|
||||
|
||||
def _get_topic(db: Session, topic_id: int) -> kb_models.KnowledgeTopic:
|
||||
topic = db.query(kb_models.KnowledgeTopic).filter(kb_models.KnowledgeTopic.id == topic_id).first()
|
||||
if not topic:
|
||||
raise HTTPException(status_code=404, detail="Topic not found")
|
||||
return topic
|
||||
|
||||
|
||||
def _get_category(db: Session, category_id: int) -> kb_models.KnowledgeCategory:
|
||||
cat = db.query(kb_models.KnowledgeCategory).filter(kb_models.KnowledgeCategory.id == category_id).first()
|
||||
if not cat:
|
||||
raise HTTPException(status_code=404, detail="Category not found")
|
||||
return cat
|
||||
|
||||
|
||||
def _descendant_category_ids(db: Session, category_id: int) -> List[int]:
|
||||
"""Return [category_id, ...all nested descendants] (deepest last)."""
|
||||
collected = [category_id]
|
||||
frontier = [category_id]
|
||||
while frontier:
|
||||
children = (
|
||||
db.query(kb_models.KnowledgeCategory.id)
|
||||
.filter(kb_models.KnowledgeCategory.parent.in_(frontier))
|
||||
.all()
|
||||
)
|
||||
child_ids = [c.id for c in children]
|
||||
if not child_ids:
|
||||
break
|
||||
collected.extend(child_ids)
|
||||
frontier = child_ids
|
||||
return collected
|
||||
|
||||
|
||||
# ===========================================================================
|
||||
# Knowledge Base CRUD
|
||||
# ===========================================================================
|
||||
@router.post("/knowledge-bases", response_model=kb_schemas.KnowledgeBaseResponse, status_code=status.HTTP_201_CREATED)
|
||||
def create_knowledge_base(
|
||||
payload: kb_schemas.KnowledgeBaseCreate,
|
||||
db: Session = Depends(get_db),
|
||||
current_user: models.User = Depends(get_current_user_or_apikey),
|
||||
):
|
||||
_require_perm(db, current_user, PERM_CREATE)
|
||||
kb = kb_models.KnowledgeBase(
|
||||
title=payload.title,
|
||||
description=payload.description,
|
||||
created_by=current_user.id,
|
||||
knowledge_base_code=_generate_kb_code(db, payload.title),
|
||||
)
|
||||
db.add(kb)
|
||||
db.commit()
|
||||
db.refresh(kb)
|
||||
return kb
|
||||
|
||||
|
||||
@router.get("/knowledge-bases", response_model=List[kb_schemas.KnowledgeBaseResponse])
|
||||
def list_knowledge_bases(
|
||||
skip: int = 0,
|
||||
limit: int = 100,
|
||||
project: Optional[str] = None,
|
||||
db: Session = Depends(get_db),
|
||||
current_user: models.User = Depends(get_current_user_or_apikey),
|
||||
):
|
||||
_require_perm(db, current_user, PERM_READ)
|
||||
q = db.query(kb_models.KnowledgeBase)
|
||||
if project is not None:
|
||||
proj = _resolve_project(db, project)
|
||||
linked_ids = [
|
||||
row.knowledge_base_id
|
||||
for row in db.query(kb_models.ProjectKnowledgeBase.knowledge_base_id)
|
||||
.filter(kb_models.ProjectKnowledgeBase.project_id == proj.id)
|
||||
.all()
|
||||
]
|
||||
if not linked_ids:
|
||||
return []
|
||||
q = q.filter(kb_models.KnowledgeBase.id.in_(linked_ids))
|
||||
return q.order_by(kb_models.KnowledgeBase.id).offset(skip).limit(limit).all()
|
||||
|
||||
|
||||
@router.get("/knowledge-bases/{kb_id}", response_model=kb_schemas.KnowledgeBaseResponse)
|
||||
def get_knowledge_base(
|
||||
kb_id: str,
|
||||
db: Session = Depends(get_db),
|
||||
current_user: models.User = Depends(get_current_user_or_apikey),
|
||||
):
|
||||
_require_perm(db, current_user, PERM_READ)
|
||||
return _resolve_kb(db, kb_id)
|
||||
|
||||
|
||||
@router.patch("/knowledge-bases/{kb_id}", response_model=kb_schemas.KnowledgeBaseResponse)
|
||||
def update_knowledge_base(
|
||||
kb_id: str,
|
||||
payload: kb_schemas.KnowledgeBaseUpdate,
|
||||
db: Session = Depends(get_db),
|
||||
current_user: models.User = Depends(get_current_user_or_apikey),
|
||||
):
|
||||
_require_perm(db, current_user, PERM_UPDATE)
|
||||
kb = _resolve_kb(db, kb_id)
|
||||
data = payload.model_dump(exclude_unset=True)
|
||||
for field, value in data.items():
|
||||
setattr(kb, field, value)
|
||||
db.commit()
|
||||
db.refresh(kb)
|
||||
return kb
|
||||
|
||||
|
||||
@router.delete("/knowledge-bases/{kb_id}", status_code=status.HTTP_204_NO_CONTENT)
|
||||
def delete_knowledge_base(
|
||||
kb_id: str,
|
||||
db: Session = Depends(get_db),
|
||||
current_user: models.User = Depends(get_current_user_or_apikey),
|
||||
):
|
||||
_require_perm(db, current_user, PERM_DELETE)
|
||||
kb = _resolve_kb(db, kb_id)
|
||||
|
||||
topic_ids = [
|
||||
t.id
|
||||
for t in db.query(kb_models.KnowledgeTopic.id)
|
||||
.filter(kb_models.KnowledgeTopic.knowledge_base_id == kb.id)
|
||||
.all()
|
||||
]
|
||||
if topic_ids:
|
||||
db.query(kb_models.KnowledgeFact).filter(kb_models.KnowledgeFact.topic_id.in_(topic_ids)).delete(synchronize_session=False)
|
||||
db.query(kb_models.KnowledgeCategory).filter(kb_models.KnowledgeCategory.topic_id.in_(topic_ids)).delete(synchronize_session=False)
|
||||
db.query(kb_models.KnowledgeTopic).filter(kb_models.KnowledgeTopic.id.in_(topic_ids)).delete(synchronize_session=False)
|
||||
db.query(kb_models.ProjectKnowledgeBase).filter(kb_models.ProjectKnowledgeBase.knowledge_base_id == kb.id).delete(synchronize_session=False)
|
||||
db.delete(kb)
|
||||
db.commit()
|
||||
return None
|
||||
|
||||
|
||||
# ===========================================================================
|
||||
# Tree (read-only aggregate)
|
||||
# ===========================================================================
|
||||
@router.get("/knowledge-bases/{kb_id}/tree", response_model=kb_schemas.KnowledgeBaseTree)
|
||||
def get_knowledge_base_tree(
|
||||
kb_id: str,
|
||||
db: Session = Depends(get_db),
|
||||
current_user: models.User = Depends(get_current_user_or_apikey),
|
||||
):
|
||||
_require_perm(db, current_user, PERM_READ)
|
||||
kb = _resolve_kb(db, kb_id)
|
||||
|
||||
topics = db.query(kb_models.KnowledgeTopic).filter(kb_models.KnowledgeTopic.knowledge_base_id == kb.id).all()
|
||||
topic_ids = [t.id for t in topics]
|
||||
cats = (
|
||||
db.query(kb_models.KnowledgeCategory).filter(kb_models.KnowledgeCategory.topic_id.in_(topic_ids)).all()
|
||||
if topic_ids else []
|
||||
)
|
||||
facts = (
|
||||
db.query(kb_models.KnowledgeFact).filter(kb_models.KnowledgeFact.topic_id.in_(topic_ids)).all()
|
||||
if topic_ids else []
|
||||
)
|
||||
|
||||
# Index facts by (topic_id, category_id) and categories by (topic_id, parent)
|
||||
facts_by_cat: dict = {}
|
||||
facts_topic_direct: dict = {}
|
||||
for f in facts:
|
||||
fr = kb_schemas.KnowledgeFactResponse.model_validate(f)
|
||||
if f.category_id is None:
|
||||
facts_topic_direct.setdefault(f.topic_id, []).append(fr)
|
||||
else:
|
||||
facts_by_cat.setdefault(f.category_id, []).append(fr)
|
||||
|
||||
cats_by_parent: dict = {}
|
||||
for c in cats:
|
||||
cats_by_parent.setdefault((c.topic_id, c.parent), []).append(c)
|
||||
|
||||
def build_category(cat) -> kb_schemas.CategoryTreeNode:
|
||||
children = cats_by_parent.get((cat.topic_id, cat.id), [])
|
||||
return kb_schemas.CategoryTreeNode(
|
||||
id=cat.id,
|
||||
name=cat.name,
|
||||
parent=cat.parent,
|
||||
topic_id=cat.topic_id,
|
||||
description=cat.description,
|
||||
categories=[build_category(ch) for ch in children],
|
||||
facts=facts_by_cat.get(cat.id, []),
|
||||
)
|
||||
|
||||
topic_nodes = []
|
||||
for t in topics:
|
||||
top_level_cats = cats_by_parent.get((t.id, None), [])
|
||||
topic_nodes.append(
|
||||
kb_schemas.TopicTreeNode(
|
||||
id=t.id,
|
||||
topic=t.topic,
|
||||
knowledge_base_id=t.knowledge_base_id,
|
||||
description=t.description,
|
||||
categories=[build_category(c) for c in top_level_cats],
|
||||
facts=facts_topic_direct.get(t.id, []),
|
||||
)
|
||||
)
|
||||
|
||||
return kb_schemas.KnowledgeBaseTree(
|
||||
id=kb.id,
|
||||
knowledge_base_code=kb.knowledge_base_code,
|
||||
title=kb.title,
|
||||
description=kb.description,
|
||||
topics=topic_nodes,
|
||||
)
|
||||
|
||||
|
||||
# ===========================================================================
|
||||
# Topics
|
||||
# ===========================================================================
|
||||
@router.get("/knowledge-bases/{kb_id}/topics", response_model=List[kb_schemas.KnowledgeTopicResponse])
|
||||
def list_topics(
|
||||
kb_id: str,
|
||||
db: Session = Depends(get_db),
|
||||
current_user: models.User = Depends(get_current_user_or_apikey),
|
||||
):
|
||||
_require_perm(db, current_user, PERM_READ)
|
||||
kb = _resolve_kb(db, kb_id)
|
||||
return db.query(kb_models.KnowledgeTopic).filter(kb_models.KnowledgeTopic.knowledge_base_id == kb.id).all()
|
||||
|
||||
|
||||
@router.post("/knowledge-bases/{kb_id}/topics", response_model=kb_schemas.KnowledgeTopicResponse, status_code=status.HTTP_201_CREATED)
|
||||
def create_topic(
|
||||
kb_id: str,
|
||||
payload: kb_schemas.KnowledgeTopicCreate,
|
||||
db: Session = Depends(get_db),
|
||||
current_user: models.User = Depends(get_current_user_or_apikey),
|
||||
):
|
||||
_require_perm(db, current_user, PERM_UPDATE)
|
||||
kb = _resolve_kb(db, kb_id)
|
||||
existing = (
|
||||
db.query(kb_models.KnowledgeTopic)
|
||||
.filter(
|
||||
kb_models.KnowledgeTopic.knowledge_base_id == kb.id,
|
||||
kb_models.KnowledgeTopic.topic == payload.topic,
|
||||
)
|
||||
.first()
|
||||
)
|
||||
if existing:
|
||||
raise HTTPException(status_code=400, detail="A topic with this name already exists in this knowledge base")
|
||||
topic = kb_models.KnowledgeTopic(
|
||||
topic=payload.topic,
|
||||
description=payload.description,
|
||||
knowledge_base_id=kb.id,
|
||||
created_by=current_user.id,
|
||||
)
|
||||
db.add(topic)
|
||||
db.commit()
|
||||
db.refresh(topic)
|
||||
return topic
|
||||
|
||||
|
||||
@router.get("/knowledge-topics/{topic_id}", response_model=kb_schemas.KnowledgeTopicResponse)
|
||||
def get_topic(
|
||||
topic_id: int,
|
||||
db: Session = Depends(get_db),
|
||||
current_user: models.User = Depends(get_current_user_or_apikey),
|
||||
):
|
||||
_require_perm(db, current_user, PERM_READ)
|
||||
return _get_topic(db, topic_id)
|
||||
|
||||
|
||||
@router.patch("/knowledge-topics/{topic_id}", response_model=kb_schemas.KnowledgeTopicResponse)
|
||||
def update_topic(
|
||||
topic_id: int,
|
||||
payload: kb_schemas.KnowledgeTopicUpdate,
|
||||
db: Session = Depends(get_db),
|
||||
current_user: models.User = Depends(get_current_user_or_apikey),
|
||||
):
|
||||
_require_perm(db, current_user, PERM_UPDATE)
|
||||
topic = _get_topic(db, topic_id)
|
||||
data = payload.model_dump(exclude_unset=True)
|
||||
if "topic" in data and data["topic"] and data["topic"] != topic.topic:
|
||||
clash = (
|
||||
db.query(kb_models.KnowledgeTopic)
|
||||
.filter(
|
||||
kb_models.KnowledgeTopic.knowledge_base_id == topic.knowledge_base_id,
|
||||
kb_models.KnowledgeTopic.topic == data["topic"],
|
||||
kb_models.KnowledgeTopic.id != topic.id,
|
||||
)
|
||||
.first()
|
||||
)
|
||||
if clash:
|
||||
raise HTTPException(status_code=400, detail="A topic with this name already exists in this knowledge base")
|
||||
for field, value in data.items():
|
||||
setattr(topic, field, value)
|
||||
db.commit()
|
||||
db.refresh(topic)
|
||||
return topic
|
||||
|
||||
|
||||
@router.delete("/knowledge-topics/{topic_id}", status_code=status.HTTP_204_NO_CONTENT)
|
||||
def delete_topic(
|
||||
topic_id: int,
|
||||
db: Session = Depends(get_db),
|
||||
current_user: models.User = Depends(get_current_user_or_apikey),
|
||||
):
|
||||
_require_perm(db, current_user, PERM_UPDATE)
|
||||
topic = _get_topic(db, topic_id)
|
||||
db.query(kb_models.KnowledgeFact).filter(kb_models.KnowledgeFact.topic_id == topic.id).delete(synchronize_session=False)
|
||||
db.query(kb_models.KnowledgeCategory).filter(kb_models.KnowledgeCategory.topic_id == topic.id).delete(synchronize_session=False)
|
||||
db.delete(topic)
|
||||
db.commit()
|
||||
return None
|
||||
|
||||
|
||||
# ===========================================================================
|
||||
# Categories
|
||||
# ===========================================================================
|
||||
def _check_category_unique(db: Session, topic_id: int, parent: Optional[int], name: str, exclude_id: Optional[int] = None):
|
||||
q = db.query(kb_models.KnowledgeCategory).filter(
|
||||
kb_models.KnowledgeCategory.topic_id == topic_id,
|
||||
kb_models.KnowledgeCategory.name == name,
|
||||
)
|
||||
if parent is None:
|
||||
q = q.filter(kb_models.KnowledgeCategory.parent.is_(None))
|
||||
else:
|
||||
q = q.filter(kb_models.KnowledgeCategory.parent == parent)
|
||||
if exclude_id is not None:
|
||||
q = q.filter(kb_models.KnowledgeCategory.id != exclude_id)
|
||||
if q.first():
|
||||
raise HTTPException(status_code=400, detail="A category with this name already exists under the same parent")
|
||||
|
||||
|
||||
@router.get("/knowledge-topics/{topic_id}/categories", response_model=List[kb_schemas.KnowledgeCategoryResponse])
|
||||
def list_categories(
|
||||
topic_id: int,
|
||||
db: Session = Depends(get_db),
|
||||
current_user: models.User = Depends(get_current_user_or_apikey),
|
||||
):
|
||||
_require_perm(db, current_user, PERM_READ)
|
||||
_get_topic(db, topic_id)
|
||||
return db.query(kb_models.KnowledgeCategory).filter(kb_models.KnowledgeCategory.topic_id == topic_id).all()
|
||||
|
||||
|
||||
@router.post("/knowledge-categories", response_model=kb_schemas.KnowledgeCategoryResponse, status_code=status.HTTP_201_CREATED)
|
||||
def create_category(
|
||||
payload: kb_schemas.KnowledgeCategoryCreate,
|
||||
db: Session = Depends(get_db),
|
||||
current_user: models.User = Depends(get_current_user_or_apikey),
|
||||
):
|
||||
_require_perm(db, current_user, PERM_UPDATE)
|
||||
_get_topic(db, payload.topic_id)
|
||||
if payload.parent is not None:
|
||||
parent_cat = _get_category(db, payload.parent)
|
||||
if parent_cat.topic_id != payload.topic_id:
|
||||
raise HTTPException(status_code=400, detail="Parent category belongs to a different topic")
|
||||
_check_category_unique(db, payload.topic_id, payload.parent, payload.name)
|
||||
cat = kb_models.KnowledgeCategory(
|
||||
name=payload.name,
|
||||
description=payload.description,
|
||||
parent=payload.parent,
|
||||
topic_id=payload.topic_id,
|
||||
created_by=current_user.id,
|
||||
last_updated_by=current_user.id,
|
||||
)
|
||||
db.add(cat)
|
||||
db.commit()
|
||||
db.refresh(cat)
|
||||
return cat
|
||||
|
||||
|
||||
@router.get("/knowledge-categories/{category_id}", response_model=kb_schemas.KnowledgeCategoryResponse)
|
||||
def get_category(
|
||||
category_id: int,
|
||||
db: Session = Depends(get_db),
|
||||
current_user: models.User = Depends(get_current_user_or_apikey),
|
||||
):
|
||||
_require_perm(db, current_user, PERM_READ)
|
||||
return _get_category(db, category_id)
|
||||
|
||||
|
||||
@router.patch("/knowledge-categories/{category_id}", response_model=kb_schemas.KnowledgeCategoryResponse)
|
||||
def update_category(
|
||||
category_id: int,
|
||||
payload: kb_schemas.KnowledgeCategoryUpdate,
|
||||
db: Session = Depends(get_db),
|
||||
current_user: models.User = Depends(get_current_user_or_apikey),
|
||||
):
|
||||
_require_perm(db, current_user, PERM_UPDATE)
|
||||
cat = _get_category(db, category_id)
|
||||
data = payload.model_dump(exclude_unset=True)
|
||||
|
||||
new_parent = data.get("parent", cat.parent) if "parent" in data else cat.parent
|
||||
if "parent" in data and data["parent"] is not None:
|
||||
if data["parent"] == cat.id:
|
||||
raise HTTPException(status_code=400, detail="A category cannot be its own parent")
|
||||
parent_cat = _get_category(db, data["parent"])
|
||||
if parent_cat.topic_id != cat.topic_id:
|
||||
raise HTTPException(status_code=400, detail="Parent category belongs to a different topic")
|
||||
# Prevent cycles: new parent must not be a descendant of this category
|
||||
if data["parent"] in _descendant_category_ids(db, cat.id):
|
||||
raise HTTPException(status_code=400, detail="Cannot move a category under one of its own descendants")
|
||||
|
||||
new_name = data.get("name", cat.name)
|
||||
if ("name" in data and data["name"] != cat.name) or ("parent" in data and new_parent != cat.parent):
|
||||
_check_category_unique(db, cat.topic_id, new_parent, new_name, exclude_id=cat.id)
|
||||
|
||||
for field, value in data.items():
|
||||
setattr(cat, field, value)
|
||||
cat.last_updated_by = current_user.id
|
||||
db.commit()
|
||||
db.refresh(cat)
|
||||
return cat
|
||||
|
||||
|
||||
@router.delete("/knowledge-categories/{category_id}", status_code=status.HTTP_204_NO_CONTENT)
|
||||
def delete_category(
|
||||
category_id: int,
|
||||
db: Session = Depends(get_db),
|
||||
current_user: models.User = Depends(get_current_user_or_apikey),
|
||||
):
|
||||
_require_perm(db, current_user, PERM_UPDATE)
|
||||
cat = _get_category(db, category_id)
|
||||
ids = _descendant_category_ids(db, cat.id)
|
||||
db.query(kb_models.KnowledgeFact).filter(kb_models.KnowledgeFact.category_id.in_(ids)).delete(synchronize_session=False)
|
||||
db.query(kb_models.KnowledgeCategory).filter(kb_models.KnowledgeCategory.id.in_(ids)).delete(synchronize_session=False)
|
||||
db.commit()
|
||||
return None
|
||||
|
||||
|
||||
# ===========================================================================
|
||||
# Facts
|
||||
# ===========================================================================
|
||||
@router.post("/knowledge-facts", response_model=kb_schemas.KnowledgeFactResponse, status_code=status.HTTP_201_CREATED)
|
||||
def create_fact(
|
||||
payload: kb_schemas.KnowledgeFactCreate,
|
||||
db: Session = Depends(get_db),
|
||||
current_user: models.User = Depends(get_current_user_or_apikey),
|
||||
):
|
||||
_require_perm(db, current_user, PERM_UPDATE)
|
||||
_get_topic(db, payload.topic_id)
|
||||
if payload.category_id is not None:
|
||||
cat = _get_category(db, payload.category_id)
|
||||
if cat.topic_id != payload.topic_id:
|
||||
raise HTTPException(status_code=400, detail="Category belongs to a different topic")
|
||||
fact = kb_models.KnowledgeFact(
|
||||
fact=payload.fact,
|
||||
topic_id=payload.topic_id,
|
||||
category_id=payload.category_id,
|
||||
)
|
||||
db.add(fact)
|
||||
db.commit()
|
||||
db.refresh(fact)
|
||||
return fact
|
||||
|
||||
|
||||
@router.get("/knowledge-facts/{fact_id}", response_model=kb_schemas.KnowledgeFactResponse)
|
||||
def get_fact(
|
||||
fact_id: int,
|
||||
db: Session = Depends(get_db),
|
||||
current_user: models.User = Depends(get_current_user_or_apikey),
|
||||
):
|
||||
_require_perm(db, current_user, PERM_READ)
|
||||
fact = db.query(kb_models.KnowledgeFact).filter(kb_models.KnowledgeFact.id == fact_id).first()
|
||||
if not fact:
|
||||
raise HTTPException(status_code=404, detail="Fact not found")
|
||||
return fact
|
||||
|
||||
|
||||
@router.patch("/knowledge-facts/{fact_id}", response_model=kb_schemas.KnowledgeFactResponse)
|
||||
def update_fact(
|
||||
fact_id: int,
|
||||
payload: kb_schemas.KnowledgeFactUpdate,
|
||||
db: Session = Depends(get_db),
|
||||
current_user: models.User = Depends(get_current_user_or_apikey),
|
||||
):
|
||||
_require_perm(db, current_user, PERM_UPDATE)
|
||||
fact = db.query(kb_models.KnowledgeFact).filter(kb_models.KnowledgeFact.id == fact_id).first()
|
||||
if not fact:
|
||||
raise HTTPException(status_code=404, detail="Fact not found")
|
||||
data = payload.model_dump(exclude_unset=True)
|
||||
if "category_id" in data and data["category_id"] is not None:
|
||||
cat = _get_category(db, data["category_id"])
|
||||
if cat.topic_id != fact.topic_id:
|
||||
raise HTTPException(status_code=400, detail="Category belongs to a different topic")
|
||||
for field, value in data.items():
|
||||
setattr(fact, field, value)
|
||||
db.commit()
|
||||
db.refresh(fact)
|
||||
return fact
|
||||
|
||||
|
||||
@router.delete("/knowledge-facts/{fact_id}", status_code=status.HTTP_204_NO_CONTENT)
|
||||
def delete_fact(
|
||||
fact_id: int,
|
||||
db: Session = Depends(get_db),
|
||||
current_user: models.User = Depends(get_current_user_or_apikey),
|
||||
):
|
||||
_require_perm(db, current_user, PERM_UPDATE)
|
||||
fact = db.query(kb_models.KnowledgeFact).filter(kb_models.KnowledgeFact.id == fact_id).first()
|
||||
if not fact:
|
||||
raise HTTPException(status_code=404, detail="Fact not found")
|
||||
db.delete(fact)
|
||||
db.commit()
|
||||
return None
|
||||
|
||||
|
||||
# ===========================================================================
|
||||
# Project <-> KnowledgeBase links
|
||||
# ===========================================================================
|
||||
@router.get("/projects/{project_id}/knowledge-bases", response_model=List[kb_schemas.KnowledgeBaseResponse])
|
||||
def list_project_knowledge_bases(
|
||||
project_id: str,
|
||||
db: Session = Depends(get_db),
|
||||
current_user: models.User = Depends(get_current_user_or_apikey),
|
||||
):
|
||||
_require_perm(db, current_user, PERM_READ)
|
||||
project = _resolve_project(db, project_id)
|
||||
linked_ids = [
|
||||
row.knowledge_base_id
|
||||
for row in db.query(kb_models.ProjectKnowledgeBase.knowledge_base_id)
|
||||
.filter(kb_models.ProjectKnowledgeBase.project_id == project.id)
|
||||
.all()
|
||||
]
|
||||
if not linked_ids:
|
||||
return []
|
||||
return db.query(kb_models.KnowledgeBase).filter(kb_models.KnowledgeBase.id.in_(linked_ids)).all()
|
||||
|
||||
|
||||
@router.post("/projects/{project_id}/knowledge-bases", response_model=kb_schemas.KnowledgeBaseResponse, status_code=status.HTTP_201_CREATED)
|
||||
def link_knowledge_base_to_project(
|
||||
project_id: str,
|
||||
payload: kb_schemas.ProjectKnowledgeBaseLink,
|
||||
db: Session = Depends(get_db),
|
||||
current_user: models.User = Depends(get_current_user_or_apikey),
|
||||
):
|
||||
_require_perm(db, current_user, PERM_UPDATE)
|
||||
project = _resolve_project(db, project_id)
|
||||
kb = _resolve_kb(db, payload.knowledge_base)
|
||||
existing = (
|
||||
db.query(kb_models.ProjectKnowledgeBase)
|
||||
.filter(
|
||||
kb_models.ProjectKnowledgeBase.project_id == project.id,
|
||||
kb_models.ProjectKnowledgeBase.knowledge_base_id == kb.id,
|
||||
)
|
||||
.first()
|
||||
)
|
||||
if not existing:
|
||||
db.add(kb_models.ProjectKnowledgeBase(project_id=project.id, knowledge_base_id=kb.id))
|
||||
db.commit()
|
||||
return kb
|
||||
|
||||
|
||||
@router.delete("/projects/{project_id}/knowledge-bases/{kb_id}", status_code=status.HTTP_204_NO_CONTENT)
|
||||
def unlink_knowledge_base_from_project(
|
||||
project_id: str,
|
||||
kb_id: str,
|
||||
db: Session = Depends(get_db),
|
||||
current_user: models.User = Depends(get_current_user_or_apikey),
|
||||
):
|
||||
_require_perm(db, current_user, PERM_UPDATE)
|
||||
project = _resolve_project(db, project_id)
|
||||
kb = _resolve_kb(db, kb_id)
|
||||
db.query(kb_models.ProjectKnowledgeBase).filter(
|
||||
kb_models.ProjectKnowledgeBase.project_id == project.id,
|
||||
kb_models.ProjectKnowledgeBase.knowledge_base_id == kb.id,
|
||||
).delete(synchronize_session=False)
|
||||
db.commit()
|
||||
return None
|
||||
@@ -12,7 +12,7 @@ from sqlalchemy import func as sqlfunc
|
||||
from pydantic import BaseModel
|
||||
|
||||
from app.core.config import get_db
|
||||
from app.api.deps import get_current_user_or_apikey
|
||||
from app.api.deps import get_current_user_or_apikey, require_admin
|
||||
from app.api.rbac import check_project_role, ensure_can_edit_milestone
|
||||
from app.models import models
|
||||
from app.models.apikey import APIKey
|
||||
@@ -60,7 +60,8 @@ class APIKeyResponse(BaseModel):
|
||||
|
||||
|
||||
@router.post("/api-keys", response_model=APIKeyResponse, status_code=status.HTTP_201_CREATED, tags=["API Keys"])
|
||||
def create_api_key(data: APIKeyCreate, db: Session = Depends(get_db)):
|
||||
def create_api_key(data: APIKeyCreate, db: Session = Depends(get_db),
|
||||
_: models.User = Depends(require_admin)):
|
||||
user = db.query(models.User).filter(models.User.id == data.user_id).first()
|
||||
if not user:
|
||||
raise HTTPException(status_code=404, detail="User not found")
|
||||
@@ -73,15 +74,22 @@ def create_api_key(data: APIKeyCreate, db: Session = Depends(get_db)):
|
||||
|
||||
|
||||
@router.get("/api-keys", response_model=List[APIKeyResponse], tags=["API Keys"])
|
||||
def list_api_keys(user_id: int = None, db: Session = Depends(get_db)):
|
||||
def list_api_keys(user_id: int = None, db: Session = Depends(get_db),
|
||||
_: models.User = Depends(require_admin)):
|
||||
query = db.query(APIKey)
|
||||
if user_id:
|
||||
query = query.filter(APIKey.user_id == user_id)
|
||||
return query.all()
|
||||
keys = query.all()
|
||||
# Never expose the full secret on listing; show only a masked prefix.
|
||||
for k in keys:
|
||||
if k.key and len(k.key) > 8:
|
||||
k.key = k.key[:6] + "…" + k.key[-2:]
|
||||
return keys
|
||||
|
||||
|
||||
@router.delete("/api-keys/{key_id}", status_code=status.HTTP_204_NO_CONTENT, tags=["API Keys"])
|
||||
def revoke_api_key(key_id: int, db: Session = Depends(get_db)):
|
||||
def revoke_api_key(key_id: int, db: Session = Depends(get_db),
|
||||
_: models.User = Depends(require_admin)):
|
||||
key_obj = db.query(APIKey).filter(APIKey.id == key_id).first()
|
||||
if not key_obj:
|
||||
raise HTTPException(status_code=404, detail="API key not found")
|
||||
@@ -106,7 +114,8 @@ class ActivityLogResponse(BaseModel):
|
||||
|
||||
@router.get("/activity", response_model=List[ActivityLogResponse], tags=["Activity"])
|
||||
def list_activity(entity_type: str = None, entity_id: int = None, user_id: int = None,
|
||||
limit: int = 50, db: Session = Depends(get_db)):
|
||||
limit: int = 50, db: Session = Depends(get_db),
|
||||
_: models.User = Depends(get_current_user_or_apikey)):
|
||||
query = db.query(ActivityLog)
|
||||
if entity_type:
|
||||
query = query.filter(ActivityLog.entity_type == entity_type)
|
||||
@@ -199,8 +208,10 @@ def update_milestone(milestone_id: str, ms_update: schemas.MilestoneUpdate, db:
|
||||
|
||||
|
||||
@router.delete("/milestones/{milestone_id}", status_code=status.HTTP_204_NO_CONTENT, tags=["Milestones"])
|
||||
def delete_milestone(milestone_id: str, db: Session = Depends(get_db)):
|
||||
def delete_milestone(milestone_id: str, db: Session = Depends(get_db),
|
||||
current_user: models.User = Depends(get_current_user_or_apikey)):
|
||||
ms = _resolve_milestone(db, milestone_id)
|
||||
ensure_can_edit_milestone(db, current_user.id, ms)
|
||||
db.delete(ms)
|
||||
db.commit()
|
||||
return None
|
||||
@@ -322,16 +333,18 @@ class WorkLogResponse(BaseModel):
|
||||
|
||||
|
||||
@router.post("/worklogs", response_model=WorkLogResponse, status_code=status.HTTP_201_CREATED, tags=["Time Tracking"])
|
||||
def create_worklog(wl: WorkLogCreate, db: Session = Depends(get_db)):
|
||||
def create_worklog(wl: WorkLogCreate, db: Session = Depends(get_db),
|
||||
current_user: models.User = Depends(get_current_user_or_apikey)):
|
||||
task = db.query(Task).filter(Task.id == wl.task_id).first()
|
||||
if not task:
|
||||
raise HTTPException(status_code=404, detail="Task not found")
|
||||
user = db.query(models.User).filter(models.User.id == wl.user_id).first()
|
||||
if not user:
|
||||
raise HTTPException(status_code=404, detail="User not found")
|
||||
if wl.hours <= 0:
|
||||
raise HTTPException(status_code=400, detail="Hours must be positive")
|
||||
db_wl = WorkLog(**wl.model_dump())
|
||||
data = wl.model_dump()
|
||||
# Worklogs are always attributed to the caller (non-admins cannot log time for others).
|
||||
if not current_user.is_admin or not data.get("user_id"):
|
||||
data["user_id"] = current_user.id
|
||||
db_wl = WorkLog(**data)
|
||||
db.add(db_wl)
|
||||
db.commit()
|
||||
db.refresh(db_wl)
|
||||
@@ -370,10 +383,13 @@ def task_worklog_summary(task_id: str, db: Session = Depends(get_db)):
|
||||
|
||||
|
||||
@router.delete("/worklogs/{worklog_id}", status_code=status.HTTP_204_NO_CONTENT, tags=["Time Tracking"])
|
||||
def delete_worklog(worklog_id: int, db: Session = Depends(get_db)):
|
||||
def delete_worklog(worklog_id: int, db: Session = Depends(get_db),
|
||||
current_user: models.User = Depends(get_current_user_or_apikey)):
|
||||
wl = db.query(WorkLog).filter(WorkLog.id == worklog_id).first()
|
||||
if not wl:
|
||||
raise HTTPException(status_code=404, detail="Work log not found")
|
||||
if not current_user.is_admin and wl.user_id != current_user.id:
|
||||
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="Cannot delete another user's worklog")
|
||||
db.delete(wl)
|
||||
db.commit()
|
||||
return None
|
||||
@@ -382,7 +398,8 @@ def delete_worklog(worklog_id: int, db: Session = Depends(get_db)):
|
||||
# ============ Export ============
|
||||
|
||||
@router.get("/export/tasks", tags=["Export"])
|
||||
def export_tasks_csv(project_id: int = None, db: Session = Depends(get_db)):
|
||||
def export_tasks_csv(project_id: int = None, db: Session = Depends(get_db),
|
||||
_: models.User = Depends(get_current_user_or_apikey)):
|
||||
query = db.query(Task)
|
||||
if project_id:
|
||||
query = query.filter(Task.project_id == project_id)
|
||||
|
||||
@@ -22,6 +22,7 @@ from app.services.monitoring import (
|
||||
get_server_states_view,
|
||||
test_provider_connection,
|
||||
)
|
||||
from app.services.discord_wakeup import create_private_wakeup_channel
|
||||
router = APIRouter(prefix='/monitor', tags=['Monitor'])
|
||||
SUPPORTED_PROVIDERS = {'anthropic', 'openai', 'minimax', 'kimi', 'qwen'}
|
||||
|
||||
@@ -42,6 +43,12 @@ class MonitoredServerCreate(BaseModel):
|
||||
display_name: str | None = None
|
||||
|
||||
|
||||
class DiscordWakeupTestRequest(BaseModel):
|
||||
discord_user_id: str
|
||||
title: str = "HarborForge Wakeup"
|
||||
message: str = "A HarborForge slot is ready to start."
|
||||
|
||||
|
||||
def require_admin(current_user: models.User = Depends(get_current_user_or_apikey)):
|
||||
if not current_user.is_admin:
|
||||
raise HTTPException(status_code=403, detail='Admin required')
|
||||
@@ -175,43 +182,11 @@ def revoke_api_key(server_id: int, db: Session = Depends(get_db), _: models.User
|
||||
return None
|
||||
|
||||
|
||||
class ServerHeartbeat(BaseModel):
|
||||
identifier: str
|
||||
openclaw_version: str | None = None
|
||||
plugin_version: str | None = None
|
||||
agents: List[dict] = []
|
||||
nginx_installed: bool | None = None
|
||||
nginx_sites: List[str] = []
|
||||
cpu_pct: float | None = None
|
||||
mem_pct: float | None = None
|
||||
disk_pct: float | None = None
|
||||
swap_pct: float | None = None
|
||||
@router.post('/admin/discord-wakeup/test')
|
||||
def discord_wakeup_test(payload: DiscordWakeupTestRequest, _: models.User = Depends(require_admin)):
|
||||
return create_private_wakeup_channel(payload.discord_user_id, payload.title, payload.message)
|
||||
|
||||
|
||||
@router.post('/server/heartbeat')
|
||||
def server_heartbeat(payload: ServerHeartbeat, db: Session = Depends(get_db)):
|
||||
server = db.query(MonitoredServer).filter(MonitoredServer.identifier == payload.identifier, MonitoredServer.is_enabled == True).first()
|
||||
if not server:
|
||||
raise HTTPException(status_code=404, detail='unknown server identifier')
|
||||
st = db.query(ServerState).filter(ServerState.server_id == server.id).first()
|
||||
if not st:
|
||||
st = ServerState(server_id=server.id)
|
||||
db.add(st)
|
||||
st.openclaw_version = payload.openclaw_version
|
||||
st.plugin_version = payload.plugin_version
|
||||
st.agents_json = json.dumps(payload.agents, ensure_ascii=False)
|
||||
st.nginx_installed = payload.nginx_installed
|
||||
st.nginx_sites_json = json.dumps(payload.nginx_sites, ensure_ascii=False)
|
||||
st.cpu_pct = payload.cpu_pct
|
||||
st.mem_pct = payload.mem_pct
|
||||
st.disk_pct = payload.disk_pct
|
||||
st.swap_pct = payload.swap_pct
|
||||
st.last_seen_at = datetime.now(timezone.utc)
|
||||
db.commit()
|
||||
return {'ok': True, 'server_id': server.id, 'last_seen_at': st.last_seen_at}
|
||||
|
||||
|
||||
# Heartbeat v2 with API Key authentication
|
||||
class TelemetryPayload(BaseModel):
|
||||
identifier: str
|
||||
openclaw_version: str | None = None
|
||||
@@ -227,13 +202,13 @@ class TelemetryPayload(BaseModel):
|
||||
uptime_seconds: int | None = None
|
||||
|
||||
|
||||
@router.post('/server/heartbeat-v2')
|
||||
def server_heartbeat_v2(
|
||||
@router.post('/server/heartbeat')
|
||||
def server_heartbeat(
|
||||
payload: TelemetryPayload,
|
||||
x_api_key: str = Header(..., alias='X-API-Key', description='API Key from /admin/servers/{id}/api-key'),
|
||||
db: Session = Depends(get_db)
|
||||
):
|
||||
"""Server heartbeat using API Key authentication (no challenge_uuid required)"""
|
||||
"""Server heartbeat using API Key authentication."""
|
||||
server = db.query(MonitoredServer).filter(
|
||||
MonitoredServer.api_key == x_api_key,
|
||||
MonitoredServer.is_enabled == True
|
||||
@@ -256,4 +231,3 @@ def server_heartbeat_v2(
|
||||
st.last_seen_at = datetime.now(timezone.utc)
|
||||
db.commit()
|
||||
return {'ok': True, 'server_id': server.id, 'identifier': server.identifier, 'last_seen_at': st.last_seen_at}
|
||||
|
||||
|
||||
346
app/api/routers/oidc.py
Normal file
346
app/api/routers/oidc.py
Normal file
@@ -0,0 +1,346 @@
|
||||
"""OIDC (OpenID Connect) login + admin-configurable provider settings.
|
||||
|
||||
Provider config (issuer / client_id / client_secret / redirect_uri /
|
||||
scopes / post_login_redirect / admin_role / enabled) lives entirely in
|
||||
the `oidc_settings` DB table (single row, id=1) and is set via either
|
||||
the admin UI or `docker exec hf-backend hf-cli config oidc ...`.
|
||||
HARBORFORGE_OIDC_ONLY is the only OIDC-related env var (deploy-time
|
||||
policy: when true, password login is disabled).
|
||||
|
||||
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:
|
||||
"""DB row is the only source of truth — no env fallback. If the row is
|
||||
absent OIDC is treated as unconfigured (login attempts will 503)."""
|
||||
row = db.query(OidcSettings).filter(OidcSettings.id == 1).first()
|
||||
if row is None:
|
||||
return EffectiveOidc(False, "", "", "", "", "", "", "admin")
|
||||
return EffectiveOidc(
|
||||
bool(row.enabled),
|
||||
row.issuer or "",
|
||||
row.client_id or "",
|
||||
row.client_secret or "",
|
||||
row.redirect_uri or "",
|
||||
row.scopes or "",
|
||||
row.post_login_redirect or "",
|
||||
getattr(row, "admin_role", None) or "admin",
|
||||
)
|
||||
|
||||
|
||||
# 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 False,
|
||||
issuer=(row.issuer if row else None) or None,
|
||||
client_id=(row.client_id if row else None) or None,
|
||||
has_client_secret=bool(row.client_secret if row else None),
|
||||
redirect_uri=(row.redirect_uri if row else None) or None,
|
||||
scopes=(row.scopes if row else None) or None,
|
||||
post_login_redirect=(row.post_login_redirect if row else None) or None,
|
||||
admin_role=cfg.admin_role,
|
||||
oidc_only=bool(settings.HARBORFORGE_OIDC_ONLY),
|
||||
effective_enabled=cfg.configured,
|
||||
source="db",
|
||||
)
|
||||
|
||||
|
||||
@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, _=_)
|
||||
@@ -153,9 +153,27 @@ def _generate_project_code(db, name: str) -> str:
|
||||
|
||||
@router.post("", response_model=schemas.ProjectResponse, status_code=status.HTTP_201_CREATED)
|
||||
def create_project(project: schemas.ProjectCreate, db: Session = Depends(get_db), current_user: models.User = Depends(get_current_user_or_apikey)):
|
||||
# Check if user is admin
|
||||
# Project creation is gated by the `project.create` global permission
|
||||
# (admin auto-grants by virtue of is_admin). Any role granted that perm
|
||||
# via the Role Editor can create projects.
|
||||
if not current_user.is_admin:
|
||||
raise HTTPException(status_code=403, detail="Only admins can create projects")
|
||||
from app.models.role_permission import Permission, RolePermission
|
||||
has = (
|
||||
db.query(Permission.id)
|
||||
.join(RolePermission, RolePermission.permission_id == Permission.id)
|
||||
.filter(
|
||||
RolePermission.role_id == current_user.role_id,
|
||||
Permission.name == "project.create",
|
||||
)
|
||||
.first()
|
||||
if current_user.role_id
|
||||
else None
|
||||
)
|
||||
if not has:
|
||||
raise HTTPException(
|
||||
status_code=403,
|
||||
detail="Permission denied: project.create required",
|
||||
)
|
||||
# Auto-fill owner_name from owner_id
|
||||
user = db.query(models.User).filter(models.User.id == project.owner_id).first()
|
||||
if not user:
|
||||
|
||||
235
app/api/routers/schedule_type.py
Normal file
235
app/api/routers/schedule_type.py
Normal file
@@ -0,0 +1,235 @@
|
||||
"""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
|
||||
|
||||
|
||||
def _attach_derived(st: ScheduleType) -> ScheduleType:
|
||||
"""Attach derived fields (maintenance_duration_minutes) so the
|
||||
pydantic ScheduleTypeResponse picks them up via from_attributes.
|
||||
|
||||
Pydantic with from_attributes reads attributes off the ORM object;
|
||||
setting a transient attr here avoids having to convert through dict.
|
||||
"""
|
||||
if st is not None:
|
||||
st.maintenance_duration_minutes = st.compute_maintenance_duration()
|
||||
return st
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 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 [_attach_derived(st) for st in 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,
|
||||
maintenance_from=payload.maintenance_from,
|
||||
maintenance_to=payload.maintenance_to,
|
||||
)
|
||||
db.add(st)
|
||||
db.commit()
|
||||
db.refresh(st)
|
||||
return _attach_derived(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")
|
||||
|
||||
update_fields = payload.model_dump(exclude_unset=True)
|
||||
for field, value in update_fields.items():
|
||||
setattr(st, field, value)
|
||||
|
||||
# Re-validate maintenance after merge (partial updates can put the row
|
||||
# into an invalid window combo that the pydantic schema couldn't catch
|
||||
# because it only saw one field).
|
||||
from app.schemas.schedule_type import _validate_maintenance_window
|
||||
try:
|
||||
_validate_maintenance_window(st.maintenance_from, st.maintenance_to)
|
||||
except ValueError as e:
|
||||
db.rollback()
|
||||
raise HTTPException(422, str(e))
|
||||
|
||||
db.commit()
|
||||
db.refresh(st)
|
||||
return _attach_derived(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
|
||||
|
||||
st = db.query(ScheduleType).filter(ScheduleType.id == agent.schedule_type_id).first()
|
||||
return _attach_derived(st) if st else None
|
||||
|
||||
|
||||
@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}
|
||||
226
app/api/routers/schedule_type_special_slot.py
Normal file
226
app/api/routers/schedule_type_special_slot.py
Normal file
@@ -0,0 +1,226 @@
|
||||
"""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,
|
||||
maintenance_duration_minutes: int,
|
||||
) -> None:
|
||||
"""Reject special slots that wouldn't fit inside the parent's maintenance window."""
|
||||
if minute_in_window + estimated_duration > maintenance_duration_minutes:
|
||||
raise HTTPException(
|
||||
422,
|
||||
(
|
||||
f"special slot does not fit in maintenance window: "
|
||||
f"minute_in_window={minute_in_window} + "
|
||||
f"estimated_duration={estimated_duration} > "
|
||||
f"maintenance window {maintenance_duration_minutes}min"
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 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)
|
||||
st = _fetch_schedule_type(db, schedule_type_id)
|
||||
_validate_fits_window(payload.minute_in_window, payload.estimated_duration, st.compute_maintenance_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)
|
||||
parent = _fetch_schedule_type(db, schedule_type_id)
|
||||
_validate_fits_window(next_min, next_dur, parent.compute_maintenance_duration())
|
||||
|
||||
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}
|
||||
@@ -576,8 +576,10 @@ def take_task(
|
||||
# ---- Assignment ----
|
||||
|
||||
@router.post("/tasks/{task_code}/assign")
|
||||
def assign_task(task_code: str, assignee_id: int, db: Session = Depends(get_db)):
|
||||
def assign_task(task_code: str, assignee_id: int, db: Session = Depends(get_db),
|
||||
current_user: models.User = Depends(get_current_user_or_apikey)):
|
||||
task = _resolve_task(db, task_code)
|
||||
ensure_can_edit_task(db, current_user.id, task)
|
||||
user = db.query(models.User).filter(models.User.id == assignee_id).first()
|
||||
if not user:
|
||||
raise HTTPException(status_code=404, detail="User not found")
|
||||
@@ -765,7 +767,8 @@ def batch_transition(
|
||||
|
||||
|
||||
@router.post("/tasks/batch/assign")
|
||||
def batch_assign(data: BatchAssign, db: Session = Depends(get_db)):
|
||||
def batch_assign(data: BatchAssign, db: Session = Depends(get_db),
|
||||
current_user: models.User = Depends(get_current_user_or_apikey)):
|
||||
user = db.query(models.User).filter(models.User.id == data.assignee_id).first()
|
||||
if not user:
|
||||
raise HTTPException(status_code=404, detail="Assignee not found")
|
||||
@@ -773,6 +776,7 @@ def batch_assign(data: BatchAssign, db: Session = Depends(get_db)):
|
||||
for task_code in data.task_codes:
|
||||
task = db.query(Task).filter(Task.task_code == task_code).first()
|
||||
if task:
|
||||
ensure_can_edit_task(db, current_user.id, task)
|
||||
task.assignee_id = data.assignee_id
|
||||
updated.append(task.task_code)
|
||||
db.commit()
|
||||
|
||||
@@ -8,7 +8,8 @@ from sqlalchemy.exc import IntegrityError
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
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_bootstrap import DELETED_USER_USERNAME
|
||||
from app.models import models
|
||||
from app.models.agent import Agent
|
||||
from app.models.role_permission import Permission, Role, RolePermission
|
||||
@@ -30,12 +31,19 @@ def _user_response(user: models.User) -> dict:
|
||||
"role_id": user.role_id,
|
||||
"role_name": user.role_name,
|
||||
"agent_id": user.agent.agent_id if user.agent else None,
|
||||
"discord_user_id": user.discord_user_id,
|
||||
"oidc_issuer": user.oidc_issuer,
|
||||
"oidc_subject": user.oidc_subject,
|
||||
"created_at": user.created_at,
|
||||
}
|
||||
return data
|
||||
|
||||
|
||||
def require_admin(current_user: models.User = Depends(get_current_user)):
|
||||
def require_admin(current_user: models.User = Depends(get_current_user_or_apikey)):
|
||||
# Accept either OAuth2 JWT or X-API-Key (incl. Bearer-as-apikey fallback)
|
||||
# so CLI clients using their provisioned api-key can hit admin-gated user
|
||||
# routes (list / get / update / patch). The admin gate still reads
|
||||
# User.is_admin — only the auth carrier broadens.
|
||||
if not current_user.is_admin:
|
||||
raise HTTPException(status_code=403, detail="Admin required")
|
||||
return current_user
|
||||
@@ -64,11 +72,29 @@ def require_account_creator(
|
||||
raise HTTPException(status_code=403, detail="Account creation permission required")
|
||||
|
||||
|
||||
def _resolve_user_role(db: Session, role_id: int | None) -> Role:
|
||||
def _resolve_user_role(db: Session, role_id: int | None, *, is_agent: bool = False) -> Role:
|
||||
"""Resolve target role for user creation.
|
||||
|
||||
Default policy when caller didn't pin role_id:
|
||||
- is_agent (i.e. payload had agent_id/claw_identifier) → general-agent
|
||||
- human user → guest
|
||||
|
||||
general-agent ≈ guest + user.reset-self-apikey so agents can rotate
|
||||
their own API key without admin intervention. Created in
|
||||
init_bootstrap.py on every startup; falls back to guest if absent
|
||||
(e.g. very old DB that hasn't been re-seeded yet).
|
||||
"""
|
||||
if role_id is None:
|
||||
role = db.query(Role).filter(Role.name == "guest").first()
|
||||
default_name = "general-agent" if is_agent else "guest"
|
||||
role = db.query(Role).filter(Role.name == default_name).first()
|
||||
if not role and is_agent:
|
||||
# general-agent missing from this DB → fall back to guest, log warn
|
||||
role = db.query(Role).filter(Role.name == "guest").first()
|
||||
if not role:
|
||||
raise HTTPException(status_code=500, detail="Default guest role is missing")
|
||||
raise HTTPException(
|
||||
status_code=500,
|
||||
detail=f"Default role '{default_name}' is missing (DB not seeded)",
|
||||
)
|
||||
return role
|
||||
|
||||
role = db.query(Role).filter(Role.id == role_id).first()
|
||||
@@ -108,12 +134,19 @@ def create_user(
|
||||
if existing_agent:
|
||||
raise HTTPException(status_code=400, detail="agent_id already in use")
|
||||
|
||||
assigned_role = _resolve_user_role(db, user.role_id)
|
||||
hashed_password = get_password_hash(user.password) if user.password else None
|
||||
assigned_role = _resolve_user_role(db, user.role_id, is_agent=has_agent_id)
|
||||
# In OIDC-only mode, ignore any supplied password: the user is created
|
||||
# passwordless (cannot password-login) and is expected to sign in via a
|
||||
# bound OIDC identity. API keys still work for such users.
|
||||
if settings.HARBORFORGE_OIDC_ONLY:
|
||||
hashed_password = None
|
||||
else:
|
||||
hashed_password = get_password_hash(user.password) if user.password else None
|
||||
db_user = models.User(
|
||||
username=user.username,
|
||||
email=user.email,
|
||||
full_name=user.full_name,
|
||||
discord_user_id=user.discord_user_id,
|
||||
hashed_password=hashed_password,
|
||||
is_admin=False,
|
||||
is_active=True,
|
||||
@@ -188,7 +221,7 @@ def update_user(
|
||||
if payload.full_name is not None:
|
||||
user.full_name = payload.full_name
|
||||
|
||||
if payload.password is not None and payload.password.strip():
|
||||
if payload.password is not None and payload.password.strip() and not settings.HARBORFORGE_OIDC_ONLY:
|
||||
user.hashed_password = get_password_hash(payload.password)
|
||||
|
||||
if payload.role_id is not None:
|
||||
@@ -202,11 +235,159 @@ def update_user(
|
||||
raise HTTPException(status_code=400, detail="You cannot deactivate your own account")
|
||||
user.is_active = payload.is_active
|
||||
|
||||
if payload.discord_user_id is not None:
|
||||
user.discord_user_id = payload.discord_user_id or None
|
||||
|
||||
db.commit()
|
||||
db.refresh(user)
|
||||
return _user_response(user)
|
||||
|
||||
|
||||
@router.patch("/{identifier}/bind-agent", response_model=schemas.UserResponse)
|
||||
def bind_agent(
|
||||
identifier: str,
|
||||
payload: schemas.UserBindAgentRequest,
|
||||
db: Session = Depends(get_db),
|
||||
_: models.User = Depends(require_account_creator),
|
||||
):
|
||||
"""Bind an existing user to (agent_id, claw_identifier).
|
||||
|
||||
Backfill path for users that were created via `hf user create` before
|
||||
the cli supported `--agent-id` / `--claw-identifier` flags. Creates
|
||||
the `agents` row that should have been written at user-create time.
|
||||
|
||||
Idempotent: if the user is already bound to the same
|
||||
(agent_id, claw_identifier), returns the user unchanged (200, no-op).
|
||||
|
||||
Rejects (409) if:
|
||||
- the user is bound to a DIFFERENT (agent_id, claw_identifier)
|
||||
- the requested agent_id is already in use by another user
|
||||
|
||||
Permission: account.create (admin auto-grants) — same gate as
|
||||
POST /users so the surface stays symmetric.
|
||||
"""
|
||||
user = _find_user_by_id_or_username(db, identifier)
|
||||
if not user:
|
||||
raise HTTPException(status_code=404, detail="User not found")
|
||||
|
||||
existing_agent_for_user = db.query(Agent).filter(Agent.user_id == user.id).first()
|
||||
if existing_agent_for_user:
|
||||
if (
|
||||
existing_agent_for_user.agent_id == payload.agent_id
|
||||
and existing_agent_for_user.claw_identifier == payload.claw_identifier
|
||||
):
|
||||
# idempotent re-bind
|
||||
return _user_response(user)
|
||||
raise HTTPException(
|
||||
status_code=409,
|
||||
detail=(
|
||||
f"User '{user.username}' is already bound to agent "
|
||||
f"'{existing_agent_for_user.agent_id}' on claw "
|
||||
f"'{existing_agent_for_user.claw_identifier}'"
|
||||
),
|
||||
)
|
||||
|
||||
existing_for_agent_id = (
|
||||
db.query(Agent).filter(Agent.agent_id == payload.agent_id).first()
|
||||
)
|
||||
if existing_for_agent_id:
|
||||
raise HTTPException(
|
||||
status_code=409,
|
||||
detail=f"agent_id '{payload.agent_id}' already in use by another user",
|
||||
)
|
||||
|
||||
db.add(
|
||||
Agent(
|
||||
user_id=user.id,
|
||||
agent_id=payload.agent_id,
|
||||
claw_identifier=payload.claw_identifier,
|
||||
)
|
||||
)
|
||||
db.commit()
|
||||
db.refresh(user)
|
||||
return _user_response(user)
|
||||
|
||||
|
||||
_BUILTIN_USERNAMES = {"acc-mgr", DELETED_USER_USERNAME}
|
||||
|
||||
|
||||
def _reassign_user_references(db: Session, old_id: int, new_id: int) -> None:
|
||||
"""Reassign all foreign key references from old_id to new_id, then delete
|
||||
records that would be meaningless under deleted-user (api_keys, notifications,
|
||||
project memberships)."""
|
||||
from app.models.apikey import APIKey
|
||||
from app.models.notification import Notification
|
||||
from app.models.activity import ActivityLog as Activity
|
||||
from app.models.worklog import WorkLog as WorkLogModel
|
||||
from app.models.meeting import Meeting, MeetingParticipant
|
||||
from app.models.task import Task
|
||||
from app.models.support import Support
|
||||
from app.models.proposal import Proposal
|
||||
from app.models.milestone import Milestone
|
||||
from app.models.calendar import TimeSlot, SchedulePlan
|
||||
from app.models.minimum_workload import MinimumWorkload
|
||||
from app.models.essential import Essential
|
||||
|
||||
# Delete records that are meaningless without the real user
|
||||
db.query(APIKey).filter(APIKey.user_id == old_id).delete()
|
||||
db.query(Notification).filter(Notification.user_id == old_id).delete()
|
||||
db.query(models.ProjectMember).filter(models.ProjectMember.user_id == old_id).delete()
|
||||
|
||||
# Reassign ownership/authorship references
|
||||
db.query(models.Project).filter(models.Project.owner_id == old_id).update(
|
||||
{"owner_id": new_id})
|
||||
db.query(models.Comment).filter(models.Comment.author_id == old_id).update(
|
||||
{"author_id": new_id})
|
||||
db.query(Activity).filter(Activity.user_id == old_id).update(
|
||||
{"user_id": new_id})
|
||||
db.query(WorkLogModel).filter(WorkLogModel.user_id == old_id).update(
|
||||
{"user_id": new_id})
|
||||
|
||||
# Tasks
|
||||
db.query(Task).filter(Task.reporter_id == old_id).update(
|
||||
{"reporter_id": new_id})
|
||||
db.query(Task).filter(Task.assignee_id == old_id).update(
|
||||
{"assignee_id": new_id})
|
||||
db.query(Task).filter(Task.created_by_id == old_id).update(
|
||||
{"created_by_id": new_id})
|
||||
|
||||
# Meetings
|
||||
db.query(Meeting).filter(Meeting.reporter_id == old_id).update(
|
||||
{"reporter_id": new_id})
|
||||
db.query(MeetingParticipant).filter(MeetingParticipant.user_id == old_id).update(
|
||||
{"user_id": new_id})
|
||||
|
||||
# Support
|
||||
db.query(Support).filter(Support.reporter_id == old_id).update(
|
||||
{"reporter_id": new_id})
|
||||
db.query(Support).filter(Support.assignee_id == old_id).update(
|
||||
{"assignee_id": new_id})
|
||||
|
||||
# Proposals
|
||||
db.query(Proposal).filter(Proposal.created_by_id == old_id).update(
|
||||
{"created_by_id": new_id})
|
||||
|
||||
# Milestones
|
||||
db.query(Milestone).filter(Milestone.created_by_id == old_id).update(
|
||||
{"created_by_id": new_id})
|
||||
|
||||
# Calendar
|
||||
db.query(TimeSlot).filter(TimeSlot.user_id == old_id).update(
|
||||
{"user_id": new_id})
|
||||
db.query(SchedulePlan).filter(SchedulePlan.user_id == old_id).update(
|
||||
{"user_id": new_id})
|
||||
|
||||
# Minimum workload / Essential
|
||||
db.query(MinimumWorkload).filter(MinimumWorkload.user_id == old_id).update(
|
||||
{"user_id": new_id})
|
||||
db.query(Essential).filter(Essential.created_by_id == old_id).update(
|
||||
{"created_by_id": new_id})
|
||||
|
||||
# Agent profile
|
||||
db.query(Agent).filter(Agent.user_id == old_id).update(
|
||||
{"user_id": new_id})
|
||||
|
||||
|
||||
@router.delete("/{identifier}", status_code=status.HTTP_204_NO_CONTENT)
|
||||
def delete_user(
|
||||
identifier: str,
|
||||
@@ -218,17 +399,26 @@ def delete_user(
|
||||
raise HTTPException(status_code=404, detail="User not found")
|
||||
if current_user.id == user.id:
|
||||
raise HTTPException(status_code=400, detail="You cannot delete your own account")
|
||||
# Protect built-in accounts from deletion
|
||||
if user.is_admin:
|
||||
raise HTTPException(status_code=400, detail="Admin accounts cannot be deleted")
|
||||
if user.username == "acc-mgr":
|
||||
raise HTTPException(status_code=400, detail="The acc-mgr account is a built-in account and cannot be deleted")
|
||||
try:
|
||||
db.delete(user)
|
||||
db.commit()
|
||||
except IntegrityError:
|
||||
db.rollback()
|
||||
raise HTTPException(status_code=400, detail="User has related records. Deactivate the account instead.")
|
||||
if user.username in _BUILTIN_USERNAMES:
|
||||
raise HTTPException(
|
||||
status_code=400,
|
||||
detail=f"The {user.username} account is a built-in account and cannot be deleted",
|
||||
)
|
||||
|
||||
deleted_user = db.query(models.User).filter(
|
||||
models.User.username == DELETED_USER_USERNAME
|
||||
).first()
|
||||
if not deleted_user:
|
||||
raise HTTPException(
|
||||
status_code=500,
|
||||
detail="Built-in deleted-user account not found. Backend startup failed to seed it; restart the container.",
|
||||
)
|
||||
|
||||
_reassign_user_references(db, user.id, deleted_user.id)
|
||||
db.delete(user)
|
||||
db.commit()
|
||||
return None
|
||||
|
||||
|
||||
@@ -236,7 +426,7 @@ def delete_user(
|
||||
def reset_user_apikey(
|
||||
identifier: str,
|
||||
db: Session = Depends(get_db),
|
||||
current_user: models.User = Depends(get_current_user),
|
||||
current_user: models.User = Depends(get_current_user_or_apikey),
|
||||
):
|
||||
"""Reset (regenerate) a user's API key.
|
||||
|
||||
@@ -244,6 +434,8 @@ def reset_user_apikey(
|
||||
- user.reset-apikey: can reset any user's API key
|
||||
- user.reset-self-apikey: can reset only own API key
|
||||
- admin: can reset any user's API key
|
||||
|
||||
Accepts both OAuth2 Bearer token and X-API-Key authentication.
|
||||
"""
|
||||
import secrets
|
||||
from app.models.apikey import APIKey
|
||||
@@ -317,3 +509,92 @@ def list_user_worklogs(
|
||||
if current_user.id != user.id and not current_user.is_admin:
|
||||
raise HTTPException(status_code=403, detail="Forbidden")
|
||||
return db.query(WorkLog).filter(WorkLog.user_id == user.id).order_by(WorkLog.logged_date.desc()).limit(limit).all()
|
||||
|
||||
|
||||
# ---- OIDC identity binding ------------------------------------------------
|
||||
|
||||
class OidcBindingRequest(BaseModel):
|
||||
issuer: str
|
||||
subject: str
|
||||
|
||||
|
||||
class OidcBindingResponse(BaseModel):
|
||||
user_id: int
|
||||
username: str
|
||||
oidc_issuer: str | None = None
|
||||
oidc_subject: str | None = None
|
||||
|
||||
|
||||
def _assert_can_manage_oidc_binding(db: Session, caller: models.User, target: models.User) -> None:
|
||||
"""Global admins may (un)bind anyone. Non-admin account managers may
|
||||
only operate on non-privileged accounts — never on an admin or another
|
||||
privileged account — otherwise binding an attacker-controlled OIDC
|
||||
identity to an admin would be a privilege-escalation primitive."""
|
||||
if getattr(caller, "is_admin", False):
|
||||
return
|
||||
privileged = (
|
||||
getattr(target, "is_admin", False)
|
||||
or target.username in ("acc-mgr", "deleted-user")
|
||||
or _has_global_permission(db, target, "account.create")
|
||||
or _has_global_permission(db, target, "user.reset-apikey")
|
||||
)
|
||||
if privileged:
|
||||
raise HTTPException(
|
||||
status_code=403,
|
||||
detail="Only a global admin may manage the OIDC binding of a privileged account",
|
||||
)
|
||||
|
||||
|
||||
@router.put("/{identifier}/oidc-binding", response_model=OidcBindingResponse)
|
||||
def bind_user_oidc(
|
||||
identifier: str,
|
||||
payload: OidcBindingRequest,
|
||||
db: Session = Depends(get_db),
|
||||
caller: models.User = Depends(require_account_creator),
|
||||
):
|
||||
"""Bind an hf user to an external OIDC identity (issuer + subject).
|
||||
|
||||
Admin or account-manager (JWT or API key). Account managers may not
|
||||
target privileged/admin accounts. One OIDC identity maps to at most
|
||||
one user."""
|
||||
issuer = (payload.issuer or "").strip()
|
||||
subject = (payload.subject or "").strip()
|
||||
if not issuer or not subject:
|
||||
raise HTTPException(status_code=400, detail="issuer and subject are required")
|
||||
user = _find_user_by_id_or_username(db, identifier)
|
||||
if not user:
|
||||
raise HTTPException(status_code=404, detail="User not found")
|
||||
_assert_can_manage_oidc_binding(db, caller, user)
|
||||
clash = db.query(models.User).filter(
|
||||
models.User.oidc_issuer == issuer,
|
||||
models.User.oidc_subject == subject,
|
||||
models.User.id != user.id,
|
||||
).first()
|
||||
if clash:
|
||||
raise HTTPException(status_code=409, detail=f"OIDC identity already bound to user '{clash.username}'")
|
||||
user.oidc_issuer = issuer
|
||||
user.oidc_subject = subject
|
||||
db.commit()
|
||||
db.refresh(user)
|
||||
return OidcBindingResponse(user_id=user.id, username=user.username,
|
||||
oidc_issuer=user.oidc_issuer, oidc_subject=user.oidc_subject)
|
||||
|
||||
|
||||
@router.delete("/{identifier}/oidc-binding", response_model=OidcBindingResponse)
|
||||
def unbind_user_oidc(
|
||||
identifier: str,
|
||||
db: Session = Depends(get_db),
|
||||
caller: models.User = Depends(require_account_creator),
|
||||
):
|
||||
"""Remove a user's OIDC binding. Admin or account-manager; account
|
||||
managers may not target privileged/admin accounts."""
|
||||
user = _find_user_by_id_or_username(db, identifier)
|
||||
if not user:
|
||||
raise HTTPException(status_code=404, detail="User not found")
|
||||
_assert_can_manage_oidc_binding(db, caller, user)
|
||||
user.oidc_issuer = None
|
||||
user.oidc_subject = None
|
||||
db.commit()
|
||||
db.refresh(user)
|
||||
return OidcBindingResponse(user_id=user.id, username=user.username,
|
||||
oidc_issuer=None, oidc_subject=None)
|
||||
|
||||
@@ -5,11 +5,13 @@ from fastapi import APIRouter, Depends, HTTPException, status, BackgroundTasks
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from app.core.config import get_db
|
||||
from app.api.deps import require_admin
|
||||
from app.models.webhook import Webhook, WebhookLog
|
||||
from app.schemas.webhook import WebhookCreate, WebhookUpdate, WebhookResponse, WebhookLogResponse
|
||||
from app.services.webhook import fire_webhooks_sync
|
||||
|
||||
router = APIRouter(prefix="/webhooks", tags=["Webhooks"])
|
||||
# Webhook management is admin-only (registration, inspection, retry, logs).
|
||||
router = APIRouter(prefix="/webhooks", tags=["Webhooks"], dependencies=[Depends(require_admin)])
|
||||
|
||||
|
||||
@router.post("", response_model=WebhookResponse, status_code=status.HTTP_201_CREATED)
|
||||
|
||||
10
app/cli/__init__.py
Normal file
10
app/cli/__init__.py
Normal file
@@ -0,0 +1,10 @@
|
||||
"""hf-cli — operator commands run inside the backend container.
|
||||
|
||||
Subjects:
|
||||
admin — bootstrap / manage the initial admin user
|
||||
config — runtime config (OIDC, etc.)
|
||||
|
||||
Invoked via the shim at /usr/local/bin/hf-cli (Dockerfile-installed):
|
||||
docker exec hf-backend hf-cli admin create-user --email me@example.com --password '...'
|
||||
docker exec hf-backend hf-cli config oidc --issuer ... --client-id ... --enabled true
|
||||
"""
|
||||
68
app/cli/__main__.py
Normal file
68
app/cli/__main__.py
Normal file
@@ -0,0 +1,68 @@
|
||||
"""hf-cli entry point. Dispatches to subject-specific modules."""
|
||||
import sys
|
||||
|
||||
|
||||
def _load_all_models() -> None:
|
||||
"""Import every model module so SQLAlchemy's declarative registry
|
||||
resolves cross-table relationships (e.g. User.role, User.agent).
|
||||
|
||||
main.py's startup() does the same thing for the web server; the CLI
|
||||
skips startup() but still queries User → would otherwise hit
|
||||
`KeyError: 'Agent'` when SA tries to resolve relationship targets.
|
||||
Keep this list in sync with main.py's startup import list.
|
||||
"""
|
||||
from app.models import ( # noqa: F401
|
||||
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,
|
||||
)
|
||||
|
||||
|
||||
_load_all_models()
|
||||
|
||||
|
||||
USAGE = """Usage:
|
||||
hf-cli admin create-user --email <e> [--username <u>] [--full-name <n>]
|
||||
[--password <p>] [--oidc-issuer <url> --oidc-subject <sub>]
|
||||
hf-cli admin list
|
||||
hf-cli admin set-role --username <u> --role <admin|mgr|dev|guest|account-manager>
|
||||
hf-cli admin reset-password --username <u> --password <p>
|
||||
hf-cli admin bind-oidc --username <u> --oidc-issuer <url> --oidc-subject <sub>
|
||||
|
||||
hf-cli config oidc [--issuer <url>] [--client-id <id>] [--client-secret <s>]
|
||||
[--redirect-uri <url>] [--post-login-redirect <url>]
|
||||
[--scopes "openid email profile"] [--admin-role <role>]
|
||||
[--enabled true|false] [--show-secret]
|
||||
|
||||
Reads DATABASE_URL + SECRET_KEY from the same env as the backend. Run
|
||||
inside the backend container: `docker exec hf-backend hf-cli ...`.
|
||||
"""
|
||||
|
||||
|
||||
def main() -> int:
|
||||
args = sys.argv[1:]
|
||||
if len(args) < 1:
|
||||
sys.stderr.write(USAGE)
|
||||
return 1
|
||||
|
||||
subject = args[0]
|
||||
rest = args[1:]
|
||||
|
||||
if subject == "admin":
|
||||
from app.cli import admin
|
||||
return admin.dispatch(rest)
|
||||
if subject == "config":
|
||||
from app.cli import config
|
||||
return config.dispatch(rest)
|
||||
if subject in ("-h", "--help", "help"):
|
||||
sys.stdout.write(USAGE)
|
||||
return 0
|
||||
|
||||
sys.stderr.write(f"unknown subject: {subject}\n\n")
|
||||
sys.stderr.write(USAGE)
|
||||
return 1
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(main())
|
||||
269
app/cli/admin.py
Normal file
269
app/cli/admin.py
Normal file
@@ -0,0 +1,269 @@
|
||||
"""hf-cli admin … — bootstrap and manage the deployment's admin user."""
|
||||
import argparse
|
||||
import json
|
||||
import sys
|
||||
|
||||
from sqlalchemy.exc import IntegrityError
|
||||
|
||||
from app.api.deps import get_password_hash
|
||||
from app.core.config import SessionLocal, settings
|
||||
from app.models import models
|
||||
from app.models.role_permission import Role
|
||||
|
||||
|
||||
def _open_db():
|
||||
return SessionLocal()
|
||||
|
||||
|
||||
def _emit(payload: dict) -> None:
|
||||
sys.stdout.write(json.dumps(payload, indent=2) + "\n")
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# create-user
|
||||
# ---------------------------------------------------------------------------
|
||||
def _cmd_create_user(argv: list[str]) -> int:
|
||||
p = argparse.ArgumentParser(prog="hf-cli admin create-user")
|
||||
p.add_argument("--email", required=True)
|
||||
p.add_argument("--username", default=None,
|
||||
help="Defaults to email's local-part if omitted.")
|
||||
p.add_argument("--full-name", default="Admin")
|
||||
p.add_argument("--password", default=None,
|
||||
help="Required when HARBORFORGE_OIDC_ONLY=false. Ignored "
|
||||
"when OIDC_ONLY=true (use --oidc-issuer/--oidc-subject).")
|
||||
p.add_argument("--oidc-issuer", default=None,
|
||||
help="Bind the new admin to this OIDC issuer at creation. "
|
||||
"Required in OIDC_ONLY mode for the bootstrap admin.")
|
||||
p.add_argument("--oidc-subject", default=None,
|
||||
help="OIDC subject claim (sub) to bind the new admin to.")
|
||||
args = p.parse_args(argv)
|
||||
|
||||
username = args.username or args.email.split("@", 1)[0]
|
||||
oidc_only = bool(settings.HARBORFORGE_OIDC_ONLY)
|
||||
|
||||
if oidc_only:
|
||||
if not (args.oidc_issuer and args.oidc_subject):
|
||||
sys.stderr.write(
|
||||
"HARBORFORGE_OIDC_ONLY=true: must pass --oidc-issuer and "
|
||||
"--oidc-subject so the new admin can sign in.\n"
|
||||
)
|
||||
return 2
|
||||
hashed_password = None
|
||||
else:
|
||||
if not args.password:
|
||||
sys.stderr.write("--password is required when OIDC_ONLY is false.\n")
|
||||
return 2
|
||||
hashed_password = get_password_hash(args.password)
|
||||
|
||||
if (args.oidc_issuer and not args.oidc_subject) or (args.oidc_subject and not args.oidc_issuer):
|
||||
sys.stderr.write("--oidc-issuer and --oidc-subject must be passed together.\n")
|
||||
return 2
|
||||
|
||||
db = _open_db()
|
||||
try:
|
||||
existing = db.query(models.User).filter(models.User.username == username).first()
|
||||
if existing:
|
||||
sys.stderr.write(f"user '{username}' already exists (id={existing.id})\n")
|
||||
return 3
|
||||
|
||||
admin_role = db.query(Role).filter(Role.name == "admin").first()
|
||||
if not admin_role:
|
||||
sys.stderr.write(
|
||||
"admin role not found — backend startup seed should create it. "
|
||||
"Restart the container then retry.\n"
|
||||
)
|
||||
return 4
|
||||
|
||||
user = models.User(
|
||||
username=username,
|
||||
email=args.email,
|
||||
full_name=args.full_name,
|
||||
hashed_password=hashed_password,
|
||||
is_admin=True,
|
||||
is_active=True,
|
||||
role_id=admin_role.id,
|
||||
oidc_issuer=(args.oidc_issuer or None),
|
||||
oidc_subject=(args.oidc_subject or None),
|
||||
)
|
||||
db.add(user)
|
||||
try:
|
||||
db.commit()
|
||||
except IntegrityError as e:
|
||||
db.rollback()
|
||||
sys.stderr.write(f"DB integrity error: {e.orig}\n")
|
||||
return 5
|
||||
db.refresh(user)
|
||||
|
||||
_emit({
|
||||
"ok": True,
|
||||
"created": True,
|
||||
"user": {
|
||||
"id": user.id,
|
||||
"username": user.username,
|
||||
"email": user.email,
|
||||
"full_name": user.full_name,
|
||||
"is_admin": user.is_admin,
|
||||
"role_id": user.role_id,
|
||||
"oidc_issuer": user.oidc_issuer,
|
||||
"oidc_subject": user.oidc_subject,
|
||||
"has_password": user.hashed_password is not None,
|
||||
},
|
||||
})
|
||||
return 0
|
||||
finally:
|
||||
db.close()
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# list
|
||||
# ---------------------------------------------------------------------------
|
||||
def _cmd_list(_argv: list[str]) -> int:
|
||||
db = _open_db()
|
||||
try:
|
||||
admins = (
|
||||
db.query(models.User)
|
||||
.filter(models.User.is_admin == True) # noqa: E712
|
||||
.order_by(models.User.id.asc())
|
||||
.all()
|
||||
)
|
||||
_emit({
|
||||
"ok": True,
|
||||
"count": len(admins),
|
||||
"admins": [
|
||||
{
|
||||
"id": u.id,
|
||||
"username": u.username,
|
||||
"email": u.email,
|
||||
"is_active": u.is_active,
|
||||
"oidc_bound": bool(u.oidc_issuer and u.oidc_subject),
|
||||
"has_password": u.hashed_password is not None,
|
||||
}
|
||||
for u in admins
|
||||
],
|
||||
})
|
||||
return 0
|
||||
finally:
|
||||
db.close()
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# set-role
|
||||
# ---------------------------------------------------------------------------
|
||||
def _cmd_set_role(argv: list[str]) -> int:
|
||||
p = argparse.ArgumentParser(prog="hf-cli admin set-role")
|
||||
p.add_argument("--username", required=True)
|
||||
p.add_argument("--role", required=True)
|
||||
args = p.parse_args(argv)
|
||||
|
||||
db = _open_db()
|
||||
try:
|
||||
user = db.query(models.User).filter(models.User.username == args.username).first()
|
||||
if not user:
|
||||
sys.stderr.write(f"user '{args.username}' not found\n")
|
||||
return 3
|
||||
role = db.query(Role).filter(Role.name == args.role).first()
|
||||
if not role:
|
||||
sys.stderr.write(f"role '{args.role}' not found\n")
|
||||
return 4
|
||||
user.role_id = role.id
|
||||
user.is_admin = (args.role == "admin")
|
||||
db.commit()
|
||||
_emit({
|
||||
"ok": True,
|
||||
"user": {"id": user.id, "username": user.username, "role": role.name, "is_admin": user.is_admin},
|
||||
})
|
||||
return 0
|
||||
finally:
|
||||
db.close()
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# reset-password
|
||||
# ---------------------------------------------------------------------------
|
||||
def _cmd_reset_password(argv: list[str]) -> int:
|
||||
p = argparse.ArgumentParser(prog="hf-cli admin reset-password")
|
||||
p.add_argument("--username", required=True)
|
||||
p.add_argument("--password", required=True)
|
||||
args = p.parse_args(argv)
|
||||
|
||||
if settings.HARBORFORGE_OIDC_ONLY:
|
||||
sys.stderr.write("HARBORFORGE_OIDC_ONLY=true: password login is disabled.\n")
|
||||
return 2
|
||||
|
||||
db = _open_db()
|
||||
try:
|
||||
user = db.query(models.User).filter(models.User.username == args.username).first()
|
||||
if not user:
|
||||
sys.stderr.write(f"user '{args.username}' not found\n")
|
||||
return 3
|
||||
user.hashed_password = get_password_hash(args.password)
|
||||
db.commit()
|
||||
_emit({"ok": True, "user": {"id": user.id, "username": user.username, "password_reset": True}})
|
||||
return 0
|
||||
finally:
|
||||
db.close()
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# bind-oidc — attach an OIDC identity to an existing admin
|
||||
# ---------------------------------------------------------------------------
|
||||
def _cmd_bind_oidc(argv: list[str]) -> int:
|
||||
p = argparse.ArgumentParser(prog="hf-cli admin bind-oidc")
|
||||
p.add_argument("--username", required=True)
|
||||
p.add_argument("--oidc-issuer", required=True)
|
||||
p.add_argument("--oidc-subject", required=True)
|
||||
args = p.parse_args(argv)
|
||||
|
||||
db = _open_db()
|
||||
try:
|
||||
user = db.query(models.User).filter(models.User.username == args.username).first()
|
||||
if not user:
|
||||
sys.stderr.write(f"user '{args.username}' not found\n")
|
||||
return 3
|
||||
clash = db.query(models.User).filter(
|
||||
models.User.oidc_issuer == args.oidc_issuer,
|
||||
models.User.oidc_subject == args.oidc_subject,
|
||||
models.User.id != user.id,
|
||||
).first()
|
||||
if clash:
|
||||
sys.stderr.write(f"OIDC subject already bound to '{clash.username}' (id={clash.id})\n")
|
||||
return 4
|
||||
user.oidc_issuer = args.oidc_issuer
|
||||
user.oidc_subject = args.oidc_subject
|
||||
db.commit()
|
||||
_emit({
|
||||
"ok": True,
|
||||
"user": {
|
||||
"id": user.id,
|
||||
"username": user.username,
|
||||
"oidc_issuer": user.oidc_issuer,
|
||||
"oidc_subject": user.oidc_subject,
|
||||
},
|
||||
})
|
||||
return 0
|
||||
finally:
|
||||
db.close()
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# dispatcher
|
||||
# ---------------------------------------------------------------------------
|
||||
ACTIONS = {
|
||||
"create-user": _cmd_create_user,
|
||||
"list": _cmd_list,
|
||||
"set-role": _cmd_set_role,
|
||||
"reset-password": _cmd_reset_password,
|
||||
"bind-oidc": _cmd_bind_oidc,
|
||||
}
|
||||
|
||||
|
||||
def dispatch(argv: list[str]) -> int:
|
||||
if not argv:
|
||||
sys.stderr.write("admin: missing action; one of: " + ", ".join(ACTIONS) + "\n")
|
||||
return 1
|
||||
action, rest = argv[0], argv[1:]
|
||||
fn = ACTIONS.get(action)
|
||||
if not fn:
|
||||
sys.stderr.write(f"admin: unknown action '{action}'; valid: {', '.join(ACTIONS)}\n")
|
||||
return 1
|
||||
return fn(rest)
|
||||
108
app/cli/config.py
Normal file
108
app/cli/config.py
Normal file
@@ -0,0 +1,108 @@
|
||||
"""hf-cli config … — runtime configuration stored in DB.
|
||||
|
||||
Currently only the OIDC provider config has a CLI surface (it used to
|
||||
live in the AbstractWizard config). Mirrors dialectic-cli's
|
||||
`config oidc` shape: only the flags you pass are mutated, the rest stays
|
||||
unchanged. Prints the post-update row with client_secret masked unless
|
||||
--show-secret is given.
|
||||
"""
|
||||
import argparse
|
||||
import json
|
||||
import sys
|
||||
|
||||
from app.core.config import SessionLocal
|
||||
from app.models.oidc_settings import OidcSettings
|
||||
|
||||
|
||||
def _emit(payload: dict) -> None:
|
||||
sys.stdout.write(json.dumps(payload, indent=2) + "\n")
|
||||
|
||||
|
||||
def _bool(v: str) -> bool:
|
||||
return v.lower() in ("1", "true", "yes", "on")
|
||||
|
||||
|
||||
def _cmd_oidc(argv: list[str]) -> int:
|
||||
p = argparse.ArgumentParser(prog="hf-cli config oidc")
|
||||
p.add_argument("--issuer", default=None)
|
||||
p.add_argument("--client-id", default=None)
|
||||
p.add_argument("--client-secret", default=None)
|
||||
p.add_argument("--redirect-uri", default=None)
|
||||
p.add_argument("--post-login-redirect", default=None)
|
||||
p.add_argument("--scopes", default=None,
|
||||
help='Default: "openid email profile"')
|
||||
p.add_argument("--admin-role", default=None,
|
||||
help="OIDC role name that bootstraps an unbound hf admin "
|
||||
"on first OIDC-only login. Default: admin.")
|
||||
p.add_argument("--enabled", default=None,
|
||||
help="true|false. Without this flag the row's existing "
|
||||
"value is preserved.")
|
||||
p.add_argument("--show-secret", action="store_true",
|
||||
help="Reveal client_secret in the output (local audit "
|
||||
"only — never paste into chat).")
|
||||
args = p.parse_args(argv)
|
||||
|
||||
db = SessionLocal()
|
||||
try:
|
||||
row = db.query(OidcSettings).filter(OidcSettings.id == 1).first()
|
||||
if row is None:
|
||||
row = OidcSettings(id=1, enabled=False)
|
||||
db.add(row)
|
||||
|
||||
if args.issuer is not None:
|
||||
row.issuer = args.issuer.strip() or None
|
||||
if args.client_id is not None:
|
||||
row.client_id = args.client_id.strip() or None
|
||||
if args.client_secret is not None:
|
||||
row.client_secret = args.client_secret or None
|
||||
if args.redirect_uri is not None:
|
||||
row.redirect_uri = args.redirect_uri.strip() or None
|
||||
if args.post_login_redirect is not None:
|
||||
row.post_login_redirect = args.post_login_redirect.strip() or None
|
||||
if args.scopes is not None:
|
||||
row.scopes = args.scopes.strip() or None
|
||||
if args.admin_role is not None:
|
||||
row.admin_role = args.admin_role.strip() or None
|
||||
if args.enabled is not None:
|
||||
row.enabled = _bool(args.enabled)
|
||||
|
||||
db.commit()
|
||||
db.refresh(row)
|
||||
|
||||
out: dict = {
|
||||
"enabled": bool(row.enabled),
|
||||
"issuer": row.issuer,
|
||||
"client_id": row.client_id,
|
||||
"redirect_uri": row.redirect_uri,
|
||||
"post_login_redirect": row.post_login_redirect,
|
||||
"scopes": row.scopes,
|
||||
"admin_role": row.admin_role,
|
||||
}
|
||||
if args.show_secret:
|
||||
out["client_secret"] = row.client_secret
|
||||
elif row.client_secret:
|
||||
out["client_secret"] = "***set***"
|
||||
else:
|
||||
out["client_secret"] = None
|
||||
|
||||
_emit({"ok": True, "config": out})
|
||||
return 0
|
||||
finally:
|
||||
db.close()
|
||||
|
||||
|
||||
ACTIONS = {
|
||||
"oidc": _cmd_oidc,
|
||||
}
|
||||
|
||||
|
||||
def dispatch(argv: list[str]) -> int:
|
||||
if not argv:
|
||||
sys.stderr.write("config: missing action; one of: " + ", ".join(ACTIONS) + "\n")
|
||||
return 1
|
||||
action, rest = argv[0], argv[1:]
|
||||
fn = ACTIONS.get(action)
|
||||
if not fn:
|
||||
sys.stderr.write(f"config: unknown action '{action}'; valid: {', '.join(ACTIONS)}\n")
|
||||
return 1
|
||||
return fn(rest)
|
||||
@@ -1,34 +1,13 @@
|
||||
import os
|
||||
import json
|
||||
"""Backend runtime settings — env-only (no wizard / no config volume).
|
||||
|
||||
OIDC issuer/client_id/etc. live in the `oidc_settings` DB table set
|
||||
via `hf-cli config oidc ...`. The OIDC_ONLY flag remains env-driven
|
||||
because it's a deploy-time policy, not a per-tenant runtime config.
|
||||
"""
|
||||
from sqlalchemy import create_engine
|
||||
from sqlalchemy.ext.declarative import declarative_base
|
||||
from sqlalchemy.orm import sessionmaker
|
||||
from pydantic_settings import BaseSettings
|
||||
from typing import Optional
|
||||
|
||||
|
||||
def _resolve_db_url(env_url: str) -> str:
|
||||
"""Read DB config from wizard config volume if available, else use env."""
|
||||
config_dir = os.getenv("CONFIG_DIR", "/config")
|
||||
config_file = os.getenv("CONFIG_FILE", "harborforge.json")
|
||||
config_path = os.path.join(config_dir, config_file)
|
||||
|
||||
if os.path.exists(config_path):
|
||||
try:
|
||||
with open(config_path, "r") as f:
|
||||
cfg = json.load(f)
|
||||
db_cfg = cfg.get("database")
|
||||
if db_cfg:
|
||||
host = db_cfg.get("host", "mysql")
|
||||
port = db_cfg.get("port", 3306)
|
||||
user = db_cfg.get("user", "harborforge")
|
||||
password = db_cfg.get("password", "harborforge_pass")
|
||||
database = db_cfg.get("database", "harborforge")
|
||||
return f"mysql+pymysql://{user}:{password}@{host}:{port}/{database}"
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
return env_url
|
||||
|
||||
|
||||
class Settings(BaseSettings):
|
||||
@@ -38,15 +17,34 @@ class Settings(BaseSettings):
|
||||
ALGORITHM: str = "HS256"
|
||||
ACCESS_TOKEN_EXPIRE_MINUTES: int = 30
|
||||
|
||||
# When true: no password login at all. Password login endpoint rejects,
|
||||
# user creation ignores any password (passwordless users that only sign
|
||||
# in via a bound OIDC identity / API keys), frontend hides password UI.
|
||||
HARBORFORGE_OIDC_ONLY: bool = False
|
||||
|
||||
class Config:
|
||||
env_file = ".env"
|
||||
|
||||
|
||||
settings = Settings()
|
||||
|
||||
# Resolve DB URL: wizard config volume > env > default
|
||||
_db_url = _resolve_db_url(settings.DATABASE_URL)
|
||||
engine = create_engine(_db_url, pool_pre_ping=True)
|
||||
# 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."
|
||||
)
|
||||
|
||||
engine = create_engine(settings.DATABASE_URL, pool_pre_ping=True)
|
||||
SessionLocal = sessionmaker(autocommit=False, autoflush=False, bind=engine)
|
||||
Base = declarative_base()
|
||||
|
||||
|
||||
@@ -1,105 +1,49 @@
|
||||
"""
|
||||
HarborForge initialization from AbstractWizard config volume.
|
||||
HarborForge unconditional startup seeds — runs every time backend boots.
|
||||
|
||||
Reads config from shared volume (written by AbstractWizard).
|
||||
On startup, creates admin user and default project if not exists.
|
||||
Seeds default permissions, default roles, the `acc-mgr` built-in (account
|
||||
provisioning agent), and the `deleted-user` foreign-key sink. Idempotent;
|
||||
existing rows are left alone.
|
||||
|
||||
Wizard/.json config bootstrap has been removed entirely as of v0.4.0.
|
||||
First-deploy admin user, OIDC settings, and discord webhook config all
|
||||
moved to operator-driven flows:
|
||||
|
||||
docker exec hf-backend hf-cli admin create-user --email ... --password ...
|
||||
docker exec hf-backend hf-cli config oidc --issuer ... --client-id ...
|
||||
|
||||
Builtin accounts created here:
|
||||
- acc-mgr (account-manager role) — cannot log in, used by the
|
||||
account-creation API as a system principal
|
||||
- deleted-user — FK sink so user delete doesn't cascade
|
||||
|
||||
The bootstrap admin user is NOT created here — that's CLI-driven so
|
||||
operators pick the email/password themselves.
|
||||
"""
|
||||
import os
|
||||
import json
|
||||
import logging
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from app.models import models
|
||||
from app.models.role_permission import Role, Permission, RolePermission
|
||||
from app.api.deps import get_password_hash
|
||||
|
||||
logger = logging.getLogger("harborforge.init")
|
||||
|
||||
CONFIG_DIR = os.getenv("CONFIG_DIR", "/config")
|
||||
CONFIG_FILE = os.getenv("CONFIG_FILE", "harborforge.json")
|
||||
logger = logging.getLogger("harborforge.bootstrap")
|
||||
|
||||
|
||||
def load_config() -> dict | None:
|
||||
"""Load initialization config from shared volume."""
|
||||
config_path = os.path.join(CONFIG_DIR, CONFIG_FILE)
|
||||
if not os.path.exists(config_path):
|
||||
logger.info("No config file at %s, skipping initialization", config_path)
|
||||
return None
|
||||
try:
|
||||
with open(config_path, "r") as f:
|
||||
return json.load(f)
|
||||
except Exception as e:
|
||||
logger.warning("Failed to read config %s: %s", config_path, e)
|
||||
return None
|
||||
|
||||
|
||||
def get_db_url(config: dict) -> str | None:
|
||||
"""Build DATABASE_URL from wizard config, or fall back to env."""
|
||||
db_cfg = config.get("database")
|
||||
if not db_cfg:
|
||||
return os.getenv("DATABASE_URL")
|
||||
|
||||
host = db_cfg.get("host", "mysql")
|
||||
port = db_cfg.get("port", 3306)
|
||||
user = db_cfg.get("user", "harborforge")
|
||||
password = db_cfg.get("password", "harborforge_pass")
|
||||
database = db_cfg.get("database", "harborforge")
|
||||
return f"mysql+pymysql://{user}:{password}@{host}:{port}/{database}"
|
||||
|
||||
|
||||
def init_admin_user(db: Session, admin_cfg: dict) -> models.User | None:
|
||||
"""Create admin user if not exists."""
|
||||
username = admin_cfg.get("username", "admin")
|
||||
existing = db.query(models.User).filter(models.User.username == username).first()
|
||||
if existing:
|
||||
logger.info("Admin user '%s' already exists (id=%d), skipping", username, existing.id)
|
||||
return existing
|
||||
|
||||
password = admin_cfg.get("password", "changeme")
|
||||
user = models.User(
|
||||
username=username,
|
||||
email=admin_cfg.get("email", f"{username}@harborforge.local"),
|
||||
full_name=admin_cfg.get("full_name", "Admin"),
|
||||
hashed_password=get_password_hash(password),
|
||||
is_admin=True,
|
||||
is_active=True,
|
||||
)
|
||||
db.add(user)
|
||||
db.commit()
|
||||
db.refresh(user)
|
||||
logger.info("Created admin user '%s' (id=%d)", username, user.id)
|
||||
return user
|
||||
|
||||
|
||||
def init_default_project(db: Session, project_cfg: dict, owner_id: int, owner_name: str = "") -> None:
|
||||
"""Create default project if configured and not exists."""
|
||||
name = project_cfg.get("name")
|
||||
if not name:
|
||||
return
|
||||
existing = db.query(models.Project).filter(models.Project.name == name).first()
|
||||
if existing:
|
||||
logger.info("Project '%s' already exists (id=%d), skipping", name, existing.id)
|
||||
return
|
||||
|
||||
project = models.Project(
|
||||
name=name,
|
||||
description=project_cfg.get("description", ""),
|
||||
owner_name=project_cfg.get("owner") or owner_name or "",
|
||||
owner_id=owner_id,
|
||||
)
|
||||
db.add(project)
|
||||
db.commit()
|
||||
db.refresh(project)
|
||||
logger.info("Created default project '%s' (id=%d)", name, project.id)
|
||||
|
||||
|
||||
# Default permissions that will be created if not exist
|
||||
# ---------------------------------------------------------------------------
|
||||
# Permissions catalog (canonical; new perms get added on every release)
|
||||
# ---------------------------------------------------------------------------
|
||||
DEFAULT_PERMISSIONS = [
|
||||
# Project permissions
|
||||
("project.read", "View project", "project"),
|
||||
("project.write", "Edit project", "project"),
|
||||
("project.create", "Create a project", "project"),
|
||||
("project.delete", "Delete project", "project"),
|
||||
("project.manage_members", "Manage project members", "project"),
|
||||
# Knowledge base permissions
|
||||
("knowledge-base.read", "View knowledge bases", "knowledge-base"),
|
||||
("knowledge-base.create", "Create a knowledge base", "knowledge-base"),
|
||||
("knowledge-base.update", "Edit a knowledge base and its structure", "knowledge-base"),
|
||||
("knowledge-base.delete", "Delete a knowledge base", "knowledge-base"),
|
||||
# Task/Milestone permissions
|
||||
("task.create", "Create tasks", "task"),
|
||||
("task.read", "View tasks", "task"),
|
||||
@@ -132,13 +76,23 @@ DEFAULT_PERMISSIONS = [
|
||||
# Monitor
|
||||
("monitor.read", "View monitor", "monitor"),
|
||||
("monitor.manage", "Manage monitor", "monitor"),
|
||||
# Calendar
|
||||
("calendar.read", "View calendar slots and plans", "calendar"),
|
||||
("calendar.write", "Create and edit calendar slots and plans", "calendar"),
|
||||
("calendar.manage", "Manage calendar settings and workload policies", "calendar"),
|
||||
# Webhook
|
||||
("webhook.manage", "Manage webhooks", "admin"),
|
||||
# Project member management (used by DELETE /projects/{id}/members/{user_id})
|
||||
("member.remove", "Remove a project member", "project"),
|
||||
# Schedule type (calendar templates) — read covers list+detail, manage covers
|
||||
# create/edit/delete on schedule_types AND their special slots.
|
||||
("schedule_type.read", "View schedule types and special slots", "calendar"),
|
||||
("schedule_type.manage", "Create / edit / delete schedule types and slots", "calendar"),
|
||||
]
|
||||
|
||||
|
||||
def init_default_permissions(db: Session) -> list[Permission]:
|
||||
"""Create default permissions if they don't exist. Returns all permissions."""
|
||||
"""Insert any missing perms from DEFAULT_PERMISSIONS. Returns all rows."""
|
||||
created = []
|
||||
for name, description, category in DEFAULT_PERMISSIONS:
|
||||
existing = db.query(Permission).filter(Permission.name == name).first()
|
||||
@@ -147,56 +101,69 @@ def init_default_permissions(db: Session) -> list[Permission]:
|
||||
db.add(perm)
|
||||
created.append(perm)
|
||||
logger.info("Created permission '%s'", name)
|
||||
|
||||
if created:
|
||||
db.commit()
|
||||
|
||||
# Return all permissions
|
||||
return db.query(Permission).all()
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Default role → permission mapping
|
||||
# Default roles + permission set per role
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
# mgr: project management + all milestone/task/proposal actions
|
||||
_MGR_PERMISSIONS = {
|
||||
"project.read", "project.write", "project.manage_members",
|
||||
"project.read", "project.write", "project.create", "project.manage_members",
|
||||
"knowledge-base.read", "knowledge-base.create", "knowledge-base.update", "knowledge-base.delete",
|
||||
"task.create", "task.read", "task.write", "task.delete",
|
||||
"milestone.create", "milestone.read", "milestone.write", "milestone.delete",
|
||||
"milestone.freeze", "milestone.start", "milestone.close",
|
||||
"task.close", "task.reopen_closed", "task.reopen_completed",
|
||||
"propose.accept", "propose.reject", "propose.reopen",
|
||||
"monitor.read",
|
||||
"calendar.read", "calendar.write", "calendar.manage",
|
||||
"user.reset-self-apikey",
|
||||
}
|
||||
|
||||
# dev: day-to-day development work — no freeze/start/close milestone, no accept/reject proposal
|
||||
_DEV_PERMISSIONS = {
|
||||
"project.read",
|
||||
"knowledge-base.read", "knowledge-base.update",
|
||||
"task.create", "task.read", "task.write",
|
||||
"milestone.read",
|
||||
"task.close", "task.reopen_closed", "task.reopen_completed",
|
||||
"monitor.read",
|
||||
"calendar.read", "calendar.write",
|
||||
"user.reset-self-apikey",
|
||||
}
|
||||
|
||||
_ACCOUNT_MANAGER_PERMISSIONS = {
|
||||
"account.create",
|
||||
"user.reset-apikey",
|
||||
}
|
||||
|
||||
# Default role for agents (assigned automatically by POST /users when
|
||||
# the create-user payload carries agent_id/claw_identifier — see
|
||||
# app/api/routers/users.py:_resolve_user_role). Guest-tier reads +
|
||||
# self-service API-key rotation so agents can manage their own creds
|
||||
# without admin intervention.
|
||||
_GENERAL_AGENT_PERMISSIONS = {
|
||||
"project.read",
|
||||
"knowledge-base.read",
|
||||
"task.read",
|
||||
"milestone.read",
|
||||
"monitor.read",
|
||||
"calendar.read",
|
||||
"user.reset-self-apikey",
|
||||
}
|
||||
|
||||
# Role definitions: (name, description, permission_set)
|
||||
_DEFAULT_ROLES = [
|
||||
("admin", "Administrator - full access to all features", None), # None ⇒ all perms
|
||||
("admin", "Administrator - full access to all features", None), # None ⇒ all perms
|
||||
("account-manager", "Account manager - can only create accounts", _ACCOUNT_MANAGER_PERMISSIONS),
|
||||
("mgr", "Manager - project & milestone management", _MGR_PERMISSIONS),
|
||||
("dev", "Developer - task execution & daily work", _DEV_PERMISSIONS),
|
||||
("guest", "Guest - read-only access", None), # special: *.read only
|
||||
("general-agent", "General agent - read-only + self API key rotation", _GENERAL_AGENT_PERMISSIONS),
|
||||
("guest", "Guest - read-only access", None), # special: *.read only
|
||||
]
|
||||
|
||||
|
||||
def _ensure_role(db: Session, name: str, description: str, is_global: bool = True) -> Role:
|
||||
"""Get or create a role by name."""
|
||||
role = db.query(Role).filter(Role.name == name).first()
|
||||
if not role:
|
||||
role = Role(name=name, description=description, is_global=is_global)
|
||||
@@ -208,13 +175,8 @@ def _ensure_role(db: Session, name: str, description: str, is_global: bool = Tru
|
||||
|
||||
|
||||
def _sync_role_permissions(db: Session, role: Role, target_perm_names: set[str] | None) -> None:
|
||||
"""Ensure *role* has exactly the permissions in *target_perm_names*.
|
||||
|
||||
* ``None`` means **all** permissions (admin).
|
||||
* The special sentinel ``"__read_only__"`` is handled by the caller passing
|
||||
just the ``*.read`` names.
|
||||
Only adds missing permissions; never removes manually-granted ones (additive).
|
||||
"""
|
||||
"""Additive: grants missing perms, never revokes manually-granted ones.
|
||||
``target_perm_names is None`` means **all** perms (admin)."""
|
||||
all_perms = db.query(Permission).all()
|
||||
perm_by_name = {p.name: p for p in all_perms}
|
||||
|
||||
@@ -228,45 +190,40 @@ def _sync_role_permissions(db: Session, role: Role, target_perm_names: set[str]
|
||||
for pid in wanted_ids - existing_ids:
|
||||
db.add(RolePermission(role_id=role.id, permission_id=pid))
|
||||
added += 1
|
||||
|
||||
if added:
|
||||
db.commit()
|
||||
logger.info("Assigned %d new permissions to role '%s'", added, role.name)
|
||||
|
||||
|
||||
def init_admin_role(db: Session, admin_user: models.User) -> None:
|
||||
"""Create default roles (admin / mgr / dev / guest) with preset permissions."""
|
||||
|
||||
def init_default_roles(db: Session) -> None:
|
||||
"""Create default roles (admin/account-manager/mgr/dev/guest) + permissions."""
|
||||
all_perms = db.query(Permission).all()
|
||||
read_perm_names = {p.name for p in all_perms if p.name.endswith(".read")}
|
||||
|
||||
for name, description, perm_set in _DEFAULT_ROLES:
|
||||
role = _ensure_role(db, name, description)
|
||||
|
||||
if name == "guest":
|
||||
_sync_role_permissions(db, role, read_perm_names)
|
||||
else:
|
||||
_sync_role_permissions(db, role, perm_set)
|
||||
logger.info("Default roles ready (admin / account-manager / mgr / dev / guest)")
|
||||
|
||||
logger.info("Default roles setup complete (admin, mgr, dev, guest)")
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Built-in user accounts (system principals, cannot log in)
|
||||
# ---------------------------------------------------------------------------
|
||||
DELETED_USER_USERNAME = "deleted-user"
|
||||
|
||||
|
||||
def init_acc_mgr_user(db: Session) -> models.User | None:
|
||||
"""Create the built-in acc-mgr user if not exists.
|
||||
|
||||
This user:
|
||||
- Has role 'account-manager' (can only create accounts)
|
||||
- Cannot log in (no password, hashed_password=None)
|
||||
- Cannot be deleted (enforced in delete endpoint)
|
||||
- Is created automatically after wizard initialization
|
||||
"""
|
||||
"""The account-manager system principal. Holds the `account-manager`
|
||||
role so the account-creation API can attribute new users to it. No
|
||||
password, no OIDC binding — cannot log in."""
|
||||
username = "acc-mgr"
|
||||
existing = db.query(models.User).filter(models.User.username == username).first()
|
||||
if existing:
|
||||
logger.info("acc-mgr user already exists (id=%d), skipping", existing.id)
|
||||
return existing
|
||||
|
||||
# Find account-manager role
|
||||
acc_mgr_role = db.query(Role).filter(Role.name == "account-manager").first()
|
||||
if not acc_mgr_role:
|
||||
logger.warning("account-manager role not found, skipping acc-mgr user creation")
|
||||
@@ -276,7 +233,7 @@ def init_acc_mgr_user(db: Session) -> models.User | None:
|
||||
username=username,
|
||||
email="acc-mgr@harborforge.internal",
|
||||
full_name="Account Manager",
|
||||
hashed_password=None, # Cannot log in — no password
|
||||
hashed_password=None,
|
||||
is_admin=False,
|
||||
is_active=True,
|
||||
role_id=acc_mgr_role.id,
|
||||
@@ -288,33 +245,42 @@ def init_acc_mgr_user(db: Session) -> models.User | None:
|
||||
return user
|
||||
|
||||
|
||||
def run_init(db: Session) -> None:
|
||||
"""Main initialization entry point. Reads config from shared volume."""
|
||||
config = load_config()
|
||||
if not config:
|
||||
return
|
||||
def init_deleted_user(db: Session) -> models.User | None:
|
||||
"""FK sink for deleted users — when a real user is deleted, all FK
|
||||
references reassign here instead of cascading."""
|
||||
existing = db.query(models.User).filter(
|
||||
models.User.username == DELETED_USER_USERNAME
|
||||
).first()
|
||||
if existing:
|
||||
return existing
|
||||
|
||||
logger.info("Running HarborForge initialization from wizard config")
|
||||
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
|
||||
|
||||
# Initialize default permissions and admin role (always run)
|
||||
all_perms = init_default_permissions(db)
|
||||
logger.info("Default permissions initialized: %d total", len(all_perms))
|
||||
|
||||
# Admin user
|
||||
admin_cfg = config.get("admin")
|
||||
admin_user = None
|
||||
if admin_cfg:
|
||||
admin_user = init_admin_user(db, admin_cfg)
|
||||
# Create admin role and assign to admin user
|
||||
if admin_user:
|
||||
init_admin_role(db, admin_user)
|
||||
# ---------------------------------------------------------------------------
|
||||
# Top-level bootstrap entry point — called from main.py startup
|
||||
# ---------------------------------------------------------------------------
|
||||
def run_bootstrap(db: Session) -> None:
|
||||
"""Idempotent startup seed. Safe to call on every boot.
|
||||
|
||||
# Built-in acc-mgr user (after roles are created)
|
||||
Does NOT create the admin user — that's CLI-driven (see hf-cli admin
|
||||
create-user) so operators pick credentials.
|
||||
"""
|
||||
init_default_permissions(db)
|
||||
init_default_roles(db)
|
||||
init_acc_mgr_user(db)
|
||||
|
||||
# Default project
|
||||
project_cfg = config.get("default_project")
|
||||
if project_cfg and admin_user:
|
||||
init_default_project(db, project_cfg, admin_user.id, admin_user.username)
|
||||
|
||||
logger.info("Initialization complete")
|
||||
init_deleted_user(db)
|
||||
logger.info("Bootstrap seeds complete")
|
||||
178
app/main.py
178
app/main.py
@@ -1,6 +1,9 @@
|
||||
"""HarborForge API — Agent/人类协同任务管理平台"""
|
||||
from fastapi import FastAPI
|
||||
from fastapi.middleware.cors import CORSMiddleware
|
||||
from starlette.middleware.sessions import SessionMiddleware
|
||||
|
||||
from app.core.config import settings
|
||||
|
||||
app = FastAPI(
|
||||
title="HarborForge API",
|
||||
@@ -17,6 +20,17 @@ app.add_middleware(
|
||||
allow_headers=["*"],
|
||||
)
|
||||
|
||||
# Short-lived signed session cookie — only used to carry the OIDC
|
||||
# state/nonce between /auth/oidc/login and the callback.
|
||||
app.add_middleware(
|
||||
SessionMiddleware,
|
||||
secret_key=settings.SECRET_KEY,
|
||||
session_cookie="hf_oidc",
|
||||
same_site="lax",
|
||||
https_only=False,
|
||||
max_age=600,
|
||||
)
|
||||
|
||||
# Health & version (kept at top level)
|
||||
@app.get("/health", tags=["System"])
|
||||
def health_check():
|
||||
@@ -28,23 +42,22 @@ def version():
|
||||
|
||||
@app.get("/config/status", tags=["System"])
|
||||
def config_status():
|
||||
"""Check if HarborForge has been initialized (reads from config volume).
|
||||
Frontend uses this instead of contacting the wizard directly."""
|
||||
import os, json
|
||||
config_dir = os.getenv("CONFIG_DIR", "/config")
|
||||
config_file = os.getenv("CONFIG_FILE", "harborforge.json")
|
||||
config_path = os.path.join(config_dir, config_file)
|
||||
if not os.path.exists(config_path):
|
||||
return {"initialized": False}
|
||||
"""Has the deployment been bootstrapped (admin user exists)?
|
||||
|
||||
Frontend hits this on mount to decide whether to show login or a
|
||||
"no admin yet, run hf-cli admin create-user" placeholder. With the
|
||||
wizard removed in v0.4.0 the only deploy-time bootstrap step is the
|
||||
operator running `docker exec hf-backend hf-cli admin create-user ...`
|
||||
once; this endpoint just reports whether that has happened.
|
||||
"""
|
||||
from app.core.config import SessionLocal
|
||||
from app.models import models
|
||||
db = SessionLocal()
|
||||
try:
|
||||
with open(config_path, "r") as f:
|
||||
cfg = json.load(f)
|
||||
return {
|
||||
"initialized": cfg.get("initialized", False),
|
||||
"backend_url": cfg.get("backend_url"),
|
||||
}
|
||||
except Exception:
|
||||
return {"initialized": False}
|
||||
admin_count = db.query(models.User).filter(models.User.is_admin == True).count() # noqa: E712
|
||||
return {"initialized": admin_count > 0}
|
||||
finally:
|
||||
db.close()
|
||||
|
||||
# Register routers
|
||||
from app.api.routers.auth import router as auth_router
|
||||
@@ -62,9 +75,14 @@ from app.api.routers.proposes import router as proposes_router # legacy compat
|
||||
from app.api.routers.milestone_actions import router as milestone_actions_router
|
||||
from app.api.routers.meetings import router as meetings_router
|
||||
from app.api.routers.essentials import router as essentials_router
|
||||
from app.api.routers.schedule_type import router as schedule_type_router
|
||||
from app.api.routers.schedule_type_special_slot import router as schedule_type_special_slot_router
|
||||
from app.api.routers.calendar import router as calendar_router
|
||||
from app.api.routers.oidc import router as oidc_router
|
||||
from app.api.routers.knowledge import router as knowledge_router
|
||||
|
||||
app.include_router(auth_router)
|
||||
app.include_router(oidc_router)
|
||||
app.include_router(tasks_router)
|
||||
app.include_router(projects_router)
|
||||
app.include_router(users_router)
|
||||
@@ -79,7 +97,10 @@ app.include_router(proposes_router) # legacy compat
|
||||
app.include_router(milestone_actions_router)
|
||||
app.include_router(meetings_router)
|
||||
app.include_router(essentials_router)
|
||||
app.include_router(schedule_type_router)
|
||||
app.include_router(schedule_type_special_slot_router)
|
||||
app.include_router(calendar_router)
|
||||
app.include_router(knowledge_router)
|
||||
|
||||
|
||||
# Auto schema migration for lightweight deployments
|
||||
@@ -96,6 +117,25 @@ def _migrate_schema():
|
||||
{"column_name": column_name},
|
||||
).fetchone() is not None
|
||||
|
||||
def _has_index(db, table_name: str, index_name: str) -> bool:
|
||||
return db.execute(
|
||||
text(
|
||||
"""
|
||||
SELECT 1
|
||||
FROM information_schema.STATISTICS
|
||||
WHERE TABLE_SCHEMA = DATABASE()
|
||||
AND TABLE_NAME = :table_name
|
||||
AND INDEX_NAME = :index_name
|
||||
LIMIT 1
|
||||
"""
|
||||
),
|
||||
{"table_name": table_name, "index_name": index_name},
|
||||
).fetchone() is not None
|
||||
|
||||
def _ensure_unique_index(db, table_name: str, index_name: str, columns_sql: str):
|
||||
if not _has_index(db, table_name, index_name):
|
||||
db.execute(text(f"CREATE UNIQUE INDEX {index_name} ON {table_name} ({columns_sql})"))
|
||||
|
||||
def _drop_fk_constraints(db, table_name: str, referenced_table: str):
|
||||
rows = db.execute(text(
|
||||
"""
|
||||
@@ -139,9 +179,7 @@ def _migrate_schema():
|
||||
result = db.execute(text("SHOW COLUMNS FROM projects LIKE 'project_code'"))
|
||||
if not result.fetchone():
|
||||
db.execute(text("ALTER TABLE projects ADD COLUMN project_code VARCHAR(16) NULL"))
|
||||
db.execute(text("CREATE UNIQUE INDEX idx_projects_project_code ON projects (project_code)"))
|
||||
else:
|
||||
db.execute(text("CREATE UNIQUE INDEX IF NOT EXISTS idx_projects_project_code ON projects (project_code)"))
|
||||
_ensure_unique_index(db, "projects", "idx_projects_project_code", "project_code")
|
||||
|
||||
# projects.owner_name
|
||||
result = db.execute(text("SHOW COLUMNS FROM projects LIKE 'owner_name'"))
|
||||
@@ -176,7 +214,7 @@ def _migrate_schema():
|
||||
db.execute(text("ALTER TABLE tasks ADD COLUMN created_by_id INTEGER NULL"))
|
||||
_ensure_fk(db, "tasks", "created_by_id", "users", "id", "fk_tasks_created_by_id")
|
||||
if _has_column(db, "tasks", "task_code"):
|
||||
db.execute(text("CREATE UNIQUE INDEX IF NOT EXISTS idx_tasks_task_code ON tasks (task_code)"))
|
||||
_ensure_unique_index(db, "tasks", "idx_tasks_task_code", "task_code")
|
||||
|
||||
# milestones creator field
|
||||
result = db.execute(text("SHOW COLUMNS FROM milestones LIKE 'created_by_id'"))
|
||||
@@ -207,7 +245,7 @@ def _migrate_schema():
|
||||
# --- Milestone status enum migration (old -> new) ---
|
||||
if _has_table(db, "milestones"):
|
||||
if _has_column(db, "milestones", "milestone_code"):
|
||||
db.execute(text("CREATE UNIQUE INDEX IF NOT EXISTS idx_milestones_milestone_code ON milestones (milestone_code)"))
|
||||
_ensure_unique_index(db, "milestones", "idx_milestones_milestone_code", "milestone_code")
|
||||
# Alter enum column to accept new values
|
||||
db.execute(text(
|
||||
"ALTER TABLE milestones MODIFY COLUMN status "
|
||||
@@ -254,6 +292,21 @@ def _migrate_schema():
|
||||
db.execute(text("ALTER TABLE users ADD COLUMN role_id INTEGER NULL"))
|
||||
_ensure_fk(db, "users", "role_id", "roles", "id", "fk_users_role_id")
|
||||
|
||||
if _has_table(db, "users") and not _has_column(db, "users", "discord_user_id"):
|
||||
db.execute(text("ALTER TABLE users ADD COLUMN discord_user_id VARCHAR(32) NULL"))
|
||||
|
||||
# --- users OIDC binding (issuer + subject), unique together ---
|
||||
if _has_table(db, "users") and not _has_column(db, "users", "oidc_issuer"):
|
||||
db.execute(text("ALTER TABLE users ADD COLUMN oidc_issuer VARCHAR(255) NULL"))
|
||||
if _has_table(db, "users") and not _has_column(db, "users", "oidc_subject"):
|
||||
db.execute(text("ALTER TABLE users ADD COLUMN oidc_subject VARCHAR(255) NULL"))
|
||||
if _has_table(db, "users") and _has_column(db, "users", "oidc_subject"):
|
||||
_ensure_unique_index(db, "users", "uq_users_oidc_identity", "oidc_issuer, oidc_subject")
|
||||
|
||||
# --- oidc_settings.admin_role (added after the table shipped) ---
|
||||
if _has_table(db, "oidc_settings") and not _has_column(db, "oidc_settings", "admin_role"):
|
||||
db.execute(text("ALTER TABLE oidc_settings ADD COLUMN admin_role VARCHAR(128) NULL"))
|
||||
|
||||
# --- monitored_servers.api_key for heartbeat v2 ---
|
||||
if _has_table(db, "monitored_servers") and not _has_column(db, "monitored_servers", "api_key"):
|
||||
db.execute(text("ALTER TABLE monitored_servers ADD COLUMN api_key VARCHAR(64) NULL"))
|
||||
@@ -264,16 +317,16 @@ def _migrate_schema():
|
||||
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"):
|
||||
db.execute(text("CREATE UNIQUE INDEX IF NOT EXISTS idx_meetings_meeting_code ON 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"):
|
||||
db.execute(text("CREATE UNIQUE INDEX IF NOT EXISTS idx_supports_support_code ON 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"):
|
||||
db.execute(text("CREATE UNIQUE INDEX IF NOT EXISTS idx_proposes_propose_code ON 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"):
|
||||
db.execute(text("CREATE UNIQUE INDEX IF NOT EXISTS idx_essentials_essential_code ON essentials (essential_code)"))
|
||||
_ensure_unique_index(db, "essentials", "idx_essentials_essential_code", "essential_code")
|
||||
|
||||
# --- server_states nginx telemetry for generic monitor client ---
|
||||
if _has_table(db, "server_states") and not _has_column(db, "server_states", "nginx_installed"):
|
||||
@@ -338,6 +391,71 @@ def _migrate_schema():
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4
|
||||
"""))
|
||||
|
||||
# --- time_slots: add wakeup_sent_at for Discord wakeup tracking ---
|
||||
if _has_table(db, "time_slots") and not _has_column(db, "time_slots", "wakeup_sent_at"):
|
||||
db.execute(text("ALTER TABLE time_slots ADD COLUMN wakeup_sent_at DATETIME NULL"))
|
||||
|
||||
# --- agents: add schedule_type_id FK ---
|
||||
if _has_table(db, "agents") and not _has_column(db, "agents", "schedule_type_id"):
|
||||
db.execute(text("ALTER TABLE agents ADD COLUMN schedule_type_id INTEGER NULL"))
|
||||
|
||||
# --- schedule_types: add maintenance_from / maintenance_to ---
|
||||
# Default 8:00–9:00 UTC for existing rows; the maintenance
|
||||
# duration invariant (1-180min) is enforced at the schema
|
||||
# level for any NEW rows by 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"
|
||||
))
|
||||
|
||||
# --- minutes-since-midnight migration (PR #21+) ---
|
||||
# The 6 schedule_type window columns used to hold *hours*
|
||||
# (0-23). PR #21 changed semantics to *minutes since UTC
|
||||
# midnight* (0-1439). Detect the legacy regime by checking
|
||||
# if ANY row has all 6 values ≤ 23 — if so, multiply each
|
||||
# by 60 to convert. Idempotent: post-conversion values are
|
||||
# all ≥ 0 and usually well above 23, so guard never fires
|
||||
# twice.
|
||||
row = db.execute(text(
|
||||
"SELECT MAX(GREATEST(work_from, work_to, entertainment_from, entertainment_to, maintenance_from, maintenance_to)) AS m "
|
||||
"FROM schedule_types"
|
||||
)).fetchone()
|
||||
if row is not None and row.m is not None and row.m <= 23:
|
||||
db.execute(text(
|
||||
"UPDATE schedule_types SET "
|
||||
" work_from = work_from * 60, "
|
||||
" work_to = work_to * 60, "
|
||||
" entertainment_from = entertainment_from * 60, "
|
||||
" entertainment_to = entertainment_to * 60, "
|
||||
" maintenance_from = maintenance_from * 60, "
|
||||
" maintenance_to = maintenance_to * 60"
|
||||
))
|
||||
|
||||
# --- time_slots: admin-locked + special_slot pointer ---
|
||||
if _has_table(db, "time_slots"):
|
||||
if not _has_column(db, "time_slots", "is_admin_locked"):
|
||||
db.execute(text(
|
||||
"ALTER TABLE time_slots ADD COLUMN is_admin_locked TINYINT(1) NOT NULL DEFAULT 0"
|
||||
))
|
||||
if not _has_column(db, "time_slots", "special_slot_id"):
|
||||
db.execute(text(
|
||||
"ALTER TABLE time_slots ADD COLUMN special_slot_id INTEGER NULL"
|
||||
))
|
||||
# Index for the materialiser's idempotency lookup
|
||||
db.execute(text(
|
||||
"CREATE INDEX idx_time_slots_special_slot_id ON time_slots (special_slot_id)"
|
||||
))
|
||||
|
||||
# --- schedule_type_special_slots: create-table is handled by
|
||||
# Base.metadata.create_all on first boot; no migration needed here
|
||||
# because there is no legacy table to evolve. Future schema bumps
|
||||
# to that table go in this block.
|
||||
|
||||
db.commit()
|
||||
except Exception as e:
|
||||
db.rollback()
|
||||
@@ -372,15 +490,17 @@ def _sync_default_user_roles(db):
|
||||
@app.on_event("startup")
|
||||
def startup():
|
||||
from app.core.config import Base, engine, SessionLocal
|
||||
from app.models import models, webhook, apikey, activity, milestone, notification, worklog, monitor, role_permission, task, support, meeting, proposal, propose, essential, agent, calendar, minimum_workload
|
||||
from app.models import models, webhook, apikey, activity, milestone, notification, worklog, monitor, role_permission, task, support, meeting, proposal, propose, essential, agent, calendar, minimum_workload, schedule_type, schedule_type_special_slot, oidc_settings, knowledge
|
||||
Base.metadata.create_all(bind=engine)
|
||||
_migrate_schema()
|
||||
|
||||
# Initialize from AbstractWizard (admin user, default project, etc.)
|
||||
from app.init_wizard import run_init
|
||||
# Idempotent startup seed: permissions, default roles, built-in
|
||||
# accounts (acc-mgr, deleted-user). The admin user + OIDC config are
|
||||
# NOT created here — they're operator-driven via hf-cli.
|
||||
from app.init_bootstrap import run_bootstrap
|
||||
db = SessionLocal()
|
||||
try:
|
||||
run_init(db)
|
||||
run_bootstrap(db)
|
||||
_sync_default_user_roles(db)
|
||||
finally:
|
||||
db.close()
|
||||
|
||||
@@ -131,6 +131,15 @@ class Agent(Base):
|
||||
comment="rate_limit | billing — why the agent is exhausted",
|
||||
)
|
||||
|
||||
# -- schedule type ------------------------------------------------------
|
||||
|
||||
schedule_type_id = Column(
|
||||
Integer,
|
||||
ForeignKey("schedule_types.id"),
|
||||
nullable=True,
|
||||
comment="FK to schedule_types — defines work/entertainment periods",
|
||||
)
|
||||
|
||||
# -- timestamps ---------------------------------------------------------
|
||||
|
||||
created_at = Column(DateTime(timezone=True), server_default=func.now())
|
||||
@@ -138,3 +147,4 @@ class Agent(Base):
|
||||
# -- relationships ------------------------------------------------------
|
||||
|
||||
user = relationship("User", back_populates="agent", uselist=False)
|
||||
schedule_type = relationship("ScheduleType", lazy="joined")
|
||||
|
||||
@@ -165,6 +165,12 @@ class TimeSlot(Base):
|
||||
comment="Lifecycle status of this slot",
|
||||
)
|
||||
|
||||
wakeup_sent_at = Column(
|
||||
DateTime(timezone=True),
|
||||
nullable=True,
|
||||
comment="When Discord wakeup was sent for this slot",
|
||||
)
|
||||
|
||||
plan_id = Column(
|
||||
Integer,
|
||||
ForeignKey("schedule_plans.id"),
|
||||
@@ -172,11 +178,37 @@ class TimeSlot(Base):
|
||||
comment="Source plan if materialized from a SchedulePlan; set NULL on edit/cancel",
|
||||
)
|
||||
|
||||
# -----------------------------------------------------------------
|
||||
# Admin-locked slots are materialised from a ScheduleTypeSpecialSlot
|
||||
# template. The agent can complete / abort / pause / resume them but
|
||||
# cannot edit their time, type, duration, or cancel them outright —
|
||||
# the slot exists because admin decided every agent on the parent
|
||||
# schedule_type should run it. See `_apply_agent_slot_update` for
|
||||
# the enforcement.
|
||||
# -----------------------------------------------------------------
|
||||
is_admin_locked = Column(
|
||||
Boolean,
|
||||
nullable=False,
|
||||
server_default="0",
|
||||
comment="True for slots materialised from a schedule_type special slot template.",
|
||||
)
|
||||
|
||||
# Pointer back to the template that materialised this slot. NULL for
|
||||
# all user-created or plan-generated slots. Lets us cascade updates
|
||||
# and surface 'why is this on my calendar' to the agent.
|
||||
special_slot_id = Column(
|
||||
Integer,
|
||||
ForeignKey("schedule_type_special_slots.id", ondelete="SET NULL"),
|
||||
nullable=True,
|
||||
index=True,
|
||||
)
|
||||
|
||||
created_at = Column(DateTime(timezone=True), server_default=func.now())
|
||||
updated_at = Column(DateTime(timezone=True), onupdate=func.now())
|
||||
|
||||
# relationship ----------------------------------------------------------
|
||||
plan = relationship("SchedulePlan", back_populates="materialized_slots")
|
||||
special_slot = relationship("ScheduleTypeSpecialSlot")
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
101
app/models/knowledge.py
Normal file
101
app/models/knowledge.py
Normal file
@@ -0,0 +1,101 @@
|
||||
"""Knowledge Base models.
|
||||
|
||||
Mirrors the Project feature's shape (human-friendly *code*, creator FK,
|
||||
created/updated timestamps). Hierarchy is:
|
||||
|
||||
knowledge_base
|
||||
└─ knowledge_topic (unique per (topic, knowledge_base_id))
|
||||
├─ knowledge_fact (category_id NULL → fact lives on the topic)
|
||||
└─ knowledge_category (parent NULL → top-level category in topic)
|
||||
├─ knowledge_fact
|
||||
└─ knowledge_category (parent → nested)
|
||||
|
||||
`project_knowledge_base` is the M2M link between projects and knowledge bases.
|
||||
|
||||
Relationships are intentionally kept minimal (no ORM cascade on the
|
||||
self-referential category tree); deletion ordering is handled explicitly in
|
||||
the router to stay clear of FK-ordering surprises under MySQL.
|
||||
"""
|
||||
from sqlalchemy import Column, Integer, String, Text, DateTime, ForeignKey, UniqueConstraint
|
||||
from sqlalchemy.orm import relationship
|
||||
from sqlalchemy.sql import func
|
||||
|
||||
from app.core.config import Base
|
||||
|
||||
|
||||
class KnowledgeBase(Base):
|
||||
__tablename__ = "knowledge_bases"
|
||||
|
||||
id = Column(Integer, primary_key=True, index=True)
|
||||
knowledge_base_code = Column(String(16), unique=True, index=True, nullable=True)
|
||||
title = Column(String(200), nullable=False)
|
||||
description = Column(Text, nullable=True)
|
||||
created_by = Column(Integer, ForeignKey("users.id"), nullable=False)
|
||||
created_at = Column(DateTime(timezone=True), server_default=func.now())
|
||||
last_updated_at = Column(DateTime(timezone=True), server_default=func.now(), onupdate=func.now())
|
||||
|
||||
creator = relationship("User", foreign_keys=[created_by])
|
||||
|
||||
|
||||
class KnowledgeTopic(Base):
|
||||
__tablename__ = "knowledge_topics"
|
||||
__table_args__ = (
|
||||
UniqueConstraint("topic", "knowledge_base_id", name="uq_knowledge_topic_kb"),
|
||||
)
|
||||
|
||||
id = Column(Integer, primary_key=True, index=True)
|
||||
topic = Column(String(200), nullable=False)
|
||||
knowledge_base_id = Column(Integer, ForeignKey("knowledge_bases.id"), nullable=False, index=True)
|
||||
description = Column(Text, nullable=True)
|
||||
created_by = Column(Integer, ForeignKey("users.id"), nullable=False)
|
||||
created_at = Column(DateTime(timezone=True), server_default=func.now())
|
||||
last_updated_at = Column(DateTime(timezone=True), server_default=func.now(), onupdate=func.now())
|
||||
|
||||
creator = relationship("User", foreign_keys=[created_by])
|
||||
|
||||
|
||||
class KnowledgeCategory(Base):
|
||||
__tablename__ = "knowledge_categories"
|
||||
__table_args__ = (
|
||||
# NOTE: MySQL treats NULLs as distinct in a UNIQUE index, so this only
|
||||
# enforces uniqueness for non-NULL `parent`. Top-level categories
|
||||
# (parent IS NULL) are de-duped in the router (application-level check).
|
||||
UniqueConstraint("topic_id", "parent", "name", name="uq_knowledge_category_triple"),
|
||||
)
|
||||
|
||||
id = Column(Integer, primary_key=True, index=True)
|
||||
name = Column(String(200), nullable=False)
|
||||
parent = Column(Integer, ForeignKey("knowledge_categories.id"), nullable=True, index=True)
|
||||
topic_id = Column(Integer, ForeignKey("knowledge_topics.id"), nullable=False, index=True)
|
||||
description = Column(Text, nullable=True)
|
||||
created_by = Column(Integer, ForeignKey("users.id"), nullable=True)
|
||||
last_updated_by = Column(Integer, ForeignKey("users.id"), nullable=True)
|
||||
|
||||
|
||||
class KnowledgeFact(Base):
|
||||
__tablename__ = "knowledge_facts"
|
||||
|
||||
id = Column(Integer, primary_key=True, index=True)
|
||||
category_id = Column(Integer, ForeignKey("knowledge_categories.id"), nullable=True, index=True)
|
||||
topic_id = Column(Integer, ForeignKey("knowledge_topics.id"), nullable=False, index=True)
|
||||
fact = Column(Text, nullable=False)
|
||||
last_updated_at = Column(DateTime(timezone=True), server_default=func.now(), onupdate=func.now())
|
||||
|
||||
|
||||
class ProjectKnowledgeBase(Base):
|
||||
__tablename__ = "project_knowledge_bases"
|
||||
__table_args__ = (
|
||||
UniqueConstraint("project_id", "knowledge_base_id", name="uq_project_knowledge_base"),
|
||||
)
|
||||
|
||||
id = Column(Integer, primary_key=True, index=True)
|
||||
project_id = Column(Integer, ForeignKey("projects.id"), nullable=False, index=True)
|
||||
knowledge_base_id = Column(Integer, ForeignKey("knowledge_bases.id"), nullable=False, index=True)
|
||||
|
||||
|
||||
class KnowledgeBaseCodeCounter(Base):
|
||||
__tablename__ = "knowledge_base_code_counters"
|
||||
|
||||
id = Column(Integer, primary_key=True, index=True)
|
||||
prefix = Column(String(16), unique=True, index=True, nullable=False)
|
||||
next_value = Column(Integer, default=0)
|
||||
@@ -1,4 +1,4 @@
|
||||
from sqlalchemy import Column, Integer, String, Text, DateTime, ForeignKey, Enum, Boolean, JSON, Time
|
||||
from sqlalchemy import Column, Integer, String, Text, DateTime, ForeignKey, Enum, Boolean, JSON, Time, UniqueConstraint
|
||||
from sqlalchemy.orm import relationship
|
||||
from sqlalchemy.sql import func
|
||||
from app.core.config import Base
|
||||
@@ -66,12 +66,20 @@ class Project(Base):
|
||||
|
||||
class User(Base):
|
||||
__tablename__ = "users"
|
||||
__table_args__ = (
|
||||
UniqueConstraint("oidc_issuer", "oidc_subject", name="uq_users_oidc_identity"),
|
||||
)
|
||||
|
||||
id = Column(Integer, primary_key=True, index=True)
|
||||
username = Column(String(50), unique=True, nullable=False)
|
||||
email = Column(String(100), unique=True, nullable=False)
|
||||
hashed_password = Column(String(255), nullable=True)
|
||||
full_name = Column(String(100), nullable=True)
|
||||
discord_user_id = Column(String(32), nullable=True)
|
||||
# OIDC binding: an hf user is linked to at most one external OIDC identity
|
||||
# (issuer + subject). Unique together so one IdP identity maps to one user.
|
||||
oidc_issuer = Column(String(255), nullable=True)
|
||||
oidc_subject = Column(String(255), nullable=True)
|
||||
is_active = Column(Boolean, default=True)
|
||||
is_admin = Column(Boolean, default=False)
|
||||
role_id = Column(Integer, ForeignKey("roles.id"), nullable=True)
|
||||
|
||||
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())
|
||||
83
app/models/schedule_type.py
Normal file
83
app/models/schedule_type.py
Normal file
@@ -0,0 +1,83 @@
|
||||
"""ScheduleType model — defines work/entertainment/maintenance time periods.
|
||||
|
||||
Each ScheduleType defines the daily work, entertainment, and maintenance
|
||||
windows for agents who reference this type. All bounds are stored as
|
||||
**minutes-since-UTC-midnight** (0-1439 inclusive) so half-hour and other
|
||||
sub-hour boundaries are exact.
|
||||
|
||||
Maintenance window length is variable (1-180 minutes) and admin-owned;
|
||||
agent slots cannot intersect it (see `app/api/routers/calendar.py`).
|
||||
|
||||
Historical note: pre-PR #21 the columns held *hours* (0-23) and the
|
||||
maintenance window was hard-fixed at exactly 1 hour. The additive
|
||||
migration in `_migrate_schema()` multiplies legacy values by 60 so
|
||||
existing rows convert transparently.
|
||||
"""
|
||||
|
||||
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')",
|
||||
)
|
||||
|
||||
# Minutes since UTC midnight, 0-1439 inclusive.
|
||||
work_from = Column(Integer, nullable=False, comment="Work period start (minutes since UTC midnight)")
|
||||
work_to = Column(Integer, nullable=False, comment="Work period end (minutes since UTC midnight)")
|
||||
|
||||
entertainment_from = Column(Integer, nullable=False, comment="Entertainment start (minutes since UTC midnight)")
|
||||
entertainment_to = Column(Integer, nullable=False, comment="Entertainment end (minutes since UTC midnight)")
|
||||
|
||||
# Maintenance window — admin-owned, variable length (1-180 min).
|
||||
# Default 8:00–9:00 UTC = 480–540 minutes for existing rows.
|
||||
maintenance_from = Column(
|
||||
Integer,
|
||||
nullable=False,
|
||||
server_default="480",
|
||||
comment="Maintenance start (minutes since UTC midnight, default 480 = 8:00 UTC).",
|
||||
)
|
||||
maintenance_to = Column(
|
||||
Integer,
|
||||
nullable=False,
|
||||
server_default="540",
|
||||
comment="Maintenance end (minutes since UTC midnight, default 540 = 9:00 UTC). Duration ((to-from) mod 1440) must be in [1, 180].",
|
||||
)
|
||||
|
||||
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",
|
||||
)
|
||||
|
||||
# ---------------------------------------------------------------
|
||||
# Convenience methods used by the API layer + materialiser.
|
||||
# ---------------------------------------------------------------
|
||||
|
||||
def compute_maintenance_duration(self) -> int:
|
||||
"""Maintenance window length in minutes (handles 23→0 wrap)."""
|
||||
return (self.maintenance_to - self.maintenance_from) % 1440 or 1440
|
||||
|
||||
def window_contains(self, start_min: int, end_min: int, win_from: int, win_to: int) -> bool:
|
||||
"""True if [start_min, end_min) intersects [win_from, win_to) (handles wrap)."""
|
||||
# Normalise into [0, 1440) — same logic as the helper in calendar.py.
|
||||
if win_to > win_from:
|
||||
return start_min < win_to and end_min > win_from
|
||||
# wrap window crosses midnight: [win_from..1440) ∪ [0..win_to)
|
||||
return start_min < win_to or end_min > win_from
|
||||
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")
|
||||
@@ -144,6 +144,8 @@ class TimeSlotResponse(BaseModel):
|
||||
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
|
||||
|
||||
@@ -226,6 +228,8 @@ class CalendarSlotItem(BaseModel):
|
||||
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
|
||||
|
||||
@@ -407,3 +411,30 @@ class DateListResponse(BaseModel):
|
||||
default_factory=list,
|
||||
description="Sorted list of future dates with materialized slots",
|
||||
)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Agent heartbeat / agent-driven slot updates
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class AgentHeartbeatResponse(BaseModel):
|
||||
"""Slots that are due for a specific agent plus its current runtime status."""
|
||||
slots: list[CalendarSlotItem] = Field(default_factory=list)
|
||||
agent_status: str
|
||||
message: Optional[str] = None
|
||||
|
||||
|
||||
class SlotAgentUpdate(BaseModel):
|
||||
"""Plugin-driven slot status update payload."""
|
||||
status: SlotStatusEnum
|
||||
started_at: Optional[dt_time] = None
|
||||
actual_duration: Optional[int] = Field(None, ge=0, le=65535)
|
||||
|
||||
|
||||
class AgentStatusUpdateRequest(BaseModel):
|
||||
"""Plugin-driven agent status report."""
|
||||
agent_id: str
|
||||
claw_identifier: str
|
||||
status: str
|
||||
recovery_at: Optional[dt_datetime] = None
|
||||
exhaust_reason: Optional[str] = None
|
||||
|
||||
166
app/schemas/knowledge.py
Normal file
166
app/schemas/knowledge.py
Normal file
@@ -0,0 +1,166 @@
|
||||
"""Pydantic schemas for the Knowledge Base feature."""
|
||||
from pydantic import BaseModel
|
||||
from typing import Optional, List
|
||||
from datetime import datetime
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------
|
||||
# Knowledge Base
|
||||
# --------------------------------------------------------------------------
|
||||
class KnowledgeBaseBase(BaseModel):
|
||||
title: str
|
||||
description: Optional[str] = None
|
||||
|
||||
|
||||
class KnowledgeBaseCreate(KnowledgeBaseBase):
|
||||
pass
|
||||
|
||||
|
||||
class KnowledgeBaseUpdate(BaseModel):
|
||||
title: Optional[str] = None
|
||||
description: Optional[str] = None
|
||||
|
||||
|
||||
class KnowledgeBaseResponse(BaseModel):
|
||||
id: int
|
||||
knowledge_base_code: Optional[str] = None
|
||||
title: str
|
||||
description: Optional[str] = None
|
||||
created_by: int
|
||||
created_at: Optional[datetime] = None
|
||||
last_updated_at: Optional[datetime] = None
|
||||
|
||||
class Config:
|
||||
from_attributes = True
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------
|
||||
# Topic
|
||||
# --------------------------------------------------------------------------
|
||||
class KnowledgeTopicBase(BaseModel):
|
||||
topic: str
|
||||
description: Optional[str] = None
|
||||
|
||||
|
||||
class KnowledgeTopicCreate(KnowledgeTopicBase):
|
||||
pass
|
||||
|
||||
|
||||
class KnowledgeTopicUpdate(BaseModel):
|
||||
topic: Optional[str] = None
|
||||
description: Optional[str] = None
|
||||
|
||||
|
||||
class KnowledgeTopicResponse(BaseModel):
|
||||
id: int
|
||||
topic: str
|
||||
knowledge_base_id: int
|
||||
description: Optional[str] = None
|
||||
created_by: int
|
||||
created_at: Optional[datetime] = None
|
||||
last_updated_at: Optional[datetime] = None
|
||||
|
||||
class Config:
|
||||
from_attributes = True
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------
|
||||
# Category
|
||||
# --------------------------------------------------------------------------
|
||||
class KnowledgeCategoryBase(BaseModel):
|
||||
name: str
|
||||
description: Optional[str] = None
|
||||
|
||||
|
||||
class KnowledgeCategoryCreate(KnowledgeCategoryBase):
|
||||
topic_id: int
|
||||
parent: Optional[int] = None
|
||||
|
||||
|
||||
class KnowledgeCategoryUpdate(BaseModel):
|
||||
name: Optional[str] = None
|
||||
description: Optional[str] = None
|
||||
parent: Optional[int] = None
|
||||
|
||||
|
||||
class KnowledgeCategoryResponse(BaseModel):
|
||||
id: int
|
||||
name: str
|
||||
parent: Optional[int] = None
|
||||
topic_id: int
|
||||
description: Optional[str] = None
|
||||
created_by: Optional[int] = None
|
||||
last_updated_by: Optional[int] = None
|
||||
|
||||
class Config:
|
||||
from_attributes = True
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------
|
||||
# Fact
|
||||
# --------------------------------------------------------------------------
|
||||
class KnowledgeFactBase(BaseModel):
|
||||
fact: str
|
||||
|
||||
|
||||
class KnowledgeFactCreate(KnowledgeFactBase):
|
||||
topic_id: int
|
||||
category_id: Optional[int] = None
|
||||
|
||||
|
||||
class KnowledgeFactUpdate(BaseModel):
|
||||
fact: Optional[str] = None
|
||||
category_id: Optional[int] = None
|
||||
|
||||
|
||||
class KnowledgeFactResponse(BaseModel):
|
||||
id: int
|
||||
category_id: Optional[int] = None
|
||||
topic_id: int
|
||||
fact: str
|
||||
last_updated_at: Optional[datetime] = None
|
||||
|
||||
class Config:
|
||||
from_attributes = True
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------
|
||||
# Project <-> KnowledgeBase link
|
||||
# --------------------------------------------------------------------------
|
||||
class ProjectKnowledgeBaseLink(BaseModel):
|
||||
# Accept either a numeric id or a knowledge_base_code (mirrors how
|
||||
# projects are referenced elsewhere).
|
||||
knowledge_base: str
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------
|
||||
# Nested tree (read-only aggregate)
|
||||
# --------------------------------------------------------------------------
|
||||
class CategoryTreeNode(BaseModel):
|
||||
id: int
|
||||
name: str
|
||||
parent: Optional[int] = None
|
||||
topic_id: int
|
||||
description: Optional[str] = None
|
||||
categories: List["CategoryTreeNode"] = []
|
||||
facts: List[KnowledgeFactResponse] = []
|
||||
|
||||
|
||||
class TopicTreeNode(BaseModel):
|
||||
id: int
|
||||
topic: str
|
||||
knowledge_base_id: int
|
||||
description: Optional[str] = None
|
||||
categories: List[CategoryTreeNode] = []
|
||||
facts: List[KnowledgeFactResponse] = []
|
||||
|
||||
|
||||
class KnowledgeBaseTree(BaseModel):
|
||||
id: int
|
||||
knowledge_base_code: Optional[str] = None
|
||||
title: str
|
||||
description: Optional[str] = None
|
||||
topics: List[TopicTreeNode] = []
|
||||
|
||||
|
||||
CategoryTreeNode.model_rebuild()
|
||||
83
app/schemas/schedule_type.py
Normal file
83
app/schemas/schedule_type.py
Normal file
@@ -0,0 +1,83 @@
|
||||
"""Schemas for ScheduleType CRUD.
|
||||
|
||||
All `*_from` / `*_to` values are **minutes since UTC midnight** (0-1439).
|
||||
A maintenance window of variable length is allowed (1-180 minutes,
|
||||
handles 23→0 wrap).
|
||||
"""
|
||||
|
||||
from pydantic import BaseModel, Field, model_validator
|
||||
from typing import Optional
|
||||
|
||||
|
||||
_MAX_MIN = 1440 # 24 * 60 — exclusive upper bound
|
||||
|
||||
|
||||
def _maintenance_duration(maint_from: int, maint_to: int) -> int:
|
||||
"""Maintenance window length in minutes; treats from==to as 24h (invalid)."""
|
||||
return (maint_to - maint_from) % _MAX_MIN or _MAX_MIN
|
||||
|
||||
|
||||
def _validate_maintenance_window(maint_from: int, maint_to: int) -> None:
|
||||
dur = _maintenance_duration(maint_from, maint_to)
|
||||
if dur < 1 or dur > 180:
|
||||
raise ValueError(
|
||||
f"maintenance window duration must be in [1, 180] minutes; "
|
||||
f"got {dur} (from={maint_from}, to={maint_to})"
|
||||
)
|
||||
|
||||
|
||||
def _validate_minute_field(name: str, value: int) -> None:
|
||||
if value < 0 or value >= _MAX_MIN:
|
||||
raise ValueError(f"{name} must be in [0, {_MAX_MIN}); got {value}")
|
||||
|
||||
|
||||
class ScheduleTypeCreate(BaseModel):
|
||||
name: str = Field(..., min_length=1, max_length=64)
|
||||
work_from: int = Field(..., ge=0, lt=_MAX_MIN, description="Work start (minutes since UTC midnight, 0-1439)")
|
||||
work_to: int = Field(..., ge=0, lt=_MAX_MIN)
|
||||
entertainment_from: int = Field(..., ge=0, lt=_MAX_MIN)
|
||||
entertainment_to: int = Field(..., ge=0, lt=_MAX_MIN)
|
||||
maintenance_from: int = Field(480, ge=0, lt=_MAX_MIN, description="Maintenance start (default 480 = 8:00 UTC)")
|
||||
maintenance_to: int = Field(540, ge=0, lt=_MAX_MIN, description="Maintenance end; (to-from) mod 1440 in [1,180]")
|
||||
|
||||
@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, lt=_MAX_MIN)
|
||||
work_to: Optional[int] = Field(None, ge=0, lt=_MAX_MIN)
|
||||
entertainment_from: Optional[int] = Field(None, ge=0, lt=_MAX_MIN)
|
||||
entertainment_to: Optional[int] = Field(None, ge=0, lt=_MAX_MIN)
|
||||
maintenance_from: Optional[int] = Field(None, ge=0, lt=_MAX_MIN)
|
||||
maintenance_to: Optional[int] = Field(None, ge=0, lt=_MAX_MIN)
|
||||
|
||||
@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
|
||||
maintenance_duration_minutes: Optional[int] = None # derived; populated by router
|
||||
|
||||
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=179, description="Minute offset (0-179) inside the schedule_type maintenance window")
|
||||
estimated_duration: int = Field(15, ge=1, le=180, description="Duration in minutes; must fit inside the maintenance window (1-180min)")
|
||||
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=179)
|
||||
estimated_duration: Optional[int] = Field(None, ge=1, le=180)
|
||||
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 datetime import datetime, time
|
||||
from enum import Enum
|
||||
@@ -171,6 +171,7 @@ class UserBase(BaseModel):
|
||||
class UserCreate(UserBase):
|
||||
password: Optional[str] = None
|
||||
role_id: Optional[int] = None
|
||||
discord_user_id: Optional[str] = None
|
||||
# Agent binding (both must be provided or both omitted)
|
||||
agent_id: Optional[str] = None
|
||||
claw_identifier: Optional[str] = None
|
||||
@@ -182,6 +183,20 @@ class UserUpdate(BaseModel):
|
||||
password: Optional[str] = None
|
||||
role_id: Optional[int] = None
|
||||
is_active: Optional[bool] = None
|
||||
discord_user_id: Optional[str] = None
|
||||
|
||||
|
||||
class UserBindAgentRequest(BaseModel):
|
||||
"""Request body for PATCH /users/{identifier}/bind-agent.
|
||||
|
||||
Binds an existing user to (agent_id, claw_identifier) by inserting a
|
||||
row in the `agents` table. Both fields required (mirrors the
|
||||
create-time invariant in UserCreate). Idempotent: re-binding the same
|
||||
user to the same (agent_id, claw_identifier) returns the existing
|
||||
Agent row instead of 409.
|
||||
"""
|
||||
agent_id: str = Field(..., min_length=1, max_length=128)
|
||||
claw_identifier: str = Field(..., min_length=1, max_length=128)
|
||||
|
||||
|
||||
class UserResponse(UserBase):
|
||||
@@ -191,8 +206,11 @@ class UserResponse(UserBase):
|
||||
role_id: Optional[int] = None
|
||||
role_name: Optional[str] = None
|
||||
agent_id: Optional[str] = None
|
||||
discord_user_id: Optional[str] = None
|
||||
oidc_issuer: Optional[str] = None
|
||||
oidc_subject: Optional[str] = None
|
||||
created_at: datetime
|
||||
|
||||
|
||||
class Config:
|
||||
from_attributes = True
|
||||
|
||||
|
||||
80
app/services/discord_wakeup.py
Normal file
80
app/services/discord_wakeup.py
Normal file
@@ -0,0 +1,80 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
from datetime import datetime, timezone
|
||||
from typing import Any
|
||||
|
||||
import requests
|
||||
from fastapi import HTTPException
|
||||
|
||||
DISCORD_API_BASE = "https://discord.com/api/v10"
|
||||
WAKEUP_CATEGORY_NAME = "HarborForge Wakeup"
|
||||
|
||||
|
||||
def _discord_config() -> dict[str, str | None]:
|
||||
"""Discord wakeup is configured via env vars (previously read from the
|
||||
AbstractWizard config file). Returns guild_id+bot_token or Nones."""
|
||||
return {
|
||||
"guild_id": os.getenv("HARBORFORGE_DISCORD_GUILD_ID") or None,
|
||||
"bot_token": os.getenv("HARBORFORGE_DISCORD_BOT_TOKEN") or None,
|
||||
}
|
||||
|
||||
|
||||
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 = _discord_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"),
|
||||
}
|
||||
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:
|
||||
# schedule_type.maintenance_from is minutes-since-UTC-midnight; the
|
||||
# template's minute_in_window is an offset inside that window. Combined
|
||||
# offset must fit in [0, 1440) and produce a wall-clock time_type.
|
||||
total_min = (schedule_type.maintenance_from + template.minute_in_window) % 1440
|
||||
scheduled_at = time_type(hour=total_min // 60, minute=total_min % 60, 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 hashlib
|
||||
import logging
|
||||
import socket
|
||||
import ipaddress
|
||||
from urllib.parse import urlparse
|
||||
from sqlalchemy.orm import Session
|
||||
from app.models.webhook import Webhook, WebhookLog
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def _validate_webhook_url(url: str) -> None:
|
||||
"""Raise ValueError if the URL would target a non-public address (SSRF guard)."""
|
||||
parsed = urlparse(url)
|
||||
if parsed.scheme not in ("http", "https"):
|
||||
raise ValueError(f"unsupported scheme: {parsed.scheme!r}")
|
||||
host = parsed.hostname
|
||||
if not host:
|
||||
raise ValueError("missing host")
|
||||
# Resolve every address the host maps to and reject non-global ranges.
|
||||
try:
|
||||
infos = socket.getaddrinfo(host, parsed.port or (443 if parsed.scheme == "https" else 80))
|
||||
except socket.gaierror as e:
|
||||
raise ValueError(f"DNS resolution failed: {e}")
|
||||
for info in infos:
|
||||
ip = ipaddress.ip_address(info[4][0])
|
||||
if (
|
||||
ip.is_private
|
||||
or ip.is_loopback
|
||||
or ip.is_link_local
|
||||
or ip.is_multicast
|
||||
or ip.is_reserved
|
||||
or ip.is_unspecified
|
||||
):
|
||||
raise ValueError(f"host resolves to non-public address {ip}")
|
||||
|
||||
|
||||
def fire_webhooks_sync(event: str, payload: dict, project_id: int, db: Session):
|
||||
"""Find matching webhooks and send payloads (sync version)."""
|
||||
import httpx
|
||||
@@ -35,6 +64,8 @@ def fire_webhooks_sync(event: str, payload: dict, project_id: int, db: Session):
|
||||
payload=payload_json,
|
||||
)
|
||||
try:
|
||||
_validate_webhook_url(wh.url)
|
||||
|
||||
headers = {"Content-Type": "application/json"}
|
||||
if wh.secret:
|
||||
sig = hmac.new(
|
||||
@@ -42,7 +73,7 @@ def fire_webhooks_sync(event: str, payload: dict, project_id: int, db: Session):
|
||||
).hexdigest()
|
||||
headers["X-Webhook-Signature"] = sig
|
||||
|
||||
with httpx.Client(timeout=10.0) as client:
|
||||
with httpx.Client(timeout=10.0, follow_redirects=False) as client:
|
||||
resp = client.post(wh.url, content=payload_json, headers=headers)
|
||||
log.response_status = resp.status_code
|
||||
log.response_body = resp.text[:1000]
|
||||
|
||||
99
docs/oidc-test-plan.md
Normal file
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)建议人工在浏览器复核。
|
||||
@@ -1,19 +1,5 @@
|
||||
#!/bin/sh
|
||||
# Wait for wizard config before starting uvicorn
|
||||
CONFIG_DIR="${CONFIG_DIR:-/config}"
|
||||
CONFIG_FILE="${CONFIG_FILE:-harborforge.json}"
|
||||
CONFIG_PATH="$CONFIG_DIR/$CONFIG_FILE"
|
||||
|
||||
echo "HarborForge Backend - waiting for config..."
|
||||
echo " Config path: $CONFIG_PATH"
|
||||
|
||||
while true; do
|
||||
if [ -f "$CONFIG_PATH" ]; then
|
||||
echo " Config found! Starting backend..."
|
||||
break
|
||||
fi
|
||||
echo " Config not ready, waiting 5s... (run setup wizard via SSH tunnel)"
|
||||
sleep 5
|
||||
done
|
||||
|
||||
# HarborForge backend entrypoint. All config comes from env vars (DATABASE_URL,
|
||||
# SECRET_KEY, HARBORFORGE_OIDC_ONLY, etc.). First-deploy admin user + OIDC
|
||||
# issuer config are operator-driven via `docker exec hf-backend hf-cli ...`.
|
||||
exec uvicorn app.main:app --host 0.0.0.0 --port 8000
|
||||
|
||||
@@ -12,3 +12,5 @@ alembic==1.13.1
|
||||
python-dotenv==1.0.0
|
||||
httpx==0.27.0
|
||||
requests==2.31.0
|
||||
authlib==1.3.2
|
||||
itsdangerous==2.2.0
|
||||
|
||||
@@ -15,7 +15,7 @@ from fastapi.testclient import TestClient
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Patch the production engine/SessionLocal BEFORE importing app so that
|
||||
# startup events (Base.metadata.create_all, init_wizard, etc.) use the
|
||||
# startup events (Base.metadata.create_all, init_bootstrap, etc.) use the
|
||||
# in-memory SQLite database instead of trying to connect to MySQL.
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
147
tests/test_knowledge_base.py
Normal file
147
tests/test_knowledge_base.py
Normal file
@@ -0,0 +1,147 @@
|
||||
"""Knowledge Base API tests — CRUD, hierarchy, uniqueness, tree, links, RBAC."""
|
||||
from tests.conftest import auth_header
|
||||
|
||||
|
||||
def _create_kb(client, token, title="Infra Runbook", description="ops notes"):
|
||||
r = client.post(
|
||||
"/knowledge-bases",
|
||||
json={"title": title, "description": description},
|
||||
headers=auth_header(token),
|
||||
)
|
||||
assert r.status_code == 201, r.text
|
||||
return r.json()
|
||||
|
||||
|
||||
class TestKnowledgeBaseCRUD:
|
||||
def test_create_generates_code(self, client, seed):
|
||||
kb = _create_kb(client, seed["admin_token"], title="Infra Runbook")
|
||||
assert kb["title"] == "Infra Runbook"
|
||||
assert kb["knowledge_base_code"] # auto-generated, non-empty
|
||||
assert kb["created_by"] == seed["admin_user"].id
|
||||
|
||||
def test_create_requires_permission(self, client, seed):
|
||||
# dev role has no knowledge-base.create
|
||||
r = client.post(
|
||||
"/knowledge-bases",
|
||||
json={"title": "Nope"},
|
||||
headers=auth_header(seed["dev_token"]),
|
||||
)
|
||||
assert r.status_code == 403
|
||||
|
||||
def test_get_by_id_and_code(self, client, seed):
|
||||
kb = _create_kb(client, seed["admin_token"])
|
||||
by_id = client.get(f"/knowledge-bases/{kb['id']}", headers=auth_header(seed["admin_token"]))
|
||||
by_code = client.get(f"/knowledge-bases/{kb['knowledge_base_code']}", headers=auth_header(seed["admin_token"]))
|
||||
assert by_id.status_code == 200 and by_code.status_code == 200
|
||||
assert by_id.json()["id"] == by_code.json()["id"] == kb["id"]
|
||||
|
||||
def test_update_and_list(self, client, seed):
|
||||
kb = _create_kb(client, seed["admin_token"])
|
||||
r = client.patch(
|
||||
f"/knowledge-bases/{kb['id']}",
|
||||
json={"description": "updated"},
|
||||
headers=auth_header(seed["admin_token"]),
|
||||
)
|
||||
assert r.status_code == 200 and r.json()["description"] == "updated"
|
||||
lst = client.get("/knowledge-bases", headers=auth_header(seed["admin_token"]))
|
||||
assert lst.status_code == 200 and any(k["id"] == kb["id"] for k in lst.json())
|
||||
|
||||
def test_delete_cascades(self, client, seed):
|
||||
token = seed["admin_token"]
|
||||
kb = _create_kb(client, token)
|
||||
topic = client.post(f"/knowledge-bases/{kb['id']}/topics", json={"topic": "Net"}, headers=auth_header(token)).json()
|
||||
client.post("/knowledge-facts", json={"topic_id": topic["id"], "fact": "x"}, headers=auth_header(token))
|
||||
r = client.delete(f"/knowledge-bases/{kb['id']}", headers=auth_header(token))
|
||||
assert r.status_code == 204
|
||||
assert client.get(f"/knowledge-bases/{kb['id']}", headers=auth_header(token)).status_code == 404
|
||||
assert client.get(f"/knowledge-topics/{topic['id']}", headers=auth_header(token)).status_code == 404
|
||||
|
||||
|
||||
class TestHierarchy:
|
||||
def test_topic_unique_per_kb(self, client, seed):
|
||||
token = seed["admin_token"]
|
||||
kb = _create_kb(client, token)
|
||||
r1 = client.post(f"/knowledge-bases/{kb['id']}/topics", json={"topic": "Routing"}, headers=auth_header(token))
|
||||
r2 = client.post(f"/knowledge-bases/{kb['id']}/topics", json={"topic": "Routing"}, headers=auth_header(token))
|
||||
assert r1.status_code == 201 and r2.status_code == 400
|
||||
|
||||
def test_category_triple_unique_and_nesting(self, client, seed):
|
||||
token = seed["admin_token"]
|
||||
kb = _create_kb(client, token)
|
||||
topic = client.post(f"/knowledge-bases/{kb['id']}/topics", json={"topic": "T"}, headers=auth_header(token)).json()
|
||||
# top-level category
|
||||
c1 = client.post("/knowledge-categories", json={"topic_id": topic["id"], "name": "DNS"}, headers=auth_header(token))
|
||||
assert c1.status_code == 201
|
||||
# duplicate top-level (parent NULL) rejected at app level
|
||||
dup = client.post("/knowledge-categories", json={"topic_id": topic["id"], "name": "DNS"}, headers=auth_header(token))
|
||||
assert dup.status_code == 400
|
||||
# nested category with same name under different parent is allowed
|
||||
child = client.post(
|
||||
"/knowledge-categories",
|
||||
json={"topic_id": topic["id"], "name": "DNS", "parent": c1.json()["id"]},
|
||||
headers=auth_header(token),
|
||||
)
|
||||
assert child.status_code == 201
|
||||
|
||||
def test_no_cycle_on_reparent(self, client, seed):
|
||||
token = seed["admin_token"]
|
||||
kb = _create_kb(client, token)
|
||||
topic = client.post(f"/knowledge-bases/{kb['id']}/topics", json={"topic": "T"}, headers=auth_header(token)).json()
|
||||
a = client.post("/knowledge-categories", json={"topic_id": topic["id"], "name": "A"}, headers=auth_header(token)).json()
|
||||
b = client.post("/knowledge-categories", json={"topic_id": topic["id"], "name": "B", "parent": a["id"]}, headers=auth_header(token)).json()
|
||||
# try to move A under its descendant B -> rejected
|
||||
r = client.patch(f"/knowledge-categories/{a['id']}", json={"parent": b["id"]}, headers=auth_header(token))
|
||||
assert r.status_code == 400
|
||||
|
||||
def test_tree_shape(self, client, seed):
|
||||
token = seed["admin_token"]
|
||||
kb = _create_kb(client, token)
|
||||
topic = client.post(f"/knowledge-bases/{kb['id']}/topics", json={"topic": "T"}, headers=auth_header(token)).json()
|
||||
cat = client.post("/knowledge-categories", json={"topic_id": topic["id"], "name": "C"}, headers=auth_header(token)).json()
|
||||
# fact directly on topic
|
||||
client.post("/knowledge-facts", json={"topic_id": topic["id"], "fact": "topic-fact"}, headers=auth_header(token))
|
||||
# fact under category
|
||||
client.post("/knowledge-facts", json={"topic_id": topic["id"], "category_id": cat["id"], "fact": "cat-fact"}, headers=auth_header(token))
|
||||
|
||||
tree = client.get(f"/knowledge-bases/{kb['id']}/tree", headers=auth_header(token)).json()
|
||||
assert len(tree["topics"]) == 1
|
||||
t = tree["topics"][0]
|
||||
assert [f["fact"] for f in t["facts"]] == ["topic-fact"]
|
||||
assert len(t["categories"]) == 1
|
||||
assert [f["fact"] for f in t["categories"][0]["facts"]] == ["cat-fact"]
|
||||
|
||||
|
||||
class TestProjectLinks:
|
||||
def test_link_unlink(self, client, seed):
|
||||
token = seed["admin_token"]
|
||||
kb = _create_kb(client, token)
|
||||
# link by code
|
||||
r = client.post(
|
||||
"/projects/TPRJ/knowledge-bases",
|
||||
json={"knowledge_base": kb["knowledge_base_code"]},
|
||||
headers=auth_header(token),
|
||||
)
|
||||
assert r.status_code == 201
|
||||
linked = client.get("/projects/TPRJ/knowledge-bases", headers=auth_header(token)).json()
|
||||
assert any(k["id"] == kb["id"] for k in linked)
|
||||
# filter list by project
|
||||
filtered = client.get(f"/knowledge-bases?project=TPRJ", headers=auth_header(token)).json()
|
||||
assert any(k["id"] == kb["id"] for k in filtered)
|
||||
# unlink
|
||||
r = client.delete(f"/projects/TPRJ/knowledge-bases/{kb['id']}", headers=auth_header(token))
|
||||
assert r.status_code == 204
|
||||
linked = client.get("/projects/TPRJ/knowledge-bases", headers=auth_header(token)).json()
|
||||
assert not any(k["id"] == kb["id"] for k in linked)
|
||||
|
||||
def test_link_is_idempotent(self, client, seed):
|
||||
token = seed["admin_token"]
|
||||
kb = _create_kb(client, token)
|
||||
for _ in range(2):
|
||||
r = client.post(
|
||||
"/projects/TPRJ/knowledge-bases",
|
||||
json={"knowledge_base": str(kb["id"])},
|
||||
headers=auth_header(token),
|
||||
)
|
||||
assert r.status_code == 201
|
||||
linked = client.get("/projects/TPRJ/knowledge-bases", headers=auth_header(token)).json()
|
||||
assert sum(1 for k in linked if k["id"] == kb["id"]) == 1
|
||||
Reference in New Issue
Block a user