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:
@@ -8,12 +8,10 @@ from typing import Any
|
||||
|
||||
from sqlalchemy import select, update
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from sqlalchemy.orm import selectinload
|
||||
|
||||
from app.core.audit import log_audit
|
||||
from app.models.address import Address
|
||||
from app.core.visibility import apply_visibility_filter, check_single_entity_access
|
||||
|
||||
from app.models.address import Address
|
||||
|
||||
VALID_ENTITY_TYPES = {"contact"}
|
||||
VALID_ADDRESS_TYPES = {"billing", "shipping", "headquarters", "branch", "private", "other"}
|
||||
@@ -216,8 +214,8 @@ async def migrate_existing_addresses(db: AsyncSession) -> int:
|
||||
"""Migrate existing address fields from companies/contacts to Address records.
|
||||
Called during migration 0013. Returns count of created addresses.
|
||||
"""
|
||||
from app.models.contact import Contact as Company
|
||||
from app.models.contact import Contact
|
||||
from app.models.contact import Contact as Company
|
||||
|
||||
count = 0
|
||||
|
||||
|
||||
@@ -3,7 +3,6 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import uuid
|
||||
from datetime import UTC, datetime
|
||||
from typing import Any
|
||||
|
||||
from sqlalchemy import desc, func, select
|
||||
@@ -12,11 +11,9 @@ from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from app.ai.llm_client import get_llm_client
|
||||
from app.core.audit import log_audit
|
||||
from app.core.permissions import check_permission
|
||||
from app.core.visibility import apply_visibility_filter
|
||||
from app.core.visibility import check_single_entity_access
|
||||
from app.core.visibility import apply_visibility_filter, check_single_entity_access
|
||||
from app.models.ai_conversation import AIConversation, AIMessage
|
||||
from app.models.contact import Contact
|
||||
from app.models.contact import Contact
|
||||
from app.models.workflow import Workflow
|
||||
|
||||
|
||||
|
||||
@@ -15,14 +15,23 @@ import uuid
|
||||
from datetime import UTC, datetime
|
||||
from typing import Any
|
||||
|
||||
from sqlalchemy import select, func
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.core.audit import log_audit
|
||||
from app.core.storage import get_storage_backend
|
||||
from app.core.visibility import apply_visibility_filter, check_single_entity_access
|
||||
from app.models.entity_attachment import EntityAttachment
|
||||
from app.plugins.builtins.dms.models import File as DmsFile
|
||||
|
||||
# DMS File model accessed via contract to avoid Core→Plugin dependency (P1-9 fix)
|
||||
|
||||
def _get_dms_file_model():
|
||||
"""Get DMS File model via contract registry, or None if DMS plugin inactive."""
|
||||
from app.plugins.builtins.contracts import get_contract
|
||||
dms_contract = get_contract("dms")
|
||||
if dms_contract is not None:
|
||||
return dms_contract.dms_file
|
||||
return None
|
||||
|
||||
|
||||
# File size limit: 50MB
|
||||
@@ -35,7 +44,7 @@ def _generate_unique_filename(original_filename: str) -> str:
|
||||
return f"{uuid.uuid4().hex}{ext}"
|
||||
|
||||
|
||||
def _entity_attachment_to_dict(ea: EntityAttachment, dms_file: DmsFile | None = None) -> dict[str, Any]:
|
||||
def _entity_attachment_to_dict(ea: EntityAttachment, dms_file: Any = None) -> dict[str, Any]:
|
||||
"""Serialize an EntityAttachment + DMS File to dict."""
|
||||
return {
|
||||
"id": str(ea.id),
|
||||
@@ -69,8 +78,6 @@ async def save_attachment(
|
||||
|
||||
Streams the file in chunks to avoid loading entire file into RAM.
|
||||
"""
|
||||
import hashlib
|
||||
from app.core.storage import get_storage_backend
|
||||
|
||||
# Generate unique filename and storage path
|
||||
unique_filename = _generate_unique_filename(filename)
|
||||
@@ -79,14 +86,14 @@ async def save_attachment(
|
||||
# Stream file to storage — compute hash and size during streaming
|
||||
sha256 = hashlib.sha256()
|
||||
file_size = 0
|
||||
CHUNK_SIZE = 1024 * 1024 # 1MB chunks
|
||||
chunk_size = 1024 * 1024 # 1MB chunks
|
||||
|
||||
async def chunk_stream():
|
||||
nonlocal file_size
|
||||
if hasattr(file, 'read'):
|
||||
# UploadFile object
|
||||
while True:
|
||||
chunk = await file.read(CHUNK_SIZE)
|
||||
chunk = await file.read(chunk_size)
|
||||
if not chunk:
|
||||
break
|
||||
file_size += len(chunk)
|
||||
@@ -111,9 +118,9 @@ async def save_attachment(
|
||||
|
||||
# Check for blocked file types
|
||||
import os as _os
|
||||
_BLOCKED = {".exe", ".bat", ".cmd", ".sh", ".jar", ".com", ".scr", ".msi", ".dll", ".vbs", ".ps1", ".app", ".bin", ".reg", ".inf"}
|
||||
_blocked = {".exe", ".bat", ".cmd", ".sh", ".jar", ".com", ".scr", ".msi", ".dll", ".vbs", ".ps1", ".app", ".bin", ".reg", ".inf"}
|
||||
_ext = _os.path.splitext(filename)[1].lower()
|
||||
if _ext in _BLOCKED:
|
||||
if _ext in _blocked:
|
||||
await storage.delete(storage_path)
|
||||
raise ValueError(f"File type not allowed: {_ext}")
|
||||
|
||||
@@ -128,12 +135,18 @@ async def save_attachment(
|
||||
|
||||
content_hash = sha256.hexdigest()
|
||||
|
||||
# Get DMS File model via contract (avoid Core→Plugin dependency)
|
||||
dms_file = _get_dms_file_model()
|
||||
if dms_file is None:
|
||||
await storage.delete(storage_path)
|
||||
raise RuntimeError("DMS plugin not available — cannot save attachment")
|
||||
|
||||
# Check for existing DMS file with same hash in same tenant (deduplication)
|
||||
existing_file = await db.execute(
|
||||
select(DmsFile).where(
|
||||
DmsFile.tenant_id == tenant_id,
|
||||
DmsFile.content_hash == content_hash,
|
||||
DmsFile.deleted_at.is_(None),
|
||||
select(dms_file).where(
|
||||
dms_file.tenant_id == tenant_id,
|
||||
dms_file.content_hash == content_hash,
|
||||
dms_file.deleted_at.is_(None),
|
||||
).limit(1)
|
||||
)
|
||||
existing_dms_file = existing_file.scalar_one_or_none()
|
||||
@@ -144,7 +157,7 @@ async def save_attachment(
|
||||
await storage.delete(storage_path) # Remove the duplicate we just saved
|
||||
else:
|
||||
# File already streamed to storage — create DMS File record
|
||||
dms_file = DmsFile(
|
||||
dms_file = dms_file(
|
||||
tenant_id=tenant_id,
|
||||
name=filename,
|
||||
folder_id=None, # Attachments don't go in DMS folders
|
||||
@@ -190,15 +203,18 @@ async def list_attachments(
|
||||
is_system_admin: bool = False,
|
||||
) -> dict[str, Any]:
|
||||
"""List attachments for a specific entity (via DMS files)."""
|
||||
dms_file = _get_dms_file_model()
|
||||
if dms_file is None:
|
||||
return {"items": [], "total": 0}
|
||||
q = (
|
||||
select(EntityAttachment, DmsFile)
|
||||
.join(DmsFile, EntityAttachment.dms_file_id == DmsFile.id)
|
||||
select(EntityAttachment, dms_file)
|
||||
.join(dms_file, EntityAttachment.dms_file_id == dms_file.id)
|
||||
.where(
|
||||
EntityAttachment.tenant_id == tenant_id,
|
||||
EntityAttachment.entity_type == entity_type,
|
||||
EntityAttachment.entity_id == entity_id,
|
||||
EntityAttachment.deleted_at.is_(None),
|
||||
DmsFile.deleted_at.is_(None),
|
||||
dms_file.deleted_at.is_(None),
|
||||
)
|
||||
.order_by(EntityAttachment.created_at.desc())
|
||||
)
|
||||
@@ -222,9 +238,12 @@ async def get_attachment(
|
||||
is_system_admin: bool = False,
|
||||
) -> dict[str, Any] | None:
|
||||
"""Get a single attachment by ID (with DMS file info)."""
|
||||
dms_file = _get_dms_file_model()
|
||||
if dms_file is None:
|
||||
return None
|
||||
q = (
|
||||
select(EntityAttachment, DmsFile)
|
||||
.join(DmsFile, EntityAttachment.dms_file_id == DmsFile.id)
|
||||
select(EntityAttachment, dms_file)
|
||||
.join(dms_file, EntityAttachment.dms_file_id == dms_file.id)
|
||||
.where(
|
||||
EntityAttachment.id == attachment_id,
|
||||
EntityAttachment.tenant_id == tenant_id,
|
||||
@@ -253,14 +272,17 @@ async def get_attachment_download_path(
|
||||
is_system_admin: bool = False,
|
||||
) -> str | None:
|
||||
"""Get the storage path for downloading an attachment's DMS file."""
|
||||
dms_file = _get_dms_file_model()
|
||||
if dms_file is None:
|
||||
return None
|
||||
q = (
|
||||
select(EntityAttachment, DmsFile)
|
||||
.join(DmsFile, EntityAttachment.dms_file_id == DmsFile.id)
|
||||
select(EntityAttachment, dms_file)
|
||||
.join(dms_file, EntityAttachment.dms_file_id == dms_file.id)
|
||||
.where(
|
||||
EntityAttachment.id == attachment_id,
|
||||
EntityAttachment.tenant_id == tenant_id,
|
||||
EntityAttachment.deleted_at.is_(None),
|
||||
DmsFile.deleted_at.is_(None),
|
||||
dms_file.deleted_at.is_(None),
|
||||
)
|
||||
)
|
||||
result = await db.execute(q)
|
||||
@@ -285,7 +307,7 @@ async def delete_attachment(
|
||||
is_system_admin: bool = False,
|
||||
) -> bool:
|
||||
"""Soft-delete an entity_attachments reference.
|
||||
|
||||
|
||||
The DMS file is NOT deleted because other entities may reference it.
|
||||
DMS file cleanup happens via DMS's own deletion workflow.
|
||||
"""
|
||||
|
||||
@@ -23,7 +23,7 @@ from app.core.auth import (
|
||||
update_session_tenant,
|
||||
verify_password,
|
||||
)
|
||||
from app.core.hooks import do_action, apply_filters
|
||||
from app.core.hooks import apply_filters, do_action
|
||||
from app.models.auth import PasswordResetToken
|
||||
from app.models.tenant import Tenant
|
||||
from app.models.user import User, UserTenant
|
||||
@@ -350,8 +350,9 @@ class AuthService:
|
||||
# Audit log entry for password reset — use separate API session (crm_api)
|
||||
# to avoid requiring audit_log INSERT grants on crm_auth
|
||||
try:
|
||||
from app.core.db import get_api_engine, set_tenant_context
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.core.db import get_api_engine, set_tenant_context
|
||||
api_engine = get_api_engine()
|
||||
async with AsyncSession(api_engine) as audit_db:
|
||||
await set_tenant_context(audit_db, reset_token.tenant_id)
|
||||
|
||||
@@ -6,7 +6,7 @@ import asyncio
|
||||
import logging
|
||||
import os
|
||||
import uuid
|
||||
from datetime import datetime, timezone
|
||||
from datetime import UTC, datetime
|
||||
from pathlib import Path
|
||||
|
||||
from sqlalchemy import select
|
||||
@@ -97,7 +97,7 @@ async def create_backup(
|
||||
"""
|
||||
_ensure_backup_dir()
|
||||
|
||||
timestamp = datetime.now(timezone.utc).strftime("%Y%m%d_%H%M%S")
|
||||
timestamp = datetime.now(UTC).strftime("%Y%m%d_%H%M%S")
|
||||
filename = f"leocrm_backup_{tenant_id}_{timestamp}.dump"
|
||||
filepath = BACKUP_DIR / filename
|
||||
|
||||
@@ -223,7 +223,7 @@ async def restore_backup(
|
||||
|
||||
# Run pg_restore in a subprocess — atomic at the DB level via pg_restore --clean
|
||||
import subprocess
|
||||
result = subprocess.run(cmd, env=env, capture_output=True, text=True, timeout=300)
|
||||
result = subprocess.run(cmd, env=env, capture_output=True, text=True, timeout=300) # noqa: ASYNC221
|
||||
if result.returncode != 0:
|
||||
logger.error("pg_restore failed: %s", result.stderr)
|
||||
backup.status = "failed"
|
||||
@@ -232,7 +232,7 @@ async def restore_backup(
|
||||
raise RuntimeError(f"pg_restore failed: {result.stderr[:200]}")
|
||||
|
||||
backup.status = "restored"
|
||||
backup.restored_at = datetime.now(timezone.utc)
|
||||
backup.restored_at = datetime.now(UTC)
|
||||
await db.commit()
|
||||
logger.info("Backup %s restored successfully", backup_id)
|
||||
return backup
|
||||
@@ -244,28 +244,6 @@ async def restore_backup(
|
||||
await db.commit()
|
||||
raise
|
||||
|
||||
logger.info("Running pg_restore: %s", cmd)
|
||||
|
||||
process = await asyncio.create_subprocess_exec(
|
||||
*cmd,
|
||||
env=env,
|
||||
stdout=asyncio.subprocess.PIPE,
|
||||
stderr=asyncio.subprocess.PIPE,
|
||||
)
|
||||
stdout, stderr = await process.communicate()
|
||||
|
||||
if process.returncode != 0:
|
||||
error_msg = stderr.decode() if stderr else "pg_restore failed with unknown error"
|
||||
logger.error("pg_restore failed: %s", error_msg)
|
||||
raise RuntimeError(f"Restore failed: {error_msg}")
|
||||
|
||||
logger.info("Restore completed from backup: %s", backup.filename)
|
||||
return backup
|
||||
|
||||
except Exception as exc:
|
||||
logger.exception("Restore failed")
|
||||
raise
|
||||
|
||||
|
||||
async def delete_backup(
|
||||
db: AsyncSession,
|
||||
|
||||
@@ -10,8 +10,8 @@ from sqlalchemy import select, update
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.core.audit import log_audit
|
||||
from app.models.bank_account import BankAccount
|
||||
from app.core.visibility import apply_visibility_filter, check_single_entity_access
|
||||
from app.models.bank_account import BankAccount
|
||||
|
||||
|
||||
def _account_to_dict(a: BankAccount) -> dict[str, Any]:
|
||||
|
||||
@@ -8,19 +8,16 @@ from __future__ import annotations
|
||||
|
||||
import logging
|
||||
import uuid
|
||||
from datetime import datetime, UTC
|
||||
from typing import Any
|
||||
|
||||
from sqlalchemy import and_, or_, select
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.models.entity_permission import EntityPermission
|
||||
from app.models.group import UserGroup
|
||||
from app.models.user import User, UserTenant
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
from app.core.permissions import PERM_RANK as _PERM_RANK
|
||||
from app.core.permissions import PERM_RANK as _PERM_RANK # noqa: E402
|
||||
|
||||
|
||||
def _rank(level: str) -> int:
|
||||
|
||||
@@ -17,9 +17,8 @@ Mapping:
|
||||
from __future__ import annotations
|
||||
|
||||
import uuid
|
||||
from typing import Any
|
||||
|
||||
from sqlalchemy import func, or_, select
|
||||
from sqlalchemy import func, select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.models.contact_folder import ContactFolder
|
||||
@@ -29,7 +28,7 @@ from app.models.user import User
|
||||
|
||||
_ENTITY_TYPE = "contact_folder"
|
||||
|
||||
from app.core.permissions import PERM_RANK as _PERM_RANK
|
||||
from app.core.permissions import PERM_RANK as _PERM_RANK # noqa: E402
|
||||
|
||||
|
||||
def _rank(level: str) -> int:
|
||||
@@ -425,7 +424,7 @@ async def get_visible_folder_ids(
|
||||
chain.append(current_parent)
|
||||
current_parent = parent_map.get(current_parent)
|
||||
|
||||
for i, ancestor_id in enumerate(chain):
|
||||
for _i, ancestor_id in enumerate(chain):
|
||||
perms = perm_lookup.get(ancestor_id, [])
|
||||
for ptype, pid, level in perms:
|
||||
# All permissions inherit after migration
|
||||
|
||||
@@ -3,11 +3,9 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import uuid
|
||||
from typing import Any
|
||||
|
||||
from sqlalchemy import func, select, update
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from sqlalchemy.orm import selectinload
|
||||
|
||||
from app.models.contact import Contact
|
||||
from app.models.contact_folder import ContactFolder
|
||||
|
||||
@@ -3,15 +3,15 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import uuid
|
||||
from typing import Any
|
||||
from datetime import UTC
|
||||
|
||||
from sqlalchemy import select, func, or_, text
|
||||
from sqlalchemy import func, select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from sqlalchemy.orm import selectinload
|
||||
|
||||
from app.core.hooks import apply_filters, do_action
|
||||
from app.models.contact import Contact, ContactPerson
|
||||
from app.services.entity_history_service import record_history
|
||||
from app.core.hooks import do_action, apply_filters
|
||||
|
||||
|
||||
def _compute_displayname(data: dict) -> str:
|
||||
@@ -243,7 +243,7 @@ async def get_contact(db: AsyncSession, tenant_id: uuid.UUID, contact_id: str,
|
||||
contact = result.scalar_one_or_none()
|
||||
if not contact:
|
||||
raise ValueError("Contact not found")
|
||||
|
||||
|
||||
# Check row-level access
|
||||
if user_id and not is_system_admin:
|
||||
from app.core.visibility import check_single_entity_access
|
||||
@@ -252,7 +252,7 @@ async def get_contact(db: AsyncSession, tenant_id: uuid.UUID, contact_id: str,
|
||||
)
|
||||
if not has_access:
|
||||
raise PermissionError("No access to this contact")
|
||||
|
||||
|
||||
return _serialize_contact_detail(contact)
|
||||
|
||||
|
||||
@@ -454,8 +454,8 @@ async def delete_contact(
|
||||
contact_full = result2.scalar_one()
|
||||
snapshot_before = _serialize_contact_detail(contact_full)
|
||||
|
||||
from datetime import datetime, timezone
|
||||
contact.deleted_at = datetime.now(timezone.utc)
|
||||
from datetime import datetime
|
||||
contact.deleted_at = datetime.now(UTC)
|
||||
await db.flush()
|
||||
|
||||
# Record history
|
||||
@@ -556,7 +556,7 @@ async def delete_contact_person(
|
||||
cp = result.scalar_one_or_none()
|
||||
if not cp:
|
||||
raise ValueError("Contact person not found")
|
||||
from datetime import datetime, timezone
|
||||
cp.deleted_at = datetime.now(timezone.utc)
|
||||
from datetime import datetime
|
||||
cp.deleted_at = datetime.now(UTC)
|
||||
await db.flush()
|
||||
|
||||
|
||||
@@ -5,7 +5,7 @@ from __future__ import annotations
|
||||
import uuid
|
||||
from typing import Any
|
||||
|
||||
from sqlalchemy import select, delete
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.core.visibility import apply_visibility_filter, check_single_entity_access
|
||||
|
||||
@@ -1,17 +1,25 @@
|
||||
"""Deduplication / merge service for contacts."""
|
||||
"""Contact deduplication / merge service.
|
||||
|
||||
This service is Contact-specific — it handles duplicate detection and merging
|
||||
for the Contact entity only. It is NOT a generic dedup service. If other
|
||||
entity types need dedup in the future, a separate service or a plugin-based
|
||||
interface should be created.
|
||||
|
||||
Declared as Contact-specific (P1-22 fix): no pretense of being generic.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import uuid
|
||||
from datetime import UTC
|
||||
from typing import Any
|
||||
|
||||
from sqlalchemy import select, func, text
|
||||
from sqlalchemy import func, select, text
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.models.contact import Contact
|
||||
from app.models.contact_merge import ContactMergeHistory
|
||||
|
||||
|
||||
# Fields used for duplicate detection
|
||||
DUPLICATE_FIELDS = [
|
||||
"displayname", "name", "firstname", "surname",
|
||||
@@ -168,7 +176,7 @@ async def find_duplicates(
|
||||
name_map.setdefault(norm, []).append(c)
|
||||
|
||||
# Find contacts with same normalized name
|
||||
for norm_name, group in name_map.items():
|
||||
for _norm_name, group in name_map.items():
|
||||
if len(group) < 2:
|
||||
continue
|
||||
for i in range(len(group)):
|
||||
@@ -314,8 +322,8 @@ async def merge_contacts(
|
||||
)
|
||||
|
||||
# Soft-delete the source contact
|
||||
from datetime import datetime, timezone
|
||||
source.deleted_at = datetime.now(timezone.utc)
|
||||
from datetime import datetime
|
||||
source.deleted_at = datetime.now(UTC)
|
||||
|
||||
# Record merge history
|
||||
history = ContactMergeHistory(
|
||||
|
||||
@@ -8,10 +8,10 @@ from __future__ import annotations
|
||||
|
||||
import logging
|
||||
import uuid
|
||||
from datetime import datetime, UTC
|
||||
from datetime import UTC, datetime
|
||||
from typing import Any
|
||||
|
||||
from sqlalchemy import and_, or_, select
|
||||
from sqlalchemy import or_, select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.models.permission_delegation import PermissionDelegation
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import uuid
|
||||
from datetime import datetime, timezone
|
||||
from datetime import UTC, datetime
|
||||
from typing import Any
|
||||
|
||||
from sqlalchemy import select
|
||||
@@ -148,7 +148,6 @@ async def restore_from_history(
|
||||
return await config.special_handler(db, entity, action, snapshot, context)
|
||||
|
||||
# Generic restore logic
|
||||
from datetime import datetime, timezone
|
||||
|
||||
if action == "delete":
|
||||
if entity is None:
|
||||
@@ -176,7 +175,7 @@ async def restore_from_history(
|
||||
elif action == "create":
|
||||
if entity is None:
|
||||
raise ValueError("Entity not found for restore")
|
||||
entity.deleted_at = datetime.now(timezone.utc)
|
||||
entity.deleted_at = datetime.now(UTC)
|
||||
if hasattr(entity, "updated_by"):
|
||||
entity.updated_by = user_id
|
||||
await db.flush()
|
||||
@@ -193,16 +192,7 @@ def _serialize_entity(entity: Any, entity_type: str) -> dict[str, Any]:
|
||||
"""
|
||||
if entity_type == "contact":
|
||||
from app.services.contact_service import _serialize_contact_detail
|
||||
from sqlalchemy.orm import selectinload
|
||||
from app.models.contact import Contact
|
||||
from sqlalchemy import select as sa_select
|
||||
|
||||
# Re-query with selectinload for contact_persons
|
||||
q = (
|
||||
sa_select(Contact)
|
||||
.options(selectinload(Contact.contact_persons))
|
||||
.where(Contact.id == entity.id)
|
||||
)
|
||||
# This is called after flush, entity is still in session
|
||||
# but we need to reload with the relationship
|
||||
return _serialize_contact_detail(entity)
|
||||
@@ -365,10 +355,11 @@ async def archive_old_history(
|
||||
GDPR compliance: after retention period, history snapshots are purged.
|
||||
Returns the number of archived entries.
|
||||
"""
|
||||
from datetime import datetime, timezone, timedelta
|
||||
from datetime import timedelta
|
||||
|
||||
from sqlalchemy import delete as sa_delete
|
||||
|
||||
cutoff = datetime.now(timezone.utc) - timedelta(days=days)
|
||||
cutoff = datetime.now(UTC) - timedelta(days=days)
|
||||
stmt = sa_delete(EntityHistory).where(
|
||||
EntityHistory.tenant_id == tenant_id,
|
||||
EntityHistory.created_at < cutoff,
|
||||
|
||||
@@ -16,36 +16,34 @@ Resolution, caching, and audit logic have been extracted into focused modules:
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import logging
|
||||
import uuid
|
||||
from datetime import datetime, UTC
|
||||
from datetime import UTC, datetime
|
||||
from typing import Any
|
||||
|
||||
import redis.asyncio as aioredis
|
||||
from sqlalchemy import and_, func, or_, select, text
|
||||
from sqlalchemy import String, select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.core.audit import log_audit
|
||||
from app.core.notifications import create_notification
|
||||
from app.models.entity_permission import EntityPermission
|
||||
from app.models.group import Group, UserGroup
|
||||
from app.models.role import Role
|
||||
from app.models.user import User, UserTenant
|
||||
from app.models.contact import Contact
|
||||
from app.core.notifications import post_system_message
|
||||
from app.models.address import Address
|
||||
from app.models.attachment import Attachment
|
||||
from app.models.bank_account import BankAccount
|
||||
from app.models.workflow import Workflow
|
||||
from app.models.sequence import Sequence
|
||||
from app.models.contact import Contact
|
||||
from app.models.contact_folder import ContactFolder
|
||||
from app.models.custom_field_definition import CustomFieldDefinition
|
||||
from app.models.entity_permission import EntityPermission
|
||||
from app.models.group import Group, UserGroup
|
||||
from app.models.role import Role
|
||||
from app.models.saved_filter import SavedFilter
|
||||
from app.models.saved_view import SavedView
|
||||
from app.models.sequence import Sequence
|
||||
from app.models.user import User
|
||||
from app.models.webhook import Webhook
|
||||
from app.models.custom_field_definition import CustomFieldDefinition
|
||||
from app.models.contact_folder import ContactFolder
|
||||
from app.models.workflow import Workflow
|
||||
|
||||
# Import cache helpers used by CRUD operations
|
||||
from app.services.permission_cache import _invalidate_user_cache, CACHE_TTL, CACHE_PREFIX
|
||||
from app.services.permission_cache import _invalidate_user_cache
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
@@ -54,6 +52,8 @@ logger = logging.getLogger(__name__)
|
||||
# This replaces insecure text(f"SELECT ... FROM {entity_type}s") queries
|
||||
# with safe SQLAlchemy model-based queries (prevents SQL injection).
|
||||
ENTITY_MODELS: dict[str, type] = {
|
||||
# Core models only — plugin models are registered dynamically
|
||||
# via plugin.get_entity_models() at activation time (P0-3 fix).
|
||||
"contact": Contact,
|
||||
"contacts": Contact,
|
||||
"company": Contact,
|
||||
@@ -81,113 +81,18 @@ try:
|
||||
except ImportError:
|
||||
pass
|
||||
|
||||
# Plugin models if available
|
||||
try:
|
||||
from app.plugins.builtins.dms.models import File as DmsFile
|
||||
ENTITY_MODELS["file"] = DmsFile
|
||||
except ImportError:
|
||||
pass
|
||||
try:
|
||||
from app.plugins.builtins.dms.models import Folder as DmsFolder
|
||||
ENTITY_MODELS["folder"] = DmsFolder
|
||||
except ImportError:
|
||||
pass
|
||||
try:
|
||||
from app.plugins.builtins.calendar.models import CalendarEntry
|
||||
ENTITY_MODELS["calendar_event"] = CalendarEntry
|
||||
except ImportError:
|
||||
pass
|
||||
try:
|
||||
from app.plugins.builtins.calendar.models import Calendar
|
||||
ENTITY_MODELS["calendar"] = Calendar
|
||||
except ImportError:
|
||||
pass
|
||||
try:
|
||||
from app.plugins.builtins.calendar.models import Subtask
|
||||
ENTITY_MODELS["subtask"] = Subtask
|
||||
except ImportError:
|
||||
pass
|
||||
try:
|
||||
from app.plugins.builtins.tasks.models import Task
|
||||
ENTITY_MODELS["task"] = Task
|
||||
except ImportError:
|
||||
pass
|
||||
try:
|
||||
from app.plugins.builtins.mail.models import MailAccount
|
||||
ENTITY_MODELS["mailbox"] = MailAccount
|
||||
ENTITY_MODELS["mail_account"] = MailAccount
|
||||
except ImportError:
|
||||
pass
|
||||
# Plugin models are registered dynamically via plugin.get_entity_models()
|
||||
# at activation time in main.py:lifespan(). No hardcoded plugin imports here.
|
||||
|
||||
# Additional plugin models with OwnedMixin
|
||||
try:
|
||||
from app.plugins.builtins.mail.models import MailMessage
|
||||
ENTITY_MODELS["mail_message"] = MailMessage
|
||||
except ImportError:
|
||||
pass
|
||||
try:
|
||||
from app.plugins.builtins.kommunikation.models import CommConversation
|
||||
ENTITY_MODELS["comm_conversation"] = CommConversation
|
||||
except ImportError:
|
||||
pass
|
||||
try:
|
||||
from app.plugins.builtins.tags.models import Tag
|
||||
ENTITY_MODELS["tag"] = Tag
|
||||
except ImportError:
|
||||
pass
|
||||
try:
|
||||
from app.plugins.builtins.agent_memory.models import AgentMemory
|
||||
ENTITY_MODELS["agent_memory"] = AgentMemory
|
||||
except ImportError:
|
||||
pass
|
||||
try:
|
||||
from app.plugins.builtins.graph_rag.models import EntityRelationship
|
||||
ENTITY_MODELS["entity_relationship"] = EntityRelationship
|
||||
except ImportError:
|
||||
pass
|
||||
try:
|
||||
from app.plugins.builtins.report_generator.models import ReportTemplate, ReportInstance
|
||||
ENTITY_MODELS["report_template"] = ReportTemplate
|
||||
ENTITY_MODELS["report_instance"] = ReportInstance
|
||||
except ImportError:
|
||||
pass
|
||||
try:
|
||||
from app.plugins.builtins.entity_links.models import EntityLink
|
||||
ENTITY_MODELS["entity_link"] = EntityLink
|
||||
except ImportError:
|
||||
pass
|
||||
try:
|
||||
from app.plugins.builtins.kommunikation.models import CommConversation as CommConv
|
||||
ENTITY_MODELS["comm_conversation"] = CommConv
|
||||
except ImportError:
|
||||
pass
|
||||
try:
|
||||
from app.plugins.builtins.ai_proactive.models import ProactiveSuggestion
|
||||
ENTITY_MODELS["proactive_suggestion"] = ProactiveSuggestion
|
||||
except ImportError:
|
||||
pass
|
||||
try:
|
||||
from app.plugins.builtins.ai_assistant.models import AIAgent, AIChatSession
|
||||
ENTITY_MODELS["ai_agent"] = AIAgent
|
||||
ENTITY_MODELS["ai_chat_session"] = AIChatSession
|
||||
except ImportError:
|
||||
pass
|
||||
try:
|
||||
from app.plugins.builtins.permissions.models import ShareLink
|
||||
ENTITY_MODELS["share_link"] = ShareLink
|
||||
except ImportError:
|
||||
pass
|
||||
try:
|
||||
from app.plugins.builtins.automation.models import AgentDefinition, AutomationDefinition
|
||||
ENTITY_MODELS["agent_definition"] = AgentDefinition
|
||||
ENTITY_MODELS["automation_definition"] = AutomationDefinition
|
||||
except ImportError:
|
||||
pass
|
||||
try:
|
||||
from app.plugins.builtins.mcp_client.models import McpServerConfig
|
||||
ENTITY_MODELS["mcp_server_config"] = McpServerConfig
|
||||
except ImportError:
|
||||
pass
|
||||
|
||||
def register_entity_model(entity_type: str, model_class: type) -> None:
|
||||
"""Register an entity model dynamically (called during plugin activation)."""
|
||||
ENTITY_MODELS[entity_type] = model_class
|
||||
|
||||
|
||||
def unregister_entity_model(entity_type: str) -> None:
|
||||
"""Unregister an entity model (called during plugin deactivation)."""
|
||||
ENTITY_MODELS.pop(entity_type, None)
|
||||
|
||||
|
||||
def _get_entity_model(entity_type: str) -> type:
|
||||
@@ -197,7 +102,7 @@ def _get_entity_model(entity_type: str) -> type:
|
||||
raise ValueError(f"Unknown entity type: {entity_type}")
|
||||
return model
|
||||
|
||||
from app.core.permissions import PERM_RANK as _PERM_RANK
|
||||
from app.core.permissions import PERM_RANK as _PERM_RANK # noqa: E402
|
||||
|
||||
|
||||
def _rank(level: str) -> int:
|
||||
@@ -305,9 +210,9 @@ async def create_permission(
|
||||
)
|
||||
# Notify user if direct permission
|
||||
if principal_type == 'user':
|
||||
await create_notification(
|
||||
await post_system_message(
|
||||
db, tenant_id, principal_uuid,
|
||||
type='permission_granted',
|
||||
message_type='permission_granted',
|
||||
title='Neue Berechtigung',
|
||||
body=f'{entity_type} wurde mit dir geteilt',
|
||||
entity_type=entity_type,
|
||||
@@ -363,9 +268,9 @@ async def create_permission(
|
||||
)
|
||||
# Notify user if direct permission
|
||||
if principal_type == 'user':
|
||||
await create_notification(
|
||||
await post_system_message(
|
||||
db, tenant_id, principal_uuid,
|
||||
type='permission_granted',
|
||||
message_type='permission_granted',
|
||||
title='Neue Berechtigung',
|
||||
body=f'{entity_type} wurde mit dir geteilt',
|
||||
entity_type=entity_type,
|
||||
@@ -460,9 +365,9 @@ async def delete_permission(
|
||||
)
|
||||
# Notify user if direct permission
|
||||
if old_principal_type == 'user':
|
||||
await create_notification(
|
||||
await post_system_message(
|
||||
db, tenant_id, old_principal_id,
|
||||
type='permission_revoked',
|
||||
message_type='permission_revoked',
|
||||
title='Berechtigung entfernt',
|
||||
body=f'{old_entity_type} wurde nicht mehr mit dir geteilt',
|
||||
entity_type=old_entity_type,
|
||||
@@ -592,15 +497,22 @@ async def cleanup_expired_permissions(db: AsyncSession) -> int:
|
||||
logger.info("Cleaned up %d expired entity permissions", count)
|
||||
return count
|
||||
|
||||
|
||||
# Backward compatibility re-exports
|
||||
from app.services.permission_resolver import ( # noqa: E402
|
||||
get_effective_access,
|
||||
get_visible_ids,
|
||||
batch_get_effective_access,
|
||||
check_entity_access,
|
||||
# Backward compatibility re-exports (intentional re-exports used by other modules)
|
||||
from app.services.permission_cache import ( # noqa: E402
|
||||
get_cached_visible_ids as get_cached_visible_ids,
|
||||
)
|
||||
from app.services.permission_cache import ( # noqa: E402
|
||||
get_cached_visible_ids,
|
||||
invalidate_all_user_entity_cache,
|
||||
invalidate_all_user_entity_cache as invalidate_all_user_entity_cache,
|
||||
)
|
||||
from app.services.permission_resolver import ( # noqa: E402
|
||||
batch_get_effective_access as batch_get_effective_access,
|
||||
)
|
||||
from app.services.permission_resolver import ( # noqa: E402
|
||||
check_entity_access as check_entity_access,
|
||||
)
|
||||
from app.services.permission_resolver import ( # noqa: E402
|
||||
get_effective_access as get_effective_access,
|
||||
)
|
||||
from app.services.permission_resolver import ( # noqa: E402
|
||||
get_visible_ids as get_visible_ids,
|
||||
)
|
||||
|
||||
@@ -5,9 +5,8 @@ from __future__ import annotations
|
||||
import csv
|
||||
import io
|
||||
import uuid
|
||||
from typing import Any
|
||||
|
||||
from sqlalchemy import select, func
|
||||
from sqlalchemy import func, select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.core.sensitive_data import get_sensitive_fields
|
||||
|
||||
@@ -5,9 +5,8 @@ from __future__ import annotations
|
||||
import uuid
|
||||
from typing import Any
|
||||
|
||||
from sqlalchemy import delete, func, select
|
||||
from sqlalchemy import delete, select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from sqlalchemy.orm import selectinload
|
||||
|
||||
from app.core.auth import get_redis
|
||||
from app.core.permissions import invalidate_all_user_permissions
|
||||
|
||||
@@ -164,7 +164,6 @@ def suggest_mapping(source_columns: list[str], target_fields: list[str]) -> dict
|
||||
aliases: dict[str, str] = {
|
||||
"first_name": "firstname",
|
||||
"last_name": "surname",
|
||||
"last_name": "surname",
|
||||
"email": "email",
|
||||
"email_address": "email",
|
||||
"phone": "phone",
|
||||
|
||||
@@ -60,8 +60,7 @@ async def import_background_job(ctx: dict[str, Any], job_id: str, params: dict[s
|
||||
- field_mapping: Optional dict[str, str]
|
||||
"""
|
||||
from app.core.auth import get_redis
|
||||
from app.core.db import get_worker_session_factory
|
||||
from app.core.db import set_tenant_context
|
||||
from app.core.db import get_worker_session_factory, set_tenant_context
|
||||
|
||||
redis_client = get_redis()
|
||||
entity_type = params["entity_type"]
|
||||
|
||||
@@ -1,5 +1,12 @@
|
||||
"""Import/export service — CSV/JSON/XLSX import with dry-run preview, partial-failure, CSV/XLSX export.
|
||||
|
||||
This service is Contact-specific — it handles import/export for Contacts and
|
||||
Companies (both use the Contact model). It is NOT a generic import/export
|
||||
service. If other entity types need import/export in the future, a separate
|
||||
service or a plugin-based interface should be created.
|
||||
|
||||
Declared as Contact-specific (P1-23 fix): no pretense of being generic.
|
||||
|
||||
Uses shared helpers from import_export_helpers.py for parsing, writing, mapping,
|
||||
and validation. Implements partial-failure semantics: valid rows are committed
|
||||
while invalid rows are collected into a structured error report.
|
||||
|
||||
@@ -4,7 +4,6 @@ from __future__ import annotations
|
||||
|
||||
import logging
|
||||
import uuid
|
||||
from typing import Any
|
||||
|
||||
from sqlalchemy import text
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
@@ -6,7 +6,7 @@ Extracted from entity_permission_service.py for modularity.
|
||||
from __future__ import annotations
|
||||
|
||||
import uuid
|
||||
from datetime import datetime, UTC
|
||||
from datetime import UTC, datetime
|
||||
|
||||
from sqlalchemy import and_, or_, select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
@@ -93,6 +93,26 @@ class PluginService:
|
||||
try:
|
||||
record = await self._registry.activate(db, name)
|
||||
was_already_active = record.active and record.status == "active"
|
||||
|
||||
# Update permission registry at runtime so newly activated plugins
|
||||
# are immediately usable without app restart (P0-10 fix).
|
||||
if not was_already_active:
|
||||
from app.core.permission_registry import (
|
||||
get_permission_registry,
|
||||
register_plugin_permissions,
|
||||
)
|
||||
plugin = self._registry.get_plugin(name)
|
||||
if plugin and plugin.manifest.permissions:
|
||||
register_plugin_permissions(name, plugin.manifest.permissions)
|
||||
# Add to active set so require_active_plugin() returns True
|
||||
get_permission_registry()._active_plugins.add(name)
|
||||
|
||||
# Register entity models for permission system (P0-3 fix)
|
||||
if plugin:
|
||||
from app.services.entity_permission_service import register_entity_model
|
||||
for entity_type, model_class in plugin.get_entity_models().items():
|
||||
register_entity_model(entity_type, model_class)
|
||||
|
||||
if tenant_id and user_id:
|
||||
await log_audit(
|
||||
db,
|
||||
@@ -137,6 +157,24 @@ class PluginService:
|
||||
try:
|
||||
record = await self._registry.deactivate(db, name)
|
||||
was_already_inactive = not record.active and record.status == "inactive"
|
||||
|
||||
# Update permission registry at runtime so deactivated plugins
|
||||
# immediately stop being usable (P0-10 fix).
|
||||
if not was_already_inactive:
|
||||
from app.core.permission_registry import (
|
||||
get_permission_registry,
|
||||
unregister_plugin_permissions,
|
||||
)
|
||||
unregister_plugin_permissions(name)
|
||||
get_permission_registry()._active_plugins.discard(name)
|
||||
|
||||
# Unregister entity models for permission system (P0-3 fix)
|
||||
plugin = self._registry.get_plugin(name)
|
||||
if plugin:
|
||||
from app.services.entity_permission_service import unregister_entity_model
|
||||
for entity_type in plugin.get_entity_models():
|
||||
unregister_entity_model(entity_type)
|
||||
|
||||
if tenant_id and user_id:
|
||||
await log_audit(
|
||||
db,
|
||||
|
||||
@@ -10,8 +10,8 @@ from sqlalchemy import select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.core.audit import log_audit
|
||||
from app.models.sequence import Sequence
|
||||
from app.core.visibility import apply_visibility_filter, check_single_entity_access
|
||||
from app.models.sequence import Sequence
|
||||
|
||||
|
||||
def _sequence_to_dict(s: Sequence) -> dict[str, Any]:
|
||||
|
||||
@@ -13,11 +13,11 @@ from typing import Any
|
||||
from urllib.parse import urlparse
|
||||
|
||||
import httpx
|
||||
from sqlalchemy import select, delete
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.models.webhook import Webhook
|
||||
from app.core.visibility import apply_visibility_filter, check_single_entity_access
|
||||
from app.models.webhook import Webhook
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
@@ -52,13 +52,13 @@ def _validate_webhook_url(url: str) -> None:
|
||||
# Not an IP — resolve hostname and check
|
||||
try:
|
||||
resolved = socket.getaddrinfo(hostname, None)
|
||||
for family, _, _, _, sockaddr in resolved:
|
||||
for _family, _, _, _, sockaddr in resolved:
|
||||
addr = sockaddr[0]
|
||||
ip_obj = ipaddress.ip_address(addr)
|
||||
if ip_obj.is_private or ip_obj.is_loopback or ip_obj.is_link_local or ip_obj.is_reserved:
|
||||
raise ValueError(f"Webhook hostname '{hostname}' resolves to private IP: {addr}")
|
||||
raise ValueError(f"Webhook hostname '{hostname}' resolves to private IP: {addr}") from exc
|
||||
except socket.gaierror:
|
||||
raise ValueError(f"Cannot resolve webhook hostname: {hostname}")
|
||||
raise ValueError(f"Cannot resolve webhook hostname: {hostname}") from None
|
||||
|
||||
|
||||
async def list_webhooks(
|
||||
|
||||
@@ -10,9 +10,9 @@ from sqlalchemy import desc, func, select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.core.audit import log_audit
|
||||
from app.models.notification import Notification
|
||||
from app.models.workflow import Workflow, WorkflowInstance, WorkflowStepHistory
|
||||
from app.core.notifications import post_system_message
|
||||
from app.core.visibility import apply_visibility_filter, check_single_entity_access
|
||||
from app.models.workflow import Workflow, WorkflowInstance, WorkflowStepHistory
|
||||
|
||||
|
||||
def _safe_iso(dt) -> str | None:
|
||||
@@ -526,15 +526,14 @@ async def advance_instance(
|
||||
|
||||
# Notify initiator
|
||||
if instance.initiated_by:
|
||||
notification = Notification(
|
||||
tenant_id=tenant_id,
|
||||
user_id=instance.initiated_by,
|
||||
type="workflow_rejected",
|
||||
title=f"Workflow '{workflow.name}' rejected",
|
||||
body=comment or f"Step {current_idx + 1} was rejected",
|
||||
await post_system_message(
|
||||
db,
|
||||
tenant_id,
|
||||
instance.initiated_by,
|
||||
"workflow_rejected",
|
||||
f"Workflow '{workflow.name}' rejected",
|
||||
comment or f"Step {current_idx + 1} was rejected",
|
||||
)
|
||||
db.add(notification)
|
||||
await db.flush()
|
||||
|
||||
await log_audit(
|
||||
db,
|
||||
@@ -733,14 +732,14 @@ async def auto_reject_timeout(
|
||||
|
||||
# Notify initiator
|
||||
if instance.initiated_by:
|
||||
notification = Notification(
|
||||
tenant_id=tenant_id,
|
||||
user_id=instance.initiated_by,
|
||||
type="workflow_timeout",
|
||||
title=f"Workflow '{workflow.name if workflow else 'Unknown'}' auto-rejected",
|
||||
body="The approval step timed out and was automatically rejected.",
|
||||
await post_system_message(
|
||||
db,
|
||||
tenant_id,
|
||||
instance.initiated_by,
|
||||
"workflow_timeout",
|
||||
f"Workflow '{workflow.name if workflow else 'Unknown'}' auto-rejected",
|
||||
"The approval step timed out and was automatically rejected.",
|
||||
)
|
||||
db.add(notification)
|
||||
|
||||
await db.flush()
|
||||
await db.refresh(instance)
|
||||
|
||||
@@ -9,12 +9,11 @@ from __future__ import annotations
|
||||
import uuid
|
||||
from typing import Any
|
||||
|
||||
from sqlalchemy import select, update, func
|
||||
from sqlalchemy import func, select, update
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from sqlalchemy.orm import selectinload
|
||||
|
||||
from app.models.user import UserTenant
|
||||
from app.models.workspace import Workspace, WorkspaceModule, WorkspaceUser, WorkspaceWidget
|
||||
from app.models.user import User, UserTenant
|
||||
|
||||
|
||||
def _workspace_to_dict(ws: Workspace, modules: list[WorkspaceModule] | None = None, user_count: int = 0) -> dict[str, Any]:
|
||||
@@ -52,7 +51,7 @@ async def list_workspaces(
|
||||
).order_by(Workspace.name)
|
||||
result = await db.execute(q)
|
||||
workspaces = result.scalars().all()
|
||||
|
||||
|
||||
items = []
|
||||
for ws in workspaces:
|
||||
# Count users
|
||||
@@ -63,7 +62,7 @@ async def list_workspaces(
|
||||
count_result = await db.execute(count_q)
|
||||
user_count = count_result.scalar() or 0
|
||||
items.append(_workspace_to_dict(ws, user_count=user_count))
|
||||
|
||||
|
||||
return {"items": items, "total": len(items)}
|
||||
|
||||
|
||||
@@ -79,7 +78,7 @@ async def get_workspace(
|
||||
ws = result.scalar_one_or_none()
|
||||
if ws is None:
|
||||
return None
|
||||
|
||||
|
||||
# Get modules
|
||||
mod_q = select(WorkspaceModule).where(
|
||||
WorkspaceModule.workspace_id == workspace_id,
|
||||
@@ -87,7 +86,7 @@ async def get_workspace(
|
||||
).order_by(WorkspaceModule.menu_order)
|
||||
mod_result = await db.execute(mod_q)
|
||||
modules = mod_result.scalars().all()
|
||||
|
||||
|
||||
# Count users
|
||||
count_q = select(func.count()).select_from(WorkspaceUser).where(
|
||||
WorkspaceUser.workspace_id == workspace_id,
|
||||
@@ -95,7 +94,7 @@ async def get_workspace(
|
||||
)
|
||||
count_result = await db.execute(count_q)
|
||||
user_count = count_result.scalar() or 0
|
||||
|
||||
|
||||
return _workspace_to_dict(ws, modules=modules, user_count=user_count)
|
||||
|
||||
|
||||
@@ -132,7 +131,7 @@ async def create_workspace(
|
||||
db.add(ws)
|
||||
await db.flush()
|
||||
await db.refresh(ws)
|
||||
|
||||
|
||||
# Auto-assign creator as manager
|
||||
wu = WorkspaceUser(
|
||||
tenant_id=tenant_id,
|
||||
@@ -144,7 +143,7 @@ async def create_workspace(
|
||||
)
|
||||
db.add(wu)
|
||||
await db.flush()
|
||||
|
||||
|
||||
return _workspace_to_dict(ws, user_count=1)
|
||||
|
||||
|
||||
@@ -167,7 +166,7 @@ async def update_workspace(
|
||||
ws = result.scalar_one_or_none()
|
||||
if ws is None:
|
||||
return None
|
||||
|
||||
|
||||
if name is not None:
|
||||
ws.name = name
|
||||
if icon is not None:
|
||||
@@ -189,7 +188,7 @@ async def update_workspace(
|
||||
ws.is_default = True
|
||||
elif is_default is False:
|
||||
ws.is_default = False
|
||||
|
||||
|
||||
await db.flush()
|
||||
await db.refresh(ws)
|
||||
return _workspace_to_dict(ws)
|
||||
@@ -227,7 +226,7 @@ async def set_workspace_modules(
|
||||
existing = await db.execute(existing_q)
|
||||
for m in existing.scalars().all():
|
||||
await db.delete(m)
|
||||
|
||||
|
||||
# Insert new modules
|
||||
result = []
|
||||
for mod in modules:
|
||||
@@ -249,7 +248,7 @@ async def set_workspace_modules(
|
||||
"menu_order": wm.menu_order,
|
||||
"config": wm.config or {},
|
||||
})
|
||||
|
||||
|
||||
return result
|
||||
|
||||
|
||||
@@ -317,7 +316,7 @@ async def get_my_workspaces(
|
||||
)
|
||||
result = await db.execute(q)
|
||||
rows = result.all()
|
||||
|
||||
|
||||
items = []
|
||||
for ws, wu in rows:
|
||||
# Get modules for this workspace
|
||||
@@ -328,7 +327,7 @@ async def get_my_workspaces(
|
||||
).order_by(WorkspaceModule.menu_order)
|
||||
mod_result = await db.execute(mod_q)
|
||||
modules = mod_result.scalars().all()
|
||||
|
||||
|
||||
items.append({
|
||||
"id": str(ws.id),
|
||||
"name": ws.name,
|
||||
@@ -346,7 +345,7 @@ async def get_my_workspaces(
|
||||
for m in modules
|
||||
],
|
||||
})
|
||||
|
||||
|
||||
return {"items": items, "total": len(items)}
|
||||
|
||||
|
||||
@@ -354,7 +353,7 @@ async def get_workspace_context(
|
||||
db: AsyncSession, tenant_id: uuid.UUID, user_id: uuid.UUID, workspace_id: uuid.UUID
|
||||
) -> dict[str, Any] | None:
|
||||
"""Get workspace context for a user — modules, widgets, config.
|
||||
|
||||
|
||||
Validates:
|
||||
- Workspace belongs to tenant
|
||||
- User is assigned or is system admin / tenant admin
|
||||
@@ -370,7 +369,7 @@ async def get_workspace_context(
|
||||
ws = ws_result.scalar_one_or_none()
|
||||
if ws is None:
|
||||
return None
|
||||
|
||||
|
||||
# Check user is assigned
|
||||
wu_q = select(WorkspaceUser).where(
|
||||
WorkspaceUser.workspace_id == workspace_id,
|
||||
@@ -381,7 +380,7 @@ async def get_workspace_context(
|
||||
wu = wu_result.scalar_one_or_none()
|
||||
if wu is None:
|
||||
return None # User not assigned — caller can check is_system_admin
|
||||
|
||||
|
||||
# Get all modules (including hidden) — frontend needs is_visible flag
|
||||
mod_q = select(WorkspaceModule).where(
|
||||
WorkspaceModule.workspace_id == workspace_id,
|
||||
@@ -389,7 +388,7 @@ async def get_workspace_context(
|
||||
).order_by(WorkspaceModule.menu_order)
|
||||
mod_result = await db.execute(mod_q)
|
||||
modules = mod_result.scalars().all()
|
||||
|
||||
|
||||
# Get widgets
|
||||
widget_q = select(WorkspaceWidget).where(
|
||||
WorkspaceWidget.workspace_id == workspace_id,
|
||||
@@ -397,7 +396,7 @@ async def get_workspace_context(
|
||||
).order_by(WorkspaceWidget.position_y, WorkspaceWidget.position_x)
|
||||
widget_result = await db.execute(widget_q)
|
||||
widgets = widget_result.scalars().all()
|
||||
|
||||
|
||||
return {
|
||||
"workspace_id": str(ws.id),
|
||||
"name": ws.name,
|
||||
|
||||
Reference in New Issue
Block a user