Initial commit: site monorepo with API, web, and infra.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
влад
2026-07-16 10:11:54 +03:00
co-authored by Cursor
commit 016910ffb7
447 changed files with 73972 additions and 0 deletions
+1
View File
@@ -0,0 +1 @@
"""Compton API application package."""
+21
View File
@@ -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()
+117
View File
@@ -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,
}
+49
View File
@@ -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))
+55
View File
@@ -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()
+133
View File
@@ -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()
+50
View File
@@ -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()
+15
View File
@@ -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)
+45
View File
@@ -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
+61
View File
@@ -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)
+2
View File
@@ -0,0 +1,2 @@
class DomainError(Exception):
"""Base domain error."""
+205
View File
@@ -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, "")
+108
View File
@@ -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")
+3
View File
@@ -0,0 +1,3 @@
from app.core.crypto import build_signed_media_url, verify_signed_media
__all__ = ["build_signed_media_url", "verify_signed_media"]
+46
View File
@@ -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()
+20
View File
@@ -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
+74
View File
@@ -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"
+21
View File
@@ -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",
]
+63
View File
@@ -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")
+61
View File
@@ -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]] = {}
+3
View File
@@ -0,0 +1,3 @@
from app.db.base import Base
__all__ = ["Base"]
+5
View File
@@ -0,0 +1,5 @@
from sqlalchemy.orm import DeclarativeBase
class Base(DeclarativeBase):
pass
+80
View File
@@ -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",
]
+99
View File
@@ -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,
)
+43
View File
@@ -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),
}
+121
View File
@@ -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()
+1
View File
@@ -0,0 +1 @@
"""Application modules."""
+1
View File
@@ -0,0 +1 @@
"""Admin module."""
+171
View File
@@ -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
]
+46
View File
@@ -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}
+209
View File
@@ -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(),
}
+1
View File
@@ -0,0 +1 @@
"""Auth module."""
+43
View File
@@ -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)
+132
View File
@@ -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)
+217
View File
@@ -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."}
+69
View File
@@ -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
+240
View File
@@ -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)
+1
View File
@@ -0,0 +1 @@
"""Content module."""
+27
View File
@@ -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),
)
+105
View File
@@ -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)
+56
View File
@@ -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"}
+24
View File
@@ -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
+70
View File
@@ -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
+155
View File
@@ -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"}
+24
View File
@@ -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)
+5
View File
@@ -0,0 +1,5 @@
from pydantic import BaseModel
class AvatarUploadOut(BaseModel):
message: str
+62
View File
@@ -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}
+5
View File
@@ -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"}
+1
View File
@@ -0,0 +1 @@
"""WESP sync orchestrator: enterprise tenants, hub pairing, ChangeEvent protocol."""
+381
View File
@@ -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"
+299
View File
@@ -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)
+41
View File
@@ -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
+394
View File
@@ -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
+178
View File
@@ -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
+165
View File
@@ -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
+178
View File
@@ -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),
}
+92
View File
@@ -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
+16
View File
@@ -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}
+1
View File
@@ -0,0 +1 @@
"""Users module."""
+52
View File
@@ -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")
+164
View File
@@ -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)
+59
View File
@@ -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)
+34
View File
@@ -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)
+31
View File
@@ -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)
+1
View File
@@ -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",
]
+94
View File
@@ -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),
)
+151
View File
@@ -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
+84
View File
@@ -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))
+435
View File
@@ -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})
+29
View File
@@ -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()