label_templates.py 4.9 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129
  1. """Saved custom label sheets (#3296).
  2. A saved template is a named sheet layout, listed in the print dialog for
  3. everyone on the install. Printing does not read this table: the dialog sends
  4. the layout itself with the print request, so anyone who can print can use a
  5. saved template or a one-off custom sheet, and only saving, editing and
  6. deleting need ``inventory:update`` (the same as storage locations).
  7. """
  8. from __future__ import annotations
  9. from fastapi import APIRouter, Depends, HTTPException, Response
  10. from sqlalchemy import select
  11. from sqlalchemy.exc import IntegrityError
  12. from sqlalchemy.ext.asyncio import AsyncSession
  13. from backend.app.core.auth import RequirePermissionIfAuthEnabled
  14. from backend.app.core.database import get_db
  15. from backend.app.core.permissions import Permission
  16. from backend.app.models.label_template import LabelTemplate
  17. from backend.app.models.user import User
  18. from backend.app.schemas.label_template import (
  19. LabelTemplateIn,
  20. LabelTemplateResponse,
  21. SheetLayoutResponse,
  22. )
  23. from backend.app.utils.natural_sort import natural_sort_key
  24. router = APIRouter(prefix="/label-templates", tags=["labels"])
  25. DUPLICATE_NAME = "A label template with this name already exists"
  26. _LAYOUT_COLUMNS = tuple(SheetLayoutResponse.model_fields)
  27. def _to_response(template: LabelTemplate) -> LabelTemplateResponse:
  28. return LabelTemplateResponse(
  29. id=template.id,
  30. name=template.name,
  31. layout=SheetLayoutResponse(**{col: getattr(template, col) for col in _LAYOUT_COLUMNS}),
  32. created_at=template.created_at,
  33. updated_at=template.updated_at,
  34. )
  35. def _apply(template: LabelTemplate, data: LabelTemplateIn) -> None:
  36. template.name = data.name
  37. template.name_key = data.name_key
  38. for col, value in data.layout.model_dump().items():
  39. setattr(template, col, value)
  40. async def _name_taken(db: AsyncSession, name_key: str, exclude_id: int | None = None) -> bool:
  41. query = select(LabelTemplate.id).where(LabelTemplate.name_key == name_key)
  42. if exclude_id is not None:
  43. query = query.where(LabelTemplate.id != exclude_id)
  44. return (await db.execute(query)).first() is not None
  45. async def _commit(db: AsyncSession, template: LabelTemplate) -> None:
  46. try:
  47. await db.commit()
  48. except IntegrityError as exc:
  49. # Two saves under the same name at once: the unique index catches the
  50. # one the lookup above could not.
  51. await db.rollback()
  52. raise HTTPException(status_code=409, detail=DUPLICATE_NAME) from exc
  53. await db.refresh(template)
  54. @router.get("", response_model=list[LabelTemplateResponse])
  55. async def list_label_templates(
  56. db: AsyncSession = Depends(get_db, scope="function"),
  57. _: User | None = RequirePermissionIfAuthEnabled(Permission.INVENTORY_READ),
  58. ):
  59. """All saved label templates, in natural name order."""
  60. result = await db.execute(select(LabelTemplate))
  61. templates = sorted(result.scalars().all(), key=lambda t: natural_sort_key(t.name))
  62. return [_to_response(t) for t in templates]
  63. @router.post("", response_model=LabelTemplateResponse, status_code=201)
  64. async def create_label_template(
  65. data: LabelTemplateIn,
  66. db: AsyncSession = Depends(get_db, scope="function"),
  67. _: User | None = RequirePermissionIfAuthEnabled(Permission.INVENTORY_UPDATE),
  68. ):
  69. """Save a custom sheet layout under a name."""
  70. if await _name_taken(db, data.name_key):
  71. raise HTTPException(status_code=409, detail=DUPLICATE_NAME)
  72. template = LabelTemplate()
  73. _apply(template, data)
  74. db.add(template)
  75. await _commit(db, template)
  76. return _to_response(template)
  77. @router.put("/{template_id}", response_model=LabelTemplateResponse)
  78. async def replace_label_template(
  79. template_id: int,
  80. data: LabelTemplateIn,
  81. db: AsyncSession = Depends(get_db, scope="function"),
  82. _: User | None = RequirePermissionIfAuthEnabled(Permission.INVENTORY_UPDATE),
  83. ):
  84. """Rename a saved template or change its layout. The body is the whole
  85. template, as the dialog's form always holds all of it."""
  86. template = await db.get(LabelTemplate, template_id)
  87. if template is None:
  88. raise HTTPException(status_code=404, detail="Label template not found")
  89. if await _name_taken(db, data.name_key, exclude_id=template_id):
  90. raise HTTPException(status_code=409, detail=DUPLICATE_NAME)
  91. _apply(template, data)
  92. await _commit(db, template)
  93. return _to_response(template)
  94. @router.delete("/{template_id}", status_code=204)
  95. async def delete_label_template(
  96. template_id: int,
  97. db: AsyncSession = Depends(get_db, scope="function"),
  98. _: User | None = RequirePermissionIfAuthEnabled(Permission.INVENTORY_UPDATE),
  99. ):
  100. """Delete a saved template. Labels already printed with it are unaffected."""
  101. template = await db.get(LabelTemplate, template_id)
  102. if template is None:
  103. raise HTTPException(status_code=404, detail="Label template not found")
  104. await db.delete(template)
  105. await db.commit()
  106. return Response(status_code=204)