Initial commit: site monorepo with API, web, and infra.
Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -0,0 +1,27 @@
|
|||||||
|
# Git / editor noise
|
||||||
|
.git
|
||||||
|
.gitignore
|
||||||
|
.cursor
|
||||||
|
.vscode
|
||||||
|
*.md
|
||||||
|
|
||||||
|
# Dependencies & build artifacts (installed inside the image)
|
||||||
|
**/node_modules
|
||||||
|
**/dist
|
||||||
|
**/coverage
|
||||||
|
**/.turbo
|
||||||
|
|
||||||
|
# Python API — not needed for the web image
|
||||||
|
apps/api
|
||||||
|
|
||||||
|
# Infra / CI helpers
|
||||||
|
infra
|
||||||
|
|
||||||
|
# Env & secrets (injected via compose or mounted at runtime)
|
||||||
|
**/.env
|
||||||
|
**/.env.*
|
||||||
|
!**/.env.example
|
||||||
|
|
||||||
|
# Test / e2e artifacts
|
||||||
|
**/playwright-report
|
||||||
|
**/test-results
|
||||||
@@ -0,0 +1,51 @@
|
|||||||
|
name: CI
|
||||||
|
|
||||||
|
on:
|
||||||
|
pull_request:
|
||||||
|
push:
|
||||||
|
branches: [main]
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
quality:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@v4
|
||||||
|
- uses: actions/setup-node@v4
|
||||||
|
with:
|
||||||
|
node-version: 22
|
||||||
|
- uses: pnpm/action-setup@v4
|
||||||
|
with:
|
||||||
|
version: 9
|
||||||
|
- uses: actions/setup-python@v5
|
||||||
|
with:
|
||||||
|
python-version: "3.12"
|
||||||
|
- name: Install frontend deps
|
||||||
|
run: pnpm install --frozen-lockfile=false
|
||||||
|
- name: Install backend deps
|
||||||
|
run: pip install -r apps/api/requirements-dev.txt
|
||||||
|
- name: Sync WESP UI parity
|
||||||
|
run: bash scripts/sync-wesp-ui.sh
|
||||||
|
- name: Lint
|
||||||
|
run: pnpm lint
|
||||||
|
- name: Types
|
||||||
|
run: pnpm typecheck
|
||||||
|
- name: Mypy
|
||||||
|
working-directory: apps/api
|
||||||
|
run: python -m mypy app
|
||||||
|
- name: Frontend tests with coverage
|
||||||
|
run: pnpm --filter web test:ci
|
||||||
|
- name: Backend tests with coverage
|
||||||
|
working-directory: apps/api
|
||||||
|
run: python -m pytest --cov=app --cov-fail-under=90
|
||||||
|
- name: Security scan (python deps)
|
||||||
|
run: pip-audit
|
||||||
|
- name: Bandit scan
|
||||||
|
run: bandit -q -r apps/api/app
|
||||||
|
- name: Security scan (node deps)
|
||||||
|
run: pnpm audit --audit-level high
|
||||||
|
- name: Gitleaks scan
|
||||||
|
run: docker run --rm -v "$PWD:/repo" zricethezav/gitleaks:latest detect --source=/repo --no-git -v
|
||||||
|
- name: Install Playwright browsers
|
||||||
|
run: pnpm --filter web exec playwright install chromium
|
||||||
|
- name: E2E smoke
|
||||||
|
run: pnpm --filter web e2e
|
||||||
+23
@@ -0,0 +1,23 @@
|
|||||||
|
node_modules/
|
||||||
|
.venv/
|
||||||
|
__pycache__/
|
||||||
|
.pytest_cache/
|
||||||
|
.mypy_cache/
|
||||||
|
coverage/
|
||||||
|
.coverage
|
||||||
|
dist/
|
||||||
|
playwright-report/
|
||||||
|
test-results/
|
||||||
|
.DS_Store
|
||||||
|
.env
|
||||||
|
*.pyc
|
||||||
|
apps/web/.env
|
||||||
|
apps/api/.env
|
||||||
|
apps/api/.e2e.sqlite
|
||||||
|
apps/api/.e2e-test.sqlite
|
||||||
|
apps/api/data/secrets/install.env
|
||||||
|
apps/api/data/secrets/install.meta.json
|
||||||
|
data/secrets/
|
||||||
|
apps/api/data/logs/
|
||||||
|
apps/api/data/compton_settings.json
|
||||||
|
apps/web/public/static/
|
||||||
@@ -0,0 +1,112 @@
|
|||||||
|
# Compton Platform
|
||||||
|
|
||||||
|
Если вы открыли этот файл — поздравляем: перед вами monorepo с React, FastAPI и амбициями выйти в production. Амбиции живут в [docs/release.md](docs/release.md), код — в `apps/`, секреты — **не** в git.
|
||||||
|
|
||||||
|
Платформа Compton Organic Tech: лендинг, auth, профиль, CMS, админка в стиле WESP/zootech. ТЗ: [docs/TZ.md](docs/TZ.md).
|
||||||
|
|
||||||
|
## Быстрый старт
|
||||||
|
|
||||||
|
```bash
|
||||||
|
python apps/api/scripts/bootstrap_install.py # один раз, до первого up
|
||||||
|
docker compose --profile docker-web up -d --build
|
||||||
|
curl http://localhost:8000/api/v1/health # Windows: curl.exe
|
||||||
|
```
|
||||||
|
|
||||||
|
| URL | Что там |
|
||||||
|
|-----|---------|
|
||||||
|
| http://localhost:5173 | Лендинг `/` + SPA (`/login`, `/admin`, …) |
|
||||||
|
| http://localhost:8000/api/v1/health | API жив? |
|
||||||
|
| http://localhost:8000/api/v1/docs | OpenAPI (только dev) |
|
||||||
|
|
||||||
|
**Логин для пробы:** `admin@compton.example` / `Admin1234` → `/admin`.
|
||||||
|
|
||||||
|
> Bootstrap **до** первого `docker compose up`. Иначе Postgres скажет «password authentication failed» — см. [docs/deploy.md § восстановление](docs/deploy.md#восстановление-секретов).
|
||||||
|
|
||||||
|
**Не делайте:** Docker `web` и `pnpm --filter web dev` одновременно — оба хотят порт **5173**.
|
||||||
|
|
||||||
|
## Демо-аккаунты (dev)
|
||||||
|
|
||||||
|
Подробнее: [docs/deploy.md § стандартные логины](docs/deploy.md#стандартные-логины-dev).
|
||||||
|
|
||||||
|
| Email | Пароль | Env | Роль | Superuser |
|
||||||
|
|-------|--------|-----|------|-----------|
|
||||||
|
| admin@compton.example | Admin1234 | `ADMIN_INITIAL_PASSWORD` | admin | да — Security, Diagnostics, secrets |
|
||||||
|
| ops@compton.example | OpsAdmin1234 | `DEMO_OPS_PASSWORD` | admin | нет — Users, Content, Activity |
|
||||||
|
| user@compton.example | User1234 | `DEMO_USER_PASSWORD` | user | — |
|
||||||
|
|
||||||
|
CMS после seed: `/pages/about`, `/pages/privacy`, `/pages/terms`.
|
||||||
|
|
||||||
|
В production/staging demo-users **выключены** (`SEED_DEMO_USERS=false`).
|
||||||
|
|
||||||
|
## Тесты
|
||||||
|
|
||||||
|
```bash
|
||||||
|
pnpm --filter web test:ci # frontend, cov ≥ 85%
|
||||||
|
cd apps/api && python -m pytest --cov=app --cov-fail-under=90
|
||||||
|
pnpm --filter web e2e # Playwright, API :8001
|
||||||
|
```
|
||||||
|
|
||||||
|
## Документация
|
||||||
|
|
||||||
|
Вся нормальная дока — в **`docs/`**, на русском, без водянистого README на 500 строк:
|
||||||
|
|
||||||
|
| Файл | О чём |
|
||||||
|
|------|-------|
|
||||||
|
| [docs/README.md](docs/README.md) | Оглавление |
|
||||||
|
| [docs/project.md](docs/project.md) | Структура, архитектура, API, маршруты |
|
||||||
|
| [docs/security.md](docs/security.md) | Auth, JWT revoke, секреты, чеклист prod |
|
||||||
|
| [docs/deploy.md](docs/deploy.md) | Dev/staging/prod, бэкапы, troubleshooting |
|
||||||
|
| [docs/release.md](docs/release.md) | QA gates, k6, ZAP, go-live |
|
||||||
|
| [docs/TZ.md](docs/TZ.md) | Техническое задание |
|
||||||
|
|
||||||
|
## Работа с Git
|
||||||
|
|
||||||
|
**Репозиторий:** https://git.groupkomton.ru/Matvey/site.git
|
||||||
|
**Ветка:** `main` (одна ветка, без сюрпризов)
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git pull origin main # перед работой — всегда
|
||||||
|
git status && git diff # перед коммитом — тоже всегда
|
||||||
|
git add .
|
||||||
|
git commit -m "Сделал то-то на русском"
|
||||||
|
git push origin main
|
||||||
|
```
|
||||||
|
|
||||||
|
**Коммиты — только на русском.** Плохо: `fix`, `WIP`, `asdf`.
|
||||||
|
|
||||||
|
**Не коммитить:** `.env`, `install.env`, `node_modules/`, логи, кэши.
|
||||||
|
|
||||||
|
**Не делать** `git push --force` на `main` без согласования.
|
||||||
|
|
||||||
|
| Команда | Зачем |
|
||||||
|
|---------|-------|
|
||||||
|
| `git log --oneline -10` | что было |
|
||||||
|
| `git checkout -- файл` | откатить файл |
|
||||||
|
| `git stash` / `git stash pop` | спрятать / вернуть правки |
|
||||||
|
|
||||||
|
## План по ТЗ (§16)
|
||||||
|
|
||||||
|
| Фаза | Задачи | Статус |
|
||||||
|
|------|--------|--------|
|
||||||
|
| **0 — Подготовка** | Monorepo, Docker Compose, UI kit, CI/coverage gates, Playwright smoke | Выполнено |
|
||||||
|
| **1 — MVP** | Landing, Auth+SMTP, Profile, Content, Admin, staging + полный регресс | В процессе |
|
||||||
|
| **2 — v1.0** | Celery/notifications, Catalog+Orders, Analytics, k6 + monitoring | Когда начнём? |
|
||||||
|
| **3 — Scale** | Read replica PG, Celery workers, CDN, horizontal API | Когда начнём? |
|
||||||
|
|
||||||
|
### Фаза 1 — детализация
|
||||||
|
|
||||||
|
| # | Задача | Модули | Статус |
|
||||||
|
|---|--------|--------|--------|
|
||||||
|
| 1.1 | Landing (перенос заглушки) | landing | Выполнено |
|
||||||
|
| 1.2 | Auth + SMTP | auth | Выполнено |
|
||||||
|
| 1.2b | Security baseline | core, auth | В процессе |
|
||||||
|
| 1.3 | Profile CRUD + avatar | profile | Выполнено |
|
||||||
|
| 1.4 | Content pages | content | Выполнено |
|
||||||
|
| 1.5 | Admin panel | admin | Выполнено |
|
||||||
|
| 1.6 | Staging deploy + полный регресс (§15.7) | — | В процессе |
|
||||||
|
|
||||||
|
Критерии приёмки MVP — §17.1 ТЗ (k6 50 CCU, Lighthouse ≥85, E2E 10/10, coverage gates).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
*Дальше — [docs/](docs/README.md). Там схемы, таблицы и вещи, которые не помещаются в README без страданий.*
|
||||||
@@ -0,0 +1,43 @@
|
|||||||
|
DATABASE_URL=postgresql+psycopg://user:pass@localhost:5432/compton
|
||||||
|
REDIS_URL=redis://localhost:6379/0
|
||||||
|
JWT_ACCESS_SECRET=replace-with-32-byte-secret
|
||||||
|
JWT_REFRESH_PEPPER=replace-with-32-byte-pepper
|
||||||
|
JWT_ACCESS_TTL_MIN=15
|
||||||
|
JWT_REFRESH_TTL_DAYS=30
|
||||||
|
ENABLE_DOCS=true
|
||||||
|
COOKIE_SECURE=false
|
||||||
|
ENABLE_RATE_LIMIT=true
|
||||||
|
APP_ENV=development
|
||||||
|
AUTH_LOCKOUT_ATTEMPTS=5
|
||||||
|
AUTH_LOCKOUT_MINUTES=15
|
||||||
|
CORS_ORIGINS=["http://localhost:5173"]
|
||||||
|
TRUSTED_PROXY_IPS=
|
||||||
|
SMTP_HOST=localhost
|
||||||
|
SMTP_PORT=1025
|
||||||
|
SMTP_USER=
|
||||||
|
SMTP_PASSWORD=
|
||||||
|
SMTP_FROM=noreply@compton.example
|
||||||
|
FRONTEND_URL=http://localhost:5173
|
||||||
|
PUBLIC_BASE_URL=http://localhost:5173
|
||||||
|
AUTH_TOKEN_TTL_HOURS=1
|
||||||
|
EMAIL_DELIVERY_MODE=memory
|
||||||
|
S3_ENDPOINT=http://localhost:9000
|
||||||
|
S3_ACCESS_KEY=minio
|
||||||
|
S3_SECRET_KEY=minio123
|
||||||
|
S3_BUCKET=compton
|
||||||
|
S3_REGION=us-east-1
|
||||||
|
STORAGE_MODE=s3
|
||||||
|
AVATAR_MAX_BYTES=2097152
|
||||||
|
MEDIA_URL_TTL_SECONDS=600
|
||||||
|
LOG_LEVEL=INFO
|
||||||
|
AUDIT_RETENTION_DAYS=90
|
||||||
|
COMPTON_SETTINGS_PATH=data/compton_settings.json
|
||||||
|
ADMIN_AUDIT_LOG_PATH=data/logs/admin-audit.jsonl
|
||||||
|
SERVER_LOG_PATH=data/logs/server.log
|
||||||
|
PASSWORD_DENYLIST_PATH=data/security/password-denylist.txt
|
||||||
|
ADMIN_INITIAL_PASSWORD=Admin1234
|
||||||
|
DEMO_USER_PASSWORD=User1234
|
||||||
|
DEMO_OPS_PASSWORD=OpsAdmin1234
|
||||||
|
# E2E only, never enable in production.
|
||||||
|
ENABLE_TEST_ROUTES=false
|
||||||
|
SEED_DEMO_USERS=true
|
||||||
@@ -0,0 +1,22 @@
|
|||||||
|
# Production API environment (non-secret keys). Secrets live in data/secrets/install.env
|
||||||
|
APP_ENV=production
|
||||||
|
ENABLE_DOCS=false
|
||||||
|
ENABLE_TEST_ROUTES=false
|
||||||
|
COOKIE_SECURE=true
|
||||||
|
ENABLE_RATE_LIMIT=true
|
||||||
|
EMAIL_DELIVERY_MODE=smtp
|
||||||
|
SEED_DEMO_USERS=false
|
||||||
|
JWT_ACCESS_TTL_MIN=15
|
||||||
|
JWT_REFRESH_TTL_DAYS=30
|
||||||
|
STORAGE_MODE=s3
|
||||||
|
S3_BUCKET=compton
|
||||||
|
S3_REGION=us-east-1
|
||||||
|
AVATAR_MAX_BYTES=2097152
|
||||||
|
MEDIA_URL_TTL_SECONDS=600
|
||||||
|
LOG_LEVEL=INFO
|
||||||
|
AUDIT_RETENTION_DAYS=90
|
||||||
|
COMPTON_SETTINGS_PATH=data/compton_settings.json
|
||||||
|
ADMIN_AUDIT_LOG_PATH=data/logs/admin-audit.jsonl
|
||||||
|
SERVER_LOG_PATH=data/logs/server.log
|
||||||
|
PASSWORD_DENYLIST_PATH=data/security/password-denylist.txt
|
||||||
|
# Set on server: FRONTEND_URL, PUBLIC_BASE_URL, CORS_ORIGINS, SMTP_*, DATABASE_URL (via install.env)
|
||||||
@@ -0,0 +1,11 @@
|
|||||||
|
DATABASE_URL=postgresql+psycopg://test:test@localhost:5433/compton_test
|
||||||
|
REDIS_URL=redis://localhost:6380/0
|
||||||
|
JWT_ACCESS_SECRET=test-access-secret-32-bytes-minimum
|
||||||
|
JWT_REFRESH_PEPPER=test-refresh-pepper-32-bytes-min
|
||||||
|
JWT_ACCESS_TTL_MIN=15
|
||||||
|
JWT_REFRESH_TTL_DAYS=30
|
||||||
|
ENABLE_DOCS=true
|
||||||
|
CORS_ORIGINS=["http://localhost:5175"]
|
||||||
|
EMAIL_DELIVERY_MODE=memory
|
||||||
|
STORAGE_MODE=memory
|
||||||
|
ENABLE_TEST_ROUTES=true
|
||||||
@@ -0,0 +1,8 @@
|
|||||||
|
FROM python:3.12-slim
|
||||||
|
WORKDIR /app
|
||||||
|
ENV PYTHONPATH=/app
|
||||||
|
COPY requirements-dev.txt .
|
||||||
|
RUN pip install --no-cache-dir -r requirements-dev.txt
|
||||||
|
COPY . .
|
||||||
|
EXPOSE 8000
|
||||||
|
CMD ["python", "scripts/docker_entrypoint.py"]
|
||||||
@@ -0,0 +1,42 @@
|
|||||||
|
[alembic]
|
||||||
|
script_location = migrations
|
||||||
|
prepend_sys_path = .
|
||||||
|
version_path_separator = os
|
||||||
|
|
||||||
|
sqlalchemy.url = driver://user:pass@localhost/dbname
|
||||||
|
|
||||||
|
[post_write_hooks]
|
||||||
|
|
||||||
|
[loggers]
|
||||||
|
keys = root,sqlalchemy,alembic
|
||||||
|
|
||||||
|
[handlers]
|
||||||
|
keys = console
|
||||||
|
|
||||||
|
[formatters]
|
||||||
|
keys = generic
|
||||||
|
|
||||||
|
[logger_root]
|
||||||
|
level = WARN
|
||||||
|
handlers = console
|
||||||
|
qualname =
|
||||||
|
|
||||||
|
[logger_sqlalchemy]
|
||||||
|
level = WARN
|
||||||
|
handlers =
|
||||||
|
qualname = sqlalchemy.engine
|
||||||
|
|
||||||
|
[logger_alembic]
|
||||||
|
level = INFO
|
||||||
|
handlers =
|
||||||
|
qualname = alembic
|
||||||
|
|
||||||
|
[handler_console]
|
||||||
|
class = StreamHandler
|
||||||
|
args = (sys.stderr,)
|
||||||
|
level = NOTSET
|
||||||
|
formatter = generic
|
||||||
|
|
||||||
|
[formatter_generic]
|
||||||
|
format = %(levelname)-5.5s [%(name)s] %(message)s
|
||||||
|
datefmt = %H:%M:%S
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
"""Compton API application package."""
|
||||||
@@ -0,0 +1,21 @@
|
|||||||
|
"""Optional Celery worker for sync reconcile + report refresh."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import os
|
||||||
|
|
||||||
|
from celery import Celery
|
||||||
|
|
||||||
|
broker = os.environ.get("CELERY_BROKER_URL", "redis://localhost:6379/0")
|
||||||
|
|
||||||
|
celery_app = Celery("wesp_orchestrator", broker=broker)
|
||||||
|
celery_app.conf.task_serializer = "json"
|
||||||
|
celery_app.conf.result_serializer = "json"
|
||||||
|
celery_app.conf.accept_content = ["json"]
|
||||||
|
|
||||||
|
|
||||||
|
@celery_app.task(name="sync.reconcile")
|
||||||
|
def reconcile_task() -> int:
|
||||||
|
from app.worker import run_sync_reconcile
|
||||||
|
|
||||||
|
return run_sync_reconcile()
|
||||||
@@ -0,0 +1,117 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import json
|
||||||
|
import os
|
||||||
|
from pathlib import Path
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
from app.core.config import settings
|
||||||
|
|
||||||
|
SETTINGS_ENV_KEYS: dict[str, str] = {
|
||||||
|
"enable_rate_limit": "ENABLE_RATE_LIMIT",
|
||||||
|
"enable_docs": "ENABLE_DOCS",
|
||||||
|
"cookie_secure": "COOKIE_SECURE",
|
||||||
|
"jwt_access_ttl_min": "JWT_ACCESS_TTL_MIN",
|
||||||
|
"auth_lockout_attempts": "AUTH_LOCKOUT_ATTEMPTS",
|
||||||
|
"auth_lockout_minutes": "AUTH_LOCKOUT_MINUTES",
|
||||||
|
"cors_origins": "CORS_ORIGINS",
|
||||||
|
"frontend_url": "FRONTEND_URL",
|
||||||
|
"public_base_url": "PUBLIC_BASE_URL",
|
||||||
|
"smtp_host": "SMTP_HOST",
|
||||||
|
"smtp_port": "SMTP_PORT",
|
||||||
|
"smtp_from": "SMTP_FROM",
|
||||||
|
"avatar_max_bytes": "AVATAR_MAX_BYTES",
|
||||||
|
"media_url_ttl_seconds": "MEDIA_URL_TTL_SECONDS",
|
||||||
|
"log_level": "LOG_LEVEL",
|
||||||
|
"audit_retention_days": "AUDIT_RETENTION_DAYS",
|
||||||
|
"jwt_refresh_ttl_days": "JWT_REFRESH_TTL_DAYS",
|
||||||
|
}
|
||||||
|
|
||||||
|
MANAGED_KEYS = tuple(SETTINGS_ENV_KEYS.keys())
|
||||||
|
|
||||||
|
|
||||||
|
def _settings_file() -> Path:
|
||||||
|
return Path(settings.compton_settings_path)
|
||||||
|
|
||||||
|
|
||||||
|
def get_settings_values() -> dict[str, Any]:
|
||||||
|
return {key: getattr(settings, key) for key in MANAGED_KEYS}
|
||||||
|
|
||||||
|
|
||||||
|
def _coerce_value(key: str, value: Any) -> Any:
|
||||||
|
current = getattr(settings, key)
|
||||||
|
if isinstance(current, bool):
|
||||||
|
if isinstance(value, bool):
|
||||||
|
return value
|
||||||
|
if isinstance(value, str):
|
||||||
|
return value.lower() in {"1", "true", "yes", "on"}
|
||||||
|
return bool(value)
|
||||||
|
if isinstance(current, int):
|
||||||
|
return int(value)
|
||||||
|
if isinstance(current, list):
|
||||||
|
if isinstance(value, list):
|
||||||
|
return [str(item) for item in value]
|
||||||
|
if isinstance(value, str):
|
||||||
|
return [item.strip() for item in value.split(",") if item.strip()]
|
||||||
|
raise ValueError(f"INVALID_LIST_{key}")
|
||||||
|
return value
|
||||||
|
|
||||||
|
|
||||||
|
def env_locks() -> dict[str, bool]:
|
||||||
|
return {key: os.getenv(env_key) is not None for key, env_key in SETTINGS_ENV_KEYS.items()}
|
||||||
|
|
||||||
|
|
||||||
|
def apply_settings_to_app(values: dict[str, Any]) -> None:
|
||||||
|
for key, value in values.items():
|
||||||
|
if key not in MANAGED_KEYS:
|
||||||
|
continue
|
||||||
|
setattr(settings, key, _coerce_value(key, value))
|
||||||
|
|
||||||
|
|
||||||
|
def read_settings() -> dict[str, Any]:
|
||||||
|
path = _settings_file()
|
||||||
|
if not path.exists():
|
||||||
|
return {}
|
||||||
|
with path.open("r", encoding="utf-8") as file:
|
||||||
|
payload = json.load(file)
|
||||||
|
if not isinstance(payload, dict):
|
||||||
|
return {}
|
||||||
|
return {key: payload[key] for key in MANAGED_KEYS if key in payload}
|
||||||
|
|
||||||
|
|
||||||
|
def write_settings(partial: dict[str, Any]) -> dict[str, Any]:
|
||||||
|
locks = env_locks()
|
||||||
|
current = get_settings_values()
|
||||||
|
for key, value in partial.items():
|
||||||
|
if key not in MANAGED_KEYS:
|
||||||
|
continue
|
||||||
|
if locks[key]:
|
||||||
|
continue
|
||||||
|
current[key] = _coerce_value(key, value)
|
||||||
|
|
||||||
|
path = _settings_file()
|
||||||
|
path.parent.mkdir(parents=True, exist_ok=True)
|
||||||
|
with path.open("w", encoding="utf-8") as file:
|
||||||
|
json.dump(current, file, ensure_ascii=False, indent=2)
|
||||||
|
return current
|
||||||
|
|
||||||
|
|
||||||
|
def bootstrap_settings() -> None:
|
||||||
|
apply_settings_to_app(read_settings())
|
||||||
|
|
||||||
|
|
||||||
|
def get_settings_payload() -> dict[str, Any]:
|
||||||
|
locks = env_locks()
|
||||||
|
values = get_settings_values()
|
||||||
|
secrets = {
|
||||||
|
"jwt_access_secret_configured": bool(settings.jwt_access_secret),
|
||||||
|
"jwt_refresh_pepper_configured": bool(settings.jwt_refresh_pepper),
|
||||||
|
"smtp_password_configured": bool(settings.smtp_password),
|
||||||
|
"s3_secret_key_configured": bool(settings.s3_secret_key),
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
"values": values,
|
||||||
|
"locks": locks,
|
||||||
|
"settings_path": str(_settings_file()),
|
||||||
|
"secrets": secrets,
|
||||||
|
}
|
||||||
@@ -0,0 +1,49 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import json
|
||||||
|
from datetime import UTC, datetime
|
||||||
|
from pathlib import Path
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
from app.core.config import settings
|
||||||
|
|
||||||
|
|
||||||
|
def _audit_path() -> Path:
|
||||||
|
return Path(settings.admin_audit_log_path)
|
||||||
|
|
||||||
|
|
||||||
|
def write_audit_event(
|
||||||
|
action: str,
|
||||||
|
actor_user_id: str,
|
||||||
|
actor_email: str,
|
||||||
|
details: dict[str, Any] | None = None,
|
||||||
|
) -> None:
|
||||||
|
payload = {
|
||||||
|
"timestamp": datetime.now(UTC).isoformat(),
|
||||||
|
"action": action,
|
||||||
|
"actor_user_id": actor_user_id,
|
||||||
|
"actor_email": actor_email,
|
||||||
|
"details": details or {},
|
||||||
|
}
|
||||||
|
path = _audit_path()
|
||||||
|
path.parent.mkdir(parents=True, exist_ok=True)
|
||||||
|
with path.open("a", encoding="utf-8") as file:
|
||||||
|
file.write(json.dumps(payload, ensure_ascii=False))
|
||||||
|
file.write("\n")
|
||||||
|
|
||||||
|
|
||||||
|
def read_audit_events(limit: int = 200) -> list[dict[str, Any]]:
|
||||||
|
path = _audit_path()
|
||||||
|
if not path.exists():
|
||||||
|
return []
|
||||||
|
lines = path.read_text(encoding="utf-8").splitlines()
|
||||||
|
tail = lines[-limit:]
|
||||||
|
events: list[dict[str, Any]] = []
|
||||||
|
for line in tail:
|
||||||
|
if not line.strip():
|
||||||
|
continue
|
||||||
|
try:
|
||||||
|
events.append(json.loads(line))
|
||||||
|
except json.JSONDecodeError:
|
||||||
|
continue
|
||||||
|
return list(reversed(events))
|
||||||
@@ -0,0 +1,55 @@
|
|||||||
|
from pydantic_settings import BaseSettings, SettingsConfigDict
|
||||||
|
|
||||||
|
from app.core.install_secrets import load_install_secrets_to_env
|
||||||
|
|
||||||
|
load_install_secrets_to_env()
|
||||||
|
|
||||||
|
|
||||||
|
class Settings(BaseSettings):
|
||||||
|
model_config = SettingsConfigDict(env_file=".env", env_file_encoding="utf-8", extra="ignore")
|
||||||
|
|
||||||
|
database_url: str = "postgresql+psycopg://user:pass@localhost:5432/compton"
|
||||||
|
redis_url: str = "redis://localhost:6379/0"
|
||||||
|
jwt_access_secret: str = "change-me-access-secret-with-at-least-32-bytes"
|
||||||
|
jwt_refresh_pepper: str = "change-me-refresh-pepper-with-at-least-32-bytes"
|
||||||
|
jwt_access_ttl_min: int = 15
|
||||||
|
jwt_refresh_ttl_days: int = 30
|
||||||
|
enable_docs: bool = True
|
||||||
|
cookie_secure: bool = False
|
||||||
|
cors_origins: list[str] = ["http://localhost:5173"]
|
||||||
|
enable_rate_limit: bool = True
|
||||||
|
auth_lockout_attempts: int = 5
|
||||||
|
auth_lockout_minutes: int = 15
|
||||||
|
admin_initial_password: str = "Admin1234"
|
||||||
|
demo_user_password: str = "User1234"
|
||||||
|
demo_ops_password: str = "OpsAdmin1234"
|
||||||
|
seed_demo_users: bool = True
|
||||||
|
smtp_host: str = "localhost"
|
||||||
|
smtp_port: int = 1025
|
||||||
|
smtp_user: str = ""
|
||||||
|
smtp_password: str = ""
|
||||||
|
smtp_from: str = "noreply@compton.example"
|
||||||
|
frontend_url: str = "http://localhost:5173"
|
||||||
|
public_base_url: str = "http://localhost:5173"
|
||||||
|
auth_token_ttl_hours: int = 1
|
||||||
|
email_delivery_mode: str = "smtp"
|
||||||
|
s3_endpoint: str = "http://localhost:9000"
|
||||||
|
s3_access_key: str = "minio"
|
||||||
|
s3_secret_key: str = "minio123"
|
||||||
|
s3_bucket: str = "compton"
|
||||||
|
s3_region: str = "us-east-1"
|
||||||
|
storage_mode: str = "s3"
|
||||||
|
avatar_max_bytes: int = 2 * 1024 * 1024
|
||||||
|
media_url_ttl_seconds: int = 600
|
||||||
|
log_level: str = "INFO"
|
||||||
|
audit_retention_days: int = 90
|
||||||
|
password_denylist_path: str = "data/security/password-denylist.txt"
|
||||||
|
compton_settings_path: str = "data/compton_settings.json"
|
||||||
|
admin_audit_log_path: str = "data/logs/admin-audit.jsonl"
|
||||||
|
server_log_path: str = "data/logs/server.log"
|
||||||
|
enable_test_routes: bool = False
|
||||||
|
app_env: str = "development"
|
||||||
|
trusted_proxy_ips: str = ""
|
||||||
|
|
||||||
|
|
||||||
|
settings = Settings()
|
||||||
@@ -0,0 +1,133 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import hashlib
|
||||||
|
import hmac
|
||||||
|
import secrets
|
||||||
|
import time
|
||||||
|
from datetime import UTC, datetime, timedelta
|
||||||
|
from urllib.parse import urlencode
|
||||||
|
|
||||||
|
import bcrypt
|
||||||
|
from jose import jwt
|
||||||
|
|
||||||
|
def generate_secret_token_urlsafe(length: int = 32) -> str:
|
||||||
|
return secrets.token_urlsafe(length)
|
||||||
|
|
||||||
|
|
||||||
|
def generate_secret_token_hex(length: int = 32) -> str:
|
||||||
|
return secrets.token_hex(length)
|
||||||
|
|
||||||
|
|
||||||
|
def generate_install_bundle() -> dict[str, str]:
|
||||||
|
postgres_user = "compton_app"
|
||||||
|
postgres_password = generate_secret_token_urlsafe(32)
|
||||||
|
postgres_db = "compton"
|
||||||
|
minio_root_user = "minio"
|
||||||
|
minio_root_password = generate_secret_token_urlsafe(32)
|
||||||
|
return {
|
||||||
|
"POSTGRES_USER": postgres_user,
|
||||||
|
"POSTGRES_PASSWORD": postgres_password,
|
||||||
|
"POSTGRES_DB": postgres_db,
|
||||||
|
"DATABASE_URL": f"postgresql+psycopg://{postgres_user}:{postgres_password}@postgres:5432/{postgres_db}",
|
||||||
|
"JWT_ACCESS_SECRET": generate_secret_token_hex(32),
|
||||||
|
"JWT_REFRESH_PEPPER": generate_secret_token_hex(32),
|
||||||
|
"S3_ACCESS_KEY": minio_root_user,
|
||||||
|
"S3_SECRET_KEY": minio_root_password,
|
||||||
|
"MINIO_ROOT_USER": minio_root_user,
|
||||||
|
"MINIO_ROOT_PASSWORD": minio_root_password,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def hash_password(raw_password: str) -> str:
|
||||||
|
return bcrypt.hashpw(raw_password.encode("utf-8"), bcrypt.gensalt(rounds=12)).decode("utf-8")
|
||||||
|
|
||||||
|
|
||||||
|
def verify_password(raw_password: str, password_hash: str) -> bool:
|
||||||
|
return bcrypt.checkpw(raw_password.encode("utf-8"), password_hash.encode("utf-8"))
|
||||||
|
|
||||||
|
|
||||||
|
def create_access_token(
|
||||||
|
user_id: str,
|
||||||
|
role: str,
|
||||||
|
is_superuser: bool = False,
|
||||||
|
*,
|
||||||
|
enterprise_id: str | None = None,
|
||||||
|
enterprise_role: str | None = None,
|
||||||
|
farm_ids: list[str] | None = None,
|
||||||
|
) -> str:
|
||||||
|
from app.core.config import settings
|
||||||
|
from app.core.jwt_denylist import get_auth_epoch
|
||||||
|
|
||||||
|
now = datetime.now(UTC)
|
||||||
|
payload = {
|
||||||
|
"sub": user_id,
|
||||||
|
"role": role,
|
||||||
|
"is_superuser": is_superuser,
|
||||||
|
"auth_epoch": get_auth_epoch(user_id),
|
||||||
|
"iat": int(now.timestamp()),
|
||||||
|
"exp": int((now + timedelta(minutes=settings.jwt_access_ttl_min)).timestamp()),
|
||||||
|
"jti": generate_secret_token_hex(16),
|
||||||
|
}
|
||||||
|
if enterprise_id:
|
||||||
|
payload["enterprise_id"] = enterprise_id
|
||||||
|
if enterprise_role:
|
||||||
|
payload["enterprise_role"] = enterprise_role
|
||||||
|
if farm_ids is not None:
|
||||||
|
payload["farm_ids"] = farm_ids
|
||||||
|
return jwt.encode(payload, settings.jwt_access_secret, algorithm="HS256")
|
||||||
|
|
||||||
|
|
||||||
|
def decode_access_token(token: str) -> dict:
|
||||||
|
from app.core.config import settings
|
||||||
|
|
||||||
|
return jwt.decode(token, settings.jwt_access_secret, algorithms=["HS256"])
|
||||||
|
|
||||||
|
|
||||||
|
def generate_refresh_token() -> str:
|
||||||
|
return generate_secret_token_urlsafe(48)
|
||||||
|
|
||||||
|
|
||||||
|
def generate_opaque_token() -> str:
|
||||||
|
return generate_secret_token_urlsafe(32)
|
||||||
|
|
||||||
|
|
||||||
|
def hash_opaque_token(token: str) -> str:
|
||||||
|
return hash_refresh_token(token)
|
||||||
|
|
||||||
|
|
||||||
|
def hash_refresh_token(token: str) -> str:
|
||||||
|
from app.core.config import settings
|
||||||
|
|
||||||
|
return hashlib.sha256(f"{token}:{settings.jwt_refresh_pepper}".encode("utf-8")).hexdigest()
|
||||||
|
|
||||||
|
|
||||||
|
def build_signed_media_url(stored_url: str | None) -> str | None:
|
||||||
|
from app.core.config import settings
|
||||||
|
|
||||||
|
if not stored_url:
|
||||||
|
return None
|
||||||
|
if not stored_url.startswith("/api/v1/media/files/"):
|
||||||
|
return stored_url
|
||||||
|
path = stored_url.removeprefix("/api/v1/media/files/")
|
||||||
|
expires = int(time.time()) + settings.media_url_ttl_seconds
|
||||||
|
signature = _sign_media_path(path, expires)
|
||||||
|
query = urlencode({"expires": expires, "sig": signature})
|
||||||
|
return f"/api/v1/media/files/{path}?{query}"
|
||||||
|
|
||||||
|
|
||||||
|
def verify_signed_media(path: str, expires: int, signature: str) -> bool:
|
||||||
|
if expires < int(time.time()):
|
||||||
|
return False
|
||||||
|
expected = _sign_media_path(path, expires)
|
||||||
|
return hmac.compare_digest(expected, signature)
|
||||||
|
|
||||||
|
|
||||||
|
def _sign_media_path(path: str, expires: int) -> str:
|
||||||
|
from app.core.config import settings
|
||||||
|
|
||||||
|
payload = f"{path}:{expires}"
|
||||||
|
return hmac.new(
|
||||||
|
settings.jwt_access_secret.encode("utf-8"),
|
||||||
|
payload.encode("utf-8"),
|
||||||
|
hashlib.sha256,
|
||||||
|
).hexdigest()
|
||||||
@@ -0,0 +1,50 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from collections.abc import Generator
|
||||||
|
from contextlib import contextmanager
|
||||||
|
|
||||||
|
from sqlalchemy import create_engine
|
||||||
|
from sqlalchemy.orm import Session, sessionmaker
|
||||||
|
from sqlalchemy.pool import StaticPool
|
||||||
|
|
||||||
|
from app.core.config import settings
|
||||||
|
|
||||||
|
_connect_args: dict[str, object] = {}
|
||||||
|
_engine_kwargs: dict[str, object] = {
|
||||||
|
"pool_pre_ping": True,
|
||||||
|
"future": True,
|
||||||
|
"pool_size": 5,
|
||||||
|
"max_overflow": 10,
|
||||||
|
"pool_recycle": 1800,
|
||||||
|
}
|
||||||
|
|
||||||
|
if settings.database_url.startswith("sqlite"):
|
||||||
|
_connect_args["check_same_thread"] = False
|
||||||
|
_engine_kwargs["poolclass"] = StaticPool
|
||||||
|
_engine_kwargs.pop("pool_size", None)
|
||||||
|
_engine_kwargs.pop("max_overflow", None)
|
||||||
|
_engine_kwargs.pop("pool_recycle", None)
|
||||||
|
|
||||||
|
engine = create_engine(settings.database_url, connect_args=_connect_args, **_engine_kwargs)
|
||||||
|
SessionLocal = sessionmaker(bind=engine, autoflush=False, autocommit=False, future=True)
|
||||||
|
|
||||||
|
|
||||||
|
@contextmanager
|
||||||
|
def session_scope() -> Generator[Session, None, None]:
|
||||||
|
session = SessionLocal()
|
||||||
|
try:
|
||||||
|
yield session
|
||||||
|
session.commit()
|
||||||
|
except Exception:
|
||||||
|
session.rollback()
|
||||||
|
raise
|
||||||
|
finally:
|
||||||
|
session.close()
|
||||||
|
|
||||||
|
|
||||||
|
def get_db() -> Generator[Session, None, None]:
|
||||||
|
session = SessionLocal()
|
||||||
|
try:
|
||||||
|
yield session
|
||||||
|
finally:
|
||||||
|
session.close()
|
||||||
@@ -0,0 +1,15 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from datetime import UTC, datetime
|
||||||
|
|
||||||
|
|
||||||
|
def ensure_utc(value: datetime | None) -> datetime | None:
|
||||||
|
if value is None:
|
||||||
|
return None
|
||||||
|
if value.tzinfo is None:
|
||||||
|
return value.replace(tzinfo=UTC)
|
||||||
|
return value.astimezone(UTC)
|
||||||
|
|
||||||
|
|
||||||
|
def utc_now() -> datetime:
|
||||||
|
return datetime.now(UTC)
|
||||||
@@ -0,0 +1,45 @@
|
|||||||
|
from fastapi import Depends, HTTPException, status
|
||||||
|
from fastapi.security import HTTPAuthorizationCredentials, HTTPBearer
|
||||||
|
|
||||||
|
from app.core.jwt_denylist import validate_access_claims
|
||||||
|
from app.core.security import decode_access_token
|
||||||
|
from app.modules.users.repository import get_user_by_id
|
||||||
|
|
||||||
|
bearer = HTTPBearer(auto_error=False)
|
||||||
|
|
||||||
|
|
||||||
|
def get_current_user(credentials: HTTPAuthorizationCredentials | None = Depends(bearer)):
|
||||||
|
if credentials is None:
|
||||||
|
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="UNAUTHORIZED")
|
||||||
|
try:
|
||||||
|
payload = decode_access_token(credentials.credentials)
|
||||||
|
validate_access_claims(payload)
|
||||||
|
except ValueError as exc:
|
||||||
|
detail = str(exc)
|
||||||
|
if detail == "TOKEN_REVOKED":
|
||||||
|
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="TOKEN_REVOKED")
|
||||||
|
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="INVALID_TOKEN") from exc
|
||||||
|
except Exception as exc: # pragma: no cover - defensive
|
||||||
|
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="INVALID_TOKEN") from exc
|
||||||
|
user = get_user_by_id(payload["sub"])
|
||||||
|
if not user:
|
||||||
|
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="UNAUTHORIZED")
|
||||||
|
if user.status == "pending":
|
||||||
|
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="EMAIL_NOT_VERIFIED")
|
||||||
|
if user.status == "blocked":
|
||||||
|
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="ACCOUNT_BLOCKED")
|
||||||
|
return user
|
||||||
|
|
||||||
|
|
||||||
|
def require_admin(user=Depends(get_current_user)):
|
||||||
|
if user.role != "admin":
|
||||||
|
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="ADMIN_ONLY")
|
||||||
|
return user
|
||||||
|
|
||||||
|
|
||||||
|
def require_superuser(user=Depends(get_current_user)):
|
||||||
|
if user.role != "admin":
|
||||||
|
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="ADMIN_ONLY")
|
||||||
|
if not user.is_superuser:
|
||||||
|
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="SUPERUSER_ONLY")
|
||||||
|
return user
|
||||||
@@ -0,0 +1,61 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import smtplib
|
||||||
|
from dataclasses import dataclass
|
||||||
|
from email.message import EmailMessage
|
||||||
|
|
||||||
|
from app.core.config import settings
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class SentEmail:
|
||||||
|
to: str
|
||||||
|
subject: str
|
||||||
|
body: str
|
||||||
|
template: str
|
||||||
|
|
||||||
|
|
||||||
|
class MemoryMailer:
|
||||||
|
def __init__(self) -> None:
|
||||||
|
self.sent: list[SentEmail] = []
|
||||||
|
|
||||||
|
def send(self, to: str, subject: str, body: str, template: str) -> None:
|
||||||
|
self.sent.append(SentEmail(to=to, subject=subject, body=body, template=template))
|
||||||
|
|
||||||
|
def clear(self) -> None:
|
||||||
|
self.sent.clear()
|
||||||
|
|
||||||
|
def latest_token(self, recipient: str, template: str) -> str | None:
|
||||||
|
for message in reversed(self.sent):
|
||||||
|
if message.to == recipient and message.template == template:
|
||||||
|
for line in message.body.splitlines():
|
||||||
|
if line.startswith("TOKEN:"):
|
||||||
|
return line.split(":", 1)[1].strip()
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
class SmtpMailer:
|
||||||
|
def send(self, to: str, subject: str, body: str, template: str) -> None:
|
||||||
|
_ = template
|
||||||
|
message = EmailMessage()
|
||||||
|
message["From"] = settings.smtp_from
|
||||||
|
message["To"] = to
|
||||||
|
message["Subject"] = subject
|
||||||
|
message.set_content(body)
|
||||||
|
with smtplib.SMTP(settings.smtp_host, settings.smtp_port, timeout=10) as smtp:
|
||||||
|
if settings.smtp_user:
|
||||||
|
smtp.login(settings.smtp_user, settings.smtp_password)
|
||||||
|
smtp.send_message(message)
|
||||||
|
|
||||||
|
|
||||||
|
memory_mailer = MemoryMailer()
|
||||||
|
|
||||||
|
|
||||||
|
def get_mailer():
|
||||||
|
if settings.email_delivery_mode == "memory":
|
||||||
|
return memory_mailer
|
||||||
|
return SmtpMailer()
|
||||||
|
|
||||||
|
|
||||||
|
def send_template_email(to: str, template: str, subject: str, body: str) -> None:
|
||||||
|
get_mailer().send(to=to, subject=subject, body=body, template=template)
|
||||||
@@ -0,0 +1,2 @@
|
|||||||
|
class DomainError(Exception):
|
||||||
|
"""Base domain error."""
|
||||||
@@ -0,0 +1,205 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import os
|
||||||
|
from dataclasses import dataclass
|
||||||
|
from datetime import UTC, datetime
|
||||||
|
from pathlib import Path
|
||||||
|
from urllib.parse import ParseResult, urlparse, urlunparse
|
||||||
|
from uuid import uuid4
|
||||||
|
|
||||||
|
from app.core.crypto import generate_install_bundle
|
||||||
|
|
||||||
|
_API_ROOT = Path(__file__).resolve().parents[2]
|
||||||
|
_INSTALL_SECRETS_ROOT = Path(os.getenv("COMPTON_INSTALL_SECRETS_DIR", str(_API_ROOT / "data" / "secrets")))
|
||||||
|
INSTALL_SECRETS_DIR = _INSTALL_SECRETS_ROOT
|
||||||
|
INSTALL_SECRETS_FILE = INSTALL_SECRETS_DIR / "install.env"
|
||||||
|
INSTALL_SECRETS_META_FILE = INSTALL_SECRETS_DIR / "install.meta.json"
|
||||||
|
REQUIRED_KEYS = (
|
||||||
|
"POSTGRES_USER",
|
||||||
|
"POSTGRES_PASSWORD",
|
||||||
|
"POSTGRES_DB",
|
||||||
|
"DATABASE_URL",
|
||||||
|
"JWT_ACCESS_SECRET",
|
||||||
|
"JWT_REFRESH_PEPPER",
|
||||||
|
"S3_ACCESS_KEY",
|
||||||
|
"S3_SECRET_KEY",
|
||||||
|
"MINIO_ROOT_USER",
|
||||||
|
"MINIO_ROOT_PASSWORD",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class InstallSecretsStatus:
|
||||||
|
initialized: bool
|
||||||
|
locked: bool
|
||||||
|
path: str
|
||||||
|
created: bool
|
||||||
|
|
||||||
|
|
||||||
|
def _parse_env_text(raw: str) -> dict[str, str]:
|
||||||
|
values: dict[str, str] = {}
|
||||||
|
for line in raw.splitlines():
|
||||||
|
line = line.strip()
|
||||||
|
if not line or line.startswith("#") or "=" not in line:
|
||||||
|
continue
|
||||||
|
key, value = line.split("=", 1)
|
||||||
|
values[key.strip()] = value.strip()
|
||||||
|
return values
|
||||||
|
|
||||||
|
|
||||||
|
def _render_env(values: dict[str, str]) -> str:
|
||||||
|
ordered = [f"{key}={values[key]}" for key in sorted(values.keys())]
|
||||||
|
return "\n".join(ordered) + "\n"
|
||||||
|
|
||||||
|
|
||||||
|
def read_install_secrets() -> dict[str, str]:
|
||||||
|
if not INSTALL_SECRETS_FILE.exists():
|
||||||
|
return {}
|
||||||
|
return _parse_env_text(INSTALL_SECRETS_FILE.read_text(encoding="utf-8"))
|
||||||
|
|
||||||
|
|
||||||
|
def _write_install_secrets(values: dict[str, str]) -> None:
|
||||||
|
INSTALL_SECRETS_DIR.mkdir(parents=True, exist_ok=True)
|
||||||
|
INSTALL_SECRETS_FILE.write_text(_render_env(values), encoding="utf-8")
|
||||||
|
|
||||||
|
|
||||||
|
def _write_meta() -> None:
|
||||||
|
payload = (
|
||||||
|
"{\n"
|
||||||
|
f' "install_id": "{uuid4()}",\n'
|
||||||
|
f' "locked_at": "{datetime.now(UTC).isoformat()}"\n'
|
||||||
|
"}\n"
|
||||||
|
)
|
||||||
|
INSTALL_SECRETS_META_FILE.write_text(payload, encoding="utf-8")
|
||||||
|
|
||||||
|
|
||||||
|
def _is_postgres_database_url(database_url: str) -> bool:
|
||||||
|
scheme = urlparse(database_url).scheme.lower()
|
||||||
|
return scheme.startswith("postgresql") or scheme.startswith("postgres+")
|
||||||
|
|
||||||
|
|
||||||
|
def _adopt_from_environment() -> dict[str, str]:
|
||||||
|
env_values = {key: os.getenv(key, "") for key in REQUIRED_KEYS if key != "DATABASE_URL"}
|
||||||
|
db_url = os.getenv("DATABASE_URL", "")
|
||||||
|
if db_url and _is_postgres_database_url(db_url):
|
||||||
|
parsed = urlparse(db_url)
|
||||||
|
if parsed.username:
|
||||||
|
env_values["POSTGRES_USER"] = parsed.username
|
||||||
|
if parsed.password:
|
||||||
|
env_values["POSTGRES_PASSWORD"] = parsed.password
|
||||||
|
if parsed.path and parsed.path != "/":
|
||||||
|
env_values["POSTGRES_DB"] = parsed.path.lstrip("/")
|
||||||
|
env_values["DATABASE_URL"] = db_url
|
||||||
|
return {key: value for key, value in env_values.items() if value}
|
||||||
|
|
||||||
|
|
||||||
|
def _sync_minio_s3_secrets(values: dict[str, str]) -> dict[str, str]:
|
||||||
|
"""MinIO root credentials are the S3 access key pair — keep them aligned."""
|
||||||
|
if (
|
||||||
|
values.get("S3_ACCESS_KEY") == values.get("MINIO_ROOT_USER")
|
||||||
|
and values.get("MINIO_ROOT_PASSWORD")
|
||||||
|
and values.get("S3_SECRET_KEY") != values["MINIO_ROOT_PASSWORD"]
|
||||||
|
):
|
||||||
|
values = dict(values)
|
||||||
|
values["S3_SECRET_KEY"] = values["MINIO_ROOT_PASSWORD"]
|
||||||
|
return values
|
||||||
|
|
||||||
|
|
||||||
|
def load_install_secrets_to_env() -> None:
|
||||||
|
values = _sync_minio_s3_secrets(read_install_secrets())
|
||||||
|
for key, value in values.items():
|
||||||
|
os.environ[key] = value
|
||||||
|
|
||||||
|
|
||||||
|
def ensure_install_secrets() -> InstallSecretsStatus:
|
||||||
|
existing = read_install_secrets()
|
||||||
|
if existing:
|
||||||
|
db_url = existing.get("DATABASE_URL", "")
|
||||||
|
if db_url and not _is_postgres_database_url(db_url):
|
||||||
|
raise RuntimeError(
|
||||||
|
"install.env DATABASE_URL must be PostgreSQL (got SQLite). "
|
||||||
|
"Remove apps/api/data/secrets/install.env and run: python apps/api/scripts/bootstrap_install.py"
|
||||||
|
)
|
||||||
|
synced = _sync_minio_s3_secrets(existing)
|
||||||
|
if synced != existing:
|
||||||
|
synced["SECRETS_LOCKED"] = existing.get("SECRETS_LOCKED", "true")
|
||||||
|
_write_install_secrets(synced)
|
||||||
|
existing = synced
|
||||||
|
load_install_secrets_to_env()
|
||||||
|
return InstallSecretsStatus(True, existing.get("SECRETS_LOCKED", "false") == "true", str(INSTALL_SECRETS_FILE), False)
|
||||||
|
|
||||||
|
adopted = _adopt_from_environment()
|
||||||
|
generated = generate_install_bundle()
|
||||||
|
values = generated | adopted
|
||||||
|
values["SECRETS_LOCKED"] = "true"
|
||||||
|
_write_install_secrets(values)
|
||||||
|
_write_meta()
|
||||||
|
load_install_secrets_to_env()
|
||||||
|
return InstallSecretsStatus(True, True, str(INSTALL_SECRETS_FILE), True)
|
||||||
|
|
||||||
|
|
||||||
|
def masked_database_url(database_url: str) -> str:
|
||||||
|
parsed = urlparse(database_url)
|
||||||
|
if not parsed.username:
|
||||||
|
return database_url
|
||||||
|
password = "***" if parsed.password else ""
|
||||||
|
credentials = f"{parsed.username}:{password}" if password else parsed.username
|
||||||
|
host = parsed.hostname or ""
|
||||||
|
if parsed.port:
|
||||||
|
host = f"{host}:{parsed.port}"
|
||||||
|
netloc = f"{credentials}@{host}"
|
||||||
|
sanitized = ParseResult(
|
||||||
|
scheme=parsed.scheme,
|
||||||
|
netloc=netloc,
|
||||||
|
path=parsed.path,
|
||||||
|
params=parsed.params,
|
||||||
|
query=parsed.query,
|
||||||
|
fragment=parsed.fragment,
|
||||||
|
)
|
||||||
|
return urlunparse(sanitized)
|
||||||
|
|
||||||
|
|
||||||
|
def install_secrets_payload() -> dict:
|
||||||
|
values = read_install_secrets()
|
||||||
|
db = urlparse(values.get("DATABASE_URL", ""))
|
||||||
|
return {
|
||||||
|
"initialized": bool(values),
|
||||||
|
"locked": values.get("SECRETS_LOCKED") == "true",
|
||||||
|
"secrets_path": str(INSTALL_SECRETS_FILE),
|
||||||
|
"database": {
|
||||||
|
"host": db.hostname,
|
||||||
|
"port": db.port,
|
||||||
|
"database": db.path.lstrip("/") if db.path else "",
|
||||||
|
"user": db.username,
|
||||||
|
"password_configured": bool(values.get("POSTGRES_PASSWORD")),
|
||||||
|
},
|
||||||
|
"connection_string_masked": masked_database_url(values.get("DATABASE_URL", "")),
|
||||||
|
"secrets_status": {
|
||||||
|
"jwt_access_secret": "configured" if bool(values.get("JWT_ACCESS_SECRET")) else "missing",
|
||||||
|
"jwt_refresh_pepper": "configured" if bool(values.get("JWT_REFRESH_PEPPER")) else "missing",
|
||||||
|
"postgres_password": "configured" if bool(values.get("POSTGRES_PASSWORD")) else "missing",
|
||||||
|
"s3_secret_key": "configured" if bool(values.get("S3_SECRET_KEY")) else "missing",
|
||||||
|
"password_bcrypt_salt": "per_user_in_db",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def reveal_install_secret(key: str) -> str:
|
||||||
|
mapping = {
|
||||||
|
"database_password": "POSTGRES_PASSWORD",
|
||||||
|
"jwt_access_secret": "JWT_ACCESS_SECRET",
|
||||||
|
"jwt_refresh_pepper": "JWT_REFRESH_PEPPER",
|
||||||
|
"s3_secret_key": "S3_SECRET_KEY",
|
||||||
|
}
|
||||||
|
env_key = mapping.get(key)
|
||||||
|
if not env_key:
|
||||||
|
raise ValueError("UNSUPPORTED_SECRET_KEY")
|
||||||
|
values = read_install_secrets()
|
||||||
|
if env_key in values:
|
||||||
|
return values[env_key]
|
||||||
|
if env_key == "POSTGRES_PASSWORD":
|
||||||
|
database_url = values.get("DATABASE_URL") or os.getenv("DATABASE_URL", "")
|
||||||
|
parsed = urlparse(database_url)
|
||||||
|
return parsed.password or ""
|
||||||
|
return os.getenv(env_key, "")
|
||||||
|
|
||||||
@@ -0,0 +1,108 @@
|
|||||||
|
"""Redis-backed JWT jti denylist and per-user auth_epoch for instant access revocation."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import time
|
||||||
|
|
||||||
|
from app.core.config import settings
|
||||||
|
from app.core.redis import get_redis_client
|
||||||
|
|
||||||
|
AUTH_EPOCH_PREFIX = "auth:epoch:"
|
||||||
|
JWT_DENY_PREFIX = "jwt:deny:"
|
||||||
|
|
||||||
|
_memory_epochs: dict[str, int] = {}
|
||||||
|
_memory_denied_jti: dict[str, float] = {}
|
||||||
|
|
||||||
|
|
||||||
|
def _purge_expired_memory_jtis() -> None:
|
||||||
|
now = time.time()
|
||||||
|
expired = [jti for jti, exp in _memory_denied_jti.items() if exp <= now]
|
||||||
|
for jti in expired:
|
||||||
|
_memory_denied_jti.pop(jti, None)
|
||||||
|
|
||||||
|
|
||||||
|
def ensure_jwt_revocation_backend() -> None:
|
||||||
|
if settings.app_env.lower() != "production":
|
||||||
|
return
|
||||||
|
if get_redis_client() is None:
|
||||||
|
raise RuntimeError("Redis is required for JWT revocation in production")
|
||||||
|
|
||||||
|
|
||||||
|
def get_auth_epoch(user_id: str) -> int:
|
||||||
|
client = get_redis_client()
|
||||||
|
if client is not None:
|
||||||
|
try:
|
||||||
|
value = client.get(f"{AUTH_EPOCH_PREFIX}{user_id}")
|
||||||
|
return int(value) if value is not None else 0
|
||||||
|
except Exception:
|
||||||
|
if settings.app_env.lower() == "production":
|
||||||
|
raise
|
||||||
|
return _memory_epochs.get(user_id, 0)
|
||||||
|
|
||||||
|
|
||||||
|
def bump_auth_epoch(user_id: str) -> int:
|
||||||
|
client = get_redis_client()
|
||||||
|
if client is not None:
|
||||||
|
try:
|
||||||
|
return int(client.incr(f"{AUTH_EPOCH_PREFIX}{user_id}"))
|
||||||
|
except Exception:
|
||||||
|
if settings.app_env.lower() == "production":
|
||||||
|
raise
|
||||||
|
next_epoch = _memory_epochs.get(user_id, 0) + 1
|
||||||
|
_memory_epochs[user_id] = next_epoch
|
||||||
|
return next_epoch
|
||||||
|
|
||||||
|
|
||||||
|
def deny_jti(jti: str, exp: int) -> None:
|
||||||
|
if not jti:
|
||||||
|
return
|
||||||
|
ttl = max(int(exp - time.time()), 1)
|
||||||
|
client = get_redis_client()
|
||||||
|
if client is not None:
|
||||||
|
try:
|
||||||
|
client.setex(f"{JWT_DENY_PREFIX}{jti}", ttl, "1")
|
||||||
|
return
|
||||||
|
except Exception:
|
||||||
|
if settings.app_env.lower() == "production":
|
||||||
|
raise
|
||||||
|
_purge_expired_memory_jtis()
|
||||||
|
_memory_denied_jti[jti] = time.time() + ttl
|
||||||
|
|
||||||
|
|
||||||
|
def is_jti_denied(jti: str) -> bool:
|
||||||
|
if not jti:
|
||||||
|
return False
|
||||||
|
client = get_redis_client()
|
||||||
|
if client is not None:
|
||||||
|
try:
|
||||||
|
return bool(client.exists(f"{JWT_DENY_PREFIX}{jti}"))
|
||||||
|
except Exception:
|
||||||
|
if settings.app_env.lower() == "production":
|
||||||
|
return True
|
||||||
|
_purge_expired_memory_jtis()
|
||||||
|
return jti in _memory_denied_jti
|
||||||
|
|
||||||
|
|
||||||
|
def revoke_access_token(token: str) -> None:
|
||||||
|
from app.core.security import decode_access_token
|
||||||
|
|
||||||
|
try:
|
||||||
|
payload = decode_access_token(token)
|
||||||
|
except Exception:
|
||||||
|
return
|
||||||
|
jti = payload.get("jti")
|
||||||
|
exp = payload.get("exp")
|
||||||
|
if jti and exp:
|
||||||
|
deny_jti(str(jti), int(exp))
|
||||||
|
|
||||||
|
|
||||||
|
def validate_access_claims(payload: dict) -> None:
|
||||||
|
user_id = payload.get("sub")
|
||||||
|
if not user_id:
|
||||||
|
raise ValueError("INVALID_TOKEN")
|
||||||
|
jti = payload.get("jti")
|
||||||
|
if jti and is_jti_denied(str(jti)):
|
||||||
|
raise ValueError("TOKEN_REVOKED")
|
||||||
|
token_epoch = int(payload.get("auth_epoch", 0))
|
||||||
|
if token_epoch != get_auth_epoch(str(user_id)):
|
||||||
|
raise ValueError("TOKEN_REVOKED")
|
||||||
@@ -0,0 +1,3 @@
|
|||||||
|
from app.core.crypto import build_signed_media_url, verify_signed_media
|
||||||
|
|
||||||
|
__all__ = ["build_signed_media_url", "verify_signed_media"]
|
||||||
@@ -0,0 +1,46 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
from app.core.config import settings
|
||||||
|
|
||||||
|
COMMON_PASSWORDS = frozenset(
|
||||||
|
{
|
||||||
|
"password",
|
||||||
|
"password1",
|
||||||
|
"password123",
|
||||||
|
"12345678",
|
||||||
|
"123456789",
|
||||||
|
"qwerty123",
|
||||||
|
"admin123",
|
||||||
|
"admin1234",
|
||||||
|
"letmein1",
|
||||||
|
"welcome1",
|
||||||
|
"iloveyou1",
|
||||||
|
"sunshine1",
|
||||||
|
"football1",
|
||||||
|
"baseball1",
|
||||||
|
"monkey123",
|
||||||
|
"dragon123",
|
||||||
|
"master123",
|
||||||
|
"trustno1",
|
||||||
|
"passw0rd",
|
||||||
|
"passw0rd1",
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def load_denylist() -> set[str]:
|
||||||
|
denylist = set(COMMON_PASSWORDS)
|
||||||
|
path = Path(settings.password_denylist_path)
|
||||||
|
if not path.exists():
|
||||||
|
return denylist
|
||||||
|
for line in path.read_text(encoding="utf-8").splitlines():
|
||||||
|
candidate = line.strip().lower()
|
||||||
|
if candidate and not candidate.startswith("#"):
|
||||||
|
denylist.add(candidate)
|
||||||
|
return denylist
|
||||||
|
|
||||||
|
|
||||||
|
def is_denied_password(password: str) -> bool:
|
||||||
|
return password.lower() in load_denylist()
|
||||||
@@ -0,0 +1,20 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import re
|
||||||
|
|
||||||
|
|
||||||
|
from app.core.password_denylist import is_denied_password
|
||||||
|
|
||||||
|
|
||||||
|
def validate_password_strength(password: str) -> str:
|
||||||
|
if len(password) < 8:
|
||||||
|
raise ValueError("Password must be at least 8 characters long")
|
||||||
|
if is_denied_password(password):
|
||||||
|
raise ValueError("Password is too common")
|
||||||
|
if not re.search(r"[A-Z]", password):
|
||||||
|
raise ValueError("Password must include at least one uppercase letter")
|
||||||
|
if not re.search(r"[a-z]", password):
|
||||||
|
raise ValueError("Password must include at least one lowercase letter")
|
||||||
|
if not re.search(r"\d", password):
|
||||||
|
raise ValueError("Password must include at least one digit")
|
||||||
|
return password
|
||||||
@@ -0,0 +1,74 @@
|
|||||||
|
"""Redis-first rate limiter with in-memory fallback."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from collections import defaultdict
|
||||||
|
from datetime import UTC, datetime, timedelta
|
||||||
|
|
||||||
|
from fastapi import HTTPException, Request, status
|
||||||
|
from redis import Redis
|
||||||
|
from redis.exceptions import RedisError
|
||||||
|
|
||||||
|
from app.core.config import settings
|
||||||
|
|
||||||
|
_buckets: dict[str, list[datetime]] = defaultdict(list)
|
||||||
|
_redis_client: Redis | None = None
|
||||||
|
|
||||||
|
|
||||||
|
def get_redis_client() -> Redis | None:
|
||||||
|
global _redis_client
|
||||||
|
if _redis_client is not None:
|
||||||
|
return _redis_client
|
||||||
|
try:
|
||||||
|
_redis_client = Redis.from_url(settings.redis_url, decode_responses=True)
|
||||||
|
_redis_client.ping()
|
||||||
|
return _redis_client
|
||||||
|
except RedisError:
|
||||||
|
_redis_client = None
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def check_rate_limit(key: str, limit: int, window_seconds: int) -> None:
|
||||||
|
if not settings.enable_rate_limit:
|
||||||
|
return
|
||||||
|
redis_client = get_redis_client()
|
||||||
|
if redis_client is not None:
|
||||||
|
redis_key = f"rl:{key}"
|
||||||
|
try:
|
||||||
|
current = redis_client.incr(redis_key)
|
||||||
|
if current == 1:
|
||||||
|
redis_client.expire(redis_key, window_seconds)
|
||||||
|
if current > limit:
|
||||||
|
ttl = redis_client.ttl(redis_key)
|
||||||
|
retry_after = ttl if ttl and ttl > 0 else window_seconds
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_429_TOO_MANY_REQUESTS,
|
||||||
|
detail="RATE_LIMIT_EXCEEDED",
|
||||||
|
headers={"Retry-After": str(retry_after)},
|
||||||
|
)
|
||||||
|
return
|
||||||
|
except RedisError:
|
||||||
|
pass
|
||||||
|
|
||||||
|
now = datetime.now(UTC)
|
||||||
|
cutoff = now - timedelta(seconds=window_seconds)
|
||||||
|
timestamps = [moment for moment in _buckets[key] if moment > cutoff]
|
||||||
|
if len(timestamps) >= limit:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_429_TOO_MANY_REQUESTS,
|
||||||
|
detail="RATE_LIMIT_EXCEEDED",
|
||||||
|
headers={"Retry-After": str(window_seconds)},
|
||||||
|
)
|
||||||
|
timestamps.append(now)
|
||||||
|
_buckets[key] = timestamps
|
||||||
|
|
||||||
|
|
||||||
|
def client_ip(request: Request) -> str:
|
||||||
|
trusted_proxy_ips = {item.strip() for item in settings.trusted_proxy_ips.split(",") if item.strip()}
|
||||||
|
forwarded = request.headers.get("X-Forwarded-For")
|
||||||
|
request_ip = request.client.host if request.client else ""
|
||||||
|
if forwarded and request_ip in trusted_proxy_ips:
|
||||||
|
return forwarded.split(",")[0].strip()
|
||||||
|
if request.client:
|
||||||
|
return request_ip
|
||||||
|
return "unknown"
|
||||||
@@ -0,0 +1,21 @@
|
|||||||
|
from app.core.crypto import (
|
||||||
|
create_access_token,
|
||||||
|
decode_access_token,
|
||||||
|
generate_opaque_token,
|
||||||
|
generate_refresh_token,
|
||||||
|
hash_opaque_token,
|
||||||
|
hash_password,
|
||||||
|
hash_refresh_token,
|
||||||
|
verify_password,
|
||||||
|
)
|
||||||
|
|
||||||
|
__all__ = [
|
||||||
|
"create_access_token",
|
||||||
|
"decode_access_token",
|
||||||
|
"generate_opaque_token",
|
||||||
|
"generate_refresh_token",
|
||||||
|
"hash_opaque_token",
|
||||||
|
"hash_password",
|
||||||
|
"hash_refresh_token",
|
||||||
|
"verify_password",
|
||||||
|
]
|
||||||
@@ -0,0 +1,63 @@
|
|||||||
|
"""File logging for WESP admin server-log viewer (SERVER_LOG_PATH)."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import logging
|
||||||
|
from datetime import UTC, datetime
|
||||||
|
from pathlib import Path
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
from app.core.config import settings
|
||||||
|
|
||||||
|
_LOG_FORMAT = "%(asctime)s [%(levelname)s] %(name)s: %(message)s"
|
||||||
|
|
||||||
|
|
||||||
|
def server_log_path() -> Path:
|
||||||
|
return Path(settings.server_log_path)
|
||||||
|
|
||||||
|
|
||||||
|
def ensure_server_log_file() -> Path:
|
||||||
|
path = server_log_path()
|
||||||
|
path.parent.mkdir(parents=True, exist_ok=True)
|
||||||
|
if not path.is_file():
|
||||||
|
started = datetime.now(UTC).strftime("%Y-%m-%dT%H:%M:%SZ")
|
||||||
|
path.write_text(f"{started} [INFO] compton.api: server log initialized\n", encoding="utf-8")
|
||||||
|
return path.resolve()
|
||||||
|
|
||||||
|
|
||||||
|
def build_uvicorn_log_config() -> dict[str, Any]:
|
||||||
|
log_path = str(ensure_server_log_file())
|
||||||
|
return {
|
||||||
|
"version": 1,
|
||||||
|
"disable_existing_loggers": False,
|
||||||
|
"formatters": {
|
||||||
|
"default": {
|
||||||
|
"format": _LOG_FORMAT,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
"handlers": {
|
||||||
|
"file": {
|
||||||
|
"class": "logging.FileHandler",
|
||||||
|
"filename": log_path,
|
||||||
|
"encoding": "utf-8",
|
||||||
|
"formatter": "default",
|
||||||
|
},
|
||||||
|
"console": {
|
||||||
|
"class": "logging.StreamHandler",
|
||||||
|
"formatter": "default",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
"loggers": {
|
||||||
|
"uvicorn": {"handlers": ["file", "console"], "level": settings.log_level, "propagate": False},
|
||||||
|
"uvicorn.error": {"handlers": ["file", "console"], "level": settings.log_level, "propagate": False},
|
||||||
|
"uvicorn.access": {"handlers": ["file", "console"], "level": "INFO", "propagate": False},
|
||||||
|
},
|
||||||
|
"root": {"handlers": ["file", "console"], "level": settings.log_level},
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def append_server_log_line(message: str, level: str = "INFO") -> None:
|
||||||
|
path = ensure_server_log_file()
|
||||||
|
ts = datetime.now(UTC).strftime("%Y-%m-%dT%H:%M:%SZ")
|
||||||
|
with path.open("a", encoding="utf-8") as fh:
|
||||||
|
fh.write(f"{ts} [{level}] compton.api: {message}\n")
|
||||||
@@ -0,0 +1,61 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from app.core.config import settings
|
||||||
|
|
||||||
|
_s3_client = None
|
||||||
|
|
||||||
|
|
||||||
|
def get_s3_client():
|
||||||
|
global _s3_client
|
||||||
|
if _s3_client is None:
|
||||||
|
import boto3
|
||||||
|
|
||||||
|
_s3_client = boto3.client(
|
||||||
|
"s3",
|
||||||
|
endpoint_url=settings.s3_endpoint,
|
||||||
|
aws_access_key_id=settings.s3_access_key,
|
||||||
|
aws_secret_access_key=settings.s3_secret_key,
|
||||||
|
region_name=settings.s3_region,
|
||||||
|
)
|
||||||
|
return _s3_client
|
||||||
|
|
||||||
|
|
||||||
|
def ensure_bucket() -> None:
|
||||||
|
if settings.storage_mode != "s3":
|
||||||
|
return
|
||||||
|
client = get_s3_client()
|
||||||
|
bucket = settings.s3_bucket
|
||||||
|
try:
|
||||||
|
client.head_bucket(Bucket=bucket)
|
||||||
|
except Exception:
|
||||||
|
client.create_bucket(Bucket=bucket)
|
||||||
|
|
||||||
|
|
||||||
|
def upload_object(key: str, body: bytes, content_type: str) -> None:
|
||||||
|
if settings.storage_mode == "memory":
|
||||||
|
memory_store[key] = (body, content_type)
|
||||||
|
return
|
||||||
|
client = get_s3_client()
|
||||||
|
ensure_bucket()
|
||||||
|
client.put_object(
|
||||||
|
Bucket=settings.s3_bucket,
|
||||||
|
Key=key,
|
||||||
|
Body=body,
|
||||||
|
ContentType=content_type,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def download_object(key: str) -> tuple[bytes, str] | None:
|
||||||
|
if settings.storage_mode == "memory":
|
||||||
|
return memory_store.get(key)
|
||||||
|
client = get_s3_client()
|
||||||
|
try:
|
||||||
|
response = client.get_object(Bucket=settings.s3_bucket, Key=key)
|
||||||
|
body = response["Body"].read()
|
||||||
|
content_type = response.get("ContentType", "application/octet-stream")
|
||||||
|
return body, content_type
|
||||||
|
except Exception:
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
memory_store: dict[str, tuple[bytes, str]] = {}
|
||||||
@@ -0,0 +1,3 @@
|
|||||||
|
from app.db.base import Base
|
||||||
|
|
||||||
|
__all__ = ["Base"]
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
from sqlalchemy.orm import DeclarativeBase
|
||||||
|
|
||||||
|
|
||||||
|
class Base(DeclarativeBase):
|
||||||
|
pass
|
||||||
@@ -0,0 +1,80 @@
|
|||||||
|
from app.modules.auth.models import EmailVerificationToken, PasswordResetToken, RefreshToken
|
||||||
|
from app.modules.content.models import ContentPage
|
||||||
|
from app.modules.sync.models import (
|
||||||
|
Enterprise,
|
||||||
|
EnterpriseMember,
|
||||||
|
FarmHub,
|
||||||
|
HubCredential,
|
||||||
|
HubPairingSession,
|
||||||
|
ReportSyncState,
|
||||||
|
SyncAppliedEvent,
|
||||||
|
SyncConflict,
|
||||||
|
SyncCursor,
|
||||||
|
SyncEventLog,
|
||||||
|
SyncOutbox,
|
||||||
|
SyncReconcileRun,
|
||||||
|
SyncRecordState,
|
||||||
|
UserFarmAccess,
|
||||||
|
)
|
||||||
|
from app.modules.users.models import User, UserProfile
|
||||||
|
from app.modules.zootech.settings_models import ZootechFeedQualitySettings, ZootechOrgSettings
|
||||||
|
from app.modules.zootech.catalog_models import (
|
||||||
|
ZootechDailyComponentNormAdjustment,
|
||||||
|
ZootechDailyIngredientReplacement,
|
||||||
|
ZootechDailyIngredientSkip,
|
||||||
|
ZootechDailyTripSkip,
|
||||||
|
ZootechDailyUnloadingGroupSkip,
|
||||||
|
ZootechFeedDispenser,
|
||||||
|
ZootechFeedingLocation,
|
||||||
|
ZootechFeedingPeriod,
|
||||||
|
ZootechFeedingPoint,
|
||||||
|
ZootechFeedMixer,
|
||||||
|
ZootechPeriodRecipe,
|
||||||
|
ZootechTrip,
|
||||||
|
ZootechUnloadingGroup,
|
||||||
|
)
|
||||||
|
from app.modules.zootech.report_models import ZootechFeedAlert, ZootechLoadingReport, ZootechUnloadingReport
|
||||||
|
|
||||||
|
__all__ = [
|
||||||
|
"User",
|
||||||
|
"UserProfile",
|
||||||
|
"RefreshToken",
|
||||||
|
"PasswordResetToken",
|
||||||
|
"EmailVerificationToken",
|
||||||
|
"ContentPage",
|
||||||
|
"Enterprise",
|
||||||
|
"EnterpriseMember",
|
||||||
|
"FarmHub",
|
||||||
|
"UserFarmAccess",
|
||||||
|
"HubCredential",
|
||||||
|
"HubPairingSession",
|
||||||
|
"SyncOutbox",
|
||||||
|
"SyncEventLog",
|
||||||
|
"SyncAppliedEvent",
|
||||||
|
"SyncCursor",
|
||||||
|
"SyncRecordState",
|
||||||
|
"SyncConflict",
|
||||||
|
"SyncReconcileRun",
|
||||||
|
"ReportSyncState",
|
||||||
|
"ZootechComponent",
|
||||||
|
"ZootechRecipe",
|
||||||
|
"ZootechIngredient",
|
||||||
|
"ZootechUnloadingGroup",
|
||||||
|
"ZootechFeedMixer",
|
||||||
|
"ZootechFeedDispenser",
|
||||||
|
"ZootechFeedingLocation",
|
||||||
|
"ZootechFeedingPeriod",
|
||||||
|
"ZootechFeedingPoint",
|
||||||
|
"ZootechPeriodRecipe",
|
||||||
|
"ZootechTrip",
|
||||||
|
"ZootechDailyTripSkip",
|
||||||
|
"ZootechDailyIngredientSkip",
|
||||||
|
"ZootechDailyUnloadingGroupSkip",
|
||||||
|
"ZootechDailyIngredientReplacement",
|
||||||
|
"ZootechDailyComponentNormAdjustment",
|
||||||
|
"ZootechLoadingReport",
|
||||||
|
"ZootechUnloadingReport",
|
||||||
|
"ZootechFeedAlert",
|
||||||
|
"ZootechOrgSettings",
|
||||||
|
"ZootechFeedQualitySettings",
|
||||||
|
]
|
||||||
@@ -0,0 +1,99 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from datetime import UTC, datetime
|
||||||
|
|
||||||
|
from app.core.config import settings
|
||||||
|
from app.core.security import hash_password
|
||||||
|
from app.modules.content.repository import create_page, get_page_by_slug
|
||||||
|
from app.modules.users.repository import create_user, get_user_by_email
|
||||||
|
|
||||||
|
|
||||||
|
def _ensure_user(email: str, password: str, role: str, is_superuser: bool, status: str):
|
||||||
|
from app.modules.users import repository
|
||||||
|
|
||||||
|
user = get_user_by_email(email)
|
||||||
|
if user:
|
||||||
|
changed = False
|
||||||
|
if user.role != role:
|
||||||
|
user.role = role
|
||||||
|
changed = True
|
||||||
|
if user.is_superuser != is_superuser:
|
||||||
|
user.is_superuser = is_superuser
|
||||||
|
changed = True
|
||||||
|
if user.status != status:
|
||||||
|
user.status = status
|
||||||
|
changed = True
|
||||||
|
if user.email_verified_at is None and status == "active":
|
||||||
|
user.email_verified_at = datetime.now(UTC)
|
||||||
|
changed = True
|
||||||
|
if changed:
|
||||||
|
repository.update_user(user)
|
||||||
|
return user
|
||||||
|
|
||||||
|
user = create_user(
|
||||||
|
email=email,
|
||||||
|
password_hash=hash_password(password),
|
||||||
|
role=role,
|
||||||
|
is_superuser=is_superuser,
|
||||||
|
status=status,
|
||||||
|
)
|
||||||
|
user.email_verified_at = datetime.now(UTC)
|
||||||
|
repository.update_user(user)
|
||||||
|
return user
|
||||||
|
|
||||||
|
|
||||||
|
def run_seed(include_demo_pages: bool = True) -> None:
|
||||||
|
admin = _ensure_user(
|
||||||
|
email="admin@compton.example",
|
||||||
|
password=settings.admin_initial_password,
|
||||||
|
role="admin",
|
||||||
|
is_superuser=True,
|
||||||
|
status="active",
|
||||||
|
)
|
||||||
|
if settings.seed_demo_users and settings.app_env.lower() != "production":
|
||||||
|
_ensure_user(
|
||||||
|
email="user@compton.example",
|
||||||
|
password=settings.demo_user_password,
|
||||||
|
role="user",
|
||||||
|
is_superuser=False,
|
||||||
|
status="active",
|
||||||
|
)
|
||||||
|
_ensure_user(
|
||||||
|
email="ops@compton.example",
|
||||||
|
password=settings.demo_ops_password,
|
||||||
|
role="admin",
|
||||||
|
is_superuser=False,
|
||||||
|
status="active",
|
||||||
|
)
|
||||||
|
|
||||||
|
if not include_demo_pages:
|
||||||
|
return
|
||||||
|
|
||||||
|
demo_pages = [
|
||||||
|
{
|
||||||
|
"slug": "about",
|
||||||
|
"title": "О бренде",
|
||||||
|
"body": "<p>Compton — платформа Organic Tech.</p>",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"slug": "privacy",
|
||||||
|
"title": "Политика конфиденциальности",
|
||||||
|
"body": "<p>Мы обрабатываем персональные данные согласно политике.</p>",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"slug": "terms",
|
||||||
|
"title": "Условия использования",
|
||||||
|
"body": "<p>Используя сервис, вы принимаете условия.</p>",
|
||||||
|
},
|
||||||
|
]
|
||||||
|
|
||||||
|
for page in demo_pages:
|
||||||
|
if get_page_by_slug(page["slug"], include_draft=True):
|
||||||
|
continue
|
||||||
|
create_page(
|
||||||
|
slug=page["slug"],
|
||||||
|
title=page["title"],
|
||||||
|
body=page["body"],
|
||||||
|
status="published",
|
||||||
|
author_id=admin.id,
|
||||||
|
)
|
||||||
@@ -0,0 +1,43 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from datetime import UTC, datetime, timedelta
|
||||||
|
|
||||||
|
from sqlalchemy import delete, or_
|
||||||
|
|
||||||
|
from app.core.database import session_scope
|
||||||
|
from app.modules.auth.models import EmailVerificationToken, PasswordResetToken, RefreshToken
|
||||||
|
|
||||||
|
|
||||||
|
def cleanup_expired_tokens(retention_days: int = 30) -> dict[str, int]:
|
||||||
|
now = datetime.now(UTC)
|
||||||
|
revoked_cutoff = now - timedelta(days=retention_days)
|
||||||
|
with session_scope() as db:
|
||||||
|
refresh_deleted = db.execute(
|
||||||
|
delete(RefreshToken).where(
|
||||||
|
or_(
|
||||||
|
RefreshToken.expires_at < now,
|
||||||
|
RefreshToken.revoked_at.is_not(None) & (RefreshToken.revoked_at < revoked_cutoff),
|
||||||
|
)
|
||||||
|
)
|
||||||
|
).rowcount or 0
|
||||||
|
reset_deleted = db.execute(
|
||||||
|
delete(PasswordResetToken).where(
|
||||||
|
or_(
|
||||||
|
PasswordResetToken.expires_at < now,
|
||||||
|
PasswordResetToken.used_at.is_not(None) & (PasswordResetToken.used_at < now),
|
||||||
|
)
|
||||||
|
)
|
||||||
|
).rowcount or 0
|
||||||
|
verify_deleted = db.execute(
|
||||||
|
delete(EmailVerificationToken).where(
|
||||||
|
or_(
|
||||||
|
EmailVerificationToken.expires_at < now,
|
||||||
|
EmailVerificationToken.used_at.is_not(None) & (EmailVerificationToken.used_at < now),
|
||||||
|
)
|
||||||
|
)
|
||||||
|
).rowcount or 0
|
||||||
|
return {
|
||||||
|
"refresh_tokens_deleted": int(refresh_deleted),
|
||||||
|
"password_reset_tokens_deleted": int(reset_deleted),
|
||||||
|
"email_verification_tokens_deleted": int(verify_deleted),
|
||||||
|
}
|
||||||
@@ -0,0 +1,121 @@
|
|||||||
|
from contextlib import asynccontextmanager
|
||||||
|
from urllib.parse import urlparse, parse_qs
|
||||||
|
|
||||||
|
from fastapi import FastAPI, HTTPException, Request
|
||||||
|
from fastapi.middleware.cors import CORSMiddleware
|
||||||
|
from fastapi.responses import JSONResponse
|
||||||
|
from fastapi.exception_handlers import http_exception_handler
|
||||||
|
|
||||||
|
from app.core.install_secrets import ensure_install_secrets
|
||||||
|
from app.core.config import settings
|
||||||
|
from app.core.jwt_denylist import ensure_jwt_revocation_backend
|
||||||
|
from app.core.app_settings import bootstrap_settings
|
||||||
|
from app.core.storage import ensure_bucket
|
||||||
|
from app.core import database as db_module
|
||||||
|
from app.db.token_cleanup import cleanup_expired_tokens
|
||||||
|
from app.db.base import Base
|
||||||
|
from app.db import models as _models # noqa: F401
|
||||||
|
from app.db.seed import run_seed
|
||||||
|
from app.modules.auth.router import router as auth_router
|
||||||
|
from app.modules.users.router import router as users_router
|
||||||
|
from app.modules.content.router import router as content_router
|
||||||
|
from app.modules.admin.router import router as admin_router
|
||||||
|
from app.modules.media.router import router as media_router
|
||||||
|
from app.modules.test.router import router as test_router
|
||||||
|
from app.modules.sync.router import router as sync_router, enterprise_router as sync_enterprise_router
|
||||||
|
from app.modules.zootech.router import router as zootech_router
|
||||||
|
from app.modules.zootech.wesp_compat import router as wesp_compat_router
|
||||||
|
from app.modules.zootech.wesp_compat_auth import router as wesp_compat_auth_router
|
||||||
|
from app.modules.zootech.wesp_compat_sync import router as wesp_compat_sync_router
|
||||||
|
from app.modules.zootech.wesp_compat_admin import router as wesp_compat_admin_router
|
||||||
|
from app.modules.zootech.wesp_compat_misc import router as wesp_compat_misc_router
|
||||||
|
from app.modules.zootech.wesp_compat_reports import router as wesp_compat_reports_router
|
||||||
|
from app.modules.zootech.wesp_compat_analytics import router as wesp_compat_analytics_router
|
||||||
|
from app.modules.enterprise.router import router as enterprise_router
|
||||||
|
|
||||||
|
|
||||||
|
def _assert_production_guards() -> None:
|
||||||
|
if settings.app_env.lower() != "production":
|
||||||
|
return
|
||||||
|
if settings.enable_test_routes:
|
||||||
|
raise RuntimeError("ENABLE_TEST_ROUTES must be false in production")
|
||||||
|
if settings.enable_docs:
|
||||||
|
raise RuntimeError("ENABLE_DOCS must be false in production")
|
||||||
|
if not settings.enable_rate_limit:
|
||||||
|
raise RuntimeError("ENABLE_RATE_LIMIT must be true in production")
|
||||||
|
if not settings.cookie_secure:
|
||||||
|
raise RuntimeError("COOKIE_SECURE must be true in production")
|
||||||
|
if settings.jwt_access_secret.startswith("change-me-"):
|
||||||
|
raise RuntimeError("JWT_ACCESS_SECRET placeholder is not allowed in production")
|
||||||
|
if settings.jwt_refresh_pepper.startswith("change-me-"):
|
||||||
|
raise RuntimeError("JWT_REFRESH_PEPPER placeholder is not allowed in production")
|
||||||
|
parsed = urlparse(settings.database_url)
|
||||||
|
if parsed.username == "user" and parsed.password == "pass":
|
||||||
|
raise RuntimeError("Default database credentials are not allowed in production")
|
||||||
|
if parsed.scheme.startswith("postgresql"):
|
||||||
|
sslmode = parse_qs(parsed.query).get("sslmode", [""])[0]
|
||||||
|
if sslmode != "require":
|
||||||
|
raise RuntimeError("DATABASE_URL must contain sslmode=require in production")
|
||||||
|
|
||||||
|
|
||||||
|
def create_app() -> FastAPI:
|
||||||
|
@asynccontextmanager
|
||||||
|
async def lifespan(_: FastAPI):
|
||||||
|
ensure_install_secrets()
|
||||||
|
bootstrap_settings()
|
||||||
|
ensure_jwt_revocation_backend()
|
||||||
|
_assert_production_guards()
|
||||||
|
if settings.enable_test_routes:
|
||||||
|
Base.metadata.create_all(db_module.engine)
|
||||||
|
run_seed()
|
||||||
|
cleanup_expired_tokens()
|
||||||
|
ensure_bucket()
|
||||||
|
yield
|
||||||
|
|
||||||
|
app = FastAPI(
|
||||||
|
title="Compton API",
|
||||||
|
version="1.0.0",
|
||||||
|
docs_url="/api/v1/docs" if settings.enable_docs else None,
|
||||||
|
openapi_url="/api/v1/openapi.json" if settings.enable_docs else None,
|
||||||
|
lifespan=lifespan,
|
||||||
|
)
|
||||||
|
app.add_middleware(
|
||||||
|
CORSMiddleware,
|
||||||
|
allow_origins=settings.cors_origins,
|
||||||
|
allow_credentials=True,
|
||||||
|
allow_methods=["GET", "POST", "PATCH", "DELETE", "OPTIONS"],
|
||||||
|
allow_headers=["Authorization", "Content-Type", "X-Request-ID"],
|
||||||
|
)
|
||||||
|
|
||||||
|
@app.exception_handler(HTTPException)
|
||||||
|
async def wesp_compat_http_exception_handler(request: Request, exc: HTTPException):
|
||||||
|
if isinstance(exc.detail, dict) and exc.detail.get("status") == "error":
|
||||||
|
return JSONResponse(status_code=exc.status_code, content=exc.detail)
|
||||||
|
return await http_exception_handler(request, exc)
|
||||||
|
|
||||||
|
@app.get("/api/v1/health")
|
||||||
|
async def health() -> dict[str, str]:
|
||||||
|
return {"status": "ok"}
|
||||||
|
|
||||||
|
app.include_router(auth_router, prefix="/api/v1/auth", tags=["auth"])
|
||||||
|
app.include_router(users_router, prefix="/api/v1/users", tags=["users"])
|
||||||
|
app.include_router(content_router, prefix="/api/v1/content", tags=["content"])
|
||||||
|
app.include_router(admin_router, prefix="/api/v1/admin", tags=["admin"])
|
||||||
|
app.include_router(media_router, prefix="/api/v1/media", tags=["media"])
|
||||||
|
app.include_router(sync_router, prefix="/api/v1/sync", tags=["sync"])
|
||||||
|
app.include_router(sync_enterprise_router, prefix="/api/v1/enterprise", tags=["enterprise"])
|
||||||
|
app.include_router(enterprise_router, prefix="/api/v1/enterprise", tags=["enterprise"])
|
||||||
|
app.include_router(zootech_router, prefix="/api/v1/zootech", tags=["zootech"])
|
||||||
|
app.include_router(wesp_compat_router, prefix="/api", tags=["zootech-wesp-compat"])
|
||||||
|
app.include_router(wesp_compat_auth_router, prefix="/api", tags=["zootech-wesp-compat"])
|
||||||
|
app.include_router(wesp_compat_sync_router, prefix="/api", tags=["zootech-wesp-compat"])
|
||||||
|
app.include_router(wesp_compat_admin_router, prefix="/api", tags=["zootech-wesp-compat"])
|
||||||
|
app.include_router(wesp_compat_misc_router, prefix="/api", tags=["zootech-wesp-compat"])
|
||||||
|
app.include_router(wesp_compat_reports_router, prefix="/api", tags=["zootech-wesp-compat"])
|
||||||
|
app.include_router(wesp_compat_analytics_router, prefix="/api", tags=["zootech-wesp-compat"])
|
||||||
|
if settings.enable_test_routes:
|
||||||
|
app.include_router(test_router, prefix="/api/v1/test", tags=["test"])
|
||||||
|
return app
|
||||||
|
|
||||||
|
|
||||||
|
app = create_app()
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
"""Application modules."""
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
"""Admin module."""
|
||||||
@@ -0,0 +1,171 @@
|
|||||||
|
from fastapi import APIRouter, Depends, HTTPException, Query
|
||||||
|
|
||||||
|
from app.core.dependencies import require_admin, require_superuser
|
||||||
|
from app.modules.admin.schemas import (
|
||||||
|
AdminSettingsPatchIn,
|
||||||
|
AdminRevealSecretIn,
|
||||||
|
AdminUiActivityIn,
|
||||||
|
AdminUserCreateIn,
|
||||||
|
AdminUserPasswordPatchIn,
|
||||||
|
AdminUserPatchIn,
|
||||||
|
)
|
||||||
|
from app.modules.admin.service import (
|
||||||
|
create_admin_user,
|
||||||
|
delete_admin_user,
|
||||||
|
get_admin_settings,
|
||||||
|
get_admin_summary,
|
||||||
|
get_install_secrets,
|
||||||
|
get_diagnostics_report,
|
||||||
|
get_server_log_tail,
|
||||||
|
list_activity_feed,
|
||||||
|
list_admin_users,
|
||||||
|
patch_admin_settings,
|
||||||
|
patch_user,
|
||||||
|
record_ui_activity,
|
||||||
|
reveal_secret,
|
||||||
|
reset_user_password,
|
||||||
|
)
|
||||||
|
from app.modules.analytics.service import get_admin_dashboard_metrics
|
||||||
|
|
||||||
|
router = APIRouter()
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/users")
|
||||||
|
async def list_users_route(
|
||||||
|
page: int = Query(default=1, ge=1),
|
||||||
|
limit: int = Query(default=20, ge=1, le=100),
|
||||||
|
_admin=Depends(require_admin),
|
||||||
|
):
|
||||||
|
return list_admin_users(page, limit)
|
||||||
|
|
||||||
|
|
||||||
|
@router.patch("/users/{user_id}")
|
||||||
|
async def patch_user_route(
|
||||||
|
user_id: str,
|
||||||
|
payload: AdminUserPatchIn,
|
||||||
|
admin=Depends(require_admin),
|
||||||
|
):
|
||||||
|
if payload.is_superuser is not None and not admin.is_superuser:
|
||||||
|
raise HTTPException(status_code=403, detail="SUPERUSER_ONLY")
|
||||||
|
try:
|
||||||
|
return patch_user(admin, user_id, payload.role, payload.status, payload.is_superuser)
|
||||||
|
except ValueError as exc:
|
||||||
|
raise HTTPException(status_code=400, detail=str(exc))
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/users")
|
||||||
|
async def create_user_route(payload: AdminUserCreateIn, admin=Depends(require_superuser)):
|
||||||
|
try:
|
||||||
|
return create_admin_user(
|
||||||
|
admin,
|
||||||
|
email=payload.email,
|
||||||
|
password=payload.password,
|
||||||
|
role=payload.role,
|
||||||
|
is_superuser=payload.is_superuser,
|
||||||
|
status=payload.status,
|
||||||
|
)
|
||||||
|
except ValueError as exc:
|
||||||
|
raise HTTPException(status_code=400, detail=str(exc))
|
||||||
|
|
||||||
|
|
||||||
|
@router.patch("/users/{user_id}/password")
|
||||||
|
async def reset_user_password_route(
|
||||||
|
user_id: str, payload: AdminUserPasswordPatchIn, admin=Depends(require_superuser)
|
||||||
|
):
|
||||||
|
try:
|
||||||
|
return reset_user_password(admin, user_id, payload.password)
|
||||||
|
except ValueError as exc:
|
||||||
|
raise HTTPException(status_code=400, detail=str(exc))
|
||||||
|
|
||||||
|
|
||||||
|
@router.delete("/users/{user_id}")
|
||||||
|
async def delete_user_route(user_id: str, admin=Depends(require_superuser)):
|
||||||
|
try:
|
||||||
|
return delete_admin_user(admin, user_id)
|
||||||
|
except ValueError as exc:
|
||||||
|
raise HTTPException(status_code=400, detail=str(exc))
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/summary")
|
||||||
|
async def summary_route(_admin=Depends(require_admin)):
|
||||||
|
return get_admin_summary()
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/stats")
|
||||||
|
async def stats_route(_admin=Depends(require_admin)):
|
||||||
|
return get_admin_dashboard_metrics()
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/settings")
|
||||||
|
async def admin_settings_route(_admin=Depends(require_superuser)):
|
||||||
|
return get_admin_settings()
|
||||||
|
|
||||||
|
|
||||||
|
@router.patch("/settings")
|
||||||
|
async def admin_settings_patch_route(payload: AdminSettingsPatchIn, admin=Depends(require_superuser)):
|
||||||
|
return patch_admin_settings(admin, payload.values)
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/diagnostics/report")
|
||||||
|
async def diagnostics_route(_admin=Depends(require_superuser)):
|
||||||
|
return get_diagnostics_report()
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/activity-feed")
|
||||||
|
async def activity_feed_route(limit: int = Query(default=200, ge=1, le=1000), _admin=Depends(require_admin)):
|
||||||
|
return list_activity_feed(limit=limit)
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/ui-activity")
|
||||||
|
async def ui_activity_route(payload: AdminUiActivityIn, admin=Depends(require_admin)):
|
||||||
|
return record_ui_activity(admin, payload.event, payload.meta)
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/server-log")
|
||||||
|
async def server_log_route(
|
||||||
|
lines: int = Query(default=200, ge=1, le=1000),
|
||||||
|
_admin=Depends(require_superuser),
|
||||||
|
):
|
||||||
|
return get_server_log_tail(lines=lines)
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/secrets")
|
||||||
|
async def install_secrets_route(_admin=Depends(require_superuser)):
|
||||||
|
return get_install_secrets()
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/secrets/reveal")
|
||||||
|
async def reveal_secret_route(payload: AdminRevealSecretIn, admin=Depends(require_superuser)):
|
||||||
|
try:
|
||||||
|
return reveal_secret(admin, payload.key)
|
||||||
|
except ValueError as exc:
|
||||||
|
raise HTTPException(status_code=400, detail=str(exc))
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/enterprises")
|
||||||
|
async def admin_enterprises(_admin=Depends(require_superuser)):
|
||||||
|
from sqlalchemy import select
|
||||||
|
|
||||||
|
from app.core.database import session_scope
|
||||||
|
from app.modules.sync.models import Enterprise
|
||||||
|
|
||||||
|
with session_scope() as db:
|
||||||
|
rows = list(db.scalars(select(Enterprise).order_by(Enterprise.name)))
|
||||||
|
return [{"id": r.id, "name": r.name, "slug": r.slug, "status": r.status} for r in rows]
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/sync-metrics")
|
||||||
|
async def admin_sync_metrics(_admin=Depends(require_superuser)):
|
||||||
|
from sqlalchemy import select
|
||||||
|
|
||||||
|
from app.core.database import session_scope
|
||||||
|
from app.modules.sync.models import Enterprise
|
||||||
|
from app.modules.sync.service import get_sync_metrics
|
||||||
|
|
||||||
|
with session_scope() as db:
|
||||||
|
enterprises = list(db.scalars(select(Enterprise).order_by(Enterprise.name)))
|
||||||
|
rows = [(e.id, e.name, e.slug) for e in enterprises]
|
||||||
|
return [
|
||||||
|
{"enterprise_id": enterprise_id, "name": name, "slug": slug, **get_sync_metrics(enterprise_id)}
|
||||||
|
for enterprise_id, name, slug in rows
|
||||||
|
]
|
||||||
@@ -0,0 +1,46 @@
|
|||||||
|
from typing import Literal
|
||||||
|
|
||||||
|
from pydantic import BaseModel, EmailStr, Field, field_validator
|
||||||
|
|
||||||
|
from app.core.password_policy import validate_password_strength
|
||||||
|
|
||||||
|
|
||||||
|
class AdminUserPatchIn(BaseModel):
|
||||||
|
role: Literal["user", "admin"] | None = None
|
||||||
|
status: Literal["pending", "active", "blocked"] | None = None
|
||||||
|
is_superuser: bool | None = None
|
||||||
|
|
||||||
|
|
||||||
|
class AdminUserCreateIn(BaseModel):
|
||||||
|
email: EmailStr
|
||||||
|
password: str = Field(min_length=8)
|
||||||
|
role: Literal["user", "admin"] = "user"
|
||||||
|
is_superuser: bool = False
|
||||||
|
status: Literal["pending", "active", "blocked"] = "active"
|
||||||
|
|
||||||
|
@field_validator("password")
|
||||||
|
@classmethod
|
||||||
|
def password_policy(cls, value: str) -> str:
|
||||||
|
return validate_password_strength(value)
|
||||||
|
|
||||||
|
|
||||||
|
class AdminUserPasswordPatchIn(BaseModel):
|
||||||
|
password: str = Field(min_length=8)
|
||||||
|
|
||||||
|
@field_validator("password")
|
||||||
|
@classmethod
|
||||||
|
def password_policy(cls, value: str) -> str:
|
||||||
|
return validate_password_strength(value)
|
||||||
|
|
||||||
|
|
||||||
|
class AdminSettingsPatchIn(BaseModel):
|
||||||
|
values: dict
|
||||||
|
|
||||||
|
|
||||||
|
class AdminUiActivityIn(BaseModel):
|
||||||
|
event: str
|
||||||
|
meta: dict | None = None
|
||||||
|
|
||||||
|
|
||||||
|
class AdminRevealSecretIn(BaseModel):
|
||||||
|
key: Literal["database_password", "jwt_access_secret", "jwt_refresh_pepper", "s3_secret_key"]
|
||||||
@@ -0,0 +1,94 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from urllib.parse import parse_qs, urlparse
|
||||||
|
|
||||||
|
from app.core.config import settings
|
||||||
|
from app.core.install_secrets import read_install_secrets
|
||||||
|
from app.core.password_denylist import load_denylist
|
||||||
|
from app.core.redis import get_redis_client
|
||||||
|
from app.core.database import session_scope
|
||||||
|
from app.modules.auth.models import RefreshToken
|
||||||
|
from app.modules.users import repository
|
||||||
|
from sqlalchemy import func, select
|
||||||
|
|
||||||
|
|
||||||
|
def build_security_diagnostics_report() -> dict:
|
||||||
|
redis_client = get_redis_client()
|
||||||
|
db = urlparse(settings.database_url)
|
||||||
|
sslmode = parse_qs(db.query).get("sslmode", [""])[0]
|
||||||
|
install_secrets = read_install_secrets()
|
||||||
|
with session_scope() as session:
|
||||||
|
refresh_count = session.scalar(select(func.count()).select_from(RefreshToken)) or 0
|
||||||
|
checks = [
|
||||||
|
{
|
||||||
|
"id": "jwt_access_secret",
|
||||||
|
"status": "ok" if len(settings.jwt_access_secret) >= 32 else "fail",
|
||||||
|
"message": "JWT access secret configured",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "jwt_refresh_pepper",
|
||||||
|
"status": "ok" if len(settings.jwt_refresh_pepper) >= 32 else "fail",
|
||||||
|
"message": "JWT refresh pepper configured",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "cookie_secure",
|
||||||
|
"status": "ok" if settings.cookie_secure else "warn",
|
||||||
|
"message": "Refresh cookie Secure flag is enabled",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "rate_limit_backend",
|
||||||
|
"status": "ok" if redis_client is not None else "warn",
|
||||||
|
"message": "Rate limiter uses Redis backend",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "password_denylist",
|
||||||
|
"status": "ok" if len(load_denylist()) >= 1000 else "warn",
|
||||||
|
"message": "Password denylist has strong coverage",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "cors_wildcard",
|
||||||
|
"status": "ok" if "*" not in settings.cors_origins else "fail",
|
||||||
|
"message": "CORS does not include wildcard origin",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "superuser_exists",
|
||||||
|
"status": "ok" if repository.count_superusers() > 0 else "fail",
|
||||||
|
"message": "At least one superuser exists",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "docs_production",
|
||||||
|
"status": "warn" if settings.enable_docs else "ok",
|
||||||
|
"message": "API docs are disabled in production",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "db_default_credentials",
|
||||||
|
"status": "fail" if db.username == "user" and db.password == "pass" else "ok",
|
||||||
|
"message": "Database does not use default credentials",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "db_localhost_exposed",
|
||||||
|
"status": "fail" if settings.app_env.lower() == "production" and db.hostname in {"localhost", "127.0.0.1"} else "ok",
|
||||||
|
"message": "Production database host is not localhost",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "db_ssl_mode",
|
||||||
|
"status": "ok" if settings.app_env.lower() != "production" or sslmode == "require" else "warn",
|
||||||
|
"message": "Production database URL uses sslmode=require",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "db_refresh_token_table_size",
|
||||||
|
"status": "warn" if refresh_count > 10000 else "ok",
|
||||||
|
"message": "Refresh token table size is under threshold",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "install_secrets_initialized",
|
||||||
|
"status": "ok" if bool(install_secrets) else "fail",
|
||||||
|
"message": "Install secrets file is initialized",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "install_secrets_locked",
|
||||||
|
"status": "ok" if install_secrets.get("SECRETS_LOCKED") == "true" else "warn",
|
||||||
|
"message": "Install secrets are locked after bootstrap",
|
||||||
|
},
|
||||||
|
]
|
||||||
|
return {"checks": checks}
|
||||||
@@ -0,0 +1,209 @@
|
|||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
from app.core.app_settings import apply_settings_to_app, get_settings_payload, write_settings
|
||||||
|
from app.core.audit_log import read_audit_events, write_audit_event
|
||||||
|
from app.core.config import settings
|
||||||
|
from app.core.install_secrets import install_secrets_payload, reveal_install_secret
|
||||||
|
from app.core.jwt_denylist import bump_auth_epoch
|
||||||
|
from app.core.security import hash_password
|
||||||
|
from app.modules.admin.security_diagnostics import build_security_diagnostics_report
|
||||||
|
from app.modules.auth.service import revoke_user_refresh_family
|
||||||
|
from app.modules.users import repository
|
||||||
|
|
||||||
|
|
||||||
|
def list_admin_users(page: int, limit: int) -> dict:
|
||||||
|
users, total = repository.list_users(page, limit)
|
||||||
|
return {
|
||||||
|
"data": [
|
||||||
|
{
|
||||||
|
"id": user.id,
|
||||||
|
"email": user.email,
|
||||||
|
"role": user.role,
|
||||||
|
"is_superuser": user.is_superuser,
|
||||||
|
"status": user.status,
|
||||||
|
}
|
||||||
|
for user in users
|
||||||
|
],
|
||||||
|
"meta": {"total": total, "page": page, "limit": limit},
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _ensure_last_superuser_protection(target, role: str | None, is_superuser: bool | None) -> None:
|
||||||
|
super_count = repository.count_superusers()
|
||||||
|
role_becomes_admin = target.role if role is None else role
|
||||||
|
super_becomes_true = target.is_superuser if is_superuser is None else is_superuser
|
||||||
|
is_losing_super = target.role == "admin" and target.is_superuser and (
|
||||||
|
role_becomes_admin != "admin" or not super_becomes_true
|
||||||
|
)
|
||||||
|
if is_losing_super and super_count <= 1:
|
||||||
|
raise ValueError("LAST_SUPERUSER_PROTECTED")
|
||||||
|
|
||||||
|
|
||||||
|
def patch_user(
|
||||||
|
admin_user, target_user_id: str, role: str | None, status: str | None, is_superuser: bool | None = None
|
||||||
|
) -> dict:
|
||||||
|
target = repository.get_user_by_id(target_user_id)
|
||||||
|
if not target:
|
||||||
|
raise ValueError("USER_NOT_FOUND")
|
||||||
|
if admin_user.id == target_user_id and role and role != "admin":
|
||||||
|
raise ValueError("SELF_DEMOTION_FORBIDDEN")
|
||||||
|
if admin_user.id == target_user_id and is_superuser is False:
|
||||||
|
raise ValueError("SELF_DEMOTION_FORBIDDEN")
|
||||||
|
if admin_user.id == target_user_id and status == "blocked":
|
||||||
|
raise ValueError("SELF_BLOCK_FORBIDDEN")
|
||||||
|
|
||||||
|
admin_count = repository.count_admins()
|
||||||
|
if target.role == "admin" and role and role != "admin" and admin_count <= 1:
|
||||||
|
raise ValueError("LAST_ADMIN_PROTECTED")
|
||||||
|
_ensure_last_superuser_protection(target, role, is_superuser)
|
||||||
|
|
||||||
|
if role:
|
||||||
|
target.role = role
|
||||||
|
if is_superuser is not None:
|
||||||
|
target.is_superuser = bool(is_superuser)
|
||||||
|
if status:
|
||||||
|
target.status = status
|
||||||
|
if status == "blocked":
|
||||||
|
bump_auth_epoch(target.id)
|
||||||
|
revoke_user_refresh_family(target.id)
|
||||||
|
repository.update_user(target)
|
||||||
|
write_audit_event(
|
||||||
|
action="admin.user.patch",
|
||||||
|
actor_user_id=admin_user.id,
|
||||||
|
actor_email=admin_user.email,
|
||||||
|
details={"target_user_id": target.id},
|
||||||
|
)
|
||||||
|
return {
|
||||||
|
"id": target.id,
|
||||||
|
"email": target.email,
|
||||||
|
"role": target.role,
|
||||||
|
"is_superuser": target.is_superuser,
|
||||||
|
"status": target.status,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def create_admin_user(admin_user, email: str, password: str, role: str, is_superuser: bool, status: str) -> dict:
|
||||||
|
if repository.get_user_by_email(email):
|
||||||
|
raise ValueError("USER_EXISTS")
|
||||||
|
user = repository.create_user(
|
||||||
|
email=email,
|
||||||
|
password_hash=hash_password(password),
|
||||||
|
role=role,
|
||||||
|
is_superuser=is_superuser,
|
||||||
|
status=status,
|
||||||
|
)
|
||||||
|
write_audit_event(
|
||||||
|
action="admin.user.create",
|
||||||
|
actor_user_id=admin_user.id,
|
||||||
|
actor_email=admin_user.email,
|
||||||
|
details={"target_user_id": user.id},
|
||||||
|
)
|
||||||
|
return {
|
||||||
|
"id": user.id,
|
||||||
|
"email": user.email,
|
||||||
|
"role": user.role,
|
||||||
|
"is_superuser": user.is_superuser,
|
||||||
|
"status": user.status,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def reset_user_password(admin_user, target_user_id: str, password: str) -> dict:
|
||||||
|
target = repository.get_user_by_id(target_user_id)
|
||||||
|
if not target:
|
||||||
|
raise ValueError("USER_NOT_FOUND")
|
||||||
|
target.password_hash = hash_password(password)
|
||||||
|
repository.update_user(target)
|
||||||
|
bump_auth_epoch(target.id)
|
||||||
|
revoke_user_refresh_family(target.id)
|
||||||
|
write_audit_event(
|
||||||
|
action="admin.user.reset_password",
|
||||||
|
actor_user_id=admin_user.id,
|
||||||
|
actor_email=admin_user.email,
|
||||||
|
details={"target_user_id": target.id},
|
||||||
|
)
|
||||||
|
return {"status": "ok"}
|
||||||
|
|
||||||
|
|
||||||
|
def delete_admin_user(admin_user, target_user_id: str) -> dict:
|
||||||
|
target = repository.get_user_by_id(target_user_id)
|
||||||
|
if not target:
|
||||||
|
raise ValueError("USER_NOT_FOUND")
|
||||||
|
if admin_user.id == target.id:
|
||||||
|
raise ValueError("SELF_DELETE_FORBIDDEN")
|
||||||
|
_ensure_last_superuser_protection(target, "user", False)
|
||||||
|
if target.role == "admin" and repository.count_admins() <= 1:
|
||||||
|
raise ValueError("LAST_ADMIN_PROTECTED")
|
||||||
|
repository.delete_user(target.id)
|
||||||
|
revoke_user_refresh_family(target.id)
|
||||||
|
write_audit_event(
|
||||||
|
action="admin.user.delete",
|
||||||
|
actor_user_id=admin_user.id,
|
||||||
|
actor_email=admin_user.email,
|
||||||
|
details={"target_user_id": target.id},
|
||||||
|
)
|
||||||
|
return {"status": "deleted"}
|
||||||
|
|
||||||
|
|
||||||
|
def get_admin_summary() -> dict:
|
||||||
|
return {
|
||||||
|
"users_count": repository.count_users(),
|
||||||
|
"registrations_day": repository.count_users_registered_today(),
|
||||||
|
"admins_count": repository.count_admins(),
|
||||||
|
"superusers_count": repository.count_superusers(),
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def get_admin_settings() -> dict:
|
||||||
|
return get_settings_payload()
|
||||||
|
|
||||||
|
|
||||||
|
def patch_admin_settings(admin_user, values: dict) -> dict:
|
||||||
|
merged = write_settings(values)
|
||||||
|
apply_settings_to_app(merged)
|
||||||
|
write_audit_event(
|
||||||
|
action="admin.settings.patch",
|
||||||
|
actor_user_id=admin_user.id,
|
||||||
|
actor_email=admin_user.email,
|
||||||
|
details={"updated_keys": sorted(values.keys())},
|
||||||
|
)
|
||||||
|
return get_settings_payload()
|
||||||
|
|
||||||
|
|
||||||
|
def get_diagnostics_report() -> dict:
|
||||||
|
return build_security_diagnostics_report()
|
||||||
|
|
||||||
|
|
||||||
|
def list_activity_feed(limit: int = 200) -> dict:
|
||||||
|
return {"events": read_audit_events(limit=limit)}
|
||||||
|
|
||||||
|
|
||||||
|
def record_ui_activity(admin_user, event: str, meta: dict | None) -> dict:
|
||||||
|
write_audit_event(
|
||||||
|
action=f"ui.{event}",
|
||||||
|
actor_user_id=admin_user.id,
|
||||||
|
actor_email=admin_user.email,
|
||||||
|
details=meta or {},
|
||||||
|
)
|
||||||
|
return {"status": "ok"}
|
||||||
|
|
||||||
|
|
||||||
|
def get_server_log_tail(lines: int = 200) -> dict:
|
||||||
|
path = Path(settings.server_log_path)
|
||||||
|
if not path.exists():
|
||||||
|
return {"lines": []}
|
||||||
|
return {"lines": path.read_text(encoding="utf-8", errors="ignore").splitlines()[-lines:]}
|
||||||
|
|
||||||
|
|
||||||
|
def get_install_secrets() -> dict:
|
||||||
|
return install_secrets_payload()
|
||||||
|
|
||||||
|
|
||||||
|
def reveal_secret(admin_user, key: str) -> dict:
|
||||||
|
value = reveal_install_secret(key)
|
||||||
|
write_audit_event(
|
||||||
|
action="admin.secrets.reveal",
|
||||||
|
actor_user_id=admin_user.id,
|
||||||
|
actor_email=admin_user.email,
|
||||||
|
details={"key": key},
|
||||||
|
)
|
||||||
|
return {"key": key, "value": value}
|
||||||
@@ -0,0 +1,8 @@
|
|||||||
|
from app.modules.users import repository
|
||||||
|
|
||||||
|
|
||||||
|
def get_admin_dashboard_metrics() -> dict:
|
||||||
|
return {
|
||||||
|
"users_count": repository.count_users(),
|
||||||
|
"registrations_day": repository.count_users_registered_today(),
|
||||||
|
}
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
"""Auth module."""
|
||||||
@@ -0,0 +1,43 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from datetime import UTC, datetime
|
||||||
|
from uuid import uuid4
|
||||||
|
|
||||||
|
from sqlalchemy import DateTime, ForeignKey, String
|
||||||
|
from sqlalchemy.orm import Mapped, mapped_column
|
||||||
|
|
||||||
|
from app.db.base import Base
|
||||||
|
|
||||||
|
|
||||||
|
class RefreshToken(Base):
|
||||||
|
__tablename__ = "refresh_tokens"
|
||||||
|
|
||||||
|
id: Mapped[str] = mapped_column(String(36), primary_key=True, default=lambda: str(uuid4()))
|
||||||
|
user_id: Mapped[str] = mapped_column(String(36), ForeignKey("users.id", ondelete="CASCADE"), index=True)
|
||||||
|
token_hash: Mapped[str] = mapped_column(String(64), unique=True, nullable=False)
|
||||||
|
family_id: Mapped[str] = mapped_column(String(36), nullable=False, index=True)
|
||||||
|
expires_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), nullable=False)
|
||||||
|
revoked_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True)
|
||||||
|
created_at: Mapped[datetime] = mapped_column(
|
||||||
|
DateTime(timezone=True), nullable=False, default=lambda: datetime.now(UTC)
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class PasswordResetToken(Base):
|
||||||
|
__tablename__ = "password_reset_tokens"
|
||||||
|
|
||||||
|
id: Mapped[str] = mapped_column(String(36), primary_key=True, default=lambda: str(uuid4()))
|
||||||
|
user_id: Mapped[str] = mapped_column(String(36), ForeignKey("users.id", ondelete="CASCADE"), index=True)
|
||||||
|
token_hash: Mapped[str] = mapped_column(String(64), unique=True, nullable=False)
|
||||||
|
expires_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), nullable=False)
|
||||||
|
used_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True)
|
||||||
|
|
||||||
|
|
||||||
|
class EmailVerificationToken(Base):
|
||||||
|
__tablename__ = "email_verification_tokens"
|
||||||
|
|
||||||
|
id: Mapped[str] = mapped_column(String(36), primary_key=True, default=lambda: str(uuid4()))
|
||||||
|
user_id: Mapped[str] = mapped_column(String(36), ForeignKey("users.id", ondelete="CASCADE"), index=True)
|
||||||
|
token_hash: Mapped[str] = mapped_column(String(64), unique=True, nullable=False)
|
||||||
|
expires_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), nullable=False)
|
||||||
|
used_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True)
|
||||||
@@ -0,0 +1,132 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from datetime import UTC, datetime
|
||||||
|
|
||||||
|
from sqlalchemy import delete, select
|
||||||
|
|
||||||
|
from app.core.database import session_scope
|
||||||
|
from app.modules.auth.models import EmailVerificationToken, PasswordResetToken, RefreshToken
|
||||||
|
|
||||||
|
|
||||||
|
def _detach(db, instance):
|
||||||
|
db.refresh(instance)
|
||||||
|
db.expunge(instance)
|
||||||
|
return instance
|
||||||
|
|
||||||
|
|
||||||
|
def create_refresh_token(
|
||||||
|
user_id: str,
|
||||||
|
token_hash: str,
|
||||||
|
family_id: str,
|
||||||
|
expires_at: datetime,
|
||||||
|
) -> RefreshToken:
|
||||||
|
with session_scope() as db:
|
||||||
|
token = RefreshToken(
|
||||||
|
user_id=user_id,
|
||||||
|
token_hash=token_hash,
|
||||||
|
family_id=family_id,
|
||||||
|
expires_at=expires_at,
|
||||||
|
)
|
||||||
|
db.add(token)
|
||||||
|
db.flush()
|
||||||
|
return _detach(db, token)
|
||||||
|
|
||||||
|
|
||||||
|
def get_refresh_token(token_hash: str) -> RefreshToken | None:
|
||||||
|
with session_scope() as db:
|
||||||
|
token = db.scalar(select(RefreshToken).where(RefreshToken.token_hash == token_hash))
|
||||||
|
if not token:
|
||||||
|
return None
|
||||||
|
return _detach(db, token)
|
||||||
|
|
||||||
|
|
||||||
|
def revoke_refresh_token(token_hash: str) -> RefreshToken | None:
|
||||||
|
with session_scope() as db:
|
||||||
|
token = db.scalar(select(RefreshToken).where(RefreshToken.token_hash == token_hash))
|
||||||
|
if not token:
|
||||||
|
return None
|
||||||
|
token.revoked_at = datetime.now(UTC)
|
||||||
|
db.flush()
|
||||||
|
return _detach(db, token)
|
||||||
|
|
||||||
|
|
||||||
|
def revoke_user_families(user_id: str) -> None:
|
||||||
|
with session_scope() as db:
|
||||||
|
tokens = db.scalars(
|
||||||
|
select(RefreshToken).where(
|
||||||
|
RefreshToken.user_id == user_id,
|
||||||
|
RefreshToken.revoked_at.is_(None),
|
||||||
|
)
|
||||||
|
).all()
|
||||||
|
now = datetime.now(UTC)
|
||||||
|
for token in tokens:
|
||||||
|
token.revoked_at = now
|
||||||
|
|
||||||
|
|
||||||
|
def revoke_family_tokens(family_id: str) -> None:
|
||||||
|
with session_scope() as db:
|
||||||
|
tokens = db.scalars(
|
||||||
|
select(RefreshToken).where(
|
||||||
|
RefreshToken.family_id == family_id,
|
||||||
|
RefreshToken.revoked_at.is_(None),
|
||||||
|
)
|
||||||
|
).all()
|
||||||
|
now = datetime.now(UTC)
|
||||||
|
for token in tokens:
|
||||||
|
token.revoked_at = now
|
||||||
|
|
||||||
|
|
||||||
|
def create_email_verification_token(user_id: str, token_hash: str, expires_at: datetime) -> None:
|
||||||
|
with session_scope() as db:
|
||||||
|
db.execute(
|
||||||
|
delete(EmailVerificationToken).where(
|
||||||
|
EmailVerificationToken.user_id == user_id,
|
||||||
|
EmailVerificationToken.used_at.is_(None),
|
||||||
|
)
|
||||||
|
)
|
||||||
|
db.add(EmailVerificationToken(user_id=user_id, token_hash=token_hash, expires_at=expires_at))
|
||||||
|
|
||||||
|
|
||||||
|
def get_email_verification_token(token_hash: str) -> EmailVerificationToken | None:
|
||||||
|
with session_scope() as db:
|
||||||
|
token = db.scalar(
|
||||||
|
select(EmailVerificationToken).where(EmailVerificationToken.token_hash == token_hash)
|
||||||
|
)
|
||||||
|
if not token:
|
||||||
|
return None
|
||||||
|
return _detach(db, token)
|
||||||
|
|
||||||
|
|
||||||
|
def mark_email_verification_token_used(token_hash: str) -> None:
|
||||||
|
with session_scope() as db:
|
||||||
|
token = db.scalar(
|
||||||
|
select(EmailVerificationToken).where(EmailVerificationToken.token_hash == token_hash)
|
||||||
|
)
|
||||||
|
if token:
|
||||||
|
token.used_at = datetime.now(UTC)
|
||||||
|
|
||||||
|
|
||||||
|
def create_password_reset_token(user_id: str, token_hash: str, expires_at: datetime) -> None:
|
||||||
|
with session_scope() as db:
|
||||||
|
db.execute(
|
||||||
|
delete(PasswordResetToken).where(
|
||||||
|
PasswordResetToken.user_id == user_id,
|
||||||
|
PasswordResetToken.used_at.is_(None),
|
||||||
|
)
|
||||||
|
)
|
||||||
|
db.add(PasswordResetToken(user_id=user_id, token_hash=token_hash, expires_at=expires_at))
|
||||||
|
|
||||||
|
|
||||||
|
def get_password_reset_token(token_hash: str) -> PasswordResetToken | None:
|
||||||
|
with session_scope() as db:
|
||||||
|
token = db.scalar(select(PasswordResetToken).where(PasswordResetToken.token_hash == token_hash))
|
||||||
|
if not token:
|
||||||
|
return None
|
||||||
|
return _detach(db, token)
|
||||||
|
|
||||||
|
|
||||||
|
def mark_password_reset_token_used(token_hash: str) -> None:
|
||||||
|
with session_scope() as db:
|
||||||
|
token = db.scalar(select(PasswordResetToken).where(PasswordResetToken.token_hash == token_hash))
|
||||||
|
if token:
|
||||||
|
token.used_at = datetime.now(UTC)
|
||||||
@@ -0,0 +1,217 @@
|
|||||||
|
from fastapi import APIRouter, Depends, HTTPException, Request, Response, status
|
||||||
|
from fastapi.security import HTTPAuthorizationCredentials, HTTPBearer
|
||||||
|
|
||||||
|
from app.core.config import settings
|
||||||
|
from app.core.datetime_utils import ensure_utc, utc_now
|
||||||
|
from app.core.redis import check_rate_limit, client_ip
|
||||||
|
from app.modules.auth.schemas import (
|
||||||
|
ForgotPasswordIn,
|
||||||
|
LoginIn,
|
||||||
|
LoginOut,
|
||||||
|
RegisterIn,
|
||||||
|
ResendVerificationIn,
|
||||||
|
ResetPasswordIn,
|
||||||
|
VerifyEmailIn,
|
||||||
|
)
|
||||||
|
from app.modules.auth.service import (
|
||||||
|
forgot_password,
|
||||||
|
login,
|
||||||
|
logout,
|
||||||
|
refresh,
|
||||||
|
register,
|
||||||
|
resend_verification,
|
||||||
|
reset_password,
|
||||||
|
verify_email_token,
|
||||||
|
)
|
||||||
|
from app.modules.users import repository
|
||||||
|
|
||||||
|
router = APIRouter()
|
||||||
|
optional_bearer = HTTPBearer(auto_error=False)
|
||||||
|
|
||||||
|
|
||||||
|
def _allowed_origins() -> set[str]:
|
||||||
|
origins: set[str] = set()
|
||||||
|
for raw in (settings.frontend_url, settings.public_base_url, *settings.cors_origins):
|
||||||
|
if not raw:
|
||||||
|
continue
|
||||||
|
normalized = raw.rstrip("/")
|
||||||
|
origins.add(normalized)
|
||||||
|
if "://localhost" in normalized:
|
||||||
|
origins.add(normalized.replace("://localhost", "://127.0.0.1"))
|
||||||
|
if "://127.0.0.1" in normalized:
|
||||||
|
origins.add(normalized.replace("://127.0.0.1", "://localhost"))
|
||||||
|
return origins
|
||||||
|
|
||||||
|
|
||||||
|
def _enforce_origin(request: Request, *, require_header: bool = False) -> None:
|
||||||
|
allowed = _allowed_origins()
|
||||||
|
origin = (request.headers.get("Origin") or "").rstrip("/")
|
||||||
|
referer = request.headers.get("Referer") or ""
|
||||||
|
if require_header and not origin and not referer:
|
||||||
|
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="INVALID_ORIGIN")
|
||||||
|
if origin and origin not in allowed:
|
||||||
|
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="INVALID_ORIGIN")
|
||||||
|
if not origin and referer:
|
||||||
|
if not any(referer.startswith(base) for base in allowed):
|
||||||
|
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="INVALID_ORIGIN")
|
||||||
|
|
||||||
|
|
||||||
|
def _set_refresh_cookie(response: Response, refresh_token: str) -> None:
|
||||||
|
response.set_cookie(
|
||||||
|
key="refresh_token",
|
||||||
|
value=refresh_token,
|
||||||
|
httponly=True,
|
||||||
|
secure=settings.cookie_secure,
|
||||||
|
samesite="lax",
|
||||||
|
path="/api/v1/auth",
|
||||||
|
max_age=settings.jwt_refresh_ttl_days * 24 * 60 * 60,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/register")
|
||||||
|
async def register_route(payload: RegisterIn, request: Request):
|
||||||
|
_enforce_origin(request)
|
||||||
|
check_rate_limit(f"register:{client_ip(request)}", limit=3, window_seconds=3600)
|
||||||
|
user = register(payload.email, payload.password)
|
||||||
|
_ = user
|
||||||
|
return {"message": "If email is valid, verification has been sent."}
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/verify-email")
|
||||||
|
async def verify_email_route(payload: VerifyEmailIn):
|
||||||
|
try:
|
||||||
|
user = verify_email_token(payload.token)
|
||||||
|
except ValueError as exc:
|
||||||
|
detail = str(exc)
|
||||||
|
if detail == "TOKEN_EXPIRED":
|
||||||
|
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="TOKEN_EXPIRED")
|
||||||
|
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="INVALID_TOKEN")
|
||||||
|
_ = user
|
||||||
|
return {"status": "active"}
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/login", response_model=LoginOut)
|
||||||
|
async def login_route(payload: LoginIn, request: Request, response: Response):
|
||||||
|
_enforce_origin(request)
|
||||||
|
user = repository.get_user_by_email(payload.email)
|
||||||
|
if user:
|
||||||
|
locked_until = ensure_utc(user.locked_until)
|
||||||
|
if locked_until and locked_until > utc_now():
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_429_TOO_MANY_REQUESTS,
|
||||||
|
detail="ACCOUNT_TEMPORARILY_LOCKED",
|
||||||
|
)
|
||||||
|
check_rate_limit(
|
||||||
|
f"login:{client_ip(request)}:{payload.email.lower()}",
|
||||||
|
limit=5,
|
||||||
|
window_seconds=60,
|
||||||
|
)
|
||||||
|
try:
|
||||||
|
access_token, refresh_token, user = login(payload.email, payload.password)
|
||||||
|
except ValueError:
|
||||||
|
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="INVALID_CREDENTIALS")
|
||||||
|
except PermissionError as exc:
|
||||||
|
detail = str(exc)
|
||||||
|
if detail == "EMAIL_NOT_VERIFIED":
|
||||||
|
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="EMAIL_NOT_VERIFIED")
|
||||||
|
if detail == "ACCOUNT_TEMPORARILY_LOCKED":
|
||||||
|
raise HTTPException(status_code=status.HTTP_429_TOO_MANY_REQUESTS, detail="ACCOUNT_TEMPORARILY_LOCKED")
|
||||||
|
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="ACCOUNT_BLOCKED")
|
||||||
|
|
||||||
|
_set_refresh_cookie(response, refresh_token)
|
||||||
|
return {
|
||||||
|
"access_token": access_token,
|
||||||
|
"expires_in": settings.jwt_access_ttl_min * 60,
|
||||||
|
"user": {
|
||||||
|
"id": user.id,
|
||||||
|
"email": user.email,
|
||||||
|
"role": user.role,
|
||||||
|
"is_superuser": user.is_superuser,
|
||||||
|
"status": user.status,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/refresh", response_model=LoginOut)
|
||||||
|
async def refresh_route(request: Request, response: Response):
|
||||||
|
refresh_token = request.cookies.get("refresh_token")
|
||||||
|
if not refresh_token:
|
||||||
|
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="REFRESH_MISSING")
|
||||||
|
_enforce_origin(request, require_header=True)
|
||||||
|
check_rate_limit(f"refresh:{client_ip(request)}", limit=30, window_seconds=60)
|
||||||
|
try:
|
||||||
|
access_token, new_refresh, user = refresh(refresh_token)
|
||||||
|
except PermissionError as exc:
|
||||||
|
detail = str(exc)
|
||||||
|
if detail == "EMAIL_NOT_VERIFIED":
|
||||||
|
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="EMAIL_NOT_VERIFIED")
|
||||||
|
if detail == "ACCOUNT_BLOCKED":
|
||||||
|
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="ACCOUNT_BLOCKED")
|
||||||
|
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="INVALID_REFRESH")
|
||||||
|
_set_refresh_cookie(response, new_refresh)
|
||||||
|
return {
|
||||||
|
"access_token": access_token,
|
||||||
|
"expires_in": settings.jwt_access_ttl_min * 60,
|
||||||
|
"user": {
|
||||||
|
"id": user.id,
|
||||||
|
"email": user.email,
|
||||||
|
"role": user.role,
|
||||||
|
"is_superuser": user.is_superuser,
|
||||||
|
"status": user.status,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/logout")
|
||||||
|
async def logout_route(
|
||||||
|
request: Request,
|
||||||
|
response: Response,
|
||||||
|
credentials: HTTPAuthorizationCredentials | None = Depends(optional_bearer),
|
||||||
|
):
|
||||||
|
_enforce_origin(request, require_header=True)
|
||||||
|
refresh_token = request.cookies.get("refresh_token")
|
||||||
|
access_token = credentials.credentials if credentials else None
|
||||||
|
logout(refresh_token, access_token)
|
||||||
|
response.delete_cookie(
|
||||||
|
"refresh_token",
|
||||||
|
path="/api/v1/auth",
|
||||||
|
secure=settings.cookie_secure,
|
||||||
|
samesite="lax",
|
||||||
|
)
|
||||||
|
return {"message": "logged_out"}
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/forgot-password")
|
||||||
|
async def forgot_password_route(payload: ForgotPasswordIn, request: Request):
|
||||||
|
_enforce_origin(request)
|
||||||
|
check_rate_limit(
|
||||||
|
f"forgot:{client_ip(request)}:{payload.email.lower()}",
|
||||||
|
limit=3,
|
||||||
|
window_seconds=3600,
|
||||||
|
)
|
||||||
|
forgot_password(payload.email)
|
||||||
|
return {"message": "If email is registered, reset instructions have been sent."}
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/reset-password")
|
||||||
|
async def reset_password_route(payload: ResetPasswordIn):
|
||||||
|
try:
|
||||||
|
reset_password(payload.token, payload.new_password)
|
||||||
|
except ValueError as exc:
|
||||||
|
detail = str(exc)
|
||||||
|
if detail == "TOKEN_EXPIRED":
|
||||||
|
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="TOKEN_EXPIRED")
|
||||||
|
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="INVALID_TOKEN")
|
||||||
|
return {"message": "password_updated"}
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/resend-verification")
|
||||||
|
async def resend_verification_route(payload: ResendVerificationIn, request: Request):
|
||||||
|
_enforce_origin(request)
|
||||||
|
check_rate_limit(
|
||||||
|
f"resend:{client_ip(request)}:{payload.email.lower()}",
|
||||||
|
limit=3,
|
||||||
|
window_seconds=3600,
|
||||||
|
)
|
||||||
|
resend_verification(payload.email)
|
||||||
|
return {"message": "If email is registered, verification has been sent."}
|
||||||
@@ -0,0 +1,69 @@
|
|||||||
|
from pydantic import BaseModel, EmailStr, Field, field_validator
|
||||||
|
|
||||||
|
from app.core.password_policy import validate_password_strength
|
||||||
|
|
||||||
|
|
||||||
|
class RegisterIn(BaseModel):
|
||||||
|
email: EmailStr
|
||||||
|
password: str = Field(min_length=8)
|
||||||
|
|
||||||
|
@field_validator("email", "password", mode="before")
|
||||||
|
@classmethod
|
||||||
|
def strip_whitespace(cls, value: str) -> str:
|
||||||
|
if isinstance(value, str):
|
||||||
|
return value.strip()
|
||||||
|
return value
|
||||||
|
|
||||||
|
@field_validator("password")
|
||||||
|
@classmethod
|
||||||
|
def password_policy(cls, value: str) -> str:
|
||||||
|
return validate_password_strength(value)
|
||||||
|
|
||||||
|
|
||||||
|
class LoginIn(BaseModel):
|
||||||
|
email: EmailStr
|
||||||
|
password: str = Field(min_length=8)
|
||||||
|
|
||||||
|
@field_validator("email", "password", mode="before")
|
||||||
|
@classmethod
|
||||||
|
def strip_whitespace(cls, value: str) -> str:
|
||||||
|
if isinstance(value, str):
|
||||||
|
return value.strip()
|
||||||
|
return value
|
||||||
|
|
||||||
|
|
||||||
|
class VerifyEmailIn(BaseModel):
|
||||||
|
token: str
|
||||||
|
|
||||||
|
|
||||||
|
class ForgotPasswordIn(BaseModel):
|
||||||
|
email: EmailStr
|
||||||
|
|
||||||
|
|
||||||
|
class ResendVerificationIn(BaseModel):
|
||||||
|
email: EmailStr
|
||||||
|
|
||||||
|
|
||||||
|
class ResetPasswordIn(BaseModel):
|
||||||
|
token: str
|
||||||
|
new_password: str = Field(min_length=8)
|
||||||
|
|
||||||
|
@field_validator("new_password")
|
||||||
|
@classmethod
|
||||||
|
def password_policy(cls, value: str) -> str:
|
||||||
|
return validate_password_strength(value)
|
||||||
|
|
||||||
|
|
||||||
|
class AuthUserOut(BaseModel):
|
||||||
|
id: str
|
||||||
|
email: EmailStr
|
||||||
|
role: str
|
||||||
|
is_superuser: bool
|
||||||
|
status: str
|
||||||
|
|
||||||
|
|
||||||
|
class LoginOut(BaseModel):
|
||||||
|
access_token: str
|
||||||
|
token_type: str = "bearer"
|
||||||
|
expires_in: int = 900
|
||||||
|
user: AuthUserOut
|
||||||
@@ -0,0 +1,240 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from datetime import timedelta
|
||||||
|
from uuid import uuid4
|
||||||
|
|
||||||
|
from app.core.config import settings
|
||||||
|
from app.core.datetime_utils import ensure_utc, utc_now
|
||||||
|
from app.core.email import send_template_email
|
||||||
|
from app.core.jwt_denylist import bump_auth_epoch, revoke_access_token
|
||||||
|
from app.core.security import (
|
||||||
|
create_access_token,
|
||||||
|
generate_opaque_token,
|
||||||
|
generate_refresh_token,
|
||||||
|
hash_opaque_token,
|
||||||
|
hash_password,
|
||||||
|
hash_refresh_token,
|
||||||
|
verify_password,
|
||||||
|
)
|
||||||
|
from app.modules.auth import repository as auth_repository
|
||||||
|
from app.modules.users import repository
|
||||||
|
from app.modules.users.models import User
|
||||||
|
|
||||||
|
def _token_expires_at():
|
||||||
|
return utc_now() + timedelta(hours=settings.auth_token_ttl_hours)
|
||||||
|
|
||||||
|
|
||||||
|
def _send_verification_email(user: User, token: str) -> None:
|
||||||
|
verify_url = f"{settings.frontend_url}/verify?token={token}"
|
||||||
|
show_token = settings.email_delivery_mode == "memory" or settings.enable_test_routes
|
||||||
|
token_line = f"TOKEN:{token}\n" if show_token else ""
|
||||||
|
body = "Confirm your Compton account.\n\n" + f"Open: {verify_url}\n" + token_line
|
||||||
|
send_template_email(
|
||||||
|
to=user.email,
|
||||||
|
template="verify_email",
|
||||||
|
subject="Confirm your Compton account",
|
||||||
|
body=body,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _send_password_reset_email(user: User, token: str) -> None:
|
||||||
|
reset_url = f"{settings.frontend_url}/reset-password?token={token}"
|
||||||
|
show_token = settings.email_delivery_mode == "memory" or settings.enable_test_routes
|
||||||
|
token_line = f"TOKEN:{token}\n" if show_token else ""
|
||||||
|
body = "Reset your Compton password.\n\n" + f"Open: {reset_url}\n" + token_line
|
||||||
|
send_template_email(
|
||||||
|
to=user.email,
|
||||||
|
template="reset_password",
|
||||||
|
subject="Reset your Compton password",
|
||||||
|
body=body,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _issue_verification_token(user_id: str) -> str:
|
||||||
|
token = generate_opaque_token()
|
||||||
|
auth_repository.create_email_verification_token(
|
||||||
|
user_id=user_id,
|
||||||
|
token_hash=hash_opaque_token(token),
|
||||||
|
expires_at=_token_expires_at(),
|
||||||
|
)
|
||||||
|
return token
|
||||||
|
|
||||||
|
|
||||||
|
def _issue_password_reset_token(user_id: str) -> str:
|
||||||
|
token = generate_opaque_token()
|
||||||
|
auth_repository.create_password_reset_token(
|
||||||
|
user_id=user_id,
|
||||||
|
token_hash=hash_opaque_token(token),
|
||||||
|
expires_at=_token_expires_at(),
|
||||||
|
)
|
||||||
|
return token
|
||||||
|
|
||||||
|
|
||||||
|
def register(email: str, password: str) -> User:
|
||||||
|
existing = repository.get_user_by_email(email)
|
||||||
|
if existing:
|
||||||
|
if existing.status == "pending":
|
||||||
|
token = _issue_verification_token(existing.id)
|
||||||
|
_send_verification_email(existing, token)
|
||||||
|
return existing
|
||||||
|
user = repository.create_user(email=email, password_hash=hash_password(password), status="pending")
|
||||||
|
token = _issue_verification_token(user.id)
|
||||||
|
_send_verification_email(user, token)
|
||||||
|
return user
|
||||||
|
|
||||||
|
|
||||||
|
def verify_email_token(token: str) -> User:
|
||||||
|
token_hash = hash_opaque_token(token)
|
||||||
|
token_row = auth_repository.get_email_verification_token(token_hash)
|
||||||
|
if not token_row or token_row.used_at is not None:
|
||||||
|
raise ValueError("INVALID_TOKEN")
|
||||||
|
if ensure_utc(token_row.expires_at) < utc_now():
|
||||||
|
raise ValueError("TOKEN_EXPIRED")
|
||||||
|
|
||||||
|
user = repository.get_user_by_id(token_row.user_id)
|
||||||
|
if not user:
|
||||||
|
raise ValueError("INVALID_TOKEN")
|
||||||
|
|
||||||
|
user.status = "active"
|
||||||
|
user.email_verified_at = utc_now()
|
||||||
|
repository.update_user(user)
|
||||||
|
auth_repository.mark_email_verification_token_used(token_hash)
|
||||||
|
return user
|
||||||
|
|
||||||
|
|
||||||
|
def resend_verification(email: str) -> None:
|
||||||
|
user = repository.get_user_by_email(email)
|
||||||
|
if not user or user.status != "pending":
|
||||||
|
return
|
||||||
|
token = _issue_verification_token(user.id)
|
||||||
|
_send_verification_email(user, token)
|
||||||
|
|
||||||
|
|
||||||
|
def forgot_password(email: str) -> None:
|
||||||
|
user = repository.get_user_by_email(email)
|
||||||
|
if not user or user.status == "blocked":
|
||||||
|
return
|
||||||
|
token = _issue_password_reset_token(user.id)
|
||||||
|
_send_password_reset_email(user, token)
|
||||||
|
|
||||||
|
|
||||||
|
def reset_password(token: str, new_password: str) -> None:
|
||||||
|
token_hash = hash_opaque_token(token)
|
||||||
|
token_row = auth_repository.get_password_reset_token(token_hash)
|
||||||
|
if not token_row or token_row.used_at is not None:
|
||||||
|
raise ValueError("INVALID_TOKEN")
|
||||||
|
if ensure_utc(token_row.expires_at) < utc_now():
|
||||||
|
raise ValueError("TOKEN_EXPIRED")
|
||||||
|
|
||||||
|
user = repository.get_user_by_id(token_row.user_id)
|
||||||
|
if not user:
|
||||||
|
raise ValueError("INVALID_TOKEN")
|
||||||
|
|
||||||
|
user.password_hash = hash_password(new_password)
|
||||||
|
repository.update_user(user)
|
||||||
|
auth_repository.mark_password_reset_token_used(token_hash)
|
||||||
|
bump_auth_epoch(user.id)
|
||||||
|
revoke_user_refresh_family(user.id)
|
||||||
|
|
||||||
|
|
||||||
|
def _is_locked(user: User) -> bool:
|
||||||
|
locked_until = ensure_utc(user.locked_until)
|
||||||
|
if locked_until and locked_until > utc_now():
|
||||||
|
return True
|
||||||
|
if locked_until and locked_until <= utc_now():
|
||||||
|
user.failed_login_attempts = 0
|
||||||
|
user.locked_until = None
|
||||||
|
repository.update_user(user)
|
||||||
|
return False
|
||||||
|
|
||||||
|
|
||||||
|
def _record_failed_login(user: User) -> None:
|
||||||
|
locked_until = ensure_utc(user.locked_until)
|
||||||
|
if locked_until and locked_until <= utc_now():
|
||||||
|
user.failed_login_attempts = 0
|
||||||
|
user.locked_until = None
|
||||||
|
user.failed_login_attempts += 1
|
||||||
|
if user.failed_login_attempts >= settings.auth_lockout_attempts:
|
||||||
|
user.locked_until = utc_now() + timedelta(minutes=settings.auth_lockout_minutes)
|
||||||
|
repository.update_user(user)
|
||||||
|
|
||||||
|
|
||||||
|
def _reset_login_attempts(user: User) -> None:
|
||||||
|
user.failed_login_attempts = 0
|
||||||
|
user.locked_until = None
|
||||||
|
repository.update_user(user)
|
||||||
|
|
||||||
|
|
||||||
|
def login(email: str, password: str) -> tuple[str, str, User]:
|
||||||
|
user = repository.get_user_by_email(email)
|
||||||
|
if user and _is_locked(user):
|
||||||
|
raise PermissionError("ACCOUNT_TEMPORARILY_LOCKED")
|
||||||
|
if not user or not verify_password(password, user.password_hash):
|
||||||
|
if user:
|
||||||
|
_record_failed_login(user)
|
||||||
|
raise ValueError("INVALID_CREDENTIALS")
|
||||||
|
if user.status == "pending":
|
||||||
|
raise PermissionError("EMAIL_NOT_VERIFIED")
|
||||||
|
if user.status == "blocked":
|
||||||
|
raise PermissionError("ACCOUNT_BLOCKED")
|
||||||
|
_reset_login_attempts(user)
|
||||||
|
access_token = create_access_token(user.id, user.role, user.is_superuser)
|
||||||
|
refresh_token = issue_refresh_token(user.id)
|
||||||
|
return access_token, refresh_token, user
|
||||||
|
|
||||||
|
|
||||||
|
def issue_refresh_token(user_id: str, family_id: str | None = None) -> str:
|
||||||
|
token = generate_refresh_token()
|
||||||
|
token_hash = hash_refresh_token(token)
|
||||||
|
family = family_id or str(uuid4())
|
||||||
|
auth_repository.create_refresh_token(
|
||||||
|
user_id=user_id,
|
||||||
|
token_hash=token_hash,
|
||||||
|
family_id=family,
|
||||||
|
expires_at=utc_now() + timedelta(days=settings.jwt_refresh_ttl_days),
|
||||||
|
)
|
||||||
|
return token
|
||||||
|
|
||||||
|
|
||||||
|
def refresh(refresh_token: str) -> tuple[str, str, User]:
|
||||||
|
token_hash = hash_refresh_token(refresh_token)
|
||||||
|
token_row = auth_repository.get_refresh_token(token_hash)
|
||||||
|
if not token_row:
|
||||||
|
raise PermissionError("INVALID_REFRESH")
|
||||||
|
|
||||||
|
user = repository.get_user_by_id(token_row.user_id)
|
||||||
|
if not user:
|
||||||
|
raise PermissionError("INVALID_REFRESH")
|
||||||
|
if user.status == "pending":
|
||||||
|
raise PermissionError("EMAIL_NOT_VERIFIED")
|
||||||
|
if user.status == "blocked":
|
||||||
|
raise PermissionError("ACCOUNT_BLOCKED")
|
||||||
|
|
||||||
|
if token_row.revoked_at is not None:
|
||||||
|
auth_repository.revoke_family_tokens(token_row.family_id)
|
||||||
|
raise PermissionError("INVALID_REFRESH")
|
||||||
|
if ensure_utc(token_row.expires_at) < utc_now():
|
||||||
|
raise PermissionError("EXPIRED_REFRESH")
|
||||||
|
|
||||||
|
auth_repository.revoke_refresh_token(token_hash)
|
||||||
|
new_refresh = issue_refresh_token(user.id, family_id=token_row.family_id)
|
||||||
|
access = create_access_token(user.id, user.role, user.is_superuser)
|
||||||
|
return access, new_refresh, user
|
||||||
|
|
||||||
|
|
||||||
|
def revoke_refresh_token(refresh_token: str) -> None:
|
||||||
|
token_hash = hash_refresh_token(refresh_token)
|
||||||
|
token_row = auth_repository.revoke_refresh_token(token_hash)
|
||||||
|
if token_row:
|
||||||
|
auth_repository.revoke_family_tokens(token_row.family_id)
|
||||||
|
|
||||||
|
|
||||||
|
def logout(refresh_token: str | None, access_token: str | None) -> None:
|
||||||
|
if access_token:
|
||||||
|
revoke_access_token(access_token)
|
||||||
|
if refresh_token:
|
||||||
|
revoke_refresh_token(refresh_token)
|
||||||
|
|
||||||
|
|
||||||
|
def revoke_user_refresh_family(user_id: str) -> None:
|
||||||
|
auth_repository.revoke_user_families(user_id)
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
"""Content module."""
|
||||||
@@ -0,0 +1,27 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from datetime import UTC, datetime
|
||||||
|
from uuid import uuid4
|
||||||
|
|
||||||
|
from sqlalchemy import DateTime, ForeignKey, String, Text
|
||||||
|
from sqlalchemy.orm import Mapped, mapped_column
|
||||||
|
|
||||||
|
from app.db.base import Base
|
||||||
|
|
||||||
|
|
||||||
|
class ContentPage(Base):
|
||||||
|
__tablename__ = "content_pages"
|
||||||
|
|
||||||
|
id: Mapped[str] = mapped_column(String(36), primary_key=True, default=lambda: str(uuid4()))
|
||||||
|
slug: Mapped[str] = mapped_column(String(120), unique=True, nullable=False, index=True)
|
||||||
|
title: Mapped[str] = mapped_column(String(255), nullable=False)
|
||||||
|
body: Mapped[str] = mapped_column(Text, nullable=False)
|
||||||
|
status: Mapped[str] = mapped_column(String(16), nullable=False, default="draft", index=True)
|
||||||
|
author_id: Mapped[str | None] = mapped_column(String(36), ForeignKey("users.id"), nullable=True)
|
||||||
|
published_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True)
|
||||||
|
updated_at: Mapped[datetime] = mapped_column(
|
||||||
|
DateTime(timezone=True),
|
||||||
|
nullable=False,
|
||||||
|
default=lambda: datetime.now(UTC),
|
||||||
|
onupdate=lambda: datetime.now(UTC),
|
||||||
|
)
|
||||||
@@ -0,0 +1,105 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from datetime import UTC, datetime
|
||||||
|
from uuid import uuid4
|
||||||
|
|
||||||
|
from sqlalchemy import select
|
||||||
|
|
||||||
|
from app.core.database import session_scope
|
||||||
|
from app.modules.content.models import ContentPage
|
||||||
|
|
||||||
|
ALLOWED_PAGE_STATUSES = {"draft", "published"}
|
||||||
|
|
||||||
|
|
||||||
|
def _detach(db, instance):
|
||||||
|
db.refresh(instance)
|
||||||
|
db.expunge(instance)
|
||||||
|
return instance
|
||||||
|
|
||||||
|
|
||||||
|
def list_published_pages() -> list[ContentPage]:
|
||||||
|
with session_scope() as db:
|
||||||
|
pages = list(
|
||||||
|
db.scalars(select(ContentPage).where(ContentPage.status == "published").order_by(ContentPage.slug))
|
||||||
|
)
|
||||||
|
return [_detach(db, page) for page in pages]
|
||||||
|
|
||||||
|
|
||||||
|
def list_all_pages() -> list[ContentPage]:
|
||||||
|
with session_scope() as db:
|
||||||
|
pages = list(db.scalars(select(ContentPage).order_by(ContentPage.slug)))
|
||||||
|
return [_detach(db, page) for page in pages]
|
||||||
|
|
||||||
|
|
||||||
|
def get_page_by_slug(slug: str, include_draft: bool = False) -> ContentPage | None:
|
||||||
|
with session_scope() as db:
|
||||||
|
page = db.scalar(select(ContentPage).where(ContentPage.slug == slug))
|
||||||
|
if not page:
|
||||||
|
return None
|
||||||
|
if include_draft or page.status == "published":
|
||||||
|
return _detach(db, page)
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def get_page_by_id(page_id: str) -> ContentPage | None:
|
||||||
|
with session_scope() as db:
|
||||||
|
page = db.get(ContentPage, page_id)
|
||||||
|
if not page:
|
||||||
|
return None
|
||||||
|
return _detach(db, page)
|
||||||
|
|
||||||
|
|
||||||
|
def create_page(
|
||||||
|
slug: str,
|
||||||
|
title: str,
|
||||||
|
body: str,
|
||||||
|
status: str,
|
||||||
|
author_id: str,
|
||||||
|
) -> ContentPage:
|
||||||
|
if status not in ALLOWED_PAGE_STATUSES:
|
||||||
|
raise ValueError("INVALID_STATUS")
|
||||||
|
with session_scope() as db:
|
||||||
|
page = ContentPage(
|
||||||
|
id=str(uuid4()),
|
||||||
|
slug=slug,
|
||||||
|
title=title,
|
||||||
|
body=body,
|
||||||
|
status=status,
|
||||||
|
author_id=author_id,
|
||||||
|
published_at=datetime.now(UTC) if status == "published" else None,
|
||||||
|
)
|
||||||
|
db.add(page)
|
||||||
|
db.flush()
|
||||||
|
return _detach(db, page)
|
||||||
|
|
||||||
|
|
||||||
|
def update_page(
|
||||||
|
page_id: str,
|
||||||
|
title: str | None,
|
||||||
|
body: str | None,
|
||||||
|
status: str | None,
|
||||||
|
) -> ContentPage:
|
||||||
|
with session_scope() as db:
|
||||||
|
page = db.get(ContentPage, page_id)
|
||||||
|
if not page:
|
||||||
|
raise KeyError(page_id)
|
||||||
|
if title:
|
||||||
|
page.title = title
|
||||||
|
if body:
|
||||||
|
page.body = body
|
||||||
|
if status:
|
||||||
|
if status not in ALLOWED_PAGE_STATUSES:
|
||||||
|
raise ValueError("INVALID_STATUS")
|
||||||
|
page.status = status
|
||||||
|
if status == "published" and page.published_at is None:
|
||||||
|
page.published_at = datetime.now(UTC)
|
||||||
|
page.updated_at = datetime.now(UTC)
|
||||||
|
db.flush()
|
||||||
|
return _detach(db, page)
|
||||||
|
|
||||||
|
|
||||||
|
def delete_page(page_id: str) -> None:
|
||||||
|
with session_scope() as db:
|
||||||
|
page = db.get(ContentPage, page_id)
|
||||||
|
if page:
|
||||||
|
db.delete(page)
|
||||||
@@ -0,0 +1,56 @@
|
|||||||
|
from fastapi import APIRouter, Depends, HTTPException
|
||||||
|
|
||||||
|
from app.core.dependencies import require_admin
|
||||||
|
from app.modules.content.schemas import ContentPageIn, ContentPagePatchIn
|
||||||
|
from app.modules.content.service import (
|
||||||
|
create_page,
|
||||||
|
delete_page,
|
||||||
|
get_page_by_slug,
|
||||||
|
list_all_pages,
|
||||||
|
list_published_pages,
|
||||||
|
page_exists,
|
||||||
|
page_to_dict,
|
||||||
|
update_page,
|
||||||
|
)
|
||||||
|
|
||||||
|
router = APIRouter()
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/pages")
|
||||||
|
async def list_pages_route():
|
||||||
|
pages = list_published_pages()
|
||||||
|
return {"data": [page_to_dict(page) for page in pages]}
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/pages/manage/all")
|
||||||
|
async def list_all_pages_route(_admin=Depends(require_admin)):
|
||||||
|
pages = list_all_pages()
|
||||||
|
return {"data": [page_to_dict(page) for page in pages]}
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/pages/{slug}")
|
||||||
|
async def get_page_route(slug: str):
|
||||||
|
page = get_page_by_slug(slug)
|
||||||
|
if not page:
|
||||||
|
raise HTTPException(status_code=404, detail="PAGE_NOT_FOUND")
|
||||||
|
return page_to_dict(page)
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/pages")
|
||||||
|
async def create_page_route(payload: ContentPageIn, admin=Depends(require_admin)):
|
||||||
|
page = create_page(payload.slug, payload.title, payload.body, payload.status, admin.id)
|
||||||
|
return page_to_dict(page)
|
||||||
|
|
||||||
|
|
||||||
|
@router.patch("/pages/{page_id}")
|
||||||
|
async def update_page_route(page_id: str, payload: ContentPagePatchIn, _admin=Depends(require_admin)):
|
||||||
|
if not page_exists(page_id):
|
||||||
|
raise HTTPException(status_code=404, detail="PAGE_NOT_FOUND")
|
||||||
|
page = update_page(page_id, payload.title, payload.body, payload.status)
|
||||||
|
return page_to_dict(page)
|
||||||
|
|
||||||
|
|
||||||
|
@router.delete("/pages/{page_id}")
|
||||||
|
async def delete_page_route(page_id: str, _admin=Depends(require_admin)):
|
||||||
|
delete_page(page_id)
|
||||||
|
return {"message": "deleted"}
|
||||||
@@ -0,0 +1,24 @@
|
|||||||
|
from typing import Literal
|
||||||
|
|
||||||
|
from pydantic import BaseModel, Field
|
||||||
|
|
||||||
|
|
||||||
|
class ContentPageIn(BaseModel):
|
||||||
|
slug: str = Field(min_length=2, max_length=120)
|
||||||
|
title: str = Field(min_length=2, max_length=200)
|
||||||
|
body: str
|
||||||
|
status: Literal["draft", "published"] = "draft"
|
||||||
|
|
||||||
|
|
||||||
|
class ContentPagePatchIn(BaseModel):
|
||||||
|
title: str | None = Field(default=None, min_length=2, max_length=200)
|
||||||
|
body: str | None = None
|
||||||
|
status: Literal["draft", "published"] | None = None
|
||||||
|
|
||||||
|
|
||||||
|
class ContentPageOut(BaseModel):
|
||||||
|
id: str
|
||||||
|
slug: str
|
||||||
|
title: str
|
||||||
|
body: str
|
||||||
|
status: str
|
||||||
@@ -0,0 +1,70 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import bleach
|
||||||
|
|
||||||
|
from app.modules.content import repository
|
||||||
|
from app.modules.content.models import ContentPage
|
||||||
|
|
||||||
|
ALLOWED_TAGS = ["p", "h1", "h2", "h3", "h4", "ul", "ol", "li", "a", "strong", "em", "br", "img"]
|
||||||
|
|
||||||
|
|
||||||
|
def sanitize_html(raw_html: str) -> str:
|
||||||
|
return bleach.clean(
|
||||||
|
raw_html,
|
||||||
|
tags=ALLOWED_TAGS,
|
||||||
|
attributes={"a": ["href"], "img": ["src", "alt"]},
|
||||||
|
protocols=["http", "https", "mailto"],
|
||||||
|
strip=True,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def page_to_dict(page: ContentPage) -> dict:
|
||||||
|
return {
|
||||||
|
"id": page.id,
|
||||||
|
"slug": page.slug,
|
||||||
|
"title": page.title,
|
||||||
|
"body": page.body,
|
||||||
|
"status": page.status,
|
||||||
|
"author_id": page.author_id,
|
||||||
|
"published_at": page.published_at.isoformat() if page.published_at else None,
|
||||||
|
"updated_at": page.updated_at.isoformat() if page.updated_at else None,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def list_published_pages() -> list[ContentPage]:
|
||||||
|
return repository.list_published_pages()
|
||||||
|
|
||||||
|
|
||||||
|
def list_all_pages() -> list[ContentPage]:
|
||||||
|
return repository.list_all_pages()
|
||||||
|
|
||||||
|
|
||||||
|
def get_page_by_slug(slug: str, include_draft: bool = False) -> ContentPage | None:
|
||||||
|
return repository.get_page_by_slug(slug, include_draft=include_draft)
|
||||||
|
|
||||||
|
|
||||||
|
def create_page(slug: str, title: str, body: str, status: str, author_id: str) -> ContentPage:
|
||||||
|
return repository.create_page(
|
||||||
|
slug=slug,
|
||||||
|
title=title,
|
||||||
|
body=sanitize_html(body),
|
||||||
|
status=status,
|
||||||
|
author_id=author_id,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def update_page(page_id: str, title: str | None, body: str | None, status: str | None) -> ContentPage:
|
||||||
|
return repository.update_page(
|
||||||
|
page_id,
|
||||||
|
title,
|
||||||
|
sanitize_html(body) if body else None,
|
||||||
|
status,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def delete_page(page_id: str) -> None:
|
||||||
|
repository.delete_page(page_id)
|
||||||
|
|
||||||
|
|
||||||
|
def page_exists(page_id: str) -> bool:
|
||||||
|
return repository.get_page_by_id(page_id) is not None
|
||||||
@@ -0,0 +1,155 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from fastapi import APIRouter, Depends, HTTPException, Query, status
|
||||||
|
|
||||||
|
from app.core.crypto import create_access_token
|
||||||
|
from app.core.dependencies import get_current_user, require_superuser
|
||||||
|
from app.modules.sync import repository as sync_repo
|
||||||
|
from app.modules.sync.schemas import EnterpriseOut, FarmHubOut
|
||||||
|
from app.modules.users.models import User
|
||||||
|
|
||||||
|
router = APIRouter()
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/enterprises")
|
||||||
|
def list_enterprises(user: User = Depends(get_current_user)):
|
||||||
|
# Superuser sees all; regular users see memberships only (simplified v1)
|
||||||
|
if user.is_superuser:
|
||||||
|
from sqlalchemy import select
|
||||||
|
|
||||||
|
from app.core.database import session_scope
|
||||||
|
from app.modules.sync.models import Enterprise
|
||||||
|
|
||||||
|
with session_scope() as db:
|
||||||
|
rows = list(db.scalars(select(Enterprise).order_by(Enterprise.name)))
|
||||||
|
return [
|
||||||
|
EnterpriseOut(id=r.id, name=r.name, slug=r.slug, status=r.status).model_dump()
|
||||||
|
for r in rows
|
||||||
|
]
|
||||||
|
from sqlalchemy import select
|
||||||
|
|
||||||
|
from app.core.database import session_scope
|
||||||
|
from app.modules.sync.models import Enterprise, EnterpriseMember
|
||||||
|
|
||||||
|
with session_scope() as db:
|
||||||
|
rows = list(
|
||||||
|
db.scalars(
|
||||||
|
select(Enterprise)
|
||||||
|
.join(EnterpriseMember, EnterpriseMember.enterprise_id == Enterprise.id)
|
||||||
|
.where(EnterpriseMember.user_id == user.id)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
return [EnterpriseOut(id=r.id, name=r.name, slug=r.slug, status=r.status).model_dump() for r in rows]
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/enterprises")
|
||||||
|
def create_enterprise(body: dict, _: User = Depends(require_superuser)):
|
||||||
|
ent = sync_repo.create_enterprise(body["name"], body["slug"])
|
||||||
|
return EnterpriseOut(id=ent.id, name=ent.name, slug=ent.slug, status=ent.status).model_dump()
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/enterprises/{enterprise_id}/farms")
|
||||||
|
def list_farms(enterprise_id: str, user: User = Depends(get_current_user)):
|
||||||
|
member = sync_repo.get_member(user.id, enterprise_id)
|
||||||
|
if not member and not user.is_superuser:
|
||||||
|
from fastapi import HTTPException, status
|
||||||
|
|
||||||
|
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="ENTERPRISE_FORBIDDEN")
|
||||||
|
hubs = sync_repo.list_farm_hubs(enterprise_id)
|
||||||
|
return [
|
||||||
|
FarmHubOut(
|
||||||
|
id=h.id,
|
||||||
|
enterprise_id=h.enterprise_id,
|
||||||
|
name=h.name,
|
||||||
|
hub_site_id=h.hub_site_id,
|
||||||
|
url=h.url,
|
||||||
|
status=h.status,
|
||||||
|
last_seen=h.last_seen,
|
||||||
|
wesp_version=h.wesp_version,
|
||||||
|
).model_dump()
|
||||||
|
for h in hubs
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/enterprises/{enterprise_id}/session")
|
||||||
|
def enterprise_session(enterprise_id: str, user: User = Depends(get_current_user)):
|
||||||
|
member = sync_repo.get_member(user.id, enterprise_id)
|
||||||
|
if not member and not user.is_superuser:
|
||||||
|
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="ENTERPRISE_FORBIDDEN")
|
||||||
|
role = member.role if member else "admin"
|
||||||
|
farm_ids = None if role == "admin" else sync_repo.list_farm_access(user.id, enterprise_id)
|
||||||
|
token = create_access_token(
|
||||||
|
user.id,
|
||||||
|
user.role,
|
||||||
|
user.is_superuser,
|
||||||
|
enterprise_id=enterprise_id,
|
||||||
|
enterprise_role=role,
|
||||||
|
farm_ids=farm_ids,
|
||||||
|
)
|
||||||
|
return {
|
||||||
|
"access_token": token,
|
||||||
|
"enterprise_id": enterprise_id,
|
||||||
|
"enterprise_role": role,
|
||||||
|
"farm_ids": farm_ids,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/enterprises/{enterprise_id}/sync-status")
|
||||||
|
def enterprise_sync_status(enterprise_id: str, user: User = Depends(get_current_user)):
|
||||||
|
member = sync_repo.get_member(user.id, enterprise_id)
|
||||||
|
if not member and not user.is_superuser:
|
||||||
|
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="ENTERPRISE_FORBIDDEN")
|
||||||
|
from app.modules.sync.service import get_sync_metrics
|
||||||
|
|
||||||
|
return get_sync_metrics(enterprise_id)
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/enterprises/{enterprise_id}/members")
|
||||||
|
def list_members(enterprise_id: str, user: User = Depends(get_current_user)):
|
||||||
|
member = sync_repo.get_member(user.id, enterprise_id)
|
||||||
|
if not member or member.role != "admin":
|
||||||
|
if not user.is_superuser:
|
||||||
|
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="ENTERPRISE_ADMIN_ONLY")
|
||||||
|
from app.modules.users.repository import get_user_by_id
|
||||||
|
|
||||||
|
rows = sync_repo.list_members(enterprise_id)
|
||||||
|
return [
|
||||||
|
{
|
||||||
|
"user_id": m.user_id,
|
||||||
|
"role": m.role,
|
||||||
|
"email": (get_user_by_id(m.user_id).email if get_user_by_id(m.user_id) else None),
|
||||||
|
"farm_ids": sync_repo.list_farm_access(m.user_id, enterprise_id),
|
||||||
|
}
|
||||||
|
for m in rows
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/enterprises/{enterprise_id}/members")
|
||||||
|
def add_enterprise_member(enterprise_id: str, body: dict, user: User = Depends(get_current_user)):
|
||||||
|
member = sync_repo.get_member(user.id, enterprise_id)
|
||||||
|
if not member or member.role != "admin":
|
||||||
|
if not user.is_superuser:
|
||||||
|
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="ENTERPRISE_ADMIN_ONLY")
|
||||||
|
from app.modules.users.repository import get_user_by_email
|
||||||
|
|
||||||
|
target = get_user_by_email(body["email"].strip().lower())
|
||||||
|
if not target:
|
||||||
|
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="USER_NOT_FOUND")
|
||||||
|
existing = sync_repo.get_member(target.id, enterprise_id)
|
||||||
|
if existing:
|
||||||
|
sync_repo.set_member_role(target.id, enterprise_id, body.get("role", existing.role))
|
||||||
|
else:
|
||||||
|
sync_repo.add_member(target.id, enterprise_id, body.get("role", "viewer"))
|
||||||
|
for farm_id in body.get("farm_ids") or []:
|
||||||
|
sync_repo.grant_farm_access(target.id, farm_id)
|
||||||
|
return {"status": "ok", "user_id": target.id}
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/enterprises/{enterprise_id}/farm-access")
|
||||||
|
def grant_farm_access(enterprise_id: str, body: dict, user: User = Depends(get_current_user)):
|
||||||
|
member = sync_repo.get_member(user.id, enterprise_id)
|
||||||
|
if not member or member.role != "admin":
|
||||||
|
if not user.is_superuser:
|
||||||
|
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="ENTERPRISE_ADMIN_ONLY")
|
||||||
|
sync_repo.grant_farm_access(body["user_id"], body["farm_hub_id"])
|
||||||
|
return {"status": "ok"}
|
||||||
@@ -0,0 +1,24 @@
|
|||||||
|
from fastapi import APIRouter, HTTPException, Query
|
||||||
|
from fastapi.responses import Response
|
||||||
|
|
||||||
|
from app.core.media_signing import verify_signed_media
|
||||||
|
from app.core.storage import download_object
|
||||||
|
|
||||||
|
router = APIRouter()
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/files/{file_path:path}")
|
||||||
|
async def get_media_file(
|
||||||
|
file_path: str,
|
||||||
|
expires: int = Query(...),
|
||||||
|
sig: str = Query(...),
|
||||||
|
):
|
||||||
|
if not file_path.startswith("avatars/"):
|
||||||
|
raise HTTPException(status_code=404, detail="FILE_NOT_FOUND")
|
||||||
|
if not verify_signed_media(file_path, expires, sig):
|
||||||
|
raise HTTPException(status_code=403, detail="INVALID_SIGNATURE")
|
||||||
|
stored = download_object(file_path)
|
||||||
|
if not stored:
|
||||||
|
raise HTTPException(status_code=404, detail="FILE_NOT_FOUND")
|
||||||
|
body, content_type = stored
|
||||||
|
return Response(content=body, media_type=content_type)
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
from pydantic import BaseModel
|
||||||
|
|
||||||
|
|
||||||
|
class AvatarUploadOut(BaseModel):
|
||||||
|
message: str
|
||||||
@@ -0,0 +1,62 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from io import BytesIO
|
||||||
|
from uuid import uuid4
|
||||||
|
|
||||||
|
from PIL import Image
|
||||||
|
|
||||||
|
from app.core.config import settings
|
||||||
|
from app.core.storage import upload_object
|
||||||
|
|
||||||
|
ALLOWED_MIME = {
|
||||||
|
"image/jpeg": ".jpg",
|
||||||
|
"image/png": ".png",
|
||||||
|
"image/webp": ".webp",
|
||||||
|
}
|
||||||
|
|
||||||
|
FORMAT_TO_MIME = {
|
||||||
|
"JPEG": "image/jpeg",
|
||||||
|
"PNG": "image/png",
|
||||||
|
"WEBP": "image/webp",
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
class AvatarValidationError(ValueError):
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
def validate_and_process_avatar(content: bytes) -> tuple[str, bytes]:
|
||||||
|
if len(content) > settings.avatar_max_bytes:
|
||||||
|
raise AvatarValidationError("FILE_TOO_LARGE")
|
||||||
|
if not content:
|
||||||
|
raise AvatarValidationError("INVALID_IMAGE")
|
||||||
|
|
||||||
|
try:
|
||||||
|
with Image.open(BytesIO(content)) as image:
|
||||||
|
image.verify()
|
||||||
|
with Image.open(BytesIO(content)) as image:
|
||||||
|
mime = FORMAT_TO_MIME.get(image.format or "")
|
||||||
|
if mime not in ALLOWED_MIME:
|
||||||
|
raise AvatarValidationError("INVALID_MIME")
|
||||||
|
|
||||||
|
buffer = BytesIO()
|
||||||
|
if mime == "image/jpeg":
|
||||||
|
rgb = image.convert("RGB")
|
||||||
|
rgb.save(buffer, format="JPEG", quality=85, optimize=True)
|
||||||
|
elif mime == "image/png":
|
||||||
|
image.save(buffer, format="PNG", optimize=True)
|
||||||
|
else:
|
||||||
|
image.save(buffer, format="WEBP", quality=85, method=6)
|
||||||
|
return mime, buffer.getvalue()
|
||||||
|
except AvatarValidationError:
|
||||||
|
raise
|
||||||
|
except Exception as exc:
|
||||||
|
raise AvatarValidationError("INVALID_IMAGE") from exc
|
||||||
|
|
||||||
|
|
||||||
|
def upload_user_avatar(user_id: str, content: bytes) -> str:
|
||||||
|
mime, processed = validate_and_process_avatar(content)
|
||||||
|
extension = ALLOWED_MIME[mime]
|
||||||
|
key = f"avatars/{user_id}/{uuid4()}{extension}"
|
||||||
|
upload_object(key, processed, mime)
|
||||||
|
return f"/api/v1/media/files/{key}"
|
||||||
@@ -0,0 +1,8 @@
|
|||||||
|
"""Celery-ready notifications service placeholder.
|
||||||
|
|
||||||
|
MVP keeps SMTP sync path in auth; v1.0 should move to async queue.
|
||||||
|
"""
|
||||||
|
|
||||||
|
|
||||||
|
def enqueue_email(template: str, recipient: str, context: dict) -> dict:
|
||||||
|
return {"queued": True, "template": template, "recipient": recipient, "context": context}
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
"""Orders module placeholder (request/booking mode without payment)."""
|
||||||
|
|
||||||
|
|
||||||
|
def create_order_request(user_id: str, items: list[dict]) -> dict:
|
||||||
|
return {"id": "order-placeholder", "user_id": user_id, "items": items, "status": "submitted"}
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
"""WESP sync orchestrator: enterprise tenants, hub pairing, ChangeEvent protocol."""
|
||||||
@@ -0,0 +1,381 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import json
|
||||||
|
from datetime import UTC, datetime
|
||||||
|
from uuid import uuid4
|
||||||
|
|
||||||
|
from app.core.database import session_scope
|
||||||
|
from app.modules.sync import repository as repo
|
||||||
|
from app.modules.sync.models import SyncConflict, SyncOutbox
|
||||||
|
from app.modules.sync.schemas import (
|
||||||
|
AckChangesRequest,
|
||||||
|
AckChangesResponse,
|
||||||
|
ChangeEventIn,
|
||||||
|
ChangeEventOut,
|
||||||
|
PullChangesResponse,
|
||||||
|
PushChangesResponse,
|
||||||
|
)
|
||||||
|
from app.modules.zootech.catalog_apply import apply_catalog_change
|
||||||
|
|
||||||
|
|
||||||
|
class SyncEngine:
|
||||||
|
ORCHESTRATOR_SITE_ID = "orchestrator"
|
||||||
|
|
||||||
|
def __init__(self, enterprise_id: str, site_id: str) -> None:
|
||||||
|
self.enterprise_id = enterprise_id
|
||||||
|
self.site_id = site_id
|
||||||
|
|
||||||
|
def process_push(self, events: list[ChangeEventIn], origin: str) -> PushChangesResponse:
|
||||||
|
applied: list[str] = []
|
||||||
|
held: list[str] = []
|
||||||
|
conflict_ids: list[str] = []
|
||||||
|
|
||||||
|
for event in events:
|
||||||
|
if repo.event_exists(event.event_id):
|
||||||
|
applied.append(event.event_id)
|
||||||
|
continue
|
||||||
|
if repo.applied_event_exists(self.site_id, event.event_id):
|
||||||
|
applied.append(event.event_id)
|
||||||
|
continue
|
||||||
|
|
||||||
|
seq = event.seq or repo.next_seq(self.enterprise_id)
|
||||||
|
payload_json = json.dumps(event.payload, ensure_ascii=False)
|
||||||
|
repo.append_event_log(
|
||||||
|
enterprise_id=self.enterprise_id,
|
||||||
|
event_id=event.event_id,
|
||||||
|
origin=origin,
|
||||||
|
origin_site_id=event.origin_site_id or self.site_id,
|
||||||
|
seq=seq,
|
||||||
|
domain=event.domain,
|
||||||
|
table_name=event.table,
|
||||||
|
record_id=event.record_id,
|
||||||
|
action=event.action,
|
||||||
|
version=event.version,
|
||||||
|
content_hash=event.content_hash,
|
||||||
|
payload_json=payload_json,
|
||||||
|
)
|
||||||
|
|
||||||
|
outcome = self._apply_or_hold(event, origin)
|
||||||
|
if outcome == "applied":
|
||||||
|
repo.mark_applied(self.enterprise_id, self.ORCHESTRATOR_SITE_ID, event.event_id)
|
||||||
|
applied.append(event.event_id)
|
||||||
|
elif outcome == "held":
|
||||||
|
held.append(event.event_id)
|
||||||
|
elif isinstance(outcome, str) and outcome.startswith("conflict:"):
|
||||||
|
conflict_ids.append(outcome.split(":", 1)[1])
|
||||||
|
|
||||||
|
return PushChangesResponse(
|
||||||
|
applied_event_ids=applied,
|
||||||
|
held_event_ids=held,
|
||||||
|
conflicts=conflict_ids,
|
||||||
|
)
|
||||||
|
|
||||||
|
def _apply_or_hold(self, event: ChangeEventIn, origin: str) -> str:
|
||||||
|
state = repo.get_record_state(self.enterprise_id, event.table, event.record_id)
|
||||||
|
if state and state.agreed_hash and state.agreed_hash != event.content_hash:
|
||||||
|
agreed_version = int(state.agreed_version or 0)
|
||||||
|
if event.version <= agreed_version:
|
||||||
|
pending = self._find_pending_conflict(event.table, event.record_id)
|
||||||
|
if pending:
|
||||||
|
self._append_held_event(pending.id, event.event_id)
|
||||||
|
return "held"
|
||||||
|
conflict_id = self._create_conflict(event, origin, state)
|
||||||
|
self._hold_outbox_for_record(event.table, event.record_id)
|
||||||
|
return f"conflict:{conflict_id}"
|
||||||
|
|
||||||
|
if event.domain in ("report", "reports"):
|
||||||
|
from app.modules.zootech.report_apply import apply_report_change
|
||||||
|
|
||||||
|
apply_report_change(
|
||||||
|
self.enterprise_id,
|
||||||
|
event.table,
|
||||||
|
event.record_id,
|
||||||
|
event.action,
|
||||||
|
event.payload,
|
||||||
|
event.version,
|
||||||
|
event.content_hash,
|
||||||
|
farm_hub_id=event.origin_site_id if origin == "hub" else None,
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
apply_catalog_change(
|
||||||
|
self.enterprise_id,
|
||||||
|
event.table,
|
||||||
|
event.record_id,
|
||||||
|
event.action,
|
||||||
|
event.payload,
|
||||||
|
event.version,
|
||||||
|
event.content_hash,
|
||||||
|
)
|
||||||
|
repo.upsert_record_state(
|
||||||
|
self.enterprise_id,
|
||||||
|
event.table,
|
||||||
|
event.record_id,
|
||||||
|
event.version,
|
||||||
|
event.content_hash,
|
||||||
|
event.event_id,
|
||||||
|
)
|
||||||
|
if origin == "orchestrator" or self.site_id == self.ORCHESTRATOR_SITE_ID:
|
||||||
|
self._enqueue_fanout(event)
|
||||||
|
return "applied"
|
||||||
|
|
||||||
|
def _append_held_event(self, conflict_id: str, event_id: str) -> None:
|
||||||
|
with session_scope() as db:
|
||||||
|
row = db.get(SyncConflict, conflict_id)
|
||||||
|
if not row:
|
||||||
|
return
|
||||||
|
held = json.loads(row.held_event_ids_json or "[]")
|
||||||
|
if event_id not in held:
|
||||||
|
held.append(event_id)
|
||||||
|
row.held_event_ids_json = json.dumps(held, ensure_ascii=False)
|
||||||
|
|
||||||
|
def _hold_outbox_for_record(self, table_name: str, record_id: str) -> None:
|
||||||
|
with session_scope() as db:
|
||||||
|
from sqlalchemy import select
|
||||||
|
|
||||||
|
rows = list(
|
||||||
|
db.scalars(
|
||||||
|
select(SyncOutbox).where(
|
||||||
|
SyncOutbox.enterprise_id == self.enterprise_id,
|
||||||
|
SyncOutbox.table_name == table_name,
|
||||||
|
SyncOutbox.record_id == record_id,
|
||||||
|
SyncOutbox.status.in_(("pending", "sent")),
|
||||||
|
)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
for row in rows:
|
||||||
|
row.status = "held"
|
||||||
|
|
||||||
|
def _has_pending_orchestrator_change(self, table_name: str, record_id: str, origin: str) -> bool:
|
||||||
|
if origin == "hub":
|
||||||
|
with session_scope() as db:
|
||||||
|
from sqlalchemy import select
|
||||||
|
|
||||||
|
row = db.scalar(
|
||||||
|
select(SyncOutbox).where(
|
||||||
|
SyncOutbox.enterprise_id == self.enterprise_id,
|
||||||
|
SyncOutbox.table_name == table_name,
|
||||||
|
SyncOutbox.record_id == record_id,
|
||||||
|
SyncOutbox.status.in_(("pending", "sent", "held")),
|
||||||
|
SyncOutbox.origin == "orchestrator",
|
||||||
|
)
|
||||||
|
)
|
||||||
|
return row is not None
|
||||||
|
return origin == "orchestrator"
|
||||||
|
|
||||||
|
def _find_pending_conflict(self, table_name: str, record_id: str) -> SyncConflict | None:
|
||||||
|
with session_scope() as db:
|
||||||
|
from sqlalchemy import select
|
||||||
|
|
||||||
|
row = db.scalar(
|
||||||
|
select(SyncConflict).where(
|
||||||
|
SyncConflict.enterprise_id == self.enterprise_id,
|
||||||
|
SyncConflict.table_name == table_name,
|
||||||
|
SyncConflict.record_id == record_id,
|
||||||
|
SyncConflict.status == "pending",
|
||||||
|
)
|
||||||
|
)
|
||||||
|
if row:
|
||||||
|
db.refresh(row)
|
||||||
|
db.expunge(row)
|
||||||
|
return row
|
||||||
|
return None
|
||||||
|
|
||||||
|
def _create_conflict(self, event: ChangeEventIn, origin: str, state) -> str:
|
||||||
|
from app.modules.zootech.catalog_apply import load_catalog_row
|
||||||
|
|
||||||
|
orchestrator_row = load_catalog_row(self.enterprise_id, event.table, event.record_id) or {}
|
||||||
|
if origin == "hub":
|
||||||
|
hub_snapshot = event.payload
|
||||||
|
orch_snapshot = orchestrator_row
|
||||||
|
else:
|
||||||
|
hub_snapshot = {}
|
||||||
|
orch_snapshot = event.payload or orchestrator_row
|
||||||
|
|
||||||
|
conflict_id = str(uuid4())
|
||||||
|
with session_scope() as db:
|
||||||
|
db.add(
|
||||||
|
SyncConflict(
|
||||||
|
id=conflict_id,
|
||||||
|
enterprise_id=self.enterprise_id,
|
||||||
|
table_name=event.table,
|
||||||
|
record_id=event.record_id,
|
||||||
|
farm_hub_id=event.origin_site_id if origin == "hub" else None,
|
||||||
|
orchestrator_snapshot_json=json.dumps(orch_snapshot, ensure_ascii=False),
|
||||||
|
hub_snapshot_json=json.dumps(hub_snapshot, ensure_ascii=False),
|
||||||
|
held_event_ids_json=json.dumps([event.event_id]),
|
||||||
|
status="pending",
|
||||||
|
)
|
||||||
|
)
|
||||||
|
return conflict_id
|
||||||
|
|
||||||
|
def _enqueue_fanout(self, event: ChangeEventIn) -> None:
|
||||||
|
seq = repo.next_seq(self.enterprise_id)
|
||||||
|
with session_scope() as db:
|
||||||
|
existing = db.scalar(
|
||||||
|
__import__("sqlalchemy").select(SyncOutbox).where(
|
||||||
|
SyncOutbox.enterprise_id == self.enterprise_id,
|
||||||
|
SyncOutbox.table_name == event.table,
|
||||||
|
SyncOutbox.record_id == event.record_id,
|
||||||
|
SyncOutbox.status.in_(("pending", "sent")),
|
||||||
|
)
|
||||||
|
)
|
||||||
|
payload_json = json.dumps(event.payload, ensure_ascii=False)
|
||||||
|
if existing:
|
||||||
|
existing.event_id = event.event_id
|
||||||
|
existing.action = event.action
|
||||||
|
existing.version = event.version
|
||||||
|
existing.content_hash = event.content_hash
|
||||||
|
existing.payload_json = payload_json
|
||||||
|
existing.seq = seq
|
||||||
|
existing.status = "pending"
|
||||||
|
existing.emitted_at = event.emitted_at
|
||||||
|
else:
|
||||||
|
db.add(
|
||||||
|
SyncOutbox(
|
||||||
|
enterprise_id=self.enterprise_id,
|
||||||
|
event_id=event.event_id,
|
||||||
|
origin="orchestrator",
|
||||||
|
origin_site_id=self.ORCHESTRATOR_SITE_ID,
|
||||||
|
seq=seq,
|
||||||
|
domain=event.domain,
|
||||||
|
table_name=event.table,
|
||||||
|
record_id=event.record_id,
|
||||||
|
action=event.action,
|
||||||
|
version=event.version,
|
||||||
|
content_hash=event.content_hash,
|
||||||
|
payload_json=payload_json,
|
||||||
|
status="pending",
|
||||||
|
emitted_at=event.emitted_at,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
def process_pull(self, farm_hub_id: str, cursor: int, limit: int) -> PullChangesResponse:
|
||||||
|
rows = repo.pull_events_since(
|
||||||
|
self.enterprise_id,
|
||||||
|
cursor,
|
||||||
|
limit,
|
||||||
|
exclude_origin_site_id=self.site_id,
|
||||||
|
)
|
||||||
|
events = [
|
||||||
|
ChangeEventOut(
|
||||||
|
event_id=row.event_id,
|
||||||
|
seq=row.seq,
|
||||||
|
domain=row.domain, # type: ignore[arg-type]
|
||||||
|
table=row.table_name,
|
||||||
|
record_id=row.record_id,
|
||||||
|
action=row.action, # type: ignore[arg-type]
|
||||||
|
version=row.version,
|
||||||
|
content_hash=row.content_hash,
|
||||||
|
payload=json.loads(row.payload_json or "{}"),
|
||||||
|
emitted_at=row.received_at,
|
||||||
|
origin_site_id=row.origin_site_id,
|
||||||
|
)
|
||||||
|
for row in rows
|
||||||
|
]
|
||||||
|
next_cursor = events[-1].seq if events else cursor
|
||||||
|
repo.get_or_create_cursor(self.enterprise_id, farm_hub_id, "outbound")
|
||||||
|
return PullChangesResponse(events=events, next_cursor=next_cursor)
|
||||||
|
|
||||||
|
def process_ack(self, farm_hub_id: str, body: AckChangesRequest) -> AckChangesResponse:
|
||||||
|
cursor = repo.get_or_create_cursor(self.enterprise_id, farm_hub_id, body.direction)
|
||||||
|
max_seq = cursor.last_acked_seq
|
||||||
|
for event_id in body.event_ids:
|
||||||
|
row = self._event_log_by_id(event_id)
|
||||||
|
if row:
|
||||||
|
max_seq = max(max_seq, row.seq)
|
||||||
|
repo.mark_applied(self.enterprise_id, farm_hub_id, event_id)
|
||||||
|
repo.update_cursor_ack(farm_hub_id, body.direction, max_seq)
|
||||||
|
return AckChangesResponse(last_acked_seq=max_seq)
|
||||||
|
|
||||||
|
def _event_log_by_id(self, event_id: str):
|
||||||
|
with session_scope() as db:
|
||||||
|
from sqlalchemy import select
|
||||||
|
|
||||||
|
from app.modules.sync.models import SyncEventLog
|
||||||
|
|
||||||
|
row = db.scalar(select(SyncEventLog).where(SyncEventLog.event_id == event_id))
|
||||||
|
if row:
|
||||||
|
db.refresh(row)
|
||||||
|
db.expunge(row)
|
||||||
|
return row
|
||||||
|
|
||||||
|
def resolve_conflict(self, conflict_id: str, user_id: str, resolution: str) -> None:
|
||||||
|
conflict = repo.get_conflict(conflict_id)
|
||||||
|
if not conflict or conflict.enterprise_id != self.enterprise_id:
|
||||||
|
raise ValueError("CONFLICT_NOT_FOUND")
|
||||||
|
snapshot = (
|
||||||
|
json.loads(conflict.orchestrator_snapshot_json)
|
||||||
|
if resolution == "keep_orchestrator"
|
||||||
|
else json.loads(conflict.hub_snapshot_json)
|
||||||
|
)
|
||||||
|
event = ChangeEventIn(
|
||||||
|
event_id=str(uuid4()),
|
||||||
|
seq=repo.next_seq(self.enterprise_id),
|
||||||
|
domain="global",
|
||||||
|
table=conflict.table_name,
|
||||||
|
record_id=conflict.record_id,
|
||||||
|
action="upsert",
|
||||||
|
version=int(snapshot.get("version", 1)),
|
||||||
|
content_hash=str(snapshot.get("content_hash", "")),
|
||||||
|
payload=snapshot,
|
||||||
|
emitted_at=datetime.now(UTC),
|
||||||
|
origin_site_id=self.ORCHESTRATOR_SITE_ID,
|
||||||
|
)
|
||||||
|
apply_catalog_change(
|
||||||
|
self.enterprise_id,
|
||||||
|
event.table,
|
||||||
|
event.record_id,
|
||||||
|
event.action,
|
||||||
|
event.payload,
|
||||||
|
event.version,
|
||||||
|
event.content_hash,
|
||||||
|
)
|
||||||
|
repo.upsert_record_state(
|
||||||
|
self.enterprise_id,
|
||||||
|
event.table,
|
||||||
|
event.record_id,
|
||||||
|
event.version,
|
||||||
|
event.content_hash,
|
||||||
|
event.event_id,
|
||||||
|
)
|
||||||
|
repo.append_event_log(
|
||||||
|
enterprise_id=self.enterprise_id,
|
||||||
|
event_id=event.event_id,
|
||||||
|
origin="orchestrator",
|
||||||
|
origin_site_id=self.ORCHESTRATOR_SITE_ID,
|
||||||
|
seq=event.seq or repo.next_seq(self.enterprise_id),
|
||||||
|
domain=event.domain,
|
||||||
|
table_name=event.table,
|
||||||
|
record_id=event.record_id,
|
||||||
|
action=event.action,
|
||||||
|
version=event.version,
|
||||||
|
content_hash=event.content_hash,
|
||||||
|
payload_json=json.dumps(event.payload, ensure_ascii=False),
|
||||||
|
)
|
||||||
|
repo.mark_applied(self.enterprise_id, self.ORCHESTRATOR_SITE_ID, event.event_id)
|
||||||
|
self._enqueue_fanout(event)
|
||||||
|
self._release_held_outbox(conflict.table_name, conflict.record_id)
|
||||||
|
with session_scope() as db:
|
||||||
|
row = db.get(SyncConflict, conflict_id)
|
||||||
|
if row:
|
||||||
|
row.status = "resolved"
|
||||||
|
row.resolution = resolution
|
||||||
|
row.resolved_by = user_id
|
||||||
|
row.resolved_at = datetime.now(UTC)
|
||||||
|
|
||||||
|
def _release_held_outbox(self, table_name: str, record_id: str) -> None:
|
||||||
|
with session_scope() as db:
|
||||||
|
from sqlalchemy import select
|
||||||
|
|
||||||
|
rows = list(
|
||||||
|
db.scalars(
|
||||||
|
select(SyncOutbox).where(
|
||||||
|
SyncOutbox.enterprise_id == self.enterprise_id,
|
||||||
|
SyncOutbox.table_name == table_name,
|
||||||
|
SyncOutbox.record_id == record_id,
|
||||||
|
SyncOutbox.status == "held",
|
||||||
|
)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
for row in rows:
|
||||||
|
row.status = "pending"
|
||||||
@@ -0,0 +1,299 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from datetime import UTC, datetime
|
||||||
|
from uuid import uuid4
|
||||||
|
|
||||||
|
from sqlalchemy import (
|
||||||
|
BigInteger,
|
||||||
|
Boolean,
|
||||||
|
DateTime,
|
||||||
|
ForeignKey,
|
||||||
|
Index,
|
||||||
|
Integer,
|
||||||
|
String,
|
||||||
|
Text,
|
||||||
|
UniqueConstraint,
|
||||||
|
)
|
||||||
|
from sqlalchemy.orm import Mapped, mapped_column, relationship
|
||||||
|
|
||||||
|
from app.db.base import Base
|
||||||
|
|
||||||
|
|
||||||
|
class Enterprise(Base):
|
||||||
|
__tablename__ = "enterprises"
|
||||||
|
|
||||||
|
id: Mapped[str] = mapped_column(String(36), primary_key=True, default=lambda: str(uuid4()))
|
||||||
|
name: Mapped[str] = mapped_column(String(255), nullable=False)
|
||||||
|
slug: Mapped[str] = mapped_column(String(64), nullable=False, unique=True, index=True)
|
||||||
|
status: Mapped[str] = mapped_column(String(16), nullable=False, default="active", index=True)
|
||||||
|
created_at: Mapped[datetime] = mapped_column(
|
||||||
|
DateTime(timezone=True), nullable=False, default=lambda: datetime.now(UTC)
|
||||||
|
)
|
||||||
|
updated_at: Mapped[datetime] = mapped_column(
|
||||||
|
DateTime(timezone=True),
|
||||||
|
nullable=False,
|
||||||
|
default=lambda: datetime.now(UTC),
|
||||||
|
onupdate=lambda: datetime.now(UTC),
|
||||||
|
)
|
||||||
|
|
||||||
|
members: Mapped[list["EnterpriseMember"]] = relationship(back_populates="enterprise", cascade="all, delete-orphan")
|
||||||
|
farm_hubs: Mapped[list["FarmHub"]] = relationship(back_populates="enterprise", cascade="all, delete-orphan")
|
||||||
|
|
||||||
|
|
||||||
|
class EnterpriseMember(Base):
|
||||||
|
__tablename__ = "enterprise_members"
|
||||||
|
__table_args__ = (UniqueConstraint("user_id", "enterprise_id", name="uq_enterprise_members_user_enterprise"),)
|
||||||
|
|
||||||
|
id: Mapped[str] = mapped_column(String(36), primary_key=True, default=lambda: str(uuid4()))
|
||||||
|
user_id: Mapped[str] = mapped_column(String(36), ForeignKey("users.id", ondelete="CASCADE"), nullable=False, index=True)
|
||||||
|
enterprise_id: Mapped[str] = mapped_column(
|
||||||
|
String(36), ForeignKey("enterprises.id", ondelete="CASCADE"), nullable=False, index=True
|
||||||
|
)
|
||||||
|
role: Mapped[str] = mapped_column(String(32), nullable=False, default="viewer")
|
||||||
|
created_at: Mapped[datetime] = mapped_column(
|
||||||
|
DateTime(timezone=True), nullable=False, default=lambda: datetime.now(UTC)
|
||||||
|
)
|
||||||
|
|
||||||
|
enterprise: Mapped[Enterprise] = relationship(back_populates="members")
|
||||||
|
|
||||||
|
|
||||||
|
class FarmHub(Base):
|
||||||
|
__tablename__ = "farm_hubs"
|
||||||
|
__table_args__ = (UniqueConstraint("hub_site_id", name="uq_farm_hubs_hub_site_id"),)
|
||||||
|
|
||||||
|
id: Mapped[str] = mapped_column(String(36), primary_key=True, default=lambda: str(uuid4()))
|
||||||
|
enterprise_id: Mapped[str] = mapped_column(
|
||||||
|
String(36), ForeignKey("enterprises.id", ondelete="CASCADE"), nullable=False, index=True
|
||||||
|
)
|
||||||
|
name: Mapped[str] = mapped_column(String(255), nullable=False)
|
||||||
|
hub_site_id: Mapped[str] = mapped_column(String(36), nullable=False)
|
||||||
|
url: Mapped[str | None] = mapped_column(String(512), nullable=True)
|
||||||
|
status: Mapped[str] = mapped_column(String(16), nullable=False, default="pending", index=True)
|
||||||
|
last_seen: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True)
|
||||||
|
wesp_version: Mapped[str | None] = mapped_column(String(64), nullable=True)
|
||||||
|
created_at: Mapped[datetime] = mapped_column(
|
||||||
|
DateTime(timezone=True), nullable=False, default=lambda: datetime.now(UTC)
|
||||||
|
)
|
||||||
|
updated_at: Mapped[datetime] = mapped_column(
|
||||||
|
DateTime(timezone=True),
|
||||||
|
nullable=False,
|
||||||
|
default=lambda: datetime.now(UTC),
|
||||||
|
onupdate=lambda: datetime.now(UTC),
|
||||||
|
)
|
||||||
|
|
||||||
|
enterprise: Mapped[Enterprise] = relationship(back_populates="farm_hubs")
|
||||||
|
credentials: Mapped[list["HubCredential"]] = relationship(back_populates="farm_hub", cascade="all, delete-orphan")
|
||||||
|
farm_access: Mapped[list["UserFarmAccess"]] = relationship(back_populates="farm_hub", cascade="all, delete-orphan")
|
||||||
|
|
||||||
|
|
||||||
|
class UserFarmAccess(Base):
|
||||||
|
__tablename__ = "user_farm_access"
|
||||||
|
__table_args__ = (UniqueConstraint("user_id", "farm_hub_id", name="uq_user_farm_access_user_farm"),)
|
||||||
|
|
||||||
|
id: Mapped[str] = mapped_column(String(36), primary_key=True, default=lambda: str(uuid4()))
|
||||||
|
user_id: Mapped[str] = mapped_column(String(36), ForeignKey("users.id", ondelete="CASCADE"), nullable=False, index=True)
|
||||||
|
farm_hub_id: Mapped[str] = mapped_column(
|
||||||
|
String(36), ForeignKey("farm_hubs.id", ondelete="CASCADE"), nullable=False, index=True
|
||||||
|
)
|
||||||
|
created_at: Mapped[datetime] = mapped_column(
|
||||||
|
DateTime(timezone=True), nullable=False, default=lambda: datetime.now(UTC)
|
||||||
|
)
|
||||||
|
|
||||||
|
farm_hub: Mapped[FarmHub] = relationship(back_populates="farm_access")
|
||||||
|
|
||||||
|
|
||||||
|
class HubCredential(Base):
|
||||||
|
__tablename__ = "hub_credentials"
|
||||||
|
|
||||||
|
id: Mapped[str] = mapped_column(String(36), primary_key=True, default=lambda: str(uuid4()))
|
||||||
|
farm_hub_id: Mapped[str] = mapped_column(
|
||||||
|
String(36), ForeignKey("farm_hubs.id", ondelete="CASCADE"), nullable=False, index=True
|
||||||
|
)
|
||||||
|
secret_hash: Mapped[str] = mapped_column(String(255), nullable=False)
|
||||||
|
paired_at: Mapped[datetime] = mapped_column(
|
||||||
|
DateTime(timezone=True), nullable=False, default=lambda: datetime.now(UTC)
|
||||||
|
)
|
||||||
|
revoked_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True)
|
||||||
|
|
||||||
|
farm_hub: Mapped[FarmHub] = relationship(back_populates="credentials")
|
||||||
|
|
||||||
|
|
||||||
|
class HubPairingSession(Base):
|
||||||
|
__tablename__ = "hub_pairing_sessions"
|
||||||
|
|
||||||
|
id: Mapped[str] = mapped_column(String(36), primary_key=True, default=lambda: str(uuid4()))
|
||||||
|
enterprise_id: Mapped[str] = mapped_column(
|
||||||
|
String(36), ForeignKey("enterprises.id", ondelete="CASCADE"), nullable=False, index=True
|
||||||
|
)
|
||||||
|
code: Mapped[str] = mapped_column(String(6), nullable=False, index=True)
|
||||||
|
code_hash: Mapped[str] = mapped_column(String(64), nullable=False)
|
||||||
|
expires_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), nullable=False)
|
||||||
|
confirmed_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True)
|
||||||
|
farm_hub_id: Mapped[str | None] = mapped_column(String(36), ForeignKey("farm_hubs.id", ondelete="SET NULL"), nullable=True)
|
||||||
|
created_at: Mapped[datetime] = mapped_column(
|
||||||
|
DateTime(timezone=True), nullable=False, default=lambda: datetime.now(UTC)
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class SyncOutbox(Base):
|
||||||
|
__tablename__ = "sync_outbox"
|
||||||
|
__table_args__ = (
|
||||||
|
Index("idx_sync_outbox_enterprise_status", "enterprise_id", "status"),
|
||||||
|
Index("idx_sync_outbox_table_record", "table_name", "record_id"),
|
||||||
|
)
|
||||||
|
|
||||||
|
id: Mapped[str] = mapped_column(String(36), primary_key=True, default=lambda: str(uuid4()))
|
||||||
|
enterprise_id: Mapped[str] = mapped_column(
|
||||||
|
String(36), ForeignKey("enterprises.id", ondelete="CASCADE"), nullable=False, index=True
|
||||||
|
)
|
||||||
|
event_id: Mapped[str] = mapped_column(String(36), nullable=False, unique=True)
|
||||||
|
origin: Mapped[str] = mapped_column(String(32), nullable=False)
|
||||||
|
origin_site_id: Mapped[str | None] = mapped_column(String(36), nullable=True)
|
||||||
|
seq: Mapped[int] = mapped_column(BigInteger, nullable=False, index=True)
|
||||||
|
domain: Mapped[str] = mapped_column(String(16), nullable=False, default="global")
|
||||||
|
table_name: Mapped[str] = mapped_column(String(64), nullable=False)
|
||||||
|
record_id: Mapped[str] = mapped_column(String(128), nullable=False)
|
||||||
|
action: Mapped[str] = mapped_column(String(16), nullable=False)
|
||||||
|
version: Mapped[int] = mapped_column(Integer, nullable=False, default=1)
|
||||||
|
content_hash: Mapped[str] = mapped_column(String(64), nullable=False, default="")
|
||||||
|
payload_json: Mapped[str] = mapped_column(Text, nullable=False, default="{}")
|
||||||
|
status: Mapped[str] = mapped_column(String(16), nullable=False, default="pending", index=True)
|
||||||
|
emitted_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), nullable=False)
|
||||||
|
created_at: Mapped[datetime] = mapped_column(
|
||||||
|
DateTime(timezone=True), nullable=False, default=lambda: datetime.now(UTC)
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class SyncEventLog(Base):
|
||||||
|
__tablename__ = "sync_event_log"
|
||||||
|
|
||||||
|
id: Mapped[str] = mapped_column(String(36), primary_key=True, default=lambda: str(uuid4()))
|
||||||
|
enterprise_id: Mapped[str] = mapped_column(
|
||||||
|
String(36), ForeignKey("enterprises.id", ondelete="CASCADE"), nullable=False, index=True
|
||||||
|
)
|
||||||
|
event_id: Mapped[str] = mapped_column(String(36), nullable=False, unique=True)
|
||||||
|
origin: Mapped[str] = mapped_column(String(32), nullable=False)
|
||||||
|
origin_site_id: Mapped[str | None] = mapped_column(String(36), nullable=True)
|
||||||
|
seq: Mapped[int] = mapped_column(BigInteger, nullable=False)
|
||||||
|
domain: Mapped[str] = mapped_column(String(16), nullable=False, default="global")
|
||||||
|
table_name: Mapped[str] = mapped_column(String(64), nullable=False)
|
||||||
|
record_id: Mapped[str] = mapped_column(String(128), nullable=False)
|
||||||
|
action: Mapped[str] = mapped_column(String(16), nullable=False)
|
||||||
|
version: Mapped[int] = mapped_column(Integer, nullable=False, default=1)
|
||||||
|
content_hash: Mapped[str] = mapped_column(String(64), nullable=False, default="")
|
||||||
|
payload_json: Mapped[str] = mapped_column(Text, nullable=False, default="{}")
|
||||||
|
received_at: Mapped[datetime] = mapped_column(
|
||||||
|
DateTime(timezone=True), nullable=False, default=lambda: datetime.now(UTC)
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class SyncAppliedEvent(Base):
|
||||||
|
__tablename__ = "sync_applied_events"
|
||||||
|
__table_args__ = (UniqueConstraint("site_id", "event_id", name="uq_sync_applied_events_site_event"),)
|
||||||
|
|
||||||
|
id: Mapped[str] = mapped_column(String(36), primary_key=True, default=lambda: str(uuid4()))
|
||||||
|
enterprise_id: Mapped[str] = mapped_column(
|
||||||
|
String(36), ForeignKey("enterprises.id", ondelete="CASCADE"), nullable=False, index=True
|
||||||
|
)
|
||||||
|
site_id: Mapped[str] = mapped_column(String(36), nullable=False, index=True)
|
||||||
|
event_id: Mapped[str] = mapped_column(String(36), nullable=False, index=True)
|
||||||
|
applied_at: Mapped[datetime] = mapped_column(
|
||||||
|
DateTime(timezone=True), nullable=False, default=lambda: datetime.now(UTC)
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class SyncCursor(Base):
|
||||||
|
__tablename__ = "sync_cursors"
|
||||||
|
__table_args__ = (UniqueConstraint("farm_hub_id", "direction", name="uq_sync_cursors_hub_direction"),)
|
||||||
|
|
||||||
|
id: Mapped[str] = mapped_column(String(36), primary_key=True, default=lambda: str(uuid4()))
|
||||||
|
enterprise_id: Mapped[str] = mapped_column(
|
||||||
|
String(36), ForeignKey("enterprises.id", ondelete="CASCADE"), nullable=False, index=True
|
||||||
|
)
|
||||||
|
farm_hub_id: Mapped[str] = mapped_column(
|
||||||
|
String(36), ForeignKey("farm_hubs.id", ondelete="CASCADE"), nullable=False, index=True
|
||||||
|
)
|
||||||
|
direction: Mapped[str] = mapped_column(String(16), nullable=False)
|
||||||
|
last_acked_seq: Mapped[int] = mapped_column(BigInteger, nullable=False, default=0)
|
||||||
|
last_pulled_seq: Mapped[int] = mapped_column(BigInteger, nullable=False, default=0)
|
||||||
|
updated_at: Mapped[datetime] = mapped_column(
|
||||||
|
DateTime(timezone=True),
|
||||||
|
nullable=False,
|
||||||
|
default=lambda: datetime.now(UTC),
|
||||||
|
onupdate=lambda: datetime.now(UTC),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class SyncRecordState(Base):
|
||||||
|
__tablename__ = "sync_record_state"
|
||||||
|
__table_args__ = (
|
||||||
|
UniqueConstraint("enterprise_id", "table_name", "record_id", name="uq_sync_record_state_ent_table_record"),
|
||||||
|
)
|
||||||
|
|
||||||
|
id: Mapped[str] = mapped_column(String(36), primary_key=True, default=lambda: str(uuid4()))
|
||||||
|
enterprise_id: Mapped[str] = mapped_column(
|
||||||
|
String(36), ForeignKey("enterprises.id", ondelete="CASCADE"), nullable=False, index=True
|
||||||
|
)
|
||||||
|
table_name: Mapped[str] = mapped_column(String(64), nullable=False)
|
||||||
|
record_id: Mapped[str] = mapped_column(String(128), nullable=False)
|
||||||
|
agreed_version: Mapped[int] = mapped_column(Integer, nullable=False, default=1)
|
||||||
|
agreed_hash: Mapped[str] = mapped_column(String(64), nullable=False, default="")
|
||||||
|
last_event_id: Mapped[str | None] = mapped_column(String(36), nullable=True)
|
||||||
|
updated_at: Mapped[datetime] = mapped_column(
|
||||||
|
DateTime(timezone=True),
|
||||||
|
nullable=False,
|
||||||
|
default=lambda: datetime.now(UTC),
|
||||||
|
onupdate=lambda: datetime.now(UTC),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class SyncConflict(Base):
|
||||||
|
__tablename__ = "sync_conflicts"
|
||||||
|
|
||||||
|
id: Mapped[str] = mapped_column(String(36), primary_key=True, default=lambda: str(uuid4()))
|
||||||
|
enterprise_id: Mapped[str] = mapped_column(
|
||||||
|
String(36), ForeignKey("enterprises.id", ondelete="CASCADE"), nullable=False, index=True
|
||||||
|
)
|
||||||
|
table_name: Mapped[str] = mapped_column(String(64), nullable=False)
|
||||||
|
record_id: Mapped[str] = mapped_column(String(128), nullable=False, index=True)
|
||||||
|
farm_hub_id: Mapped[str | None] = mapped_column(String(36), ForeignKey("farm_hubs.id", ondelete="SET NULL"), nullable=True)
|
||||||
|
orchestrator_snapshot_json: Mapped[str] = mapped_column(Text, nullable=False, default="{}")
|
||||||
|
hub_snapshot_json: Mapped[str] = mapped_column(Text, nullable=False, default="{}")
|
||||||
|
held_event_ids_json: Mapped[str] = mapped_column(Text, nullable=False, default="[]")
|
||||||
|
status: Mapped[str] = mapped_column(String(16), nullable=False, default="pending", index=True)
|
||||||
|
resolution: Mapped[str | None] = mapped_column(String(32), nullable=True)
|
||||||
|
resolved_by: Mapped[str | None] = mapped_column(String(36), nullable=True)
|
||||||
|
resolved_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True)
|
||||||
|
created_at: Mapped[datetime] = mapped_column(
|
||||||
|
DateTime(timezone=True), nullable=False, default=lambda: datetime.now(UTC)
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class SyncReconcileRun(Base):
|
||||||
|
__tablename__ = "sync_reconcile_runs"
|
||||||
|
|
||||||
|
id: Mapped[str] = mapped_column(String(36), primary_key=True, default=lambda: str(uuid4()))
|
||||||
|
enterprise_id: Mapped[str] = mapped_column(
|
||||||
|
String(36), ForeignKey("enterprises.id", ondelete="CASCADE"), nullable=False, index=True
|
||||||
|
)
|
||||||
|
started_at: Mapped[datetime] = mapped_column(
|
||||||
|
DateTime(timezone=True), nullable=False, default=lambda: datetime.now(UTC)
|
||||||
|
)
|
||||||
|
finished_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True)
|
||||||
|
mismatches_count: Mapped[int] = mapped_column(Integer, nullable=False, default=0)
|
||||||
|
auto_repaired: Mapped[int] = mapped_column(Integer, nullable=False, default=0)
|
||||||
|
status: Mapped[str] = mapped_column(String(16), nullable=False, default="running")
|
||||||
|
|
||||||
|
|
||||||
|
class ReportSyncState(Base):
|
||||||
|
__tablename__ = "report_sync_state"
|
||||||
|
|
||||||
|
id: Mapped[str] = mapped_column(String(36), primary_key=True, default=lambda: str(uuid4()))
|
||||||
|
farm_hub_id: Mapped[str] = mapped_column(
|
||||||
|
String(36), ForeignKey("farm_hubs.id", ondelete="CASCADE"), nullable=False, unique=True
|
||||||
|
)
|
||||||
|
enterprise_id: Mapped[str] = mapped_column(
|
||||||
|
String(36), ForeignKey("enterprises.id", ondelete="CASCADE"), nullable=False, index=True
|
||||||
|
)
|
||||||
|
last_report_cursor: Mapped[int] = mapped_column(BigInteger, nullable=False, default=0)
|
||||||
|
last_pull_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True)
|
||||||
@@ -0,0 +1,41 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import json
|
||||||
|
from datetime import UTC, datetime
|
||||||
|
|
||||||
|
from sqlalchemy import select
|
||||||
|
|
||||||
|
from app.core.database import session_scope
|
||||||
|
from app.modules.sync.models import FarmHub, SyncRecordState
|
||||||
|
from app.modules.zootech.catalog_apply import load_catalog_row
|
||||||
|
|
||||||
|
|
||||||
|
def run_sync_reconcile() -> int:
|
||||||
|
"""Compare orchestrator catalog content_hash vs sync_record_state agreed_hash."""
|
||||||
|
mismatches = 0
|
||||||
|
with session_scope() as db:
|
||||||
|
hubs = list(db.scalars(select(FarmHub).where(FarmHub.status == "active")))
|
||||||
|
states = list(db.scalars(select(SyncRecordState)))
|
||||||
|
for state in states:
|
||||||
|
row = load_catalog_row(state.enterprise_id, state.table_name, state.record_id)
|
||||||
|
if not row:
|
||||||
|
continue
|
||||||
|
catalog_hash = str(row.get("content_hash") or "")
|
||||||
|
if state.agreed_hash and catalog_hash and state.agreed_hash != catalog_hash:
|
||||||
|
mismatches += 1
|
||||||
|
state.agreed_hash = catalog_hash
|
||||||
|
state.agreed_version = int(row.get("version") or state.agreed_version)
|
||||||
|
state.updated_at = datetime.now(UTC)
|
||||||
|
for hub in hubs:
|
||||||
|
from app.modules.sync.models import SyncReconcileRun
|
||||||
|
|
||||||
|
db.add(
|
||||||
|
SyncReconcileRun(
|
||||||
|
enterprise_id=hub.enterprise_id,
|
||||||
|
status="completed",
|
||||||
|
mismatches_count=mismatches,
|
||||||
|
auto_repaired=mismatches,
|
||||||
|
finished_at=datetime.now(UTC),
|
||||||
|
)
|
||||||
|
)
|
||||||
|
return mismatches
|
||||||
@@ -0,0 +1,394 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from datetime import UTC, datetime, timedelta
|
||||||
|
from uuid import uuid4
|
||||||
|
|
||||||
|
from sqlalchemy import func, select
|
||||||
|
|
||||||
|
from app.core.crypto import hash_opaque_token
|
||||||
|
from app.core.database import session_scope
|
||||||
|
from app.modules.sync.models import (
|
||||||
|
Enterprise,
|
||||||
|
EnterpriseMember,
|
||||||
|
FarmHub,
|
||||||
|
HubCredential,
|
||||||
|
HubPairingSession,
|
||||||
|
SyncAppliedEvent,
|
||||||
|
SyncConflict,
|
||||||
|
SyncCursor,
|
||||||
|
SyncEventLog,
|
||||||
|
SyncOutbox,
|
||||||
|
SyncRecordState,
|
||||||
|
UserFarmAccess,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _detach(db, instance):
|
||||||
|
db.refresh(instance)
|
||||||
|
db.expunge(instance)
|
||||||
|
return instance
|
||||||
|
|
||||||
|
|
||||||
|
def get_enterprise_by_id(enterprise_id: str) -> Enterprise | None:
|
||||||
|
with session_scope() as db:
|
||||||
|
row = db.get(Enterprise, enterprise_id)
|
||||||
|
return _detach(db, row) if row else None
|
||||||
|
|
||||||
|
|
||||||
|
def get_enterprise_by_slug(slug: str) -> Enterprise | None:
|
||||||
|
with session_scope() as db:
|
||||||
|
row = db.scalar(select(Enterprise).where(Enterprise.slug == slug))
|
||||||
|
return _detach(db, row) if row else None
|
||||||
|
|
||||||
|
|
||||||
|
def create_enterprise(name: str, slug: str) -> Enterprise:
|
||||||
|
with session_scope() as db:
|
||||||
|
row = Enterprise(name=name, slug=slug, status="active")
|
||||||
|
db.add(row)
|
||||||
|
db.flush()
|
||||||
|
return _detach(db, row)
|
||||||
|
|
||||||
|
|
||||||
|
def get_member(user_id: str, enterprise_id: str) -> EnterpriseMember | None:
|
||||||
|
with session_scope() as db:
|
||||||
|
row = db.scalar(
|
||||||
|
select(EnterpriseMember).where(
|
||||||
|
EnterpriseMember.user_id == user_id,
|
||||||
|
EnterpriseMember.enterprise_id == enterprise_id,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
return _detach(db, row) if row else None
|
||||||
|
|
||||||
|
|
||||||
|
def add_member(user_id: str, enterprise_id: str, role: str) -> EnterpriseMember:
|
||||||
|
with session_scope() as db:
|
||||||
|
row = EnterpriseMember(user_id=user_id, enterprise_id=enterprise_id, role=role)
|
||||||
|
db.add(row)
|
||||||
|
db.flush()
|
||||||
|
return _detach(db, row)
|
||||||
|
|
||||||
|
|
||||||
|
def list_members(enterprise_id: str) -> list[EnterpriseMember]:
|
||||||
|
with session_scope() as db:
|
||||||
|
rows = list(
|
||||||
|
db.scalars(
|
||||||
|
select(EnterpriseMember).where(EnterpriseMember.enterprise_id == enterprise_id)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
return [_detach(db, row) for row in rows]
|
||||||
|
|
||||||
|
|
||||||
|
def set_member_role(user_id: str, enterprise_id: str, role: str) -> EnterpriseMember | None:
|
||||||
|
with session_scope() as db:
|
||||||
|
row = db.scalar(
|
||||||
|
select(EnterpriseMember).where(
|
||||||
|
EnterpriseMember.user_id == user_id,
|
||||||
|
EnterpriseMember.enterprise_id == enterprise_id,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
if not row:
|
||||||
|
return None
|
||||||
|
row.role = role
|
||||||
|
db.flush()
|
||||||
|
return _detach(db, row)
|
||||||
|
|
||||||
|
|
||||||
|
def list_farm_access(user_id: str, enterprise_id: str) -> list[str]:
|
||||||
|
with session_scope() as db:
|
||||||
|
rows = db.scalars(
|
||||||
|
select(UserFarmAccess.farm_hub_id)
|
||||||
|
.join(FarmHub, FarmHub.id == UserFarmAccess.farm_hub_id)
|
||||||
|
.where(UserFarmAccess.user_id == user_id, FarmHub.enterprise_id == enterprise_id)
|
||||||
|
).all()
|
||||||
|
return list(rows)
|
||||||
|
|
||||||
|
|
||||||
|
def grant_farm_access(user_id: str, farm_hub_id: str) -> UserFarmAccess:
|
||||||
|
with session_scope() as db:
|
||||||
|
existing = db.scalar(
|
||||||
|
select(UserFarmAccess).where(
|
||||||
|
UserFarmAccess.user_id == user_id,
|
||||||
|
UserFarmAccess.farm_hub_id == farm_hub_id,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
if existing:
|
||||||
|
return _detach(db, existing)
|
||||||
|
row = UserFarmAccess(user_id=user_id, farm_hub_id=farm_hub_id)
|
||||||
|
db.add(row)
|
||||||
|
db.flush()
|
||||||
|
return _detach(db, row)
|
||||||
|
|
||||||
|
|
||||||
|
def get_farm_hub_by_site_id(hub_site_id: str) -> FarmHub | None:
|
||||||
|
with session_scope() as db:
|
||||||
|
row = db.scalar(select(FarmHub).where(FarmHub.hub_site_id == hub_site_id))
|
||||||
|
return _detach(db, row) if row else None
|
||||||
|
|
||||||
|
|
||||||
|
def get_farm_hub_by_id(farm_hub_id: str) -> FarmHub | None:
|
||||||
|
with session_scope() as db:
|
||||||
|
row = db.get(FarmHub, farm_hub_id)
|
||||||
|
return _detach(db, row) if row else None
|
||||||
|
|
||||||
|
|
||||||
|
def list_farm_hubs(enterprise_id: str) -> list[FarmHub]:
|
||||||
|
with session_scope() as db:
|
||||||
|
rows = list(db.scalars(select(FarmHub).where(FarmHub.enterprise_id == enterprise_id).order_by(FarmHub.name)))
|
||||||
|
return [_detach(db, row) for row in rows]
|
||||||
|
|
||||||
|
|
||||||
|
def create_pairing_session(enterprise_id: str, code: str, code_hash: str, ttl_minutes: int = 15) -> HubPairingSession:
|
||||||
|
with session_scope() as db:
|
||||||
|
row = HubPairingSession(
|
||||||
|
enterprise_id=enterprise_id,
|
||||||
|
code=code,
|
||||||
|
code_hash=code_hash,
|
||||||
|
expires_at=datetime.now(UTC) + timedelta(minutes=ttl_minutes),
|
||||||
|
)
|
||||||
|
db.add(row)
|
||||||
|
db.flush()
|
||||||
|
return _detach(db, row)
|
||||||
|
|
||||||
|
|
||||||
|
def get_pairing_session_by_code_hash(code_hash: str) -> HubPairingSession | None:
|
||||||
|
with session_scope() as db:
|
||||||
|
row = db.scalar(
|
||||||
|
select(HubPairingSession).where(
|
||||||
|
HubPairingSession.code_hash == code_hash,
|
||||||
|
HubPairingSession.confirmed_at.is_(None),
|
||||||
|
)
|
||||||
|
)
|
||||||
|
return _detach(db, row) if row else None
|
||||||
|
|
||||||
|
|
||||||
|
def confirm_pairing_session(session_id: str, farm_hub_id: str) -> None:
|
||||||
|
with session_scope() as db:
|
||||||
|
row = db.get(HubPairingSession, session_id)
|
||||||
|
if row:
|
||||||
|
row.confirmed_at = datetime.now(UTC)
|
||||||
|
row.farm_hub_id = farm_hub_id
|
||||||
|
|
||||||
|
|
||||||
|
def create_farm_hub(
|
||||||
|
enterprise_id: str,
|
||||||
|
name: str,
|
||||||
|
hub_site_id: str,
|
||||||
|
url: str | None = None,
|
||||||
|
) -> FarmHub:
|
||||||
|
with session_scope() as db:
|
||||||
|
row = FarmHub(
|
||||||
|
enterprise_id=enterprise_id,
|
||||||
|
name=name,
|
||||||
|
hub_site_id=hub_site_id,
|
||||||
|
url=url,
|
||||||
|
status="active",
|
||||||
|
)
|
||||||
|
db.add(row)
|
||||||
|
db.flush()
|
||||||
|
return _detach(db, row)
|
||||||
|
|
||||||
|
|
||||||
|
def create_hub_credential(farm_hub_id: str, api_key: str) -> HubCredential:
|
||||||
|
from app.core.crypto import hash_opaque_token as _hash
|
||||||
|
|
||||||
|
with session_scope() as db:
|
||||||
|
row = HubCredential(farm_hub_id=farm_hub_id, secret_hash=_hash(api_key))
|
||||||
|
db.add(row)
|
||||||
|
db.flush()
|
||||||
|
return _detach(db, row)
|
||||||
|
|
||||||
|
|
||||||
|
def verify_hub_credential(hub_site_id: str, api_key: str) -> FarmHub | None:
|
||||||
|
from app.core.crypto import hash_opaque_token as _hash
|
||||||
|
|
||||||
|
key_hash = _hash(api_key)
|
||||||
|
with session_scope() as db:
|
||||||
|
row = db.scalar(
|
||||||
|
select(FarmHub)
|
||||||
|
.join(HubCredential, HubCredential.farm_hub_id == FarmHub.id)
|
||||||
|
.where(
|
||||||
|
FarmHub.hub_site_id == hub_site_id,
|
||||||
|
HubCredential.secret_hash == key_hash,
|
||||||
|
HubCredential.revoked_at.is_(None),
|
||||||
|
)
|
||||||
|
)
|
||||||
|
return _detach(db, row) if row else None
|
||||||
|
|
||||||
|
|
||||||
|
def update_hub_heartbeat(farm_hub_id: str, wesp_version: str | None) -> None:
|
||||||
|
with session_scope() as db:
|
||||||
|
row = db.get(FarmHub, farm_hub_id)
|
||||||
|
if row:
|
||||||
|
row.last_seen = datetime.now(UTC)
|
||||||
|
if wesp_version:
|
||||||
|
row.wesp_version = wesp_version
|
||||||
|
|
||||||
|
|
||||||
|
def next_seq(enterprise_id: str) -> int:
|
||||||
|
with session_scope() as db:
|
||||||
|
current = db.scalar(
|
||||||
|
select(func.max(SyncEventLog.seq)).where(SyncEventLog.enterprise_id == enterprise_id)
|
||||||
|
)
|
||||||
|
return int(current or 0) + 1
|
||||||
|
|
||||||
|
|
||||||
|
def event_exists(event_id: str) -> bool:
|
||||||
|
with session_scope() as db:
|
||||||
|
row = db.scalar(select(SyncEventLog.id).where(SyncEventLog.event_id == event_id))
|
||||||
|
return row is not None
|
||||||
|
|
||||||
|
|
||||||
|
def append_event_log(**kwargs) -> SyncEventLog:
|
||||||
|
with session_scope() as db:
|
||||||
|
row = SyncEventLog(**kwargs)
|
||||||
|
db.add(row)
|
||||||
|
db.flush()
|
||||||
|
return _detach(db, row)
|
||||||
|
|
||||||
|
|
||||||
|
def applied_event_exists(site_id: str, event_id: str) -> bool:
|
||||||
|
with session_scope() as db:
|
||||||
|
row = db.scalar(
|
||||||
|
select(SyncAppliedEvent.id).where(
|
||||||
|
SyncAppliedEvent.site_id == site_id,
|
||||||
|
SyncAppliedEvent.event_id == event_id,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
return row is not None
|
||||||
|
|
||||||
|
|
||||||
|
def mark_applied(enterprise_id: str, site_id: str, event_id: str) -> None:
|
||||||
|
with session_scope() as db:
|
||||||
|
db.add(
|
||||||
|
SyncAppliedEvent(
|
||||||
|
enterprise_id=enterprise_id,
|
||||||
|
site_id=site_id,
|
||||||
|
event_id=event_id,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def get_record_state(enterprise_id: str, table_name: str, record_id: str) -> SyncRecordState | None:
|
||||||
|
with session_scope() as db:
|
||||||
|
row = db.scalar(
|
||||||
|
select(SyncRecordState).where(
|
||||||
|
SyncRecordState.enterprise_id == enterprise_id,
|
||||||
|
SyncRecordState.table_name == table_name,
|
||||||
|
SyncRecordState.record_id == record_id,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
return _detach(db, row) if row else None
|
||||||
|
|
||||||
|
|
||||||
|
def upsert_record_state(
|
||||||
|
enterprise_id: str,
|
||||||
|
table_name: str,
|
||||||
|
record_id: str,
|
||||||
|
agreed_version: int,
|
||||||
|
agreed_hash: str,
|
||||||
|
last_event_id: str,
|
||||||
|
) -> None:
|
||||||
|
with session_scope() as db:
|
||||||
|
row = db.scalar(
|
||||||
|
select(SyncRecordState).where(
|
||||||
|
SyncRecordState.enterprise_id == enterprise_id,
|
||||||
|
SyncRecordState.table_name == table_name,
|
||||||
|
SyncRecordState.record_id == record_id,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
if row:
|
||||||
|
row.agreed_version = agreed_version
|
||||||
|
row.agreed_hash = agreed_hash
|
||||||
|
row.last_event_id = last_event_id
|
||||||
|
row.updated_at = datetime.now(UTC)
|
||||||
|
else:
|
||||||
|
db.add(
|
||||||
|
SyncRecordState(
|
||||||
|
enterprise_id=enterprise_id,
|
||||||
|
table_name=table_name,
|
||||||
|
record_id=record_id,
|
||||||
|
agreed_version=agreed_version,
|
||||||
|
agreed_hash=agreed_hash,
|
||||||
|
last_event_id=last_event_id,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def list_conflicts(enterprise_id: str, status: str = "pending") -> list[SyncConflict]:
|
||||||
|
with session_scope() as db:
|
||||||
|
rows = list(
|
||||||
|
db.scalars(
|
||||||
|
select(SyncConflict)
|
||||||
|
.where(SyncConflict.enterprise_id == enterprise_id, SyncConflict.status == status)
|
||||||
|
.order_by(SyncConflict.created_at.desc())
|
||||||
|
)
|
||||||
|
)
|
||||||
|
return [_detach(db, row) for row in rows]
|
||||||
|
|
||||||
|
|
||||||
|
def get_conflict(conflict_id: str) -> SyncConflict | None:
|
||||||
|
with session_scope() as db:
|
||||||
|
row = db.get(SyncConflict, conflict_id)
|
||||||
|
return _detach(db, row) if row else None
|
||||||
|
|
||||||
|
|
||||||
|
def get_or_create_cursor(enterprise_id: str, farm_hub_id: str, direction: str) -> SyncCursor:
|
||||||
|
with session_scope() as db:
|
||||||
|
row = db.scalar(
|
||||||
|
select(SyncCursor).where(
|
||||||
|
SyncCursor.farm_hub_id == farm_hub_id,
|
||||||
|
SyncCursor.direction == direction,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
if row:
|
||||||
|
return _detach(db, row)
|
||||||
|
row = SyncCursor(
|
||||||
|
enterprise_id=enterprise_id,
|
||||||
|
farm_hub_id=farm_hub_id,
|
||||||
|
direction=direction,
|
||||||
|
last_acked_seq=0,
|
||||||
|
last_pulled_seq=0,
|
||||||
|
)
|
||||||
|
db.add(row)
|
||||||
|
db.flush()
|
||||||
|
return _detach(db, row)
|
||||||
|
|
||||||
|
|
||||||
|
def update_cursor_ack(farm_hub_id: str, direction: str, last_acked_seq: int) -> None:
|
||||||
|
with session_scope() as db:
|
||||||
|
row = db.scalar(
|
||||||
|
select(SyncCursor).where(
|
||||||
|
SyncCursor.farm_hub_id == farm_hub_id,
|
||||||
|
SyncCursor.direction == direction,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
if row:
|
||||||
|
row.last_acked_seq = max(row.last_acked_seq, last_acked_seq)
|
||||||
|
row.updated_at = datetime.now(UTC)
|
||||||
|
|
||||||
|
|
||||||
|
def pull_events_since(
|
||||||
|
enterprise_id: str,
|
||||||
|
cursor: int,
|
||||||
|
limit: int,
|
||||||
|
*,
|
||||||
|
exclude_origin_site_id: str | None = None,
|
||||||
|
) -> list[SyncEventLog]:
|
||||||
|
with session_scope() as db:
|
||||||
|
query = select(SyncEventLog).where(
|
||||||
|
SyncEventLog.enterprise_id == enterprise_id,
|
||||||
|
SyncEventLog.seq > cursor,
|
||||||
|
)
|
||||||
|
if exclude_origin_site_id:
|
||||||
|
query = query.where(
|
||||||
|
(SyncEventLog.origin_site_id.is_(None))
|
||||||
|
| (SyncEventLog.origin_site_id != exclude_origin_site_id)
|
||||||
|
)
|
||||||
|
rows = list(db.scalars(query.order_by(SyncEventLog.seq.asc()).limit(limit)))
|
||||||
|
return [_detach(db, row) for row in rows]
|
||||||
|
|
||||||
|
|
||||||
|
def get_farm_hub_site_id(farm_hub_id: str) -> str | None:
|
||||||
|
with session_scope() as db:
|
||||||
|
row = db.get(FarmHub, farm_hub_id)
|
||||||
|
return row.hub_site_id if row else None
|
||||||
@@ -0,0 +1,178 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from fastapi import APIRouter, Depends, HTTPException, Query, status
|
||||||
|
|
||||||
|
from app.core.dependencies import require_superuser
|
||||||
|
from app.modules.sync import service
|
||||||
|
from app.modules.sync.schemas import (
|
||||||
|
AckChangesRequest,
|
||||||
|
AckChangesResponse,
|
||||||
|
HeartbeatRequest,
|
||||||
|
HeartbeatResponse,
|
||||||
|
PairConfirmRequest,
|
||||||
|
PairConfirmResponse,
|
||||||
|
PairStartRequest,
|
||||||
|
PullChangesRequest,
|
||||||
|
PullChangesResponse,
|
||||||
|
PushChangesRequest,
|
||||||
|
PushChangesResponse,
|
||||||
|
ResolveConflictRequest,
|
||||||
|
SyncCapabilitiesResponse,
|
||||||
|
)
|
||||||
|
from app.modules.sync.service import SyncServiceError
|
||||||
|
from app.modules.sync.tenant import (
|
||||||
|
HubPrincipal,
|
||||||
|
TenantContext,
|
||||||
|
get_hub_auth,
|
||||||
|
get_tenant_context,
|
||||||
|
require_enterprise_zootech,
|
||||||
|
)
|
||||||
|
from app.modules.users.models import User
|
||||||
|
from app.core.dependencies import get_current_user
|
||||||
|
|
||||||
|
router = APIRouter()
|
||||||
|
|
||||||
|
|
||||||
|
def _map_error(exc: SyncServiceError) -> HTTPException:
|
||||||
|
code = str(exc)
|
||||||
|
status_code = status.HTTP_400_BAD_REQUEST
|
||||||
|
if code in {"ENTERPRISE_ADMIN_ONLY", "ENTERPRISE_FORBIDDEN", "ENTERPRISE_ZOOTECH_ONLY"}:
|
||||||
|
status_code = status.HTTP_403_FORBIDDEN
|
||||||
|
if code in {"INVALID_PAIRING_CODE", "PAIRING_CODE_EXPIRED"}:
|
||||||
|
status_code = status.HTTP_400_BAD_REQUEST
|
||||||
|
if code == "CONFLICT_NOT_FOUND":
|
||||||
|
status_code = status.HTTP_404_NOT_FOUND
|
||||||
|
return HTTPException(status_code=status_code, detail=code)
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/reports/push")
|
||||||
|
def reports_push(body: PushChangesRequest, hub: HubPrincipal = Depends(get_hub_auth)) -> PushChangesResponse:
|
||||||
|
"""Report domain ingest — same durability path as global push."""
|
||||||
|
return service.push_changes(hub, body)
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/reports/refresh")
|
||||||
|
def reports_refresh(
|
||||||
|
farm_hub_id: str = Query(...),
|
||||||
|
enterprise_id: str = Query(...),
|
||||||
|
tenant: TenantContext = Depends(require_enterprise_zootech),
|
||||||
|
):
|
||||||
|
if tenant.farm_hub_ids is not None and farm_hub_id not in tenant.farm_hub_ids:
|
||||||
|
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="FARM_ACCESS_FORBIDDEN")
|
||||||
|
return {"status": "queued", "farm_hub_id": farm_hub_id, "enterprise_id": tenant.enterprise_id}
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/metrics")
|
||||||
|
def sync_metrics(
|
||||||
|
enterprise_id: str = Query(...),
|
||||||
|
tenant: TenantContext = Depends(require_enterprise_zootech),
|
||||||
|
):
|
||||||
|
if tenant.enterprise_id != enterprise_id:
|
||||||
|
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="ENTERPRISE_FORBIDDEN")
|
||||||
|
return service.get_sync_metrics(enterprise_id)
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/capabilities", response_model=SyncCapabilitiesResponse)
|
||||||
|
def capabilities() -> SyncCapabilitiesResponse:
|
||||||
|
return service.get_capabilities()
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/changes/push", response_model=PushChangesResponse)
|
||||||
|
def push_changes(body: PushChangesRequest, hub: HubPrincipal = Depends(get_hub_auth)) -> PushChangesResponse:
|
||||||
|
return service.push_changes(hub, body)
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/changes/pull", response_model=PullChangesResponse)
|
||||||
|
def pull_changes(body: PullChangesRequest, hub: HubPrincipal = Depends(get_hub_auth)) -> PullChangesResponse:
|
||||||
|
return service.pull_changes(hub, body)
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/changes/ack", response_model=AckChangesResponse)
|
||||||
|
def ack_changes(body: AckChangesRequest, hub: HubPrincipal = Depends(get_hub_auth)) -> AckChangesResponse:
|
||||||
|
return service.ack_changes(hub, body)
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/hubs/heartbeat", response_model=HeartbeatResponse)
|
||||||
|
def heartbeat(body: HeartbeatRequest, hub: HubPrincipal = Depends(get_hub_auth)) -> HeartbeatResponse:
|
||||||
|
return service.hub_heartbeat(hub, body)
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/hub/conflicts")
|
||||||
|
def list_hub_conflicts(hub: HubPrincipal = Depends(get_hub_auth)):
|
||||||
|
return service.list_conflicts_for_enterprise(hub.enterprise_id)
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/hub/conflicts/{conflict_id}")
|
||||||
|
def get_hub_conflict(conflict_id: str, hub: HubPrincipal = Depends(get_hub_auth)):
|
||||||
|
try:
|
||||||
|
return service.get_conflict_detail(conflict_id, hub.enterprise_id)
|
||||||
|
except SyncServiceError as exc:
|
||||||
|
raise _map_error(exc) from exc
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/hub/conflicts/{conflict_id}/resolve")
|
||||||
|
def resolve_hub_conflict(
|
||||||
|
conflict_id: str,
|
||||||
|
body: ResolveConflictRequest,
|
||||||
|
hub: HubPrincipal = Depends(get_hub_auth),
|
||||||
|
):
|
||||||
|
try:
|
||||||
|
service.resolve_conflict(conflict_id, hub.enterprise_id, f"hub:{hub.hub_site_id}", body)
|
||||||
|
return {"status": "ok"}
|
||||||
|
except SyncServiceError as exc:
|
||||||
|
raise _map_error(exc) from exc
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/conflicts")
|
||||||
|
def list_conflicts(
|
||||||
|
enterprise_id: str = Query(...),
|
||||||
|
tenant: TenantContext = Depends(require_enterprise_zootech),
|
||||||
|
):
|
||||||
|
if tenant.enterprise_id != enterprise_id and tenant.enterprise_role != "admin":
|
||||||
|
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="ENTERPRISE_FORBIDDEN")
|
||||||
|
return service.list_conflicts_for_enterprise(enterprise_id)
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/conflicts/{conflict_id}")
|
||||||
|
def get_conflict(
|
||||||
|
conflict_id: str,
|
||||||
|
enterprise_id: str = Query(...),
|
||||||
|
tenant: TenantContext = Depends(require_enterprise_zootech),
|
||||||
|
):
|
||||||
|
try:
|
||||||
|
return service.get_conflict_detail(conflict_id, enterprise_id)
|
||||||
|
except SyncServiceError as exc:
|
||||||
|
raise _map_error(exc) from exc
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/conflicts/{conflict_id}/resolve")
|
||||||
|
def resolve_conflict(
|
||||||
|
conflict_id: str,
|
||||||
|
body: ResolveConflictRequest,
|
||||||
|
enterprise_id: str = Query(...),
|
||||||
|
tenant: TenantContext = Depends(require_enterprise_zootech),
|
||||||
|
):
|
||||||
|
try:
|
||||||
|
service.resolve_conflict(conflict_id, enterprise_id, tenant.user_id or "", body)
|
||||||
|
return {"status": "ok"}
|
||||||
|
except SyncServiceError as exc:
|
||||||
|
raise _map_error(exc) from exc
|
||||||
|
|
||||||
|
|
||||||
|
enterprise_router = APIRouter()
|
||||||
|
|
||||||
|
|
||||||
|
@enterprise_router.post("/pair/start")
|
||||||
|
def pair_start(body: PairStartRequest, user: User = Depends(get_current_user)):
|
||||||
|
try:
|
||||||
|
return service.start_pairing(user, body)
|
||||||
|
except SyncServiceError as exc:
|
||||||
|
raise _map_error(exc) from exc
|
||||||
|
|
||||||
|
|
||||||
|
@enterprise_router.post("/pair/confirm", response_model=PairConfirmResponse)
|
||||||
|
def pair_confirm(body: PairConfirmRequest) -> PairConfirmResponse:
|
||||||
|
try:
|
||||||
|
return service.confirm_pairing(body)
|
||||||
|
except SyncServiceError as exc:
|
||||||
|
raise _map_error(exc) from exc
|
||||||
@@ -0,0 +1,165 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from dataclasses import dataclass
|
||||||
|
from datetime import datetime
|
||||||
|
from typing import Any, Literal
|
||||||
|
|
||||||
|
from pydantic import BaseModel, Field
|
||||||
|
|
||||||
|
|
||||||
|
ChangeAction = Literal["upsert", "delete"]
|
||||||
|
ChangeDomain = Literal["global", "report"]
|
||||||
|
EnterpriseRole = Literal["admin", "zootech", "viewer"]
|
||||||
|
ConflictResolution = Literal["keep_orchestrator", "keep_hub"]
|
||||||
|
|
||||||
|
|
||||||
|
class ChangeEventIn(BaseModel):
|
||||||
|
event_id: str
|
||||||
|
seq: int
|
||||||
|
domain: ChangeDomain = "global"
|
||||||
|
table: str = Field(alias="table")
|
||||||
|
record_id: str
|
||||||
|
action: ChangeAction
|
||||||
|
version: int = 1
|
||||||
|
content_hash: str = ""
|
||||||
|
payload: dict[str, Any] = Field(default_factory=dict)
|
||||||
|
emitted_at: datetime
|
||||||
|
origin_site_id: str | None = None
|
||||||
|
|
||||||
|
model_config = {"populate_by_name": True}
|
||||||
|
|
||||||
|
|
||||||
|
class ChangeEventOut(BaseModel):
|
||||||
|
event_id: str
|
||||||
|
seq: int
|
||||||
|
domain: ChangeDomain
|
||||||
|
table: str
|
||||||
|
record_id: str
|
||||||
|
action: ChangeAction
|
||||||
|
version: int
|
||||||
|
content_hash: str
|
||||||
|
payload: dict[str, Any]
|
||||||
|
emitted_at: datetime
|
||||||
|
origin_site_id: str | None = None
|
||||||
|
|
||||||
|
|
||||||
|
class PushChangesRequest(BaseModel):
|
||||||
|
events: list[ChangeEventIn]
|
||||||
|
|
||||||
|
|
||||||
|
class PushChangesResponse(BaseModel):
|
||||||
|
applied_event_ids: list[str]
|
||||||
|
held_event_ids: list[str]
|
||||||
|
conflicts: list[str]
|
||||||
|
|
||||||
|
|
||||||
|
class PullChangesRequest(BaseModel):
|
||||||
|
cursor: int = 0
|
||||||
|
limit: int = 100
|
||||||
|
|
||||||
|
|
||||||
|
class PullChangesResponse(BaseModel):
|
||||||
|
events: list[ChangeEventOut]
|
||||||
|
next_cursor: int
|
||||||
|
|
||||||
|
|
||||||
|
class AckChangesRequest(BaseModel):
|
||||||
|
event_ids: list[str]
|
||||||
|
direction: Literal["inbound", "outbound"] = "inbound"
|
||||||
|
|
||||||
|
|
||||||
|
class AckChangesResponse(BaseModel):
|
||||||
|
last_acked_seq: int
|
||||||
|
|
||||||
|
|
||||||
|
class SyncCapabilitiesResponse(BaseModel):
|
||||||
|
protocol_version: str = "1.0"
|
||||||
|
domains: list[str] = Field(default_factory=lambda: ["global", "report"])
|
||||||
|
|
||||||
|
|
||||||
|
class HeartbeatRequest(BaseModel):
|
||||||
|
wesp_version: str | None = None
|
||||||
|
lag_seconds: int | None = None
|
||||||
|
|
||||||
|
|
||||||
|
class HeartbeatResponse(BaseModel):
|
||||||
|
status: str = "ok"
|
||||||
|
server_time: datetime
|
||||||
|
|
||||||
|
|
||||||
|
class PairStartRequest(BaseModel):
|
||||||
|
enterprise_id: str
|
||||||
|
farm_name: str
|
||||||
|
|
||||||
|
|
||||||
|
class PairStartResponse(BaseModel):
|
||||||
|
session_id: str
|
||||||
|
code: str
|
||||||
|
expires_at: datetime
|
||||||
|
|
||||||
|
|
||||||
|
class PairConfirmRequest(BaseModel):
|
||||||
|
code: str
|
||||||
|
hub_site_id: str
|
||||||
|
hub_name: str | None = None
|
||||||
|
hub_url: str | None = None
|
||||||
|
|
||||||
|
|
||||||
|
class PairConfirmResponse(BaseModel):
|
||||||
|
farm_hub_id: str
|
||||||
|
hub_site_id: str
|
||||||
|
api_key: str
|
||||||
|
enterprise_id: str
|
||||||
|
|
||||||
|
|
||||||
|
class ConflictSummary(BaseModel):
|
||||||
|
id: str
|
||||||
|
table_name: str
|
||||||
|
record_id: str
|
||||||
|
farm_hub_id: str | None
|
||||||
|
status: str
|
||||||
|
created_at: datetime
|
||||||
|
|
||||||
|
|
||||||
|
class ConflictDetail(ConflictSummary):
|
||||||
|
orchestrator_snapshot: dict[str, Any]
|
||||||
|
hub_snapshot: dict[str, Any]
|
||||||
|
held_event_ids: list[str]
|
||||||
|
|
||||||
|
|
||||||
|
class ResolveConflictRequest(BaseModel):
|
||||||
|
resolution: ConflictResolution
|
||||||
|
|
||||||
|
|
||||||
|
class EnterpriseOut(BaseModel):
|
||||||
|
id: str
|
||||||
|
name: str
|
||||||
|
slug: str
|
||||||
|
status: str
|
||||||
|
|
||||||
|
|
||||||
|
class FarmHubOut(BaseModel):
|
||||||
|
id: str
|
||||||
|
enterprise_id: str
|
||||||
|
name: str
|
||||||
|
hub_site_id: str
|
||||||
|
url: str | None
|
||||||
|
status: str
|
||||||
|
last_seen: datetime | None
|
||||||
|
wesp_version: str | None
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class HubAuthContext:
|
||||||
|
farm_hub_id: str
|
||||||
|
hub_site_id: str
|
||||||
|
enterprise_id: str
|
||||||
|
farm_hub: Any
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class TenantContext:
|
||||||
|
enterprise_id: str
|
||||||
|
enterprise_role: EnterpriseRole | None
|
||||||
|
user_id: str | None
|
||||||
|
farm_hub_ids: list[str] | None
|
||||||
@@ -0,0 +1,178 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import json
|
||||||
|
import secrets
|
||||||
|
from datetime import UTC, datetime
|
||||||
|
from uuid import uuid4
|
||||||
|
|
||||||
|
from app.core.crypto import generate_secret_token_urlsafe, hash_opaque_token
|
||||||
|
from app.core.exceptions import DomainError
|
||||||
|
from app.modules.sync import repository as repo
|
||||||
|
from app.modules.sync.schemas import (
|
||||||
|
AckChangesRequest,
|
||||||
|
AckChangesResponse,
|
||||||
|
ChangeEventIn,
|
||||||
|
ChangeEventOut,
|
||||||
|
ConflictDetail,
|
||||||
|
ConflictSummary,
|
||||||
|
HeartbeatRequest,
|
||||||
|
HeartbeatResponse,
|
||||||
|
PairConfirmRequest,
|
||||||
|
PairConfirmResponse,
|
||||||
|
PairStartRequest,
|
||||||
|
PairStartResponse,
|
||||||
|
PullChangesRequest,
|
||||||
|
PullChangesResponse,
|
||||||
|
PushChangesRequest,
|
||||||
|
PushChangesResponse,
|
||||||
|
ResolveConflictRequest,
|
||||||
|
SyncCapabilitiesResponse,
|
||||||
|
)
|
||||||
|
from app.modules.sync.engine import SyncEngine
|
||||||
|
from app.modules.users.models import User
|
||||||
|
|
||||||
|
|
||||||
|
class SyncServiceError(DomainError):
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
def get_capabilities() -> SyncCapabilitiesResponse:
|
||||||
|
return SyncCapabilitiesResponse()
|
||||||
|
|
||||||
|
|
||||||
|
def start_pairing(user: User, body: PairStartRequest) -> PairStartResponse:
|
||||||
|
member = repo.get_member(user.id, body.enterprise_id)
|
||||||
|
if not member or member.role != "admin":
|
||||||
|
if not user.is_superuser:
|
||||||
|
raise SyncServiceError("ENTERPRISE_ADMIN_ONLY")
|
||||||
|
enterprise = repo.get_enterprise_by_id(body.enterprise_id)
|
||||||
|
if not enterprise:
|
||||||
|
raise SyncServiceError("ENTERPRISE_NOT_FOUND")
|
||||||
|
code = f"{secrets.randbelow(900000) + 100000:06d}"
|
||||||
|
session = repo.create_pairing_session(body.enterprise_id, code, hash_opaque_token(code))
|
||||||
|
return PairStartResponse(session_id=session.id, code=code, expires_at=session.expires_at)
|
||||||
|
|
||||||
|
|
||||||
|
def confirm_pairing(body: PairConfirmRequest) -> PairConfirmResponse:
|
||||||
|
code_hash = hash_opaque_token(body.code.strip())
|
||||||
|
session = repo.get_pairing_session_by_code_hash(code_hash)
|
||||||
|
if not session:
|
||||||
|
raise SyncServiceError("INVALID_PAIRING_CODE")
|
||||||
|
if session.expires_at.replace(tzinfo=UTC) < datetime.now(UTC):
|
||||||
|
raise SyncServiceError("PAIRING_CODE_EXPIRED")
|
||||||
|
hub = repo.create_farm_hub(
|
||||||
|
session.enterprise_id,
|
||||||
|
body.hub_name or f"Hub {body.hub_site_id[:8]}",
|
||||||
|
body.hub_site_id,
|
||||||
|
body.hub_url,
|
||||||
|
)
|
||||||
|
api_key = generate_secret_token_urlsafe(48)
|
||||||
|
repo.create_hub_credential(hub.id, api_key)
|
||||||
|
repo.confirm_pairing_session(session.id, hub.id)
|
||||||
|
return PairConfirmResponse(
|
||||||
|
farm_hub_id=hub.id,
|
||||||
|
hub_site_id=hub.hub_site_id,
|
||||||
|
api_key=api_key,
|
||||||
|
enterprise_id=session.enterprise_id,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def hub_heartbeat(hub, body: HeartbeatRequest) -> HeartbeatResponse:
|
||||||
|
repo.update_hub_heartbeat(hub.farm_hub_id, body.wesp_version)
|
||||||
|
return HeartbeatResponse(server_time=datetime.now(UTC))
|
||||||
|
|
||||||
|
|
||||||
|
def push_changes(hub, body: PushChangesRequest) -> PushChangesResponse:
|
||||||
|
return SyncEngine(hub.enterprise_id, hub.hub_site_id).process_push(body.events, origin="hub")
|
||||||
|
|
||||||
|
|
||||||
|
def pull_changes(hub, body: PullChangesRequest) -> PullChangesResponse:
|
||||||
|
return SyncEngine(hub.enterprise_id, hub.hub_site_id).process_pull(hub.farm_hub_id, body.cursor, body.limit)
|
||||||
|
|
||||||
|
|
||||||
|
def ack_changes(hub, body: AckChangesRequest) -> AckChangesResponse:
|
||||||
|
return SyncEngine(hub.enterprise_id, hub.hub_site_id).process_ack(hub.farm_hub_id, body)
|
||||||
|
|
||||||
|
|
||||||
|
def list_conflicts_for_enterprise(enterprise_id: str) -> list[ConflictSummary]:
|
||||||
|
rows = repo.list_conflicts(enterprise_id)
|
||||||
|
return [
|
||||||
|
ConflictSummary(
|
||||||
|
id=row.id,
|
||||||
|
table_name=row.table_name,
|
||||||
|
record_id=row.record_id,
|
||||||
|
farm_hub_id=row.farm_hub_id,
|
||||||
|
status=row.status,
|
||||||
|
created_at=row.created_at,
|
||||||
|
)
|
||||||
|
for row in rows
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
def get_conflict_detail(conflict_id: str, enterprise_id: str) -> ConflictDetail:
|
||||||
|
row = repo.get_conflict(conflict_id)
|
||||||
|
if not row or row.enterprise_id != enterprise_id:
|
||||||
|
raise SyncServiceError("CONFLICT_NOT_FOUND")
|
||||||
|
return ConflictDetail(
|
||||||
|
id=row.id,
|
||||||
|
table_name=row.table_name,
|
||||||
|
record_id=row.record_id,
|
||||||
|
farm_hub_id=row.farm_hub_id,
|
||||||
|
status=row.status,
|
||||||
|
created_at=row.created_at,
|
||||||
|
orchestrator_snapshot=json.loads(row.orchestrator_snapshot_json or "{}"),
|
||||||
|
hub_snapshot=json.loads(row.hub_snapshot_json or "{}"),
|
||||||
|
held_event_ids=json.loads(row.held_event_ids_json or "[]"),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def resolve_conflict(conflict_id: str, enterprise_id: str, user_id: str, body: ResolveConflictRequest) -> None:
|
||||||
|
SyncEngine(enterprise_id, "orchestrator").resolve_conflict(conflict_id, user_id, body.resolution)
|
||||||
|
|
||||||
|
|
||||||
|
def get_sync_metrics(enterprise_id: str) -> dict:
|
||||||
|
from sqlalchemy import func, select
|
||||||
|
|
||||||
|
from app.core.database import session_scope
|
||||||
|
from app.modules.sync.models import FarmHub, SyncConflict, SyncCursor, SyncEventLog, SyncOutbox
|
||||||
|
|
||||||
|
with session_scope() as db:
|
||||||
|
hubs = list(db.scalars(select(FarmHub).where(FarmHub.enterprise_id == enterprise_id)))
|
||||||
|
outbox_pending = db.scalar(
|
||||||
|
select(func.count())
|
||||||
|
.select_from(SyncOutbox)
|
||||||
|
.where(SyncOutbox.enterprise_id == enterprise_id, SyncOutbox.status == "pending")
|
||||||
|
)
|
||||||
|
conflicts_pending = db.scalar(
|
||||||
|
select(func.count())
|
||||||
|
.select_from(SyncConflict)
|
||||||
|
.where(SyncConflict.enterprise_id == enterprise_id, SyncConflict.status == "pending")
|
||||||
|
)
|
||||||
|
max_seq = db.scalar(
|
||||||
|
select(func.max(SyncEventLog.seq)).where(SyncEventLog.enterprise_id == enterprise_id)
|
||||||
|
) or 0
|
||||||
|
hub_metrics = []
|
||||||
|
for h in hubs:
|
||||||
|
cursor = db.scalar(
|
||||||
|
select(SyncCursor).where(
|
||||||
|
SyncCursor.farm_hub_id == h.id,
|
||||||
|
SyncCursor.direction == "inbound",
|
||||||
|
)
|
||||||
|
)
|
||||||
|
lag = max(0, int(max_seq) - int(cursor.last_acked_seq if cursor else 0))
|
||||||
|
hub_metrics.append(
|
||||||
|
{
|
||||||
|
"farm_hub_id": h.id,
|
||||||
|
"name": h.name,
|
||||||
|
"hub_site_id": h.hub_site_id,
|
||||||
|
"status": h.status,
|
||||||
|
"last_seen": h.last_seen.isoformat() if h.last_seen else None,
|
||||||
|
"sync_lag": lag,
|
||||||
|
}
|
||||||
|
)
|
||||||
|
return {
|
||||||
|
"hubs": hub_metrics,
|
||||||
|
"outbox_pending": int(outbox_pending or 0),
|
||||||
|
"conflicts_pending": int(conflicts_pending or 0),
|
||||||
|
"max_event_seq": int(max_seq),
|
||||||
|
}
|
||||||
@@ -0,0 +1,92 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from dataclasses import dataclass
|
||||||
|
|
||||||
|
from fastapi import Depends, Header, HTTPException, status
|
||||||
|
from fastapi.security import HTTPAuthorizationCredentials
|
||||||
|
|
||||||
|
from app.core.dependencies import bearer, get_current_user
|
||||||
|
from app.core.security import decode_access_token
|
||||||
|
from app.modules.sync import repository as sync_repo
|
||||||
|
from app.modules.sync.schemas import HubAuthContext, TenantContext
|
||||||
|
from app.modules.users.models import User
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class HubPrincipal:
|
||||||
|
farm_hub_id: str
|
||||||
|
hub_site_id: str
|
||||||
|
enterprise_id: str
|
||||||
|
|
||||||
|
|
||||||
|
def get_hub_auth(authorization: str | None = Header(default=None)) -> HubPrincipal:
|
||||||
|
if not authorization or not authorization.startswith("Hub "):
|
||||||
|
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="HUB_UNAUTHORIZED")
|
||||||
|
token = authorization[4:].strip()
|
||||||
|
if ":" not in token:
|
||||||
|
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="HUB_UNAUTHORIZED")
|
||||||
|
hub_site_id, api_key = token.split(":", 1)
|
||||||
|
hub = sync_repo.verify_hub_credential(hub_site_id.strip(), api_key.strip())
|
||||||
|
if not hub:
|
||||||
|
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="HUB_UNAUTHORIZED")
|
||||||
|
return HubPrincipal(farm_hub_id=hub.id, hub_site_id=hub.hub_site_id, enterprise_id=hub.enterprise_id)
|
||||||
|
|
||||||
|
|
||||||
|
def get_tenant_context(
|
||||||
|
enterprise_id: str,
|
||||||
|
user: User = Depends(get_current_user),
|
||||||
|
credentials: HTTPAuthorizationCredentials | None = Depends(bearer),
|
||||||
|
) -> TenantContext:
|
||||||
|
if user.is_superuser:
|
||||||
|
return TenantContext(
|
||||||
|
enterprise_id=enterprise_id,
|
||||||
|
enterprise_role="admin",
|
||||||
|
user_id=user.id,
|
||||||
|
farm_hub_ids=None,
|
||||||
|
)
|
||||||
|
if credentials:
|
||||||
|
try:
|
||||||
|
payload = decode_access_token(credentials.credentials)
|
||||||
|
if payload.get("enterprise_id") == enterprise_id:
|
||||||
|
farm_ids = payload.get("farm_ids")
|
||||||
|
return TenantContext(
|
||||||
|
enterprise_id=enterprise_id,
|
||||||
|
enterprise_role=payload.get("enterprise_role", "viewer"), # type: ignore[arg-type]
|
||||||
|
user_id=user.id,
|
||||||
|
farm_hub_ids=farm_ids,
|
||||||
|
)
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
member = sync_repo.get_member(user.id, enterprise_id)
|
||||||
|
if not member:
|
||||||
|
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="ENTERPRISE_FORBIDDEN")
|
||||||
|
farm_ids = None if member.role == "admin" else sync_repo.list_farm_access(user.id, enterprise_id)
|
||||||
|
return TenantContext(
|
||||||
|
enterprise_id=enterprise_id,
|
||||||
|
enterprise_role=member.role, # type: ignore[arg-type]
|
||||||
|
user_id=user.id,
|
||||||
|
farm_hub_ids=farm_ids,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def require_farm_access(
|
||||||
|
farm_hub_id: str,
|
||||||
|
enterprise_id: str,
|
||||||
|
tenant: TenantContext,
|
||||||
|
) -> None:
|
||||||
|
if tenant.enterprise_role == "admin" or tenant.farm_hub_ids is None:
|
||||||
|
return
|
||||||
|
if farm_hub_id not in (tenant.farm_hub_ids or []):
|
||||||
|
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="FARM_ACCESS_FORBIDDEN")
|
||||||
|
|
||||||
|
|
||||||
|
def require_enterprise_admin(tenant: TenantContext = Depends(get_tenant_context)) -> TenantContext:
|
||||||
|
if tenant.enterprise_role != "admin":
|
||||||
|
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="ENTERPRISE_ADMIN_ONLY")
|
||||||
|
return tenant
|
||||||
|
|
||||||
|
|
||||||
|
def require_enterprise_zootech(tenant: TenantContext = Depends(get_tenant_context)) -> TenantContext:
|
||||||
|
if tenant.enterprise_role not in ("admin", "zootech"):
|
||||||
|
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="ENTERPRISE_ZOOTECH_ONLY")
|
||||||
|
return tenant
|
||||||
@@ -0,0 +1,16 @@
|
|||||||
|
from fastapi import APIRouter, HTTPException, Query
|
||||||
|
|
||||||
|
from app.core.config import settings
|
||||||
|
from app.core.email import memory_mailer
|
||||||
|
|
||||||
|
router = APIRouter()
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/emails/latest-token")
|
||||||
|
async def latest_email_token(to: str = Query(...), template: str = Query(...)):
|
||||||
|
if not settings.enable_test_routes or settings.email_delivery_mode != "memory":
|
||||||
|
raise HTTPException(status_code=404, detail="NOT_FOUND")
|
||||||
|
token = memory_mailer.latest_token(to, template)
|
||||||
|
if not token:
|
||||||
|
raise HTTPException(status_code=404, detail="TOKEN_NOT_FOUND")
|
||||||
|
return {"token": token}
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
"""Users module."""
|
||||||
@@ -0,0 +1,52 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from datetime import UTC, datetime
|
||||||
|
from uuid import uuid4
|
||||||
|
|
||||||
|
from sqlalchemy import DateTime, ForeignKey, Integer, String, Text
|
||||||
|
from sqlalchemy.orm import Mapped, mapped_column, relationship
|
||||||
|
|
||||||
|
from app.db.base import Base
|
||||||
|
|
||||||
|
|
||||||
|
class User(Base):
|
||||||
|
__tablename__ = "users"
|
||||||
|
|
||||||
|
id: Mapped[str] = mapped_column(String(36), primary_key=True, default=lambda: str(uuid4()))
|
||||||
|
email: Mapped[str] = mapped_column(String(255), unique=True, nullable=False, index=True)
|
||||||
|
password_hash: Mapped[str] = mapped_column(String(255), nullable=False)
|
||||||
|
role: Mapped[str] = mapped_column(String(16), nullable=False, default="user")
|
||||||
|
is_superuser: Mapped[bool] = mapped_column(nullable=False, default=False)
|
||||||
|
status: Mapped[str] = mapped_column(String(16), nullable=False, default="pending", index=True)
|
||||||
|
failed_login_attempts: Mapped[int] = mapped_column(Integer, nullable=False, default=0)
|
||||||
|
locked_until: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True)
|
||||||
|
email_verified_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True)
|
||||||
|
created_at: Mapped[datetime] = mapped_column(
|
||||||
|
DateTime(timezone=True), nullable=False, default=lambda: datetime.now(UTC)
|
||||||
|
)
|
||||||
|
updated_at: Mapped[datetime] = mapped_column(
|
||||||
|
DateTime(timezone=True),
|
||||||
|
nullable=False,
|
||||||
|
default=lambda: datetime.now(UTC),
|
||||||
|
onupdate=lambda: datetime.now(UTC),
|
||||||
|
)
|
||||||
|
|
||||||
|
profile: Mapped["UserProfile"] = relationship(
|
||||||
|
back_populates="user",
|
||||||
|
uselist=False,
|
||||||
|
cascade="all, delete-orphan",
|
||||||
|
passive_deletes=True,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class UserProfile(Base):
|
||||||
|
__tablename__ = "user_profiles"
|
||||||
|
|
||||||
|
user_id: Mapped[str] = mapped_column(
|
||||||
|
String(36), ForeignKey("users.id", ondelete="CASCADE"), primary_key=True
|
||||||
|
)
|
||||||
|
display_name: Mapped[str] = mapped_column(String(120), nullable=False)
|
||||||
|
avatar_url: Mapped[str | None] = mapped_column(String(512), nullable=True)
|
||||||
|
metadata_json: Mapped[str | None] = mapped_column(Text, nullable=True)
|
||||||
|
|
||||||
|
user: Mapped[User] = relationship(back_populates="profile")
|
||||||
@@ -0,0 +1,164 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from datetime import UTC, datetime
|
||||||
|
from uuid import uuid4
|
||||||
|
|
||||||
|
from sqlalchemy import func, select
|
||||||
|
|
||||||
|
from app.core.database import session_scope
|
||||||
|
from app.modules.users.models import User, UserProfile
|
||||||
|
|
||||||
|
ALLOWED_ROLES = {"user", "admin"}
|
||||||
|
ALLOWED_STATUSES = {"pending", "active", "blocked"}
|
||||||
|
|
||||||
|
|
||||||
|
def _detach(db, instance):
|
||||||
|
db.refresh(instance)
|
||||||
|
db.expunge(instance)
|
||||||
|
return instance
|
||||||
|
|
||||||
|
|
||||||
|
def create_user(
|
||||||
|
email: str,
|
||||||
|
password_hash: str,
|
||||||
|
role: str = "user",
|
||||||
|
is_superuser: bool = False,
|
||||||
|
status: str = "pending",
|
||||||
|
) -> User:
|
||||||
|
if role not in ALLOWED_ROLES:
|
||||||
|
raise ValueError("INVALID_ROLE")
|
||||||
|
if status not in ALLOWED_STATUSES:
|
||||||
|
raise ValueError("INVALID_STATUS")
|
||||||
|
if is_superuser and role != "admin":
|
||||||
|
raise ValueError("SUPERUSER_REQUIRES_ADMIN")
|
||||||
|
with session_scope() as db:
|
||||||
|
user = User(
|
||||||
|
id=str(uuid4()),
|
||||||
|
email=email.lower(),
|
||||||
|
password_hash=password_hash,
|
||||||
|
role=role,
|
||||||
|
is_superuser=is_superuser,
|
||||||
|
status=status,
|
||||||
|
)
|
||||||
|
db.add(user)
|
||||||
|
db.flush()
|
||||||
|
profile = UserProfile(user_id=user.id, display_name=email.split("@")[0])
|
||||||
|
db.add(profile)
|
||||||
|
db.flush()
|
||||||
|
return _detach(db, user)
|
||||||
|
|
||||||
|
|
||||||
|
def get_user_by_email(email: str) -> User | None:
|
||||||
|
with session_scope() as db:
|
||||||
|
user = db.scalar(select(User).where(User.email == email.lower()))
|
||||||
|
if not user:
|
||||||
|
return None
|
||||||
|
return _detach(db, user)
|
||||||
|
|
||||||
|
|
||||||
|
def get_user_by_id(user_id: str) -> User | None:
|
||||||
|
with session_scope() as db:
|
||||||
|
user = db.get(User, user_id)
|
||||||
|
if not user:
|
||||||
|
return None
|
||||||
|
return _detach(db, user)
|
||||||
|
|
||||||
|
|
||||||
|
def update_user(user: User) -> None:
|
||||||
|
if user.role not in ALLOWED_ROLES:
|
||||||
|
raise ValueError("INVALID_ROLE")
|
||||||
|
if user.status not in ALLOWED_STATUSES:
|
||||||
|
raise ValueError("INVALID_STATUS")
|
||||||
|
if user.is_superuser and user.role != "admin":
|
||||||
|
raise ValueError("SUPERUSER_REQUIRES_ADMIN")
|
||||||
|
with session_scope() as db:
|
||||||
|
db_user = db.get(User, user.id)
|
||||||
|
if not db_user:
|
||||||
|
return
|
||||||
|
db_user.email = user.email
|
||||||
|
db_user.password_hash = user.password_hash
|
||||||
|
db_user.role = user.role
|
||||||
|
db_user.is_superuser = user.is_superuser
|
||||||
|
db_user.status = user.status
|
||||||
|
db_user.failed_login_attempts = user.failed_login_attempts
|
||||||
|
db_user.locked_until = user.locked_until
|
||||||
|
db_user.email_verified_at = user.email_verified_at
|
||||||
|
db_user.updated_at = datetime.now(UTC)
|
||||||
|
|
||||||
|
|
||||||
|
def list_users(page: int, limit: int) -> tuple[list[User], int]:
|
||||||
|
with session_scope() as db:
|
||||||
|
total = db.scalar(select(func.count()).select_from(User)) or 0
|
||||||
|
users = db.scalars(
|
||||||
|
select(User).order_by(User.created_at.desc()).offset((page - 1) * limit).limit(limit)
|
||||||
|
).all()
|
||||||
|
return [_detach(db, user) for user in users], total
|
||||||
|
|
||||||
|
|
||||||
|
def count_users() -> int:
|
||||||
|
with session_scope() as db:
|
||||||
|
return db.scalar(select(func.count()).select_from(User)) or 0
|
||||||
|
|
||||||
|
|
||||||
|
def count_users_registered_today() -> int:
|
||||||
|
today = datetime.now(UTC).date()
|
||||||
|
with session_scope() as db:
|
||||||
|
return (
|
||||||
|
db.scalar(
|
||||||
|
select(func.count())
|
||||||
|
.select_from(User)
|
||||||
|
.where(func.date(User.created_at) == today)
|
||||||
|
)
|
||||||
|
or 0
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def count_admins() -> int:
|
||||||
|
with session_scope() as db:
|
||||||
|
return db.scalar(select(func.count()).select_from(User).where(User.role == "admin")) or 0
|
||||||
|
|
||||||
|
|
||||||
|
def count_superusers() -> int:
|
||||||
|
with session_scope() as db:
|
||||||
|
return (
|
||||||
|
db.scalar(
|
||||||
|
select(func.count())
|
||||||
|
.select_from(User)
|
||||||
|
.where(User.role == "admin", User.is_superuser.is_(True))
|
||||||
|
)
|
||||||
|
or 0
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def delete_user(user_id: str) -> bool:
|
||||||
|
with session_scope() as db:
|
||||||
|
user = db.get(User, user_id)
|
||||||
|
if not user:
|
||||||
|
return False
|
||||||
|
db.delete(user)
|
||||||
|
return True
|
||||||
|
|
||||||
|
|
||||||
|
def get_profile(user_id: str) -> UserProfile:
|
||||||
|
with session_scope() as db:
|
||||||
|
profile = db.get(UserProfile, user_id)
|
||||||
|
if not profile:
|
||||||
|
raise KeyError(user_id)
|
||||||
|
return _detach(db, profile)
|
||||||
|
|
||||||
|
|
||||||
|
def update_profile(
|
||||||
|
user_id: str,
|
||||||
|
display_name: str | None = None,
|
||||||
|
avatar_url: str | None = None,
|
||||||
|
) -> UserProfile:
|
||||||
|
with session_scope() as db:
|
||||||
|
profile = db.get(UserProfile, user_id)
|
||||||
|
if not profile:
|
||||||
|
raise KeyError(user_id)
|
||||||
|
if display_name is not None:
|
||||||
|
profile.display_name = display_name
|
||||||
|
if avatar_url is not None:
|
||||||
|
profile.avatar_url = avatar_url
|
||||||
|
db.flush()
|
||||||
|
return _detach(db, profile)
|
||||||
@@ -0,0 +1,59 @@
|
|||||||
|
from fastapi import APIRouter, Depends, File, HTTPException, Request, UploadFile
|
||||||
|
|
||||||
|
from app.core.dependencies import get_current_user
|
||||||
|
from app.core.media_signing import build_signed_media_url
|
||||||
|
from app.core.redis import check_rate_limit
|
||||||
|
from app.modules.media.service import AvatarValidationError
|
||||||
|
from app.modules.users.schemas import PasswordChangeIn, UserPatchIn
|
||||||
|
from app.modules.users.service import change_password, get_me, update_me, upload_avatar
|
||||||
|
|
||||||
|
router = APIRouter()
|
||||||
|
|
||||||
|
|
||||||
|
def _profile_response(data: dict) -> dict:
|
||||||
|
return {
|
||||||
|
"user": {
|
||||||
|
"id": data["user"].id,
|
||||||
|
"email": data["user"].email,
|
||||||
|
"role": data["user"].role,
|
||||||
|
"status": data["user"].status,
|
||||||
|
},
|
||||||
|
"profile": {
|
||||||
|
"display_name": data["profile"].display_name,
|
||||||
|
"avatar_url": build_signed_media_url(data["profile"].avatar_url),
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/me")
|
||||||
|
async def me_route(current_user=Depends(get_current_user)):
|
||||||
|
return _profile_response(get_me(current_user))
|
||||||
|
|
||||||
|
|
||||||
|
@router.patch("/me")
|
||||||
|
async def patch_me_route(payload: UserPatchIn, current_user=Depends(get_current_user)):
|
||||||
|
return _profile_response(update_me(current_user, payload.display_name))
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/me/password")
|
||||||
|
async def change_password_route(payload: PasswordChangeIn, current_user=Depends(get_current_user)):
|
||||||
|
try:
|
||||||
|
change_password(current_user, payload.current_password, payload.new_password)
|
||||||
|
except ValueError:
|
||||||
|
raise HTTPException(status_code=400, detail="INVALID_CURRENT_PASSWORD")
|
||||||
|
return {"message": "password_changed"}
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/me/avatar")
|
||||||
|
async def upload_avatar_route(
|
||||||
|
request: Request,
|
||||||
|
file: UploadFile = File(...),
|
||||||
|
current_user=Depends(get_current_user),
|
||||||
|
):
|
||||||
|
check_rate_limit(f"avatar:{current_user.id}", limit=10, window_seconds=3600)
|
||||||
|
content = await file.read()
|
||||||
|
try:
|
||||||
|
data = upload_avatar(current_user, content)
|
||||||
|
except AvatarValidationError as exc:
|
||||||
|
raise HTTPException(status_code=400, detail=str(exc))
|
||||||
|
return _profile_response(data)
|
||||||
@@ -0,0 +1,34 @@
|
|||||||
|
from pydantic import BaseModel, EmailStr, Field, field_validator
|
||||||
|
|
||||||
|
from app.core.password_policy import validate_password_strength
|
||||||
|
|
||||||
|
|
||||||
|
class UserOut(BaseModel):
|
||||||
|
id: str
|
||||||
|
email: EmailStr
|
||||||
|
role: str
|
||||||
|
status: str
|
||||||
|
|
||||||
|
|
||||||
|
class UserProfileOut(BaseModel):
|
||||||
|
display_name: str
|
||||||
|
avatar_url: str | None = None
|
||||||
|
|
||||||
|
|
||||||
|
class UserMeOut(BaseModel):
|
||||||
|
user: UserOut
|
||||||
|
profile: UserProfileOut
|
||||||
|
|
||||||
|
|
||||||
|
class UserPatchIn(BaseModel):
|
||||||
|
display_name: str | None = Field(default=None, min_length=1, max_length=120)
|
||||||
|
|
||||||
|
|
||||||
|
class PasswordChangeIn(BaseModel):
|
||||||
|
current_password: str
|
||||||
|
new_password: str = Field(min_length=8)
|
||||||
|
|
||||||
|
@field_validator("new_password")
|
||||||
|
@classmethod
|
||||||
|
def password_policy(cls, value: str) -> str:
|
||||||
|
return validate_password_strength(value)
|
||||||
@@ -0,0 +1,31 @@
|
|||||||
|
from app.core.jwt_denylist import bump_auth_epoch
|
||||||
|
from app.core.security import hash_password, verify_password
|
||||||
|
from app.modules.auth.service import revoke_user_refresh_family
|
||||||
|
from app.modules.media.service import upload_user_avatar
|
||||||
|
from app.modules.users import repository
|
||||||
|
from app.modules.users.models import User
|
||||||
|
|
||||||
|
|
||||||
|
def get_me(user: User) -> dict:
|
||||||
|
profile = repository.get_profile(user.id)
|
||||||
|
return {"user": user, "profile": profile}
|
||||||
|
|
||||||
|
|
||||||
|
def update_me(user: User, display_name: str | None) -> dict:
|
||||||
|
profile = repository.update_profile(user.id, display_name=display_name)
|
||||||
|
return {"user": user, "profile": profile}
|
||||||
|
|
||||||
|
|
||||||
|
def upload_avatar(user: User, content: bytes) -> dict:
|
||||||
|
avatar_url = upload_user_avatar(user.id, content)
|
||||||
|
profile = repository.update_profile(user.id, avatar_url=avatar_url)
|
||||||
|
return {"user": user, "profile": profile}
|
||||||
|
|
||||||
|
|
||||||
|
def change_password(user: User, current_password: str, new_password: str) -> None:
|
||||||
|
if not verify_password(current_password, user.password_hash):
|
||||||
|
raise ValueError("INVALID_CURRENT_PASSWORD")
|
||||||
|
user.password_hash = hash_password(new_password)
|
||||||
|
repository.update_user(user)
|
||||||
|
bump_auth_epoch(user.id)
|
||||||
|
revoke_user_refresh_family(user.id)
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
"""Zootech catalog API — WESP-compatible shape for copied static UI."""
|
||||||
@@ -0,0 +1,183 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import json
|
||||||
|
from datetime import UTC, datetime
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
from sqlalchemy import DateTime, select
|
||||||
|
|
||||||
|
from app.core.database import session_scope
|
||||||
|
from app.modules.zootech.catalog_models import (
|
||||||
|
ZootechDailyComponentNormAdjustment,
|
||||||
|
ZootechDailyIngredientReplacement,
|
||||||
|
ZootechDailyIngredientSkip,
|
||||||
|
ZootechDailyTripSkip,
|
||||||
|
ZootechDailyUnloadingGroupSkip,
|
||||||
|
ZootechFeedDispenser,
|
||||||
|
ZootechFeedingLocation,
|
||||||
|
ZootechFeedingPeriod,
|
||||||
|
ZootechFeedingPoint,
|
||||||
|
ZootechFeedMixer,
|
||||||
|
ZootechPeriodRecipe,
|
||||||
|
ZootechTrip,
|
||||||
|
ZootechUnloadingGroup,
|
||||||
|
)
|
||||||
|
from app.modules.zootech.models import ZootechComponent, ZootechIngredient, ZootechRecipe
|
||||||
|
|
||||||
|
TABLE_MODEL_MAP = {
|
||||||
|
"component": ZootechComponent,
|
||||||
|
"recipe": ZootechRecipe,
|
||||||
|
"ingredient": ZootechIngredient,
|
||||||
|
"unloading_group": ZootechUnloadingGroup,
|
||||||
|
"feed_mixer": ZootechFeedMixer,
|
||||||
|
"feed_dispenser": ZootechFeedDispenser,
|
||||||
|
"feeding_location": ZootechFeedingLocation,
|
||||||
|
"feeding_period": ZootechFeedingPeriod,
|
||||||
|
"feeding_point": ZootechFeedingPoint,
|
||||||
|
"period_recipes": ZootechPeriodRecipe,
|
||||||
|
"daily_trip_skip": ZootechDailyTripSkip,
|
||||||
|
"daily_ingredient_skip": ZootechDailyIngredientSkip,
|
||||||
|
"daily_unloading_group_skip": ZootechDailyUnloadingGroupSkip,
|
||||||
|
"daily_ingredient_replacement": ZootechDailyIngredientReplacement,
|
||||||
|
"daily_component_norm_adjustment": ZootechDailyComponentNormAdjustment,
|
||||||
|
"trip": ZootechTrip,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _coerce_for_model(model, key: str, value: Any) -> Any:
|
||||||
|
if value is None:
|
||||||
|
return value
|
||||||
|
column = model.__table__.columns.get(key)
|
||||||
|
if column is None:
|
||||||
|
return value
|
||||||
|
if isinstance(column.type, DateTime) and isinstance(value, str):
|
||||||
|
try:
|
||||||
|
return datetime.fromisoformat(value.replace("Z", "+00:00"))
|
||||||
|
except ValueError:
|
||||||
|
return datetime.now(UTC)
|
||||||
|
return value
|
||||||
|
|
||||||
|
|
||||||
|
def _parse_period_recipe_id(record_id: str) -> tuple[str, str]:
|
||||||
|
if ":" in record_id:
|
||||||
|
period_id, recipe_id = record_id.split(":", 1)
|
||||||
|
return period_id, recipe_id
|
||||||
|
return record_id, record_id
|
||||||
|
|
||||||
|
|
||||||
|
def load_catalog_row(enterprise_id: str, table_name: str, record_id: str) -> dict[str, Any] | None:
|
||||||
|
model = TABLE_MODEL_MAP.get(table_name)
|
||||||
|
if not model:
|
||||||
|
return None
|
||||||
|
with session_scope() as db:
|
||||||
|
if model is ZootechPeriodRecipe:
|
||||||
|
period_id, recipe_id = _parse_period_recipe_id(record_id)
|
||||||
|
row = db.scalar(
|
||||||
|
select(model).where(
|
||||||
|
model.enterprise_id == enterprise_id,
|
||||||
|
model.period_id == period_id,
|
||||||
|
model.recipe_id == recipe_id,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
row = db.scalar(
|
||||||
|
select(model).where(model.enterprise_id == enterprise_id, model.id == record_id)
|
||||||
|
)
|
||||||
|
if not row:
|
||||||
|
return None
|
||||||
|
return _row_to_dict(row, table_name)
|
||||||
|
|
||||||
|
|
||||||
|
def apply_catalog_change(
|
||||||
|
enterprise_id: str,
|
||||||
|
table_name: str,
|
||||||
|
record_id: str,
|
||||||
|
action: str,
|
||||||
|
payload: dict[str, Any],
|
||||||
|
version: int,
|
||||||
|
content_hash: str,
|
||||||
|
) -> None:
|
||||||
|
model = TABLE_MODEL_MAP.get(table_name)
|
||||||
|
if not model:
|
||||||
|
return
|
||||||
|
with session_scope() as db:
|
||||||
|
if model is ZootechPeriodRecipe:
|
||||||
|
period_id, recipe_id = _parse_period_recipe_id(record_id)
|
||||||
|
row = db.scalar(
|
||||||
|
select(model).where(
|
||||||
|
model.enterprise_id == enterprise_id,
|
||||||
|
model.period_id == period_id,
|
||||||
|
model.recipe_id == recipe_id,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
row = db.scalar(
|
||||||
|
select(model).where(model.enterprise_id == enterprise_id, model.id == record_id)
|
||||||
|
)
|
||||||
|
if action == "delete":
|
||||||
|
if row:
|
||||||
|
row.is_deleted = True
|
||||||
|
row.version = version
|
||||||
|
row.content_hash = content_hash
|
||||||
|
row.updated_at = datetime.now(UTC)
|
||||||
|
return
|
||||||
|
data = dict(payload)
|
||||||
|
data["enterprise_id"] = enterprise_id
|
||||||
|
data["version"] = version
|
||||||
|
data["content_hash"] = content_hash
|
||||||
|
data["is_deleted"] = False
|
||||||
|
if model is ZootechPeriodRecipe:
|
||||||
|
data["period_id"] = data.get("period_id") or _parse_period_recipe_id(record_id)[0]
|
||||||
|
data["recipe_id"] = data.get("recipe_id") or _parse_period_recipe_id(record_id)[1]
|
||||||
|
else:
|
||||||
|
data["id"] = record_id
|
||||||
|
if row:
|
||||||
|
_apply_fields(row, data, model)
|
||||||
|
row.updated_at = datetime.now(UTC)
|
||||||
|
else:
|
||||||
|
allowed = {c.key for c in model.__table__.columns}
|
||||||
|
filtered = {
|
||||||
|
k: _coerce_for_model(model, k, v) for k, v in data.items() if k in allowed
|
||||||
|
}
|
||||||
|
extra = {k: v for k, v in data.items() if k not in allowed}
|
||||||
|
if extra and "payload_json" in allowed:
|
||||||
|
filtered["payload_json"] = json.dumps(extra, ensure_ascii=False)
|
||||||
|
db.add(model(**filtered))
|
||||||
|
|
||||||
|
|
||||||
|
def _apply_fields(row, data: dict[str, Any], model) -> None:
|
||||||
|
allowed = {c.key for c in model.__table__.columns}
|
||||||
|
extra: dict[str, Any] = {}
|
||||||
|
for key, value in data.items():
|
||||||
|
if key in allowed and key not in ("enterprise_id", "created_at"):
|
||||||
|
setattr(row, key, _coerce_for_model(model, key, value))
|
||||||
|
elif key not in ("enterprise_id", "created_at", "id"):
|
||||||
|
extra[key] = value
|
||||||
|
if model is ZootechRecipe and "ingredients" in data:
|
||||||
|
row.payload_json = json.dumps(data.get("ingredients"), ensure_ascii=False)
|
||||||
|
elif extra and "payload_json" in allowed:
|
||||||
|
row.payload_json = json.dumps(extra, ensure_ascii=False)
|
||||||
|
|
||||||
|
|
||||||
|
def _row_to_dict(row, table_name: str) -> dict[str, Any]:
|
||||||
|
result = {}
|
||||||
|
for col in row.__table__.columns:
|
||||||
|
val = getattr(row, col.key)
|
||||||
|
if isinstance(val, datetime):
|
||||||
|
val = val.isoformat()
|
||||||
|
result[col.key] = val
|
||||||
|
if isinstance(row, ZootechRecipe) and row.payload_json:
|
||||||
|
try:
|
||||||
|
parsed = json.loads(row.payload_json)
|
||||||
|
if isinstance(parsed, list):
|
||||||
|
result["ingredients"] = parsed
|
||||||
|
except json.JSONDecodeError:
|
||||||
|
pass
|
||||||
|
if table_name == "period_recipes":
|
||||||
|
result["id"] = f"{row.period_id}:{row.recipe_id}"
|
||||||
|
elif hasattr(row, "payload_json") and row.payload_json and table_name != "recipe":
|
||||||
|
try:
|
||||||
|
result.update(json.loads(row.payload_json))
|
||||||
|
except json.JSONDecodeError:
|
||||||
|
pass
|
||||||
|
return result
|
||||||
@@ -0,0 +1,161 @@
|
|||||||
|
"""Additional zootech catalog tables (SERVER_MASTER_TABLES parity)."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from datetime import UTC, datetime
|
||||||
|
from uuid import uuid4
|
||||||
|
|
||||||
|
from sqlalchemy import Boolean, DateTime, Float, Integer, String, Text, UniqueConstraint
|
||||||
|
from sqlalchemy.orm import Mapped, mapped_column
|
||||||
|
|
||||||
|
from app.db.base import Base
|
||||||
|
|
||||||
|
|
||||||
|
class _CatalogMixin:
|
||||||
|
enterprise_id: Mapped[str] = mapped_column(String(36), nullable=False, index=True)
|
||||||
|
version: Mapped[int] = mapped_column(Integer, nullable=False, default=1)
|
||||||
|
content_hash: Mapped[str] = mapped_column(String(64), nullable=False, default="")
|
||||||
|
is_deleted: Mapped[bool] = mapped_column(Boolean, nullable=False, default=False)
|
||||||
|
payload_json: Mapped[str | None] = mapped_column(Text, nullable=True)
|
||||||
|
created_at: Mapped[datetime] = mapped_column(
|
||||||
|
DateTime(timezone=True), nullable=False, default=lambda: datetime.now(UTC)
|
||||||
|
)
|
||||||
|
updated_at: Mapped[datetime] = mapped_column(
|
||||||
|
DateTime(timezone=True),
|
||||||
|
nullable=False,
|
||||||
|
default=lambda: datetime.now(UTC),
|
||||||
|
onupdate=lambda: datetime.now(UTC),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class ZootechUnloadingGroup(_CatalogMixin, Base):
|
||||||
|
__tablename__ = "zootech_unloading_group"
|
||||||
|
__table_args__ = (UniqueConstraint("enterprise_id", "id", name="uq_zootech_unloading_group_ent_id"),)
|
||||||
|
|
||||||
|
id: Mapped[str] = mapped_column(String(36), primary_key=True, default=lambda: str(uuid4()))
|
||||||
|
name: Mapped[str] = mapped_column(String(100), nullable=False, default="")
|
||||||
|
recipe_id: Mapped[str] = mapped_column(String(36), nullable=False, index=True)
|
||||||
|
|
||||||
|
|
||||||
|
class ZootechFeedMixer(_CatalogMixin, Base):
|
||||||
|
__tablename__ = "zootech_feed_mixer"
|
||||||
|
__table_args__ = (UniqueConstraint("enterprise_id", "id", name="uq_zootech_feed_mixer_ent_id"),)
|
||||||
|
|
||||||
|
id: Mapped[str] = mapped_column(String(36), primary_key=True, default=lambda: str(uuid4()))
|
||||||
|
name: Mapped[str] = mapped_column(String(100), nullable=False, default="")
|
||||||
|
|
||||||
|
|
||||||
|
class ZootechFeedDispenser(_CatalogMixin, Base):
|
||||||
|
__tablename__ = "zootech_feed_dispenser"
|
||||||
|
__table_args__ = (UniqueConstraint("enterprise_id", "id", name="uq_zootech_feed_dispenser_ent_id"),)
|
||||||
|
|
||||||
|
id: Mapped[str] = mapped_column(String(36), primary_key=True, default=lambda: str(uuid4()))
|
||||||
|
name: Mapped[str] = mapped_column(String(100), nullable=False, default="")
|
||||||
|
|
||||||
|
|
||||||
|
class ZootechFeedingLocation(_CatalogMixin, Base):
|
||||||
|
__tablename__ = "zootech_feeding_location"
|
||||||
|
__table_args__ = (UniqueConstraint("enterprise_id", "id", name="uq_zootech_feeding_location_ent_id"),)
|
||||||
|
|
||||||
|
id: Mapped[str] = mapped_column(String(36), primary_key=True, default=lambda: str(uuid4()))
|
||||||
|
name: Mapped[str] = mapped_column(String(100), nullable=False, default="")
|
||||||
|
|
||||||
|
|
||||||
|
class ZootechFeedingPeriod(_CatalogMixin, Base):
|
||||||
|
__tablename__ = "zootech_feeding_period"
|
||||||
|
__table_args__ = (UniqueConstraint("enterprise_id", "id", name="uq_zootech_feeding_period_ent_id"),)
|
||||||
|
|
||||||
|
id: Mapped[str] = mapped_column(String(36), primary_key=True, default=lambda: str(uuid4()))
|
||||||
|
name: Mapped[str] = mapped_column(String(100), nullable=False, default="")
|
||||||
|
dispenser_id: Mapped[str | None] = mapped_column(String(36), nullable=True)
|
||||||
|
|
||||||
|
|
||||||
|
class ZootechFeedingPoint(_CatalogMixin, Base):
|
||||||
|
__tablename__ = "zootech_feeding_point"
|
||||||
|
__table_args__ = (UniqueConstraint("enterprise_id", "id", name="uq_zootech_feeding_point_ent_id"),)
|
||||||
|
|
||||||
|
id: Mapped[str] = mapped_column(String(36), primary_key=True, default=lambda: str(uuid4()))
|
||||||
|
name: Mapped[str] = mapped_column(String(100), nullable=False, default="")
|
||||||
|
period_id: Mapped[str | None] = mapped_column(String(36), nullable=True)
|
||||||
|
|
||||||
|
|
||||||
|
class ZootechPeriodRecipe(_CatalogMixin, Base):
|
||||||
|
__tablename__ = "zootech_period_recipes"
|
||||||
|
__table_args__ = (
|
||||||
|
UniqueConstraint("enterprise_id", "period_id", "recipe_id", name="uq_zootech_period_recipes_ent"),
|
||||||
|
)
|
||||||
|
|
||||||
|
period_id: Mapped[str] = mapped_column(String(36), primary_key=True)
|
||||||
|
recipe_id: Mapped[str] = mapped_column(String(36), primary_key=True)
|
||||||
|
order: Mapped[int] = mapped_column(Integer, nullable=False, default=0)
|
||||||
|
|
||||||
|
|
||||||
|
class ZootechTrip(_CatalogMixin, Base):
|
||||||
|
__tablename__ = "zootech_trip"
|
||||||
|
__table_args__ = (UniqueConstraint("enterprise_id", "id", name="uq_zootech_trip_ent_id"),)
|
||||||
|
|
||||||
|
id: Mapped[str] = mapped_column(String(36), primary_key=True, default=lambda: str(uuid4()))
|
||||||
|
mixer_id: Mapped[str | None] = mapped_column(String(36), nullable=True)
|
||||||
|
recipe_id: Mapped[str | None] = mapped_column(String(36), nullable=True)
|
||||||
|
status: Mapped[str] = mapped_column(String(20), nullable=False, default="pending")
|
||||||
|
|
||||||
|
|
||||||
|
class ZootechDailyTripSkip(_CatalogMixin, Base):
|
||||||
|
__tablename__ = "zootech_daily_trip_skip"
|
||||||
|
__table_args__ = (UniqueConstraint("enterprise_id", "id", name="uq_zootech_daily_trip_skip_ent_id"),)
|
||||||
|
|
||||||
|
id: Mapped[str] = mapped_column(String(36), primary_key=True, default=lambda: str(uuid4()))
|
||||||
|
|
||||||
|
|
||||||
|
class ZootechDailyIngredientSkip(_CatalogMixin, Base):
|
||||||
|
__tablename__ = "zootech_daily_ingredient_skip"
|
||||||
|
__table_args__ = (UniqueConstraint("enterprise_id", "id", name="uq_zootech_daily_ingredient_skip_ent_id"),)
|
||||||
|
|
||||||
|
id: Mapped[str] = mapped_column(String(36), primary_key=True, default=lambda: str(uuid4()))
|
||||||
|
|
||||||
|
|
||||||
|
class ZootechDailyUnloadingGroupSkip(_CatalogMixin, Base):
|
||||||
|
__tablename__ = "zootech_daily_unloading_group_skip"
|
||||||
|
__table_args__ = (
|
||||||
|
UniqueConstraint("enterprise_id", "id", name="uq_zootech_daily_unloading_group_skip_ent_id"),
|
||||||
|
)
|
||||||
|
|
||||||
|
id: Mapped[str] = mapped_column(String(36), primary_key=True, default=lambda: str(uuid4()))
|
||||||
|
|
||||||
|
|
||||||
|
class ZootechDailyIngredientReplacement(_CatalogMixin, Base):
|
||||||
|
__tablename__ = "zootech_daily_ingredient_replacement"
|
||||||
|
__table_args__ = (
|
||||||
|
UniqueConstraint("enterprise_id", "id", name="uq_zootech_daily_ingredient_replacement_ent_id"),
|
||||||
|
)
|
||||||
|
|
||||||
|
id: Mapped[str] = mapped_column(String(36), primary_key=True, default=lambda: str(uuid4()))
|
||||||
|
|
||||||
|
|
||||||
|
class ZootechDailyComponentNormAdjustment(_CatalogMixin, Base):
|
||||||
|
__tablename__ = "zootech_daily_component_norm_adjustment"
|
||||||
|
__table_args__ = (
|
||||||
|
UniqueConstraint("enterprise_id", "id", name="uq_zootech_daily_component_norm_adjustment_ent_id"),
|
||||||
|
)
|
||||||
|
|
||||||
|
id: Mapped[str] = mapped_column(String(36), primary_key=True, default=lambda: str(uuid4()))
|
||||||
|
|
||||||
|
|
||||||
|
CATALOG_TABLE_ORDER = [
|
||||||
|
"component",
|
||||||
|
"recipe",
|
||||||
|
"ingredient",
|
||||||
|
"unloading_group",
|
||||||
|
"feed_mixer",
|
||||||
|
"feed_dispenser",
|
||||||
|
"feeding_location",
|
||||||
|
"feeding_period",
|
||||||
|
"feeding_point",
|
||||||
|
"period_recipes",
|
||||||
|
"daily_trip_skip",
|
||||||
|
"daily_ingredient_skip",
|
||||||
|
"daily_unloading_group_skip",
|
||||||
|
"daily_ingredient_replacement",
|
||||||
|
"daily_component_norm_adjustment",
|
||||||
|
"trip",
|
||||||
|
]
|
||||||
@@ -0,0 +1,94 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from datetime import UTC, datetime
|
||||||
|
from uuid import uuid4
|
||||||
|
|
||||||
|
from sqlalchemy import Boolean, DateTime, Float, ForeignKey, Integer, String, Text, UniqueConstraint
|
||||||
|
from sqlalchemy.orm import Mapped, mapped_column
|
||||||
|
|
||||||
|
from app.db.base import Base
|
||||||
|
|
||||||
|
|
||||||
|
class ZootechComponent(Base):
|
||||||
|
__tablename__ = "zootech_component"
|
||||||
|
__table_args__ = (UniqueConstraint("enterprise_id", "id", name="uq_zootech_component_ent_id"),)
|
||||||
|
|
||||||
|
enterprise_id: Mapped[str] = mapped_column(String(36), nullable=False, index=True)
|
||||||
|
id: Mapped[str] = mapped_column(String(36), primary_key=True, default=lambda: str(uuid4()))
|
||||||
|
name: Mapped[str] = mapped_column(String(100), nullable=False)
|
||||||
|
type: Mapped[str] = mapped_column(String(100), nullable=False, default="")
|
||||||
|
is_active: Mapped[bool] = mapped_column(Boolean, nullable=False, default=True)
|
||||||
|
dry_matter: Mapped[float] = mapped_column(Float, nullable=False, default=0.0)
|
||||||
|
protein: Mapped[float] = mapped_column(Float, nullable=False, default=0.0)
|
||||||
|
energy: Mapped[float] = mapped_column(Float, nullable=False, default=0.0)
|
||||||
|
price: Mapped[float] = mapped_column(Float, nullable=False, default=0.0)
|
||||||
|
external_no: Mapped[int | None] = mapped_column(Integer, nullable=True)
|
||||||
|
version: Mapped[int] = mapped_column(Integer, nullable=False, default=1)
|
||||||
|
content_hash: Mapped[str] = mapped_column(String(64), nullable=False, default="")
|
||||||
|
is_deleted: Mapped[bool] = mapped_column(Boolean, nullable=False, default=False)
|
||||||
|
created_at: Mapped[datetime] = mapped_column(
|
||||||
|
DateTime(timezone=True), nullable=False, default=lambda: datetime.now(UTC)
|
||||||
|
)
|
||||||
|
updated_at: Mapped[datetime] = mapped_column(
|
||||||
|
DateTime(timezone=True),
|
||||||
|
nullable=False,
|
||||||
|
default=lambda: datetime.now(UTC),
|
||||||
|
onupdate=lambda: datetime.now(UTC),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class ZootechRecipe(Base):
|
||||||
|
__tablename__ = "zootech_recipe"
|
||||||
|
__table_args__ = (UniqueConstraint("enterprise_id", "id", name="uq_zootech_recipe_ent_id"),)
|
||||||
|
|
||||||
|
enterprise_id: Mapped[str] = mapped_column(String(36), nullable=False, index=True)
|
||||||
|
id: Mapped[str] = mapped_column(String(36), primary_key=True, default=lambda: str(uuid4()))
|
||||||
|
name: Mapped[str] = mapped_column(String(100), nullable=False)
|
||||||
|
heads_per_trip: Mapped[int] = mapped_column(Integer, nullable=False, default=1)
|
||||||
|
mixing_time: Mapped[int] = mapped_column(Integer, nullable=False, default=0)
|
||||||
|
trip_percent: Mapped[float] = mapped_column(Float, nullable=False, default=100.0)
|
||||||
|
dry_matter_locked: Mapped[bool] = mapped_column(Boolean, nullable=False, default=False)
|
||||||
|
unloading_link_broken: Mapped[bool] = mapped_column(Boolean, nullable=False, default=False)
|
||||||
|
target_component_id: Mapped[str | None] = mapped_column(String(36), nullable=True)
|
||||||
|
ration_type: Mapped[str | None] = mapped_column(String(10), nullable=True)
|
||||||
|
version: Mapped[int] = mapped_column(Integer, nullable=False, default=1)
|
||||||
|
content_hash: Mapped[str] = mapped_column(String(64), nullable=False, default="")
|
||||||
|
is_deleted: Mapped[bool] = mapped_column(Boolean, nullable=False, default=False)
|
||||||
|
payload_json: Mapped[str | None] = mapped_column(Text, nullable=True)
|
||||||
|
created_at: Mapped[datetime] = mapped_column(
|
||||||
|
DateTime(timezone=True), nullable=False, default=lambda: datetime.now(UTC)
|
||||||
|
)
|
||||||
|
updated_at: Mapped[datetime] = mapped_column(
|
||||||
|
DateTime(timezone=True),
|
||||||
|
nullable=False,
|
||||||
|
default=lambda: datetime.now(UTC),
|
||||||
|
onupdate=lambda: datetime.now(UTC),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class ZootechIngredient(Base):
|
||||||
|
__tablename__ = "zootech_ingredient"
|
||||||
|
__table_args__ = (UniqueConstraint("enterprise_id", "id", name="uq_zootech_ingredient_ent_id"),)
|
||||||
|
|
||||||
|
enterprise_id: Mapped[str] = mapped_column(String(36), nullable=False, index=True)
|
||||||
|
id: Mapped[str] = mapped_column(String(36), primary_key=True, default=lambda: str(uuid4()))
|
||||||
|
recipe_id: Mapped[str] = mapped_column(String(36), nullable=False, index=True)
|
||||||
|
component_id: Mapped[str | None] = mapped_column(String(36), nullable=True, index=True)
|
||||||
|
name: Mapped[str] = mapped_column(String(100), nullable=False)
|
||||||
|
amount: Mapped[float] = mapped_column(Float, nullable=False, default=0.0)
|
||||||
|
weight_per_head: Mapped[float | None] = mapped_column(Float, nullable=True)
|
||||||
|
dry_matter: Mapped[float] = mapped_column(Float, nullable=False, default=0.0)
|
||||||
|
dry_matter_per_head: Mapped[float | None] = mapped_column(Float, nullable=True)
|
||||||
|
order: Mapped[int] = mapped_column(Integer, nullable=False, default=0)
|
||||||
|
version: Mapped[int] = mapped_column(Integer, nullable=False, default=1)
|
||||||
|
content_hash: Mapped[str] = mapped_column(String(64), nullable=False, default="")
|
||||||
|
is_deleted: Mapped[bool] = mapped_column(Boolean, nullable=False, default=False)
|
||||||
|
created_at: Mapped[datetime] = mapped_column(
|
||||||
|
DateTime(timezone=True), nullable=False, default=lambda: datetime.now(UTC)
|
||||||
|
)
|
||||||
|
updated_at: Mapped[datetime] = mapped_column(
|
||||||
|
DateTime(timezone=True),
|
||||||
|
nullable=False,
|
||||||
|
default=lambda: datetime.now(UTC),
|
||||||
|
onupdate=lambda: datetime.now(UTC),
|
||||||
|
)
|
||||||
@@ -0,0 +1,135 @@
|
|||||||
|
"""Orchestrator system metrics in WESP admin-panel shape."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import os
|
||||||
|
import time
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
_NET_STATE: dict[str, float | int] | None = None
|
||||||
|
|
||||||
|
|
||||||
|
def _human_uptime(seconds: int | None) -> str | None:
|
||||||
|
if seconds is None:
|
||||||
|
return None
|
||||||
|
sec = max(0, int(seconds))
|
||||||
|
days, rem = divmod(sec, 86400)
|
||||||
|
hours, rem = divmod(rem, 3600)
|
||||||
|
minutes, _ = divmod(rem, 60)
|
||||||
|
parts: list[str] = []
|
||||||
|
if days:
|
||||||
|
parts.append(f"{days}d")
|
||||||
|
if hours or days:
|
||||||
|
parts.append(f"{hours}h")
|
||||||
|
parts.append(f"{minutes}m")
|
||||||
|
return " ".join(parts)
|
||||||
|
|
||||||
|
|
||||||
|
def collect_orchestrator_system_metrics() -> dict[str, Any]:
|
||||||
|
try:
|
||||||
|
import psutil # type: ignore[import-untyped]
|
||||||
|
except ImportError:
|
||||||
|
return {
|
||||||
|
"available": False,
|
||||||
|
"message": "Установите пакет psutil (pip install psutil) для метрик CPU/RAM/диска.",
|
||||||
|
}
|
||||||
|
|
||||||
|
global _NET_STATE
|
||||||
|
now = time.time()
|
||||||
|
boot_t = float(psutil.boot_time())
|
||||||
|
host_uptime_sec = int(max(0.0, now - boot_t))
|
||||||
|
|
||||||
|
proc_uptime_sec = None
|
||||||
|
try:
|
||||||
|
proc = psutil.Process()
|
||||||
|
proc_uptime_sec = int(max(0.0, now - float(proc.create_time())))
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
|
vm = psutil.virtual_memory()
|
||||||
|
sw = psutil.swap_memory()
|
||||||
|
|
||||||
|
disk_block: dict[str, Any] | None
|
||||||
|
try:
|
||||||
|
du = psutil.disk_usage("/")
|
||||||
|
disk_block = {
|
||||||
|
"path": "/",
|
||||||
|
"used": int(du.used),
|
||||||
|
"total": int(du.total),
|
||||||
|
"percent": round(du.percent, 2),
|
||||||
|
}
|
||||||
|
except OSError:
|
||||||
|
disk_block = None
|
||||||
|
|
||||||
|
load_avg = None
|
||||||
|
try:
|
||||||
|
load_avg = [round(x, 2) for x in os.getloadavg()]
|
||||||
|
except (OSError, AttributeError):
|
||||||
|
pass
|
||||||
|
|
||||||
|
net = psutil.net_io_counters()
|
||||||
|
upload_bps = 0.0
|
||||||
|
download_bps = 0.0
|
||||||
|
if _NET_STATE is not None and now > float(_NET_STATE["t"]):
|
||||||
|
dt = now - float(_NET_STATE["t"])
|
||||||
|
if dt > 0:
|
||||||
|
upload_bps = max(0.0, (int(net.bytes_sent) - int(_NET_STATE["sent"])) / dt)
|
||||||
|
download_bps = max(0.0, (int(net.bytes_recv) - int(_NET_STATE["recv"])) / dt)
|
||||||
|
_NET_STATE = {"t": now, "sent": int(net.bytes_sent), "recv": int(net.bytes_recv)}
|
||||||
|
|
||||||
|
conn_counts: dict[str, Any] = {"tcp": "—", "udp": "—"}
|
||||||
|
try:
|
||||||
|
conn_counts = {
|
||||||
|
"tcp": len(psutil.net_connections(kind="tcp")),
|
||||||
|
"udp": len(psutil.net_connections(kind="udp")),
|
||||||
|
}
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
|
addresses: list[dict[str, Any]] = []
|
||||||
|
try:
|
||||||
|
for name, addrs in psutil.net_if_addrs().items():
|
||||||
|
for addr in addrs:
|
||||||
|
if getattr(addr, "family", None) and str(getattr(addr, "address", "")).strip():
|
||||||
|
addresses.append({"iface": name, "address": addr.address})
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
|
return {
|
||||||
|
"available": True,
|
||||||
|
"timestamp": time.strftime("%Y-%m-%dT%H:%M:%SZ", time.gmtime(int(now))),
|
||||||
|
"host": {
|
||||||
|
"boot_time_unix": int(boot_t),
|
||||||
|
"uptime_seconds": host_uptime_sec,
|
||||||
|
"uptime_human": _human_uptime(host_uptime_sec),
|
||||||
|
},
|
||||||
|
"process": {
|
||||||
|
"pid": os.getpid(),
|
||||||
|
"uptime_seconds": proc_uptime_sec,
|
||||||
|
"uptime_human": _human_uptime(proc_uptime_sec) if proc_uptime_sec is not None else None,
|
||||||
|
},
|
||||||
|
"cpu": {
|
||||||
|
"percent": round(float(psutil.cpu_percent(interval=0.1)), 2),
|
||||||
|
"cores": int(psutil.cpu_count(logical=True) or 1),
|
||||||
|
},
|
||||||
|
"memory": {
|
||||||
|
"used": int(vm.used),
|
||||||
|
"total": int(vm.total),
|
||||||
|
"percent": round(vm.percent, 2),
|
||||||
|
},
|
||||||
|
"swap": {
|
||||||
|
"used": int(sw.used),
|
||||||
|
"total": int(sw.total),
|
||||||
|
"percent": round(sw.percent, 2) if sw.total else 0.0,
|
||||||
|
},
|
||||||
|
"disk": disk_block,
|
||||||
|
"network": {
|
||||||
|
"upload_bps": round(upload_bps, 2),
|
||||||
|
"download_bps": round(download_bps, 2),
|
||||||
|
"bytes_sent_total": int(net.bytes_sent),
|
||||||
|
"bytes_recv_total": int(net.bytes_recv),
|
||||||
|
},
|
||||||
|
"load_avg": load_avg,
|
||||||
|
"connections": conn_counts,
|
||||||
|
"addresses": addresses,
|
||||||
|
}
|
||||||
@@ -0,0 +1,372 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
# -*- coding: utf-8 -*-
|
||||||
|
"""
|
||||||
|
Модуль для расчетов рецептов кормления.
|
||||||
|
|
||||||
|
Перенесен из корневого recipe_calculator.py в пакет app.services
|
||||||
|
без изменения алгоритмов.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from typing import List, Dict, Any, Optional, Tuple
|
||||||
|
import os
|
||||||
|
import json
|
||||||
|
import logging
|
||||||
|
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
def _calc_debug_enabled() -> bool:
|
||||||
|
"""
|
||||||
|
Включает подробный трейс расчетов только по флагу окружения.
|
||||||
|
Удобно для диагностики: set CALC_DEBUG=1
|
||||||
|
"""
|
||||||
|
return str(os.getenv("CALC_DEBUG", "")).strip().lower() in {
|
||||||
|
"1",
|
||||||
|
"true",
|
||||||
|
"yes",
|
||||||
|
"on",
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _calc_log(msg: str, **data) -> None:
|
||||||
|
if not _calc_debug_enabled():
|
||||||
|
return
|
||||||
|
try:
|
||||||
|
if data:
|
||||||
|
logger.info(
|
||||||
|
"[CALC] %s | %s",
|
||||||
|
msg,
|
||||||
|
json.dumps(data, ensure_ascii=False, default=str),
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
logger.info("[CALC] %s", msg)
|
||||||
|
except Exception:
|
||||||
|
# никогда не ломаем расчет из-за логов
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
def round_to_step5(value: float) -> float:
|
||||||
|
"""
|
||||||
|
Округляет значение до кратного 5 кг
|
||||||
|
"""
|
||||||
|
return round(value / 5) * 5
|
||||||
|
|
||||||
|
|
||||||
|
def calculate_ingredients(
|
||||||
|
ingredients: List[Dict[str, Any]],
|
||||||
|
heads_count: int,
|
||||||
|
trip_percent: float,
|
||||||
|
component_dry_matter_map: Optional[Dict[str, float]] = None,
|
||||||
|
) -> List[Dict[str, float]]:
|
||||||
|
"""
|
||||||
|
Рассчитывает веса и сухое вещество для ингредиентов
|
||||||
|
"""
|
||||||
|
calculated_ingredients = []
|
||||||
|
|
||||||
|
_calc_log(
|
||||||
|
"calculate_ingredients:start",
|
||||||
|
heads_count=heads_count,
|
||||||
|
trip_percent=trip_percent,
|
||||||
|
ingredient_count=len(ingredients),
|
||||||
|
)
|
||||||
|
|
||||||
|
for idx, ing in enumerate(ingredients, 1):
|
||||||
|
weight_per_head_raw = ing.get("weightPerHead", 0)
|
||||||
|
weight_per_head = float(weight_per_head_raw)
|
||||||
|
dry_matter_percent = float(ing.get("dryMatter", 0))
|
||||||
|
component_id = ing.get("component_id")
|
||||||
|
_calc_log(
|
||||||
|
"calculate_ingredients:weightPerHead:input",
|
||||||
|
idx=idx,
|
||||||
|
component_id=component_id,
|
||||||
|
weightPerHead_raw=weight_per_head_raw,
|
||||||
|
weightPerHead_raw_type=type(weight_per_head_raw).__name__,
|
||||||
|
weightPerHead_float=weight_per_head,
|
||||||
|
)
|
||||||
|
|
||||||
|
# Получаем dry_matter из компонента, если не указан и есть component_id
|
||||||
|
dm_source = "payload"
|
||||||
|
if dry_matter_percent == 0 and component_id and component_dry_matter_map:
|
||||||
|
if component_id in component_dry_matter_map:
|
||||||
|
dry_matter_percent = component_dry_matter_map[component_id]
|
||||||
|
dm_source = "component_map"
|
||||||
|
|
||||||
|
# Расчеты
|
||||||
|
weight = weight_per_head * heads_count
|
||||||
|
trip_weight = weight * (trip_percent / 100)
|
||||||
|
dry_matter_per_head = weight_per_head * (dry_matter_percent / 100)
|
||||||
|
|
||||||
|
if 0 < weight_per_head < 0.01:
|
||||||
|
rounded_wph = round(weight_per_head, 3)
|
||||||
|
else:
|
||||||
|
rounded_wph = round(weight_per_head, 2)
|
||||||
|
|
||||||
|
if 0 < dry_matter_per_head < 0.01:
|
||||||
|
rounded_dm_per_head = round(dry_matter_per_head, 4)
|
||||||
|
else:
|
||||||
|
rounded_dm_per_head = round(dry_matter_per_head, 4)
|
||||||
|
|
||||||
|
_calc_log(
|
||||||
|
"ingredient:calc",
|
||||||
|
idx=idx,
|
||||||
|
component_id=component_id,
|
||||||
|
dm_source=dm_source,
|
||||||
|
input={
|
||||||
|
"weightPerHead": weight_per_head,
|
||||||
|
"dryMatterPercent": dry_matter_percent,
|
||||||
|
"headsCount": heads_count,
|
||||||
|
"tripPercent": trip_percent,
|
||||||
|
},
|
||||||
|
formula={
|
||||||
|
"totalWeight": "weightPerHead * headsCount",
|
||||||
|
"tripWeight": "totalWeight * (tripPercent/100)",
|
||||||
|
"dryMatterPerHead": "weightPerHead * (dryMatterPercent/100)",
|
||||||
|
},
|
||||||
|
result={
|
||||||
|
"totalWeight": round(weight, 2),
|
||||||
|
"tripWeight": round(trip_weight, 2),
|
||||||
|
"dryMatterPerHead": rounded_dm_per_head,
|
||||||
|
"weightPerHead": rounded_wph,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
_calc_log(
|
||||||
|
"calculate_ingredients:weightPerHead:output",
|
||||||
|
idx=idx,
|
||||||
|
component_id=component_id,
|
||||||
|
weightPerHead_before_round=weight_per_head,
|
||||||
|
rounded_wph=rounded_wph,
|
||||||
|
rounded_wph_type=type(rounded_wph).__name__,
|
||||||
|
)
|
||||||
|
|
||||||
|
calculated_ingredients.append(
|
||||||
|
{
|
||||||
|
"totalWeight": round(weight, 2),
|
||||||
|
"tripWeight": round(trip_weight, 2),
|
||||||
|
"dryMatterPerHead": rounded_dm_per_head,
|
||||||
|
"weightPerHead": rounded_wph,
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
_calc_log("calculate_ingredients:end", ingredient_count=len(calculated_ingredients))
|
||||||
|
return calculated_ingredients
|
||||||
|
|
||||||
|
|
||||||
|
def calculate_totals(
|
||||||
|
calculated_ingredients: List[Dict[str, float]],
|
||||||
|
ingredients: List[Dict[str, Any]],
|
||||||
|
) -> Dict[str, float]:
|
||||||
|
"""
|
||||||
|
Рассчитывает общие итоги по ингредиентам
|
||||||
|
"""
|
||||||
|
total_weight = 0.0
|
||||||
|
total_trip_weight = 0.0
|
||||||
|
total_dry_matter_per_head = 0.0
|
||||||
|
total_weight_per_head = 0.0
|
||||||
|
|
||||||
|
_calc_log(
|
||||||
|
"calculate_totals:start", ingredient_count=len(calculated_ingredients)
|
||||||
|
)
|
||||||
|
|
||||||
|
for i, calc in enumerate(calculated_ingredients):
|
||||||
|
total_weight += calc["totalWeight"]
|
||||||
|
total_trip_weight += calc["tripWeight"]
|
||||||
|
total_dry_matter_per_head += calc["dryMatterPerHead"]
|
||||||
|
|
||||||
|
if "weightPerHead" in calc:
|
||||||
|
total_weight_per_head += calc["weightPerHead"]
|
||||||
|
elif i < len(ingredients):
|
||||||
|
total_weight_per_head += float(ingredients[i].get("weightPerHead", 0))
|
||||||
|
|
||||||
|
totals = {
|
||||||
|
"totalWeight": round(total_weight, 2),
|
||||||
|
"totalTripWeight": round(total_trip_weight, 2),
|
||||||
|
"totalDryMatterPerHead": round(total_dry_matter_per_head, 2),
|
||||||
|
"totalWeightPerHead": round(total_weight_per_head, 2),
|
||||||
|
}
|
||||||
|
|
||||||
|
_calc_log("calculate_totals:end", totals=totals)
|
||||||
|
return totals
|
||||||
|
|
||||||
|
|
||||||
|
def calculate_unloading_groups(
|
||||||
|
unloading_groups: List[Dict[str, Any]],
|
||||||
|
total_trip_weight: float,
|
||||||
|
heads_count: int,
|
||||||
|
) -> Tuple[List[Dict[str, float]], Dict[str, Any]]:
|
||||||
|
"""
|
||||||
|
Рассчитывает веса для групп выгрузки.
|
||||||
|
"""
|
||||||
|
calculated_groups: List[Dict[str, float]] = []
|
||||||
|
total_percent = 0.0
|
||||||
|
total_heads = 0.0
|
||||||
|
total_weight_kg = 0.0
|
||||||
|
|
||||||
|
for group in unloading_groups:
|
||||||
|
group_type = group.get("distributionType", "percent")
|
||||||
|
value = float(group.get("value", 0))
|
||||||
|
|
||||||
|
calculated_weight = 0.0
|
||||||
|
if group_type == "percent" and value > 0:
|
||||||
|
calculated_weight = (total_trip_weight * value) / 100.0
|
||||||
|
elif group_type == "heads" and value > 0 and heads_count > 0:
|
||||||
|
calculated_weight = (total_trip_weight / heads_count) * value
|
||||||
|
|
||||||
|
calculated_weight_rounded = round_to_step5(calculated_weight)
|
||||||
|
|
||||||
|
calculated_groups.append({"calculatedWeight": calculated_weight_rounded})
|
||||||
|
|
||||||
|
if group_type == "percent":
|
||||||
|
total_percent += value
|
||||||
|
else:
|
||||||
|
total_heads += value
|
||||||
|
|
||||||
|
total_weight_kg += calculated_weight
|
||||||
|
|
||||||
|
unloading_totals = {
|
||||||
|
"totalPercent": round(total_percent, 1),
|
||||||
|
"totalHeads": int(total_heads),
|
||||||
|
"totalWeightKg": round_to_step5(total_weight_kg),
|
||||||
|
}
|
||||||
|
|
||||||
|
return calculated_groups, unloading_totals
|
||||||
|
|
||||||
|
|
||||||
|
def calculate_recipe(
|
||||||
|
ingredients: List[Dict[str, Any]],
|
||||||
|
heads_count: int,
|
||||||
|
trip_percent: float,
|
||||||
|
unloading_groups: Optional[List[Dict[str, Any]]] = None,
|
||||||
|
component_dry_matter_map: Optional[Dict[str, float]] = None,
|
||||||
|
calculate_from_dry_matter: bool = False,
|
||||||
|
) -> Dict[str, Any]:
|
||||||
|
"""
|
||||||
|
Главная функция для расчета всего рецепта.
|
||||||
|
"""
|
||||||
|
_calc_log(
|
||||||
|
"calculate_recipe:start",
|
||||||
|
heads_count=heads_count,
|
||||||
|
trip_percent=trip_percent,
|
||||||
|
calculate_from_dry_matter=calculate_from_dry_matter,
|
||||||
|
ingredient_count=len(ingredients or []),
|
||||||
|
unloading_group_count=len(unloading_groups or []),
|
||||||
|
)
|
||||||
|
|
||||||
|
if unloading_groups is None:
|
||||||
|
unloading_groups = []
|
||||||
|
|
||||||
|
if calculate_from_dry_matter:
|
||||||
|
calculated_ingredients = calculate_ingredients_from_dry_matter(
|
||||||
|
ingredients, heads_count, trip_percent, component_dry_matter_map
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
calculated_ingredients = calculate_ingredients(
|
||||||
|
ingredients, heads_count, trip_percent, component_dry_matter_map
|
||||||
|
)
|
||||||
|
|
||||||
|
totals = calculate_totals(calculated_ingredients, ingredients)
|
||||||
|
|
||||||
|
calculated_groups, unloading_totals = calculate_unloading_groups(
|
||||||
|
unloading_groups, totals["totalTripWeight"], heads_count
|
||||||
|
)
|
||||||
|
|
||||||
|
result: Dict[str, Any] = {
|
||||||
|
"ingredients": calculated_ingredients,
|
||||||
|
"totals": totals,
|
||||||
|
"unloadingGroups": calculated_groups,
|
||||||
|
"unloadingTotals": unloading_totals,
|
||||||
|
}
|
||||||
|
|
||||||
|
_calc_log("calculate_recipe:end", totals=totals, unloadingTotals=unloading_totals)
|
||||||
|
return result
|
||||||
|
|
||||||
|
|
||||||
|
def calculate_ingredients_from_dry_matter(
|
||||||
|
ingredients: List[Dict[str, Any]],
|
||||||
|
heads_count: int,
|
||||||
|
trip_percent: float,
|
||||||
|
component_dry_matter_map: Optional[Dict[str, float]] = None,
|
||||||
|
) -> List[Dict[str, float]]:
|
||||||
|
"""
|
||||||
|
Рассчитывает веса от сухого вещества (обратный расчет).
|
||||||
|
"""
|
||||||
|
calculated_ingredients: List[Dict[str, float]] = []
|
||||||
|
|
||||||
|
_calc_log(
|
||||||
|
"calculate_ingredients_from_dry_matter:start",
|
||||||
|
heads_count=heads_count,
|
||||||
|
trip_percent=trip_percent,
|
||||||
|
ingredient_count=len(ingredients),
|
||||||
|
)
|
||||||
|
|
||||||
|
for idx, ing in enumerate(ingredients, 1):
|
||||||
|
dry_matter_per_head_raw = ing.get("dryMatterPerHead", 0)
|
||||||
|
dry_matter_per_head = float(dry_matter_per_head_raw)
|
||||||
|
dry_matter_percent = float(ing.get("dryMatter", 0))
|
||||||
|
component_id = ing.get("component_id")
|
||||||
|
_calc_log(
|
||||||
|
"calculate_ingredients_from_dry_matter:input",
|
||||||
|
idx=idx,
|
||||||
|
component_id=component_id,
|
||||||
|
dryMatterPerHead_raw=dry_matter_per_head_raw,
|
||||||
|
dryMatterPercent=dry_matter_percent,
|
||||||
|
)
|
||||||
|
|
||||||
|
dm_source = "payload"
|
||||||
|
if dry_matter_percent == 0 and component_id and component_dry_matter_map:
|
||||||
|
if component_id in component_dry_matter_map:
|
||||||
|
dry_matter_percent = component_dry_matter_map[component_id]
|
||||||
|
dm_source = "component_map"
|
||||||
|
|
||||||
|
EPSILON = 1e-6
|
||||||
|
|
||||||
|
if dry_matter_percent > EPSILON:
|
||||||
|
weight_per_head = (dry_matter_per_head * 100.0) / dry_matter_percent
|
||||||
|
|
||||||
|
if weight_per_head < 0.01 and dry_matter_per_head >= 0.001:
|
||||||
|
weight_per_head = 0.01
|
||||||
|
else:
|
||||||
|
if dry_matter_per_head > EPSILON:
|
||||||
|
_calc_log(
|
||||||
|
"ingredient:warning_zero_dm_percent",
|
||||||
|
idx=idx,
|
||||||
|
component_id=component_id,
|
||||||
|
dry_matter_per_head=dry_matter_per_head,
|
||||||
|
message=(
|
||||||
|
"dry_matter_percent равен 0, но dry_matter_per_head > 0 - "
|
||||||
|
"веса обнулены"
|
||||||
|
),
|
||||||
|
)
|
||||||
|
weight_per_head = 0.0
|
||||||
|
|
||||||
|
weight = weight_per_head * heads_count
|
||||||
|
trip_weight = weight * (trip_percent / 100.0)
|
||||||
|
|
||||||
|
_calc_log(
|
||||||
|
"ingredient:inverse_calc",
|
||||||
|
idx=idx,
|
||||||
|
component_id=component_id,
|
||||||
|
dm_source=dm_source,
|
||||||
|
input={
|
||||||
|
"dryMatterPerHead": dry_matter_per_head,
|
||||||
|
"dryMatterPercent": dry_matter_percent,
|
||||||
|
"headsCount": heads_count,
|
||||||
|
"tripPercent": trip_percent,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
calculated_ingredients.append(
|
||||||
|
{
|
||||||
|
"totalWeight": round(weight, 2),
|
||||||
|
"tripWeight": round(trip_weight, 2),
|
||||||
|
"weightPerHead": round(weight_per_head, 2),
|
||||||
|
"dryMatterPerHead": round(dry_matter_per_head, 4),
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
_calc_log(
|
||||||
|
"calculate_ingredients_from_dry_matter:end",
|
||||||
|
ingredient_count=len(calculated_ingredients),
|
||||||
|
)
|
||||||
|
return calculated_ingredients
|
||||||
|
|
||||||
@@ -0,0 +1,151 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import json
|
||||||
|
from datetime import UTC, datetime
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
from sqlalchemy import select
|
||||||
|
|
||||||
|
from app.core.database import session_scope
|
||||||
|
from app.modules.zootech.report_models import ZootechFeedAlert, ZootechLoadingReport, ZootechUnloadingReport
|
||||||
|
|
||||||
|
REPORT_TABLE_MAP = {
|
||||||
|
"loading_report": ZootechLoadingReport,
|
||||||
|
"unloading_report": ZootechUnloadingReport,
|
||||||
|
"feed_alert": ZootechFeedAlert,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _write_payload_json(row: Any, extra: dict[str, Any]) -> None:
|
||||||
|
if not extra:
|
||||||
|
return
|
||||||
|
row.payload_json = json.dumps(extra, ensure_ascii=False)
|
||||||
|
|
||||||
|
|
||||||
|
def repair_report_payloads_from_event_log(enterprise_id: str) -> int:
|
||||||
|
"""Re-apply latest sync event payload per report row (one-time repair helper)."""
|
||||||
|
from app.modules.sync.models import SyncEventLog
|
||||||
|
|
||||||
|
repaired = 0
|
||||||
|
events: list[tuple[str, str, str, int, str, str | None]] = []
|
||||||
|
with session_scope() as db:
|
||||||
|
rows = list(
|
||||||
|
db.scalars(
|
||||||
|
select(SyncEventLog)
|
||||||
|
.where(
|
||||||
|
SyncEventLog.enterprise_id == enterprise_id,
|
||||||
|
SyncEventLog.table_name.in_(("loading_report", "unloading_report", "feed_alert")),
|
||||||
|
)
|
||||||
|
.order_by(SyncEventLog.record_id.asc(), SyncEventLog.received_at.desc())
|
||||||
|
)
|
||||||
|
)
|
||||||
|
latest_by_record: dict[tuple[str, str], SyncEventLog] = {}
|
||||||
|
for row in rows:
|
||||||
|
key = (row.table_name, row.record_id)
|
||||||
|
if key not in latest_by_record:
|
||||||
|
latest_by_record[key] = row
|
||||||
|
for (table_name, record_id), event in latest_by_record.items():
|
||||||
|
events.append(
|
||||||
|
(
|
||||||
|
table_name,
|
||||||
|
record_id,
|
||||||
|
event.payload_json or "{}",
|
||||||
|
int(event.version or 1),
|
||||||
|
str(event.content_hash or ""),
|
||||||
|
event.origin_site_id,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
for table_name, record_id, payload_json, version, content_hash, origin_site_id in events:
|
||||||
|
try:
|
||||||
|
payload = json.loads(payload_json)
|
||||||
|
except json.JSONDecodeError:
|
||||||
|
continue
|
||||||
|
if not isinstance(payload, dict):
|
||||||
|
continue
|
||||||
|
apply_report_change(
|
||||||
|
enterprise_id,
|
||||||
|
table_name,
|
||||||
|
record_id,
|
||||||
|
"upsert",
|
||||||
|
payload,
|
||||||
|
version,
|
||||||
|
content_hash,
|
||||||
|
farm_hub_id=origin_site_id,
|
||||||
|
)
|
||||||
|
repaired += 1
|
||||||
|
return repaired
|
||||||
|
|
||||||
|
|
||||||
|
def apply_report_change(
|
||||||
|
enterprise_id: str,
|
||||||
|
table_name: str,
|
||||||
|
record_id: str,
|
||||||
|
action: str,
|
||||||
|
payload: dict[str, Any],
|
||||||
|
version: int,
|
||||||
|
content_hash: str,
|
||||||
|
farm_hub_id: str | None = None,
|
||||||
|
) -> None:
|
||||||
|
model = REPORT_TABLE_MAP.get(table_name)
|
||||||
|
if not model:
|
||||||
|
return
|
||||||
|
with session_scope() as db:
|
||||||
|
row = db.scalar(
|
||||||
|
select(model).where(model.enterprise_id == enterprise_id, model.id == record_id)
|
||||||
|
)
|
||||||
|
if action == "delete":
|
||||||
|
if row:
|
||||||
|
row.is_deleted = True
|
||||||
|
row.version = version
|
||||||
|
row.content_hash = content_hash
|
||||||
|
row.updated_at = datetime.now(UTC)
|
||||||
|
return
|
||||||
|
data = dict(payload)
|
||||||
|
data["id"] = record_id
|
||||||
|
data["enterprise_id"] = enterprise_id
|
||||||
|
data["farm_hub_id"] = farm_hub_id or data.get("farm_hub_id")
|
||||||
|
data["version"] = version
|
||||||
|
data["content_hash"] = content_hash
|
||||||
|
data["is_deleted"] = False
|
||||||
|
if table_name == "feed_alert":
|
||||||
|
if data.get("event_type") and not data.get("alert_type"):
|
||||||
|
data["alert_type"] = str(data["event_type"])
|
||||||
|
if data.get("detail") and not data.get("message"):
|
||||||
|
data["message"] = str(data["detail"])
|
||||||
|
if row:
|
||||||
|
allowed = {c.key for c in model.__table__.columns}
|
||||||
|
extra = {k: v for k, v in data.items() if k not in allowed}
|
||||||
|
for key, value in data.items():
|
||||||
|
if key in allowed and key not in ("enterprise_id", "created_at"):
|
||||||
|
setattr(row, key, value)
|
||||||
|
if extra and "payload_json" in allowed:
|
||||||
|
_write_payload_json(row, extra)
|
||||||
|
# #region agent log
|
||||||
|
try:
|
||||||
|
import pathlib
|
||||||
|
_log_path = pathlib.Path("/Users/vlad/Documents/wesp new (1)/.cursor/debug-785e22.log")
|
||||||
|
_log_path.parent.mkdir(parents=True, exist_ok=True)
|
||||||
|
with _log_path.open("a", encoding="utf-8") as _lf:
|
||||||
|
_lf.write(json.dumps({"sessionId":"785e22","hypothesisId":"D","location":"report_apply.py:update","message":"report payload update","data":{"table":table_name,"record_id":record_id,"extra_keys":sorted(extra.keys()),"components_len":len(extra.get("components") or []) if isinstance(extra.get("components"), list) else None,"groups_len":len(extra.get("unloading_groups") or []) if isinstance(extra.get("unloading_groups"), list) else None},"timestamp":int(datetime.now(UTC).timestamp()*1000)}, ensure_ascii=False) + "\n")
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
# #endregion
|
||||||
|
row.updated_at = datetime.now(UTC)
|
||||||
|
else:
|
||||||
|
allowed = {c.key for c in model.__table__.columns}
|
||||||
|
filtered = {k: v for k, v in data.items() if k in allowed}
|
||||||
|
extra = {k: v for k, v in data.items() if k not in allowed}
|
||||||
|
if extra and "payload_json" in allowed:
|
||||||
|
filtered["payload_json"] = json.dumps(extra, ensure_ascii=False)
|
||||||
|
db.add(model(**filtered))
|
||||||
|
# #region agent log
|
||||||
|
try:
|
||||||
|
import pathlib
|
||||||
|
_log_path = pathlib.Path("/Users/vlad/Documents/wesp new (1)/.cursor/debug-785e22.log")
|
||||||
|
_log_path.parent.mkdir(parents=True, exist_ok=True)
|
||||||
|
with _log_path.open("a", encoding="utf-8") as _lf:
|
||||||
|
_lf.write(json.dumps({"sessionId":"785e22","hypothesisId":"D","location":"report_apply.py:insert","message":"report payload insert","data":{"table":table_name,"record_id":record_id,"components_len":len(extra.get("components") or []) if isinstance(extra.get("components"), list) else None},"timestamp":int(datetime.now(UTC).timestamp()*1000)}, ensure_ascii=False) + "\n")
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
# #endregion
|
||||||
@@ -0,0 +1,82 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from datetime import UTC, datetime
|
||||||
|
from uuid import uuid4
|
||||||
|
|
||||||
|
from sqlalchemy import Boolean, DateTime, Float, Integer, String, Text, UniqueConstraint
|
||||||
|
from sqlalchemy.orm import Mapped, mapped_column
|
||||||
|
|
||||||
|
from app.db.base import Base
|
||||||
|
|
||||||
|
|
||||||
|
class ZootechLoadingReport(Base):
|
||||||
|
__tablename__ = "zootech_loading_report"
|
||||||
|
__table_args__ = (UniqueConstraint("enterprise_id", "id", name="uq_zootech_loading_report_ent_id"),)
|
||||||
|
|
||||||
|
enterprise_id: Mapped[str] = mapped_column(String(36), nullable=False, index=True)
|
||||||
|
farm_hub_id: Mapped[str | None] = mapped_column(String(36), nullable=True, index=True)
|
||||||
|
id: Mapped[str] = mapped_column(String(36), primary_key=True, default=lambda: str(uuid4()))
|
||||||
|
trip_id: Mapped[str | None] = mapped_column(String(36), nullable=True)
|
||||||
|
status: Mapped[str] = mapped_column(String(32), nullable=False, default="pending")
|
||||||
|
version: Mapped[int] = mapped_column(Integer, nullable=False, default=1)
|
||||||
|
content_hash: Mapped[str] = mapped_column(String(64), nullable=False, default="")
|
||||||
|
payload_json: Mapped[str | None] = mapped_column(Text, nullable=True)
|
||||||
|
is_deleted: Mapped[bool] = mapped_column(Boolean, nullable=False, default=False)
|
||||||
|
created_at: Mapped[datetime] = mapped_column(
|
||||||
|
DateTime(timezone=True), nullable=False, default=lambda: datetime.now(UTC)
|
||||||
|
)
|
||||||
|
updated_at: Mapped[datetime] = mapped_column(
|
||||||
|
DateTime(timezone=True),
|
||||||
|
nullable=False,
|
||||||
|
default=lambda: datetime.now(UTC),
|
||||||
|
onupdate=lambda: datetime.now(UTC),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class ZootechUnloadingReport(Base):
|
||||||
|
__tablename__ = "zootech_unloading_report"
|
||||||
|
__table_args__ = (UniqueConstraint("enterprise_id", "id", name="uq_zootech_unloading_report_ent_id"),)
|
||||||
|
|
||||||
|
enterprise_id: Mapped[str] = mapped_column(String(36), nullable=False, index=True)
|
||||||
|
farm_hub_id: Mapped[str | None] = mapped_column(String(36), nullable=True, index=True)
|
||||||
|
id: Mapped[str] = mapped_column(String(36), primary_key=True, default=lambda: str(uuid4()))
|
||||||
|
trip_id: Mapped[str | None] = mapped_column(String(36), nullable=True)
|
||||||
|
status: Mapped[str] = mapped_column(String(32), nullable=False, default="pending")
|
||||||
|
version: Mapped[int] = mapped_column(Integer, nullable=False, default=1)
|
||||||
|
content_hash: Mapped[str] = mapped_column(String(64), nullable=False, default="")
|
||||||
|
payload_json: Mapped[str | None] = mapped_column(Text, nullable=True)
|
||||||
|
is_deleted: Mapped[bool] = mapped_column(Boolean, nullable=False, default=False)
|
||||||
|
created_at: Mapped[datetime] = mapped_column(
|
||||||
|
DateTime(timezone=True), nullable=False, default=lambda: datetime.now(UTC)
|
||||||
|
)
|
||||||
|
updated_at: Mapped[datetime] = mapped_column(
|
||||||
|
DateTime(timezone=True),
|
||||||
|
nullable=False,
|
||||||
|
default=lambda: datetime.now(UTC),
|
||||||
|
onupdate=lambda: datetime.now(UTC),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class ZootechFeedAlert(Base):
|
||||||
|
__tablename__ = "zootech_feed_alert"
|
||||||
|
__table_args__ = (UniqueConstraint("enterprise_id", "id", name="uq_zootech_feed_alert_ent_id"),)
|
||||||
|
|
||||||
|
enterprise_id: Mapped[str] = mapped_column(String(36), nullable=False, index=True)
|
||||||
|
farm_hub_id: Mapped[str | None] = mapped_column(String(36), nullable=True, index=True)
|
||||||
|
id: Mapped[str] = mapped_column(String(36), primary_key=True, default=lambda: str(uuid4()))
|
||||||
|
alert_type: Mapped[str] = mapped_column(String(64), nullable=False, default="")
|
||||||
|
severity: Mapped[str] = mapped_column(String(16), nullable=False, default="info")
|
||||||
|
message: Mapped[str] = mapped_column(Text, nullable=False, default="")
|
||||||
|
version: Mapped[int] = mapped_column(Integer, nullable=False, default=1)
|
||||||
|
content_hash: Mapped[str] = mapped_column(String(64), nullable=False, default="")
|
||||||
|
payload_json: Mapped[str | None] = mapped_column(Text, nullable=True)
|
||||||
|
is_deleted: Mapped[bool] = mapped_column(Boolean, nullable=False, default=False)
|
||||||
|
created_at: Mapped[datetime] = mapped_column(
|
||||||
|
DateTime(timezone=True), nullable=False, default=lambda: datetime.now(UTC)
|
||||||
|
)
|
||||||
|
updated_at: Mapped[datetime] = mapped_column(
|
||||||
|
DateTime(timezone=True),
|
||||||
|
nullable=False,
|
||||||
|
default=lambda: datetime.now(UTC),
|
||||||
|
onupdate=lambda: datetime.now(UTC),
|
||||||
|
)
|
||||||
@@ -0,0 +1,151 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from fastapi import APIRouter, Depends, HTTPException, Query, status
|
||||||
|
from sqlalchemy import select
|
||||||
|
|
||||||
|
from app.core.database import session_scope
|
||||||
|
from app.modules.sync.tenant import TenantContext, get_tenant_context, require_enterprise_zootech
|
||||||
|
from app.modules.zootech.catalog_apply import load_catalog_row
|
||||||
|
from app.modules.zootech import service as zootech_service
|
||||||
|
from app.modules.zootech.service import ZootechServiceError
|
||||||
|
from app.modules.zootech.models import ZootechComponent, ZootechRecipe
|
||||||
|
|
||||||
|
router = APIRouter()
|
||||||
|
|
||||||
|
|
||||||
|
def _list_components(enterprise_id: str) -> list[dict]:
|
||||||
|
with session_scope() as db:
|
||||||
|
rows = list(
|
||||||
|
db.scalars(
|
||||||
|
select(ZootechComponent).where(
|
||||||
|
ZootechComponent.enterprise_id == enterprise_id,
|
||||||
|
ZootechComponent.is_deleted.is_(False),
|
||||||
|
)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
return [
|
||||||
|
{
|
||||||
|
"id": row.id,
|
||||||
|
"name": row.name,
|
||||||
|
"type": row.type,
|
||||||
|
"is_active": row.is_active,
|
||||||
|
"dry_matter": row.dry_matter,
|
||||||
|
"protein": row.protein,
|
||||||
|
"energy": row.energy,
|
||||||
|
"price": row.price,
|
||||||
|
"external_no": row.external_no,
|
||||||
|
"version": row.version,
|
||||||
|
"content_hash": row.content_hash,
|
||||||
|
}
|
||||||
|
for row in rows
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
def _list_recipes(enterprise_id: str) -> list[dict]:
|
||||||
|
with session_scope() as db:
|
||||||
|
rows = list(
|
||||||
|
db.scalars(
|
||||||
|
select(ZootechRecipe).where(
|
||||||
|
ZootechRecipe.enterprise_id == enterprise_id,
|
||||||
|
ZootechRecipe.is_deleted.is_(False),
|
||||||
|
)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
return [
|
||||||
|
{
|
||||||
|
"id": row.id,
|
||||||
|
"name": row.name,
|
||||||
|
"heads_per_trip": row.heads_per_trip,
|
||||||
|
"mixing_time": row.mixing_time,
|
||||||
|
"trip_percent": row.trip_percent,
|
||||||
|
"dry_matter_locked": row.dry_matter_locked,
|
||||||
|
"version": row.version,
|
||||||
|
"content_hash": row.content_hash,
|
||||||
|
}
|
||||||
|
for row in rows
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/components")
|
||||||
|
def list_components(
|
||||||
|
enterprise_id: str = Query(...),
|
||||||
|
tenant: TenantContext = Depends(require_enterprise_zootech),
|
||||||
|
):
|
||||||
|
if tenant.enterprise_id != enterprise_id:
|
||||||
|
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="ENTERPRISE_FORBIDDEN")
|
||||||
|
return {"components": _list_components(enterprise_id)}
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/components/{component_id}")
|
||||||
|
def get_component(
|
||||||
|
component_id: str,
|
||||||
|
enterprise_id: str = Query(...),
|
||||||
|
tenant: TenantContext = Depends(require_enterprise_zootech),
|
||||||
|
):
|
||||||
|
if tenant.enterprise_id != enterprise_id:
|
||||||
|
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="ENTERPRISE_FORBIDDEN")
|
||||||
|
row = load_catalog_row(enterprise_id, "component", component_id)
|
||||||
|
if not row:
|
||||||
|
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="NOT_FOUND")
|
||||||
|
return row
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/components")
|
||||||
|
def create_component(
|
||||||
|
body: dict,
|
||||||
|
enterprise_id: str = Query(...),
|
||||||
|
tenant: TenantContext = Depends(require_enterprise_zootech),
|
||||||
|
):
|
||||||
|
if tenant.enterprise_id != enterprise_id:
|
||||||
|
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="ENTERPRISE_FORBIDDEN")
|
||||||
|
return zootech_service.upsert_component(enterprise_id, None, body)
|
||||||
|
|
||||||
|
|
||||||
|
@router.patch("/components/{component_id}")
|
||||||
|
def update_component(
|
||||||
|
component_id: str,
|
||||||
|
body: dict,
|
||||||
|
enterprise_id: str = Query(...),
|
||||||
|
tenant: TenantContext = Depends(require_enterprise_zootech),
|
||||||
|
):
|
||||||
|
if tenant.enterprise_id != enterprise_id:
|
||||||
|
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="ENTERPRISE_FORBIDDEN")
|
||||||
|
try:
|
||||||
|
return zootech_service.patch_component(enterprise_id, component_id, body)
|
||||||
|
except ZootechServiceError:
|
||||||
|
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="NOT_FOUND") from None
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/recipes")
|
||||||
|
def create_recipe(
|
||||||
|
body: dict,
|
||||||
|
enterprise_id: str = Query(...),
|
||||||
|
tenant: TenantContext = Depends(require_enterprise_zootech),
|
||||||
|
):
|
||||||
|
if tenant.enterprise_id != enterprise_id:
|
||||||
|
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="ENTERPRISE_FORBIDDEN")
|
||||||
|
return zootech_service.upsert_recipe(enterprise_id, None, body)
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/recipes")
|
||||||
|
def list_recipes(
|
||||||
|
enterprise_id: str = Query(...),
|
||||||
|
tenant: TenantContext = Depends(require_enterprise_zootech),
|
||||||
|
):
|
||||||
|
if tenant.enterprise_id != enterprise_id:
|
||||||
|
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="ENTERPRISE_FORBIDDEN")
|
||||||
|
return {"recipes": _list_recipes(enterprise_id)}
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/recipes/{recipe_id}")
|
||||||
|
def get_recipe(
|
||||||
|
recipe_id: str,
|
||||||
|
enterprise_id: str = Query(...),
|
||||||
|
tenant: TenantContext = Depends(require_enterprise_zootech),
|
||||||
|
):
|
||||||
|
if tenant.enterprise_id != enterprise_id:
|
||||||
|
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="ENTERPRISE_FORBIDDEN")
|
||||||
|
row = load_catalog_row(enterprise_id, "recipe", recipe_id)
|
||||||
|
if not row:
|
||||||
|
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="NOT_FOUND")
|
||||||
|
return row
|
||||||
@@ -0,0 +1,84 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from datetime import UTC, datetime
|
||||||
|
from uuid import uuid4
|
||||||
|
|
||||||
|
from app.core.exceptions import DomainError
|
||||||
|
from app.modules.sync.engine import SyncEngine
|
||||||
|
from app.modules.sync.schemas import ChangeEventIn
|
||||||
|
from app.modules.zootech.catalog_apply import load_catalog_row
|
||||||
|
from app.modules.zootech.sync_content_hash import compute_content_hash
|
||||||
|
|
||||||
|
|
||||||
|
class ZootechServiceError(DomainError):
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
def _emit_change(enterprise_id: str, table: str, record_id: str, payload: dict, version: int, content_hash: str) -> None:
|
||||||
|
event = ChangeEventIn(
|
||||||
|
event_id=str(uuid4()),
|
||||||
|
seq=None,
|
||||||
|
domain="global",
|
||||||
|
table=table,
|
||||||
|
record_id=record_id,
|
||||||
|
action="upsert",
|
||||||
|
version=version,
|
||||||
|
content_hash=content_hash,
|
||||||
|
payload=payload,
|
||||||
|
emitted_at=datetime.now(UTC),
|
||||||
|
origin_site_id=SyncEngine.ORCHESTRATOR_SITE_ID,
|
||||||
|
)
|
||||||
|
SyncEngine(enterprise_id, SyncEngine.ORCHESTRATOR_SITE_ID).process_push([event], origin="orchestrator")
|
||||||
|
|
||||||
|
|
||||||
|
def upsert_component(enterprise_id: str, record_id: str | None, body: dict) -> dict:
|
||||||
|
rid = record_id or str(uuid4())
|
||||||
|
existing = load_catalog_row(enterprise_id, "component", rid)
|
||||||
|
version = int(body.get("version") or (existing or {}).get("version") or 0) + 1 if existing else 1
|
||||||
|
payload = {
|
||||||
|
"id": rid,
|
||||||
|
"name": body.get("name", (existing or {}).get("name", "")),
|
||||||
|
"type": body.get("type", (existing or {}).get("type", "")),
|
||||||
|
"is_active": body.get("is_active", (existing or {}).get("is_active", True)),
|
||||||
|
"dry_matter": float(body.get("dry_matter", (existing or {}).get("dry_matter", 0.0))),
|
||||||
|
"protein": float(body.get("protein", (existing or {}).get("protein", 0.0))),
|
||||||
|
"energy": float(body.get("energy", (existing or {}).get("energy", 0.0))),
|
||||||
|
"price": float(body.get("price", (existing or {}).get("price", 0.0))),
|
||||||
|
"external_no": body.get("external_no", (existing or {}).get("external_no")),
|
||||||
|
"version": version,
|
||||||
|
}
|
||||||
|
content_hash = compute_content_hash(payload)
|
||||||
|
payload["content_hash"] = content_hash
|
||||||
|
_emit_change(enterprise_id, "component", rid, payload, version, content_hash)
|
||||||
|
return load_catalog_row(enterprise_id, "component", rid) or payload
|
||||||
|
|
||||||
|
|
||||||
|
def patch_component(enterprise_id: str, record_id: str, body: dict) -> dict:
|
||||||
|
existing = load_catalog_row(enterprise_id, "component", record_id)
|
||||||
|
if not existing:
|
||||||
|
raise ZootechServiceError("NOT_FOUND")
|
||||||
|
merged = {**existing, **body}
|
||||||
|
return upsert_component(enterprise_id, record_id, merged)
|
||||||
|
|
||||||
|
|
||||||
|
def upsert_recipe(enterprise_id: str, record_id: str | None, body: dict) -> dict:
|
||||||
|
rid = record_id or str(uuid4())
|
||||||
|
existing = load_catalog_row(enterprise_id, "recipe", rid)
|
||||||
|
version = int(body.get("version") or (existing or {}).get("version") or 0) + 1 if existing else 1
|
||||||
|
payload = {
|
||||||
|
"id": rid,
|
||||||
|
"name": body.get("name", (existing or {}).get("name", "")),
|
||||||
|
"heads_per_trip": int(body.get("heads_per_trip", (existing or {}).get("heads_per_trip", 1))),
|
||||||
|
"mixing_time": int(body.get("mixing_time", (existing or {}).get("mixing_time", 0))),
|
||||||
|
"trip_percent": float(body.get("trip_percent", (existing or {}).get("trip_percent", 100.0))),
|
||||||
|
"dry_matter_locked": body.get("dry_matter_locked", (existing or {}).get("dry_matter_locked", False)),
|
||||||
|
"version": version,
|
||||||
|
}
|
||||||
|
if "ingredients" in body:
|
||||||
|
payload["ingredients"] = body["ingredients"]
|
||||||
|
elif existing and "ingredients" in existing:
|
||||||
|
payload["ingredients"] = existing["ingredients"]
|
||||||
|
content_hash = compute_content_hash(payload)
|
||||||
|
payload["content_hash"] = content_hash
|
||||||
|
_emit_change(enterprise_id, "recipe", rid, payload, version, content_hash)
|
||||||
|
return load_catalog_row(enterprise_id, "recipe", rid) or payload
|
||||||
@@ -0,0 +1,37 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from datetime import UTC, datetime
|
||||||
|
from uuid import uuid4
|
||||||
|
|
||||||
|
from sqlalchemy import DateTime, Integer, String, Text, UniqueConstraint
|
||||||
|
from sqlalchemy.orm import Mapped, mapped_column
|
||||||
|
|
||||||
|
from app.db.base import Base
|
||||||
|
|
||||||
|
|
||||||
|
class ZootechOrgSettings(Base):
|
||||||
|
__tablename__ = "zootech_org_settings"
|
||||||
|
__table_args__ = (UniqueConstraint("enterprise_id", name="uq_zootech_org_settings_enterprise"),)
|
||||||
|
|
||||||
|
enterprise_id: Mapped[str] = mapped_column(String(36), nullable=False, index=True)
|
||||||
|
id: Mapped[str] = mapped_column(String(36), primary_key=True, default=lambda: str(uuid4()))
|
||||||
|
payload_json: Mapped[str] = mapped_column(Text, nullable=False, default="{}")
|
||||||
|
version: Mapped[int] = mapped_column(Integer, nullable=False, default=1)
|
||||||
|
content_hash: Mapped[str] = mapped_column(String(64), nullable=False, default="")
|
||||||
|
updated_at: Mapped[datetime] = mapped_column(
|
||||||
|
DateTime(timezone=True), nullable=False, default=lambda: datetime.now(UTC)
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class ZootechFeedQualitySettings(Base):
|
||||||
|
__tablename__ = "zootech_feed_quality_settings"
|
||||||
|
__table_args__ = (UniqueConstraint("enterprise_id", name="uq_zootech_feed_quality_settings_enterprise"),)
|
||||||
|
|
||||||
|
enterprise_id: Mapped[str] = mapped_column(String(36), nullable=False, index=True)
|
||||||
|
id: Mapped[str] = mapped_column(String(36), primary_key=True, default=lambda: str(uuid4()))
|
||||||
|
payload_json: Mapped[str] = mapped_column(Text, nullable=False, default="{}")
|
||||||
|
version: Mapped[int] = mapped_column(Integer, nullable=False, default=1)
|
||||||
|
content_hash: Mapped[str] = mapped_column(String(64), nullable=False, default="")
|
||||||
|
updated_at: Mapped[datetime] = mapped_column(
|
||||||
|
DateTime(timezone=True), nullable=False, default=lambda: datetime.now(UTC)
|
||||||
|
)
|
||||||
@@ -0,0 +1,37 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import hashlib
|
||||||
|
import json
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
_EXCLUDED_FROM_HASH = frozenset(
|
||||||
|
{
|
||||||
|
"version",
|
||||||
|
"content_hash",
|
||||||
|
"sync_timestamp",
|
||||||
|
"sync_status",
|
||||||
|
"created_at",
|
||||||
|
"updated_at",
|
||||||
|
"created_by",
|
||||||
|
"updated_by",
|
||||||
|
"client_id",
|
||||||
|
"server_synced",
|
||||||
|
"is_deleted",
|
||||||
|
"deleted_at",
|
||||||
|
"deleted_by",
|
||||||
|
"enterprise_id",
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def stable_payload_for_hash(data: dict[str, Any]) -> dict[str, Any]:
|
||||||
|
return {k: v for k, v in data.items() if k not in _EXCLUDED_FROM_HASH}
|
||||||
|
|
||||||
|
|
||||||
|
def compute_content_hash_hex(payload: dict[str, Any]) -> str:
|
||||||
|
raw = json.dumps(payload, sort_keys=True, ensure_ascii=False, default=str).encode("utf-8")
|
||||||
|
return hashlib.sha256(raw).hexdigest()
|
||||||
|
|
||||||
|
|
||||||
|
def compute_content_hash(data: dict[str, Any]) -> str:
|
||||||
|
return compute_content_hash_hex(stable_payload_for_hash(data))
|
||||||
@@ -0,0 +1,435 @@
|
|||||||
|
"""WESP-shaped HTTP API for copied static UI (orchestrator zootech catalog)."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import json
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
from fastapi import APIRouter, Depends, HTTPException, Query, status
|
||||||
|
from sqlalchemy import select
|
||||||
|
|
||||||
|
from app.core.database import session_scope
|
||||||
|
from app.modules.sync.tenant import TenantContext, require_enterprise_zootech
|
||||||
|
from app.modules.zootech.catalog_apply import load_catalog_row
|
||||||
|
from app.modules.zootech.catalog_models import (
|
||||||
|
ZootechFeedDispenser,
|
||||||
|
ZootechFeedingPeriod,
|
||||||
|
ZootechPeriodRecipe,
|
||||||
|
ZootechUnloadingGroup,
|
||||||
|
)
|
||||||
|
from app.modules.zootech.models import ZootechComponent, ZootechIngredient, ZootechRecipe
|
||||||
|
|
||||||
|
router = APIRouter()
|
||||||
|
|
||||||
|
|
||||||
|
def _require_ent(enterprise_id: str, tenant: TenantContext) -> None:
|
||||||
|
if tenant.enterprise_id != enterprise_id:
|
||||||
|
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="ENTERPRISE_FORBIDDEN")
|
||||||
|
|
||||||
|
|
||||||
|
def _component_wesp(row: ZootechComponent) -> dict[str, Any]:
|
||||||
|
return {
|
||||||
|
"id": row.id,
|
||||||
|
"name": row.name,
|
||||||
|
"type": row.type,
|
||||||
|
"is_active": row.is_active,
|
||||||
|
"dryMatter": row.dry_matter,
|
||||||
|
"protein": row.protein,
|
||||||
|
"energy": row.energy,
|
||||||
|
"price": row.price,
|
||||||
|
"externalNo": row.external_no,
|
||||||
|
"version": row.version,
|
||||||
|
"content_hash": row.content_hash,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _recipe_short(row: ZootechRecipe) -> dict[str, Any]:
|
||||||
|
return {
|
||||||
|
"id": row.id,
|
||||||
|
"name": row.name,
|
||||||
|
"heads_count": row.heads_per_trip,
|
||||||
|
"mixing_time": row.mixing_time,
|
||||||
|
"trip_percent": row.trip_percent,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _ingredient_wesp(row: ZootechIngredient, comp_name: str | None = None) -> dict[str, Any]:
|
||||||
|
wph = float(row.weight_per_head or 0)
|
||||||
|
dm_pct = float(row.dry_matter or 0)
|
||||||
|
dm_ph = float(row.dry_matter_per_head or 0)
|
||||||
|
if not dm_ph and wph > 0 and dm_pct > 0:
|
||||||
|
dm_ph = wph * (dm_pct / 100.0)
|
||||||
|
name = (row.name or "").strip() or (comp_name or "") or "—"
|
||||||
|
return {
|
||||||
|
"id": row.id,
|
||||||
|
"name": name,
|
||||||
|
"weightPerHead": wph,
|
||||||
|
"weight_per_head": wph,
|
||||||
|
"amount": float(row.amount or 0),
|
||||||
|
"dry_matter": dm_pct,
|
||||||
|
"dry_matter_per_head": dm_ph,
|
||||||
|
"order": int(row.order or 0),
|
||||||
|
"component_id": row.component_id,
|
||||||
|
"version": row.version,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _unloading_group_wesp(row: ZootechUnloadingGroup) -> dict[str, Any]:
|
||||||
|
extra: dict[str, Any] = {}
|
||||||
|
if row.payload_json:
|
||||||
|
try:
|
||||||
|
extra = json.loads(row.payload_json)
|
||||||
|
except json.JSONDecodeError:
|
||||||
|
pass
|
||||||
|
distribution_type = str(extra.get("distribution_type") or extra.get("distributionType") or "percent")
|
||||||
|
value = float(extra.get("value") or 0)
|
||||||
|
weight = float(extra.get("weight") or 0)
|
||||||
|
order = int(extra.get("order") or 0)
|
||||||
|
base = {
|
||||||
|
"id": row.id,
|
||||||
|
"name": row.name,
|
||||||
|
"distributionType": distribution_type,
|
||||||
|
"distribution_type": distribution_type,
|
||||||
|
"value": value,
|
||||||
|
"weight": weight,
|
||||||
|
"order": order,
|
||||||
|
"version": row.version,
|
||||||
|
}
|
||||||
|
for key in ("created_at", "updated_at", "created_by", "updated_by"):
|
||||||
|
if key in extra:
|
||||||
|
base[key] = extra[key]
|
||||||
|
return base
|
||||||
|
|
||||||
|
|
||||||
|
def _serialize_recipe_wesp(db, enterprise_id: str, recipe: ZootechRecipe) -> dict[str, Any]:
|
||||||
|
ingredients = list(
|
||||||
|
db.scalars(
|
||||||
|
select(ZootechIngredient)
|
||||||
|
.where(
|
||||||
|
ZootechIngredient.enterprise_id == enterprise_id,
|
||||||
|
ZootechIngredient.recipe_id == recipe.id,
|
||||||
|
ZootechIngredient.is_deleted.is_(False),
|
||||||
|
)
|
||||||
|
.order_by(ZootechIngredient.order.asc())
|
||||||
|
)
|
||||||
|
)
|
||||||
|
comp_ids = [i.component_id for i in ingredients if i.component_id]
|
||||||
|
comp_names: dict[str, str] = {}
|
||||||
|
if comp_ids:
|
||||||
|
for comp in db.scalars(
|
||||||
|
select(ZootechComponent).where(
|
||||||
|
ZootechComponent.enterprise_id == enterprise_id,
|
||||||
|
ZootechComponent.id.in_(comp_ids),
|
||||||
|
)
|
||||||
|
):
|
||||||
|
comp_names[comp.id] = comp.name
|
||||||
|
|
||||||
|
groups = list(
|
||||||
|
db.scalars(
|
||||||
|
select(ZootechUnloadingGroup)
|
||||||
|
.where(
|
||||||
|
ZootechUnloadingGroup.enterprise_id == enterprise_id,
|
||||||
|
ZootechUnloadingGroup.recipe_id == recipe.id,
|
||||||
|
ZootechUnloadingGroup.is_deleted.is_(False),
|
||||||
|
)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
groups.sort(key=lambda g: int((_unloading_group_wesp(g).get("order") or 0)))
|
||||||
|
|
||||||
|
unloading_groups = [_unloading_group_wesp(g) for g in groups]
|
||||||
|
return {
|
||||||
|
"id": recipe.id,
|
||||||
|
"name": recipe.name,
|
||||||
|
"headsPerTrip": recipe.heads_per_trip,
|
||||||
|
"mixingTime": recipe.mixing_time,
|
||||||
|
"tripPercent": recipe.trip_percent,
|
||||||
|
"heads_count": recipe.heads_per_trip,
|
||||||
|
"mixing_time": recipe.mixing_time,
|
||||||
|
"trip_percent": recipe.trip_percent,
|
||||||
|
"dryMatterLocked": recipe.dry_matter_locked,
|
||||||
|
"dry_matter_locked": recipe.dry_matter_locked,
|
||||||
|
"unloading_link_broken": recipe.unloading_link_broken,
|
||||||
|
"unloadingLinkBroken": recipe.unloading_link_broken,
|
||||||
|
"target_component_id": recipe.target_component_id,
|
||||||
|
"version": recipe.version,
|
||||||
|
"ingredients": [_ingredient_wesp(i, comp_names.get(i.component_id or "")) for i in ingredients],
|
||||||
|
"unloadingGroups": unloading_groups,
|
||||||
|
"unloading_groups": unloading_groups,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _dispenser_wesp(row: ZootechFeedDispenser, periods: list[dict] | None = None) -> dict[str, Any]:
|
||||||
|
extra: dict[str, Any] = {}
|
||||||
|
if row.payload_json:
|
||||||
|
try:
|
||||||
|
extra = json.loads(row.payload_json)
|
||||||
|
except json.JSONDecodeError:
|
||||||
|
pass
|
||||||
|
return {
|
||||||
|
"id": row.id,
|
||||||
|
"name": row.name,
|
||||||
|
"version": row.version,
|
||||||
|
"content_hash": row.content_hash,
|
||||||
|
"periods": periods or [],
|
||||||
|
"hasSkipToday": False,
|
||||||
|
**{k: v for k, v in extra.items() if k not in ("id", "name")},
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/components/ping")
|
||||||
|
def components_ping():
|
||||||
|
return {"status": "ok"}
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/components")
|
||||||
|
def wesp_list_components(
|
||||||
|
enterprise_id: str = Query(...),
|
||||||
|
tenant: TenantContext = Depends(require_enterprise_zootech),
|
||||||
|
limit: int = Query(1000),
|
||||||
|
offset: int = Query(0),
|
||||||
|
):
|
||||||
|
_require_ent(enterprise_id, tenant)
|
||||||
|
with session_scope() as db:
|
||||||
|
rows = list(
|
||||||
|
db.scalars(
|
||||||
|
select(ZootechComponent)
|
||||||
|
.where(
|
||||||
|
ZootechComponent.enterprise_id == enterprise_id,
|
||||||
|
ZootechComponent.is_deleted.is_(False),
|
||||||
|
ZootechComponent.is_active.is_(True),
|
||||||
|
)
|
||||||
|
.offset(offset)
|
||||||
|
.limit(limit)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
return [_component_wesp(r) for r in rows]
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/components/{component_id}")
|
||||||
|
def wesp_get_component(
|
||||||
|
component_id: str,
|
||||||
|
enterprise_id: str = Query(...),
|
||||||
|
tenant: TenantContext = Depends(require_enterprise_zootech),
|
||||||
|
):
|
||||||
|
_require_ent(enterprise_id, tenant)
|
||||||
|
row = load_catalog_row(enterprise_id, "component", component_id)
|
||||||
|
if not row:
|
||||||
|
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="NOT_FOUND")
|
||||||
|
return {
|
||||||
|
"id": row["id"],
|
||||||
|
"name": row["name"],
|
||||||
|
"type": row.get("type", ""),
|
||||||
|
"is_active": row.get("is_active", True),
|
||||||
|
"dryMatter": row.get("dry_matter", 0),
|
||||||
|
"protein": row.get("protein", 0),
|
||||||
|
"energy": row.get("energy", 0),
|
||||||
|
"price": row.get("price", 0),
|
||||||
|
"externalNo": row.get("external_no"),
|
||||||
|
"version": row.get("version", 1),
|
||||||
|
"content_hash": row.get("content_hash", ""),
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/feed_dispensers/ping")
|
||||||
|
def feed_dispensers_ping():
|
||||||
|
return {"status": "ok"}
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/feed_dispensers/names")
|
||||||
|
def feed_dispenser_names(
|
||||||
|
enterprise_id: str = Query(...),
|
||||||
|
tenant: TenantContext = Depends(require_enterprise_zootech),
|
||||||
|
):
|
||||||
|
_require_ent(enterprise_id, tenant)
|
||||||
|
with session_scope() as db:
|
||||||
|
rows = list(
|
||||||
|
db.scalars(
|
||||||
|
select(ZootechFeedDispenser)
|
||||||
|
.where(
|
||||||
|
ZootechFeedDispenser.enterprise_id == enterprise_id,
|
||||||
|
ZootechFeedDispenser.is_deleted.is_(False),
|
||||||
|
)
|
||||||
|
.order_by(ZootechFeedDispenser.name.asc())
|
||||||
|
)
|
||||||
|
)
|
||||||
|
seen: set[str] = set()
|
||||||
|
names: list[str] = []
|
||||||
|
for r in rows:
|
||||||
|
if r.name and r.name not in seen:
|
||||||
|
seen.add(r.name)
|
||||||
|
names.append(r.name)
|
||||||
|
return names
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/feed_dispensers")
|
||||||
|
def list_feed_dispensers(
|
||||||
|
enterprise_id: str = Query(...),
|
||||||
|
tenant: TenantContext = Depends(require_enterprise_zootech),
|
||||||
|
limit: int = Query(100),
|
||||||
|
offset: int = Query(0),
|
||||||
|
):
|
||||||
|
_require_ent(enterprise_id, tenant)
|
||||||
|
with session_scope() as db:
|
||||||
|
dispensers = list(
|
||||||
|
db.scalars(
|
||||||
|
select(ZootechFeedDispenser)
|
||||||
|
.where(
|
||||||
|
ZootechFeedDispenser.enterprise_id == enterprise_id,
|
||||||
|
ZootechFeedDispenser.is_deleted.is_(False),
|
||||||
|
)
|
||||||
|
.order_by(ZootechFeedDispenser.created_at.desc())
|
||||||
|
.offset(offset)
|
||||||
|
.limit(limit)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
result = []
|
||||||
|
for d in dispensers:
|
||||||
|
periods = list(
|
||||||
|
db.scalars(
|
||||||
|
select(ZootechFeedingPeriod).where(
|
||||||
|
ZootechFeedingPeriod.enterprise_id == enterprise_id,
|
||||||
|
ZootechFeedingPeriod.dispenser_id == d.id,
|
||||||
|
ZootechFeedingPeriod.is_deleted.is_(False),
|
||||||
|
)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
period_payload = [{"id": p.id, "name": p.name} for p in periods]
|
||||||
|
result.append(_dispenser_wesp(d, period_payload))
|
||||||
|
return result
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/feed_dispensers/{dispenser_id}/periods")
|
||||||
|
def dispenser_periods(
|
||||||
|
dispenser_id: str,
|
||||||
|
enterprise_id: str = Query(...),
|
||||||
|
tenant: TenantContext = Depends(require_enterprise_zootech),
|
||||||
|
):
|
||||||
|
_require_ent(enterprise_id, tenant)
|
||||||
|
with session_scope() as db:
|
||||||
|
periods = list(
|
||||||
|
db.scalars(
|
||||||
|
select(ZootechFeedingPeriod).where(
|
||||||
|
ZootechFeedingPeriod.enterprise_id == enterprise_id,
|
||||||
|
ZootechFeedingPeriod.dispenser_id == dispenser_id,
|
||||||
|
ZootechFeedingPeriod.is_deleted.is_(False),
|
||||||
|
)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
return [{"id": p.id, "name": p.name, "dispenser_id": p.dispenser_id} for p in periods]
|
||||||
|
|
||||||
|
|
||||||
|
def _recipes_for_period(db, enterprise_id: str, period_id: str) -> list[dict]:
|
||||||
|
links = list(
|
||||||
|
db.scalars(
|
||||||
|
select(ZootechPeriodRecipe)
|
||||||
|
.where(
|
||||||
|
ZootechPeriodRecipe.enterprise_id == enterprise_id,
|
||||||
|
ZootechPeriodRecipe.period_id == period_id,
|
||||||
|
ZootechPeriodRecipe.is_deleted.is_(False),
|
||||||
|
)
|
||||||
|
.order_by(ZootechPeriodRecipe.order.asc())
|
||||||
|
)
|
||||||
|
)
|
||||||
|
out: list[dict] = []
|
||||||
|
for link in links:
|
||||||
|
recipe = db.scalar(
|
||||||
|
select(ZootechRecipe).where(
|
||||||
|
ZootechRecipe.enterprise_id == enterprise_id,
|
||||||
|
ZootechRecipe.id == link.recipe_id,
|
||||||
|
ZootechRecipe.is_deleted.is_(False),
|
||||||
|
)
|
||||||
|
)
|
||||||
|
if recipe:
|
||||||
|
out.append(_recipe_short(recipe))
|
||||||
|
return out
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/feed_dispensers/{dispenser_id}/recipes")
|
||||||
|
def dispenser_recipes(
|
||||||
|
dispenser_id: str,
|
||||||
|
enterprise_id: str = Query(...),
|
||||||
|
tenant: TenantContext = Depends(require_enterprise_zootech),
|
||||||
|
):
|
||||||
|
_require_ent(enterprise_id, tenant)
|
||||||
|
with session_scope() as db:
|
||||||
|
periods = list(
|
||||||
|
db.scalars(
|
||||||
|
select(ZootechFeedingPeriod).where(
|
||||||
|
ZootechFeedingPeriod.enterprise_id == enterprise_id,
|
||||||
|
ZootechFeedingPeriod.dispenser_id == dispenser_id,
|
||||||
|
ZootechFeedingPeriod.is_deleted.is_(False),
|
||||||
|
)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
merged: dict[str, dict] = {}
|
||||||
|
for p in periods:
|
||||||
|
for r in _recipes_for_period(db, enterprise_id, p.id):
|
||||||
|
merged[r["id"]] = r
|
||||||
|
return list(merged.values())
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/periods/{period_id}/recipes")
|
||||||
|
def period_recipes(
|
||||||
|
period_id: str,
|
||||||
|
enterprise_id: str = Query(...),
|
||||||
|
tenant: TenantContext = Depends(require_enterprise_zootech),
|
||||||
|
):
|
||||||
|
_require_ent(enterprise_id, tenant)
|
||||||
|
with session_scope() as db:
|
||||||
|
period = db.scalar(
|
||||||
|
select(ZootechFeedingPeriod).where(
|
||||||
|
ZootechFeedingPeriod.enterprise_id == enterprise_id,
|
||||||
|
ZootechFeedingPeriod.id == period_id,
|
||||||
|
ZootechFeedingPeriod.is_deleted.is_(False),
|
||||||
|
)
|
||||||
|
)
|
||||||
|
if not period:
|
||||||
|
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="NOT_FOUND")
|
||||||
|
return _recipes_for_period(db, enterprise_id, period_id)
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/recipes/{recipe_id}")
|
||||||
|
def wesp_get_recipe(
|
||||||
|
recipe_id: str,
|
||||||
|
enterprise_id: str = Query(...),
|
||||||
|
tenant: TenantContext = Depends(require_enterprise_zootech),
|
||||||
|
date: str | None = Query(None),
|
||||||
|
):
|
||||||
|
_require_ent(enterprise_id, tenant)
|
||||||
|
with session_scope() as db:
|
||||||
|
recipe = db.scalar(
|
||||||
|
select(ZootechRecipe).where(
|
||||||
|
ZootechRecipe.enterprise_id == enterprise_id,
|
||||||
|
ZootechRecipe.id == recipe_id,
|
||||||
|
ZootechRecipe.is_deleted.is_(False),
|
||||||
|
)
|
||||||
|
)
|
||||||
|
if not recipe:
|
||||||
|
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="NOT_FOUND")
|
||||||
|
return _serialize_recipe_wesp(db, enterprise_id, recipe)
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/recipes/calculate")
|
||||||
|
def wesp_calculate_recipe(body: dict):
|
||||||
|
from app.modules.zootech.wesp_recipe_write import RecipeWriteError, calculate_recipe_wesp, recipe_write_http_error
|
||||||
|
|
||||||
|
try:
|
||||||
|
return calculate_recipe_wesp(body)
|
||||||
|
except RecipeWriteError as exc:
|
||||||
|
raise recipe_write_http_error(exc) from exc
|
||||||
|
|
||||||
|
|
||||||
|
@router.put("/recipes/{recipe_id}")
|
||||||
|
def wesp_update_recipe(
|
||||||
|
recipe_id: str,
|
||||||
|
body: dict,
|
||||||
|
enterprise_id: str = Query(...),
|
||||||
|
tenant: TenantContext = Depends(require_enterprise_zootech),
|
||||||
|
):
|
||||||
|
from app.modules.zootech.wesp_recipe_write import RecipeWriteError, recipe_write_http_error, update_recipe_wesp
|
||||||
|
|
||||||
|
_require_ent(enterprise_id, tenant)
|
||||||
|
try:
|
||||||
|
return update_recipe_wesp(enterprise_id, recipe_id, body)
|
||||||
|
except RecipeWriteError as exc:
|
||||||
|
raise recipe_write_http_error(exc) from exc
|
||||||
@@ -0,0 +1,729 @@
|
|||||||
|
"""WESP-shaped admin endpoints for copied static UI (/api/admin/*)."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import json
|
||||||
|
import time
|
||||||
|
from datetime import UTC, datetime
|
||||||
|
from pathlib import Path
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
from fastapi import APIRouter, Depends, HTTPException, Query, status
|
||||||
|
from fastapi.responses import FileResponse
|
||||||
|
from pydantic import BaseModel, Field
|
||||||
|
from sqlalchemy import func, select
|
||||||
|
|
||||||
|
from app.core.config import settings
|
||||||
|
from app.core.server_logging import ensure_server_log_file
|
||||||
|
from app.core.database import session_scope
|
||||||
|
from app.core.dependencies import require_superuser
|
||||||
|
from app.core.audit_log import read_audit_events, write_audit_event
|
||||||
|
from app.modules.admin.service import (
|
||||||
|
create_admin_user,
|
||||||
|
delete_admin_user,
|
||||||
|
get_diagnostics_report,
|
||||||
|
get_server_log_tail,
|
||||||
|
patch_user,
|
||||||
|
reset_user_password,
|
||||||
|
)
|
||||||
|
from app.modules.zootech.orchestrator_system_metrics import collect_orchestrator_system_metrics
|
||||||
|
from app.modules.sync.models import Enterprise, FarmHub, SyncConflict, SyncOutbox
|
||||||
|
from app.modules.sync.service import get_sync_metrics
|
||||||
|
from app.modules.users import repository as users_repository
|
||||||
|
from app.modules.users.models import User
|
||||||
|
from app.modules.zootech.catalog_models import ZootechFeedDispenser, ZootechFeedingPeriod
|
||||||
|
from app.modules.zootech.models import ZootechComponent, ZootechIngredient, ZootechRecipe
|
||||||
|
|
||||||
|
router = APIRouter(prefix="/admin", tags=["zootech-wesp-admin"])
|
||||||
|
|
||||||
|
|
||||||
|
def _wesp_error(message: str, code: int = 400) -> None:
|
||||||
|
raise HTTPException(status_code=code, detail={"status": "error", "message": message})
|
||||||
|
|
||||||
|
|
||||||
|
def _count_zootech(model) -> int:
|
||||||
|
with session_scope() as db:
|
||||||
|
return (
|
||||||
|
db.scalar(select(func.count()).select_from(model).where(model.is_deleted.is_(False))) or 0
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _sync_queue_stats() -> dict[str, int]:
|
||||||
|
with session_scope() as db:
|
||||||
|
total = db.scalar(select(func.count()).select_from(SyncOutbox)) or 0
|
||||||
|
pending = (
|
||||||
|
db.scalar(
|
||||||
|
select(func.count()).select_from(SyncOutbox).where(SyncOutbox.status == "pending")
|
||||||
|
)
|
||||||
|
or 0
|
||||||
|
)
|
||||||
|
processing = (
|
||||||
|
db.scalar(
|
||||||
|
select(func.count())
|
||||||
|
.select_from(SyncOutbox)
|
||||||
|
.where(SyncOutbox.status == "processing")
|
||||||
|
)
|
||||||
|
or 0
|
||||||
|
)
|
||||||
|
failed = (
|
||||||
|
db.scalar(
|
||||||
|
select(func.count()).select_from(SyncOutbox).where(SyncOutbox.status == "failed")
|
||||||
|
)
|
||||||
|
or 0
|
||||||
|
)
|
||||||
|
return {
|
||||||
|
"total": int(total),
|
||||||
|
"pending": int(pending),
|
||||||
|
"processing": int(processing),
|
||||||
|
"failed": int(failed),
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _user_to_wesp(user: User) -> dict[str, Any]:
|
||||||
|
return {
|
||||||
|
"id": user.id,
|
||||||
|
"login": user.email,
|
||||||
|
"is_superuser": bool(user.is_superuser),
|
||||||
|
"lab_access": False,
|
||||||
|
"created_at": user.created_at.isoformat() if user.created_at else None,
|
||||||
|
"updated_at": user.updated_at.isoformat() if user.updated_at else None,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
class WespUiActivityIn(BaseModel):
|
||||||
|
text: str = Field(min_length=1)
|
||||||
|
level: str = "ok"
|
||||||
|
|
||||||
|
|
||||||
|
class WespUserCreateIn(BaseModel):
|
||||||
|
login: str = Field(min_length=1)
|
||||||
|
password: str = Field(min_length=8)
|
||||||
|
is_superuser: bool = False
|
||||||
|
|
||||||
|
|
||||||
|
class WespUserPatchIn(BaseModel):
|
||||||
|
password: str | None = None
|
||||||
|
is_superuser: bool | None = None
|
||||||
|
lab_access: bool | None = None
|
||||||
|
|
||||||
|
|
||||||
|
class WespNetworkPatchIn(BaseModel):
|
||||||
|
local_hostname: str | None = None
|
||||||
|
public_base_url: str | None = None
|
||||||
|
mdns_enabled: bool | None = None
|
||||||
|
|
||||||
|
|
||||||
|
class WespOrchestratorSyncPatchIn(BaseModel):
|
||||||
|
upstream_url: str | None = None
|
||||||
|
hub_site_id: str | None = None
|
||||||
|
api_key: str | None = None
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/users")
|
||||||
|
def wesp_list_users(_admin: User = Depends(require_superuser)):
|
||||||
|
users, _total = users_repository.list_users(page=1, limit=500)
|
||||||
|
payload = {"status": "success", "users": [_user_to_wesp(u) for u in users]}
|
||||||
|
return payload
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/users", status_code=201)
|
||||||
|
def wesp_create_user(payload: WespUserCreateIn, admin: User = Depends(require_superuser)):
|
||||||
|
email = payload.login.strip()
|
||||||
|
if "@" not in email:
|
||||||
|
_wesp_error("Используйте email в поле логина (admin@compton.example)")
|
||||||
|
try:
|
||||||
|
create_admin_user(
|
||||||
|
admin,
|
||||||
|
email=email,
|
||||||
|
password=payload.password,
|
||||||
|
role="admin" if payload.is_superuser else "user",
|
||||||
|
is_superuser=payload.is_superuser,
|
||||||
|
status="active",
|
||||||
|
)
|
||||||
|
except ValueError as exc:
|
||||||
|
if str(exc) == "USER_EXISTS":
|
||||||
|
_wesp_error("Пользователь с таким логином уже существует", 409)
|
||||||
|
_wesp_error(str(exc))
|
||||||
|
return {"status": "success"}
|
||||||
|
|
||||||
|
|
||||||
|
@router.patch("/users/{user_id}")
|
||||||
|
def wesp_patch_user(user_id: str, payload: WespUserPatchIn, admin: User = Depends(require_superuser)):
|
||||||
|
if payload.password:
|
||||||
|
try:
|
||||||
|
reset_user_password(admin, user_id, payload.password)
|
||||||
|
except ValueError as exc:
|
||||||
|
_wesp_error(str(exc), 404 if str(exc) == "USER_NOT_FOUND" else 400)
|
||||||
|
return {"status": "success"}
|
||||||
|
if payload.is_superuser is not None:
|
||||||
|
try:
|
||||||
|
patch_user(admin, user_id, None, None, payload.is_superuser)
|
||||||
|
except ValueError as exc:
|
||||||
|
_wesp_error(str(exc), 404 if str(exc) == "USER_NOT_FOUND" else 400)
|
||||||
|
return {"status": "success"}
|
||||||
|
if payload.lab_access is not None:
|
||||||
|
return {"status": "success"}
|
||||||
|
_wesp_error("Нет полей для обновления")
|
||||||
|
|
||||||
|
|
||||||
|
@router.delete("/users/{user_id}")
|
||||||
|
def wesp_delete_user(user_id: str, admin: User = Depends(require_superuser)):
|
||||||
|
try:
|
||||||
|
delete_admin_user(admin, user_id)
|
||||||
|
except ValueError as exc:
|
||||||
|
_wesp_error(str(exc), 404 if str(exc) == "USER_NOT_FOUND" else 400)
|
||||||
|
return {"status": "success"}
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/summary")
|
||||||
|
def wesp_admin_summary(_admin: User = Depends(require_superuser)):
|
||||||
|
queue = _sync_queue_stats()
|
||||||
|
with session_scope() as db:
|
||||||
|
enterprises = list(db.scalars(select(Enterprise).order_by(Enterprise.name)))
|
||||||
|
hub_count = db.scalar(select(func.count()).select_from(FarmHub)) or 0
|
||||||
|
enterprise_ids = [e.id for e in enterprises]
|
||||||
|
|
||||||
|
conflicts_pending = 0
|
||||||
|
for enterprise_id in enterprise_ids:
|
||||||
|
conflicts_pending += int(get_sync_metrics(enterprise_id).get("conflicts_pending") or 0)
|
||||||
|
|
||||||
|
payload = {
|
||||||
|
"status": "success",
|
||||||
|
"generated_at": datetime.now(UTC).isoformat(),
|
||||||
|
"app": {
|
||||||
|
"config_mode": settings.app_env,
|
||||||
|
"debug": settings.app_env != "production",
|
||||||
|
"version": "1.0.0-orchestrator",
|
||||||
|
"role": "server",
|
||||||
|
"first_launch_at": None,
|
||||||
|
"warranty_until": None,
|
||||||
|
"warranty_days_remaining": None,
|
||||||
|
},
|
||||||
|
"databases": {
|
||||||
|
"recipes": {"engine": "postgresql", "path": "orchestrator"},
|
||||||
|
"reports": {"engine": "postgresql", "path": "orchestrator"},
|
||||||
|
},
|
||||||
|
"machine": {"platform": "orchestrator"},
|
||||||
|
"counts": {
|
||||||
|
"users": users_repository.count_users(),
|
||||||
|
"recipes": _count_zootech(ZootechRecipe),
|
||||||
|
"ingredients": _count_zootech(ZootechIngredient),
|
||||||
|
"components": _count_zootech(ZootechComponent),
|
||||||
|
"feed_dispensers": _count_zootech(ZootechFeedDispenser),
|
||||||
|
"feeding_periods": _count_zootech(ZootechFeedingPeriod),
|
||||||
|
},
|
||||||
|
"sync": {
|
||||||
|
"client_role": "server",
|
||||||
|
"connection": {
|
||||||
|
"role": "server",
|
||||||
|
"server_url": settings.public_base_url.rstrip("/"),
|
||||||
|
"configured": True,
|
||||||
|
},
|
||||||
|
"max_concurrent": 4,
|
||||||
|
"queue": queue,
|
||||||
|
"client_poll_interval_sec": 14,
|
||||||
|
"first_sync_banner": False,
|
||||||
|
"initial_sync_progress": {"active": False},
|
||||||
|
"guarantees": {
|
||||||
|
"master_data": "Orchestrator — источник правды для предприятий и хабов.",
|
||||||
|
"reports": "Отчёты принимаются через sync API.",
|
||||||
|
},
|
||||||
|
"state": {"hub_count": int(hub_count), "enterprise_count": len(enterprise_ids)},
|
||||||
|
"queue_errors": [],
|
||||||
|
},
|
||||||
|
"security": {
|
||||||
|
"mac_lock_enabled": False,
|
||||||
|
"calibration_public": False,
|
||||||
|
"device_token_configured": False,
|
||||||
|
"kiosk_enforce_paired_only": False,
|
||||||
|
},
|
||||||
|
"auto_update": {"enabled": False, "note": "Обновления управляются деплоем orchestrator."},
|
||||||
|
"hardware": {"simulation_mode": True, "note": "Железо недоступно на orchestrator."},
|
||||||
|
}
|
||||||
|
return payload
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/system-metrics")
|
||||||
|
def wesp_system_metrics(_admin: User = Depends(require_superuser)):
|
||||||
|
metrics = collect_orchestrator_system_metrics()
|
||||||
|
return {"status": "success", "metrics": metrics}
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/factory-reset/preview")
|
||||||
|
def wesp_factory_reset_preview(_admin: User = Depends(require_superuser)):
|
||||||
|
return {
|
||||||
|
"status": "success",
|
||||||
|
"confirm_phrase": "СБРОС ДАННЫХ",
|
||||||
|
"preserved_note": "Сброс data/ недоступен на orchestrator — данные в PostgreSQL и S3.",
|
||||||
|
"files": [],
|
||||||
|
"directories": [],
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/network-settings")
|
||||||
|
def wesp_network_settings_get(_admin: User = Depends(require_superuser)):
|
||||||
|
public_url = settings.public_base_url.rstrip("/")
|
||||||
|
return {
|
||||||
|
"status": "success",
|
||||||
|
"network": {
|
||||||
|
"local_hostname": "orchestrator.local",
|
||||||
|
"default_local_hostname": "orchestrator.local",
|
||||||
|
"public_base_url": public_url,
|
||||||
|
"mdns_enabled": False,
|
||||||
|
"local_hostname_locked_by_env": True,
|
||||||
|
"public_base_url_locked_by_env": False,
|
||||||
|
"mdns_enabled_locked_by_env": True,
|
||||||
|
"detected": {
|
||||||
|
"listen_port": 5173,
|
||||||
|
"listen_host": "0.0.0.0",
|
||||||
|
"os_hostname": "orchestrator",
|
||||||
|
"mdns_available": False,
|
||||||
|
"mdns_active": False,
|
||||||
|
},
|
||||||
|
"suggested_public_url": public_url,
|
||||||
|
"sync_url_by_ip": public_url,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
@router.patch("/network-settings")
|
||||||
|
def wesp_network_settings_patch(_payload: WespNetworkPatchIn, _admin: User = Depends(require_superuser)):
|
||||||
|
return {"status": "success", "message": "Сетевые настройки orchestrator задаются через PUBLIC_BASE_URL / nginx."}
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/peripheral-events")
|
||||||
|
def wesp_peripheral_events(
|
||||||
|
limit: int = Query(default=50, ge=1, le=200),
|
||||||
|
component: str | None = None,
|
||||||
|
exclude_component: str | None = None,
|
||||||
|
_admin: User = Depends(require_superuser),
|
||||||
|
):
|
||||||
|
_ = (limit, component, exclude_component)
|
||||||
|
return {"status": "success", "events": []}
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/sync-diagnostics")
|
||||||
|
def wesp_sync_diagnostics(_admin: User = Depends(require_superuser)):
|
||||||
|
queue = _sync_queue_stats()
|
||||||
|
with session_scope() as db:
|
||||||
|
enterprises = list(db.scalars(select(Enterprise).order_by(Enterprise.name)))
|
||||||
|
hubs = list(db.scalars(select(FarmHub).order_by(FarmHub.created_at.desc())))
|
||||||
|
conflicts_total = db.scalar(select(func.count()).select_from(SyncConflict)) or 0
|
||||||
|
conflicts_pending = (
|
||||||
|
db.scalar(
|
||||||
|
select(func.count())
|
||||||
|
.select_from(SyncConflict)
|
||||||
|
.where(SyncConflict.status == "pending")
|
||||||
|
)
|
||||||
|
or 0
|
||||||
|
)
|
||||||
|
hub_rows = [
|
||||||
|
{
|
||||||
|
"farm_hub_id": h.id,
|
||||||
|
"name": h.name,
|
||||||
|
"hub_site_id": h.hub_site_id,
|
||||||
|
"status": h.status,
|
||||||
|
"last_seen": h.last_seen.isoformat() if h.last_seen else None,
|
||||||
|
"enterprise_id": h.enterprise_id,
|
||||||
|
}
|
||||||
|
for h in hubs
|
||||||
|
]
|
||||||
|
enterprise_rows = [{"id": e.id, "name": e.name, "slug": e.slug} for e in enterprises]
|
||||||
|
|
||||||
|
for row in hub_rows:
|
||||||
|
row.update(get_sync_metrics(row.pop("enterprise_id")))
|
||||||
|
|
||||||
|
payload = {
|
||||||
|
"status": "success",
|
||||||
|
"role": "server",
|
||||||
|
"queues": {
|
||||||
|
"summary": queue,
|
||||||
|
"by_table": {},
|
||||||
|
"stuck_processing": [],
|
||||||
|
"processing_missing_processed_at": [],
|
||||||
|
"failed_recent": [],
|
||||||
|
"high_retry_pending": [],
|
||||||
|
},
|
||||||
|
"clients": {"recent_deliveries": []},
|
||||||
|
"conflicts": {
|
||||||
|
"summary": {"total": int(conflicts_total), "pending": int(conflicts_pending)},
|
||||||
|
"recent": [],
|
||||||
|
},
|
||||||
|
"report_push": {
|
||||||
|
"scope": "orchestrator",
|
||||||
|
"note": "Push отчётов обрабатывается через sync engine.",
|
||||||
|
"total": 0,
|
||||||
|
},
|
||||||
|
"engine_state": {
|
||||||
|
"enterprises": enterprise_rows,
|
||||||
|
"hubs": hub_rows,
|
||||||
|
},
|
||||||
|
"initial_sync_progress": {"active": False},
|
||||||
|
"actions_capabilities": {
|
||||||
|
"requeue_stuck": False,
|
||||||
|
"restart_local_sync": False,
|
||||||
|
"refresh_diagnostics": True,
|
||||||
|
"retry_report_push": False,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
return payload
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/orchestrator-sync")
|
||||||
|
def wesp_orchestrator_sync_get(_admin: User = Depends(require_superuser)):
|
||||||
|
with session_scope() as db:
|
||||||
|
hub = db.scalar(select(FarmHub).order_by(FarmHub.created_at.desc()))
|
||||||
|
hub_site_id = hub.hub_site_id if hub else ""
|
||||||
|
return {
|
||||||
|
"status": "success",
|
||||||
|
"orchestrator_sync": {
|
||||||
|
"upstream_url": settings.public_base_url.rstrip("/"),
|
||||||
|
"hub_site_id": hub_site_id,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
@router.patch("/orchestrator-sync")
|
||||||
|
def wesp_orchestrator_sync_patch(_payload: WespOrchestratorSyncPatchIn, _admin: User = Depends(require_superuser)):
|
||||||
|
return {
|
||||||
|
"status": "success",
|
||||||
|
"message": "Параметры hub↔orchestrator настраиваются через pairing, не из этой формы.",
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/activity-feed")
|
||||||
|
def wesp_activity_feed(limit: int = Query(default=200, ge=1, le=1000), _admin: User = Depends(require_superuser)):
|
||||||
|
entries: list[dict[str, Any]] = []
|
||||||
|
for event in read_audit_events(limit=limit):
|
||||||
|
ts_raw = event.get("timestamp")
|
||||||
|
ts_ms = 0
|
||||||
|
if ts_raw:
|
||||||
|
try:
|
||||||
|
ts_ms = int(datetime.fromisoformat(str(ts_raw).replace("Z", "+00:00")).timestamp() * 1000)
|
||||||
|
except ValueError:
|
||||||
|
ts_ms = int(time.time() * 1000)
|
||||||
|
details = event.get("details") or {}
|
||||||
|
text = str(details.get("text") or event.get("action") or "")
|
||||||
|
level = str(details.get("level") or "ok")
|
||||||
|
if level not in {"ok", "err", "warn"}:
|
||||||
|
level = "ok"
|
||||||
|
entries.append({"ts": ts_ms, "level": level, "text": text})
|
||||||
|
return {"status": "success", "entries": entries, "meta": "orchestrator-audit"}
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/ui-activity")
|
||||||
|
def wesp_ui_activity(payload: WespUiActivityIn, admin: User = Depends(require_superuser)):
|
||||||
|
level = payload.level if payload.level in {"ok", "err", "warn"} else "ok"
|
||||||
|
write_audit_event(
|
||||||
|
action="ui.activity",
|
||||||
|
actor_user_id=admin.id,
|
||||||
|
actor_email=admin.email,
|
||||||
|
details={"text": payload.text, "level": level},
|
||||||
|
)
|
||||||
|
return {"status": "success"}
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/diagnostics/report")
|
||||||
|
def wesp_diagnostics_report(_admin: User = Depends(require_superuser)):
|
||||||
|
report = get_diagnostics_report()
|
||||||
|
return {"status": "success", **report}
|
||||||
|
|
||||||
|
|
||||||
|
def _security_settings_payload() -> dict[str, Any]:
|
||||||
|
return {
|
||||||
|
"mac_lock_enabled": False,
|
||||||
|
"mac_lock_enabled_locked_by_env": True,
|
||||||
|
"allowed_mac_addresses": "",
|
||||||
|
"allowed_mac_addresses_locked_by_env": True,
|
||||||
|
"kiosk_enforce_paired_only": False,
|
||||||
|
"kiosk_enforce_paired_only_locked_by_env": True,
|
||||||
|
"calibration_public": False,
|
||||||
|
"calibration_public_locked_by_env": True,
|
||||||
|
"device_token_configured": False,
|
||||||
|
"device_token_locked_by_env": True,
|
||||||
|
"device_token_header": "X-Device-Token",
|
||||||
|
"device_token_header_locked_by_env": True,
|
||||||
|
"kiosk_pair_token_ttl_seconds": 300,
|
||||||
|
"kiosk_pair_token_ttl_seconds_locked_by_env": True,
|
||||||
|
"kiosk_auth_cookie_days": 18250,
|
||||||
|
"kiosk_auth_cookie_days_locked_by_env": True,
|
||||||
|
"session_remember_days": 7,
|
||||||
|
"session_remember_days_locked_by_env": True,
|
||||||
|
"llm_autostart": False,
|
||||||
|
"llm_autostart_locked_by_env": True,
|
||||||
|
"admin_llm_enabled": False,
|
||||||
|
"admin_llm_enabled_locked_by_env": True,
|
||||||
|
"llm_chat_db_context": False,
|
||||||
|
"llm_chat_db_context_locked_by_env": True,
|
||||||
|
"llm_tools_enabled": False,
|
||||||
|
"llm_tools_enabled_locked_by_env": True,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _auto_update_payload() -> dict[str, Any]:
|
||||||
|
return {
|
||||||
|
"enabled": False,
|
||||||
|
"auto_install": False,
|
||||||
|
"gitea_url": "",
|
||||||
|
"gitea_owner": "",
|
||||||
|
"gitea_repo": "",
|
||||||
|
"repository_url": "",
|
||||||
|
"check_interval_sec": 3600,
|
||||||
|
"note": "Автообновление недоступно на orchestrator.",
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _hub_only_stub(message: str, **extra: Any) -> dict[str, Any]:
|
||||||
|
return {"status": "success", "available": False, "orchestrator": True, "message": message, **extra}
|
||||||
|
|
||||||
|
|
||||||
|
def _patch_ok(message: str, **extra: Any) -> dict[str, Any]:
|
||||||
|
return {"status": "success", "message": message, **extra}
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/hardware-status")
|
||||||
|
def wesp_hardware_status(_admin: User = Depends(require_superuser)):
|
||||||
|
return {
|
||||||
|
"status": "success",
|
||||||
|
"config": {"simulation_mode": True, "read_interval": 0.05, "samples_per_read": 3},
|
||||||
|
"peripherals": {},
|
||||||
|
"scales": {"available": False, "error": "Железо недоступно на orchestrator (Docker)."},
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/hardware-metrics-history")
|
||||||
|
def wesp_hardware_metrics_history(_admin: User = Depends(require_superuser)):
|
||||||
|
return {"status": "success", "points": [], "path": None, "retention_days": 0}
|
||||||
|
|
||||||
|
|
||||||
|
@router.patch("/hardware/simulation")
|
||||||
|
def wesp_hardware_simulation_patch(_admin: User = Depends(require_superuser)):
|
||||||
|
return _patch_ok("Симуляция железа недоступна на orchestrator.")
|
||||||
|
|
||||||
|
|
||||||
|
@router.put("/hardware/simulation/weight")
|
||||||
|
@router.patch("/hardware/simulation/weight")
|
||||||
|
def wesp_hardware_simulation_weight(_admin: User = Depends(require_superuser)):
|
||||||
|
return _patch_ok("Симуляция весов недоступна на orchestrator.")
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/llm/status")
|
||||||
|
def wesp_llm_status(_admin: User = Depends(require_superuser)):
|
||||||
|
return {
|
||||||
|
"status": "success",
|
||||||
|
"enabled": False,
|
||||||
|
"llm_base_url": "",
|
||||||
|
"model": "",
|
||||||
|
"assistant_dir": "",
|
||||||
|
"llm_autostart": False,
|
||||||
|
"llm_autostart_locked_by_env": True,
|
||||||
|
"admin_llm_enabled_locked_by_env": True,
|
||||||
|
"llm_chat_db_context": False,
|
||||||
|
"llm_chat_db_context_locked_by_env": True,
|
||||||
|
"llm_tools_enabled": False,
|
||||||
|
"llm_tools_enabled_locked_by_env": True,
|
||||||
|
"llm_reachable": False,
|
||||||
|
"model_present": False,
|
||||||
|
"llm_error": "LLM недоступен на orchestrator.",
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/llm/activity")
|
||||||
|
def wesp_llm_activity(_admin: User = Depends(require_superuser)):
|
||||||
|
return {"status": "success", "entries": []}
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/llm/chat")
|
||||||
|
@router.post("/llm/ping")
|
||||||
|
@router.post("/llm/diagnostics")
|
||||||
|
@router.post("/llm/summary")
|
||||||
|
def wesp_llm_actions(_admin: User = Depends(require_superuser)):
|
||||||
|
return {"status": "error", "message": "LLM недоступен на orchestrator."}
|
||||||
|
|
||||||
|
|
||||||
|
@router.patch("/security-settings")
|
||||||
|
def wesp_security_settings_patch(_admin: User = Depends(require_superuser)):
|
||||||
|
return _patch_ok(
|
||||||
|
"Настройки безопасности hub недоступны на orchestrator.",
|
||||||
|
security=_security_settings_payload(),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@router.patch("/auto-update-settings")
|
||||||
|
def wesp_auto_update_settings_patch(_admin: User = Depends(require_superuser)):
|
||||||
|
return _patch_ok(
|
||||||
|
"Автообновление недоступно на orchestrator.",
|
||||||
|
auto_update=_auto_update_payload(),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@router.patch("/gitea-secrets")
|
||||||
|
def wesp_gitea_secrets_patch(_admin: User = Depends(require_superuser)):
|
||||||
|
return _patch_ok("Gitea secrets недоступны на orchestrator.", auto_update=_auto_update_payload())
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/sync-settings")
|
||||||
|
def wesp_sync_settings_get(_admin: User = Depends(require_superuser)):
|
||||||
|
return {
|
||||||
|
"status": "success",
|
||||||
|
"connection": {"role": "server", "server_url": settings.public_base_url.rstrip("/"), "configured": True},
|
||||||
|
"state": {},
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
@router.patch("/sync-settings")
|
||||||
|
def wesp_sync_settings_patch(_admin: User = Depends(require_superuser)):
|
||||||
|
return _patch_ok("Sync settings hub-client недоступны на orchestrator (роль server).")
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/sync-actions/requeue-stuck")
|
||||||
|
@router.post("/sync-actions/restart-local-sync")
|
||||||
|
@router.post("/sync-actions/refresh-runtime")
|
||||||
|
@router.post("/sync-actions/retry-report-push")
|
||||||
|
def wesp_sync_actions(_admin: User = Depends(require_superuser)):
|
||||||
|
return _patch_ok("Действие sync hub-client недоступно на orchestrator.")
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/kiosk-full-setup/status")
|
||||||
|
def wesp_kiosk_full_setup_status(_admin: User = Depends(require_superuser)):
|
||||||
|
return _hub_only_stub(
|
||||||
|
"Kiosk setup только на Raspberry Pi hub.",
|
||||||
|
running_as_root=False,
|
||||||
|
full_setup_ready=False,
|
||||||
|
platform_ready=False,
|
||||||
|
kiosk_enabled=False,
|
||||||
|
chromium_found=False,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/kiosk-full-setup")
|
||||||
|
def wesp_kiosk_full_setup_post(_admin: User = Depends(require_superuser)):
|
||||||
|
_wesp_error("Kiosk setup только на Raspberry Pi hub.", 400)
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/pi-platform/status")
|
||||||
|
def wesp_pi_platform_status(_admin: User = Depends(require_superuser)):
|
||||||
|
return _hub_only_stub("Pi platform setup только на hub.", running_as_root=False, platform_ready=False)
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/pi-platform/apply")
|
||||||
|
def wesp_pi_platform_apply(_admin: User = Depends(require_superuser)):
|
||||||
|
_wesp_error("Pi platform setup только на hub.", 400)
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/pi-boot/rainbow-splash/status")
|
||||||
|
def wesp_pi_rainbow_status(_admin: User = Depends(require_superuser)):
|
||||||
|
return _hub_only_stub("Pi boot config только на hub.", config_found=False, disable_splash=True)
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/pi-boot/rainbow-splash")
|
||||||
|
def wesp_pi_rainbow_post(_admin: User = Depends(require_superuser)):
|
||||||
|
_wesp_error("Pi boot config только на hub.", 400)
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/plymouth/status")
|
||||||
|
def wesp_plymouth_status(_admin: User = Depends(require_superuser)):
|
||||||
|
return _hub_only_stub("Plymouth только на hub.", installed=False, running=False)
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/plymouth/install")
|
||||||
|
def wesp_plymouth_install(_admin: User = Depends(require_superuser)):
|
||||||
|
_wesp_error("Plymouth только на hub.", 400)
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/kiosk-boot/status")
|
||||||
|
def wesp_kiosk_boot_status(_admin: User = Depends(require_superuser)):
|
||||||
|
return _hub_only_stub("Kiosk boot только на hub.", configured=False)
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/kiosk-boot")
|
||||||
|
def wesp_kiosk_boot_post(_admin: User = Depends(require_superuser)):
|
||||||
|
_wesp_error("Kiosk boot только на hub.", 400)
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/factory-reset")
|
||||||
|
def wesp_factory_reset_post(_admin: User = Depends(require_superuser)):
|
||||||
|
_wesp_error("Factory reset data/ недоступен на orchestrator.", 400)
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/service-control")
|
||||||
|
def wesp_service_control(_admin: User = Depends(require_superuser)):
|
||||||
|
_wesp_error("Управление systemd недоступно на orchestrator.", 400)
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/backup.sqlite")
|
||||||
|
@router.post("/restore.sqlite")
|
||||||
|
def wesp_sqlite_backup(_admin: User = Depends(require_superuser)):
|
||||||
|
_wesp_error("SQLite backup/restore только на hub.", 400)
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/client-uploaded-logs")
|
||||||
|
def wesp_client_uploaded_logs(_admin: User = Depends(require_superuser)):
|
||||||
|
return {"status": "success", "root": "", "clients": []}
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/client-uploaded-logs/tail")
|
||||||
|
def wesp_client_uploaded_logs_tail(
|
||||||
|
client_id: str | None = None,
|
||||||
|
file: str | None = None,
|
||||||
|
lines: int = Query(default=300, ge=1, le=2000),
|
||||||
|
_admin: User = Depends(require_superuser),
|
||||||
|
):
|
||||||
|
_ = (client_id, file, lines)
|
||||||
|
return {
|
||||||
|
"status": "error",
|
||||||
|
"message": "Логи клиентов (POST /api/sync/client-log) на orchestrator пока не настроены.",
|
||||||
|
"lines": [],
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/client-uploaded-logs/download")
|
||||||
|
def wesp_client_uploaded_logs_download(
|
||||||
|
client_id: str | None = None,
|
||||||
|
file: str | None = None,
|
||||||
|
_admin: User = Depends(require_superuser),
|
||||||
|
):
|
||||||
|
_ = (client_id, file)
|
||||||
|
_wesp_error("Логи клиентов на orchestrator пока не настроены.", 404)
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/server-log")
|
||||||
|
def wesp_server_log(lines: int = Query(default=200, ge=1, le=1000), _admin: User = Depends(require_superuser)):
|
||||||
|
log_path = ensure_server_log_file()
|
||||||
|
tail = get_server_log_tail(lines=lines)
|
||||||
|
payload = {"status": "success", "path": str(log_path), "lines": tail.get("lines") or []}
|
||||||
|
return payload
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/server-log/download")
|
||||||
|
def wesp_server_log_download(_admin: User = Depends(require_superuser)):
|
||||||
|
log_path = ensure_server_log_file()
|
||||||
|
return FileResponse(
|
||||||
|
log_path,
|
||||||
|
media_type="text/plain; charset=utf-8",
|
||||||
|
filename=log_path.name,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/diagnostics/settings")
|
||||||
|
def wesp_diagnostics_settings_get(_admin: User = Depends(require_superuser)):
|
||||||
|
return {"status": "success", "targets": [], "extra_targets": []}
|
||||||
|
|
||||||
|
|
||||||
|
@router.patch("/diagnostics/settings")
|
||||||
|
def wesp_diagnostics_settings_patch(_admin: User = Depends(require_superuser)):
|
||||||
|
return _patch_ok("Diagnostics settings сохранены локально недоступны на orchestrator.")
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/diagnostics/network-run")
|
||||||
|
@router.post("/diagnostics/hx711-sample")
|
||||||
|
@router.post("/diagnostics/gpio-blink")
|
||||||
|
@router.post("/diagnostics/traceroute-by-hosts")
|
||||||
|
def wesp_diagnostics_actions(_admin: User = Depends(require_superuser)):
|
||||||
|
return _patch_ok("Диагностика железа/сети hub недоступна на orchestrator.")
|
||||||
|
|
||||||
|
|
||||||
|
@router.patch("/sync-clients/{node_id}/ip")
|
||||||
|
def wesp_sync_client_ip(_node_id: str, _admin: User = Depends(require_superuser)):
|
||||||
|
return _patch_ok("Sync client IP управляется через hub sync API.")
|
||||||
|
|
||||||
@@ -0,0 +1,324 @@
|
|||||||
|
"""WESP-shaped analytics and feed-quality endpoints for /reports tabs."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import json
|
||||||
|
from collections import defaultdict
|
||||||
|
from datetime import UTC, datetime, timedelta
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
from fastapi import APIRouter, Depends, HTTPException, Query, status
|
||||||
|
from sqlalchemy import select
|
||||||
|
|
||||||
|
from app.core.database import session_scope
|
||||||
|
from app.modules.sync.tenant import TenantContext, require_enterprise_zootech
|
||||||
|
from app.modules.zootech.models import ZootechComponent
|
||||||
|
from app.modules.zootech.report_models import ZootechFeedAlert, ZootechLoadingReport
|
||||||
|
from app.modules.zootech.wesp_compat_reports import _parse_payload, _parse_report_time
|
||||||
|
|
||||||
|
router = APIRouter()
|
||||||
|
|
||||||
|
|
||||||
|
def _require_ent(enterprise_id: str, tenant: TenantContext) -> None:
|
||||||
|
if tenant.enterprise_id != enterprise_id:
|
||||||
|
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="ENTERPRISE_FORBIDDEN")
|
||||||
|
|
||||||
|
|
||||||
|
def _parse_date_window(date_from: str | None, date_to: str | None) -> tuple[datetime, datetime] | None:
|
||||||
|
if not date_from or not date_to:
|
||||||
|
return None
|
||||||
|
try:
|
||||||
|
start = datetime.strptime(date_from.strip(), "%Y-%m-%d").replace(tzinfo=UTC)
|
||||||
|
end = datetime.strptime(date_to.strip(), "%Y-%m-%d").replace(tzinfo=UTC) + timedelta(days=1)
|
||||||
|
return start, end
|
||||||
|
except ValueError as exc:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_400_BAD_REQUEST,
|
||||||
|
detail={"error": True, "message": "Некорректный формат date_from/date_to (YYYY-MM-DD)"},
|
||||||
|
) from exc
|
||||||
|
|
||||||
|
|
||||||
|
def _loading_reports_in_range(
|
||||||
|
enterprise_id: str, window: tuple[datetime, datetime] | None
|
||||||
|
) -> list[tuple[str, dict[str, Any]]]:
|
||||||
|
with session_scope() as db:
|
||||||
|
rows = list(
|
||||||
|
db.scalars(
|
||||||
|
select(ZootechLoadingReport).where(
|
||||||
|
ZootechLoadingReport.enterprise_id == enterprise_id,
|
||||||
|
ZootechLoadingReport.is_deleted.is_(False),
|
||||||
|
)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
out: list[tuple[str, dict[str, Any]]] = []
|
||||||
|
for row in rows:
|
||||||
|
payload = _parse_payload(row)
|
||||||
|
start_time = _parse_report_time(payload.get("start_time"))
|
||||||
|
if window and start_time and not (window[0] <= start_time < window[1]):
|
||||||
|
continue
|
||||||
|
if window and start_time is None:
|
||||||
|
continue
|
||||||
|
out.append((row.id, payload))
|
||||||
|
return out
|
||||||
|
|
||||||
|
|
||||||
|
def _component_prices(enterprise_id: str) -> dict[str, dict[str, float]]:
|
||||||
|
with session_scope() as db:
|
||||||
|
rows = list(
|
||||||
|
db.scalars(
|
||||||
|
select(ZootechComponent).where(
|
||||||
|
ZootechComponent.enterprise_id == enterprise_id,
|
||||||
|
ZootechComponent.is_deleted.is_(False),
|
||||||
|
)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
by_id = {row.id: float(row.price or 0) for row in rows}
|
||||||
|
by_name = {(row.name or "").strip().lower(): float(row.price or 0) for row in rows if row.name}
|
||||||
|
return {"id": by_id, "name": by_name}
|
||||||
|
|
||||||
|
|
||||||
|
def _price_for(prices: dict[str, dict[str, float]], component_id: str | None, name: str) -> float:
|
||||||
|
if component_id and component_id in prices["id"]:
|
||||||
|
return prices["id"][component_id]
|
||||||
|
key = (name or "").strip().lower()
|
||||||
|
if key and key in prices["name"]:
|
||||||
|
return prices["name"][key]
|
||||||
|
return 0.0
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/analytics/finance")
|
||||||
|
def analytics_finance_wesp(
|
||||||
|
enterprise_id: str = Query(...),
|
||||||
|
date_from: str | None = Query(None),
|
||||||
|
date_to: str | None = Query(None),
|
||||||
|
tenant: TenantContext = Depends(require_enterprise_zootech),
|
||||||
|
):
|
||||||
|
_require_ent(enterprise_id, tenant)
|
||||||
|
window = _parse_date_window(date_from, date_to)
|
||||||
|
reports = _loading_reports_in_range(enterprise_id, window)
|
||||||
|
if not reports:
|
||||||
|
return {
|
||||||
|
"overloadRub": 0.0,
|
||||||
|
"underloadRub": 0.0,
|
||||||
|
"netRub": 0.0,
|
||||||
|
"dominantIssue": "balanced",
|
||||||
|
"topComponents": [],
|
||||||
|
"reportCount": 0,
|
||||||
|
}
|
||||||
|
|
||||||
|
prices = _component_prices(enterprise_id)
|
||||||
|
by_component: dict[str, dict[str, Any]] = defaultdict(
|
||||||
|
lambda: {"name": "", "overloadRub": 0.0, "underloadRub": 0.0, "netRub": 0.0}
|
||||||
|
)
|
||||||
|
overload_total = 0.0
|
||||||
|
underload_total = 0.0
|
||||||
|
|
||||||
|
for _report_id, payload in reports:
|
||||||
|
components = payload.get("components")
|
||||||
|
if not isinstance(components, list):
|
||||||
|
continue
|
||||||
|
for comp in components:
|
||||||
|
if not isinstance(comp, dict):
|
||||||
|
continue
|
||||||
|
target = float(comp.get("target_weight") or 0)
|
||||||
|
actual = float(comp.get("actual_weight") or 0)
|
||||||
|
if target <= 0 and actual <= 0:
|
||||||
|
continue
|
||||||
|
name = str(comp.get("component_name") or "—")
|
||||||
|
price = _price_for(prices, comp.get("component_id"), name)
|
||||||
|
dev_kg = actual - target
|
||||||
|
dev_rub = dev_kg * price
|
||||||
|
key = str(comp.get("component_id") or name)
|
||||||
|
row = by_component[key]
|
||||||
|
row["name"] = name
|
||||||
|
row["componentId"] = comp.get("component_id")
|
||||||
|
if dev_rub > 0:
|
||||||
|
row["overloadRub"] += dev_rub
|
||||||
|
overload_total += dev_rub
|
||||||
|
elif dev_rub < 0:
|
||||||
|
row["underloadRub"] += abs(dev_rub)
|
||||||
|
underload_total += abs(dev_rub)
|
||||||
|
row["netRub"] += dev_rub
|
||||||
|
|
||||||
|
top = sorted(
|
||||||
|
by_component.values(),
|
||||||
|
key=lambda item: max(item["overloadRub"], item["underloadRub"]),
|
||||||
|
reverse=True,
|
||||||
|
)[:3]
|
||||||
|
top_out = [
|
||||||
|
{
|
||||||
|
"name": item["name"],
|
||||||
|
"componentId": item.get("componentId"),
|
||||||
|
"overloadRub": round(item["overloadRub"], 2),
|
||||||
|
"underloadRub": round(item["underloadRub"], 2),
|
||||||
|
"netRub": round(item["netRub"], 2),
|
||||||
|
}
|
||||||
|
for item in top
|
||||||
|
if max(item["overloadRub"], item["underloadRub"]) > 0
|
||||||
|
]
|
||||||
|
net = overload_total - underload_total
|
||||||
|
if overload_total > underload_total:
|
||||||
|
dominant = "overload"
|
||||||
|
elif underload_total > overload_total:
|
||||||
|
dominant = "underload"
|
||||||
|
else:
|
||||||
|
dominant = "balanced"
|
||||||
|
result = {
|
||||||
|
"overloadRub": round(overload_total, 2),
|
||||||
|
"underloadRub": round(underload_total, 2),
|
||||||
|
"netRub": round(net, 2),
|
||||||
|
"dominantIssue": dominant,
|
||||||
|
"topComponents": top_out,
|
||||||
|
"reportCount": len(reports),
|
||||||
|
}
|
||||||
|
# #region agent log
|
||||||
|
try:
|
||||||
|
import pathlib
|
||||||
|
_log_path = pathlib.Path("/Users/vlad/Documents/wesp new (1)/.cursor/debug-785e22.log")
|
||||||
|
_log_path.parent.mkdir(parents=True, exist_ok=True)
|
||||||
|
with _log_path.open("a", encoding="utf-8") as _lf:
|
||||||
|
_lf.write(json.dumps({"sessionId":"785e22","hypothesisId":"E","location":"wesp_compat_analytics.py:finance","message":"finance summary","data":{"date_from":date_from,"date_to":date_to,"report_count":len(reports),"underloadRub":result["underloadRub"],"top_count":len(top_out)},"timestamp":int(datetime.now(UTC).timestamp()*1000)}, ensure_ascii=False) + "\n")
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
# #endregion
|
||||||
|
return result
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/analytics/plan-fact")
|
||||||
|
def analytics_plan_fact_wesp(
|
||||||
|
enterprise_id: str = Query(...),
|
||||||
|
date_from: str | None = Query(None),
|
||||||
|
date_to: str | None = Query(None),
|
||||||
|
tenant: TenantContext = Depends(require_enterprise_zootech),
|
||||||
|
):
|
||||||
|
_require_ent(enterprise_id, tenant)
|
||||||
|
return {"items": []}
|
||||||
|
|
||||||
|
|
||||||
|
def _parse_alert_payload_json(raw: str | None) -> dict[str, Any]:
|
||||||
|
if not raw:
|
||||||
|
return {}
|
||||||
|
try:
|
||||||
|
parsed = json.loads(raw)
|
||||||
|
except json.JSONDecodeError:
|
||||||
|
return {}
|
||||||
|
return parsed if isinstance(parsed, dict) else {}
|
||||||
|
|
||||||
|
|
||||||
|
def _alert_payload(row: ZootechFeedAlert) -> dict[str, Any]:
|
||||||
|
return _parse_alert_payload_json(row.payload_json)
|
||||||
|
|
||||||
|
|
||||||
|
def _serialize_feed_alert(
|
||||||
|
alert_id: str,
|
||||||
|
alert_type: str,
|
||||||
|
severity: str,
|
||||||
|
message: str,
|
||||||
|
created_at: datetime | None,
|
||||||
|
payload: dict[str, Any],
|
||||||
|
) -> dict[str, Any]:
|
||||||
|
event_type = payload.get("event_type") or alert_type or ""
|
||||||
|
created = payload.get("created_at") or (created_at.isoformat() if created_at else None)
|
||||||
|
return {
|
||||||
|
"id": alert_id,
|
||||||
|
"eventType": event_type,
|
||||||
|
"severity": payload.get("severity") or severity or "warning",
|
||||||
|
"loadingReportId": payload.get("loading_report_id") or "",
|
||||||
|
"unloadingReportId": payload.get("unloading_report_id"),
|
||||||
|
"recipeId": payload.get("recipe_id") or "",
|
||||||
|
"recipeName": payload.get("recipe_name") or "",
|
||||||
|
"componentName": payload.get("component_name"),
|
||||||
|
"groupName": payload.get("group_name"),
|
||||||
|
"detail": payload.get("detail") or message or "",
|
||||||
|
"deviationKg": payload.get("deviation_kg"),
|
||||||
|
"deviationPct": payload.get("deviation_pct"),
|
||||||
|
"costDeviationRub": payload.get("cost_deviation_rub"),
|
||||||
|
"clientId": payload.get("client_id"),
|
||||||
|
"createdAt": created,
|
||||||
|
"linkKind": "report_loading",
|
||||||
|
"linkId": payload.get("loading_report_id") or "",
|
||||||
|
"targetKg": None,
|
||||||
|
"actualKg": None,
|
||||||
|
"durationSec": None,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _feed_alerts_for_range(
|
||||||
|
enterprise_id: str,
|
||||||
|
window: tuple[datetime, datetime] | None,
|
||||||
|
*,
|
||||||
|
severity: str | None = None,
|
||||||
|
limit: int = 200,
|
||||||
|
) -> list[dict[str, Any]]:
|
||||||
|
loading_by_id = {
|
||||||
|
report_id: payload for report_id, payload in _loading_reports_in_range(enterprise_id, window)
|
||||||
|
}
|
||||||
|
alert_rows: list[tuple[str, str, str, str, datetime | None, dict[str, Any]]] = []
|
||||||
|
with session_scope() as db:
|
||||||
|
rows = list(
|
||||||
|
db.scalars(
|
||||||
|
select(ZootechFeedAlert).where(
|
||||||
|
ZootechFeedAlert.enterprise_id == enterprise_id,
|
||||||
|
ZootechFeedAlert.is_deleted.is_(False),
|
||||||
|
)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
for row in rows:
|
||||||
|
payload = _parse_alert_payload_json(row.payload_json)
|
||||||
|
alert_rows.append(
|
||||||
|
(row.id, row.alert_type, row.severity, row.message, row.created_at, payload)
|
||||||
|
)
|
||||||
|
items: list[dict[str, Any]] = []
|
||||||
|
for alert_id, alert_type, sev, message, created_at, payload in alert_rows:
|
||||||
|
loading_id = str(payload.get("loading_report_id") or "").strip()
|
||||||
|
if window and loading_id and loading_id not in loading_by_id:
|
||||||
|
continue
|
||||||
|
item = _serialize_feed_alert(alert_id, alert_type, sev, message, created_at, payload)
|
||||||
|
if severity and (item.get("severity") or "").lower() != severity.strip().lower():
|
||||||
|
continue
|
||||||
|
items.append(item)
|
||||||
|
items.sort(key=lambda item: str(item.get("createdAt") or ""), reverse=True)
|
||||||
|
return items[: max(1, min(limit, 500))]
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/feed-quality/alerts/summary")
|
||||||
|
def feed_quality_alerts_summary_wesp(
|
||||||
|
enterprise_id: str = Query(...),
|
||||||
|
date_from: str | None = Query(None),
|
||||||
|
date_to: str | None = Query(None),
|
||||||
|
tenant: TenantContext = Depends(require_enterprise_zootech),
|
||||||
|
):
|
||||||
|
_require_ent(enterprise_id, tenant)
|
||||||
|
window = _parse_date_window(date_from, date_to)
|
||||||
|
items = _feed_alerts_for_range(enterprise_id, window, limit=500)
|
||||||
|
by_severity = {"warning": 0, "error": 0, "info": 0}
|
||||||
|
for item in items:
|
||||||
|
sev = (item.get("severity") or "warning").lower()
|
||||||
|
if sev in by_severity:
|
||||||
|
by_severity[sev] += 1
|
||||||
|
return {
|
||||||
|
"total": len(items),
|
||||||
|
"warning": by_severity["warning"],
|
||||||
|
"error": by_severity["error"],
|
||||||
|
"info": by_severity["info"],
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/feed-quality/alerts")
|
||||||
|
def feed_quality_alerts_wesp(
|
||||||
|
enterprise_id: str = Query(...),
|
||||||
|
date_from: str | None = Query(None),
|
||||||
|
date_to: str | None = Query(None),
|
||||||
|
severity: str | None = Query(None),
|
||||||
|
limit: int = Query(200, ge=1, le=500),
|
||||||
|
tenant: TenantContext = Depends(require_enterprise_zootech),
|
||||||
|
):
|
||||||
|
_require_ent(enterprise_id, tenant)
|
||||||
|
window = _parse_date_window(date_from, date_to)
|
||||||
|
items = _feed_alerts_for_range(enterprise_id, window, severity=severity, limit=limit)
|
||||||
|
by_severity = {"warning": 0, "error": 0, "info": 0}
|
||||||
|
for item in items:
|
||||||
|
sev = (item.get("severity") or "warning").lower()
|
||||||
|
if sev in by_severity:
|
||||||
|
by_severity[sev] += 1
|
||||||
|
return {"items": items, "total": len(items), "bySeverity": by_severity}
|
||||||
@@ -0,0 +1,89 @@
|
|||||||
|
"""WESP-shaped auth endpoints for copied static UI (/api/auth/*)."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from fastapi import APIRouter, Depends, HTTPException, Request, Response, status
|
||||||
|
from pydantic import BaseModel, Field
|
||||||
|
|
||||||
|
from app.core.dependencies import get_current_user
|
||||||
|
from app.modules.auth.router import _set_refresh_cookie
|
||||||
|
from app.modules.auth.service import login as auth_login
|
||||||
|
from app.modules.users.models import User
|
||||||
|
|
||||||
|
router = APIRouter(prefix="/auth", tags=["zootech-wesp-auth"])
|
||||||
|
|
||||||
|
_USER_RULES = {
|
||||||
|
"password_min_len": 8,
|
||||||
|
"login_min_len": 3,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
class WespLoginIn(BaseModel):
|
||||||
|
login: str = Field(min_length=1)
|
||||||
|
password: str = Field(min_length=1)
|
||||||
|
remember: bool = False
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/login")
|
||||||
|
def wesp_auth_login(payload: WespLoginIn, response: Response):
|
||||||
|
email = payload.login.strip()
|
||||||
|
if "@" not in email:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_401_UNAUTHORIZED,
|
||||||
|
detail={"status": "error", "message": "Используйте email (admin@compton.example)"},
|
||||||
|
)
|
||||||
|
try:
|
||||||
|
access_token, refresh_token, user = auth_login(email, payload.password)
|
||||||
|
except ValueError:
|
||||||
|
return {"status": "error", "message": "Неверный логин или пароль"}
|
||||||
|
except PermissionError as exc:
|
||||||
|
detail = str(exc)
|
||||||
|
if detail == "EMAIL_NOT_VERIFIED":
|
||||||
|
return {"status": "error", "message": "Email не подтверждён"}
|
||||||
|
if detail == "ACCOUNT_BLOCKED":
|
||||||
|
return {"status": "error", "message": "Учётная запись заблокирована"}
|
||||||
|
return {"status": "error", "message": "Вход временно недоступен"}
|
||||||
|
|
||||||
|
_set_refresh_cookie(response, refresh_token)
|
||||||
|
return {
|
||||||
|
"status": "success",
|
||||||
|
"message": "Авторизация успешна",
|
||||||
|
"authenticated": True,
|
||||||
|
"user_login": user.email,
|
||||||
|
"access_token": access_token,
|
||||||
|
"user": {
|
||||||
|
"id": user.id,
|
||||||
|
"email": user.email,
|
||||||
|
"role": user.role,
|
||||||
|
"is_superuser": user.is_superuser,
|
||||||
|
"status": user.status,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/check")
|
||||||
|
def wesp_auth_check(user: User = Depends(get_current_user)):
|
||||||
|
return {
|
||||||
|
"status": "success",
|
||||||
|
"authenticated": True,
|
||||||
|
"user_login": user.email,
|
||||||
|
"remember_login": False,
|
||||||
|
"is_superuser": bool(user.is_superuser),
|
||||||
|
"can_lab": bool(user.is_superuser),
|
||||||
|
"user_rules": _USER_RULES,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/get_current_credentials")
|
||||||
|
def wesp_get_current_credentials(user: User = Depends(get_current_user)):
|
||||||
|
return {
|
||||||
|
"status": "success",
|
||||||
|
"login": user.email,
|
||||||
|
"password": "",
|
||||||
|
"user_rules": _USER_RULES,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/logout")
|
||||||
|
def wesp_logout():
|
||||||
|
return {"status": "success", "message": "Выход выполнен успешно"}
|
||||||
@@ -0,0 +1,101 @@
|
|||||||
|
"""WESP-shaped misc endpoints: notifications, updates (/api/notifications, /api/updates/*)."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from datetime import date
|
||||||
|
|
||||||
|
from fastapi import APIRouter, Depends
|
||||||
|
from pydantic import BaseModel
|
||||||
|
|
||||||
|
from app.core.dependencies import get_current_user
|
||||||
|
from app.modules.users.models import User
|
||||||
|
|
||||||
|
router = APIRouter(tags=["zootech-wesp-misc"])
|
||||||
|
|
||||||
|
|
||||||
|
def _empty_notifications(*, summary: bool = False) -> dict:
|
||||||
|
today = date.today().isoformat()
|
||||||
|
if summary:
|
||||||
|
return {"items": [], "unreadCount": 0}
|
||||||
|
return {
|
||||||
|
"items": [],
|
||||||
|
"unreadCount": 0,
|
||||||
|
"date": today,
|
||||||
|
"prevDate": None,
|
||||||
|
"hasMore": False,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/notifications")
|
||||||
|
def wesp_notifications_list(
|
||||||
|
summary: str | None = None,
|
||||||
|
_user: User = Depends(get_current_user),
|
||||||
|
):
|
||||||
|
if summary in ("1", "true", "yes"):
|
||||||
|
return _empty_notifications(summary=True)
|
||||||
|
return _empty_notifications()
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/notifications")
|
||||||
|
def wesp_notifications_create(_user: User = Depends(get_current_user)):
|
||||||
|
return {
|
||||||
|
"id": "orchestrator-stub",
|
||||||
|
"title": "",
|
||||||
|
"detail": "",
|
||||||
|
"kind": "info",
|
||||||
|
"category": "general",
|
||||||
|
"read": False,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
@router.patch("/notifications/read-all")
|
||||||
|
def wesp_notifications_read_all(_user: User = Depends(get_current_user)):
|
||||||
|
return {"success": True, "marked": 0}
|
||||||
|
|
||||||
|
|
||||||
|
@router.patch("/notifications/{notification_id}/read")
|
||||||
|
def wesp_notifications_mark_read(notification_id: str, _user: User = Depends(get_current_user)):
|
||||||
|
return {"id": notification_id, "read": True}
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/updates/status")
|
||||||
|
def wesp_updates_status(_user: User = Depends(get_current_user)):
|
||||||
|
return {
|
||||||
|
"initialized": False,
|
||||||
|
"check_enabled": False,
|
||||||
|
"current_version": "1.0.0-orchestrator",
|
||||||
|
"update_available": False,
|
||||||
|
"pending_version": None,
|
||||||
|
"pending_name": None,
|
||||||
|
"pending_body": None,
|
||||||
|
"published_at": None,
|
||||||
|
"last_check_at": None,
|
||||||
|
"is_running": False,
|
||||||
|
"is_updating": False,
|
||||||
|
"update_progress": None,
|
||||||
|
"last_update_state": {"status": "idle", "note": "orchestrator deploy"},
|
||||||
|
"gitea_configured": False,
|
||||||
|
"restart_configured": False,
|
||||||
|
"gitea_url": None,
|
||||||
|
"gitea_repo": None,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
class UpdatesInstallIn(BaseModel):
|
||||||
|
version: str | None = None
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/updates/install")
|
||||||
|
def wesp_updates_install(_payload: UpdatesInstallIn, _user: User = Depends(get_current_user)):
|
||||||
|
return {
|
||||||
|
"status": "error",
|
||||||
|
"message": "Автообновление недоступно на orchestrator — используйте деплой Docker.",
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/updates/check")
|
||||||
|
def wesp_updates_check(_user: User = Depends(get_current_user)):
|
||||||
|
return {
|
||||||
|
"update_available": False,
|
||||||
|
"message": "Проверка обновлений недоступна на orchestrator.",
|
||||||
|
}
|
||||||
@@ -0,0 +1,183 @@
|
|||||||
|
"""WESP-shaped reports API for copied static UI (/api/reports)."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import json
|
||||||
|
from datetime import UTC, datetime, timedelta
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
from fastapi import APIRouter, Depends, HTTPException, Query, status
|
||||||
|
|
||||||
|
from app.core.database import session_scope
|
||||||
|
from app.modules.sync.tenant import TenantContext, require_enterprise_zootech
|
||||||
|
from app.modules.zootech.report_models import ZootechLoadingReport, ZootechUnloadingReport
|
||||||
|
from sqlalchemy import select
|
||||||
|
|
||||||
|
router = APIRouter()
|
||||||
|
|
||||||
|
|
||||||
|
def _require_ent(enterprise_id: str, tenant: TenantContext) -> None:
|
||||||
|
if tenant.enterprise_id != enterprise_id:
|
||||||
|
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="ENTERPRISE_FORBIDDEN")
|
||||||
|
|
||||||
|
|
||||||
|
def _parse_payload(row: ZootechLoadingReport | ZootechUnloadingReport) -> dict[str, Any]:
|
||||||
|
if not row.payload_json:
|
||||||
|
return {}
|
||||||
|
try:
|
||||||
|
data = json.loads(row.payload_json)
|
||||||
|
except json.JSONDecodeError:
|
||||||
|
return {}
|
||||||
|
return data if isinstance(data, dict) else {}
|
||||||
|
|
||||||
|
|
||||||
|
def _parse_date_range(
|
||||||
|
date_from: str | None, date_to: str | None
|
||||||
|
) -> tuple[datetime, datetime] | None:
|
||||||
|
if not date_from or not date_to:
|
||||||
|
end = datetime.now(UTC)
|
||||||
|
return end - timedelta(hours=24), end + timedelta(seconds=1)
|
||||||
|
try:
|
||||||
|
start = datetime.strptime(date_from.strip(), "%Y-%m-%d").replace(tzinfo=UTC)
|
||||||
|
end = datetime.strptime(date_to.strip(), "%Y-%m-%d").replace(tzinfo=UTC) + timedelta(days=1)
|
||||||
|
return start, end
|
||||||
|
except ValueError as exc:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_400_BAD_REQUEST,
|
||||||
|
detail={"error": True, "message": "Некорректный формат date_from/date_to (YYYY-MM-DD)"},
|
||||||
|
) from exc
|
||||||
|
|
||||||
|
|
||||||
|
def _parse_report_time(value: Any) -> datetime | None:
|
||||||
|
if value is None:
|
||||||
|
return None
|
||||||
|
if isinstance(value, datetime):
|
||||||
|
return value if value.tzinfo else value.replace(tzinfo=UTC)
|
||||||
|
text = str(value).strip()
|
||||||
|
if not text:
|
||||||
|
return None
|
||||||
|
try:
|
||||||
|
parsed = datetime.fromisoformat(text.replace("Z", "+00:00"))
|
||||||
|
except ValueError:
|
||||||
|
return None
|
||||||
|
return parsed if parsed.tzinfo else parsed.replace(tzinfo=UTC)
|
||||||
|
|
||||||
|
|
||||||
|
def _in_range(start_time: datetime | None, window: tuple[datetime, datetime]) -> bool:
|
||||||
|
if start_time is None:
|
||||||
|
return False
|
||||||
|
start, end = window
|
||||||
|
return start <= start_time < end
|
||||||
|
|
||||||
|
|
||||||
|
def _serialize_unloading_id(unloading_id: str, payload: dict[str, Any]) -> dict[str, Any]:
|
||||||
|
groups = payload.get("unloading_groups")
|
||||||
|
if not isinstance(groups, list):
|
||||||
|
groups = payload.get("groups") if isinstance(payload.get("groups"), list) else []
|
||||||
|
return {
|
||||||
|
"id": unloading_id,
|
||||||
|
"start_time": payload.get("start_time"),
|
||||||
|
"end_time": payload.get("end_time"),
|
||||||
|
"total_weight": payload.get("total_weight"),
|
||||||
|
"total_unloaded_weight": payload.get("total_unloaded_weight"),
|
||||||
|
"remaining_weight": payload.get("remaining_weight"),
|
||||||
|
"unloading_groups": groups,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _serialize_unloading(row: ZootechUnloadingReport, payload: dict[str, Any]) -> dict[str, Any]:
|
||||||
|
return _serialize_unloading_id(row.id, payload)
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/reports")
|
||||||
|
def list_reports_wesp(
|
||||||
|
enterprise_id: str = Query(...),
|
||||||
|
date_from: str | None = Query(None),
|
||||||
|
date_to: str | None = Query(None),
|
||||||
|
limit: int = Query(500, ge=1, le=2000),
|
||||||
|
offset: int = Query(0, ge=0),
|
||||||
|
tenant: TenantContext = Depends(require_enterprise_zootech),
|
||||||
|
):
|
||||||
|
"""Legacy-compatible aggregated loading reports for reports.html."""
|
||||||
|
_require_ent(enterprise_id, tenant)
|
||||||
|
window = _parse_date_range(date_from, date_to)
|
||||||
|
|
||||||
|
with session_scope() as db:
|
||||||
|
loading_rows = list(
|
||||||
|
db.scalars(
|
||||||
|
select(ZootechLoadingReport).where(
|
||||||
|
ZootechLoadingReport.enterprise_id == enterprise_id,
|
||||||
|
ZootechLoadingReport.is_deleted.is_(False),
|
||||||
|
)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
unloading_rows = list(
|
||||||
|
db.scalars(
|
||||||
|
select(ZootechUnloadingReport).where(
|
||||||
|
ZootechUnloadingReport.enterprise_id == enterprise_id,
|
||||||
|
ZootechUnloadingReport.is_deleted.is_(False),
|
||||||
|
)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
loading_data = [(row.id, _parse_payload(row)) for row in loading_rows]
|
||||||
|
unloading_data: list[tuple[str, str, dict[str, Any]]] = []
|
||||||
|
for row in unloading_rows:
|
||||||
|
payload = _parse_payload(row)
|
||||||
|
loading_id = str(payload.get("loading_report_id") or "").strip()
|
||||||
|
if loading_id:
|
||||||
|
unloading_data.append((loading_id, row.id, payload))
|
||||||
|
|
||||||
|
unloading_by_loading: dict[str, tuple[str, dict[str, Any]]] = {
|
||||||
|
loading_id: (unloading_id, payload) for loading_id, unloading_id, payload in unloading_data
|
||||||
|
}
|
||||||
|
|
||||||
|
payload: list[dict[str, Any]] = []
|
||||||
|
for row_id, data in loading_data:
|
||||||
|
start_time = _parse_report_time(data.get("start_time"))
|
||||||
|
if window and not _in_range(start_time, window):
|
||||||
|
continue
|
||||||
|
unloading = unloading_by_loading.get(row_id)
|
||||||
|
unloading_payload = (
|
||||||
|
_serialize_unloading_id(unloading[0], unloading[1]) if unloading else None
|
||||||
|
)
|
||||||
|
payload.append(
|
||||||
|
{
|
||||||
|
"id": row_id,
|
||||||
|
"recipe_id": data.get("recipe_id"),
|
||||||
|
"recipe_name": data.get("recipe_name"),
|
||||||
|
"start_time": data.get("start_time"),
|
||||||
|
"end_time": data.get("end_time"),
|
||||||
|
"target_mixing_time": data.get("target_mixing_time"),
|
||||||
|
"actual_mixing_time": data.get("actual_mixing_time"),
|
||||||
|
"total_weight": data.get("total_weight"),
|
||||||
|
"dispenser_type": data.get("dispenser_type") or "dispenser",
|
||||||
|
"components": data.get("components") if isinstance(data.get("components"), list) else [],
|
||||||
|
"component_loading_times": (
|
||||||
|
data.get("component_loading_times")
|
||||||
|
if isinstance(data.get("component_loading_times"), list)
|
||||||
|
else []
|
||||||
|
),
|
||||||
|
"unloading_data": unloading_payload,
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
payload.sort(key=lambda item: str(item.get("start_time") or ""), reverse=True)
|
||||||
|
result = payload[offset : offset + limit]
|
||||||
|
|
||||||
|
# #region agent log
|
||||||
|
try:
|
||||||
|
import pathlib
|
||||||
|
_log_path = pathlib.Path("/Users/vlad/Documents/wesp new (1)/.cursor/debug-785e22.log")
|
||||||
|
_log_path.parent.mkdir(parents=True, exist_ok=True)
|
||||||
|
with _log_path.open("a", encoding="utf-8") as _lf:
|
||||||
|
_lf.write(json.dumps({"sessionId":"785e22","hypothesisId":"A,B","location":"wesp_compat_reports.py:list","message":"reports api response","data":{"date_from":date_from,"date_to":date_to,"total_before_filter":len(payload),"returned":len(result),"component_counts":[len(r.get("components") or []) for r in result[:5]]},"timestamp":int(datetime.now(UTC).timestamp()*1000)}, ensure_ascii=False) + "\n")
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
# #endregion
|
||||||
|
|
||||||
|
return result
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/reports/ping")
|
||||||
|
def reports_ping_wesp():
|
||||||
|
return {"status": "ok"}
|
||||||
@@ -0,0 +1,30 @@
|
|||||||
|
"""WESP /api/sync/clients compat — maps to orchestrator farm hubs."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from fastapi import APIRouter, Depends, HTTPException, Query, status
|
||||||
|
|
||||||
|
from app.modules.sync import repository as sync_repo
|
||||||
|
from app.modules.sync.tenant import TenantContext, require_enterprise_zootech
|
||||||
|
|
||||||
|
router = APIRouter()
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/sync/clients")
|
||||||
|
def list_sync_clients(
|
||||||
|
enterprise_id: str = Query(...),
|
||||||
|
tenant: TenantContext = Depends(require_enterprise_zootech),
|
||||||
|
):
|
||||||
|
if tenant.enterprise_id != enterprise_id:
|
||||||
|
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="ENTERPRISE_FORBIDDEN")
|
||||||
|
hubs = sync_repo.list_farm_hubs(enterprise_id)
|
||||||
|
return [
|
||||||
|
{
|
||||||
|
"id": h.id,
|
||||||
|
"node_id": h.hub_site_id,
|
||||||
|
"client_name": h.name,
|
||||||
|
"status": h.status,
|
||||||
|
"last_seen": h.last_seen.isoformat() if h.last_seen else None,
|
||||||
|
}
|
||||||
|
for h in hubs
|
||||||
|
]
|
||||||
@@ -0,0 +1,352 @@
|
|||||||
|
"""WESP-shaped recipe write/calculate for orchestrator static UI."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from datetime import UTC, datetime
|
||||||
|
from typing import Any
|
||||||
|
from uuid import uuid4
|
||||||
|
|
||||||
|
from fastapi import HTTPException, status
|
||||||
|
from sqlalchemy import select
|
||||||
|
|
||||||
|
from app.core.database import session_scope
|
||||||
|
from app.modules.sync.engine import SyncEngine
|
||||||
|
from app.modules.sync.schemas import ChangeEventIn
|
||||||
|
from app.modules.zootech.catalog_apply import load_catalog_row
|
||||||
|
from app.modules.zootech.catalog_models import ZootechUnloadingGroup
|
||||||
|
from app.modules.zootech.models import ZootechComponent, ZootechIngredient, ZootechRecipe
|
||||||
|
from app.modules.zootech.recipe_calculator import calculate_recipe
|
||||||
|
from app.modules.zootech.sync_content_hash import compute_content_hash
|
||||||
|
|
||||||
|
|
||||||
|
class RecipeWriteError(Exception):
|
||||||
|
def __init__(self, message: str, status_code: int = 400):
|
||||||
|
self.message = message
|
||||||
|
self.status_code = status_code
|
||||||
|
super().__init__(message)
|
||||||
|
|
||||||
|
|
||||||
|
def _normalize_groups(raw: list[Any]) -> list[dict[str, Any]]:
|
||||||
|
out: list[dict[str, Any]] = []
|
||||||
|
for group in raw:
|
||||||
|
if not isinstance(group, dict):
|
||||||
|
continue
|
||||||
|
g = dict(group)
|
||||||
|
if "distributionType" not in g and "distribution_type" in g:
|
||||||
|
g["distributionType"] = g.get("distribution_type")
|
||||||
|
out.append(g)
|
||||||
|
return out
|
||||||
|
|
||||||
|
|
||||||
|
def calculate_recipe_wesp(body: dict[str, Any]) -> dict[str, Any]:
|
||||||
|
if not body:
|
||||||
|
raise RecipeWriteError("Данные не предоставлены", 400)
|
||||||
|
try:
|
||||||
|
heads_count = int(body.get("headsCount") or body.get("heads_count") or body.get("headsPerTrip") or 0)
|
||||||
|
trip_percent = float(body.get("tripPercent") or body.get("trip_percent") or 100)
|
||||||
|
except (TypeError, ValueError) as exc:
|
||||||
|
raise RecipeWriteError("Некорректные числовые параметры", 400) from exc
|
||||||
|
|
||||||
|
ingredients = body.get("ingredients") or []
|
||||||
|
unloading_groups = _normalize_groups(body.get("unloadingGroups") or body.get("unloading_groups") or [])
|
||||||
|
calculate_from_dry_matter = bool(
|
||||||
|
body.get("calculateFromDryMatter")
|
||||||
|
if body.get("calculateFromDryMatter") is not None
|
||||||
|
else body.get("calculate_from_dry_matter", False)
|
||||||
|
)
|
||||||
|
|
||||||
|
component_ids = [i.get("component_id") for i in ingredients if isinstance(i, dict) and i.get("component_id")]
|
||||||
|
component_dry_matter_map: dict[str, float] = {}
|
||||||
|
if component_ids:
|
||||||
|
with session_scope() as db:
|
||||||
|
for comp in db.scalars(select(ZootechComponent).where(ZootechComponent.id.in_(component_ids))):
|
||||||
|
component_dry_matter_map[comp.id] = float(comp.dry_matter or 0)
|
||||||
|
|
||||||
|
result = calculate_recipe(
|
||||||
|
ingredients=[i for i in ingredients if isinstance(i, dict)],
|
||||||
|
heads_count=heads_count,
|
||||||
|
trip_percent=trip_percent,
|
||||||
|
unloading_groups=unloading_groups,
|
||||||
|
component_dry_matter_map=component_dry_matter_map or None,
|
||||||
|
calculate_from_dry_matter=calculate_from_dry_matter,
|
||||||
|
)
|
||||||
|
|
||||||
|
def _to_float2(x: Any) -> float:
|
||||||
|
try:
|
||||||
|
return round(float(x), 2)
|
||||||
|
except (TypeError, ValueError):
|
||||||
|
return 0.0
|
||||||
|
|
||||||
|
def _truncate2(x: Any) -> float:
|
||||||
|
try:
|
||||||
|
v = float(x)
|
||||||
|
return float(int(v * 100)) / 100.0
|
||||||
|
except (TypeError, ValueError):
|
||||||
|
return 0.0
|
||||||
|
|
||||||
|
for ing in result.get("ingredients") or []:
|
||||||
|
for key in ("weightPerHead", "tripWeight", "totalWeight", "dryMatterPerHead"):
|
||||||
|
if key in ing and ing[key] is not None:
|
||||||
|
raw_val = ing[key]
|
||||||
|
v = _truncate2(raw_val) if key == "weightPerHead" else _to_float2(raw_val)
|
||||||
|
ing[key] = f"{v:.2f}" if key == "weightPerHead" else v
|
||||||
|
|
||||||
|
totals = result.get("totals") or {}
|
||||||
|
for key in ("totalWeight", "totalTripWeight", "totalDryMatterPerHead", "totalWeightPerHead"):
|
||||||
|
if key in totals and totals[key] is not None:
|
||||||
|
totals[key] = _to_float2(totals[key])
|
||||||
|
result["totals"] = totals
|
||||||
|
return result
|
||||||
|
|
||||||
|
|
||||||
|
def _push_change(
|
||||||
|
enterprise_id: str,
|
||||||
|
table: str,
|
||||||
|
record_id: str,
|
||||||
|
payload: dict[str, Any],
|
||||||
|
*,
|
||||||
|
action: str = "upsert",
|
||||||
|
) -> None:
|
||||||
|
version = int(payload.get("version") or 1)
|
||||||
|
content_hash = compute_content_hash(payload)
|
||||||
|
payload = {**payload, "version": version, "content_hash": content_hash}
|
||||||
|
event = ChangeEventIn(
|
||||||
|
event_id=str(uuid4()),
|
||||||
|
seq=0,
|
||||||
|
domain="global",
|
||||||
|
table=table,
|
||||||
|
record_id=record_id,
|
||||||
|
action=action,
|
||||||
|
version=version,
|
||||||
|
content_hash=content_hash,
|
||||||
|
payload=payload,
|
||||||
|
emitted_at=datetime.now(UTC),
|
||||||
|
origin_site_id=SyncEngine.ORCHESTRATOR_SITE_ID,
|
||||||
|
)
|
||||||
|
SyncEngine(enterprise_id, SyncEngine.ORCHESTRATOR_SITE_ID).process_push([event], origin="orchestrator")
|
||||||
|
|
||||||
|
|
||||||
|
def _resolve_component(enterprise_id: str, ing: dict[str, Any]) -> ZootechComponent | None:
|
||||||
|
comp_id = ing.get("component_id")
|
||||||
|
with session_scope() as db:
|
||||||
|
if comp_id:
|
||||||
|
row = db.scalar(
|
||||||
|
select(ZootechComponent).where(
|
||||||
|
ZootechComponent.enterprise_id == enterprise_id,
|
||||||
|
ZootechComponent.id == str(comp_id),
|
||||||
|
ZootechComponent.is_deleted.is_(False),
|
||||||
|
)
|
||||||
|
)
|
||||||
|
if row:
|
||||||
|
return row
|
||||||
|
name = (ing.get("name") or "").strip()
|
||||||
|
if name:
|
||||||
|
return db.scalar(
|
||||||
|
select(ZootechComponent).where(
|
||||||
|
ZootechComponent.enterprise_id == enterprise_id,
|
||||||
|
ZootechComponent.name == name,
|
||||||
|
ZootechComponent.is_deleted.is_(False),
|
||||||
|
)
|
||||||
|
)
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def update_recipe_wesp(enterprise_id: str, recipe_id: str, data: dict[str, Any]) -> dict[str, Any]:
|
||||||
|
if not data:
|
||||||
|
raise RecipeWriteError("Данные не предоставлены", 400)
|
||||||
|
for key in ("name", "heads_count", "mixing_time"):
|
||||||
|
if key not in data:
|
||||||
|
raise RecipeWriteError(f'Отсутствует обязательное поле "{key}"', 400)
|
||||||
|
|
||||||
|
with session_scope() as db:
|
||||||
|
recipe = db.scalar(
|
||||||
|
select(ZootechRecipe).where(
|
||||||
|
ZootechRecipe.enterprise_id == enterprise_id,
|
||||||
|
ZootechRecipe.id == recipe_id,
|
||||||
|
ZootechRecipe.is_deleted.is_(False),
|
||||||
|
)
|
||||||
|
)
|
||||||
|
if not recipe:
|
||||||
|
raise RecipeWriteError("Рецепт не найден", 404)
|
||||||
|
|
||||||
|
existing_ings = list(
|
||||||
|
db.scalars(
|
||||||
|
select(ZootechIngredient).where(
|
||||||
|
ZootechIngredient.enterprise_id == enterprise_id,
|
||||||
|
ZootechIngredient.recipe_id == recipe_id,
|
||||||
|
ZootechIngredient.is_deleted.is_(False),
|
||||||
|
)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
existing_groups = list(
|
||||||
|
db.scalars(
|
||||||
|
select(ZootechUnloadingGroup).where(
|
||||||
|
ZootechUnloadingGroup.enterprise_id == enterprise_id,
|
||||||
|
ZootechUnloadingGroup.recipe_id == recipe_id,
|
||||||
|
ZootechUnloadingGroup.is_deleted.is_(False),
|
||||||
|
)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
recipe_trip_percent = float(recipe.trip_percent or 100)
|
||||||
|
recipe_dry_matter_locked = bool(recipe.dry_matter_locked)
|
||||||
|
recipe_unloading_link_broken = bool(recipe.unloading_link_broken)
|
||||||
|
recipe_target_component_id = recipe.target_component_id
|
||||||
|
recipe_version = int(recipe.version or 1) + 1
|
||||||
|
ing_by_id = {
|
||||||
|
str(i.id): {"id": str(i.id), "name": i.name, "version": int(i.version or 1)}
|
||||||
|
for i in existing_ings
|
||||||
|
}
|
||||||
|
grp_by_id = {
|
||||||
|
str(g.id): {"id": str(g.id), "name": g.name, "version": int(g.version or 1)}
|
||||||
|
for g in existing_groups
|
||||||
|
}
|
||||||
|
|
||||||
|
recipe_payload = {
|
||||||
|
"id": recipe_id,
|
||||||
|
"name": str(data["name"]),
|
||||||
|
"heads_per_trip": int(data["heads_count"]),
|
||||||
|
"mixing_time": int(data["mixing_time"]),
|
||||||
|
"trip_percent": float(data.get("trip_percent") or recipe_trip_percent),
|
||||||
|
"dry_matter_locked": bool(data.get("dry_matter_locked", recipe_dry_matter_locked)),
|
||||||
|
"unloading_link_broken": bool(data.get("unloading_link_broken", recipe_unloading_link_broken)),
|
||||||
|
"target_component_id": data.get("target_component_id", recipe_target_component_id),
|
||||||
|
"version": recipe_version,
|
||||||
|
}
|
||||||
|
_push_change(enterprise_id, "recipe", recipe_id, recipe_payload)
|
||||||
|
|
||||||
|
seen_ing_ids: set[str] = set()
|
||||||
|
for idx, ing in enumerate([x for x in (data.get("ingredients") or []) if isinstance(x, dict)], start=1):
|
||||||
|
component = _resolve_component(enterprise_id, ing)
|
||||||
|
if not component:
|
||||||
|
raise RecipeWriteError(
|
||||||
|
f'Компонент не найден (id="{ing.get("component_id", "")}", name="{ing.get("name", "")}")',
|
||||||
|
400,
|
||||||
|
)
|
||||||
|
ing_id = str(ing.get("id") or "").strip() or str(uuid4())
|
||||||
|
existing = ing_by_id.get(ing_id)
|
||||||
|
order_value = int(ing.get("order") or idx)
|
||||||
|
wph = float(ing.get("weight_per_head") or ing.get("weightPerHead") or 0)
|
||||||
|
amount = float(ing.get("amount") or 0)
|
||||||
|
dm = float(ing.get("dry_matter") or component.dry_matter or 0)
|
||||||
|
dm_ph = ing.get("dry_matter_per_head")
|
||||||
|
if dm_ph is None:
|
||||||
|
dm_ph = ing.get("dryMatterPerHead")
|
||||||
|
dm_ph_f = float(dm_ph) if dm_ph not in (None, "") else (wph * (dm / 100.0) if wph and dm else 0.0)
|
||||||
|
version = int(existing.get("version") or 1) + 1 if existing else 1
|
||||||
|
payload = {
|
||||||
|
"id": ing_id,
|
||||||
|
"recipe_id": recipe_id,
|
||||||
|
"component_id": component.id,
|
||||||
|
"name": component.name,
|
||||||
|
"amount": amount,
|
||||||
|
"weight_per_head": wph,
|
||||||
|
"dry_matter": dm,
|
||||||
|
"dry_matter_per_head": dm_ph_f,
|
||||||
|
"order": order_value,
|
||||||
|
"version": version,
|
||||||
|
}
|
||||||
|
_push_change(enterprise_id, "ingredient", ing_id, payload)
|
||||||
|
seen_ing_ids.add(ing_id)
|
||||||
|
|
||||||
|
for raw_id in data.get("deleted_ingredient_ids") or []:
|
||||||
|
ing_id = str(raw_id or "").strip()
|
||||||
|
if not ing_id or ing_id not in ing_by_id:
|
||||||
|
continue
|
||||||
|
existing = ing_by_id[ing_id]
|
||||||
|
version = int(existing["version"] or 1) + 1
|
||||||
|
payload = {
|
||||||
|
"id": ing_id,
|
||||||
|
"recipe_id": recipe_id,
|
||||||
|
"name": existing["name"],
|
||||||
|
"version": version,
|
||||||
|
}
|
||||||
|
_push_change(enterprise_id, "ingredient", ing_id, payload, action="delete")
|
||||||
|
|
||||||
|
for ing_id, existing in ing_by_id.items():
|
||||||
|
if ing_id not in seen_ing_ids and ing_id not in {str(x) for x in (data.get("deleted_ingredient_ids") or [])}:
|
||||||
|
version = int(existing["version"] or 1) + 1
|
||||||
|
_push_change(
|
||||||
|
enterprise_id,
|
||||||
|
"ingredient",
|
||||||
|
ing_id,
|
||||||
|
{"id": ing_id, "recipe_id": recipe_id, "name": existing["name"], "version": version},
|
||||||
|
action="delete",
|
||||||
|
)
|
||||||
|
|
||||||
|
seen_grp_ids: set[str] = set()
|
||||||
|
groups_in = [x for x in (data.get("unloading_groups") or data.get("unloadingGroups") or []) if isinstance(x, dict)]
|
||||||
|
for idx, group in enumerate(groups_in, start=1):
|
||||||
|
try:
|
||||||
|
gname = str(group["name"])
|
||||||
|
gdist = str(group.get("distribution_type") or group.get("distributionType") or "percent")
|
||||||
|
gval = float(group.get("value") or 0)
|
||||||
|
except (KeyError, TypeError, ValueError) as exc:
|
||||||
|
raise RecipeWriteError(f"Некорректная группа выгрузки (order={idx}): {exc}", 400) from exc
|
||||||
|
grp_id = str(group.get("id") or "").strip() or str(uuid4())
|
||||||
|
existing = grp_by_id.get(grp_id)
|
||||||
|
order_value = int(group.get("order") or idx)
|
||||||
|
weight_raw = group.get("weight")
|
||||||
|
weight = float(weight_raw) if weight_raw not in (None, "") else None
|
||||||
|
version = int(existing.get("version") or 1) + 1 if existing else 1
|
||||||
|
payload = {
|
||||||
|
"id": grp_id,
|
||||||
|
"recipe_id": recipe_id,
|
||||||
|
"name": gname,
|
||||||
|
"distribution_type": gdist,
|
||||||
|
"value": gval,
|
||||||
|
"weight": weight,
|
||||||
|
"order": order_value,
|
||||||
|
"version": version,
|
||||||
|
}
|
||||||
|
_push_change(enterprise_id, "unloading_group", grp_id, payload)
|
||||||
|
seen_grp_ids.add(grp_id)
|
||||||
|
|
||||||
|
for raw_id in data.get("deleted_unloading_group_ids") or []:
|
||||||
|
grp_id = str(raw_id or "").strip()
|
||||||
|
if not grp_id or grp_id not in grp_by_id:
|
||||||
|
continue
|
||||||
|
existing = grp_by_id[grp_id]
|
||||||
|
version = int(existing["version"] or 1) + 1
|
||||||
|
_push_change(
|
||||||
|
enterprise_id,
|
||||||
|
"unloading_group",
|
||||||
|
grp_id,
|
||||||
|
{"id": grp_id, "recipe_id": recipe_id, "name": existing["name"], "version": version},
|
||||||
|
action="delete",
|
||||||
|
)
|
||||||
|
|
||||||
|
for grp_id, existing in grp_by_id.items():
|
||||||
|
if grp_id not in seen_grp_ids and grp_id not in {str(x) for x in (data.get("deleted_unloading_group_ids") or [])}:
|
||||||
|
version = int(existing["version"] or 1) + 1
|
||||||
|
_push_change(
|
||||||
|
enterprise_id,
|
||||||
|
"unloading_group",
|
||||||
|
grp_id,
|
||||||
|
{"id": grp_id, "recipe_id": recipe_id, "name": existing["name"], "version": version},
|
||||||
|
action="delete",
|
||||||
|
)
|
||||||
|
|
||||||
|
with session_scope() as db:
|
||||||
|
saved = db.scalar(
|
||||||
|
select(ZootechRecipe).where(
|
||||||
|
ZootechRecipe.enterprise_id == enterprise_id,
|
||||||
|
ZootechRecipe.id == recipe_id,
|
||||||
|
ZootechRecipe.is_deleted.is_(False),
|
||||||
|
)
|
||||||
|
)
|
||||||
|
if not saved:
|
||||||
|
raise RecipeWriteError("Рецепт не найден после сохранения", 404)
|
||||||
|
|
||||||
|
return {
|
||||||
|
"success": True,
|
||||||
|
"message": "Рецепт обновлен",
|
||||||
|
"id": recipe_id,
|
||||||
|
"stats": {
|
||||||
|
"ingredients": len(seen_ing_ids),
|
||||||
|
"unloading_groups": len(seen_grp_ids),
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def recipe_write_http_error(exc: RecipeWriteError) -> HTTPException:
|
||||||
|
return HTTPException(status_code=exc.status_code, detail={"message": exc.message, "error": True})
|
||||||
@@ -0,0 +1,29 @@
|
|||||||
|
"""Background worker: sync reconcile + report jobs."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import logging
|
||||||
|
import time
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
def run_sync_reconcile() -> int:
|
||||||
|
from app.modules.sync.reconcile import run_sync_reconcile as _reconcile
|
||||||
|
|
||||||
|
return _reconcile()
|
||||||
|
|
||||||
|
|
||||||
|
def main() -> None:
|
||||||
|
logging.basicConfig(level=logging.INFO)
|
||||||
|
logger.info("worker started")
|
||||||
|
while True:
|
||||||
|
try:
|
||||||
|
run_sync_reconcile()
|
||||||
|
except Exception:
|
||||||
|
logger.exception("reconcile failed")
|
||||||
|
time.sleep(300)
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
main()
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user