Coverage for src/typedal/mixins.py: 100%
55 statements
« prev ^ index » next coverage.py v7.5.1, created at 2024-05-08 16:34 +0200
« prev ^ index » next coverage.py v7.5.1, created at 2024-05-08 16:34 +0200
1"""
2This file contains example Mixins.
4Mixins can add reusable fields and behavior (optimally both, otherwise it doesn't add much).
5"""
7import base64
8import os
9import typing
10import warnings
11from datetime import datetime
12from typing import Any, Optional
14from slugify import slugify
16from .core import ( # noqa F401 - used by example in docstring
17 QueryBuilder,
18 T_MetaInstance,
19 TableMeta,
20 TypeDAL,
21 TypedTable,
22 _TypedTable,
23)
24from .fields import DatetimeField, StringField
25from .types import OpRow, Set
28class Mixin(_TypedTable):
29 """
30 A mixin should be derived from this class.
32 The mixin base class itself doesn't do anything,
33 but using it makes sure the mixin fields are placed AFTER the table's normal fields (instead of before)
35 During runtime, mixin should not have a base class in order to prevent MRO issues
36 ('inconsistent method resolution' or 'metaclass conflicts')
37 """
40class TimestampsMixin(Mixin):
41 """
42 A Mixin class for adding timestamp fields to a model.
43 """
45 created_at = DatetimeField(default=datetime.now, writable=False)
46 updated_at = DatetimeField(default=datetime.now, writable=False)
48 @classmethod
49 def __on_define__(cls, db: TypeDAL) -> None:
50 """
51 Hook called when defining the model to initialize timestamps.
53 Args:
54 db (TypeDAL): The database layer.
55 """
56 super().__on_define__(db)
58 def set_updated_at(_: Set, row: OpRow) -> None:
59 """
60 Callback function to update the 'updated_at' field before saving changes.
62 Args:
63 _: Set: Unused parameter.
64 row (OpRow): The row to update.
65 """
66 row["updated_at"] = datetime.now()
68 cls._before_update.append(set_updated_at)
71def slug_random_suffix(length: int = 8) -> str:
72 """
73 Generate a random suffix to make slugs unique, even when titles are the same.
75 UUID4 uses 16 bytes, but 8 is probably more than enough given you probably don't have THAT much duplicate titles.
76 Strip away '=' to make it URL-safe
77 (even though 'urlsafe_b64encode' sounds like it should already be url-safe - it is not)
78 """
79 return base64.urlsafe_b64encode(os.urandom(length)).rstrip(b"=").decode().strip("=")
82class SlugMixin(Mixin):
83 """
84 (Opinionated) example mixin to add a 'slug' field, which depends on a user-provided other field.
86 Some random bytes are added at the end to prevent duplicates.
88 Example:
89 >>> class MyTable(TypedTable, SlugMixin, slug_field="some_name", slug_suffix_length=8):
90 >>> some_name: str
91 >>> ...
92 """
94 # pub:
95 slug = StringField(unique=True, writable=False)
96 # priv:
97 __settings__: typing.TypedDict( # type: ignore
98 "SlugFieldSettings",
99 {
100 "slug_field": str,
101 "slug_suffix": int,
102 },
103 ) # set via init subclass
105 def __init_subclass__(cls, slug_field: str = None, slug_suffix_length: int = 0, **kw: Any) -> None:
106 """
107 Bind 'slug field' option to be used later (on_define).
109 You can control the length of the random suffix with the `slug_suffix_length` option (0 is no suffix).
110 """
111 # unfortunately, PyCharm and mypy do not recognize/autocomplete/typecheck init subclass (keyword) arguments.
112 if slug_field is None:
113 raise ValueError(
114 "SlugMixin requires a valid slug_field setting: "
115 "e.g. `class MyClass(TypedTable, SlugMixin, slug_field='title'): ...`"
116 )
118 if "slug_suffix" in kw:
119 warnings.warn(
120 "The 'slug_suffix' option is deprecated, use 'slug_suffix_length' instead.",
121 DeprecationWarning,
122 )
124 slug_suffix = slug_suffix_length or kw.get("slug_suffix", 0)
126 cls.__settings__ = {
127 "slug_field": slug_field,
128 "slug_suffix": slug_suffix,
129 }
131 @classmethod
132 def __on_define__(cls, db: TypeDAL) -> None:
133 """
134 When db is available, include a before_insert hook to generate and include a slug.
135 """
136 super().__on_define__(db)
138 # slugs should not be editable (for SEO reasons), so there is only a before insert hook:
139 def generate_slug_before_insert(row: OpRow) -> None:
140 settings = cls.__settings__
142 text_input = row[settings["slug_field"]]
143 generated_slug = slugify(text_input)
145 if suffix_len := settings["slug_suffix"]:
146 generated_slug += f"-{slug_random_suffix(suffix_len)}"
148 row["slug"] = slugify(generated_slug)
150 cls._before_insert.append(generate_slug_before_insert)
152 @classmethod
153 def from_slug(cls: typing.Type[T_MetaInstance], slug: str, join: bool = True) -> Optional[T_MetaInstance]:
154 """
155 Find a row by its slug.
156 """
157 builder = cls.where(slug=slug)
158 if join:
159 builder = builder.join()
161 return builder.first()
163 @classmethod
164 def from_slug_or_fail(cls: typing.Type[T_MetaInstance], slug: str, join: bool = True) -> T_MetaInstance:
165 """
166 Find a row by its slug, or raise an error if it doesn't exist.
167 """
168 builder = cls.where(slug=slug)
169 if join:
170 builder = builder.join()
172 return builder.first_or_fail()