label_template.py 3.0 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899
  1. """Sheet layouts for custom label sheets, and the saved templates that hold
  2. them (#3296)."""
  3. import unicodedata
  4. from datetime import datetime
  5. from pydantic import BaseModel, ConfigDict, Field, field_validator, model_validator
  6. from backend.app.services.label_renderer import (
  7. MAX_COLS,
  8. MAX_LENGTH_MM,
  9. MAX_ROWS,
  10. MIN_LABEL_H_MM,
  11. MIN_LABEL_W_MM,
  12. PageSize,
  13. SheetLayout,
  14. )
  15. # Each character can casefold to at most three ("ffi" -> "ffi"), so a 100-character
  16. # name always has a key that fits the 300-character column.
  17. NAME_MAX_LENGTH = 100
  18. NAME_KEY_MAX_LENGTH = 300
  19. def label_template_name_key(name: str) -> str:
  20. """What makes two names the same template: case and Unicode composition
  21. ignored, so "Café" typed with a combining accent matches the composed one."""
  22. return unicodedata.normalize("NFC", name).casefold()
  23. class SheetLayoutSpec(BaseModel):
  24. """A sheet layout as a request carries it. Lengths are in millimetres."""
  25. model_config = ConfigDict(allow_inf_nan=False)
  26. page: PageSize
  27. label_w_mm: float = Field(ge=MIN_LABEL_W_MM, le=MAX_LENGTH_MM)
  28. label_h_mm: float = Field(ge=MIN_LABEL_H_MM, le=MAX_LENGTH_MM)
  29. cols: int = Field(ge=1, le=MAX_COLS)
  30. rows: int = Field(ge=1, le=MAX_ROWS)
  31. top_mm: float = Field(ge=0, le=MAX_LENGTH_MM)
  32. left_mm: float = Field(ge=0, le=MAX_LENGTH_MM)
  33. col_gap_mm: float = Field(default=0.0, ge=0, le=MAX_LENGTH_MM)
  34. row_gap_mm: float = Field(default=0.0, ge=0, le=MAX_LENGTH_MM)
  35. @model_validator(mode="after")
  36. def fits_on_page(self) -> "SheetLayoutSpec":
  37. problem = self.to_layout().problem()
  38. if problem:
  39. raise ValueError(problem)
  40. return self
  41. def to_layout(self) -> SheetLayout:
  42. return SheetLayout(**self.model_dump())
  43. class SheetLayoutResponse(BaseModel):
  44. """A stored layout, returned as saved. Not re-validated, so a template saved
  45. under looser limits still lists rather than failing the whole response."""
  46. page: str
  47. label_w_mm: float
  48. label_h_mm: float
  49. cols: int
  50. rows: int
  51. top_mm: float
  52. left_mm: float
  53. col_gap_mm: float
  54. row_gap_mm: float
  55. class LabelTemplateIn(BaseModel):
  56. """Create or replace a saved template: a name and the whole layout."""
  57. name: str = Field(..., min_length=1, max_length=NAME_MAX_LENGTH)
  58. layout: SheetLayoutSpec
  59. @field_validator("name")
  60. @classmethod
  61. def normalize_name(cls, v: str) -> str:
  62. v = unicodedata.normalize("NFC", v.strip())
  63. if not v:
  64. raise ValueError("name must not be empty")
  65. # NFC can (rarely) lengthen a name, so both columns are checked again.
  66. if len(v) > NAME_MAX_LENGTH or len(label_template_name_key(v)) > NAME_KEY_MAX_LENGTH:
  67. raise ValueError("name is too long")
  68. return v
  69. @property
  70. def name_key(self) -> str:
  71. return label_template_name_key(self.name)
  72. class LabelTemplateResponse(BaseModel):
  73. id: int
  74. name: str
  75. layout: SheetLayoutResponse
  76. created_at: datetime
  77. updated_at: datetime