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:
@@ -9,28 +9,17 @@ Commands encapsulate business operations with:
|
||||
|
||||
Commands do NOT commit or rollback — the calling layer (FastAPI dependency
|
||||
``get_db``) manages the transaction boundary.
|
||||
|
||||
Note: Plugin-specific commands (mail, calendar, dms) have been moved to their
|
||||
respective plugins. Import them directly from the plugin package.
|
||||
"""
|
||||
|
||||
from app.commands.base import BaseCommand, CommandResult
|
||||
from app.commands.contact_commands import (
|
||||
CreateContactCommand,
|
||||
UpdateContactCommand,
|
||||
DeleteContactCommand,
|
||||
MergeContactsCommand,
|
||||
)
|
||||
from app.commands.dms_commands import (
|
||||
UploadFileCommand,
|
||||
DeleteFileCommand,
|
||||
)
|
||||
from app.commands.mail_commands import (
|
||||
SendMailCommand,
|
||||
MarkMailReadCommand,
|
||||
DeleteMailCommand,
|
||||
)
|
||||
from app.commands.calendar_commands import (
|
||||
CreateCalendarEntryCommand,
|
||||
UpdateCalendarEntryCommand,
|
||||
DeleteCalendarEntryCommand,
|
||||
UpdateContactCommand,
|
||||
)
|
||||
|
||||
__all__ = [
|
||||
@@ -40,12 +29,4 @@ __all__ = [
|
||||
"UpdateContactCommand",
|
||||
"DeleteContactCommand",
|
||||
"MergeContactsCommand",
|
||||
"UploadFileCommand",
|
||||
"DeleteFileCommand",
|
||||
"SendMailCommand",
|
||||
"MarkMailReadCommand",
|
||||
"DeleteMailCommand",
|
||||
"CreateCalendarEntryCommand",
|
||||
"UpdateCalendarEntryCommand",
|
||||
"DeleteCalendarEntryCommand",
|
||||
]
|
||||
|
||||
@@ -15,9 +15,8 @@ import uuid
|
||||
from dataclasses import dataclass, field
|
||||
from typing import Any
|
||||
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
import redis.asyncio as aioredis
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.core.permissions import check_permission
|
||||
|
||||
@@ -41,12 +40,12 @@ class CommandResult:
|
||||
events: list[dict] = field(default_factory=list)
|
||||
|
||||
@classmethod
|
||||
def ok(cls, data: dict | None = None, events: list[dict] | None = None) -> "CommandResult":
|
||||
def ok(cls, data: dict | None = None, events: list[dict] | None = None) -> CommandResult:
|
||||
"""Create a successful result."""
|
||||
return cls(success=True, data=data, events=events or [])
|
||||
|
||||
@classmethod
|
||||
def fail(cls, error: str) -> "CommandResult":
|
||||
def fail(cls, error: str) -> CommandResult:
|
||||
"""Create a failed result."""
|
||||
return cls(success=False, error=error)
|
||||
|
||||
|
||||
@@ -1,181 +0,0 @@
|
||||
"""Calendar commands — create, update, delete entries via Command pattern."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
import uuid
|
||||
from datetime import UTC, datetime
|
||||
from typing import Any
|
||||
|
||||
import redis.asyncio as aioredis
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.commands.base import BaseCommand, CommandResult
|
||||
from app.core.outbox import enqueue_outbox_event
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class CreateCalendarEntryCommand(BaseCommand):
|
||||
"""Create a new calendar entry (appointment, task, reminder)."""
|
||||
|
||||
permission = "calendar:write"
|
||||
|
||||
def __init__(self, calendar_id: str, title: str, start_at: str, end_at: str | None = None,
|
||||
description: str | None = None, location: str | None = None,
|
||||
entry_type: str = "appointment", status: str = "open"):
|
||||
self.calendar_id = calendar_id
|
||||
self.title = title
|
||||
self.start_at = start_at
|
||||
self.end_at = end_at
|
||||
self.description = description
|
||||
self.location = location
|
||||
self.entry_type = entry_type
|
||||
self.status = status
|
||||
|
||||
async def run(self, db: AsyncSession, redis: aioredis.Redis, current_user: dict[str, Any]) -> CommandResult:
|
||||
from app.plugins.builtins.calendar.models import Calendar, CalendarEntry
|
||||
|
||||
tenant_id = self._tenant_id(current_user)
|
||||
user_id = self._user_id(current_user)
|
||||
|
||||
try:
|
||||
cal_id = uuid.UUID(self.calendar_id)
|
||||
except ValueError:
|
||||
return CommandResult.fail("Invalid calendar_id")
|
||||
|
||||
# Verify calendar belongs to tenant
|
||||
cal_result = await db.execute(
|
||||
select(Calendar).where(Calendar.id == cal_id, Calendar.tenant_id == tenant_id)
|
||||
)
|
||||
if cal_result.scalar_one_or_none() is None:
|
||||
return CommandResult.fail("Calendar not found")
|
||||
|
||||
entry_id = uuid.uuid4()
|
||||
entry = CalendarEntry(
|
||||
id=entry_id,
|
||||
tenant_id=tenant_id,
|
||||
calendar_id=cal_id,
|
||||
title=self.title,
|
||||
description=self.description,
|
||||
location=self.location,
|
||||
start_at=datetime.fromisoformat(self.start_at),
|
||||
end_at=datetime.fromisoformat(self.end_at) if self.end_at else None,
|
||||
entry_type=self.entry_type,
|
||||
status=self.status,
|
||||
created_by=user_id,
|
||||
)
|
||||
db.add(entry)
|
||||
await db.flush()
|
||||
|
||||
await enqueue_outbox_event(db, tenant_id, "calendar.entry.created", {
|
||||
"entry_id": str(entry_id),
|
||||
"title": self.title,
|
||||
"start_at": self.start_at,
|
||||
})
|
||||
|
||||
return CommandResult.ok({
|
||||
"id": str(entry_id),
|
||||
"title": self.title,
|
||||
"start_at": self.start_at,
|
||||
"status": self.status,
|
||||
})
|
||||
|
||||
async def audit(self, db: AsyncSession, current_user: dict[str, Any]) -> None:
|
||||
from app.core.audit import log_audit
|
||||
await log_audit(
|
||||
db, self._tenant_id(current_user), self._user_id(current_user),
|
||||
action="calendar.entry.create", entity_type="calendar_entry",
|
||||
changes={"title": self.title, "start_at": self.start_at},
|
||||
)
|
||||
|
||||
|
||||
class UpdateCalendarEntryCommand(BaseCommand):
|
||||
"""Update an existing calendar entry."""
|
||||
|
||||
permission = "calendar:write"
|
||||
|
||||
def __init__(self, entry_id: str, data: dict[str, Any]):
|
||||
self.entry_id = entry_id
|
||||
self.data = data
|
||||
|
||||
async def run(self, db: AsyncSession, redis: aioredis.Redis, current_user: dict[str, Any]) -> CommandResult:
|
||||
from app.plugins.builtins.calendar.models import CalendarEntry
|
||||
|
||||
tenant_id = self._tenant_id(current_user)
|
||||
try:
|
||||
eid = uuid.UUID(self.entry_id)
|
||||
except ValueError:
|
||||
return CommandResult.fail("Invalid entry_id")
|
||||
|
||||
result = await db.execute(
|
||||
select(CalendarEntry).where(CalendarEntry.id == eid, CalendarEntry.tenant_id == tenant_id)
|
||||
)
|
||||
entry = result.scalar_one_or_none()
|
||||
if entry is None:
|
||||
return CommandResult.fail("Calendar entry not found")
|
||||
|
||||
# Apply updates
|
||||
for key, value in self.data.items():
|
||||
if hasattr(entry, key) and key not in ("id", "tenant_id", "created_at"):
|
||||
if key in ("start_at", "end_at") and isinstance(value, str):
|
||||
value = datetime.fromisoformat(value)
|
||||
setattr(entry, key, value)
|
||||
|
||||
await db.flush()
|
||||
|
||||
await enqueue_outbox_event(db, tenant_id, "calendar.entry.updated", {
|
||||
"entry_id": self.entry_id,
|
||||
"changes": self.data,
|
||||
})
|
||||
|
||||
return CommandResult.ok({"id": self.entry_id, "updated": True})
|
||||
|
||||
async def audit(self, db: AsyncSession, current_user: dict[str, Any]) -> None:
|
||||
from app.core.audit import log_audit
|
||||
await log_audit(
|
||||
db, self._tenant_id(current_user), self._user_id(current_user),
|
||||
action="calendar.entry.update", entity_type="calendar_entry",
|
||||
changes={"entry_id": self.entry_id, "fields": list(self.data.keys())},
|
||||
)
|
||||
|
||||
|
||||
class DeleteCalendarEntryCommand(BaseCommand):
|
||||
"""Delete a calendar entry."""
|
||||
|
||||
permission = "calendar:delete"
|
||||
|
||||
def __init__(self, entry_id: str):
|
||||
self.entry_id = entry_id
|
||||
|
||||
async def run(self, db: AsyncSession, redis: aioredis.Redis, current_user: dict[str, Any]) -> CommandResult:
|
||||
from app.plugins.builtins.calendar.models import CalendarEntry
|
||||
|
||||
tenant_id = self._tenant_id(current_user)
|
||||
try:
|
||||
eid = uuid.UUID(self.entry_id)
|
||||
except ValueError:
|
||||
return CommandResult.fail("Invalid entry_id")
|
||||
|
||||
result = await db.execute(
|
||||
select(CalendarEntry).where(CalendarEntry.id == eid, CalendarEntry.tenant_id == tenant_id)
|
||||
)
|
||||
entry = result.scalar_one_or_none()
|
||||
if entry is None:
|
||||
return CommandResult.fail("Calendar entry not found")
|
||||
|
||||
await db.delete(entry)
|
||||
await db.flush()
|
||||
|
||||
await enqueue_outbox_event(db, tenant_id, "calendar.entry.deleted", {"entry_id": self.entry_id})
|
||||
|
||||
return CommandResult.ok({"id": self.entry_id, "deleted": True})
|
||||
|
||||
async def audit(self, db: AsyncSession, current_user: dict[str, Any]) -> None:
|
||||
from app.core.audit import log_audit
|
||||
await log_audit(
|
||||
db, self._tenant_id(current_user), self._user_id(current_user),
|
||||
action="calendar.entry.delete", entity_type="calendar_entry",
|
||||
changes={"entry_id": self.entry_id},
|
||||
)
|
||||
@@ -14,20 +14,17 @@ from __future__ import annotations
|
||||
|
||||
import logging
|
||||
import uuid
|
||||
from datetime import datetime, timezone
|
||||
from typing import Any
|
||||
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
import redis.asyncio as aioredis
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.commands.base import BaseCommand, CommandResult
|
||||
from app.core.outbox import enqueue_outbox_event
|
||||
from app.core.state_machine import contact_state_machine, StateMachineError
|
||||
from app.core.state_machine import StateMachineError, contact_state_machine
|
||||
from app.models.audit import AuditLog
|
||||
from app.models.contact import Contact
|
||||
from app.services import contact_service, dedup_service
|
||||
from app.services.entity_history_service import record_history
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
@@ -1,159 +0,0 @@
|
||||
"""DMS commands — file upload, delete, restore via Command pattern."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import hashlib
|
||||
import logging
|
||||
import os
|
||||
import uuid
|
||||
from typing import Any
|
||||
|
||||
import redis.asyncio as aioredis
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.commands.base import BaseCommand, CommandResult
|
||||
from app.core.outbox import enqueue_outbox_event
|
||||
from app.core.storage import get_storage_backend
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
CHUNK_SIZE = 1024 * 1024 # 1MB
|
||||
|
||||
|
||||
def _sanitize_filename(filename: str) -> str:
|
||||
"""Sanitize a filename for safe use in Content-Disposition headers."""
|
||||
import re
|
||||
safe = os.path.basename(filename.replace("\\", "/"))
|
||||
safe = re.sub(r"[^a-zA-Z0-9.\-_\u00c0-\u017f\u4e00-\u9fff ]", "_", safe)
|
||||
safe = re.sub(r"\.{2,}", "_", safe)
|
||||
safe = re.sub(r" {2,}", " ", safe)
|
||||
safe = safe.lstrip(".").strip()
|
||||
if len(safe) > 200:
|
||||
name, ext = safe.rsplit(".", 1) if "." in safe[:200] else (safe[:200], "")
|
||||
safe = name[:200] + ("." + ext if ext else "")
|
||||
return safe or "file"
|
||||
|
||||
|
||||
class UploadFileCommand(BaseCommand):
|
||||
"""Upload a file to DMS with chunked streaming and SHA-256 hashing."""
|
||||
|
||||
permission = "dms:write"
|
||||
|
||||
def __init__(self, file_content: bytes, filename: str, mime_type: str, folder_id: str | None = None):
|
||||
self.file_content = file_content
|
||||
self.filename = _sanitize_filename(filename)
|
||||
self.mime_type = mime_type
|
||||
self.folder_id = folder_id
|
||||
|
||||
async def run(self, db: AsyncSession, redis: aioredis.Redis, current_user: dict[str, Any]) -> CommandResult:
|
||||
from app.plugins.builtins.dms.models import File as DmsFile, Folder
|
||||
|
||||
tenant_id = self._tenant_id(current_user)
|
||||
user_id = self._user_id(current_user)
|
||||
|
||||
# Validate folder if specified
|
||||
fid = None
|
||||
if self.folder_id:
|
||||
try:
|
||||
fid = uuid.UUID(self.folder_id)
|
||||
except ValueError:
|
||||
return CommandResult.fail("Invalid folder_id")
|
||||
folder_result = await db.execute(
|
||||
select(Folder).where(Folder.id == fid, Folder.tenant_id == tenant_id, Folder.deleted_at.is_(None))
|
||||
)
|
||||
if folder_result.scalar_one_or_none() is None:
|
||||
return CommandResult.fail("Folder not found")
|
||||
|
||||
# Calculate SHA-256
|
||||
sha256 = hashlib.sha256()
|
||||
sha256.update(self.file_content)
|
||||
content_hash = sha256.hexdigest()
|
||||
file_size = len(self.file_content)
|
||||
|
||||
# Create file record
|
||||
file_id = uuid.uuid4()
|
||||
storage_path = f"{tenant_id}/{file_id}"
|
||||
|
||||
# Save file
|
||||
storage = get_storage_backend()
|
||||
await storage.save(storage_path, self.file_content)
|
||||
|
||||
dms_file = DmsFile(
|
||||
id=file_id,
|
||||
tenant_id=tenant_id,
|
||||
name=self.filename,
|
||||
folder_id=fid,
|
||||
uploaded_by=user_id,
|
||||
mime_type=self.mime_type,
|
||||
size_bytes=file_size,
|
||||
storage_path=storage_path,
|
||||
content_hash=content_hash,
|
||||
)
|
||||
db.add(dms_file)
|
||||
await db.flush()
|
||||
|
||||
# Enqueue outbox event
|
||||
await enqueue_outbox_event(db, tenant_id, "dms.file.uploaded", {
|
||||
"file_id": str(file_id),
|
||||
"name": self.filename,
|
||||
"size_bytes": file_size,
|
||||
"content_hash": content_hash,
|
||||
})
|
||||
|
||||
return CommandResult.ok({
|
||||
"id": str(file_id),
|
||||
"name": self.filename,
|
||||
"size_bytes": file_size,
|
||||
"content_hash": content_hash,
|
||||
"mime_type": self.mime_type,
|
||||
})
|
||||
|
||||
async def audit(self, db: AsyncSession, current_user: dict[str, Any]) -> None:
|
||||
from app.core.audit import log_audit
|
||||
await log_audit(
|
||||
db, self._tenant_id(current_user), self._user_id(current_user),
|
||||
action="dms.file.upload", entity_type="dms_file",
|
||||
changes={"name": self.filename, "size": len(self.file_content)},
|
||||
)
|
||||
|
||||
|
||||
class DeleteFileCommand(BaseCommand):
|
||||
"""Soft-delete a DMS file."""
|
||||
|
||||
permission = "dms:delete"
|
||||
|
||||
def __init__(self, file_id: str):
|
||||
self.file_id = file_id
|
||||
|
||||
async def run(self, db: AsyncSession, redis: aioredis.Redis, current_user: dict[str, Any]) -> CommandResult:
|
||||
from app.plugins.builtins.dms.models import File as DmsFile
|
||||
from datetime import UTC, datetime
|
||||
|
||||
tenant_id = self._tenant_id(current_user)
|
||||
try:
|
||||
fid = uuid.UUID(self.file_id)
|
||||
except ValueError:
|
||||
return CommandResult.fail("Invalid file_id")
|
||||
|
||||
result = await db.execute(
|
||||
select(DmsFile).where(DmsFile.id == fid, DmsFile.tenant_id == tenant_id, DmsFile.deleted_at.is_(None))
|
||||
)
|
||||
dms_file = result.scalar_one_or_none()
|
||||
if dms_file is None:
|
||||
return CommandResult.fail("File not found")
|
||||
|
||||
dms_file.deleted_at = datetime.now(UTC)
|
||||
await db.flush()
|
||||
|
||||
await enqueue_outbox_event(db, tenant_id, "dms.file.deleted", {"file_id": self.file_id})
|
||||
|
||||
return CommandResult.ok({"id": self.file_id, "deleted": True})
|
||||
|
||||
async def audit(self, db: AsyncSession, current_user: dict[str, Any]) -> None:
|
||||
from app.core.audit import log_audit
|
||||
await log_audit(
|
||||
db, self._tenant_id(current_user), self._user_id(current_user),
|
||||
action="dms.file.delete", entity_type="dms_file",
|
||||
changes={"file_id": self.file_id},
|
||||
)
|
||||
@@ -1,175 +0,0 @@
|
||||
"""Mail commands — send, mark read/unread, delete via Command pattern."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
import uuid
|
||||
from datetime import UTC, datetime
|
||||
from typing import Any
|
||||
|
||||
import redis.asyncio as aioredis
|
||||
from sqlalchemy import select, update
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.commands.base import BaseCommand, CommandResult
|
||||
from app.core.outbox import enqueue_outbox_event
|
||||
from app.plugins.builtins.mail.services import sanitize_html
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class SendMailCommand(BaseCommand):
|
||||
"""Send an email via a configured IMAP/SMTP account."""
|
||||
|
||||
permission = "mail:send"
|
||||
|
||||
def __init__(self, account_id: str, to: list[str], subject: str, body_text: str, body_html: str | None = None, cc: list[str] | None = None, in_reply_to: str | None = None):
|
||||
self.account_id = account_id
|
||||
self.to = to
|
||||
self.subject = subject
|
||||
self.body_text = body_text
|
||||
self.body_html = body_html
|
||||
self.cc = cc or []
|
||||
self.in_reply_to = in_reply_to
|
||||
|
||||
async def run(self, db: AsyncSession, redis: aioredis.Redis, current_user: dict[str, Any]) -> CommandResult:
|
||||
from app.plugins.builtins.mail.models import Mail, MailAccount
|
||||
|
||||
tenant_id = self._tenant_id(current_user)
|
||||
user_id = self._user_id(current_user)
|
||||
|
||||
try:
|
||||
account_uuid = uuid.UUID(self.account_id)
|
||||
except ValueError:
|
||||
return CommandResult.fail("Invalid account_id")
|
||||
|
||||
# Verify account belongs to tenant
|
||||
acct_result = await db.execute(
|
||||
select(MailAccount).where(MailAccount.id == account_uuid, MailAccount.tenant_id == tenant_id)
|
||||
)
|
||||
account = acct_result.scalar_one_or_none()
|
||||
if account is None:
|
||||
return CommandResult.fail("Mail account not found")
|
||||
|
||||
# Create mail record
|
||||
mail_id = uuid.uuid4()
|
||||
mail = Mail(
|
||||
id=mail_id,
|
||||
tenant_id=tenant_id,
|
||||
account_id=account_uuid,
|
||||
message_id=f"<leocrm-{mail_id}@{account.email_address}>",
|
||||
from_addr=account.email_address,
|
||||
to_addr=",".join(self.to),
|
||||
cc_addr=",".join(self.cc) if self.cc else None,
|
||||
subject=self.subject,
|
||||
body_text=self.body_text,
|
||||
body_html_sanitized=sanitize_html(self.body_html) if self.body_html else None,
|
||||
direction="outgoing",
|
||||
received_at=datetime.now(UTC),
|
||||
is_read=True,
|
||||
folder="Sent",
|
||||
)
|
||||
db.add(mail)
|
||||
await db.flush()
|
||||
|
||||
# Enqueue outbox event for async SMTP send
|
||||
await enqueue_outbox_event(db, tenant_id, "mail.send", {
|
||||
"mail_id": str(mail_id),
|
||||
"account_id": self.account_id,
|
||||
"to": self.to,
|
||||
"subject": self.subject,
|
||||
})
|
||||
|
||||
return CommandResult.ok({
|
||||
"id": str(mail_id),
|
||||
"status": "queued",
|
||||
"to": self.to,
|
||||
"subject": self.subject,
|
||||
})
|
||||
|
||||
async def audit(self, db: AsyncSession, current_user: dict[str, Any]) -> None:
|
||||
from app.core.audit import log_audit
|
||||
await log_audit(
|
||||
db, self._tenant_id(current_user), self._user_id(current_user),
|
||||
action="mail.send", entity_type="mail",
|
||||
changes={"to": self.to, "subject": self.subject},
|
||||
)
|
||||
|
||||
|
||||
class MarkMailReadCommand(BaseCommand):
|
||||
"""Mark a mail as read or unread."""
|
||||
|
||||
permission = "mail:write"
|
||||
|
||||
def __init__(self, mail_id: str, is_read: bool = True):
|
||||
self.mail_id = mail_id
|
||||
self.is_read = is_read
|
||||
|
||||
async def run(self, db: AsyncSession, redis: aioredis.Redis, current_user: dict[str, Any]) -> CommandResult:
|
||||
from app.plugins.builtins.mail.models import Mail
|
||||
|
||||
tenant_id = self._tenant_id(current_user)
|
||||
try:
|
||||
mid = uuid.UUID(self.mail_id)
|
||||
except ValueError:
|
||||
return CommandResult.fail("Invalid mail_id")
|
||||
|
||||
result = await db.execute(
|
||||
select(Mail).where(Mail.id == mid, Mail.tenant_id == tenant_id)
|
||||
)
|
||||
mail = result.scalar_one_or_none()
|
||||
if mail is None:
|
||||
return CommandResult.fail("Mail not found")
|
||||
|
||||
mail.is_read = self.is_read
|
||||
await db.flush()
|
||||
|
||||
return CommandResult.ok({"id": self.mail_id, "is_read": self.is_read})
|
||||
|
||||
async def audit(self, db: AsyncSession, current_user: dict[str, Any]) -> None:
|
||||
from app.core.audit import log_audit
|
||||
await log_audit(
|
||||
db, self._tenant_id(current_user), self._user_id(current_user),
|
||||
action="mail.mark_read", entity_type="mail",
|
||||
changes={"mail_id": self.mail_id, "is_read": self.is_read},
|
||||
)
|
||||
|
||||
|
||||
class DeleteMailCommand(BaseCommand):
|
||||
"""Soft-delete a mail."""
|
||||
|
||||
permission = "mail:delete"
|
||||
|
||||
def __init__(self, mail_id: str):
|
||||
self.mail_id = mail_id
|
||||
|
||||
async def run(self, db: AsyncSession, redis: aioredis.Redis, current_user: dict[str, Any]) -> CommandResult:
|
||||
from app.plugins.builtins.mail.models import Mail
|
||||
|
||||
tenant_id = self._tenant_id(current_user)
|
||||
try:
|
||||
mid = uuid.UUID(self.mail_id)
|
||||
except ValueError:
|
||||
return CommandResult.fail("Invalid mail_id")
|
||||
|
||||
result = await db.execute(
|
||||
select(Mail).where(Mail.id == mid, Mail.tenant_id == tenant_id, Mail.deleted_at.is_(None))
|
||||
)
|
||||
mail = result.scalar_one_or_none()
|
||||
if mail is None:
|
||||
return CommandResult.fail("Mail not found")
|
||||
|
||||
mail.deleted_at = datetime.now(UTC)
|
||||
await db.flush()
|
||||
|
||||
await enqueue_outbox_event(db, tenant_id, "mail.deleted", {"mail_id": self.mail_id})
|
||||
|
||||
return CommandResult.ok({"id": self.mail_id, "deleted": True})
|
||||
|
||||
async def audit(self, db: AsyncSession, current_user: dict[str, Any]) -> None:
|
||||
from app.core.audit import log_audit
|
||||
await log_audit(
|
||||
db, self._tenant_id(current_user), self._user_id(current_user),
|
||||
action="mail.delete", entity_type="mail",
|
||||
changes={"mail_id": self.mail_id},
|
||||
)
|
||||
Reference in New Issue
Block a user