refactor(d3): ARCH-051 — 14 dict-body-Routes auf Pydantic-Schemas umgestellt (entity_permissions bulk ×2, guests invite, users menu-order, system_settings backup-config+dsar, knowledge ×3, self_improvement ×5); DSAR-Export F821-Bug behoben (datetime/timezone undefined → NameError beim GDPR-Export), Zeitstempel auf datetime.now(UTC); Validierung jetzt im Schema statt in Routen
Check Cross-Plugin Imports / check (push) Has been cancelled
Check Cross-Plugin Imports / check (push) Has been cancelled
This commit is contained in:
+2
-1
@@ -77,6 +77,7 @@
|
|||||||
| D3-d | Systemischer P1-Bug: DMS/Mail überschrieben get_entity_models() nicht → 'dms_file'/'dms_folder'/'file'/'mail_account' fehlten im ENTITY_MODELS-Mapping → ValueError bei allen Entity-Freigaben/Berechtigungen zur Laufzeit (28 Mail-Test-Failures + 2 test_permissions-Failures, Stash-verifiziert) | ✅ Overrides ergänzt (DMS: dms_file/dms_folder/file-Alias; Mail: mail_account); test_permissions 22/22 grün; Resolver-Auflösung aller 4 Typen direkt bewiesen | — |
|
| D3-d | Systemischer P1-Bug: DMS/Mail überschrieben get_entity_models() nicht → 'dms_file'/'dms_folder'/'file'/'mail_account' fehlten im ENTITY_MODELS-Mapping → ValueError bei allen Entity-Freigaben/Berechtigungen zur Laufzeit (28 Mail-Test-Failures + 2 test_permissions-Failures, Stash-verifiziert) | ✅ Overrides ergänzt (DMS: dms_file/dms_folder/file-Alias; Mail: mail_account); test_permissions 22/22 grün; Resolver-Auflösung aller 4 Typen direkt bewiesen | — |
|
||||||
| D3-e | conftest db_setup: pgvector-Extension fehlte nach DB-Recreate → alle create_all-Läufe scheiterten an 'type vector does not exist' | ✅ CREATE EXTENSION IF NOT EXISTS vector in db_setup-Fixture verankert (nach CREATE SCHEMA, vor alembic upgrade head) | — |
|
| D3-e | conftest db_setup: pgvector-Extension fehlte nach DB-Recreate → alle create_all-Läufe scheiterten an 'type vector does not exist' | ✅ CREATE EXTENSION IF NOT EXISTS vector in db_setup-Fixture verankert (nach CREATE SCHEMA, vor alembic upgrade head) | — |
|
||||||
| D3-f | BUG-027–029/031–035/071 (falsche Test-Pfade/Payloads): Recherche zeigte — falsche Pfade existieren NICHT mehr in tests/, reale API hat korrekte Prefixe (/api/v1/user/preferences, /api/v1/permissions, /api/v1/mail) | ✅ Als obsolet/bereits behoben dokumentiert | — |
|
| D3-f | BUG-027–029/031–035/071 (falsche Test-Pfade/Payloads): Recherche zeigte — falsche Pfade existieren NICHT mehr in tests/, reale API hat korrekte Prefixe (/api/v1/user/preferences, /api/v1/permissions, /api/v1/mail) | ✅ Als obsolet/bereits behoben dokumentiert | — |
|
||||||
|
| D3-g | ARCH-051: 14 dict-body-Routes auf Pydantic-Schemas umgestellt (entity_permissions bulk ×2, guests invite, users menu-order, system_settings backup-config+dsar, knowledge ×3, self_improvement ×5); dabei DSAR-Export F821-Bug behoben (datetime/timezone undefined → NameError zur Laufzeit beim GDPR-Export) und Zeitstempel auf datetime.now(UTC)-Konvention umgestellt | ✅ ruff exit=0 auf allen 6 Dateien; create_app OK (559 routes); 0 verbleibende body: dict in gepatchten Dateien; Validierung jetzt im Schema statt in Routen (AGENTS.md-Konvention) | — |
|
||||||
|
|
||||||
**Block A ABGESCHLOSSEN** — Gate A bestanden (32f63ad).
|
**Block A ABGESCHLOSSEN** — Gate A bestanden (32f63ad).
|
||||||
**Block B ABGESCHLOSSEN** — Gate B bestanden (alle 5 Checks bewiesen).
|
**Block B ABGESCHLOSSEN** — Gate B bestanden (alle 5 Checks bewiesen).
|
||||||
@@ -85,7 +86,7 @@
|
|||||||
|
|
||||||
### Bekannte Vorbestände (Block D Triage)
|
### Bekannte Vorbestände (Block D Triage)
|
||||||
- ~~9 Contact/Company-Test-Failures~~ ✅ GELÖST in D1-b (ContactCreate-Typ-Inferenz, 9d8da99) — Root-Cause war BUG-008-Validator-Default type='company'.
|
- ~~9 Contact/Company-Test-Failures~~ ✅ GELÖST in D1-b (ContactCreate-Typ-Inferenz, 9d8da99) — Root-Cause war BUG-008-Validator-Default type='company'.
|
||||||
- test_mail: 28 Failures 'ValueError: Unknown entity type: mail_account' aus permission_resolver._get_entity_model — Stash-verifiziert als Vorbestand (2026-08-24); Permission-Resolver-Mapping kennt mail_account nicht → D3/D4-Thema.
|
- ~~test_mail: 28 Failures 'Unknown entity type: mail_account'~~ ✅ Root-Cause in D3-d behoben (ef90d57): Mail/DMS get_entity_models-Overrides fehlten; Rest-Failures im vollen Mail-Lauf = IMAP-Netzwerk-Calls ohne Mocking (Test-Infrastruktur-Follow-up, kein Produktionsbug).
|
||||||
- 5 PluginLoader-Test-Failures sind Vorbestand (Stash-Verifikation): Tests erwarten UI-Text 'Failed to load plugin', aktueller Loader zeigt deutsche Texte.
|
- 5 PluginLoader-Test-Failures sind Vorbestand (Stash-Verifikation): Tests erwarten UI-Text 'Failed to load plugin', aktueller Loader zeigt deutsche Texte.
|
||||||
- Geister-Komponenten: Backend-Manifeste referenzieren @/pages/AIAssistant + 5 Contact-Detail-Tabs, die im Frontend nicht existieren (siehe unten).
|
- Geister-Komponenten: Backend-Manifeste referenzieren @/pages/AIAssistant + 5 Contact-Detail-Tabs, die im Frontend nicht existieren (siehe unten).
|
||||||
|
|
||||||
|
|||||||
@@ -1,51 +1,70 @@
|
|||||||
"""Knowledge plugin routes — extraction, ask, review queue."""
|
"""Knowledge plugin routes — extraction, ask, review queue."""
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
import uuid
|
import uuid
|
||||||
|
|
||||||
from fastapi import APIRouter, Depends, HTTPException, Query
|
from fastapi import APIRouter, Depends, HTTPException, Query
|
||||||
|
from pydantic import BaseModel, Field
|
||||||
from sqlalchemy.ext.asyncio import AsyncSession
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
|
||||||
from app.core.db import get_db
|
from app.core.db import get_db
|
||||||
from app.deps import require_permission
|
from app.deps import require_permission
|
||||||
from app.plugins.builtins.knowledge.services import extract_knowledge, ask_knowledge, get_review_queue, review_extraction
|
from app.plugins.builtins.knowledge.services import (
|
||||||
|
ask_knowledge,
|
||||||
|
extract_knowledge,
|
||||||
|
get_review_queue,
|
||||||
|
review_extraction,
|
||||||
|
)
|
||||||
|
|
||||||
router = APIRouter(prefix="/api/v1/knowledge", tags=["knowledge"])
|
router = APIRouter(prefix="/api/v1/knowledge", tags=["knowledge"])
|
||||||
|
|
||||||
|
|
||||||
|
class KnowledgeExtractRequest(BaseModel):
|
||||||
|
"""Extract knowledge from a source."""
|
||||||
|
|
||||||
|
source_type: str = Field(..., min_length=1)
|
||||||
|
source_id: uuid.UUID
|
||||||
|
source_title: str | None = None
|
||||||
|
source_text: str = Field(..., min_length=1)
|
||||||
|
|
||||||
|
|
||||||
|
class KnowledgeAskRequest(BaseModel):
|
||||||
|
"""Ask a knowledge question."""
|
||||||
|
|
||||||
|
question: str = Field(..., min_length=1)
|
||||||
|
|
||||||
|
|
||||||
|
class KnowledgeReviewRequest(BaseModel):
|
||||||
|
"""Approve or reject a knowledge extraction."""
|
||||||
|
|
||||||
|
approved: bool = False
|
||||||
|
notes: str | None = None
|
||||||
|
|
||||||
|
|
||||||
@router.post("/extract")
|
@router.post("/extract")
|
||||||
async def extract(
|
async def extract(
|
||||||
body: dict,
|
body: KnowledgeExtractRequest,
|
||||||
db: AsyncSession = Depends(get_db),
|
db: AsyncSession = Depends(get_db),
|
||||||
current_user: dict = Depends(require_permission("wiki:read")),
|
current_user: dict = Depends(require_permission("wiki:read")),
|
||||||
):
|
):
|
||||||
"""Extract knowledge from a source (wiki article, dms file, mail, communication)."""
|
"""Extract knowledge from a source (wiki article, dms file, mail, communication)."""
|
||||||
tenant_id = uuid.UUID(current_user["tenant_id"])
|
tenant_id = uuid.UUID(current_user["tenant_id"])
|
||||||
source_type = body.get("source_type", "")
|
|
||||||
source_id = body.get("source_id", "")
|
|
||||||
source_title = body.get("source_title")
|
|
||||||
source_text = body.get("source_text", "")
|
|
||||||
if not source_type or not source_id or not source_text:
|
|
||||||
raise HTTPException(400, detail={"detail": "source_type, source_id, source_text required", "code": "missing_fields"})
|
|
||||||
try:
|
|
||||||
sid = uuid.UUID(source_id)
|
|
||||||
except ValueError:
|
|
||||||
raise HTTPException(400, detail={"detail": "Invalid source_id", "code": "invalid_id"}) from None
|
|
||||||
result = await extract_knowledge(
|
result = await extract_knowledge(
|
||||||
db=db, tenant_id=tenant_id, source_type=source_type, source_id=sid,
|
db=db, tenant_id=tenant_id, source_type=body.source_type, source_id=body.source_id,
|
||||||
source_title=source_title, source_text=source_text,
|
source_title=body.source_title, source_text=body.source_text,
|
||||||
user_id=uuid.UUID(current_user["user_id"]) if current_user.get("user_id") else None,
|
user_id=uuid.UUID(current_user["user_id"]) if current_user.get("user_id") else None,
|
||||||
)
|
)
|
||||||
return result
|
return result
|
||||||
|
|
||||||
@router.post("/ask")
|
@router.post("/ask")
|
||||||
async def ask(
|
async def ask(
|
||||||
body: dict,
|
body: KnowledgeAskRequest,
|
||||||
db: AsyncSession = Depends(get_db),
|
db: AsyncSession = Depends(get_db),
|
||||||
current_user: dict = Depends(require_permission("wiki:read")),
|
current_user: dict = Depends(require_permission("wiki:read")),
|
||||||
):
|
):
|
||||||
"""Ask a knowledge question — uses wiki + graph_rag as context."""
|
"""Ask a knowledge question — uses wiki + graph_rag as context."""
|
||||||
tenant_id = uuid.UUID(current_user["tenant_id"])
|
tenant_id = uuid.UUID(current_user["tenant_id"])
|
||||||
question = body.get("question") or body.get("query", "")
|
result = await ask_knowledge(db=db, tenant_id=tenant_id, question=body.question)
|
||||||
if not question:
|
|
||||||
raise HTTPException(400, detail={"detail": "question required", "code": "missing_question"})
|
|
||||||
result = await ask_knowledge(db=db, tenant_id=tenant_id, question=question)
|
|
||||||
return result
|
return result
|
||||||
|
|
||||||
@router.get("/review")
|
@router.get("/review")
|
||||||
@@ -62,22 +81,20 @@ async def review_queue(
|
|||||||
@router.post("/review/{extraction_id}")
|
@router.post("/review/{extraction_id}")
|
||||||
async def review(
|
async def review(
|
||||||
extraction_id: str,
|
extraction_id: str,
|
||||||
body: dict,
|
body: KnowledgeReviewRequest,
|
||||||
db: AsyncSession = Depends(get_db),
|
db: AsyncSession = Depends(get_db),
|
||||||
current_user: dict = Depends(require_permission("wiki:write")),
|
current_user: dict = Depends(require_permission("wiki:write")),
|
||||||
):
|
):
|
||||||
"""Approve or reject a knowledge extraction."""
|
"""Approve or reject a knowledge extraction."""
|
||||||
tenant_id = uuid.UUID(current_user["tenant_id"])
|
tenant_id = uuid.UUID(current_user["tenant_id"])
|
||||||
approved = body.get("approved", False)
|
|
||||||
notes = body.get("notes")
|
|
||||||
try:
|
try:
|
||||||
eid = uuid.UUID(extraction_id)
|
eid = uuid.UUID(extraction_id)
|
||||||
except ValueError:
|
except ValueError:
|
||||||
raise HTTPException(400, detail={"detail": "Invalid extraction_id", "code": "invalid_id"}) from None
|
raise HTTPException(400, detail={"detail": "Invalid extraction_id", "code": "invalid_id"}) from None
|
||||||
result = await review_extraction(
|
result = await review_extraction(
|
||||||
db=db, tenant_id=tenant_id, extraction_id=eid, approved=approved,
|
db=db, tenant_id=tenant_id, extraction_id=eid, approved=body.approved,
|
||||||
user_id=uuid.UUID(current_user["user_id"]) if current_user.get("user_id") else None,
|
user_id=uuid.UUID(current_user["user_id"]) if current_user.get("user_id") else None,
|
||||||
notes=notes,
|
notes=body.notes,
|
||||||
)
|
)
|
||||||
if "error" in result:
|
if "error" in result:
|
||||||
raise HTTPException(404, detail={"detail": result["error"], "code": "not_found"})
|
raise HTTPException(404, detail={"detail": result["error"], "code": "not_found"})
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ from datetime import datetime
|
|||||||
from typing import Any
|
from typing import Any
|
||||||
|
|
||||||
from fastapi import APIRouter, Depends, HTTPException, Query
|
from fastapi import APIRouter, Depends, HTTPException, Query
|
||||||
|
from pydantic import BaseModel, Field
|
||||||
from sqlalchemy.ext.asyncio import AsyncSession
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
|
||||||
from app.core.db import get_db
|
from app.core.db import get_db
|
||||||
@@ -28,6 +29,46 @@ from app.plugins.builtins.self_improvement.services import (
|
|||||||
router = APIRouter(prefix="/api/v1/improvement", tags=["improvement"])
|
router = APIRouter(prefix="/api/v1/improvement", tags=["improvement"])
|
||||||
|
|
||||||
|
|
||||||
|
class CollectSignalsRequest(BaseModel):
|
||||||
|
"""Collect improvement signals from existing system data."""
|
||||||
|
|
||||||
|
since: datetime | None = None
|
||||||
|
limit: int = Field(100, ge=1, le=500)
|
||||||
|
|
||||||
|
|
||||||
|
class DetectPatternsRequest(BaseModel):
|
||||||
|
"""Detect recurring patterns from collected signals."""
|
||||||
|
|
||||||
|
min_occurrences: int = Field(2, ge=2)
|
||||||
|
|
||||||
|
|
||||||
|
class CreateProposalRequest(BaseModel):
|
||||||
|
"""Create a new improvement proposal."""
|
||||||
|
|
||||||
|
pattern_id: uuid.UUID | None = None
|
||||||
|
title: str = Field(..., min_length=1)
|
||||||
|
description: str = ""
|
||||||
|
target_type: str = Field(..., min_length=1)
|
||||||
|
target_ref_id: uuid.UUID | None = None
|
||||||
|
target_name: str | None = None
|
||||||
|
proposed_config: dict[str, Any] = Field(default_factory=dict)
|
||||||
|
rationale: str = ""
|
||||||
|
expected_benefit: str = ""
|
||||||
|
risk_assessment: str = ""
|
||||||
|
|
||||||
|
|
||||||
|
class RequestApprovalRequest(BaseModel):
|
||||||
|
"""Request human approval for a proposal."""
|
||||||
|
|
||||||
|
approver_id: uuid.UUID | None = None
|
||||||
|
|
||||||
|
|
||||||
|
class RollbackProposalRequest(BaseModel):
|
||||||
|
"""Rollback an active proposal."""
|
||||||
|
|
||||||
|
reason: str = ""
|
||||||
|
|
||||||
|
|
||||||
# ──────────────────────────────────────────────────────────────────────────
|
# ──────────────────────────────────────────────────────────────────────────
|
||||||
# J-SIGNAL: Signal Collection
|
# J-SIGNAL: Signal Collection
|
||||||
# NOTE: /signals/collect must be defined before /signals to avoid route conflicts
|
# NOTE: /signals/collect must be defined before /signals to avoid route conflicts
|
||||||
@@ -35,21 +76,13 @@ router = APIRouter(prefix="/api/v1/improvement", tags=["improvement"])
|
|||||||
|
|
||||||
@router.post("/signals/collect")
|
@router.post("/signals/collect")
|
||||||
async def collect(
|
async def collect(
|
||||||
body: dict,
|
body: CollectSignalsRequest,
|
||||||
db: AsyncSession = Depends(get_db),
|
db: AsyncSession = Depends(get_db),
|
||||||
current_user: dict = Depends(require_permission("automation:read")),
|
current_user: dict = Depends(require_permission("automation:read")),
|
||||||
):
|
):
|
||||||
"""Collect improvement signals from existing system data."""
|
"""Collect improvement signals from existing system data."""
|
||||||
tenant_id = uuid.UUID(current_user["tenant_id"])
|
tenant_id = uuid.UUID(current_user["tenant_id"])
|
||||||
since_str = body.get("since")
|
result = await collect_signals(db=db, tenant_id=tenant_id, since=body.since, limit=body.limit)
|
||||||
since = None
|
|
||||||
if since_str:
|
|
||||||
try:
|
|
||||||
since = datetime.fromisoformat(since_str)
|
|
||||||
except ValueError:
|
|
||||||
raise HTTPException(400, detail={"detail": "Invalid since format", "code": "invalid_date"}) from None
|
|
||||||
limit = min(body.get("limit", 100), 500)
|
|
||||||
result = await collect_signals(db=db, tenant_id=tenant_id, since=since, limit=limit)
|
|
||||||
await db.commit()
|
await db.commit()
|
||||||
return result
|
return result
|
||||||
|
|
||||||
@@ -73,14 +106,13 @@ async def signals(
|
|||||||
|
|
||||||
@router.post("/patterns/detect")
|
@router.post("/patterns/detect")
|
||||||
async def detect(
|
async def detect(
|
||||||
body: dict,
|
body: DetectPatternsRequest,
|
||||||
db: AsyncSession = Depends(get_db),
|
db: AsyncSession = Depends(get_db),
|
||||||
current_user: dict = Depends(require_permission("automation:read")),
|
current_user: dict = Depends(require_permission("automation:read")),
|
||||||
):
|
):
|
||||||
"""Detect recurring patterns from collected signals."""
|
"""Detect recurring patterns from collected signals."""
|
||||||
tenant_id = uuid.UUID(current_user["tenant_id"])
|
tenant_id = uuid.UUID(current_user["tenant_id"])
|
||||||
min_occurrences = body.get("min_occurrences", 2)
|
result = await detect_patterns(db=db, tenant_id=tenant_id, min_occurrences=body.min_occurrences)
|
||||||
result = await detect_patterns(db=db, tenant_id=tenant_id, min_occurrences=min_occurrences)
|
|
||||||
await db.commit()
|
await db.commit()
|
||||||
return result
|
return result
|
||||||
|
|
||||||
@@ -103,7 +135,7 @@ async def patterns(
|
|||||||
|
|
||||||
@router.post("/proposals")
|
@router.post("/proposals")
|
||||||
async def create(
|
async def create(
|
||||||
body: dict,
|
body: CreateProposalRequest,
|
||||||
db: AsyncSession = Depends(get_db),
|
db: AsyncSession = Depends(get_db),
|
||||||
current_user: dict = Depends(require_permission("automation:write")),
|
current_user: dict = Depends(require_permission("automation:write")),
|
||||||
):
|
):
|
||||||
@@ -111,35 +143,18 @@ async def create(
|
|||||||
tenant_id = uuid.UUID(current_user["tenant_id"])
|
tenant_id = uuid.UUID(current_user["tenant_id"])
|
||||||
user_id = uuid.UUID(current_user["user_id"]) if current_user.get("user_id") else None
|
user_id = uuid.UUID(current_user["user_id"]) if current_user.get("user_id") else None
|
||||||
|
|
||||||
pattern_id = None
|
|
||||||
if body.get("pattern_id"):
|
|
||||||
try:
|
|
||||||
pattern_id = uuid.UUID(body["pattern_id"])
|
|
||||||
except ValueError:
|
|
||||||
raise HTTPException(400, detail={"detail": "Invalid pattern_id", "code": "invalid_id"}) from None
|
|
||||||
|
|
||||||
target_ref_id = None
|
|
||||||
if body.get("target_ref_id"):
|
|
||||||
try:
|
|
||||||
target_ref_id = uuid.UUID(body["target_ref_id"])
|
|
||||||
except ValueError:
|
|
||||||
raise HTTPException(400, detail={"detail": "Invalid target_ref_id", "code": "invalid_id"}) from None
|
|
||||||
|
|
||||||
if not body.get("title") or not body.get("target_type"):
|
|
||||||
raise HTTPException(400, detail={"detail": "title and target_type required", "code": "missing_fields"})
|
|
||||||
|
|
||||||
proposal = await create_proposal(
|
proposal = await create_proposal(
|
||||||
db=db, tenant_id=tenant_id,
|
db=db, tenant_id=tenant_id,
|
||||||
pattern_id=pattern_id,
|
pattern_id=body.pattern_id,
|
||||||
title=body["title"],
|
title=body.title,
|
||||||
description=body.get("description", ""),
|
description=body.description,
|
||||||
target_type=body["target_type"],
|
target_type=body.target_type,
|
||||||
target_ref_id=target_ref_id,
|
target_ref_id=body.target_ref_id,
|
||||||
target_name=body.get("target_name"),
|
target_name=body.target_name,
|
||||||
proposed_config=body.get("proposed_config", {}),
|
proposed_config=body.proposed_config,
|
||||||
rationale=body.get("rationale", ""),
|
rationale=body.rationale,
|
||||||
expected_benefit=body.get("expected_benefit", ""),
|
expected_benefit=body.expected_benefit,
|
||||||
risk_assessment=body.get("risk_assessment", ""),
|
risk_assessment=body.risk_assessment,
|
||||||
user_id=user_id,
|
user_id=user_id,
|
||||||
)
|
)
|
||||||
await db.commit()
|
await db.commit()
|
||||||
@@ -207,7 +222,7 @@ async def evaluate(
|
|||||||
@router.post("/proposals/{proposal_id}/request-approval")
|
@router.post("/proposals/{proposal_id}/request-approval")
|
||||||
async def req_approval(
|
async def req_approval(
|
||||||
proposal_id: str,
|
proposal_id: str,
|
||||||
body: dict,
|
body: RequestApprovalRequest,
|
||||||
db: AsyncSession = Depends(get_db),
|
db: AsyncSession = Depends(get_db),
|
||||||
current_user: dict = Depends(require_permission("automation:write")),
|
current_user: dict = Depends(require_permission("automation:write")),
|
||||||
):
|
):
|
||||||
@@ -218,13 +233,7 @@ async def req_approval(
|
|||||||
pid = uuid.UUID(proposal_id)
|
pid = uuid.UUID(proposal_id)
|
||||||
except ValueError:
|
except ValueError:
|
||||||
raise HTTPException(400, detail={"detail": "Invalid proposal_id", "code": "invalid_id"}) from None
|
raise HTTPException(400, detail={"detail": "Invalid proposal_id", "code": "invalid_id"}) from None
|
||||||
approver_id = None
|
result = await request_approval(db=db, tenant_id=tenant_id, proposal_id=pid, requested_by=user_id, approver_id=body.approver_id)
|
||||||
if body.get("approver_id"):
|
|
||||||
try:
|
|
||||||
approver_id = uuid.UUID(body["approver_id"])
|
|
||||||
except ValueError:
|
|
||||||
raise HTTPException(400, detail={"detail": "Invalid approver_id", "code": "invalid_id"}) from None
|
|
||||||
result = await request_approval(db=db, tenant_id=tenant_id, proposal_id=pid, requested_by=user_id, approver_id=approver_id)
|
|
||||||
if "error" in result:
|
if "error" in result:
|
||||||
raise HTTPException(400, detail={"detail": result["error"], "code": "invalid_state"})
|
raise HTTPException(400, detail={"detail": result["error"], "code": "invalid_state"})
|
||||||
await db.commit()
|
await db.commit()
|
||||||
@@ -258,7 +267,7 @@ async def activate(
|
|||||||
@router.post("/proposals/{proposal_id}/rollback")
|
@router.post("/proposals/{proposal_id}/rollback")
|
||||||
async def rollback(
|
async def rollback(
|
||||||
proposal_id: str,
|
proposal_id: str,
|
||||||
body: dict,
|
body: RollbackProposalRequest,
|
||||||
db: AsyncSession = Depends(get_db),
|
db: AsyncSession = Depends(get_db),
|
||||||
current_user: dict = Depends(require_permission("automation:admin")),
|
current_user: dict = Depends(require_permission("automation:admin")),
|
||||||
):
|
):
|
||||||
@@ -268,8 +277,7 @@ async def rollback(
|
|||||||
pid = uuid.UUID(proposal_id)
|
pid = uuid.UUID(proposal_id)
|
||||||
except ValueError:
|
except ValueError:
|
||||||
raise HTTPException(400, detail={"detail": "Invalid proposal_id", "code": "invalid_id"}) from None
|
raise HTTPException(400, detail={"detail": "Invalid proposal_id", "code": "invalid_id"}) from None
|
||||||
reason = body.get("reason", "")
|
result = await rollback_proposal(db=db, tenant_id=tenant_id, proposal_id=pid, reason=body.reason)
|
||||||
result = await rollback_proposal(db=db, tenant_id=tenant_id, proposal_id=pid, reason=reason)
|
|
||||||
if "error" in result:
|
if "error" in result:
|
||||||
raise HTTPException(400, detail={"detail": result["error"], "code": "invalid_state"})
|
raise HTTPException(400, detail={"detail": result["error"], "code": "invalid_state"})
|
||||||
await db.commit()
|
await db.commit()
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ from __future__ import annotations
|
|||||||
import uuid
|
import uuid
|
||||||
|
|
||||||
from fastapi import APIRouter, Depends, HTTPException, status
|
from fastapi import APIRouter, Depends, HTTPException, status
|
||||||
|
from pydantic import BaseModel, Field
|
||||||
from sqlalchemy.ext.asyncio import AsyncSession
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
|
||||||
from app.core.db import get_db
|
from app.core.db import get_db
|
||||||
@@ -18,6 +19,25 @@ from app.services import bulk_permission_service, entity_permission_service
|
|||||||
|
|
||||||
router = APIRouter(prefix="/api/v1/permissions", tags=["entity-permissions"])
|
router = APIRouter(prefix="/api/v1/permissions", tags=["entity-permissions"])
|
||||||
|
|
||||||
|
|
||||||
|
class BulkShareRequest(BaseModel):
|
||||||
|
"""Bulk-share multiple entities with a principal."""
|
||||||
|
|
||||||
|
entity_type: str = Field(..., min_length=1)
|
||||||
|
entity_ids: list[str] = Field(..., min_length=1)
|
||||||
|
principal_type: str = Field(..., pattern="^(user|group|guest)$")
|
||||||
|
principal_id: str = Field(..., min_length=1)
|
||||||
|
level: str = Field(..., pattern="^(read|write|admin|delete|owner)$")
|
||||||
|
|
||||||
|
|
||||||
|
class BulkUnshareRequest(BaseModel):
|
||||||
|
"""Bulk-remove permissions for a principal from multiple entities."""
|
||||||
|
|
||||||
|
entity_type: str = Field(..., min_length=1)
|
||||||
|
entity_ids: list[str] = Field(..., min_length=1)
|
||||||
|
principal_type: str = Field(..., pattern="^(user|group|guest)$")
|
||||||
|
principal_id: str = Field(..., min_length=1)
|
||||||
|
|
||||||
# Rate limits for permission changes (prevent abuse/DoS)
|
# Rate limits for permission changes (prevent abuse/DoS)
|
||||||
_PERM_RATE_LIMIT_MAX = 50 # max changes per minute
|
_PERM_RATE_LIMIT_MAX = 50 # max changes per minute
|
||||||
_PERM_RATE_LIMIT_WINDOW = 60 # 60 seconds
|
_PERM_RATE_LIMIT_WINDOW = 60 # 60 seconds
|
||||||
@@ -279,7 +299,7 @@ async def list_entity_registry(
|
|||||||
|
|
||||||
@router.post("/bulk", status_code=status.HTTP_201_CREATED)
|
@router.post("/bulk", status_code=status.HTTP_201_CREATED)
|
||||||
async def bulk_share_permissions(
|
async def bulk_share_permissions(
|
||||||
body: dict,
|
body: BulkShareRequest,
|
||||||
db: AsyncSession = Depends(get_db),
|
db: AsyncSession = Depends(get_db),
|
||||||
current_user: dict = Depends(require_permission("settings:write")),
|
current_user: dict = Depends(require_permission("settings:write")),
|
||||||
):
|
):
|
||||||
@@ -290,11 +310,11 @@ async def bulk_share_permissions(
|
|||||||
result = await bulk_permission_service.bulk_share(
|
result = await bulk_permission_service.bulk_share(
|
||||||
db,
|
db,
|
||||||
tenant_id,
|
tenant_id,
|
||||||
body["entity_type"],
|
body.entity_type,
|
||||||
body["entity_ids"],
|
body.entity_ids,
|
||||||
body["principal_type"],
|
body.principal_type,
|
||||||
body["principal_id"],
|
body.principal_id,
|
||||||
body["level"],
|
body.level,
|
||||||
created_by=user_id,
|
created_by=user_id,
|
||||||
)
|
)
|
||||||
return result
|
return result
|
||||||
@@ -304,7 +324,7 @@ async def bulk_share_permissions(
|
|||||||
|
|
||||||
@router.post("/bulk/unshare", status_code=status.HTTP_200_OK)
|
@router.post("/bulk/unshare", status_code=status.HTTP_200_OK)
|
||||||
async def bulk_unshare_permissions(
|
async def bulk_unshare_permissions(
|
||||||
body: dict,
|
body: BulkUnshareRequest,
|
||||||
db: AsyncSession = Depends(get_db),
|
db: AsyncSession = Depends(get_db),
|
||||||
current_user: dict = Depends(require_permission("settings:write")),
|
current_user: dict = Depends(require_permission("settings:write")),
|
||||||
):
|
):
|
||||||
@@ -314,10 +334,10 @@ async def bulk_unshare_permissions(
|
|||||||
result = await bulk_permission_service.bulk_unshare(
|
result = await bulk_permission_service.bulk_unshare(
|
||||||
db,
|
db,
|
||||||
tenant_id,
|
tenant_id,
|
||||||
body["entity_type"],
|
body.entity_type,
|
||||||
body["entity_ids"],
|
body.entity_ids,
|
||||||
body["principal_type"],
|
body.principal_type,
|
||||||
body["principal_id"],
|
body.principal_id,
|
||||||
)
|
)
|
||||||
return result
|
return result
|
||||||
except (ValueError, KeyError) as e:
|
except (ValueError, KeyError) as e:
|
||||||
|
|||||||
+11
-8
@@ -14,6 +14,7 @@ import secrets
|
|||||||
import uuid
|
import uuid
|
||||||
|
|
||||||
from fastapi import APIRouter, Depends, HTTPException, Request, status
|
from fastapi import APIRouter, Depends, HTTPException, Request, status
|
||||||
|
from pydantic import BaseModel, EmailStr, Field
|
||||||
from sqlalchemy import select
|
from sqlalchemy import select
|
||||||
from sqlalchemy.ext.asyncio import AsyncSession
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
|
||||||
@@ -27,6 +28,13 @@ router = APIRouter(prefix="/api/v1/guests", tags=["guests"])
|
|||||||
settings = get_settings()
|
settings = get_settings()
|
||||||
|
|
||||||
|
|
||||||
|
class GuestInviteRequest(BaseModel):
|
||||||
|
"""Invite a guest user."""
|
||||||
|
|
||||||
|
email: EmailStr
|
||||||
|
name: str = Field(..., min_length=1, max_length=200)
|
||||||
|
|
||||||
|
|
||||||
def _hash_token(token: str) -> str:
|
def _hash_token(token: str) -> str:
|
||||||
"""Hash a token using SHA-256."""
|
"""Hash a token using SHA-256."""
|
||||||
return hashlib.sha256(token.encode()).hexdigest()
|
return hashlib.sha256(token.encode()).hexdigest()
|
||||||
@@ -35,7 +43,7 @@ def _hash_token(token: str) -> str:
|
|||||||
@router.post("/invite")
|
@router.post("/invite")
|
||||||
async def invite_guest(
|
async def invite_guest(
|
||||||
request: Request,
|
request: Request,
|
||||||
body: dict,
|
body: GuestInviteRequest,
|
||||||
db: AsyncSession = Depends(get_db),
|
db: AsyncSession = Depends(get_db),
|
||||||
current_user: dict = Depends(require_admin),
|
current_user: dict = Depends(require_admin),
|
||||||
):
|
):
|
||||||
@@ -43,13 +51,8 @@ async def invite_guest(
|
|||||||
|
|
||||||
⚠️ Guest-System umgebaut — Guests sind jetzt reguläre User mit role=guest
|
⚠️ Guest-System umgebaut — Guests sind jetzt reguläre User mit role=guest
|
||||||
"""
|
"""
|
||||||
email = body.get("email", "")
|
email = body.email
|
||||||
name = body.get("name", "")
|
name = body.name
|
||||||
if not email or not name:
|
|
||||||
raise HTTPException(
|
|
||||||
status_code=status.HTTP_400_BAD_REQUEST,
|
|
||||||
detail={"detail": "Email and name required", "code": "missing_fields"},
|
|
||||||
)
|
|
||||||
|
|
||||||
tenant_id = uuid.UUID(current_user["tenant_id"])
|
tenant_id = uuid.UUID(current_user["tenant_id"])
|
||||||
|
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ from __future__ import annotations
|
|||||||
import uuid
|
import uuid
|
||||||
|
|
||||||
from fastapi import APIRouter, Depends, HTTPException
|
from fastapi import APIRouter, Depends, HTTPException
|
||||||
|
from pydantic import BaseModel, Field
|
||||||
from sqlalchemy import select as sa_select
|
from sqlalchemy import select as sa_select
|
||||||
from sqlalchemy.ext.asyncio import AsyncSession
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
|
||||||
@@ -18,6 +19,21 @@ from app.services import system_settings_service
|
|||||||
router = APIRouter(prefix="/api/v1/system-settings", tags=["system-settings"])
|
router = APIRouter(prefix="/api/v1/system-settings", tags=["system-settings"])
|
||||||
|
|
||||||
|
|
||||||
|
class BackupConfigRequest(BaseModel):
|
||||||
|
"""Update backup configuration (all fields optional)."""
|
||||||
|
|
||||||
|
backup_enabled: bool | None = None
|
||||||
|
backup_interval: str | None = Field(None, max_length=20)
|
||||||
|
backup_retention_days: int | None = Field(None, ge=1, le=365)
|
||||||
|
backup_destination: str | None = Field(None, max_length=20)
|
||||||
|
|
||||||
|
|
||||||
|
class DsarRequest(BaseModel):
|
||||||
|
"""Submit a Data Subject Access Request."""
|
||||||
|
|
||||||
|
type: str = Field("access", pattern="^(access|deletion|rectification)$")
|
||||||
|
|
||||||
|
|
||||||
@router.get("", response_model=SystemSettingsResponse)
|
@router.get("", response_model=SystemSettingsResponse)
|
||||||
async def get_system_settings(
|
async def get_system_settings(
|
||||||
db: AsyncSession = Depends(get_db),
|
db: AsyncSession = Depends(get_db),
|
||||||
@@ -86,7 +102,7 @@ async def get_backup_config(
|
|||||||
|
|
||||||
@router.put("/backup-config")
|
@router.put("/backup-config")
|
||||||
async def update_backup_config(
|
async def update_backup_config(
|
||||||
body: dict,
|
body: BackupConfigRequest,
|
||||||
db: AsyncSession = Depends(get_db),
|
db: AsyncSession = Depends(get_db),
|
||||||
current_user: dict = Depends(require_permission("settings:write")),
|
current_user: dict = Depends(require_permission("settings:write")),
|
||||||
):
|
):
|
||||||
@@ -105,14 +121,15 @@ async def update_backup_config(
|
|||||||
|
|
||||||
# Merge backup fields into existing data
|
# Merge backup fields into existing data
|
||||||
data = dict(existing)
|
data = dict(existing)
|
||||||
if "backup_enabled" in body:
|
updates = body.model_dump(exclude_unset=True)
|
||||||
data["backup_enabled"] = bool(body["backup_enabled"])
|
for key in (
|
||||||
if "backup_interval" in body:
|
"backup_enabled",
|
||||||
data["backup_interval"] = str(body["backup_interval"])
|
"backup_interval",
|
||||||
if "backup_retention_days" in body:
|
"backup_retention_days",
|
||||||
data["backup_retention_days"] = int(body["backup_retention_days"])
|
"backup_destination",
|
||||||
if "backup_destination" in body:
|
):
|
||||||
data["backup_destination"] = str(body["backup_destination"])
|
if key in updates:
|
||||||
|
data[key] = updates[key]
|
||||||
|
|
||||||
return await system_settings_service.upsert_system_settings(db, tenant_id, user_id, data)
|
return await system_settings_service.upsert_system_settings(db, tenant_id, user_id, data)
|
||||||
|
|
||||||
@@ -184,13 +201,16 @@ async def dsgvo_export(
|
|||||||
- Calendar events
|
- Calendar events
|
||||||
- Communication messages
|
- Communication messages
|
||||||
"""
|
"""
|
||||||
import json
|
|
||||||
import io
|
import io
|
||||||
|
import json
|
||||||
|
from datetime import UTC, datetime
|
||||||
|
|
||||||
from fastapi.responses import StreamingResponse
|
from fastapi.responses import StreamingResponse
|
||||||
from sqlalchemy import select as sa_select
|
from sqlalchemy import select as sa_select
|
||||||
from app.models.user import User
|
|
||||||
from app.models.contact import Contact
|
|
||||||
from app.models.audit import AuditLog
|
from app.models.audit import AuditLog
|
||||||
|
from app.models.contact import Contact
|
||||||
|
from app.models.user import User
|
||||||
|
|
||||||
tenant_id = uuid.UUID(current_user["tenant_id"])
|
tenant_id = uuid.UUID(current_user["tenant_id"])
|
||||||
try:
|
try:
|
||||||
@@ -198,7 +218,7 @@ async def dsgvo_export(
|
|||||||
except ValueError:
|
except ValueError:
|
||||||
raise HTTPException(400, detail={"detail": "Invalid user_id", "code": "invalid_id"}) from None
|
raise HTTPException(400, detail={"detail": "Invalid user_id", "code": "invalid_id"}) from None
|
||||||
|
|
||||||
export_data = {"user_id": str(uid), "exported_at": datetime.now(timezone.utc).isoformat(), "data": {}}
|
export_data = {"user_id": str(uid), "exported_at": datetime.now(UTC).isoformat(), "data": {}}
|
||||||
|
|
||||||
# User profile
|
# User profile
|
||||||
user_result = await db.execute(sa_select(User).where(User.id == uid))
|
user_result = await db.execute(sa_select(User).where(User.id == uid))
|
||||||
@@ -243,7 +263,7 @@ async def dsgvo_export(
|
|||||||
@router.post("/dsar/{user_id}")
|
@router.post("/dsar/{user_id}")
|
||||||
async def dsar_request(
|
async def dsar_request(
|
||||||
user_id: str,
|
user_id: str,
|
||||||
body: dict,
|
body: DsarRequest,
|
||||||
db: AsyncSession = Depends(get_db),
|
db: AsyncSession = Depends(get_db),
|
||||||
current_user: dict = Depends(require_permission("system:admin")),
|
current_user: dict = Depends(require_permission("system:admin")),
|
||||||
):
|
):
|
||||||
@@ -254,7 +274,7 @@ async def dsar_request(
|
|||||||
"""
|
"""
|
||||||
from app.core.jobs import enqueue_job
|
from app.core.jobs import enqueue_job
|
||||||
tenant_id = uuid.UUID(current_user["tenant_id"])
|
tenant_id = uuid.UUID(current_user["tenant_id"])
|
||||||
request_type = body.get("type", "access")
|
request_type = body.type
|
||||||
try:
|
try:
|
||||||
uid = uuid.UUID(user_id)
|
uid = uuid.UUID(user_id)
|
||||||
except ValueError:
|
except ValueError:
|
||||||
|
|||||||
+9
-7
@@ -6,6 +6,7 @@ import uuid
|
|||||||
from typing import Any
|
from typing import Any
|
||||||
|
|
||||||
from fastapi import APIRouter, Depends, HTTPException, Query, Response, status
|
from fastapi import APIRouter, Depends, HTTPException, Query, Response, status
|
||||||
|
from pydantic import BaseModel, Field
|
||||||
from sqlalchemy import select
|
from sqlalchemy import select
|
||||||
from sqlalchemy.ext.asyncio import AsyncSession
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
|
||||||
@@ -23,6 +24,12 @@ from app.services.user_service import _UNSET, user_service
|
|||||||
router = APIRouter(prefix="/api/v1/users", tags=["users"])
|
router = APIRouter(prefix="/api/v1/users", tags=["users"])
|
||||||
|
|
||||||
|
|
||||||
|
class MenuOrderRequest(BaseModel):
|
||||||
|
"""Update the current user's menu order preference."""
|
||||||
|
|
||||||
|
menu_order: list[str] = Field(..., min_length=0)
|
||||||
|
|
||||||
|
|
||||||
def _parse_role_id(raw: str | None) -> uuid.UUID | None:
|
def _parse_role_id(raw: str | None) -> uuid.UUID | None:
|
||||||
"""Convert a string body value into a UUID or None.
|
"""Convert a string body value into a UUID or None.
|
||||||
|
|
||||||
@@ -368,19 +375,14 @@ async def get_menu_order(
|
|||||||
|
|
||||||
@router.put("/me/menu-order")
|
@router.put("/me/menu-order")
|
||||||
async def update_menu_order(
|
async def update_menu_order(
|
||||||
body: dict,
|
body: MenuOrderRequest,
|
||||||
db: AsyncSession = Depends(get_db),
|
db: AsyncSession = Depends(get_db),
|
||||||
current_user: dict = Depends(get_current_user),
|
current_user: dict = Depends(get_current_user),
|
||||||
):
|
):
|
||||||
"""Update the current user's menu order preference."""
|
"""Update the current user's menu order preference."""
|
||||||
user_id = uuid.UUID(current_user["user_id"])
|
user_id = uuid.UUID(current_user["user_id"])
|
||||||
|
|
||||||
menu_order = body.get("menu_order")
|
menu_order = body.menu_order
|
||||||
if not isinstance(menu_order, list) or not all(isinstance(x, str) for x in menu_order):
|
|
||||||
raise HTTPException(
|
|
||||||
400,
|
|
||||||
detail={"detail": "menu_order must be a list of strings", "code": "invalid_format"},
|
|
||||||
)
|
|
||||||
|
|
||||||
result = await db.execute(
|
result = await db.execute(
|
||||||
select(User).where(User.id == user_id)
|
select(User).where(User.id == user_id)
|
||||||
|
|||||||
Reference in New Issue
Block a user