Watch
1
0
Fork
You've already forked Seyyed_arc
0
forked from hesabix/arc
Seyyed_arc/hesabixAPI/app/services/crm_calendar_note_service.py
2026-04-25 02:31:13 +03:30

842 lines
28 KiB
Python
Executable file

# noqa: D100
"""منطق یادداشت و تقویم CRM: دسترسی، انواع پیش‌فرض، audit"""
from __future__ import annotations
import json
from datetime import date, datetime
from typing import Any, Dict, List, Optional
from sqlalchemy import and_, exists, or_
from sqlalchemy.orm import Session, joinedload, selectinload
from adapters.db.models.business import Business
from adapters.db.models.business_permission import BusinessPermission
from adapters.db.models.crm import (
CrmNote,
CrmNoteAclUser,
CrmNoteAuditEvent,
CrmNoteComment,
CrmNoteType,
Lead,
)
from adapters.db.models.user import User
from app.core.auth_dependency import AuthContext
from app.core.responses import ApiError
DEFAULT_NOTE_TYPES: List[Dict[str, Any]] = [
{
"code": "announcement",
"title_i18n": {"fa": "اطلاعیه", "en": "Announcement"},
"scheduling_mode": "day_only",
"allow_comments": True,
"sort_order": 10,
},
{
"code": "personal_note",
"title_i18n": {"fa": "یادداشت شخصی", "en": "Personal note"},
"scheduling_mode": "day_only",
"allow_comments": False,
"sort_order": 20,
},
{
"code": "meeting",
"title_i18n": {"fa": "جلسه", "en": "Meeting"},
"scheduling_mode": "meeting",
"allow_comments": True,
"sort_order": 30,
},
{
"code": "task",
"title_i18n": {"fa": "وظیفه", "en": "Task"},
"scheduling_mode": "day_only",
"allow_comments": False,
"sort_order": 40,
},
]
def resolve_i18n_map(title_i18n: dict | None, lang: str) -> str:
if not title_i18n or not isinstance(title_i18n, dict):
return ""
base = (lang or "en").split("-")[0].lower()
if base not in ("fa", "en"):
base = "en"
v = title_i18n.get(base) or title_i18n.get("fa") or title_i18n.get("en")
if isinstance(v, str) and v.strip():
return v.strip()
for _k, val in title_i18n.items():
if isinstance(val, str) and val.strip():
return val.strip()
return ""
def _user_display_name(user: User | None) -> str:
if not user:
return ""
parts = [user.first_name or "", user.last_name or ""]
name = " ".join(p for p in parts if p).strip()
return name or (user.email or user.mobile or str(user.id))
def user_has_business_join(db: Session, user_id: int, business_id: int) -> bool:
biz = db.get(Business, business_id)
if not biz or getattr(biz, "deleted_at", None) is not None:
return False
if getattr(biz, "owner_id", None) == user_id:
return True
perm = (
db.query(BusinessPermission)
.filter(
BusinessPermission.user_id == int(user_id),
BusinessPermission.business_id == int(business_id),
)
.first()
)
if not perm:
return False
raw = perm.business_permissions
normalized: dict = {}
if isinstance(raw, dict):
normalized = raw
elif isinstance(raw, list):
try:
if all(isinstance(item, list) and len(item) == 2 for item in raw):
normalized = {k: v for k, v in raw if isinstance(k, str)}
elif all(isinstance(item, dict) for item in raw):
merged: dict = {}
for item in raw:
merged.update({k: v for k, v in item.items()})
normalized = merged
except Exception:
normalized = {}
return normalized.get("join") is True
def ensure_default_note_types(db: Session, business_id: int) -> None:
existing = db.query(CrmNoteType.id).filter(CrmNoteType.business_id == business_id).first()
if existing:
return
now = datetime.utcnow()
for row in DEFAULT_NOTE_TYPES:
db.add(
CrmNoteType(
business_id=business_id,
code=row["code"],
title_i18n=dict(row["title_i18n"]),
scheduling_mode=row["scheduling_mode"],
allow_comments=bool(row["allow_comments"]),
is_system=True,
is_active=True,
sort_order=int(row["sort_order"]),
created_at=now,
updated_at=now,
)
)
db.flush()
def _audit(
db: Session,
*,
business_id: int,
note_id: int,
actor_user_id: int,
action: str,
payload: dict | None = None,
) -> None:
db.add(
CrmNoteAuditEvent(
business_id=business_id,
note_id=note_id,
actor_user_id=actor_user_id,
action=action,
payload=payload,
occurred_at=datetime.utcnow(),
)
)
def _note_visible_filter(user_id: int):
acl_exists = exists().where(
CrmNoteAclUser.note_id == CrmNote.id,
CrmNoteAclUser.user_id == user_id,
)
return or_(
CrmNote.visibility == "business_public",
and_(CrmNote.visibility == "private", CrmNote.created_by_user_id == user_id),
and_(
CrmNote.visibility == "shared",
or_(CrmNote.created_by_user_id == user_id, acl_exists),
),
)
def can_view_note(db: Session, ctx: AuthContext, note: CrmNote) -> bool:
uid = ctx.get_user_id()
if uid is None:
return False
if ctx.is_superadmin():
return True
if ctx.is_business_owner(note.business_id):
return True
if not user_has_business_join(db, int(uid), note.business_id):
return False
q = (
db.query(CrmNote.id)
.filter(
CrmNote.id == note.id,
CrmNote.deleted_at.is_(None),
_note_visible_filter(int(uid)),
)
.first()
)
return q is not None
def can_edit_note(db: Session, ctx: AuthContext, note: CrmNote) -> bool:
uid = ctx.get_user_id()
if uid is None:
return False
if ctx.is_superadmin() or ctx.is_business_owner(note.business_id):
return True
if note.created_by_user_id == int(uid):
return True
return False
def comments_enabled_for(note: CrmNote, ntype: CrmNoteType) -> bool:
if note.visibility != "business_public":
return False
return bool(ntype.allow_comments)
def list_note_types(db: Session, business_id: int, lang: str) -> List[dict]:
ensure_default_note_types(db, business_id)
db.flush()
rows = (
db.query(CrmNoteType)
.filter(CrmNoteType.business_id == business_id, CrmNoteType.is_active.is_(True))
.order_by(CrmNoteType.sort_order, CrmNoteType.id)
.all()
)
out: List[dict] = []
for r in rows:
out.append(
{
"id": r.id,
"business_id": r.business_id,
"code": r.code,
"title_i18n": r.title_i18n,
"title": resolve_i18n_map(r.title_i18n, lang),
"scheduling_mode": r.scheduling_mode,
"allow_comments": r.allow_comments,
"is_system": r.is_system,
"sort_order": r.sort_order,
}
)
return out
def create_note_type(
db: Session,
business_id: int,
*,
code: str,
title_i18n: dict,
scheduling_mode: str,
allow_comments: bool,
sort_order: int,
lang: str,
translator,
) -> dict:
ensure_default_note_types(db, business_id)
code_n = (code or "").strip().lower()
if scheduling_mode not in ("day_only", "meeting"):
raise ApiError(
"CRM_NOTE_TYPE_INVALID_MODE",
"Invalid scheduling_mode",
translator=translator,
)
dup = db.query(CrmNoteType.id).filter(CrmNoteType.business_id == business_id, CrmNoteType.code == code_n).first()
if dup:
raise ApiError("CRM_NOTE_TYPE_CODE_EXISTS", "Code exists", translator=translator)
now = datetime.utcnow()
row = CrmNoteType(
business_id=business_id,
code=code_n,
title_i18n=dict(title_i18n),
scheduling_mode=scheduling_mode,
allow_comments=allow_comments,
is_system=False,
is_active=True,
sort_order=sort_order,
created_at=now,
updated_at=now,
)
db.add(row)
db.flush()
return {
"id": row.id,
"business_id": row.business_id,
"code": row.code,
"title_i18n": row.title_i18n,
"title": resolve_i18n_map(row.title_i18n, lang),
"scheduling_mode": row.scheduling_mode,
"allow_comments": row.allow_comments,
"is_system": row.is_system,
"sort_order": row.sort_order,
}
def update_note_type(
db: Session,
business_id: int,
type_id: int,
*,
title_i18n: dict | None,
scheduling_mode: str | None,
allow_comments: bool | None,
is_active: bool | None,
sort_order: int | None,
lang: str,
translator,
) -> dict:
row = db.get(CrmNoteType, type_id)
if not row or row.business_id != business_id:
raise ApiError("CRM_NOTE_TYPE_NOT_FOUND", "Not found", http_status=404, translator=translator)
if scheduling_mode is not None and scheduling_mode not in ("day_only", "meeting"):
raise ApiError("CRM_NOTE_TYPE_INVALID_MODE", "Invalid mode", translator=translator)
if title_i18n is not None:
row.title_i18n = dict(title_i18n)
if scheduling_mode is not None:
row.scheduling_mode = scheduling_mode
if allow_comments is not None:
row.allow_comments = allow_comments
if is_active is not None:
row.is_active = is_active
if sort_order is not None:
row.sort_order = sort_order
row.updated_at = datetime.utcnow()
db.flush()
return {
"id": row.id,
"business_id": row.business_id,
"code": row.code,
"title_i18n": row.title_i18n,
"title": resolve_i18n_map(row.title_i18n, lang),
"scheduling_mode": row.scheduling_mode,
"allow_comments": row.allow_comments,
"is_system": row.is_system,
"sort_order": row.sort_order,
}
def delete_note_type(db: Session, business_id: int, type_id: int, translator) -> None:
row = db.get(CrmNoteType, type_id)
if not row or row.business_id != business_id:
raise ApiError("CRM_NOTE_TYPE_NOT_FOUND", "Not found", http_status=404, translator=translator)
if row.is_system:
raise ApiError("CRM_NOTE_TYPE_SYSTEM", "Cannot delete system type", translator=translator)
in_use = db.query(CrmNote.id).filter(CrmNote.note_type_id == type_id, CrmNote.deleted_at.is_(None)).first()
if in_use:
raise ApiError("CRM_NOTE_TYPE_IN_USE", "Type in use", translator=translator)
db.delete(row)
def _validate_lead(db: Session, business_id: int, lead_id: int | None, translator) -> None:
if lead_id is None:
return
lead = db.get(Lead, lead_id)
if not lead or lead.business_id != business_id:
raise ApiError("CRM_NOTE_LEAD_INVALID", "Invalid lead", translator=translator)
def _validate_shared_users(db: Session, business_id: int, creator_id: int, user_ids: List[int], translator) -> List[int]:
clean = sorted({int(u) for u in user_ids if int(u) != int(creator_id)})
if not clean:
raise ApiError("CRM_NOTE_SHARED_EMPTY", "shared_user_ids required", translator=translator)
for uid in clean:
if not user_has_business_join(db, uid, business_id):
raise ApiError("CRM_NOTE_SHARED_USER_INVALID", "Invalid user", translator=translator)
return clean
def create_note(
db: Session,
ctx: AuthContext,
business_id: int,
*,
note_type_id: int,
visibility: str,
title: str | None,
body: str,
occurs_on: date | None,
starts_at: datetime | None,
ends_at: datetime | None,
lead_id: int | None,
shared_user_ids: List[int] | None,
translator,
) -> CrmNote:
ensure_default_note_types(db, business_id)
uid = ctx.get_user_id()
if uid is None:
raise ApiError("UNAUTHORIZED", "Unauthorized", http_status=401, translator=translator)
if visibility not in ("private", "business_public", "shared"):
raise ApiError("CRM_NOTE_VISIBILITY_INVALID", "Invalid visibility", translator=translator)
ntype = db.get(CrmNoteType, note_type_id)
if not ntype or ntype.business_id != business_id or not ntype.is_active:
raise ApiError("CRM_NOTE_TYPE_NOT_FOUND", "Note type not found", http_status=404, translator=translator)
occ: date | None = occurs_on
st = starts_at
en = ends_at
if ntype.scheduling_mode == "meeting":
if st is None:
raise ApiError("CRM_NOTE_MEETING_START", "starts_at required", translator=translator)
occ = st.date()
else:
if occ is None:
raise ApiError("CRM_NOTE_DATE_REQUIRED", "occurs_on required", translator=translator)
st = None
en = None
acl: List[int] = []
if visibility == "shared":
if not shared_user_ids:
raise ApiError("CRM_NOTE_SHARED_EMPTY", "shared_user_ids required", translator=translator)
acl = _validate_shared_users(db, business_id, int(uid), shared_user_ids, translator)
_validate_lead(db, business_id, lead_id, translator)
now = datetime.utcnow()
note = CrmNote(
business_id=business_id,
note_type_id=note_type_id,
visibility=visibility,
title=(title or "").strip() or None,
body=body.strip(),
occurs_on=occ, # type: ignore[arg-type]
starts_at=st,
ends_at=en,
lead_id=lead_id,
created_by_user_id=int(uid),
status="active",
deleted_at=None,
created_at=now,
updated_at=now,
)
db.add(note)
db.flush()
for x in acl:
db.add(
CrmNoteAclUser(
business_id=business_id,
note_id=note.id,
user_id=x,
created_at=now,
)
)
db.flush()
_audit(
db,
business_id=business_id,
note_id=note.id,
actor_user_id=int(uid),
action="CREATED",
payload={
"visibility": visibility,
"note_type_id": note_type_id,
"occurs_on": occ.isoformat() if occ else None,
"lead_id": lead_id,
"shared_user_ids": acl,
},
)
return note
def update_note(
db: Session,
ctx: AuthContext,
business_id: int,
note_id: int,
*,
note_type_id: int | None,
visibility: str | None,
title: str | None,
body: str | None,
occurs_on: date | None,
starts_at: datetime | None,
ends_at: datetime | None,
lead_id: int | None,
lead_field_set: bool = False,
status: str | None,
shared_user_ids: List[int] | None,
translator,
) -> CrmNote:
note = (
db.query(CrmNote)
.options(selectinload(CrmNote.acl_users))
.filter(CrmNote.id == note_id, CrmNote.business_id == business_id, CrmNote.deleted_at.is_(None))
.first()
)
if not note:
raise ApiError("CRM_NOTE_NOT_FOUND", "Not found", http_status=404, translator=translator)
if not can_edit_note(db, ctx, note):
raise ApiError("CRM_NOTE_FORBIDDEN", "Forbidden", http_status=403, translator=translator)
uid = int(ctx.get_user_id() or 0)
changes: dict[str, Any] = {}
ntype = note.note_type
if note_type_id is not None:
nt = db.get(CrmNoteType, note_type_id)
if not nt or nt.business_id != business_id or not nt.is_active:
raise ApiError("CRM_NOTE_TYPE_NOT_FOUND", "Note type not found", http_status=404, translator=translator)
if nt.id != note.note_type_id:
changes["note_type_id"] = {"old": note.note_type_id, "new": note_type_id}
note.note_type_id = note_type_id
ntype = nt
if visibility is not None:
if visibility not in ("private", "business_public", "shared"):
raise ApiError("CRM_NOTE_VISIBILITY_INVALID", "Invalid visibility", translator=translator)
if visibility != note.visibility:
changes["visibility"] = {"old": note.visibility, "new": visibility}
note.visibility = visibility
if visibility == "shared" and shared_user_ids is None and not note.acl_users:
raise ApiError("CRM_NOTE_SHARED_EMPTY", "shared_user_ids required", translator=translator)
if title is not None:
note.title = title.strip() or None
if body is not None:
note.body = body.strip()
if lead_field_set:
if lead_id is None:
if note.lead_id is not None:
changes["lead_id"] = {"old": note.lead_id, "new": None}
note.lead_id = None
else:
_validate_lead(db, business_id, lead_id, translator)
if note.lead_id != lead_id:
changes["lead_id"] = {"old": note.lead_id, "new": lead_id}
note.lead_id = lead_id
if status is not None:
if status not in ("active", "archived", "cancelled"):
raise ApiError("CRM_NOTE_STATUS_INVALID", "Invalid status", translator=translator)
if note.status != status:
changes["status"] = {"old": note.status, "new": status}
note.status = status
if ntype.scheduling_mode == "meeting":
if starts_at is not None:
if note.starts_at != starts_at:
changes["starts_at"] = {"old": _iso(note.starts_at), "new": _iso(starts_at)}
note.starts_at = starts_at
note.occurs_on = starts_at.date()
if ends_at is not None:
if note.ends_at != ends_at:
changes["ends_at"] = {"old": _iso(note.ends_at), "new": _iso(ends_at)}
note.ends_at = ends_at
else:
if occurs_on is not None:
if note.occurs_on != occurs_on:
changes["occurs_on"] = {"old": note.occurs_on.isoformat(), "new": occurs_on.isoformat()}
note.occurs_on = occurs_on
note.starts_at = None
note.ends_at = None
if shared_user_ids is not None:
if note.visibility != "shared":
raise ApiError("CRM_NOTE_ACL_NOT_SHARED", "Not shared note", translator=translator)
acl = _validate_shared_users(db, business_id, note.created_by_user_id, shared_user_ids, translator)
old_ids = sorted([a.user_id for a in note.acl_users])
if old_ids != sorted(acl):
changes["shared_user_ids"] = {"old": old_ids, "new": acl}
note.acl_users.clear()
db.flush()
now = datetime.utcnow()
for x in acl:
note.acl_users.append(
CrmNoteAclUser(
business_id=business_id,
note_id=note.id,
user_id=x,
created_at=now,
)
)
if note.visibility != "shared" and note.acl_users:
changes["shared_user_ids_cleared"] = True
note.acl_users.clear()
db.flush()
note.updated_at = datetime.utcnow()
db.flush()
if changes:
_audit(
db,
business_id=business_id,
note_id=note.id,
actor_user_id=uid,
action="UPDATED",
payload=changes,
)
return note
def _iso(dt: datetime | None) -> str | None:
if dt is None:
return None
return dt.isoformat()
def soft_delete_note(db: Session, ctx: AuthContext, business_id: int, note_id: int, translator) -> None:
note = db.get(CrmNote, note_id)
if not note or note.business_id != business_id or note.deleted_at is not None:
raise ApiError("CRM_NOTE_NOT_FOUND", "Not found", http_status=404, translator=translator)
if not can_edit_note(db, ctx, note):
raise ApiError("CRM_NOTE_FORBIDDEN", "Forbidden", http_status=403, translator=translator)
note.deleted_at = datetime.utcnow()
note.updated_at = datetime.utcnow()
db.flush()
_audit(
db,
business_id=business_id,
note_id=note.id,
actor_user_id=int(ctx.get_user_id() or 0),
action="SOFT_DELETED",
payload=None,
)
def list_notes(
db: Session,
ctx: AuthContext,
business_id: int,
from_date: date,
to_date: date,
lang: str,
) -> List[dict]:
ensure_default_note_types(db, business_id)
uid = ctx.get_user_id()
if uid is None:
return []
q = (
db.query(CrmNote)
.options(
joinedload(CrmNote.note_type),
joinedload(CrmNote.created_by),
joinedload(CrmNote.lead),
selectinload(CrmNote.acl_users),
)
.filter(
CrmNote.business_id == business_id,
CrmNote.deleted_at.is_(None),
CrmNote.occurs_on >= from_date,
CrmNote.occurs_on <= to_date,
_note_visible_filter(int(uid)),
)
.order_by(CrmNote.occurs_on, CrmNote.starts_at, CrmNote.id)
)
rows = q.all()
return [note_to_dict(n, lang) for n in rows]
def get_note(db: Session, ctx: AuthContext, business_id: int, note_id: int, lang: str) -> dict | None:
note = (
db.query(CrmNote)
.options(
joinedload(CrmNote.note_type),
joinedload(CrmNote.created_by),
joinedload(CrmNote.lead),
selectinload(CrmNote.acl_users),
)
.filter(CrmNote.id == note_id, CrmNote.business_id == business_id, CrmNote.deleted_at.is_(None))
.first()
)
if not note or not can_view_note(db, ctx, note):
return None
return note_to_dict(note, lang, include_acl=True)
def note_to_dict(note: CrmNote, lang: str, *, include_acl: bool = False) -> dict:
nt = note.note_type
lead = note.lead
data: dict[str, Any] = {
"id": note.id,
"business_id": note.business_id,
"note_type_id": note.note_type_id,
"note_type_code": nt.code if nt else None,
"note_type_title": resolve_i18n_map(nt.title_i18n, lang) if nt else "",
"scheduling_mode": nt.scheduling_mode if nt else "day_only",
"visibility": note.visibility,
"title": note.title,
"body": note.body,
"occurs_on": note.occurs_on,
"starts_at": note.starts_at,
"ends_at": note.ends_at,
"lead_id": note.lead_id,
"lead_name": lead.name if lead else None,
"lead_code": lead.code if lead else None,
"status": note.status,
"created_by_user_id": note.created_by_user_id,
"created_by_name": _user_display_name(note.created_by),
"comments_enabled": comments_enabled_for(note, nt) if nt else False,
"created_at": note.created_at,
"updated_at": note.updated_at,
}
if include_acl and note.visibility == "shared":
data["shared_user_ids"] = [a.user_id for a in note.acl_users]
return data
def list_comments(db: Session, ctx: AuthContext, business_id: int, note_id: int, lang: str) -> List[dict]:
note = db.get(CrmNote, note_id)
if not note or note.business_id != business_id or note.deleted_at is not None:
raise ApiError("CRM_NOTE_NOT_FOUND", "Not found", http_status=404, translator=None)
if not can_view_note(db, ctx, note):
raise ApiError("CRM_NOTE_FORBIDDEN", "Forbidden", http_status=403, translator=None)
ntype = db.get(CrmNoteType, note.note_type_id)
if not ntype or not comments_enabled_for(note, ntype):
return []
rows = (
db.query(CrmNoteComment)
.options(joinedload(CrmNoteComment.created_by))
.filter(
CrmNoteComment.note_id == note_id,
CrmNoteComment.business_id == business_id,
CrmNoteComment.deleted_at.is_(None),
)
.order_by(CrmNoteComment.id)
.all()
)
out = []
for c in rows:
out.append(
{
"id": c.id,
"note_id": c.note_id,
"body": c.body,
"created_by_user_id": c.created_by_user_id,
"created_by_name": _user_display_name(c.created_by),
"created_at": c.created_at,
"updated_at": c.updated_at,
}
)
return out
def add_comment(
db: Session,
ctx: AuthContext,
business_id: int,
note_id: int,
body: str,
translator,
) -> dict:
note = db.get(CrmNote, note_id)
if not note or note.business_id != business_id or note.deleted_at is not None:
raise ApiError("CRM_NOTE_NOT_FOUND", "Not found", http_status=404, translator=translator)
if not can_view_note(db, ctx, note):
raise ApiError("CRM_NOTE_FORBIDDEN", "Forbidden", http_status=403, translator=translator)
ntype = db.get(CrmNoteType, note.note_type_id)
if not ntype or not comments_enabled_for(note, ntype):
raise ApiError("CRM_NOTE_COMMENTS_DISABLED", "Comments disabled", http_status=400, translator=translator)
uid = int(ctx.get_user_id() or 0)
now = datetime.utcnow()
c = CrmNoteComment(
business_id=business_id,
note_id=note_id,
body=body.strip(),
created_by_user_id=uid,
created_at=now,
updated_at=now,
)
db.add(c)
db.flush()
author = db.get(User, uid)
_audit(
db,
business_id=business_id,
note_id=note_id,
actor_user_id=uid,
action="COMMENT_CREATED",
payload={"comment_id": c.id},
)
return {
"id": c.id,
"note_id": c.note_id,
"body": c.body,
"created_by_user_id": c.created_by_user_id,
"created_by_name": _user_display_name(author),
"created_at": c.created_at,
"updated_at": c.updated_at,
}
def delete_comment(db: Session, ctx: AuthContext, business_id: int, note_id: int, comment_id: int, translator) -> None:
note = db.get(CrmNote, note_id)
if not note or note.business_id != business_id:
raise ApiError("CRM_NOTE_NOT_FOUND", "Not found", http_status=404, translator=translator)
c = db.get(CrmNoteComment, comment_id)
if not c or c.note_id != note_id or c.deleted_at is not None:
raise ApiError("CRM_NOTE_COMMENT_NOT_FOUND", "Not found", http_status=404, translator=translator)
uid = int(ctx.get_user_id() or 0)
if c.created_by_user_id != uid and not (ctx.is_superadmin() or ctx.is_business_owner(business_id)):
raise ApiError("CRM_NOTE_FORBIDDEN", "Forbidden", http_status=403, translator=translator)
c.deleted_at = datetime.utcnow()
c.updated_at = datetime.utcnow()
db.flush()
_audit(
db,
business_id=business_id,
note_id=note_id,
actor_user_id=uid,
action="COMMENT_DELETED",
payload={"comment_id": comment_id},
)
def list_audit(db: Session, ctx: AuthContext, business_id: int, note_id: int) -> List[dict]:
note = db.get(CrmNote, note_id)
if not note or note.business_id != business_id:
raise ApiError("CRM_NOTE_NOT_FOUND", "Not found", http_status=404, translator=None)
if not can_view_note(db, ctx, note):
raise ApiError("CRM_NOTE_FORBIDDEN", "Forbidden", http_status=403, translator=None)
uid = int(ctx.get_user_id() or 0)
if not (
ctx.is_superadmin()
or ctx.is_business_owner(business_id)
or note.created_by_user_id == uid
):
raise ApiError("CRM_NOTE_AUDIT_FORBIDDEN", "Audit forbidden", http_status=403, translator=None)
rows = (
db.query(CrmNoteAuditEvent)
.options(joinedload(CrmNoteAuditEvent.actor))
.filter(CrmNoteAuditEvent.note_id == note_id, CrmNoteAuditEvent.business_id == business_id)
.order_by(CrmNoteAuditEvent.id.desc())
.limit(200)
.all()
)
out = []
for e in rows:
payload = e.payload
if isinstance(payload, dict):
payload_str = json.dumps(payload, ensure_ascii=False)
else:
payload_str = None
out.append(
{
"id": e.id,
"action": e.action,
"payload": e.payload,
"payload_text": payload_str,
"actor_user_id": e.actor_user_id,
"actor_name": _user_display_name(e.actor),
"occurred_at": e.occurred_at,
}
)
return out