Files
leocrm/app/core/jobs.py
T

531 lines
18 KiB
Python
Raw Normal View History

"""ARQ job queue integration."""
from __future__ import annotations
2026-07-25 21:03:46 +02:00
import logging
from typing import Any
from arq import create_pool
from arq.connections import ArqRedis, RedisSettings
from app.config import get_settings
2026-07-25 21:03:46 +02:00
logger = logging.getLogger(__name__)
2026-07-25 21:03:46 +02:00
# ── Global ARQ pool singleton ────────────────────────────────────────────────
_job_pool: ArqRedis | None = None
async def init_job_pool() -> ArqRedis:
"""Create and store the global ARQ job pool.
Called once during app lifespan startup so every subsequent enqueue
reuses the same connection instead of opening a new one per call.
"""
global _job_pool
if _job_pool is not None:
logger.warning("init_job_pool() called but pool already initialized")
return _job_pool
settings = get_settings()
redis_settings = RedisSettings.from_dsn(settings.redis_url)
2026-07-25 21:03:46 +02:00
_job_pool = await create_pool(redis_settings)
logger.info("Global ARQ job pool initialized")
return _job_pool
async def close_job_pool() -> None:
"""Close the global ARQ job pool. Called during app lifespan shutdown."""
global _job_pool
if _job_pool is not None:
await _job_pool.close()
_job_pool = None
logger.info("Global ARQ job pool closed")
async def get_job_pool() -> ArqRedis:
"""Return the global ARQ job pool singleton.
If init_job_pool() has not been called yet (e.g. during testing),
a new pool is created lazily so callers always get a working connection.
"""
global _job_pool
if _job_pool is None:
settings = get_settings()
redis_settings = RedisSettings.from_dsn(settings.redis_url)
_job_pool = await create_pool(redis_settings)
logger.debug("ARQ job pool created lazily (init_job_pool not called)")
return _job_pool
async def enqueue_job(job_name: str, *args: Any, **kwargs: Any) -> str | None:
"""Enqueue a background job. Returns job_id or None."""
pool = await get_job_pool()
job = await pool.enqueue_job(job_name, *args, **kwargs)
if job:
return job.job_id
return None
async def get_job_status(job_id: str) -> dict[str, Any] | None:
"""Get the status of a background job."""
pool = await get_job_pool()
job_info = await pool.job_info(job_id)
if job_info is None:
return None
return {
"job_id": job_id,
"status": str(job_info.status),
"result": job_info.result,
"enqueue_time": job_info.enqueue_time.isoformat() if job_info.enqueue_time else None,
"start_time": job_info.start_time.isoformat() if job_info.start_time else None,
"finish_time": job_info.finish_time.isoformat() if job_info.finish_time else None,
}
# ── Password Reset Email Job ─────────────────────────────────────────────────
async def send_password_reset_email(
ctx: dict[str, Any],
*,
user_id: str,
email: str,
raw_token: str,
expires_at: str,
) -> None:
"""Send a password reset email via SMTP.
This is an ARQ worker function. It is registered with the job registry
so the worker can execute it when the auth service enqueues it.
"""
from email.mime.multipart import MIMEMultipart
from email.mime.text import MIMEText
import aiosmtplib
settings = get_settings()
# Build the reset URL
reset_url = f"{settings.frontend_url.rstrip('/')}/reset-password?token={raw_token}"
# Build the email
msg = MIMEMultipart("alternative")
msg["From"] = settings.smtp_from_email
msg["To"] = email
msg["Subject"] = "LeoCRM — Passwort zurücksetzen"
text_body = (
f"Sie haben angefordert, Ihr Passwort zurückzusetzen.\n\n"
f"Klicken Sie auf den folgenden Link, um ein neues Passwort zu setzen:\n"
f"{reset_url}\n\n"
f"Dieser Link ist gültig bis {expires_at}.\n\n"
f"Falls Sie diese Anfrage nicht gestellt haben, können Sie diese\n"
f"E-Mail ignorieren. Ihr Passwort bleibt unverändert.\n"
)
html_body = (
f"<html><body>"
f"<h2>Passwort zurücksetzen</h2>"
f"<p>Sie haben angefordert, Ihr Passwort zurückzusetzen.</p>"
f"<p><a href=\"{reset_url}\">Passwort jetzt zurücksetzen</a></p>"
f"<p>Dieser Link ist gültig bis {expires_at}.</p>"
f"<p>Falls Sie diese Anfrage nicht gestellt haben, können Sie diese "
f"E-Mail ignorieren. Ihr Passwort bleibt unverändert.</p>"
f"</body></html>"
)
msg.attach(MIMEText(text_body, "plain", "utf-8"))
msg.attach(MIMEText(html_body, "html", "utf-8"))
# Send via SMTP (port 465 = implicit TLS, port 587 = STARTTLS)
use_tls = settings.smtp_port == 465
start_tls = settings.smtp_use_tls and not use_tls
await aiosmtplib.send(
msg,
hostname=settings.smtp_host,
port=settings.smtp_port,
username=settings.smtp_username,
password=settings.smtp_password,
use_tls=use_tls,
start_tls=start_tls,
)
logger.info("Password reset email sent to %s for user %s", email, user_id)
# Register the job so the worker can find it
from app.core.job_registry import register_job # noqa: E402
register_job("send_password_reset_email", send_password_reset_email)
# ── DSAR Processing Job (G1 DSGVO: Art. 15 Auskunft / Art. 17 Löschung) ─────
async def _dsar_collect_user_data(db: Any, tenant_id: str, user_id: str) -> dict[str, Any]:
"""Collect every data category the dsgvo-export route promises.
Shared by type=access (full export) so both paths stay consistent.
"""
from datetime import UTC, datetime
from uuid import UUID as PyUUID
from sqlalchemy import or_ as sa_or_
from sqlalchemy import select as sa_select
from app.models.audit import AuditLog
from app.models.contact import Contact
from app.models.notification import Notification
from app.models.user import User
uid = PyUUID(user_id)
tid = PyUUID(tenant_id)
export_data: dict[str, Any] = {
"user_id": user_id,
"exported_at": datetime.now(UTC).isoformat(),
"legal_basis": "GDPR Art. 15 (access) / Art. 20 (portability)",
"data": {},
}
# Profile
user = (
await db.execute(sa_select(User).where(User.id == uid))
).scalar_one_or_none()
if user:
export_data["data"]["profile"] = {
"email": user.email,
"name": user.name,
"is_active": user.is_active,
"created_at": user.created_at.isoformat() if user.created_at else None,
}
# Contacts owned by the user
contacts = (
await db.execute(
sa_select(Contact).where(
Contact.tenant_id == tid,
Contact.owner_id == uid,
Contact.deleted_at.is_(None),
)
)
).scalars().all()
export_data["data"]["contacts"] = [
{
"id": str(c.id),
"type": c.type,
"displayname": c.displayname,
"email_1": c.email_1,
"email_2": c.email_2,
}
for c in contacts
]
# Audit trail entries by/about the user (bounded to keep payloads sane)
audit_entries = (
await db.execute(
sa_select(AuditLog).where(
AuditLog.tenant_id == tid,
AuditLog.user_id == uid,
).limit(1000)
)
).scalars().all()
export_data["data"]["audit_log"] = [
{
"action": a.action,
"entity_type": a.entity_type,
"timestamp": a.timestamp.isoformat() if a.timestamp else None,
}
for a in audit_entries
]
# Notifications addressed to the user
notifications = (
await db.execute(
sa_select(Notification).where(
Notification.tenant_id == tid,
Notification.owner_id == uid,
).limit(1000)
)
).scalars().all()
export_data["data"]["notifications"] = [
{
"id": str(n.id),
"type": getattr(n, "type", None),
"title": getattr(n, "title", None),
"created_at": n.created_at.isoformat() if n.created_at else None,
}
for n in notifications
]
# ── Categories promised by the dsgvo-export route docstring ──
# (G1-b: mail accounts, tasks, calendar entries, comm messages)
try:
from app.plugins.builtins.mail.models import MailAccount
mail_accounts = (
await db.execute(
sa_select(MailAccount).where(
MailAccount.tenant_id == tid,
MailAccount.user_id == uid,
).limit(500)
)
).scalars().all()
export_data["data"]["mail_accounts"] = [
{
"id": str(a.id),
"email_address": a.email_address,
"display_name": a.display_name,
"is_shared": a.is_shared,
"is_active": a.is_active,
}
for a in mail_accounts
]
except ImportError:
pass
try:
from app.plugins.builtins.tasks.models import Task as TaskModel
tasks = (
await db.execute(
sa_select(TaskModel).where(
sa_or_(TaskModel.owner_id == uid, TaskModel.assigned_to == uid),
TaskModel.tenant_id == tid,
TaskModel.deleted_at.is_(None),
).limit(1000)
)
).scalars().all()
export_data["data"]["tasks"] = [
{
"id": str(t.id),
"title": t.title,
"status": t.status,
"priority": t.priority,
"due_date": t.due_date.isoformat() if t.due_date else None,
}
for t in tasks
]
except ImportError:
pass
try:
from app.plugins.builtins.calendar.models import CalendarEntry as CalEntry
cal_entries = (
await db.execute(
sa_select(CalEntry).where(
CalEntry.tenant_id == tid,
CalEntry.owner_id == uid,
CalEntry.deleted_at.is_(None),
).limit(1000)
)
).scalars().all()
export_data["data"]["calendar_entries"] = [
{
"id": str(e.id),
"title": e.title,
"entry_type": e.entry_type,
"start_at": e.start_at.isoformat() if e.start_at else None,
"end_at": e.end_at.isoformat() if e.end_at else None,
}
for e in cal_entries
]
except ImportError:
pass
try:
from app.plugins.builtins.kommunikation.models import CommMessage
comm_messages = (
await db.execute(
sa_select(CommMessage).where(
CommMessage.sender_id == uid,
CommMessage.tenant_id == tid,
).limit(1000)
)
).scalars().all()
export_data["data"]["comm_messages"] = [
{
"id": str(m.id),
"sender_type": m.sender_type,
"content": m.content[:500],
"created_at": m.created_at.isoformat() if m.created_at else None,
}
for m in comm_messages
]
except ImportError:
pass
return export_data
async def _dsar_execute_deletion(db: Any, tenant_id: str, user_id: str) -> dict[str, int]:
"""Execute GDPR Art. 17 erasure for a user within one tenant.
Strategy (respects retention duties):
- Contacts owned by the user → soft-delete via deleted_at
(audit history must remain intact — it is not personal data of the
subject but business record; retention policy governs its cleanup)
- Notifications owned by the user → hard delete
- User account → deactivate (is_active=False), clear personal fields,
scramble password hash and email (keeps FK integrity for audit rows)
Returns counters for the audit entry.
"""
from datetime import UTC, datetime
from uuid import UUID as PyUUID
from sqlalchemy import select as sa_select
from sqlalchemy import update as sa_update
from app.core.audit import log_audit
from app.models.contact import Contact
from app.models.notification import Notification
from app.models.user import User
uid = PyUUID(user_id)
tid = PyUUID(tenant_id)
counts: dict[str, int] = {}
# 1. Soft-delete contacts owned by the user
contact_result = await db.execute(
sa_select(Contact).where(
Contact.tenant_id == tid,
Contact.owner_id == uid,
Contact.deleted_at.is_(None),
)
)
contacts = contact_result.scalars().all()
for c in contacts:
c.deleted_at = datetime.now(UTC)
counts["contacts_soft_deleted"] = len(contacts)
# 2. Hard-delete notifications owned by the user
notif_result = await db.execute(
sa_select(Notification).where(
Notification.tenant_id == tid,
Notification.owner_id == uid,
)
)
notifications = notif_result.scalars().all()
for n in notifications:
await db.delete(n)
counts["notifications_deleted"] = len(notifications)
# 3. Anonymize + deactivate the account (FK integrity for audit rows kept)
await db.execute(
sa_update(User)
.where(User.id == uid)
.values(
email=f"erased.{uid.hex[:16]}@anonymized.invalid",
name="[gelöscht gemäß DSGVO Art. 17]",
first_name=None,
last_name=None,
avatar_url=None,
password_hash="!dsar-erased",
is_active=False,
preferences={},
)
)
counts["user_anonymized"] = 1
# 4. Audit the erasure itself (who/what/when — required by Art. 17 recital)
await log_audit(
db,
tid,
user_id,
"dsar_erasure",
"user",
uid,
{"target_user": user_id, **counts},
)
return counts
async def process_dsar(
ctx: dict[str, Any],
*,
user_id: str,
tenant_id: str,
request_type: str,
) -> dict[str, Any]:
"""Process a GDPR Data Subject Access Request (DSAR).
ARQ worker function registered as "process_dsar".
request_type:
- "access": collect all data categories (Art. 15/20) and post a system
message that the export is ready (served via the existing dsgvo-export
endpoint).
- "deletion": execute Art. 17 erasure (soft-delete contacts, hard-delete
notifications, anonymize+deactivate account) and audit it.
- "rectification": post a system message asking admins to handle the
correction manually.
Returns a summary dict for the job result.
"""
import logging
import uuid as uuid_module
from app.core.db import get_worker_session_factory
from app.core.notifications import post_system_message
logger = logging.getLogger(__name__)
tid = uuid_module.UUID(tenant_id)
uid = uuid_module.UUID(user_id)
factory = get_worker_session_factory()
async with factory() as db:
try:
if request_type == "access":
data = await _dsar_collect_user_data(db, tenant_id, user_id)
await db.commit()
categories = list(data.get("data", {}).keys())
await post_system_message(
db,
tid,
uid,
"dsar_access_ready",
"DSGVO-Auskunft bereit",
f"Datenkategorien: {', '.join(categories)}",
severity="info",
)
await db.commit()
logger.info("DSAR access processed for user %s", user_id)
return {"type": request_type, "status": "completed", "categories": categories}
if request_type == "deletion":
counts = await _dsar_execute_deletion(db, tenant_id, user_id)
await db.commit()
await post_system_message(
db,
tid,
uid,
"dsar_deletion_done",
"DSGVO-Löschung ausgeführt",
f"Kontakten soft-gelöscht: {counts.get('contacts_soft_deleted', 0)}; Konto anonymisiert.",
severity="info",
)
await db.commit()
logger.info("DSAR deletion executed for user %s: %s", user_id, counts)
return {"type": request_type, "status": "completed", **counts}
if request_type == "rectification":
await post_system_message(
db,
tid,
uid,
"dsar_rectification_requested",
"DSGVO-Berichtigung angefordert",
f"Manuelle Bearbeitung für User {user_id} erforderlich.",
severity="warning",
)
await db.commit()
logger.info("DSAR rectification requested for user %s", user_id)
return {"type": request_type, "status": "queued_for_manual_handling"}
logger.warning("Unknown DSAR request_type '%s' for user %s", request_type, user_id)
return {"type": request_type, "status": "unknown_type"}
except Exception:
await db.rollback()
raise
register_job("process_dsar", process_dsar)