fix(audit): P0-P3 audit fixes — 838 ruff errors → 0, 30 F821 bugs fixed, 118 files changed
- P0: hooks.py 3-tuple fix, trigger_dispatcher Contract, contacts/plugin unregister_actions_by_owner - P0: 5 test files — check_permission mocks removed, hardcoded DB credential → env var - P1: attachment_service DmsFile via Contract helper, restore_registry/history_hooks dedup - P1: mail/plugin restore unregister, mcp_client datetime.now(UTC), saved_views/filters patterns - P1: ProtectedRoute fail-closed, 13 test assertion fixes (bcrypt, DB-URLs, SECRET_KEYs) - P2: deprecated notifications → post_system_message (3 files), forgejo Base, report_generator lazy import - P2: webhooks permissions, deps.py/roles.py plugin perms removed, import_export default - P2: address/tags/entity_links patterns removed, worker.py Contract-Umgehungen fixed - P2: 28 frontend TODOs (hardcoded constants, deprecated notification API) - P3: dead code, duplicates, deprecated imports, private attr, __import__ inline - P3: 8 frontend TODOs (LucideIcons, inline styles, XSS, i18n) - ruff: 838 → 0 (612 auto-fix + 246 manual + 27 F821 regression fix) - F821: 30 → 0 (AutomationDefinition, DmsFile, user_id, Path, Any, String) - Contract-Umgehungen: 2 neue gefunden (worker.py:169, worker.py:280) und gefixt
This commit is contained in:
@@ -3,26 +3,26 @@
|
||||
from app.routes import (
|
||||
addresses, # noqa: F401
|
||||
ai_copilot, # noqa: F401
|
||||
bank_accounts, # noqa: F401
|
||||
attachments, # noqa: F401
|
||||
audit, # noqa: F401
|
||||
auth, # noqa: F401
|
||||
bank_accounts, # noqa: F401
|
||||
contacts, # noqa: F401
|
||||
currencies, # noqa: F401
|
||||
dashboard, # noqa: F401
|
||||
entity_history, # noqa: F401
|
||||
currencies, # noqa: F401
|
||||
taxes, # noqa: F401
|
||||
sequences, # noqa: F401
|
||||
system_settings, # noqa: F401
|
||||
attachments, # noqa: F401
|
||||
guests, # noqa: F401 # ⚠️ Guest-System umgebaut — Guests sind jetzt reguläre User mit role=guest
|
||||
health, # noqa: F401
|
||||
import_export, # noqa: F401
|
||||
metrics, # noqa: F401
|
||||
notifications, # noqa: F401
|
||||
plugins, # noqa: F401
|
||||
roles, # noqa: F401
|
||||
sequences, # noqa: F401
|
||||
system_settings, # noqa: F401
|
||||
taxes, # noqa: F401
|
||||
tenants, # noqa: F401
|
||||
users, # noqa: F401
|
||||
user_preferences, # noqa: F401
|
||||
users, # noqa: F401
|
||||
workflows, # noqa: F401
|
||||
guests, # noqa: F401 # ⚠️ Guest-System umgebaut — Guests sind jetzt reguläre User mit role=guest
|
||||
)
|
||||
|
||||
+15
-1
@@ -15,9 +15,20 @@ from app.services import address_service
|
||||
router = APIRouter(prefix="/api/v1/addresses", tags=["addresses"])
|
||||
|
||||
|
||||
def _validate_entity_type(entity_type: str) -> None:
|
||||
"""Validate entity_type against the ENTITY_MODELS registry."""
|
||||
from app.services.entity_permission_service import ENTITY_MODELS
|
||||
|
||||
if entity_type not in ENTITY_MODELS:
|
||||
raise HTTPException(
|
||||
status_code=400,
|
||||
detail={"detail": f"Invalid entity_type: {entity_type}", "code": "invalid_entity_type"},
|
||||
)
|
||||
|
||||
|
||||
@router.get("")
|
||||
async def list_addresses(
|
||||
entity_type: str = Query(..., pattern="^contact$"),
|
||||
entity_type: str = Query(...),
|
||||
entity_id: str = Query(...),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
current_user: dict = Depends(require_permission("addresses:read")),
|
||||
@@ -27,6 +38,8 @@ async def list_addresses(
|
||||
user_id = uuid.UUID(current_user["user_id"])
|
||||
is_admin = current_user.get("is_system_admin", False)
|
||||
|
||||
_validate_entity_type(entity_type)
|
||||
|
||||
try:
|
||||
eid = uuid.UUID(entity_id)
|
||||
except ValueError:
|
||||
@@ -50,6 +63,7 @@ async def create_address(
|
||||
is_admin = current_user.get("is_system_admin", False)
|
||||
|
||||
data = body.model_dump()
|
||||
_validate_entity_type(data["entity_type"])
|
||||
try:
|
||||
return await address_service.create_address(db, tenant_id, user_id, data, is_system_admin=is_admin)
|
||||
except PermissionError as e:
|
||||
|
||||
@@ -8,8 +8,8 @@ from fastapi import APIRouter, Depends, HTTPException, Query, Request, status
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.core.db import get_db
|
||||
from app.core.rate_limit import RateLimitPolicy, check_rate_limit_policy, get_client_ip
|
||||
from app.deps import get_current_user, require_permission
|
||||
from app.core.rate_limit import RateLimitPolicy, check_rate_limit_policy
|
||||
from app.deps import require_permission
|
||||
from app.schemas.ai_copilot import (
|
||||
CopilotExecuteRequest,
|
||||
CopilotQueryRequest,
|
||||
|
||||
@@ -3,8 +3,7 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import uuid
|
||||
from datetime import datetime, timedelta, timezone
|
||||
from typing import Any
|
||||
from datetime import UTC, datetime, timedelta
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException, status
|
||||
from pydantic import BaseModel
|
||||
@@ -12,7 +11,7 @@ from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.core.api_token import create_api_token, list_api_tokens, revoke_api_token
|
||||
from app.core.db import get_db
|
||||
from app.deps import get_current_user, require_permission
|
||||
from app.deps import require_permission
|
||||
|
||||
router = APIRouter(prefix="/api/v1/tokens", tags=["api-tokens"])
|
||||
|
||||
@@ -39,7 +38,7 @@ async def create_token(
|
||||
|
||||
expires_at = None
|
||||
if body.expires_in_days is not None:
|
||||
expires_at = datetime.now(timezone.utc) + timedelta(days=body.expires_in_days)
|
||||
expires_at = datetime.now(UTC) + timedelta(days=body.expires_in_days)
|
||||
|
||||
result = await create_api_token(
|
||||
db, tenant_id, user_id, body.name, body.scopes, expires_at,
|
||||
@@ -71,7 +70,7 @@ async def revoke_token(
|
||||
try:
|
||||
tid = uuid.UUID(token_id)
|
||||
except ValueError:
|
||||
raise HTTPException(400, detail={"detail": "Invalid token_id", "code": "invalid_id"})
|
||||
raise HTTPException(400, detail={"detail": "Invalid token_id", "code": "invalid_id"}) from None
|
||||
revoked = await revoke_api_token(db, tenant_id, tid)
|
||||
if not revoked:
|
||||
raise HTTPException(404, detail={"detail": "Token not found or already revoked", "code": "not_found"})
|
||||
|
||||
@@ -2,11 +2,9 @@
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
import uuid
|
||||
|
||||
from fastapi import APIRouter, Depends, File, Form, HTTPException, Request, UploadFile, status
|
||||
from fastapi.responses import FileResponse
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.core.db import get_db
|
||||
@@ -112,7 +110,7 @@ async def download_attachment(
|
||||
try:
|
||||
file_bytes = await storage.read(storage_path)
|
||||
except Exception:
|
||||
raise HTTPException(404, detail={"detail": "File not found in storage", "code": "file_missing"})
|
||||
raise HTTPException(404, detail={"detail": "File not found in storage", "code": "file_missing"}) from None
|
||||
|
||||
from fastapi.responses import Response
|
||||
return Response(
|
||||
|
||||
+2
-2
@@ -5,8 +5,8 @@ from __future__ import annotations
|
||||
import uuid
|
||||
from datetime import datetime
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException, Query, status
|
||||
from sqlalchemy import select, func
|
||||
from fastapi import APIRouter, Depends, HTTPException, Query
|
||||
from sqlalchemy import func, select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.core.db import get_db
|
||||
|
||||
+1
-1
@@ -16,6 +16,7 @@ from app.core.rate_limit import (
|
||||
get_client_ip,
|
||||
reset_rate_limit,
|
||||
)
|
||||
from app.deps import get_current_user
|
||||
from app.schemas.auth import (
|
||||
AuthResponse,
|
||||
LoginRequest,
|
||||
@@ -24,7 +25,6 @@ from app.schemas.auth import (
|
||||
PasswordResetRequest,
|
||||
SwitchTenantRequest,
|
||||
)
|
||||
from app.deps import get_current_user
|
||||
from app.services.auth_service import auth_service
|
||||
|
||||
router = APIRouter(prefix="/api/v1/auth", tags=["auth"])
|
||||
|
||||
@@ -12,12 +12,12 @@ from datetime import UTC, datetime
|
||||
from typing import Any
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException, Query, Response, status
|
||||
from sqlalchemy import select, func
|
||||
from sqlalchemy.orm import selectinload
|
||||
from sqlalchemy import func, select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from sqlalchemy.orm import selectinload
|
||||
|
||||
from app.core.db import get_db
|
||||
from app.deps import get_current_user, get_redis_dep, require_permission
|
||||
from app.deps import get_redis_dep, require_permission
|
||||
from app.models.audit import AuditLog
|
||||
from app.models.contact import Contact
|
||||
from app.services.entity_history_service import record_history
|
||||
@@ -44,8 +44,8 @@ def _serialize_company(c: Contact) -> dict[str, Any]:
|
||||
"mailing_country": c.mailing_country,
|
||||
"tags": c.tags,
|
||||
"custom": c.custom,
|
||||
|
||||
|
||||
|
||||
|
||||
}
|
||||
|
||||
|
||||
@@ -152,7 +152,7 @@ async def export_companies(
|
||||
try:
|
||||
import openpyxl
|
||||
except ImportError:
|
||||
raise HTTPException(status_code=500, detail="openpyxl not installed")
|
||||
raise HTTPException(status_code=500, detail="openpyxl not installed") from None
|
||||
wb = openpyxl.Workbook()
|
||||
ws = wb.active
|
||||
ws.title = "Companies"
|
||||
@@ -332,6 +332,7 @@ async def unlink_contact_from_company(
|
||||
raise HTTPException(status_code=404, detail="Company not found")
|
||||
# Remove ContactPerson link
|
||||
from sqlalchemy import delete as sa_delete
|
||||
|
||||
from app.models.contact import ContactPerson
|
||||
await db.execute(
|
||||
sa_delete(ContactPerson).where(
|
||||
|
||||
@@ -29,7 +29,7 @@ async def list_folder_permissions(
|
||||
try:
|
||||
items = await contact_folder_permission_service.list_permissions(db, tenant_id, folder_id)
|
||||
except ValueError as e:
|
||||
raise HTTPException(status_code=404, detail=str(e))
|
||||
raise HTTPException(status_code=404, detail=str(e)) from e
|
||||
return {"items": items, "total": len(items)}
|
||||
|
||||
|
||||
@@ -53,7 +53,7 @@ async def create_folder_permission(
|
||||
body.inherit_to_subfolders,
|
||||
)
|
||||
except ValueError as e:
|
||||
raise HTTPException(status_code=400, detail=str(e))
|
||||
raise HTTPException(status_code=400, detail=str(e)) from e
|
||||
|
||||
|
||||
@router.put("/{folder_id}/permissions/{permission_id}")
|
||||
@@ -75,7 +75,7 @@ async def update_folder_permission(
|
||||
body.inherit_to_subfolders,
|
||||
)
|
||||
except ValueError as e:
|
||||
raise HTTPException(status_code=404, detail=str(e))
|
||||
raise HTTPException(status_code=404, detail=str(e)) from e
|
||||
|
||||
|
||||
@router.delete("/{folder_id}/permissions/{permission_id}", status_code=status.HTTP_204_NO_CONTENT)
|
||||
@@ -90,7 +90,7 @@ async def delete_folder_permission(
|
||||
try:
|
||||
await contact_folder_permission_service.delete_permission(db, tenant_id, permission_id)
|
||||
except ValueError as e:
|
||||
raise HTTPException(status_code=404, detail=str(e))
|
||||
raise HTTPException(status_code=404, detail=str(e)) from e
|
||||
|
||||
|
||||
@router.get("/{folder_id}/access")
|
||||
|
||||
@@ -61,7 +61,7 @@ async def update_folder(
|
||||
db, tenant_id, user_id, folder_id, data
|
||||
)
|
||||
except ValueError as e:
|
||||
raise HTTPException(status_code=404, detail=str(e))
|
||||
raise HTTPException(status_code=404, detail=str(e)) from e
|
||||
|
||||
|
||||
@router.delete("/{folder_id}", status_code=status.HTTP_204_NO_CONTENT)
|
||||
@@ -76,7 +76,7 @@ async def delete_folder(
|
||||
try:
|
||||
await contact_folder_service.delete_folder(db, tenant_id, user_id, folder_id)
|
||||
except ValueError as e:
|
||||
raise HTTPException(status_code=404, detail=str(e))
|
||||
raise HTTPException(status_code=404, detail=str(e)) from e
|
||||
|
||||
|
||||
@router.put("/{folder_id}/reorder")
|
||||
@@ -107,4 +107,4 @@ async def move_contact(
|
||||
db, tenant_id, contact_id, body.folder_id
|
||||
)
|
||||
except ValueError as e:
|
||||
raise HTTPException(status_code=404, detail=str(e))
|
||||
raise HTTPException(status_code=404, detail=str(e)) from e
|
||||
|
||||
+12
-14
@@ -10,29 +10,27 @@ import io
|
||||
import uuid
|
||||
from typing import Any
|
||||
|
||||
import redis.asyncio as aioredis
|
||||
from fastapi import APIRouter, Depends, HTTPException, Query, Response, status
|
||||
from fastapi.responses import StreamingResponse
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
import redis.asyncio as aioredis
|
||||
|
||||
from app.commands.contact_commands import (
|
||||
CreateContactCommand,
|
||||
UpdateContactCommand,
|
||||
DeleteContactCommand,
|
||||
MergeContactsCommand,
|
||||
UpdateContactCommand,
|
||||
)
|
||||
from app.core.db import get_db
|
||||
from app.core.visibility import check_single_entity_access
|
||||
from app.deps import get_current_user, get_redis_dep, require_permission
|
||||
from app.deps import get_redis_dep, require_permission
|
||||
from app.schemas.contact import (
|
||||
ContactCreate,
|
||||
ContactUpdate,
|
||||
ContactPersonCreate,
|
||||
ContactPersonUpdate,
|
||||
ContactUpdate,
|
||||
)
|
||||
from app.services import contact_service
|
||||
from app.services import dedup_service
|
||||
from app.services import contact_service, dedup_service
|
||||
from app.services.export_service import export_service
|
||||
|
||||
router = APIRouter(prefix="/api/v1/contacts", tags=["contacts"])
|
||||
@@ -40,7 +38,7 @@ router = APIRouter(prefix="/api/v1/contacts", tags=["contacts"])
|
||||
|
||||
# ── Deduplication / Merge (Task 5.23) ──────────────────────────────────────────
|
||||
|
||||
from pydantic import BaseModel, Field
|
||||
from pydantic import BaseModel, Field # noqa: E402
|
||||
|
||||
|
||||
class DuplicateCheckRequest(BaseModel):
|
||||
@@ -153,9 +151,9 @@ async def get_contact(
|
||||
try:
|
||||
return await contact_service.get_contact(db, tenant_id, contact_id, user_id=user_id, is_system_admin=is_admin)
|
||||
except ValueError as e:
|
||||
raise HTTPException(status_code=404, detail=str(e))
|
||||
raise HTTPException(status_code=404, detail=str(e)) from e
|
||||
except PermissionError as e:
|
||||
raise HTTPException(status_code=403, detail=str(e))
|
||||
raise HTTPException(status_code=403, detail=str(e)) from e
|
||||
|
||||
|
||||
@router.put("/{contact_id}")
|
||||
@@ -223,7 +221,7 @@ async def create_contact_person(
|
||||
try:
|
||||
return await contact_service.create_contact_person(db, tenant_id, user_id, contact_id, data)
|
||||
except ValueError as e:
|
||||
raise HTTPException(status_code=404, detail=str(e))
|
||||
raise HTTPException(status_code=404, detail=str(e)) from e
|
||||
|
||||
|
||||
@router.put("/{contact_id}/persons/{person_id}")
|
||||
@@ -241,7 +239,7 @@ async def update_contact_person(
|
||||
try:
|
||||
return await contact_service.update_contact_person(db, tenant_id, user_id, contact_id, person_id, data)
|
||||
except ValueError as e:
|
||||
raise HTTPException(status_code=404, detail=str(e))
|
||||
raise HTTPException(status_code=404, detail=str(e)) from e
|
||||
|
||||
|
||||
@router.delete("/{contact_id}/persons/{person_id}", status_code=status.HTTP_204_NO_CONTENT)
|
||||
@@ -256,7 +254,7 @@ async def delete_contact_person(
|
||||
try:
|
||||
await contact_service.delete_contact_person(db, tenant_id, contact_id, person_id)
|
||||
except ValueError as e:
|
||||
raise HTTPException(status_code=404, detail=str(e))
|
||||
raise HTTPException(status_code=404, detail=str(e)) from e
|
||||
|
||||
|
||||
# ── Deduplication / Merge endpoints (Task 5.23) ───────────────────────────────
|
||||
@@ -292,7 +290,7 @@ async def merge_duplicate_contacts(
|
||||
source_uuid = uuid.UUID(body.source_contact_id)
|
||||
target_uuid = uuid.UUID(body.target_contact_id)
|
||||
except (ValueError, TypeError):
|
||||
raise HTTPException(status_code=400, detail="Invalid contact ID")
|
||||
raise HTTPException(status_code=400, detail="Invalid contact ID") from None
|
||||
|
||||
source_access = await check_single_entity_access(
|
||||
db, "contact", source_uuid, user_id, tenant_id,
|
||||
|
||||
@@ -5,7 +5,7 @@ from __future__ import annotations
|
||||
import uuid
|
||||
from typing import Any
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException, Body
|
||||
from fastapi import APIRouter, Depends, HTTPException
|
||||
from pydantic import BaseModel
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
@@ -39,7 +39,10 @@ async def _collect_custom_field_definitions(
|
||||
|
||||
# 1. Collect from active plugin manifests
|
||||
registry = get_registry()
|
||||
for plugin in registry._plugins.values():
|
||||
for name in registry.list_discovered():
|
||||
plugin = registry.get_plugin(name)
|
||||
if plugin is None:
|
||||
continue
|
||||
manifest = plugin.manifest
|
||||
for cf in manifest.custom_fields:
|
||||
if cf.entity != entity:
|
||||
|
||||
@@ -4,7 +4,7 @@ from __future__ import annotations
|
||||
|
||||
import uuid
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException
|
||||
from fastapi import APIRouter, Depends
|
||||
from sqlalchemy import func, select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
|
||||
@@ -3,13 +3,12 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import uuid
|
||||
from datetime import datetime
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException, Query, status
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.core.db import get_db
|
||||
from app.deps import get_current_user, require_permission
|
||||
from app.deps import require_permission
|
||||
from app.schemas.delegation import DelegationCreate, DelegationUpdate
|
||||
from app.services import delegation_service
|
||||
|
||||
@@ -49,7 +48,7 @@ async def create_delegation(
|
||||
scope=body.scope,
|
||||
)
|
||||
except ValueError as e:
|
||||
raise HTTPException(status_code=400, detail=str(e))
|
||||
raise HTTPException(status_code=400, detail=str(e)) from e
|
||||
|
||||
|
||||
@router.put("/{delegation_id}")
|
||||
@@ -72,7 +71,7 @@ async def update_delegation(
|
||||
active=body.active,
|
||||
)
|
||||
except ValueError as e:
|
||||
raise HTTPException(status_code=404, detail=str(e))
|
||||
raise HTTPException(status_code=404, detail=str(e)) from e
|
||||
|
||||
|
||||
@router.delete("/{delegation_id}", status_code=status.HTTP_204_NO_CONTENT)
|
||||
@@ -86,7 +85,7 @@ async def delete_delegation(
|
||||
try:
|
||||
await delegation_service.delete_delegation(db, tenant_id, delegation_id)
|
||||
except ValueError as e:
|
||||
raise HTTPException(status_code=404, detail=str(e))
|
||||
raise HTTPException(status_code=404, detail=str(e)) from e
|
||||
|
||||
|
||||
@router.get("/active")
|
||||
|
||||
@@ -4,7 +4,7 @@ from __future__ import annotations
|
||||
|
||||
import uuid
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException, Query, status
|
||||
from fastapi import APIRouter, Depends, HTTPException, Query
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.core.db import get_db
|
||||
|
||||
@@ -3,9 +3,8 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import uuid
|
||||
from datetime import datetime
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException, Query, status
|
||||
from fastapi import APIRouter, Depends, HTTPException, status
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.core.db import get_db
|
||||
@@ -15,7 +14,7 @@ from app.schemas.entity_permission import (
|
||||
EntityPermissionCreate,
|
||||
EntityPermissionUpdate,
|
||||
)
|
||||
from app.services import entity_permission_service, bulk_permission_service
|
||||
from app.services import bulk_permission_service, entity_permission_service
|
||||
|
||||
router = APIRouter(prefix="/api/v1/permissions", tags=["entity-permissions"])
|
||||
|
||||
@@ -38,7 +37,7 @@ async def list_entity_permissions(
|
||||
db, tenant_id, entity_type, entity_id
|
||||
)
|
||||
except ValueError as e:
|
||||
raise HTTPException(status_code=400, detail=str(e))
|
||||
raise HTTPException(status_code=400, detail=str(e)) from e
|
||||
return {"items": items, "total": len(items)}
|
||||
|
||||
|
||||
@@ -83,7 +82,7 @@ async def create_entity_permission(
|
||||
)
|
||||
return result
|
||||
except ValueError as e:
|
||||
raise HTTPException(status_code=400, detail=str(e))
|
||||
raise HTTPException(status_code=400, detail=str(e)) from e
|
||||
|
||||
|
||||
async def _check_entity_ownership(
|
||||
@@ -101,9 +100,10 @@ async def _check_entity_ownership(
|
||||
if is_system_admin:
|
||||
return
|
||||
|
||||
from app.models.entity_permission import ENTITY_MODELS
|
||||
from sqlalchemy import select
|
||||
|
||||
from app.models.entity_permission import ENTITY_MODELS
|
||||
|
||||
model_info = ENTITY_MODELS.get(entity_type)
|
||||
if model_info is None:
|
||||
raise HTTPException(
|
||||
@@ -171,7 +171,7 @@ async def update_entity_permission(
|
||||
body.expires_at,
|
||||
)
|
||||
except ValueError as e:
|
||||
raise HTTPException(status_code=404, detail=str(e))
|
||||
raise HTTPException(status_code=404, detail=str(e)) from e
|
||||
|
||||
|
||||
@router.delete("/{entity_type}/{entity_id}/{permission_id}", status_code=status.HTTP_204_NO_CONTENT)
|
||||
@@ -193,7 +193,7 @@ async def delete_entity_permission(
|
||||
try:
|
||||
await entity_permission_service.delete_permission(db, tenant_id, permission_id)
|
||||
except ValueError as e:
|
||||
raise HTTPException(status_code=404, detail=str(e))
|
||||
raise HTTPException(status_code=404, detail=str(e)) from e
|
||||
|
||||
|
||||
@router.get("/{entity_type}/{entity_id}/access")
|
||||
@@ -277,7 +277,7 @@ async def bulk_share_permissions(
|
||||
)
|
||||
return result
|
||||
except (ValueError, KeyError) as e:
|
||||
raise HTTPException(status_code=400, detail=str(e))
|
||||
raise HTTPException(status_code=400, detail=str(e)) from e
|
||||
|
||||
|
||||
@router.post("/bulk/unshare", status_code=status.HTTP_200_OK)
|
||||
@@ -299,7 +299,7 @@ async def bulk_unshare_permissions(
|
||||
)
|
||||
return result
|
||||
except (ValueError, KeyError) as e:
|
||||
raise HTTPException(status_code=400, detail=str(e))
|
||||
raise HTTPException(status_code=400, detail=str(e)) from e
|
||||
|
||||
|
||||
@router.get("/analytics")
|
||||
@@ -313,4 +313,4 @@ async def get_permission_analytics(
|
||||
result = await entity_permission_service.get_permission_analytics(db, tenant_id)
|
||||
return result
|
||||
except ValueError as e:
|
||||
raise HTTPException(status_code=400, detail=str(e))
|
||||
raise HTTPException(status_code=400, detail=str(e)) from e
|
||||
|
||||
@@ -79,7 +79,7 @@ class ErrorReport(BaseModel):
|
||||
stack: str | None = Field(None, max_length=10000)
|
||||
context: dict[str, Any] | None = None
|
||||
url: str | None = Field(None, max_length=500)
|
||||
userAgent: str | None = Field(None, max_length=500)
|
||||
user_agent: str | None = Field(None, max_length=500)
|
||||
|
||||
|
||||
@router.post("", status_code=status.HTTP_204_NO_CONTENT)
|
||||
@@ -121,8 +121,10 @@ async def report_error(error: ErrorReport, request: Request) -> Response:
|
||||
|
||||
# If forgejo_error_reporter plugin is active, forward sanitized error
|
||||
try:
|
||||
from app.plugins.builtins.forgejo_error_reporter.service import report_error_to_forgejo
|
||||
entry = {
|
||||
from app.plugins.builtins.contracts import get_contract
|
||||
reporter_contract = get_contract("forgejo_error_reporter")
|
||||
if reporter_contract is not None:
|
||||
entry = {
|
||||
"message": error.message,
|
||||
"stack": error.stack,
|
||||
"url": error.url,
|
||||
@@ -130,7 +132,7 @@ async def report_error(error: ErrorReport, request: Request) -> Response:
|
||||
"timestamp": error.timestamp,
|
||||
"context": sanitized_context,
|
||||
}
|
||||
await report_error_to_forgejo(entry)
|
||||
await reporter_contract.report_error_to_forgejo(entry)
|
||||
except Exception:
|
||||
pass # Plugin not active or error in reporting
|
||||
|
||||
|
||||
@@ -12,18 +12,16 @@ from __future__ import annotations
|
||||
import hashlib
|
||||
import secrets
|
||||
import uuid
|
||||
from datetime import UTC, datetime, timedelta
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException, Request, status
|
||||
from sqlalchemy import select, update
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.config import get_settings
|
||||
from app.core.auth import get_redis, hash_password
|
||||
from app.core.db import get_db
|
||||
from app.deps import get_current_user, require_admin
|
||||
from app.deps import require_admin
|
||||
from app.models.user import User, UserTenant
|
||||
from app.models.tenant import Tenant
|
||||
|
||||
router = APIRouter(prefix="/api/v1/guests", tags=["guests"])
|
||||
settings = get_settings()
|
||||
@@ -47,8 +45,6 @@ async def invite_guest(
|
||||
"""
|
||||
email = body.get("email", "")
|
||||
name = body.get("name", "")
|
||||
expires_in_hours = body.get("expires_in_hours", 72)
|
||||
|
||||
if not email or not name:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
@@ -56,7 +52,6 @@ async def invite_guest(
|
||||
)
|
||||
|
||||
tenant_id = uuid.UUID(current_user["tenant_id"])
|
||||
user_id = uuid.UUID(current_user["user_id"])
|
||||
|
||||
# Check if user already exists by email
|
||||
user_q = await db.execute(
|
||||
@@ -172,7 +167,7 @@ async def delete_guest(
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail={"detail": "Invalid guest ID", "code": "invalid_id"},
|
||||
)
|
||||
) from None
|
||||
|
||||
# Find the user_tenants entry for this guest
|
||||
ut_q = await db.execute(
|
||||
|
||||
+16
-18
@@ -22,9 +22,7 @@ from app.core.db import get_db
|
||||
from app.deps import require_permission
|
||||
from app.services import import_export_service
|
||||
from app.services.import_export_helpers import (
|
||||
detect_format,
|
||||
parse_file,
|
||||
write_csv,
|
||||
write_xlsx,
|
||||
)
|
||||
|
||||
@@ -37,7 +35,7 @@ _BACKGROUND_THRESHOLD = 1000
|
||||
@router.post("/import")
|
||||
async def import_csv(
|
||||
file: UploadFile = File(...),
|
||||
entity_type: str = Form("companies"),
|
||||
entity_type: str = Form(...),
|
||||
field_mapping: str = Form(None),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
current_user: dict = Depends(require_permission("import_export:write")),
|
||||
@@ -60,13 +58,13 @@ async def import_csv(
|
||||
try:
|
||||
mapping = json.loads(field_mapping)
|
||||
except json.JSONDecodeError:
|
||||
raise HTTPException(status_code=400, detail="Invalid field_mapping JSON")
|
||||
raise HTTPException(status_code=400, detail="Invalid field_mapping JSON") from None
|
||||
|
||||
# Check row count for background processing
|
||||
try:
|
||||
rows = parse_file(file.filename or "upload.csv", content)
|
||||
except Exception as exc:
|
||||
raise HTTPException(status_code=400, detail=f"Failed to parse file: {exc}")
|
||||
raise HTTPException(status_code=400, detail=f"Failed to parse file: {exc}") from exc
|
||||
|
||||
if len(rows) > _BACKGROUND_THRESHOLD:
|
||||
# Enqueue as background job
|
||||
@@ -87,7 +85,7 @@ async def import_csv(
|
||||
"total": len(rows),
|
||||
}
|
||||
except Exception as exc:
|
||||
raise HTTPException(status_code=500, detail=f"Failed to enqueue import job: {exc}")
|
||||
raise HTTPException(status_code=500, detail=f"Failed to enqueue import job: {exc}") from exc
|
||||
|
||||
# Synchronous import for smaller files
|
||||
result = await import_export_service.import_csv(
|
||||
@@ -105,7 +103,7 @@ async def import_csv(
|
||||
@router.post("/import/preview")
|
||||
async def import_csv_preview(
|
||||
file: UploadFile = File(...),
|
||||
entity_type: str = Form("companies"),
|
||||
entity_type: str = Form(...),
|
||||
current_user: dict = Depends(require_permission("import_export:read")),
|
||||
):
|
||||
"""Preview CSV import (dry-run — no DB changes).
|
||||
@@ -121,7 +119,7 @@ async def import_csv_preview(
|
||||
entity_type=entity_type,
|
||||
)
|
||||
except Exception as exc:
|
||||
raise HTTPException(status_code=400, detail=f"Failed to parse file: {exc}")
|
||||
raise HTTPException(status_code=400, detail=f"Failed to parse file: {exc}") from exc
|
||||
|
||||
return result
|
||||
|
||||
@@ -129,7 +127,7 @@ async def import_csv_preview(
|
||||
@router.post("/import/validate")
|
||||
async def import_validate(
|
||||
file: UploadFile = File(...),
|
||||
entity_type: str = Form("companies"),
|
||||
entity_type: str = Form(...),
|
||||
field_mapping: str = Form(None),
|
||||
current_user: dict = Depends(require_permission("import_export:read")),
|
||||
):
|
||||
@@ -141,7 +139,7 @@ async def import_validate(
|
||||
try:
|
||||
mapping = json.loads(field_mapping)
|
||||
except json.JSONDecodeError:
|
||||
raise HTTPException(status_code=400, detail="Invalid field_mapping JSON")
|
||||
raise HTTPException(status_code=400, detail="Invalid field_mapping JSON") from None
|
||||
|
||||
try:
|
||||
result = import_export_service.validate_import(
|
||||
@@ -151,7 +149,7 @@ async def import_validate(
|
||||
field_mapping=mapping,
|
||||
)
|
||||
except Exception as exc:
|
||||
raise HTTPException(status_code=400, detail=f"Failed to validate file: {exc}")
|
||||
raise HTTPException(status_code=400, detail=f"Failed to validate file: {exc}") from exc
|
||||
|
||||
return result
|
||||
|
||||
@@ -192,13 +190,13 @@ async def import_contacts_route(
|
||||
try:
|
||||
mapping = json.loads(field_mapping)
|
||||
except json.JSONDecodeError:
|
||||
raise HTTPException(status_code=400, detail="Invalid field_mapping JSON")
|
||||
raise HTTPException(status_code=400, detail="Invalid field_mapping JSON") from None
|
||||
|
||||
# Check row count for background processing
|
||||
try:
|
||||
rows = parse_file(file.filename or "upload.csv", content)
|
||||
except Exception as exc:
|
||||
raise HTTPException(status_code=400, detail=f"Failed to parse file: {exc}")
|
||||
raise HTTPException(status_code=400, detail=f"Failed to parse file: {exc}") from exc
|
||||
|
||||
if len(rows) > _BACKGROUND_THRESHOLD:
|
||||
from app.services.import_export_jobs import create_import_job
|
||||
@@ -218,7 +216,7 @@ async def import_contacts_route(
|
||||
"total": len(rows),
|
||||
}
|
||||
except Exception as exc:
|
||||
raise HTTPException(status_code=500, detail=f"Failed to enqueue import job: {exc}")
|
||||
raise HTTPException(status_code=500, detail=f"Failed to enqueue import job: {exc}") from exc
|
||||
|
||||
result = await import_export_service.import_contacts(
|
||||
db,
|
||||
@@ -253,13 +251,13 @@ async def import_companies_route(
|
||||
try:
|
||||
mapping = json.loads(field_mapping)
|
||||
except json.JSONDecodeError:
|
||||
raise HTTPException(status_code=400, detail="Invalid field_mapping JSON")
|
||||
raise HTTPException(status_code=400, detail="Invalid field_mapping JSON") from None
|
||||
|
||||
# Check row count for background processing
|
||||
try:
|
||||
rows = parse_file(file.filename or "upload.csv", content)
|
||||
except Exception as exc:
|
||||
raise HTTPException(status_code=400, detail=f"Failed to parse file: {exc}")
|
||||
raise HTTPException(status_code=400, detail=f"Failed to parse file: {exc}") from exc
|
||||
|
||||
if len(rows) > _BACKGROUND_THRESHOLD:
|
||||
from app.services.import_export_jobs import create_import_job
|
||||
@@ -279,7 +277,7 @@ async def import_companies_route(
|
||||
"total": len(rows),
|
||||
}
|
||||
except Exception as exc:
|
||||
raise HTTPException(status_code=500, detail=f"Failed to enqueue import job: {exc}")
|
||||
raise HTTPException(status_code=500, detail=f"Failed to enqueue import job: {exc}") from exc
|
||||
|
||||
result = await import_export_service.import_companies(
|
||||
db,
|
||||
@@ -329,7 +327,7 @@ async def export_data(
|
||||
if not all_rows:
|
||||
raise HTTPException(status_code=404, detail="No data to export")
|
||||
headers = all_rows[0]
|
||||
data_rows = [dict(zip(headers, row)) for row in all_rows[1:]]
|
||||
data_rows = [dict(zip(headers, row, strict=False)) for row in all_rows[1:]]
|
||||
|
||||
if format == "xlsx":
|
||||
try:
|
||||
|
||||
@@ -20,11 +20,10 @@ from app.core.notifications import (
|
||||
)
|
||||
from app.deps import require_permission
|
||||
from app.models.notification import (
|
||||
Notification,
|
||||
NotificationPreference,
|
||||
NotificationType,
|
||||
)
|
||||
from app.schemas.common import NotificationPreferenceUpdate, UnreadCountResponse
|
||||
from app.schemas.common import NotificationPreferenceUpdate
|
||||
from app.services import entity_permission_service
|
||||
|
||||
router = APIRouter(prefix="/api/v1/notifications", tags=["notifications"])
|
||||
|
||||
@@ -91,7 +91,7 @@ async def outbox_replay_single(
|
||||
"detail": "Invalid event ID format",
|
||||
"code": "invalid_uuid",
|
||||
},
|
||||
)
|
||||
) from None
|
||||
|
||||
replayed = await replay_failed_event(db, eid)
|
||||
if not replayed:
|
||||
|
||||
@@ -40,7 +40,7 @@ async def transfer_ownership_endpoint(
|
||||
raise HTTPException(
|
||||
400,
|
||||
detail={"detail": "Invalid user_id format", "code": "invalid_id"},
|
||||
)
|
||||
) from None
|
||||
|
||||
tenant_id = uuid.UUID(current_user["tenant_id"])
|
||||
|
||||
|
||||
@@ -8,11 +8,11 @@ from fastapi import APIRouter, Depends, HTTPException, status
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.core.db import get_db
|
||||
from app.deps import get_current_user, require_permission
|
||||
from app.deps import require_permission
|
||||
from app.schemas.permission_template import (
|
||||
PermissionTemplateApply,
|
||||
PermissionTemplateCreate,
|
||||
PermissionTemplateUpdate,
|
||||
PermissionTemplateApply,
|
||||
)
|
||||
from app.services import permission_template_service
|
||||
|
||||
@@ -50,7 +50,7 @@ async def create_template(
|
||||
auto_share_with=body.auto_share_with,
|
||||
)
|
||||
except ValueError as e:
|
||||
raise HTTPException(status_code=400, detail=str(e))
|
||||
raise HTTPException(status_code=400, detail=str(e)) from e
|
||||
|
||||
|
||||
@router.put("/{template_id}")
|
||||
@@ -74,7 +74,7 @@ async def update_template(
|
||||
auto_share_with=body.auto_share_with,
|
||||
)
|
||||
except ValueError as e:
|
||||
raise HTTPException(status_code=404, detail=str(e))
|
||||
raise HTTPException(status_code=404, detail=str(e)) from e
|
||||
|
||||
|
||||
@router.delete("/{template_id}", status_code=status.HTTP_204_NO_CONTENT)
|
||||
@@ -88,7 +88,7 @@ async def delete_template(
|
||||
try:
|
||||
await permission_template_service.delete_template(db, tenant_id, template_id)
|
||||
except ValueError as e:
|
||||
raise HTTPException(status_code=404, detail=str(e))
|
||||
raise HTTPException(status_code=404, detail=str(e)) from e
|
||||
|
||||
|
||||
@router.post("/apply", status_code=status.HTTP_201_CREATED)
|
||||
@@ -111,4 +111,4 @@ async def apply_template(
|
||||
)
|
||||
return {"applied": result, "count": len(result)}
|
||||
except ValueError as e:
|
||||
raise HTTPException(status_code=400, detail=str(e))
|
||||
raise HTTPException(status_code=400, detail=str(e)) from e
|
||||
|
||||
@@ -10,10 +10,9 @@ from pydantic import BaseModel
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.core.db import get_db
|
||||
from app.deps import require_permission, require_admin
|
||||
from app.deps import require_admin, require_permission
|
||||
from app.plugins.migration_runner import MigrationValidationError
|
||||
from app.services.plugin_service import get_plugin_service
|
||||
from app.services.plugin_install_service import PluginInstallService
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
@@ -8,7 +8,7 @@ from fastapi import APIRouter, Depends, HTTPException, status
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.core.db import get_db
|
||||
from app.deps import get_current_user, require_permission
|
||||
from app.deps import require_permission
|
||||
from app.schemas.policy import PolicyCreate, PolicyUpdate
|
||||
from app.services import policy_service
|
||||
|
||||
@@ -26,7 +26,7 @@ async def list_policies(
|
||||
try:
|
||||
items = await policy_service.list_policies(db, tenant_id, entity_type)
|
||||
except ValueError as e:
|
||||
raise HTTPException(status_code=400, detail=str(e))
|
||||
raise HTTPException(status_code=400, detail=str(e)) from e
|
||||
return {"items": items, "total": len(items)}
|
||||
|
||||
|
||||
@@ -51,7 +51,7 @@ async def create_policy(
|
||||
priority=body.priority,
|
||||
)
|
||||
except ValueError as e:
|
||||
raise HTTPException(status_code=400, detail=str(e))
|
||||
raise HTTPException(status_code=400, detail=str(e)) from e
|
||||
|
||||
|
||||
@router.put("/{policy_id}")
|
||||
@@ -78,7 +78,7 @@ async def update_policy(
|
||||
enabled=body.enabled,
|
||||
)
|
||||
except ValueError as e:
|
||||
raise HTTPException(status_code=404, detail=str(e))
|
||||
raise HTTPException(status_code=404, detail=str(e)) from e
|
||||
|
||||
|
||||
@router.delete("/{policy_id}", status_code=status.HTTP_204_NO_CONTENT)
|
||||
@@ -92,4 +92,4 @@ async def delete_policy(
|
||||
try:
|
||||
await policy_service.delete_policy(db, tenant_id, policy_id)
|
||||
except ValueError as e:
|
||||
raise HTTPException(status_code=404, detail=str(e))
|
||||
raise HTTPException(status_code=404, detail=str(e)) from e
|
||||
|
||||
+1
-3
@@ -25,9 +25,7 @@ router = APIRouter(prefix="/api/v1/roles", tags=["roles"])
|
||||
|
||||
|
||||
SYSTEM_PERMISSIONS: list[dict[str, str]] = [
|
||||
{"key": "contacts:read", "label": "Contacts: Read", "category": "system"},
|
||||
{"key": "contacts:write", "label": "Contacts: Write", "category": "system"},
|
||||
{"key": "contacts:delete", "label": "Contacts: Delete", "category": "system"},
|
||||
# Plugin permissions are loaded dynamically from the permission registry.
|
||||
{"key": "users:read", "label": "Users: Read", "category": "system"},
|
||||
{"key": "users:write", "label": "Users: Write", "category": "system"},
|
||||
{"key": "users:delete", "label": "Users: Delete", "category": "system"},
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import uuid
|
||||
from datetime import UTC
|
||||
from typing import Any
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException, Query, Response, status
|
||||
@@ -16,13 +17,26 @@ from app.models.saved_filter import SavedFilter
|
||||
|
||||
router = APIRouter(prefix="/api/v1/saved-filters", tags=["saved-filters"])
|
||||
|
||||
VALID_ENTITY_TYPES = {"contacts", "mail", "calendar", "dms"}
|
||||
VALID_ENTITY_TYPES = None # Dynamic — validated against ENTITY_MODELS at runtime
|
||||
|
||||
|
||||
def _validate_entity_type(entity_type: str) -> None:
|
||||
"""Validate entity_type against ENTITY_MODELS. Raises HTTPException if invalid."""
|
||||
from app.services.entity_permission_service import ENTITY_MODELS
|
||||
if entity_type not in ENTITY_MODELS:
|
||||
from fastapi import HTTPException
|
||||
valid = sorted(ENTITY_MODELS.keys())
|
||||
raise HTTPException(400, detail={
|
||||
"detail": f"Invalid entity_type: {entity_type}",
|
||||
"code": "invalid_entity_type",
|
||||
"valid_types": valid,
|
||||
})
|
||||
|
||||
|
||||
class SavedFilterCreate(BaseModel):
|
||||
"""Schema for creating a saved filter."""
|
||||
name: str = Field(..., min_length=1, max_length=100)
|
||||
entity_type: str = Field(..., pattern="^(contacts|mail|calendar|dms)$")
|
||||
entity_type: str = Field(..., min_length=1, max_length=50)
|
||||
filter_criteria: dict[str, Any] = Field(default_factory=dict)
|
||||
|
||||
|
||||
@@ -44,16 +58,15 @@ def _filter_to_dict(f: SavedFilter) -> dict[str, Any]:
|
||||
}
|
||||
|
||||
|
||||
@router.get("", dependencies=[Depends(require_permission("contacts:read"))])
|
||||
@router.get("")
|
||||
async def list_saved_filters(
|
||||
entity_type: str | None = Query(None, pattern="^(contacts|mail|calendar|dms)$"),
|
||||
entity_type: str | None = Query(None),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
current_user: dict = Depends(get_current_user),
|
||||
):
|
||||
"""List saved filters for the current user, optionally filtered by entity_type."""
|
||||
tenant_id = uuid.UUID(current_user["tenant_id"])
|
||||
user_id = uuid.UUID(current_user["user_id"])
|
||||
is_admin = current_user.get("is_system_admin", False)
|
||||
|
||||
try:
|
||||
query = select(SavedFilter).where(
|
||||
@@ -79,9 +92,9 @@ async def create_saved_filter(
|
||||
current_user: dict = Depends(get_current_user),
|
||||
):
|
||||
"""Create a new saved filter for the current user."""
|
||||
_validate_entity_type(body.entity_type)
|
||||
tenant_id = uuid.UUID(current_user["tenant_id"])
|
||||
user_id = uuid.UUID(current_user["user_id"])
|
||||
is_admin = current_user.get("is_system_admin", False)
|
||||
|
||||
try:
|
||||
# Check uniqueness within user+entity
|
||||
@@ -120,7 +133,6 @@ async def delete_saved_filter(
|
||||
"""Delete a saved filter (soft-delete)."""
|
||||
tenant_id = uuid.UUID(current_user["tenant_id"])
|
||||
user_id = uuid.UUID(current_user["user_id"])
|
||||
is_admin = current_user.get("is_system_admin", False)
|
||||
|
||||
try:
|
||||
try:
|
||||
@@ -140,8 +152,8 @@ async def delete_saved_filter(
|
||||
if saved is None:
|
||||
raise HTTPException(404, detail={"detail": "Saved filter not found", "code": "not_found"})
|
||||
|
||||
from datetime import datetime, timezone
|
||||
saved.deleted_at = datetime.now(timezone.utc)
|
||||
from datetime import datetime
|
||||
saved.deleted_at = datetime.now(UTC)
|
||||
await db.flush()
|
||||
return Response(status_code=status.HTTP_204_NO_CONTENT)
|
||||
except PermissionError as e:
|
||||
|
||||
+21
-10
@@ -3,6 +3,7 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import uuid
|
||||
from datetime import UTC
|
||||
from typing import Any
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException, Query, Response, status
|
||||
@@ -16,13 +17,26 @@ from app.models.saved_view import SavedView
|
||||
|
||||
router = APIRouter(prefix="/api/v1/saved-views", tags=["saved-views"])
|
||||
|
||||
VALID_ENTITY_TYPES = {"contacts", "mail", "calendar", "dms"}
|
||||
VALID_ENTITY_TYPES = None # Dynamic — validated against ENTITY_MODELS at runtime
|
||||
|
||||
|
||||
def _validate_entity_type(entity_type: str) -> None:
|
||||
"""Validate entity_type against ENTITY_MODELS. Raises HTTPException if invalid."""
|
||||
from app.services.entity_permission_service import ENTITY_MODELS
|
||||
if entity_type not in ENTITY_MODELS:
|
||||
from fastapi import HTTPException
|
||||
valid = sorted(ENTITY_MODELS.keys())
|
||||
raise HTTPException(400, detail={
|
||||
"detail": f"Invalid entity_type: {entity_type}",
|
||||
"code": "invalid_entity_type",
|
||||
"valid_types": valid,
|
||||
})
|
||||
|
||||
|
||||
class SavedViewCreate(BaseModel):
|
||||
"""Schema for creating a saved view."""
|
||||
name: str = Field(..., min_length=1, max_length=100)
|
||||
entity_type: str = Field(..., pattern="^(contacts|mail|calendar|dms)$")
|
||||
entity_type: str = Field(..., min_length=1, max_length=50)
|
||||
view_config: dict[str, Any] = Field(default_factory=dict)
|
||||
|
||||
|
||||
@@ -44,16 +58,15 @@ def _view_to_dict(v: SavedView) -> dict[str, Any]:
|
||||
}
|
||||
|
||||
|
||||
@router.get("", dependencies=[Depends(require_permission("contacts:read"))])
|
||||
@router.get("")
|
||||
async def list_saved_views(
|
||||
entity_type: str | None = Query(None, pattern="^(contacts|mail|calendar|dms)$"),
|
||||
entity_type: str | None = Query(None),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
current_user: dict = Depends(get_current_user),
|
||||
):
|
||||
"""List saved views for the current user, optionally filtered by entity_type."""
|
||||
tenant_id = uuid.UUID(current_user["tenant_id"])
|
||||
user_id = uuid.UUID(current_user["user_id"])
|
||||
is_admin = current_user.get("is_system_admin", False)
|
||||
|
||||
try:
|
||||
query = select(SavedView).where(
|
||||
@@ -79,9 +92,9 @@ async def create_saved_view(
|
||||
current_user: dict = Depends(get_current_user),
|
||||
):
|
||||
"""Create a new saved view for the current user."""
|
||||
_validate_entity_type(body.entity_type)
|
||||
tenant_id = uuid.UUID(current_user["tenant_id"])
|
||||
user_id = uuid.UUID(current_user["user_id"])
|
||||
is_admin = current_user.get("is_system_admin", False)
|
||||
|
||||
try:
|
||||
# Check uniqueness within user+entity
|
||||
@@ -121,7 +134,6 @@ async def update_saved_view(
|
||||
"""Update a saved view."""
|
||||
tenant_id = uuid.UUID(current_user["tenant_id"])
|
||||
user_id = uuid.UUID(current_user["user_id"])
|
||||
is_admin = current_user.get("is_system_admin", False)
|
||||
|
||||
try:
|
||||
try:
|
||||
@@ -160,7 +172,6 @@ async def delete_saved_view(
|
||||
"""Delete a saved view (soft-delete)."""
|
||||
tenant_id = uuid.UUID(current_user["tenant_id"])
|
||||
user_id = uuid.UUID(current_user["user_id"])
|
||||
is_admin = current_user.get("is_system_admin", False)
|
||||
|
||||
try:
|
||||
try:
|
||||
@@ -180,8 +191,8 @@ async def delete_saved_view(
|
||||
if saved is None:
|
||||
raise HTTPException(404, detail={"detail": "Saved view not found", "code": "not_found"})
|
||||
|
||||
from datetime import datetime, timezone
|
||||
saved.deleted_at = datetime.now(timezone.utc)
|
||||
from datetime import datetime
|
||||
saved.deleted_at = datetime.now(UTC)
|
||||
await db.flush()
|
||||
return Response(status_code=status.HTTP_204_NO_CONTENT)
|
||||
except PermissionError as e:
|
||||
|
||||
@@ -4,12 +4,12 @@ from __future__ import annotations
|
||||
|
||||
import uuid
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException, status
|
||||
from fastapi import APIRouter, Depends
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.core.db import get_db
|
||||
from app.deps import require_permission
|
||||
from app.schemas.system_settings import SystemSettingsUpsert, SystemSettingsResponse
|
||||
from app.schemas.system_settings import SystemSettingsResponse, SystemSettingsUpsert
|
||||
from app.services import system_settings_service
|
||||
|
||||
router = APIRouter(prefix="/api/v1/system-settings", tags=["system-settings"])
|
||||
|
||||
@@ -7,7 +7,7 @@ from typing import Any
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException, status
|
||||
from pydantic import BaseModel, Field
|
||||
from sqlalchemy import delete, select
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.core.db import get_db
|
||||
|
||||
+6
-6
@@ -6,19 +6,19 @@ import uuid
|
||||
from typing import Any
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException, Query, Response, status
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.core.audit import log_audit
|
||||
from app.core.auth import get_redis, invalidate_all_user_sessions
|
||||
from app.core.db import get_db
|
||||
from app.core.notifications import create_notification
|
||||
from app.core.notifications import post_system_message
|
||||
from app.core.permissions import invalidate_permission_cache
|
||||
from app.deps import get_current_user, require_permission
|
||||
from app.models.user import User, UserTenant
|
||||
from app.schemas.user import UserCreate, UserUpdate, UserResponse, PaginatedUsers
|
||||
from app.services.user_service import user_service, _UNSET
|
||||
from app.models.user import User
|
||||
from app.schemas.user import PaginatedUsers, UserCreate, UserResponse, UserUpdate
|
||||
from app.services.owner_transfer_service import transfer_ownership
|
||||
from app.services.user_service import _UNSET, user_service
|
||||
|
||||
router = APIRouter(prefix="/api/v1/users", tags=["users"])
|
||||
|
||||
@@ -103,7 +103,7 @@ async def create_user(
|
||||
)
|
||||
|
||||
# Notification
|
||||
await create_notification(
|
||||
await post_system_message(
|
||||
db,
|
||||
tenant_id,
|
||||
user.id,
|
||||
|
||||
@@ -23,7 +23,7 @@ router = APIRouter(prefix="/api/v1/webhooks", tags=["webhooks"])
|
||||
@router.get(
|
||||
"",
|
||||
response_model=list[WebhookResponse],
|
||||
dependencies=[Depends(require_permission("automation:read"))],
|
||||
dependencies=[Depends(require_permission("workflows:read"))],
|
||||
)
|
||||
async def list_webhooks(
|
||||
event: str | None = Query(None, description="Filter by event name"),
|
||||
@@ -46,7 +46,7 @@ async def list_webhooks(
|
||||
"",
|
||||
response_model=WebhookResponse,
|
||||
status_code=201,
|
||||
dependencies=[Depends(require_permission("automation:write"))],
|
||||
dependencies=[Depends(require_permission("workflows:write"))],
|
||||
)
|
||||
async def create_webhook(
|
||||
body: WebhookCreate,
|
||||
|
||||
+17
-17
@@ -9,7 +9,7 @@ from __future__ import annotations
|
||||
import uuid
|
||||
from typing import Any
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException, Header, status
|
||||
from fastapi import APIRouter, Depends, Header, HTTPException, status
|
||||
from pydantic import BaseModel
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
@@ -79,20 +79,20 @@ async def workspace_context(
|
||||
x_workspace_id: str | None = Header(None, alias="X-Workspace-ID"),
|
||||
):
|
||||
"""Get workspace context for the current user (modules, widgets).
|
||||
|
||||
|
||||
Uses X-Workspace-ID header for tab-local workspace selection.
|
||||
Falls back to user's default workspace if no header.
|
||||
"""
|
||||
tenant_id = uuid.UUID(current_user["tenant_id"])
|
||||
user_id = uuid.UUID(current_user["user_id"])
|
||||
is_admin = current_user.get("is_system_admin", False)
|
||||
|
||||
|
||||
workspace_id = None
|
||||
if x_workspace_id:
|
||||
try:
|
||||
workspace_id = uuid.UUID(x_workspace_id)
|
||||
except ValueError:
|
||||
raise HTTPException(400, detail={"detail": "Invalid X-Workspace-ID", "code": "invalid_id"})
|
||||
raise HTTPException(400, detail={"detail": "Invalid X-Workspace-ID", "code": "invalid_id"}) from None
|
||||
else:
|
||||
# Find user's default workspace
|
||||
my = await workspace_service.get_my_workspaces(db, tenant_id, user_id)
|
||||
@@ -102,10 +102,10 @@ async def workspace_context(
|
||||
break
|
||||
if workspace_id is None and my["items"]:
|
||||
workspace_id = uuid.UUID(my["items"][0]["id"])
|
||||
|
||||
|
||||
if workspace_id is None:
|
||||
return {"workspace_id": None, "modules": [], "widgets": []}
|
||||
|
||||
|
||||
ctx = await workspace_service.get_workspace_context(db, tenant_id, user_id, workspace_id)
|
||||
if ctx is None and not is_admin:
|
||||
# User not assigned — return empty context
|
||||
@@ -151,7 +151,7 @@ async def get_workspace(
|
||||
try:
|
||||
wid = uuid.UUID(workspace_id)
|
||||
except ValueError:
|
||||
raise HTTPException(400, detail={"detail": "Invalid workspace_id", "code": "invalid_id"})
|
||||
raise HTTPException(400, detail={"detail": "Invalid workspace_id", "code": "invalid_id"}) from None
|
||||
result = await workspace_service.get_workspace(db, tenant_id, wid)
|
||||
if result is None:
|
||||
raise HTTPException(404, detail={"detail": "Workspace not found", "code": "not_found"})
|
||||
@@ -170,7 +170,7 @@ async def update_workspace(
|
||||
try:
|
||||
wid = uuid.UUID(workspace_id)
|
||||
except ValueError:
|
||||
raise HTTPException(400, detail={"detail": "Invalid workspace_id", "code": "invalid_id"})
|
||||
raise HTTPException(400, detail={"detail": "Invalid workspace_id", "code": "invalid_id"}) from None
|
||||
result = await workspace_service.update_workspace(
|
||||
db, tenant_id, wid, body.name, body.icon, body.description, body.is_default, body.is_active
|
||||
)
|
||||
@@ -190,7 +190,7 @@ async def delete_workspace(
|
||||
try:
|
||||
wid = uuid.UUID(workspace_id)
|
||||
except ValueError:
|
||||
raise HTTPException(400, detail={"detail": "Invalid workspace_id", "code": "invalid_id"})
|
||||
raise HTTPException(400, detail={"detail": "Invalid workspace_id", "code": "invalid_id"}) from None
|
||||
deleted = await workspace_service.delete_workspace(db, tenant_id, wid)
|
||||
if not deleted:
|
||||
raise HTTPException(404, detail={"detail": "Workspace not found", "code": "not_found"})
|
||||
@@ -208,7 +208,7 @@ async def set_modules(
|
||||
try:
|
||||
wid = uuid.UUID(workspace_id)
|
||||
except ValueError:
|
||||
raise HTTPException(400, detail={"detail": "Invalid workspace_id", "code": "invalid_id"})
|
||||
raise HTTPException(400, detail={"detail": "Invalid workspace_id", "code": "invalid_id"}) from None
|
||||
modules = [{"module_key": m.module_key, "is_visible": m.is_visible, "menu_order": m.menu_order, "config": m.config} for m in body.modules]
|
||||
return await workspace_service.set_workspace_modules(db, tenant_id, wid, modules)
|
||||
|
||||
@@ -227,7 +227,7 @@ async def assign_user(
|
||||
wid = uuid.UUID(workspace_id)
|
||||
target_uid = uuid.UUID(body.user_id)
|
||||
except ValueError:
|
||||
raise HTTPException(400, detail={"detail": "Invalid ID", "code": "invalid_id"})
|
||||
raise HTTPException(400, detail={"detail": "Invalid ID", "code": "invalid_id"}) from None
|
||||
# Cross-tenant validation: target user must belong to same tenant
|
||||
if not await workspace_service.verify_user_same_tenant(db, tenant_id, target_uid):
|
||||
raise HTTPException(403, detail={"detail": "Cannot assign user from different tenant", "code": "cross_tenant"})
|
||||
@@ -247,7 +247,7 @@ async def remove_user(
|
||||
wid = uuid.UUID(workspace_id)
|
||||
uid = uuid.UUID(user_id)
|
||||
except ValueError:
|
||||
raise HTTPException(400, detail={"detail": "Invalid ID", "code": "invalid_id"})
|
||||
raise HTTPException(400, detail={"detail": "Invalid ID", "code": "invalid_id"}) from None
|
||||
removed = await workspace_service.remove_user(db, tenant_id, wid, uid)
|
||||
if not removed:
|
||||
raise HTTPException(404, detail={"detail": "User not assigned to this workspace", "code": "not_found"})
|
||||
@@ -283,7 +283,7 @@ async def list_widgets(
|
||||
try:
|
||||
wid = uuid.UUID(workspace_id)
|
||||
except ValueError:
|
||||
raise HTTPException(400, detail={"detail": "Invalid workspace_id", "code": "invalid_id"})
|
||||
raise HTTPException(400, detail={"detail": "Invalid workspace_id", "code": "invalid_id"}) from None
|
||||
widgets = await workspace_service.get_widgets(db, tenant_id, wid)
|
||||
return {"items": widgets, "total": len(widgets)}
|
||||
|
||||
@@ -300,7 +300,7 @@ async def create_widget(
|
||||
try:
|
||||
wid = uuid.UUID(workspace_id)
|
||||
except ValueError:
|
||||
raise HTTPException(400, detail={"detail": "Invalid workspace_id", "code": "invalid_id"})
|
||||
raise HTTPException(400, detail={"detail": "Invalid workspace_id", "code": "invalid_id"}) from None
|
||||
return await workspace_service.create_widget(
|
||||
db, tenant_id, wid, body.widget_key,
|
||||
body.position_x, body.position_y, body.width, body.height, body.config,
|
||||
@@ -321,7 +321,7 @@ async def update_widget(
|
||||
ws_id = uuid.UUID(workspace_id)
|
||||
wid = uuid.UUID(widget_id)
|
||||
except ValueError:
|
||||
raise HTTPException(400, detail={"detail": "Invalid ID", "code": "invalid_id"})
|
||||
raise HTTPException(400, detail={"detail": "Invalid ID", "code": "invalid_id"}) from None
|
||||
result = await workspace_service.update_widget(
|
||||
db, tenant_id, ws_id, wid,
|
||||
body.position_x, body.position_y, body.width, body.height, body.config,
|
||||
@@ -344,7 +344,7 @@ async def delete_widget(
|
||||
ws_id = uuid.UUID(workspace_id)
|
||||
wid = uuid.UUID(widget_id)
|
||||
except ValueError:
|
||||
raise HTTPException(400, detail={"detail": "Invalid ID", "code": "invalid_id"})
|
||||
raise HTTPException(400, detail={"detail": "Invalid ID", "code": "invalid_id"}) from None
|
||||
deleted = await workspace_service.delete_widget(db, tenant_id, ws_id, wid)
|
||||
if not deleted:
|
||||
raise HTTPException(404, detail={"detail": "Widget not found", "code": "not_found"})
|
||||
@@ -364,7 +364,7 @@ async def set_default_workspace(
|
||||
try:
|
||||
wid = uuid.UUID(workspace_id)
|
||||
except ValueError:
|
||||
raise HTTPException(400, detail={"detail": "Invalid workspace_id", "code": "invalid_id"})
|
||||
raise HTTPException(400, detail={"detail": "Invalid workspace_id", "code": "invalid_id"}) from None
|
||||
# Verify user is assigned to this workspace
|
||||
ctx = await workspace_service.get_workspace_context(db, tenant_id, user_id, wid)
|
||||
if ctx is None and not current_user.get("is_system_admin"):
|
||||
|
||||
Reference in New Issue
Block a user