diff --git a/app/modules/services/catalogue_merge.py b/app/modules/services/catalogue_merge.py
index 0c5aff5..5a450f0 100644
--- a/app/modules/services/catalogue_merge.py
+++ b/app/modules/services/catalogue_merge.py
@@ -44,6 +44,8 @@ def build_merge_preview(db: Session, *, source_id: int, target_id: int) -> Merge
target = db.get(ServiceCatalogue, target_id)
if not source or not target:
raise ValueError("The selected source or target service no longer exists.")
+ if not source.is_active or not target.is_active:
+ raise ValueError("Only active services can be selected for a merge.")
counts = {
"firm_selections": _count(db, FirmServiceSelection, FirmServiceSelection.service_catalogue_id == source_id),
diff --git a/app/modules/services/services.py b/app/modules/services/services.py
index 16437d1..0a13c60 100644
--- a/app/modules/services/services.py
+++ b/app/modules/services/services.py
@@ -65,7 +65,9 @@ def get_category(db: Session, category_id: int) -> ServiceCategory | None:
def list_catalogue_payload(db: Session, *, q: str = "", category_id: int | None = None, recurrence_type: str = "", engagement_type: str = "", page: int = 1, per_page: int = 20):
- query = select(ServiceCatalogue).options(
+ query = select(ServiceCatalogue).where(
+ ServiceCatalogue.is_active.is_(True)
+ ).options(
selectinload(ServiceCatalogue.service_category),
selectinload(ServiceCatalogue.default_task_templates),
selectinload(ServiceCatalogue.due_date_rules),
@@ -115,6 +117,7 @@ def list_firm_services_payload(db: Session, *, tenant_id: int, q: str = ""):
.where(
FirmServiceSelection.tenant_id == tenant_id,
FirmServiceSelection.is_enabled.is_(True),
+ ServiceCatalogue.is_active.is_(True),
)
)
if q.strip():
@@ -143,7 +146,10 @@ def list_disabled_catalogues(db: Session, *, tenant_id: int, q: str = ""):
)
)
- query = select(ServiceCatalogue).where(~ServiceCatalogue.id.in_(enabled_subq)).options(
+ query = select(ServiceCatalogue).where(
+ ServiceCatalogue.is_active.is_(True),
+ ~ServiceCatalogue.id.in_(enabled_subq),
+ ).options(
selectinload(ServiceCatalogue.service_category),
selectinload(ServiceCatalogue.default_task_templates),
selectinload(ServiceCatalogue.due_date_rules),
diff --git a/app/modules/services/templates/services/catalogue_list.html b/app/modules/services/templates/services/catalogue_list.html
index 72380bd..829c924 100644
--- a/app/modules/services/templates/services/catalogue_list.html
+++ b/app/modules/services/templates/services/catalogue_list.html
@@ -12,6 +12,7 @@
Firm Task Templates
{% if can_create %}
System Default Tasks
+ Export Excel
Merge Services
Add Catalogue Service
{% endif %}
diff --git a/app/modules/services/ui.py b/app/modules/services/ui.py
index 686800c..29085f8 100644
--- a/app/modules/services/ui.py
+++ b/app/modules/services/ui.py
@@ -21,6 +21,7 @@ from app.modules.services.bulk_imports import (
import_system_default_tasks,
import_due_date_extensions,
)
+from app.modules.services.catalogue_export import build_service_catalogue_export
from app.modules.services.due_dates import (
DUE_PERIOD_TYPES,
DUE_YEAR_BASIS_CHOICES,
@@ -333,6 +334,28 @@ def catalogue_list(request: Request, q: str = '', category_id: int | None = None
db.close()
+@router.get('/catalogue/export')
+def catalogue_export(request: Request):
+ db = CommonSessionLocal()
+ try:
+ user = get_current_user(request, db=db)
+ if not user:
+ return RedirectResponse(url='/login', status_code=303)
+ if not _is_system_admin(db, user):
+ return _redirect_denied()
+ require_permission(db, user, 'services.view')
+
+ workbook_bytes = build_service_catalogue_export(db)
+ filename = f"service_catalogue_export_{datetime.now(timezone.utc).strftime('%Y%m%d_%H%M%S')}.xlsx"
+ return StreamingResponse(
+ iter([workbook_bytes]),
+ media_type='application/vnd.openxmlformats-officedocument.spreadsheetml.sheet',
+ headers={'Content-Disposition': f'attachment; filename="{filename}"'},
+ )
+ finally:
+ db.close()
+
+
@router.get('/catalogue/merge')
def catalogue_merge_page(request: Request, source_id: int | None = None, target_id: int | None = None):
db = CommonSessionLocal()
@@ -345,7 +368,9 @@ def catalogue_merge_page(request: Request, source_id: int | None = None, target_
require_permission(db, user, 'services.edit')
services = db.execute(
- select(ServiceCatalogue).order_by(ServiceCatalogue.is_active.desc(), ServiceCatalogue.service_code.asc())
+ select(ServiceCatalogue)
+ .where(ServiceCatalogue.is_active.is_(True))
+ .order_by(ServiceCatalogue.service_code.asc())
).scalars().all()
preview = None
error = None
@@ -495,7 +520,7 @@ def catalogue_detail(request: Request, catalogue_id: int):
if not _can_view_services(db, user):
return _redirect_denied()
row = get_catalogue(db, catalogue_id)
- if not row:
+ if not row or not row.is_active:
return RedirectResponse(url='/services/catalogue', status_code=303)
tenant_id = _active_tenant_id(request, user)
can_edit = _is_system_admin(db, user) and _has_perm(db, user, 'services.edit')
@@ -764,7 +789,7 @@ def catalogue_edit_page(request: Request, catalogue_id: int):
return _redirect_denied()
require_permission(db, user, 'services.edit')
row = get_catalogue(db, catalogue_id)
- if not row:
+ if not row or not row.is_active:
return RedirectResponse(url='/services/catalogue', status_code=303)
return _render(request, 'modules/services/templates/services/catalogue_form.html', db, user, title='Edit Service Catalogue', mode='edit', catalogue=row, categories=list_categories(db))
except Exception:
@@ -785,7 +810,7 @@ def catalogue_edit_submit(request: Request, catalogue_id: int, service_name: str
return _redirect_denied()
require_permission(db, user, 'services.edit')
row = get_catalogue(db, catalogue_id)
- if not row:
+ if not row or not row.is_active:
return RedirectResponse(url='/services/catalogue', status_code=303)
selected_category = get_category(db, int(category_id)) if str(category_id).strip() else None
row.service_name = service_name.strip()
@@ -823,8 +848,8 @@ def toggle_firm_service(request: Request, catalogue_id: int, default_branch_id:
return _redirect_denied()
tenant_id = _active_tenant_id(request, user)
row = get_catalogue(db, catalogue_id)
- if not row:
- return RedirectResponse(url='/services', status_code=303)
+ if not row or not row.is_active:
+ return RedirectResponse(url='/services/catalogue', status_code=303)
selection = get_firm_selection(db, tenant_id=tenant_id, catalogue_id=catalogue_id)
if not selection:
selection = FirmServiceSelection(