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