main
py 605 lines 21.6 KB
Raw
1 """
2 Service layer for case template CRUD (issue #792, Phase 2).
3
4 Templates are scoped by ``customer_code`` (NULL = global) and ``source``
5 (NULL = any alert source). Rows are managed exclusively by admin/analyst
6 operators — the route layer enforces the auth scope, this layer is auth-
7 agnostic and only handles persistence and validation.
8
9 Tasks within a template are exposed via separate functions
10 (``add_template_task`` etc.) so the API can support incremental task
11 authoring without requiring a full template replacement.
12
13 Phase 3 will introduce ``pick_template`` for case-creation-time
14 selection and the snapshot-copy of CaseTemplateTask -> CaseTask.
15 """
16
17 from datetime import datetime
18 from typing import List
19 from typing import Optional
20
21 from loguru import logger
22 from sqlalchemy import select
23 from sqlalchemy.ext.asyncio import AsyncSession
24 from sqlalchemy.orm import selectinload
25
26 from app.incidents.models import CaseTemplate
27 from app.incidents.models import CaseTemplateTask
28 from app.incidents.schema.case_templates import CaseTemplateCreate
29 from app.incidents.schema.case_templates import CaseTemplateListResponse
30 from app.incidents.schema.case_templates import CaseTemplateOperationResponse
31 from app.incidents.schema.case_templates import CaseTemplateResponse
32 from app.incidents.schema.case_templates import CaseTemplateTaskCreate
33 from app.incidents.schema.case_templates import CaseTemplateTaskOperationResponse
34 from app.incidents.schema.case_templates import CaseTemplateTaskResponse
35 from app.incidents.schema.case_templates import CaseTemplateTaskUpdate
36 from app.incidents.schema.case_templates import CaseTemplateUpdate
37
38 # ---------------------------------------------------------------------------
39 # Internal helpers
40 # ---------------------------------------------------------------------------
41
42
43 def _template_task_to_response(task: CaseTemplateTask) -> CaseTemplateTaskResponse:
44 return CaseTemplateTaskResponse(
45 id=task.id,
46 template_id=task.template_id,
47 title=task.title,
48 description=task.description,
49 guidelines=task.guidelines,
50 mandatory=task.mandatory,
51 order_index=task.order_index,
52 )
53
54
55 def _template_to_response(template: CaseTemplate) -> CaseTemplateResponse:
56 tasks_sorted = sorted(template.tasks or [], key=lambda t: (t.order_index, t.id))
57 return CaseTemplateResponse(
58 id=template.id,
59 name=template.name,
60 description=template.description,
61 customer_code=template.customer_code,
62 source=template.source,
63 is_default=template.is_default,
64 match_field=template.match_field,
65 match_value=template.match_value,
66 created_by=template.created_by,
67 created_at=template.created_at,
68 updated_at=template.updated_at,
69 tasks=[_template_task_to_response(t) for t in tasks_sorted],
70 )
71
72
73 def _validate_match_pair(match_field: Optional[str], match_value: Optional[str]) -> None:
74 """Both-or-neither rule: a half-set match would silently never trigger,
75 which is a foot-gun. Reject the request rather than persist an inert state."""
76 if (match_field is None) != (match_value is None):
77 raise ValueError(
78 "match_field and match_value must both be set or both be null. "
79 "Set both to enable conditional auto-apply, or both to null for an unconditional template.",
80 )
81
82
83 async def _load_template_with_tasks(
84 template_id: int,
85 session: AsyncSession,
86 ) -> Optional[CaseTemplate]:
87 stmt = select(CaseTemplate).where(CaseTemplate.id == template_id).options(selectinload(CaseTemplate.tasks))
88 result = await session.execute(stmt)
89 return result.scalar_one_or_none()
90
91
92 async def _enforce_single_default(
93 customer_code: Optional[str],
94 source: Optional[str],
95 exclude_template_id: Optional[int],
96 session: AsyncSession,
97 ) -> None:
98 """
99 Demote any other ``is_default`` template that shares the same
100 (customer_code, source) scope. Keeps default selection unambiguous.
101 """
102 stmt = (
103 select(CaseTemplate)
104 .where(CaseTemplate.is_default == True) # noqa: E712 - SQL boolean
105 .where(CaseTemplate.customer_code.is_(None) if customer_code is None else CaseTemplate.customer_code == customer_code)
106 .where(CaseTemplate.source.is_(None) if source is None else CaseTemplate.source == source)
107 )
108 if exclude_template_id is not None:
109 stmt = stmt.where(CaseTemplate.id != exclude_template_id)
110
111 result = await session.execute(stmt)
112 others = result.scalars().all()
113 for other in others:
114 other.is_default = False
115 other.updated_at = datetime.utcnow()
116 session.add(other)
117
118
119 # ---------------------------------------------------------------------------
120 # Template CRUD
121 # ---------------------------------------------------------------------------
122
123
124 async def create_template(
125 request: CaseTemplateCreate,
126 actor: str,
127 session: AsyncSession,
128 ) -> CaseTemplateOperationResponse:
129 """Create a new template with optional initial tasks."""
130 logger.info(f"Creating case template '{request.name}' by {actor}")
131
132 try:
133 _validate_match_pair(request.match_field, request.match_value)
134
135 if request.is_default:
136 await _enforce_single_default(
137 customer_code=request.customer_code,
138 source=request.source,
139 exclude_template_id=None,
140 session=session,
141 )
142
143 template = CaseTemplate(
144 name=request.name,
145 description=request.description,
146 customer_code=request.customer_code,
147 source=request.source,
148 is_default=request.is_default,
149 match_field=request.match_field,
150 match_value=request.match_value,
151 created_by=actor,
152 )
153 session.add(template)
154 await session.flush() # populate template.id before adding tasks
155
156 for task_payload in request.tasks:
157 session.add(
158 CaseTemplateTask(
159 template_id=template.id,
160 title=task_payload.title,
161 description=task_payload.description,
162 guidelines=task_payload.guidelines,
163 mandatory=task_payload.mandatory,
164 order_index=task_payload.order_index,
165 ),
166 )
167
168 await session.commit()
169
170 loaded = await _load_template_with_tasks(template.id, session)
171 return CaseTemplateOperationResponse(
172 template=_template_to_response(loaded),
173 success=True,
174 message=f"Created template id={template.id}",
175 )
176
177 except Exception as e:
178 logger.error(f"Failed to create case template: {e}")
179 await session.rollback()
180 return CaseTemplateOperationResponse(
181 template=None,
182 success=False,
183 message=f"Failed to create case template: {e}",
184 )
185
186
187 async def list_templates(
188 session: AsyncSession,
189 customer_code: Optional[str] = None,
190 source: Optional[str] = None,
191 include_global: bool = True,
192 ) -> CaseTemplateListResponse:
193 """
194 List templates, optionally filtered. Filtering rules:
195
196 - ``customer_code`` provided + ``include_global=True`` (default): returns
197 templates for that customer plus all global templates (customer_code IS
198 NULL). This matches the natural admin-UI need: "show me what's available
199 for customer X".
200 - ``customer_code`` provided + ``include_global=False``: customer-scoped
201 only.
202 - ``source`` provided: same logic for the source dimension.
203 - Both omitted: returns everything (typical for admin Templates view).
204 """
205 try:
206 stmt = select(CaseTemplate).options(selectinload(CaseTemplate.tasks))
207
208 if customer_code is not None:
209 if include_global:
210 stmt = stmt.where(
211 (CaseTemplate.customer_code == customer_code) | (CaseTemplate.customer_code.is_(None)),
212 )
213 else:
214 stmt = stmt.where(CaseTemplate.customer_code == customer_code)
215
216 if source is not None:
217 if include_global:
218 stmt = stmt.where(
219 (CaseTemplate.source == source) | (CaseTemplate.source.is_(None)),
220 )
221 else:
222 stmt = stmt.where(CaseTemplate.source == source)
223
224 stmt = stmt.order_by(CaseTemplate.created_at.desc())
225
226 result = await session.execute(stmt)
227 templates = result.scalars().all()
228
229 return CaseTemplateListResponse(
230 templates=[_template_to_response(t) for t in templates],
231 success=True,
232 message=f"Retrieved {len(templates)} template(s)",
233 )
234
235 except Exception as e:
236 logger.error(f"Failed to list case templates: {e}")
237 return CaseTemplateListResponse(
238 templates=[],
239 success=False,
240 message=f"Failed to list case templates: {e}",
241 )
242
243
244 async def get_template(
245 template_id: int,
246 session: AsyncSession,
247 ) -> CaseTemplateOperationResponse:
248 template = await _load_template_with_tasks(template_id, session)
249 if template is None:
250 return CaseTemplateOperationResponse(
251 template=None,
252 success=False,
253 message=f"Template id={template_id} not found",
254 )
255
256 return CaseTemplateOperationResponse(
257 template=_template_to_response(template),
258 success=True,
259 message=f"Retrieved template id={template_id}",
260 )
261
262
263 async def update_template(
264 template_id: int,
265 request: CaseTemplateUpdate,
266 session: AsyncSession,
267 ) -> CaseTemplateOperationResponse:
268 """Partial update of template metadata. Tasks are managed separately."""
269 try:
270 template = await _load_template_with_tasks(template_id, session)
271 if template is None:
272 return CaseTemplateOperationResponse(
273 template=None,
274 success=False,
275 message=f"Template id={template_id} not found",
276 )
277
278 fields_set = request.__fields_set__
279
280 if "name" in fields_set and request.name is not None:
281 template.name = request.name
282 if "description" in fields_set:
283 template.description = request.description
284 if "customer_code" in fields_set:
285 template.customer_code = request.customer_code
286 if "source" in fields_set:
287 template.source = request.source
288 if "is_default" in fields_set and request.is_default is not None:
289 template.is_default = request.is_default
290 if template.is_default:
291 await _enforce_single_default(
292 customer_code=template.customer_code,
293 source=template.source,
294 exclude_template_id=template.id,
295 session=session,
296 )
297
298 # Match-pair edits: a partial update can touch one, both, or neither
299 # field. Compute the post-update state and validate the pair before
300 # writing — protects against ending up with field-without-value or
301 # value-without-field.
302 if "match_field" in fields_set or "match_value" in fields_set:
303 new_field = request.match_field if "match_field" in fields_set else template.match_field
304 new_value = request.match_value if "match_value" in fields_set else template.match_value
305 _validate_match_pair(new_field, new_value)
306 template.match_field = new_field
307 template.match_value = new_value
308
309 template.updated_at = datetime.utcnow()
310 session.add(template)
311 await session.commit()
312
313 refreshed = await _load_template_with_tasks(template_id, session)
314 return CaseTemplateOperationResponse(
315 template=_template_to_response(refreshed),
316 success=True,
317 message=f"Updated template id={template_id}",
318 )
319
320 except Exception as e:
321 logger.error(f"Failed to update case template id={template_id}: {e}")
322 await session.rollback()
323 return CaseTemplateOperationResponse(
324 template=None,
325 success=False,
326 message=f"Failed to update case template: {e}",
327 )
328
329
330 async def delete_template(
331 template_id: int,
332 session: AsyncSession,
333 ) -> CaseTemplateOperationResponse:
334 """
335 Delete a template and its template tasks. Existing CaseTask rows on
336 real cases are preserved (they're snapshots) and have their
337 ``template_task_id`` FK set to NULL implicitly via a manual update —
338 we don't rely on cascade because the column is nullable by design.
339 """
340 try:
341 template = await _load_template_with_tasks(template_id, session)
342 if template is None:
343 return CaseTemplateOperationResponse(
344 template=None,
345 success=False,
346 message=f"Template id={template_id} not found",
347 )
348
349 snapshot = _template_to_response(template)
350
351 # Null out the soft FK on any CaseTask snapshots that pointed here.
352 # Doing this explicitly so that a future change to ON DELETE behavior
353 # doesn't silently clobber audit trails.
354 from app.incidents.models import CaseTask
355
356 task_ids = [t.id for t in template.tasks]
357 if task_ids:
358 stmt = select(CaseTask).where(CaseTask.template_task_id.in_(task_ids))
359 result = await session.execute(stmt)
360 for case_task in result.scalars().all():
361 case_task.template_task_id = None
362 session.add(case_task)
363
364 for task in list(template.tasks):
365 await session.delete(task)
366 await session.delete(template)
367 await session.commit()
368
369 return CaseTemplateOperationResponse(
370 template=snapshot,
371 success=True,
372 message=f"Deleted template id={template_id}",
373 )
374
375 except Exception as e:
376 logger.error(f"Failed to delete case template id={template_id}: {e}")
377 await session.rollback()
378 return CaseTemplateOperationResponse(
379 template=None,
380 success=False,
381 message=f"Failed to delete case template: {e}",
382 )
383
384
385 # ---------------------------------------------------------------------------
386 # Template task CRUD
387 # ---------------------------------------------------------------------------
388
389
390 async def add_template_task(
391 template_id: int,
392 request: CaseTemplateTaskCreate,
393 session: AsyncSession,
394 ) -> CaseTemplateTaskOperationResponse:
395 try:
396 template = await _load_template_with_tasks(template_id, session)
397 if template is None:
398 return CaseTemplateTaskOperationResponse(
399 task=None,
400 success=False,
401 message=f"Template id={template_id} not found",
402 )
403
404 task = CaseTemplateTask(
405 template_id=template_id,
406 title=request.title,
407 description=request.description,
408 guidelines=request.guidelines,
409 mandatory=request.mandatory,
410 order_index=request.order_index,
411 )
412 session.add(task)
413
414 template.updated_at = datetime.utcnow()
415 session.add(template)
416
417 await session.commit()
418 await session.refresh(task)
419
420 return CaseTemplateTaskOperationResponse(
421 task=_template_task_to_response(task),
422 success=True,
423 message=f"Added task id={task.id} to template id={template_id}",
424 )
425
426 except Exception as e:
427 logger.error(f"Failed to add task to template id={template_id}: {e}")
428 await session.rollback()
429 return CaseTemplateTaskOperationResponse(
430 task=None,
431 success=False,
432 message=f"Failed to add template task: {e}",
433 )
434
435
436 async def update_template_task(
437 task_id: int,
438 request: CaseTemplateTaskUpdate,
439 session: AsyncSession,
440 ) -> CaseTemplateTaskOperationResponse:
441 try:
442 result = await session.execute(select(CaseTemplateTask).where(CaseTemplateTask.id == task_id))
443 task = result.scalar_one_or_none()
444 if task is None:
445 return CaseTemplateTaskOperationResponse(
446 task=None,
447 success=False,
448 message=f"Template task id={task_id} not found",
449 )
450
451 fields_set = request.__fields_set__
452 if "title" in fields_set and request.title is not None:
453 task.title = request.title
454 if "description" in fields_set:
455 task.description = request.description
456 if "guidelines" in fields_set:
457 task.guidelines = request.guidelines
458 if "mandatory" in fields_set and request.mandatory is not None:
459 task.mandatory = request.mandatory
460 if "order_index" in fields_set and request.order_index is not None:
461 task.order_index = request.order_index
462
463 session.add(task)
464
465 # Touch the parent template so updated_at reflects the change.
466 template_result = await session.execute(
467 select(CaseTemplate).where(CaseTemplate.id == task.template_id),
468 )
469 parent = template_result.scalar_one_or_none()
470 if parent is not None:
471 parent.updated_at = datetime.utcnow()
472 session.add(parent)
473
474 await session.commit()
475 await session.refresh(task)
476
477 return CaseTemplateTaskOperationResponse(
478 task=_template_task_to_response(task),
479 success=True,
480 message=f"Updated template task id={task_id}",
481 )
482
483 except Exception as e:
484 logger.error(f"Failed to update template task id={task_id}: {e}")
485 await session.rollback()
486 return CaseTemplateTaskOperationResponse(
487 task=None,
488 success=False,
489 message=f"Failed to update template task: {e}",
490 )
491
492
493 async def delete_template_task(
494 task_id: int,
495 session: AsyncSession,
496 ) -> CaseTemplateTaskOperationResponse:
497 try:
498 result = await session.execute(select(CaseTemplateTask).where(CaseTemplateTask.id == task_id))
499 task = result.scalar_one_or_none()
500 if task is None:
501 return CaseTemplateTaskOperationResponse(
502 task=None,
503 success=False,
504 message=f"Template task id={task_id} not found",
505 )
506
507 snapshot = _template_task_to_response(task)
508 template_id = task.template_id
509
510 # Null out any CaseTask soft FKs that pointed at this template task.
511 from app.incidents.models import CaseTask
512
513 case_task_result = await session.execute(
514 select(CaseTask).where(CaseTask.template_task_id == task_id),
515 )
516 for case_task in case_task_result.scalars().all():
517 case_task.template_task_id = None
518 session.add(case_task)
519
520 await session.delete(task)
521
522 template_result = await session.execute(
523 select(CaseTemplate).where(CaseTemplate.id == template_id),
524 )
525 parent = template_result.scalar_one_or_none()
526 if parent is not None:
527 parent.updated_at = datetime.utcnow()
528 session.add(parent)
529
530 await session.commit()
531
532 return CaseTemplateTaskOperationResponse(
533 task=snapshot,
534 success=True,
535 message=f"Deleted template task id={task_id}",
536 )
537
538 except Exception as e:
539 logger.error(f"Failed to delete template task id={task_id}: {e}")
540 await session.rollback()
541 return CaseTemplateTaskOperationResponse(
542 task=None,
543 success=False,
544 message=f"Failed to delete template task: {e}",
545 )
546
547
548 async def reorder_template_tasks(
549 template_id: int,
550 ordered_task_ids: List[int],
551 session: AsyncSession,
552 ) -> CaseTemplateOperationResponse:
553 """
554 Reorder tasks within a template by passing the task IDs in the desired
555 order. Tasks not included in the list keep their existing order_index
556 value (effectively pushed to the end of the explicit list).
557
558 Validates that every passed ID belongs to the named template.
559 """
560 try:
561 template = await _load_template_with_tasks(template_id, session)
562 if template is None:
563 return CaseTemplateOperationResponse(
564 template=None,
565 success=False,
566 message=f"Template id={template_id} not found",
567 )
568
569 existing_ids = {t.id for t in template.tasks}
570 bad = [tid for tid in ordered_task_ids if tid not in existing_ids]
571 if bad:
572 return CaseTemplateOperationResponse(
573 template=None,
574 success=False,
575 message=f"Task ids do not belong to template id={template_id}: {bad}",
576 )
577
578 # Assign sequential indices to the explicit list so a renamed UI
579 # drag-drop reflects clean 0..N ordering on subsequent loads.
580 index_map = {tid: idx for idx, tid in enumerate(ordered_task_ids)}
581 for task in template.tasks:
582 if task.id in index_map:
583 task.order_index = index_map[task.id]
584 session.add(task)
585
586 template.updated_at = datetime.utcnow()
587 session.add(template)
588
589 await session.commit()
590
591 refreshed = await _load_template_with_tasks(template_id, session)
592 return CaseTemplateOperationResponse(
593 template=_template_to_response(refreshed),
594 success=True,
595 message=f"Reordered {len(ordered_task_ids)} task(s) on template id={template_id}",
596 )
597
598 except Exception as e:
599 logger.error(f"Failed to reorder tasks on template id={template_id}: {e}")
600 await session.rollback()
601 return CaseTemplateOperationResponse(
602 template=None,
603 success=False,
604 message=f"Failed to reorder template tasks: {e}",
605 )