update: Add Global Search on Header, Add Global Audit log for all actions.
This commit is contained in:
@@ -1,5 +1,6 @@
|
||||
from fastapi import APIRouter, Depends, HTTPException
|
||||
from fastapi.responses import FileResponse, PlainTextResponse
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from auth.models import TokenPayload
|
||||
from auth.dependencies import require_permission
|
||||
from builder.models import (
|
||||
@@ -9,6 +10,8 @@ from builder.models import (
|
||||
BuiltMelodyListResponse,
|
||||
)
|
||||
from builder import service
|
||||
from database.postgres import get_pg_session
|
||||
from shared.audit import log_action
|
||||
|
||||
router = APIRouter(prefix="/api/builder/melodies", tags=["builder"])
|
||||
|
||||
@@ -54,8 +57,12 @@ async def get_built_melody(
|
||||
async def create_built_melody(
|
||||
body: BuiltMelodyCreate,
|
||||
_user: TokenPayload = Depends(require_permission("melodies", "edit")),
|
||||
db: AsyncSession = Depends(get_pg_session),
|
||||
):
|
||||
return await service.create_built_melody(body)
|
||||
melody = await service.create_built_melody(body)
|
||||
await log_action(db, _user.sub, _user.name or _user.email, "CREATE", "archetype",
|
||||
str(melody.id), melody.name or str(melody.id))
|
||||
return melody
|
||||
|
||||
|
||||
@router.put("/{melody_id}", response_model=BuiltMelodyInDB)
|
||||
@@ -63,16 +70,31 @@ async def update_built_melody(
|
||||
melody_id: str,
|
||||
body: BuiltMelodyUpdate,
|
||||
_user: TokenPayload = Depends(require_permission("melodies", "edit")),
|
||||
db: AsyncSession = Depends(get_pg_session),
|
||||
):
|
||||
return await service.update_built_melody(melody_id, body)
|
||||
old = await service.get_built_melody(melody_id)
|
||||
melody = await service.update_built_melody(melody_id, body)
|
||||
_SKIP = {"updated_at", "id", "steps", "builtin_code"}
|
||||
changes = {
|
||||
k: {"old": getattr(old, k, None), "new": getattr(melody, k, None)}
|
||||
for k in body.model_fields_set
|
||||
if k not in _SKIP and getattr(old, k, None) != getattr(melody, k, None)
|
||||
}
|
||||
await log_action(db, _user.sub, _user.name or _user.email, "UPDATE", "archetype",
|
||||
melody_id, melody.name or melody_id, changes=changes or None)
|
||||
return melody
|
||||
|
||||
|
||||
@router.delete("/{melody_id}", status_code=204)
|
||||
async def delete_built_melody(
|
||||
melody_id: str,
|
||||
_user: TokenPayload = Depends(require_permission("melodies", "delete")),
|
||||
db: AsyncSession = Depends(get_pg_session),
|
||||
):
|
||||
melody = await service.get_built_melody(melody_id)
|
||||
await service.delete_built_melody(melody_id)
|
||||
await log_action(db, _user.sub, _user.name or _user.email, "DELETE", "archetype",
|
||||
melody_id, melody.name if melody else melody_id)
|
||||
|
||||
|
||||
@router.post("/{melody_id}/toggle-builtin", response_model=BuiltMelodyInDB)
|
||||
|
||||
@@ -2,16 +2,86 @@ import asyncio
|
||||
import logging
|
||||
from fastapi import APIRouter, Depends, Query, BackgroundTasks, Body
|
||||
from typing import Optional
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from auth.models import TokenPayload
|
||||
from auth.dependencies import require_permission
|
||||
from crm.models import CustomerCreate, CustomerUpdate, CustomerInDB, CustomerListResponse, TransactionEntry
|
||||
from crm import service, nextcloud
|
||||
from config import settings
|
||||
from database.postgres import get_pg_session
|
||||
from shared.audit import log_action
|
||||
|
||||
router = APIRouter(prefix="/api/crm/customers", tags=["crm-customers"])
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# ── Diff helpers ──────────────────────────────────────────────────────────────
|
||||
|
||||
_SCALAR_FIELDS = {
|
||||
"name", "surname", "title", "organization", "religion", "language",
|
||||
"relationship_status", "nextcloud_folder",
|
||||
}
|
||||
_SKIP_FIELDS = {"updated_at", "firestore_id", "id"}
|
||||
|
||||
|
||||
def _scalar_diff(old, new) -> dict:
|
||||
result = {}
|
||||
for f in _SCALAR_FIELDS:
|
||||
ov = getattr(old, f, None)
|
||||
nv = getattr(new, f, None)
|
||||
if ov != nv:
|
||||
result[f] = {"old": ov, "new": nv}
|
||||
return result
|
||||
|
||||
|
||||
def _list_diff(field: str, old_list: list, new_list: list, label_fn) -> dict:
|
||||
old_labels = {label_fn(i) for i in (old_list or [])}
|
||||
new_labels = {label_fn(i) for i in (new_list or [])}
|
||||
added = new_labels - old_labels
|
||||
removed = old_labels - new_labels
|
||||
result = {}
|
||||
if added:
|
||||
result[f"{field}.added"] = {"old": None, "new": sorted(added)}
|
||||
if removed:
|
||||
result[f"{field}.removed"] = {"old": sorted(removed), "new": None}
|
||||
return result
|
||||
|
||||
|
||||
def _customer_diff(old, new, changed_fields: set) -> dict:
|
||||
changes = _scalar_diff(old, new)
|
||||
|
||||
# contacts — keyed by type+value string
|
||||
if "contacts" in changed_fields:
|
||||
changes.update(_list_diff(
|
||||
"contacts",
|
||||
old.contacts or [],
|
||||
new.contacts or [],
|
||||
lambda c: f"{c.get('type','?')}:{c.get('value','?')}" if isinstance(c, dict)
|
||||
else f"{getattr(c,'type','?')}:{getattr(c,'value','?')}",
|
||||
))
|
||||
|
||||
# location — flatten to individual sub-fields
|
||||
if "location" in changed_fields:
|
||||
old_loc = old.location or {}
|
||||
new_loc = new.location or {}
|
||||
if isinstance(old_loc, object) and not isinstance(old_loc, dict):
|
||||
old_loc = old_loc.model_dump() if hasattr(old_loc, "model_dump") else {}
|
||||
if isinstance(new_loc, object) and not isinstance(new_loc, dict):
|
||||
new_loc = new_loc.model_dump() if hasattr(new_loc, "model_dump") else {}
|
||||
for k in set(old_loc) | set(new_loc):
|
||||
ov, nv = old_loc.get(k), new_loc.get(k)
|
||||
if ov != nv:
|
||||
changes[f"location.{k}"] = {"old": ov, "new": nv}
|
||||
|
||||
# tags
|
||||
if "tags" in changed_fields:
|
||||
old_tags = set(old.tags or [])
|
||||
new_tags = set(new.tags or [])
|
||||
if old_tags != new_tags:
|
||||
changes["tags"] = {"old": sorted(old_tags), "new": sorted(new_tags)}
|
||||
|
||||
return changes
|
||||
|
||||
|
||||
@router.get("", response_model=CustomerListResponse)
|
||||
async def list_customers(
|
||||
@@ -46,10 +116,14 @@ async def create_customer(
|
||||
body: CustomerCreate,
|
||||
background_tasks: BackgroundTasks,
|
||||
_user: TokenPayload = Depends(require_permission("crm", "edit")),
|
||||
db: AsyncSession = Depends(get_pg_session),
|
||||
):
|
||||
customer = service.create_customer(body)
|
||||
if settings.nextcloud_url:
|
||||
background_tasks.add_task(_init_nextcloud_folder, customer)
|
||||
label = " ".join(filter(None, [customer.name, customer.surname])) or customer.organization or customer.id
|
||||
await log_action(db, _user.sub, _user.name or _user.email, "CREATE", "customer",
|
||||
customer.id, label)
|
||||
return customer
|
||||
|
||||
|
||||
@@ -65,12 +139,19 @@ async def _init_nextcloud_folder(customer) -> None:
|
||||
|
||||
|
||||
@router.put("/{customer_id}", response_model=CustomerInDB)
|
||||
def update_customer(
|
||||
async def update_customer(
|
||||
customer_id: str,
|
||||
body: CustomerUpdate,
|
||||
_user: TokenPayload = Depends(require_permission("crm", "edit")),
|
||||
db: AsyncSession = Depends(get_pg_session),
|
||||
):
|
||||
return service.update_customer(customer_id, body)
|
||||
old = service.get_customer(customer_id)
|
||||
customer = service.update_customer(customer_id, body)
|
||||
label = " ".join(filter(None, [customer.name, customer.surname])) or customer.organization or customer_id
|
||||
changes = _customer_diff(old, customer, body.model_fields_set)
|
||||
await log_action(db, _user.sub, _user.name or _user.email, "UPDATE", "customer",
|
||||
customer_id, label, changes=changes or None)
|
||||
return customer
|
||||
|
||||
|
||||
@router.delete("/{customer_id}", status_code=204)
|
||||
@@ -80,6 +161,7 @@ async def delete_customer(
|
||||
wipe_files: bool = Query(False),
|
||||
wipe_nextcloud: bool = Query(False),
|
||||
_user: TokenPayload = Depends(require_permission("crm", "edit")),
|
||||
db: AsyncSession = Depends(get_pg_session),
|
||||
):
|
||||
customer = service.delete_customer(customer_id)
|
||||
nc_path = service.get_customer_nc_path(customer)
|
||||
@@ -104,6 +186,10 @@ async def delete_customer(
|
||||
except Exception as e:
|
||||
logger.warning("Could not rename NC folder for customer %s: %s", customer_id, e)
|
||||
|
||||
label = " ".join(filter(None, [customer.name, customer.surname])) or customer.organization or customer_id
|
||||
await log_action(db, _user.sub, _user.name or _user.email, "DELETE", "customer",
|
||||
customer_id, label)
|
||||
|
||||
|
||||
@router.get("/{customer_id}/last-comm-direction")
|
||||
async def get_last_comm_direction(
|
||||
@@ -117,12 +203,17 @@ async def get_last_comm_direction(
|
||||
# ── Relationship Status ───────────────────────────────────────────────────────
|
||||
|
||||
@router.patch("/{customer_id}/relationship-status", response_model=CustomerInDB)
|
||||
def update_relationship_status(
|
||||
async def update_relationship_status(
|
||||
customer_id: str,
|
||||
body: dict = Body(...),
|
||||
_user: TokenPayload = Depends(require_permission("crm", "edit")),
|
||||
db: AsyncSession = Depends(get_pg_session),
|
||||
):
|
||||
return service.update_relationship_status(customer_id, body.get("status", ""))
|
||||
customer = service.update_relationship_status(customer_id, body.get("status", ""))
|
||||
label = " ".join(filter(None, [customer.name, customer.surname])) or customer.organization or customer_id
|
||||
await log_action(db, _user.sub, _user.name or _user.email, "STATUS_CHANGE", "customer",
|
||||
customer_id, label, meta={"status": body.get("status", "")})
|
||||
return customer
|
||||
|
||||
|
||||
# ── Technical Issues ──────────────────────────────────────────────────────────
|
||||
|
||||
@@ -1,10 +1,13 @@
|
||||
from fastapi import APIRouter, Depends, Query
|
||||
from typing import Optional
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from auth.models import TokenPayload
|
||||
from auth.dependencies import require_permission
|
||||
from crm.models import OrderCreate, OrderUpdate, OrderInDB, OrderListResponse
|
||||
from crm import service
|
||||
from database.postgres import get_pg_session
|
||||
from shared.audit import log_action
|
||||
|
||||
router = APIRouter(prefix="/api/crm/customers/{customer_id}/orders", tags=["crm-orders"])
|
||||
|
||||
@@ -29,27 +32,35 @@ def get_next_order_number(
|
||||
|
||||
|
||||
@router.post("/init-negotiations", response_model=OrderInDB, status_code=201)
|
||||
def init_negotiations(
|
||||
async def init_negotiations(
|
||||
customer_id: str,
|
||||
body: dict,
|
||||
_user: TokenPayload = Depends(require_permission("crm", "edit")),
|
||||
db: AsyncSession = Depends(get_pg_session),
|
||||
):
|
||||
return service.init_negotiations(
|
||||
order = service.init_negotiations(
|
||||
customer_id=customer_id,
|
||||
title=body.get("title", ""),
|
||||
note=body.get("note", ""),
|
||||
date=body.get("date"),
|
||||
created_by=body.get("created_by", ""),
|
||||
)
|
||||
await log_action(db, _user.sub, _user.name or _user.email, "CREATE", "order",
|
||||
order.id, order.order_number or order.id, meta={"action_detail": "negotiations_started"})
|
||||
return order
|
||||
|
||||
|
||||
@router.post("", response_model=OrderInDB, status_code=201)
|
||||
def create_order(
|
||||
async def create_order(
|
||||
customer_id: str,
|
||||
body: OrderCreate,
|
||||
_user: TokenPayload = Depends(require_permission("crm", "edit")),
|
||||
db: AsyncSession = Depends(get_pg_session),
|
||||
):
|
||||
return service.create_order(customer_id, body)
|
||||
order = service.create_order(customer_id, body)
|
||||
await log_action(db, _user.sub, _user.name or _user.email, "CREATE", "order",
|
||||
order.id, order.order_number or order.id)
|
||||
return order
|
||||
|
||||
|
||||
@router.get("/{order_id}", response_model=OrderInDB)
|
||||
@@ -62,22 +73,37 @@ def get_order(
|
||||
|
||||
|
||||
@router.patch("/{order_id}", response_model=OrderInDB)
|
||||
def update_order(
|
||||
async def update_order(
|
||||
customer_id: str,
|
||||
order_id: str,
|
||||
body: OrderUpdate,
|
||||
_user: TokenPayload = Depends(require_permission("crm", "edit")),
|
||||
db: AsyncSession = Depends(get_pg_session),
|
||||
):
|
||||
return service.update_order(customer_id, order_id, body)
|
||||
old = service.get_order(customer_id, order_id)
|
||||
order = service.update_order(customer_id, order_id, body)
|
||||
action = "STATUS_CHANGE" if body.status is not None else "UPDATE"
|
||||
_SKIP = {"updated_at", "id", "customer_id", "items", "timeline", "discount", "shipping", "payment_status"}
|
||||
changes = {
|
||||
k: {"old": getattr(old, k, None), "new": getattr(order, k, None)}
|
||||
for k in body.model_fields_set
|
||||
if k not in _SKIP and getattr(old, k, None) != getattr(order, k, None)
|
||||
}
|
||||
await log_action(db, _user.sub, _user.name or _user.email, action, "order",
|
||||
order_id, order.order_number or order_id, changes=changes or None)
|
||||
return order
|
||||
|
||||
|
||||
@router.delete("/{order_id}", status_code=204)
|
||||
def delete_order(
|
||||
async def delete_order(
|
||||
customer_id: str,
|
||||
order_id: str,
|
||||
_user: TokenPayload = Depends(require_permission("crm", "edit")),
|
||||
db: AsyncSession = Depends(get_pg_session),
|
||||
):
|
||||
service.delete_order(customer_id, order_id)
|
||||
await log_action(db, _user.sub, _user.name or _user.email, "DELETE", "order",
|
||||
order_id, order_id)
|
||||
|
||||
|
||||
@router.post("/{order_id}/timeline", response_model=OrderInDB)
|
||||
|
||||
@@ -2,6 +2,7 @@ from fastapi import APIRouter, Depends, Query, UploadFile, File
|
||||
from fastapi.responses import StreamingResponse
|
||||
from typing import Optional
|
||||
import io
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from auth.dependencies import require_permission
|
||||
from auth.models import TokenPayload
|
||||
@@ -13,6 +14,8 @@ from crm.quotation_models import (
|
||||
QuotationUpdate,
|
||||
)
|
||||
from crm import quotations_service as svc
|
||||
from database.postgres import get_pg_session
|
||||
from shared.audit import log_action
|
||||
|
||||
router = APIRouter(prefix="/api/crm/quotations", tags=["crm-quotations"])
|
||||
|
||||
@@ -72,11 +75,15 @@ async def create_quotation(
|
||||
body: QuotationCreate,
|
||||
generate_pdf: bool = Query(False),
|
||||
_user: TokenPayload = Depends(require_permission("crm", "edit")),
|
||||
db: AsyncSession = Depends(get_pg_session),
|
||||
):
|
||||
"""
|
||||
Create a quotation. Pass ?generate_pdf=true to immediately generate and upload the PDF.
|
||||
"""
|
||||
return await svc.create_quotation(body, generate_pdf=generate_pdf)
|
||||
q = await svc.create_quotation(body, generate_pdf=generate_pdf)
|
||||
await log_action(db, _user.sub, _user.name or _user.email, "CREATE", "quotation",
|
||||
str(q.id), q.quotation_number or str(q.id))
|
||||
return q
|
||||
|
||||
|
||||
@router.put("/{quotation_id}", response_model=QuotationInDB)
|
||||
@@ -85,19 +92,34 @@ async def update_quotation(
|
||||
body: QuotationUpdate,
|
||||
generate_pdf: bool = Query(False),
|
||||
_user: TokenPayload = Depends(require_permission("crm", "edit")),
|
||||
db: AsyncSession = Depends(get_pg_session),
|
||||
):
|
||||
"""
|
||||
Update a quotation. Pass ?generate_pdf=true to regenerate the PDF.
|
||||
"""
|
||||
return await svc.update_quotation(quotation_id, body, generate_pdf=generate_pdf)
|
||||
old = await svc.get_quotation(quotation_id)
|
||||
q = await svc.update_quotation(quotation_id, body, generate_pdf=generate_pdf)
|
||||
_SKIP = {"updated_at", "id", "items", "pdf_path"}
|
||||
changes = {
|
||||
k: {"old": getattr(old, k, None), "new": getattr(q, k, None)}
|
||||
for k in body.model_fields_set
|
||||
if k not in _SKIP and getattr(old, k, None) != getattr(q, k, None)
|
||||
}
|
||||
await log_action(db, _user.sub, _user.name or _user.email, "UPDATE", "quotation",
|
||||
quotation_id, q.quotation_number or quotation_id, changes=changes or None)
|
||||
return q
|
||||
|
||||
|
||||
@router.delete("/{quotation_id}", status_code=204)
|
||||
async def delete_quotation(
|
||||
quotation_id: str,
|
||||
_user: TokenPayload = Depends(require_permission("crm", "edit")),
|
||||
db: AsyncSession = Depends(get_pg_session),
|
||||
):
|
||||
q = await svc.get_quotation(quotation_id)
|
||||
await svc.delete_quotation(quotation_id)
|
||||
await log_action(db, _user.sub, _user.name or _user.email, "DELETE", "quotation",
|
||||
quotation_id, q.quotation_number if q else quotation_id)
|
||||
|
||||
|
||||
@router.post("/{quotation_id}/regenerate-pdf", response_model=QuotationInDB)
|
||||
|
||||
@@ -3,11 +3,14 @@ from fastapi.responses import FileResponse
|
||||
from typing import Optional
|
||||
import os
|
||||
import shutil
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from auth.models import TokenPayload
|
||||
from auth.dependencies import require_permission
|
||||
from crm.models import ProductCreate, ProductUpdate, ProductInDB, ProductListResponse
|
||||
from crm import service
|
||||
from database.postgres import get_pg_session
|
||||
from shared.audit import log_action
|
||||
|
||||
router = APIRouter(prefix="/api/crm/products", tags=["crm-products"])
|
||||
|
||||
@@ -35,28 +38,47 @@ def get_product(
|
||||
|
||||
|
||||
@router.post("", response_model=ProductInDB, status_code=201)
|
||||
def create_product(
|
||||
async def create_product(
|
||||
body: ProductCreate,
|
||||
_user: TokenPayload = Depends(require_permission("crm", "edit")),
|
||||
db: AsyncSession = Depends(get_pg_session),
|
||||
):
|
||||
return service.create_product(body)
|
||||
product = service.create_product(body)
|
||||
await log_action(db, _user.sub, _user.name or _user.email, "CREATE", "product",
|
||||
product.id, product.name)
|
||||
return product
|
||||
|
||||
|
||||
@router.put("/{product_id}", response_model=ProductInDB)
|
||||
def update_product(
|
||||
async def update_product(
|
||||
product_id: str,
|
||||
body: ProductUpdate,
|
||||
_user: TokenPayload = Depends(require_permission("crm", "edit")),
|
||||
db: AsyncSession = Depends(get_pg_session),
|
||||
):
|
||||
return service.update_product(product_id, body)
|
||||
old = service.get_product(product_id)
|
||||
product = service.update_product(product_id, body)
|
||||
_SKIP = {"updated_at", "id", "photo_url"}
|
||||
changes = {
|
||||
k: {"old": getattr(old, k, None), "new": getattr(product, k, None)}
|
||||
for k in body.model_fields_set
|
||||
if k not in _SKIP and getattr(old, k, None) != getattr(product, k, None)
|
||||
}
|
||||
await log_action(db, _user.sub, _user.name or _user.email, "UPDATE", "product",
|
||||
product_id, product.name, changes=changes or None)
|
||||
return product
|
||||
|
||||
|
||||
@router.delete("/{product_id}", status_code=204)
|
||||
def delete_product(
|
||||
async def delete_product(
|
||||
product_id: str,
|
||||
_user: TokenPayload = Depends(require_permission("crm", "edit")),
|
||||
db: AsyncSession = Depends(get_pg_session),
|
||||
):
|
||||
product = service.get_product(product_id)
|
||||
service.delete_product(product_id)
|
||||
await log_action(db, _user.sub, _user.name or _user.email, "DELETE", "product",
|
||||
product_id, product.name)
|
||||
|
||||
|
||||
@router.post("/{product_id}/photo", response_model=ProductInDB)
|
||||
|
||||
@@ -3,6 +3,7 @@ from datetime import datetime
|
||||
from fastapi import APIRouter, Depends, Query, HTTPException
|
||||
from typing import Optional, List
|
||||
from pydantic import BaseModel
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from auth.models import TokenPayload
|
||||
from auth.dependencies import require_permission
|
||||
from devices.models import (
|
||||
@@ -14,6 +15,8 @@ from devices import service
|
||||
import database as mqtt_db
|
||||
from mqtt.models import DeviceAlertEntry, DeviceAlertsResponse
|
||||
from shared.firebase import get_db as get_firestore
|
||||
from database.postgres import get_pg_session
|
||||
from shared.audit import log_action
|
||||
|
||||
router = APIRouter(prefix="/api/devices", tags=["devices"])
|
||||
|
||||
@@ -58,8 +61,12 @@ async def get_device_users(
|
||||
async def create_device(
|
||||
body: DeviceCreate,
|
||||
_user: TokenPayload = Depends(require_permission("devices", "add")),
|
||||
db: AsyncSession = Depends(get_pg_session),
|
||||
):
|
||||
return service.create_device(body)
|
||||
device = service.create_device(body)
|
||||
await log_action(db, _user.sub, _user.name or _user.email, "CREATE", "device",
|
||||
device.device_id, device.device_name or device.device_id)
|
||||
return device
|
||||
|
||||
|
||||
@router.put("/{device_id}", response_model=DeviceInDB)
|
||||
@@ -67,16 +74,32 @@ async def update_device(
|
||||
device_id: str,
|
||||
body: DeviceUpdate,
|
||||
_user: TokenPayload = Depends(require_permission("devices", "edit")),
|
||||
db: AsyncSession = Depends(get_pg_session),
|
||||
):
|
||||
return service.update_device(device_id, body)
|
||||
old = service.get_device(device_id)
|
||||
device = service.update_device(device_id, body)
|
||||
_SKIP = {"updated_at", "device_id", "tags", "user_list"}
|
||||
changes = {
|
||||
k: {"old": getattr(old, k, None), "new": getattr(device, k, None)}
|
||||
for k in body.model_fields_set
|
||||
if k not in _SKIP and getattr(old, k, None) != getattr(device, k, None)
|
||||
}
|
||||
if "tags" in body.model_fields_set and (old.tags or []) != (device.tags or []):
|
||||
changes["tags"] = {"old": sorted(old.tags or []), "new": sorted(device.tags or [])}
|
||||
await log_action(db, _user.sub, _user.name or _user.email, "UPDATE", "device",
|
||||
device_id, device.device_name or device_id, changes=changes or None)
|
||||
return device
|
||||
|
||||
|
||||
@router.delete("/{device_id}", status_code=204)
|
||||
async def delete_device(
|
||||
device_id: str,
|
||||
_user: TokenPayload = Depends(require_permission("devices", "delete")),
|
||||
db: AsyncSession = Depends(get_pg_session),
|
||||
):
|
||||
service.delete_device(device_id)
|
||||
await log_action(db, _user.sub, _user.name or _user.email, "DELETE", "device",
|
||||
device_id, device_id)
|
||||
|
||||
|
||||
@router.get("/{device_id}/alerts", response_model=DeviceAlertsResponse)
|
||||
@@ -100,16 +123,16 @@ async def list_device_notes(
|
||||
):
|
||||
"""List all notes for a device."""
|
||||
db = get_firestore()
|
||||
docs = db.collection(NOTES_COLLECTION).where("device_id", "==", device_id).order_by("created_at").stream()
|
||||
docs = db.collection(NOTES_COLLECTION).where("device_id", "==", device_id).stream()
|
||||
notes = []
|
||||
for doc in docs:
|
||||
note = doc.to_dict()
|
||||
note["id"] = doc.id
|
||||
# Convert Firestore Timestamps to ISO strings
|
||||
for f in ("created_at", "updated_at"):
|
||||
if hasattr(note.get(f), "isoformat"):
|
||||
note[f] = note[f].isoformat()
|
||||
notes.append(note)
|
||||
notes.sort(key=lambda n: n.get("created_at") or "", reverse=False)
|
||||
return {"notes": notes, "total": len(notes)}
|
||||
|
||||
|
||||
@@ -251,6 +274,7 @@ async def assign_device_to_customer(
|
||||
device_id: str,
|
||||
body: AssignCustomerBody,
|
||||
_user: TokenPayload = Depends(require_permission("devices", "edit")),
|
||||
db: AsyncSession = Depends(get_pg_session),
|
||||
):
|
||||
"""Assign a device to a customer.
|
||||
|
||||
@@ -290,6 +314,9 @@ async def assign_device_to_customer(
|
||||
})
|
||||
customer_ref.update({"owned_items": owned_items})
|
||||
|
||||
await log_action(db, _user.sub, _user.name or _user.email, "UPDATE", "device",
|
||||
device_id, device_id, meta={"action_detail": "assigned_to_customer",
|
||||
"customer_id": body.customer_id})
|
||||
return {"status": "assigned", "device_id": device_id, "customer_id": body.customer_id}
|
||||
|
||||
|
||||
@@ -298,6 +325,7 @@ async def unassign_device_from_customer(
|
||||
device_id: str,
|
||||
customer_id: str = Query(...),
|
||||
_user: TokenPayload = Depends(require_permission("devices", "edit")),
|
||||
db: AsyncSession = Depends(get_pg_session),
|
||||
):
|
||||
"""Remove device assignment from a customer."""
|
||||
db = get_firestore()
|
||||
@@ -317,6 +345,10 @@ async def unassign_device_from_customer(
|
||||
]
|
||||
customer_ref.update({"owned_items": owned_items})
|
||||
|
||||
await log_action(db, _user.sub, _user.name or _user.email, "UPDATE", "device",
|
||||
device_id, device_id, meta={"action_detail": "unassigned_from_customer",
|
||||
"customer_id": customer_id})
|
||||
|
||||
|
||||
# ─────────────────────────────────────────────────────────────────────────────
|
||||
# Customer detail (for Owner display in fleet)
|
||||
@@ -402,6 +434,7 @@ async def add_user_to_device(
|
||||
device_id: str,
|
||||
body: AddUserBody,
|
||||
_user: TokenPayload = Depends(require_permission("devices", "edit")),
|
||||
db: AsyncSession = Depends(get_pg_session),
|
||||
):
|
||||
"""Add a user reference to the device's user_list field."""
|
||||
db = get_firestore()
|
||||
@@ -432,6 +465,9 @@ async def add_user_to_device(
|
||||
user_list.append(user_ref)
|
||||
device_ref.update({"user_list": user_list})
|
||||
|
||||
await log_action(db, _user.sub, _user.name or _user.email, "UPDATE", "device",
|
||||
device_id, device_id, meta={"action_detail": "user_added",
|
||||
"user_id": body.user_id})
|
||||
return {"status": "added", "user_id": body.user_id}
|
||||
|
||||
|
||||
@@ -440,6 +476,7 @@ async def remove_user_from_device(
|
||||
device_id: str,
|
||||
user_id: str,
|
||||
_user: TokenPayload = Depends(require_permission("devices", "edit")),
|
||||
db: AsyncSession = Depends(get_pg_session),
|
||||
):
|
||||
"""Remove a user reference from the device's user_list field."""
|
||||
db = get_firestore()
|
||||
@@ -464,4 +501,7 @@ async def remove_user_from_device(
|
||||
new_list = [entry for entry in user_list if not resolves_to(entry, user_id)]
|
||||
device_ref.update({"user_list": new_list})
|
||||
|
||||
await log_action(db, _user.sub, _user.name or _user.email, "UPDATE", "device",
|
||||
device_id, device_id, meta={"action_detail": "user_removed",
|
||||
"user_id": user_id})
|
||||
return {"status": "removed", "user_id": user_id}
|
||||
|
||||
@@ -3,11 +3,14 @@ from fastapi.responses import FileResponse, PlainTextResponse
|
||||
from pydantic import BaseModel
|
||||
from typing import Optional
|
||||
import logging
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from auth.models import TokenPayload
|
||||
from auth.dependencies import require_permission
|
||||
from firmware.models import FirmwareVersion, FirmwareListResponse, FirmwareMetadataResponse, UpdateType
|
||||
from firmware import service
|
||||
from database.postgres import get_pg_session
|
||||
from shared.audit import log_action
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
@@ -27,9 +30,10 @@ async def upload_firmware(
|
||||
bespoke_uid: Optional[str] = Form(None),
|
||||
file: UploadFile = File(...),
|
||||
_user: TokenPayload = Depends(require_permission("manufacturing", "add")),
|
||||
db: AsyncSession = Depends(get_pg_session),
|
||||
):
|
||||
file_bytes = await file.read()
|
||||
return service.upload_firmware(
|
||||
fw = service.upload_firmware(
|
||||
hw_type=hw_type,
|
||||
channel=channel,
|
||||
version=version,
|
||||
@@ -40,6 +44,9 @@ async def upload_firmware(
|
||||
release_note=release_note,
|
||||
bespoke_uid=bespoke_uid,
|
||||
)
|
||||
await log_action(db, _user.sub, _user.name or _user.email, "CREATE", "firmware",
|
||||
fw.id, f"{hw_type} v{version} ({channel})")
|
||||
return fw
|
||||
|
||||
|
||||
@router.get("", response_model=FirmwareListResponse)
|
||||
@@ -108,9 +115,10 @@ async def edit_firmware(
|
||||
bespoke_uid: Optional[str] = Form(None),
|
||||
file: Optional[UploadFile] = File(None),
|
||||
_user: TokenPayload = Depends(require_permission("manufacturing", "add")),
|
||||
db: AsyncSession = Depends(get_pg_session),
|
||||
):
|
||||
file_bytes = await file.read() if file and file.filename else None
|
||||
return service.edit_firmware(
|
||||
fw = service.edit_firmware(
|
||||
doc_id=firmware_id,
|
||||
channel=channel,
|
||||
version=version,
|
||||
@@ -121,14 +129,22 @@ async def edit_firmware(
|
||||
bespoke_uid=bespoke_uid,
|
||||
file_bytes=file_bytes,
|
||||
)
|
||||
await log_action(db, _user.sub, _user.name or _user.email, "UPDATE", "firmware",
|
||||
firmware_id, f"{fw.hw_type} v{fw.version} ({fw.channel})" if fw else firmware_id)
|
||||
return fw
|
||||
|
||||
|
||||
@router.delete("/{firmware_id}", status_code=204)
|
||||
def delete_firmware(
|
||||
async def delete_firmware(
|
||||
firmware_id: str,
|
||||
_user: TokenPayload = Depends(require_permission("manufacturing", "delete")),
|
||||
db: AsyncSession = Depends(get_pg_session),
|
||||
):
|
||||
fw = service.get_firmware(firmware_id) if hasattr(service, "get_firmware") else None
|
||||
service.delete_firmware(firmware_id)
|
||||
label = f"{fw.hw_type} v{fw.version} ({fw.channel})" if fw else firmware_id
|
||||
await log_action(db, _user.sub, _user.name or _user.email, "DELETE", "firmware",
|
||||
firmware_id, label)
|
||||
|
||||
|
||||
# ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
@@ -28,6 +28,7 @@ from public.router import router as public_router
|
||||
from notes.router import router as notes_router
|
||||
from tickets.router import router as tickets_router
|
||||
from audit.router import router as audit_router
|
||||
from search.router import router as search_router
|
||||
from crm.nextcloud import close_client as close_nextcloud_client, keepalive_ping as nextcloud_keepalive
|
||||
from crm.mail_accounts import get_mail_accounts
|
||||
from mqtt.client import mqtt_manager
|
||||
@@ -76,6 +77,7 @@ app.include_router(public_router)
|
||||
app.include_router(notes_router)
|
||||
app.include_router(tickets_router)
|
||||
app.include_router(audit_router)
|
||||
app.include_router(search_router)
|
||||
|
||||
|
||||
async def nextcloud_keepalive_loop():
|
||||
|
||||
@@ -1,11 +1,14 @@
|
||||
from fastapi import APIRouter, Depends, UploadFile, File, Query, HTTPException, Response
|
||||
from typing import Optional
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from auth.models import TokenPayload
|
||||
from auth.dependencies import require_permission
|
||||
from melodies.models import (
|
||||
MelodyCreate, MelodyUpdate, MelodyInDB, MelodyListResponse, MelodyInfo,
|
||||
)
|
||||
from melodies import service
|
||||
from database.postgres import get_pg_session
|
||||
from shared.audit import log_action
|
||||
|
||||
router = APIRouter(prefix="/api/melodies", tags=["melodies"])
|
||||
|
||||
@@ -42,8 +45,12 @@ async def create_melody(
|
||||
body: MelodyCreate,
|
||||
publish: bool = Query(False),
|
||||
_user: TokenPayload = Depends(require_permission("melodies", "add")),
|
||||
db: AsyncSession = Depends(get_pg_session),
|
||||
):
|
||||
return await service.create_melody(body, publish=publish, actor_name=_user.name)
|
||||
melody = await service.create_melody(body, publish=publish, actor_name=_user.name)
|
||||
await log_action(db, _user.sub, _user.name or _user.email, "CREATE", "melody",
|
||||
melody.id, melody.information.name if melody.information else melody.id)
|
||||
return melody
|
||||
|
||||
|
||||
@router.put("/{melody_id}", response_model=MelodyInDB)
|
||||
@@ -51,32 +58,61 @@ async def update_melody(
|
||||
melody_id: str,
|
||||
body: MelodyUpdate,
|
||||
_user: TokenPayload = Depends(require_permission("melodies", "edit")),
|
||||
db: AsyncSession = Depends(get_pg_session),
|
||||
):
|
||||
return await service.update_melody(melody_id, body, actor_name=_user.name)
|
||||
old = await service.get_melody(melody_id)
|
||||
melody = await service.update_melody(melody_id, body, actor_name=_user.name)
|
||||
_SKIP = {"updated_at", "id", "metadata", "information", "noteAssignments"}
|
||||
changes = {
|
||||
k: {"old": getattr(old, k, None), "new": getattr(melody, k, None)}
|
||||
for k in body.model_fields_set
|
||||
if k not in _SKIP and getattr(old, k, None) != getattr(melody, k, None)
|
||||
}
|
||||
# Surface the name change from inside the information sub-object
|
||||
old_name = old.information.name if old.information else None
|
||||
new_name = melody.information.name if melody.information else None
|
||||
if old_name != new_name:
|
||||
changes["name"] = {"old": old_name, "new": new_name}
|
||||
await log_action(db, _user.sub, _user.name or _user.email, "UPDATE", "melody",
|
||||
melody_id, new_name or melody_id, changes=changes or None)
|
||||
return melody
|
||||
|
||||
|
||||
@router.delete("/{melody_id}", status_code=204)
|
||||
async def delete_melody(
|
||||
melody_id: str,
|
||||
_user: TokenPayload = Depends(require_permission("melodies", "delete")),
|
||||
db: AsyncSession = Depends(get_pg_session),
|
||||
):
|
||||
melody = await service.get_melody(melody_id)
|
||||
label = melody.information.name if melody.information else melody_id
|
||||
await service.delete_melody(melody_id)
|
||||
await log_action(db, _user.sub, _user.name or _user.email, "DELETE", "melody",
|
||||
melody_id, label)
|
||||
|
||||
|
||||
@router.post("/{melody_id}/publish", response_model=MelodyInDB)
|
||||
async def publish_melody(
|
||||
melody_id: str,
|
||||
_user: TokenPayload = Depends(require_permission("melodies", "edit")),
|
||||
db: AsyncSession = Depends(get_pg_session),
|
||||
):
|
||||
return await service.publish_melody(melody_id)
|
||||
melody = await service.publish_melody(melody_id)
|
||||
await log_action(db, _user.sub, _user.name or _user.email, "PUBLISH", "melody",
|
||||
melody_id, melody.information.name if melody.information else melody_id)
|
||||
return melody
|
||||
|
||||
|
||||
@router.post("/{melody_id}/unpublish", response_model=MelodyInDB)
|
||||
async def unpublish_melody(
|
||||
melody_id: str,
|
||||
_user: TokenPayload = Depends(require_permission("melodies", "edit")),
|
||||
db: AsyncSession = Depends(get_pg_session),
|
||||
):
|
||||
return await service.unpublish_melody(melody_id)
|
||||
melody = await service.unpublish_melody(melody_id)
|
||||
await log_action(db, _user.sub, _user.name or _user.email, "UNPUBLISH", "melody",
|
||||
melody_id, melody.information.name if melody.information else melody_id)
|
||||
return melody
|
||||
|
||||
|
||||
@router.post("/{melody_id}/upload/{file_type}")
|
||||
|
||||
@@ -6,6 +6,7 @@ from auth.dependencies import require_permission
|
||||
from auth.models import TokenPayload
|
||||
from notes import service
|
||||
from notes.models import EntryCreate, EntryUpdate, EntryOut, EntryListResponse, LinksReplaceIn
|
||||
from shared.audit import log_action
|
||||
|
||||
router = APIRouter(prefix="/api/notes", tags=["notes"])
|
||||
|
||||
@@ -49,7 +50,10 @@ async def create_entry(
|
||||
db: AsyncSession = Depends(get_pg_session),
|
||||
_user: TokenPayload = Depends(require_permission("crm", "add")),
|
||||
):
|
||||
return await service.create_entry(db, body, _user.sub, _user.name or _user.email)
|
||||
entry = await service.create_entry(db, body, _user.sub, _user.name or _user.email)
|
||||
await log_action(db, _user.sub, _user.name or _user.email, "CREATE", "note",
|
||||
str(entry.id), entry.title or entry.type)
|
||||
return entry
|
||||
|
||||
|
||||
@router.patch("/{entry_id}", response_model=EntryOut)
|
||||
@@ -58,7 +62,10 @@ async def update_entry(
|
||||
db: AsyncSession = Depends(get_pg_session),
|
||||
_user: TokenPayload = Depends(require_permission("crm", "edit")),
|
||||
):
|
||||
return await service.update_entry(db, entry_id, body)
|
||||
entry = await service.update_entry(db, entry_id, body)
|
||||
await log_action(db, _user.sub, _user.name or _user.email, "UPDATE", "note",
|
||||
str(entry_id), entry.title or entry.type)
|
||||
return entry
|
||||
|
||||
|
||||
@router.patch("/{entry_id}/links", response_model=EntryOut)
|
||||
@@ -67,7 +74,11 @@ async def replace_links(
|
||||
db: AsyncSession = Depends(get_pg_session),
|
||||
_user: TokenPayload = Depends(require_permission("crm", "edit")),
|
||||
):
|
||||
return await service.replace_links(db, entry_id, body.links)
|
||||
entry = await service.replace_links(db, entry_id, body.links)
|
||||
await log_action(db, _user.sub, _user.name or _user.email, "UPDATE", "note",
|
||||
str(entry_id), entry.title or entry.type,
|
||||
meta={"action_detail": "links_updated"})
|
||||
return entry
|
||||
|
||||
|
||||
@router.delete("/{entry_id}", status_code=204)
|
||||
@@ -76,4 +87,7 @@ async def delete_entry(
|
||||
db: AsyncSession = Depends(get_pg_session),
|
||||
_user: TokenPayload = Depends(require_permission("crm", "delete")),
|
||||
):
|
||||
entry = await service.get_entry(db, entry_id)
|
||||
await service.delete_entry(db, entry_id)
|
||||
await log_action(db, _user.sub, _user.name or _user.email, "DELETE", "note",
|
||||
str(entry_id), entry.title or entry.type if entry else str(entry_id))
|
||||
|
||||
0
backend/search/__init__.py
Normal file
0
backend/search/__init__.py
Normal file
163
backend/search/router.py
Normal file
163
backend/search/router.py
Normal file
@@ -0,0 +1,163 @@
|
||||
import asyncio
|
||||
import json
|
||||
from fastapi import APIRouter, Depends, Query
|
||||
from auth.dependencies import get_current_user
|
||||
from auth.models import TokenPayload
|
||||
from devices import service as devices_service
|
||||
from users import service as users_service
|
||||
from crm import service as crm_service
|
||||
from melodies import service as melodies_service
|
||||
|
||||
router = APIRouter(prefix="/api/search", tags=["search"])
|
||||
|
||||
LIMIT = 5
|
||||
|
||||
|
||||
def _truncate(s: str, n: int = 48) -> str:
|
||||
if not s:
|
||||
return ""
|
||||
return s if len(s) <= n else s[:n - 1] + "…"
|
||||
|
||||
|
||||
def _search_devices(q: str) -> list[dict]:
|
||||
try:
|
||||
results = devices_service.list_devices(search=q)
|
||||
except Exception:
|
||||
return []
|
||||
out = []
|
||||
for d in results[:LIMIT]:
|
||||
label = d.device_name or d.serial_number or d.device_id or d.id
|
||||
sublabel = d.serial_number if d.device_name else None
|
||||
out.append({
|
||||
"type": "device",
|
||||
"id": d.id,
|
||||
"label": _truncate(label),
|
||||
"sublabel": _truncate(sublabel) if sublabel else None,
|
||||
"url": f"/devices/{d.id}",
|
||||
})
|
||||
return out
|
||||
|
||||
|
||||
def _search_users(q: str) -> list[dict]:
|
||||
try:
|
||||
results = users_service.list_users(search=q)
|
||||
except Exception:
|
||||
return []
|
||||
out = []
|
||||
for u in results[:LIMIT]:
|
||||
label = u.display_name or u.email or u.id
|
||||
sublabel = u.email if u.display_name else None
|
||||
out.append({
|
||||
"type": "user",
|
||||
"id": u.id,
|
||||
"label": _truncate(label),
|
||||
"sublabel": _truncate(sublabel) if sublabel else None,
|
||||
"url": f"/users/{u.id}",
|
||||
})
|
||||
return out
|
||||
|
||||
|
||||
def _search_customers(q: str) -> list[dict]:
|
||||
try:
|
||||
results = crm_service.list_customers(search=q)
|
||||
except Exception:
|
||||
return []
|
||||
out = []
|
||||
for c in results[:LIMIT]:
|
||||
name_parts = [c.name, c.surname]
|
||||
label = " ".join(p for p in name_parts if p) or c.organization or c.id
|
||||
sublabel_parts = []
|
||||
if c.organization and (c.name or c.surname):
|
||||
sublabel_parts.append(c.organization)
|
||||
if c.location:
|
||||
if c.location.city:
|
||||
sublabel_parts.append(c.location.city)
|
||||
if c.location.country:
|
||||
sublabel_parts.append(c.location.country)
|
||||
out.append({
|
||||
"type": "customer",
|
||||
"id": c.id,
|
||||
"label": _truncate(label),
|
||||
"sublabel": _truncate(" · ".join(sublabel_parts)) if sublabel_parts else None,
|
||||
"url": f"/crm/customers/{c.id}",
|
||||
})
|
||||
return out
|
||||
|
||||
|
||||
def _search_products(q: str) -> list[dict]:
|
||||
try:
|
||||
results = crm_service.list_products(search=q)
|
||||
except Exception:
|
||||
return []
|
||||
out = []
|
||||
for p in results[:LIMIT]:
|
||||
sublabel_parts = []
|
||||
if p.category:
|
||||
sublabel_parts.append(p.category.value.replace("_", " ").title())
|
||||
if p.sku:
|
||||
sublabel_parts.append(p.sku)
|
||||
out.append({
|
||||
"type": "product",
|
||||
"id": p.id,
|
||||
"label": _truncate(p.name or p.id),
|
||||
"sublabel": _truncate(" · ".join(sublabel_parts)) if sublabel_parts else None,
|
||||
"url": f"/crm/products/{p.id}",
|
||||
})
|
||||
return out
|
||||
|
||||
|
||||
async def _search_melodies(q: str) -> list[dict]:
|
||||
try:
|
||||
results = await melodies_service.list_melodies(search=q)
|
||||
except Exception:
|
||||
return []
|
||||
out = []
|
||||
for m in results[:LIMIT]:
|
||||
try:
|
||||
name_dict = json.loads(m.information.name) if m.information.name else {}
|
||||
label = name_dict.get("en") or name_dict.get("gr") or next(iter(name_dict.values()), None) or m.id
|
||||
except Exception:
|
||||
label = m.information.name or m.id
|
||||
sublabel_parts = []
|
||||
if m.pid:
|
||||
sublabel_parts.append(m.pid)
|
||||
if m.information.melodyTone:
|
||||
sublabel_parts.append(m.information.melodyTone.value.title())
|
||||
if m.information.totalActiveBells:
|
||||
sublabel_parts.append(f"{m.information.totalActiveBells} bells")
|
||||
out.append({
|
||||
"type": "melody",
|
||||
"id": m.id,
|
||||
"label": _truncate(label),
|
||||
"sublabel": _truncate(" · ".join(sublabel_parts)) if sublabel_parts else None,
|
||||
"url": f"/melodies/{m.id}",
|
||||
})
|
||||
return out
|
||||
|
||||
|
||||
@router.get("")
|
||||
async def global_search(
|
||||
q: str = Query(..., min_length=1, max_length=100),
|
||||
_user: TokenPayload = Depends(get_current_user),
|
||||
):
|
||||
q = q.strip()
|
||||
if not q:
|
||||
return {"results": []}
|
||||
|
||||
# Run sync searches in a thread pool, melody search is already async
|
||||
loop = asyncio.get_event_loop()
|
||||
devices_fut = loop.run_in_executor(None, _search_devices, q)
|
||||
users_fut = loop.run_in_executor(None, _search_users, q)
|
||||
customers_fut = loop.run_in_executor(None, _search_customers, q)
|
||||
products_fut = loop.run_in_executor(None, _search_products, q)
|
||||
melodies_task = _search_melodies(q)
|
||||
|
||||
devices, users, customers, products, melodies = await asyncio.gather(
|
||||
devices_fut, users_fut, customers_fut, products_fut, melodies_task
|
||||
)
|
||||
|
||||
results = []
|
||||
for group in (devices, users, customers, products, melodies):
|
||||
results.extend(group)
|
||||
|
||||
return {"results": results}
|
||||
@@ -43,6 +43,10 @@ async def log_action(
|
||||
"""
|
||||
Insert one row into audit_log. Never raises — failures are silently swallowed
|
||||
so a logging error never disrupts the primary request.
|
||||
|
||||
Always commits its own mini-transaction. Callers that run inside a larger
|
||||
transaction (e.g. staff service) should commit themselves after calling this;
|
||||
the extra commit here is a no-op if the session is already clean.
|
||||
"""
|
||||
try:
|
||||
entry = AuditLog(
|
||||
@@ -57,12 +61,9 @@ async def log_action(
|
||||
meta=meta,
|
||||
)
|
||||
db.add(entry)
|
||||
# Flush without committing — caller's transaction commits it atomically.
|
||||
# If the caller hasn't started a transaction, flush still works; the
|
||||
# session will auto-commit on the next explicit commit call.
|
||||
await db.flush()
|
||||
await db.commit()
|
||||
except Exception:
|
||||
pass
|
||||
await db.rollback()
|
||||
|
||||
|
||||
def diff(old: dict, new: dict) -> dict[str, dict]:
|
||||
|
||||
@@ -6,6 +6,7 @@ from auth.dependencies import require_permission
|
||||
from auth.models import TokenPayload
|
||||
from tickets import service
|
||||
from tickets.models import TicketCreate, TicketUpdate, MessageCreate, EscalateIn, TicketOut, TicketListResponse
|
||||
from shared.audit import log_action
|
||||
|
||||
router = APIRouter(prefix="/api/tickets", tags=["tickets"])
|
||||
|
||||
@@ -57,7 +58,10 @@ async def create_ticket(
|
||||
db: AsyncSession = Depends(get_pg_session),
|
||||
_user: TokenPayload = Depends(require_permission("crm", "add")),
|
||||
):
|
||||
return await service.create_ticket(db, body)
|
||||
ticket = await service.create_ticket(db, body)
|
||||
await log_action(db, _user.sub, _user.name or _user.email, "CREATE", "ticket",
|
||||
str(ticket.id), ticket.subject)
|
||||
return ticket
|
||||
|
||||
|
||||
@router.patch("/{ticket_id}", response_model=TicketOut)
|
||||
@@ -66,7 +70,11 @@ async def update_ticket(
|
||||
db: AsyncSession = Depends(get_pg_session),
|
||||
_user: TokenPayload = Depends(require_permission("crm", "edit")),
|
||||
):
|
||||
return await service.update_ticket(db, ticket_id, body)
|
||||
ticket = await service.update_ticket(db, ticket_id, body)
|
||||
action = "STATUS_CHANGE" if body.status is not None else "UPDATE"
|
||||
await log_action(db, _user.sub, _user.name or _user.email, action, "ticket",
|
||||
str(ticket_id), ticket.subject)
|
||||
return ticket
|
||||
|
||||
|
||||
@router.post("/{ticket_id}/messages", response_model=TicketOut)
|
||||
@@ -75,7 +83,10 @@ async def add_message(
|
||||
db: AsyncSession = Depends(get_pg_session),
|
||||
_user: TokenPayload = Depends(require_permission("crm", "edit")),
|
||||
):
|
||||
return await service.add_message(db, ticket_id, body)
|
||||
ticket = await service.add_message(db, ticket_id, body)
|
||||
await log_action(db, _user.sub, _user.name or _user.email, "UPDATE", "ticket",
|
||||
str(ticket_id), ticket.subject, meta={"action_detail": "message_added"})
|
||||
return ticket
|
||||
|
||||
|
||||
@router.post("/{ticket_id}/escalate", response_model=TicketOut)
|
||||
@@ -84,4 +95,7 @@ async def escalate(
|
||||
db: AsyncSession = Depends(get_pg_session),
|
||||
_user: TokenPayload = Depends(require_permission("crm", "edit")),
|
||||
):
|
||||
return await service.escalate_to_issue(db, ticket_id, body.entry_id)
|
||||
ticket = await service.escalate_to_issue(db, ticket_id, body.entry_id)
|
||||
await log_action(db, _user.sub, _user.name or _user.email, "STATUS_CHANGE", "ticket",
|
||||
str(ticket_id), ticket.subject, meta={"action_detail": "escalated_to_issue"})
|
||||
return ticket
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
from fastapi import APIRouter, Depends, Query, UploadFile, File
|
||||
from typing import Optional, List
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from auth.models import TokenPayload
|
||||
from auth.dependencies import require_permission
|
||||
from users.models import (
|
||||
@@ -7,6 +8,8 @@ from users.models import (
|
||||
SetPasswordRequest, ResetPasswordRequest,
|
||||
)
|
||||
from users import service
|
||||
from database.postgres import get_pg_session
|
||||
from shared.audit import log_action
|
||||
|
||||
router = APIRouter(prefix="/api/users", tags=["users"])
|
||||
|
||||
@@ -33,8 +36,12 @@ async def get_user(
|
||||
async def create_user(
|
||||
body: UserCreate,
|
||||
_user: TokenPayload = Depends(require_permission("app_users", "add")),
|
||||
db: AsyncSession = Depends(get_pg_session),
|
||||
):
|
||||
return service.create_user(body)
|
||||
app_user = service.create_user(body)
|
||||
await log_action(db, _user.sub, _user.name or _user.email, "CREATE", "app_user",
|
||||
app_user.id, app_user.display_name or app_user.email or app_user.id)
|
||||
return app_user
|
||||
|
||||
|
||||
@router.put("/{user_id}", response_model=UserInDB)
|
||||
@@ -42,32 +49,57 @@ async def update_user(
|
||||
user_id: str,
|
||||
body: UserUpdate,
|
||||
_user: TokenPayload = Depends(require_permission("app_users", "edit")),
|
||||
db: AsyncSession = Depends(get_pg_session),
|
||||
):
|
||||
return service.update_user(user_id, body)
|
||||
old = service.get_user(user_id)
|
||||
app_user = service.update_user(user_id, body)
|
||||
_SKIP = {"updated_at", "id", "photo_url"}
|
||||
changes = {
|
||||
k: {"old": getattr(old, k, None), "new": getattr(app_user, k, None)}
|
||||
for k in body.model_fields_set
|
||||
if k not in _SKIP and getattr(old, k, None) != getattr(app_user, k, None)
|
||||
}
|
||||
await log_action(db, _user.sub, _user.name or _user.email, "UPDATE", "app_user",
|
||||
user_id, app_user.display_name or app_user.email or user_id,
|
||||
changes=changes or None)
|
||||
return app_user
|
||||
|
||||
|
||||
@router.delete("/{user_id}", status_code=204)
|
||||
async def delete_user(
|
||||
user_id: str,
|
||||
_user: TokenPayload = Depends(require_permission("app_users", "delete")),
|
||||
db: AsyncSession = Depends(get_pg_session),
|
||||
):
|
||||
service.delete_user(user_id)
|
||||
await log_action(db, _user.sub, _user.name or _user.email, "DELETE", "app_user",
|
||||
user_id, user_id)
|
||||
|
||||
|
||||
@router.post("/{user_id}/block", response_model=UserInDB)
|
||||
async def block_user(
|
||||
user_id: str,
|
||||
_user: TokenPayload = Depends(require_permission("app_users", "edit")),
|
||||
db: AsyncSession = Depends(get_pg_session),
|
||||
):
|
||||
return service.block_user(user_id)
|
||||
app_user = service.block_user(user_id)
|
||||
await log_action(db, _user.sub, _user.name or _user.email, "STATUS_CHANGE", "app_user",
|
||||
user_id, app_user.display_name or app_user.email or user_id,
|
||||
meta={"status": "blocked"})
|
||||
return app_user
|
||||
|
||||
|
||||
@router.post("/{user_id}/unblock", response_model=UserInDB)
|
||||
async def unblock_user(
|
||||
user_id: str,
|
||||
_user: TokenPayload = Depends(require_permission("app_users", "edit")),
|
||||
db: AsyncSession = Depends(get_pg_session),
|
||||
):
|
||||
return service.unblock_user(user_id)
|
||||
app_user = service.unblock_user(user_id)
|
||||
await log_action(db, _user.sub, _user.name or _user.email, "STATUS_CHANGE", "app_user",
|
||||
user_id, app_user.display_name or app_user.email or user_id,
|
||||
meta={"status": "unblocked"})
|
||||
return app_user
|
||||
|
||||
|
||||
@router.get("/{user_id}/devices", response_model=List[dict])
|
||||
|
||||
Reference in New Issue
Block a user