update: Add Global Search on Header, Add Global Audit log for all actions.

This commit is contained in:
2026-04-19 15:41:29 +03:00
parent 4f35bef6e3
commit 6a958a8d7d
27 changed files with 2086 additions and 267 deletions

View File

@@ -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)

View File

@@ -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 ──────────────────────────────────────────────────────────

View File

@@ -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)

View File

@@ -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)

View File

@@ -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)

View File

@@ -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}

View File

@@ -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)
# ─────────────────────────────────────────────────────────────────────────────

View File

@@ -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():

View File

@@ -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}")

View File

@@ -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))

View File

163
backend/search/router.py Normal file
View 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}

View File

@@ -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]:

View File

@@ -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

View File

@@ -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])