"""Shared request/response shapes and value normalisation helpers.""" import re from decimal import Decimal from typing import Generic, TypeVar from pydantic import BaseModel, Field, field_serializer, field_validator T = TypeVar("T") class PageResult(BaseModel, Generic[T]): """One page of a list endpoint.""" items: list[T] total: int page: int page_size: int pages: int = 0 @classmethod def build( cls, *, items: list[T], total: int, page: int, page_size: int, ) -> "PageResult[T]": """Assemble a page and derive ``pages`` from the page size. ``ceil`` is done in integers so an empty result reports 0 pages rather than a misleading 1. """ pages = (total + page_size - 1) // page_size if page_size > 0 else 0 return cls(items=items, total=total, page=page, page_size=page_size, pages=pages) class BatchDeleteRequest(BaseModel): """Body for the batch-delete endpoints.""" ids: list[int] = Field(min_length=1, description="Primary keys to delete") class BatchDeleteResult(BaseModel): """How many rows a batch delete actually removed.""" deleted: int # ``#rgb`` / ``#rrggbb`` / ``rrggbb`` — the ``#`` is optional. _HEX_RE = re.compile(r"^#?([0-9a-fA-F]{3}|[0-9a-fA-F]{6})$") # ``rgb(r, g, b)`` / ``rgba(r, g, b, a)`` — alpha, when present, is discarded: # the stored value is plain RGB. _RGB_RE = re.compile( r"^rgba?\(\s*(\d{1,3})\s*,\s*(\d{1,3})\s*,\s*(\d{1,3})\s*(?:,\s*[\d.]+\s*)?\)$" ) COLOR_ERROR = "颜色格式无效,请使用 #RRGGBB 或 rgb(r,g,b),例如 #FF0000 / rgb(255, 0, 0)" def normalize_hex_color(value: str) -> str: """Normalise any accepted colour spelling to canonical ``#RRGGBB``. The database column is fixed-width ``CHAR(7)``, so exactly one spelling has to survive the write. Accepting the common variants costs little and means neither the colour picker nor a hand-written API call can produce a value the column cannot hold. Raises: ValueError: if the value is not a recognisable colour. """ text = value.strip() rgb_match = _RGB_RE.match(text) if rgb_match: channels = [int(part) for part in rgb_match.groups()] if any(channel > 255 for channel in channels): raise ValueError(COLOR_ERROR) return "#{:02X}{:02X}{:02X}".format(*channels) hex_match = _HEX_RE.match(text) if hex_match: digits = hex_match.group(1) if len(digits) == 3: # #abc -> #AABBCC digits = "".join(char * 2 for char in digits) return "#" + digits.upper() raise ValueError(COLOR_ERROR) class TypographyMixin: """Shared handling of the two typography columns, ``font_size`` / ``font_color``, for every schema that carries them. A plain mixin rather than a :class:`BaseModel` subclass: pydantic v2 collects ``field_validator`` / ``field_serializer`` from non-model bases in the MRO, so a schema picks these up with ``class Foo(TypographyMixin, BaseModel)`` and keeps its own base-model configuration. Two rules, both about making the wire format unsurprising: * **In** — any accepted colour spelling is normalised to ``#RRGGBB``, so a ``CHAR(7)`` column always receives something it can hold. * **Out** — ``font_size`` is emitted as a JSON *number*, not the string pydantic produces for ``Decimal`` by default. A client should not have to parse a font size before putting it in a CSS rule. """ @field_validator("font_color", mode="before", check_fields=False) @classmethod def _normalise_color(cls, value: object) -> object: # Non-strings pass through so an omitted colour stays omitted. The # colour normaliser tolerates a None-valued optional field this way. if isinstance(value, str): return normalize_hex_color(value) return value @field_serializer("font_size", check_fields=False) def _serialize_font_size(self, value: Decimal) -> float: return float(value)