sqlmodel-object-helpers 0.0.4__tar.gz → 0.0.5__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (42) hide show
  1. {sqlmodel_object_helpers-0.0.4 → sqlmodel_object_helpers-0.0.5}/PKG-INFO +57 -4
  2. {sqlmodel_object_helpers-0.0.4 → sqlmodel_object_helpers-0.0.5}/README.md +56 -3
  3. {sqlmodel_object_helpers-0.0.4 → sqlmodel_object_helpers-0.0.5}/src/sqlmodel_object_helpers/__init__.py +14 -1
  4. sqlmodel_object_helpers-0.0.5/src/sqlmodel_object_helpers/types/__init__.py +50 -0
  5. sqlmodel_object_helpers-0.0.5/src/sqlmodel_object_helpers/types/columns.py +79 -0
  6. {sqlmodel_object_helpers-0.0.4 → sqlmodel_object_helpers-0.0.5}/src/sqlmodel_object_helpers/types/filters.py +152 -1
  7. sqlmodel_object_helpers-0.0.5/src/sqlmodel_object_helpers/types/pagination.py +49 -0
  8. sqlmodel_object_helpers-0.0.5/tests/test_column_meta.py +511 -0
  9. sqlmodel_object_helpers-0.0.5/tests/test_datetime_range.py +290 -0
  10. sqlmodel_object_helpers-0.0.4/src/sqlmodel_object_helpers/types/__init__.py +0 -3
  11. sqlmodel_object_helpers-0.0.4/src/sqlmodel_object_helpers/types/pagination.py +0 -15
  12. {sqlmodel_object_helpers-0.0.4 → sqlmodel_object_helpers-0.0.5}/.github/workflows/publish.yml +0 -0
  13. {sqlmodel_object_helpers-0.0.4 → sqlmodel_object_helpers-0.0.5}/.gitignore +0 -0
  14. {sqlmodel_object_helpers-0.0.4 → sqlmodel_object_helpers-0.0.5}/LICENSE +0 -0
  15. {sqlmodel_object_helpers-0.0.4 → sqlmodel_object_helpers-0.0.5}/pyproject.toml +0 -0
  16. {sqlmodel_object_helpers-0.0.4 → sqlmodel_object_helpers-0.0.5}/src/sqlmodel_object_helpers/constants.py +0 -0
  17. {sqlmodel_object_helpers-0.0.4 → sqlmodel_object_helpers-0.0.5}/src/sqlmodel_object_helpers/exceptions.py +0 -0
  18. {sqlmodel_object_helpers-0.0.4 → sqlmodel_object_helpers-0.0.5}/src/sqlmodel_object_helpers/filters.py +0 -0
  19. {sqlmodel_object_helpers-0.0.4 → sqlmodel_object_helpers-0.0.5}/src/sqlmodel_object_helpers/loaders.py +0 -0
  20. {sqlmodel_object_helpers-0.0.4 → sqlmodel_object_helpers-0.0.5}/src/sqlmodel_object_helpers/mutations.py +0 -0
  21. {sqlmodel_object_helpers-0.0.4 → sqlmodel_object_helpers-0.0.5}/src/sqlmodel_object_helpers/operators.py +0 -0
  22. {sqlmodel_object_helpers-0.0.4 → sqlmodel_object_helpers-0.0.5}/src/sqlmodel_object_helpers/query.py +0 -0
  23. {sqlmodel_object_helpers-0.0.4 → sqlmodel_object_helpers-0.0.5}/src/sqlmodel_object_helpers/session.py +0 -0
  24. {sqlmodel_object_helpers-0.0.4 → sqlmodel_object_helpers-0.0.5}/src/sqlmodel_object_helpers/standalone.py +0 -0
  25. {sqlmodel_object_helpers-0.0.4 → sqlmodel_object_helpers-0.0.5}/src/sqlmodel_object_helpers/types/datetime.py +0 -0
  26. {sqlmodel_object_helpers-0.0.4 → sqlmodel_object_helpers-0.0.5}/src/sqlmodel_object_helpers/types/projections.py +0 -0
  27. {sqlmodel_object_helpers-0.0.4 → sqlmodel_object_helpers-0.0.5}/tests/conftest.py +0 -0
  28. {sqlmodel_object_helpers-0.0.4 → sqlmodel_object_helpers-0.0.5}/tests/test_bulk_mutations.py +0 -0
  29. {sqlmodel_object_helpers-0.0.4 → sqlmodel_object_helpers-0.0.5}/tests/test_computed_columns.py +0 -0
  30. {sqlmodel_object_helpers-0.0.4 → sqlmodel_object_helpers-0.0.5}/tests/test_count_exists.py +0 -0
  31. {sqlmodel_object_helpers-0.0.4 → sqlmodel_object_helpers-0.0.5}/tests/test_exceptions.py +0 -0
  32. {sqlmodel_object_helpers-0.0.4 → sqlmodel_object_helpers-0.0.5}/tests/test_filters.py +0 -0
  33. {sqlmodel_object_helpers-0.0.4 → sqlmodel_object_helpers-0.0.5}/tests/test_for_update.py +0 -0
  34. {sqlmodel_object_helpers-0.0.4 → sqlmodel_object_helpers-0.0.5}/tests/test_generated_columns_pg.py +0 -0
  35. {sqlmodel_object_helpers-0.0.4 → sqlmodel_object_helpers-0.0.5}/tests/test_loaders.py +0 -0
  36. {sqlmodel_object_helpers-0.0.4 → sqlmodel_object_helpers-0.0.5}/tests/test_mutations.py +0 -0
  37. {sqlmodel_object_helpers-0.0.4 → sqlmodel_object_helpers-0.0.5}/tests/test_operators.py +0 -0
  38. {sqlmodel_object_helpers-0.0.4 → sqlmodel_object_helpers-0.0.5}/tests/test_query.py +0 -0
  39. {sqlmodel_object_helpers-0.0.4 → sqlmodel_object_helpers-0.0.5}/tests/test_settings.py +0 -0
  40. {sqlmodel_object_helpers-0.0.4 → sqlmodel_object_helpers-0.0.5}/tests/test_standalone.py +0 -0
  41. {sqlmodel_object_helpers-0.0.4 → sqlmodel_object_helpers-0.0.5}/tests/test_time_filter.py +0 -0
  42. {sqlmodel_object_helpers-0.0.4 → sqlmodel_object_helpers-0.0.5}/tests/test_types.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: sqlmodel-object-helpers
3
- Version: 0.0.4
3
+ Version: 0.0.5
4
4
  Summary: Generic async query helpers for SQLModel: filtering, eager loading, pagination
5
5
  Project-URL: Homepage, https://github.com/itstandart/sqlmodel-object-helpers
6
6
  Project-URL: Repository, https://github.com/itstandart/sqlmodel-object-helpers
@@ -539,7 +539,34 @@ class UserFilter(BaseModel):
539
539
  posts: soh.FilterExists | None = None # exists: bool
540
540
  ```
541
541
 
542
- Available: `FilterInt`, `FilterStr`, `FilterDate`, `FilterDatetime`, `FilterTimedelta`, `FilterBool`, `FilterExists`.
542
+ Available: `FilterInt`, `FilterStr`, `FilterDate`, `FilterDatetime`, `FilterNaiveDatetime`, `FilterTimedelta`, `FilterBool`, `FilterExists`.
543
+
544
+ #### Range Filters
545
+
546
+ `FilterDatetimeRange` and `FilterNaiveDatetimeRange` parse a comma-separated date string into `gt`/`lt` operators:
547
+
548
+ ```python
549
+ import sqlmodel_object_helpers as soh
550
+
551
+ # Full range: "FROM,TO" → gt + lt
552
+ f = soh.FilterDatetimeRange.model_validate("2026-04-01,2026-05-06")
553
+ f.model_dump(exclude_none=True)
554
+ # {"gt": datetime(2026, 4, 1, tzinfo=UTC), "lt": datetime(2026, 5, 6, tzinfo=UTC)}
555
+ # SQL: WHERE field > '2026-04-01' AND field < '2026-05-06'
556
+
557
+ # Open end: "FROM," → gt only
558
+ f = soh.FilterDatetimeRange.model_validate("2026-04-01,")
559
+ # SQL: WHERE field > '2026-04-01'
560
+
561
+ # Open start: ",TO" → lt only
562
+ f = soh.FilterDatetimeRange.model_validate(",2026-05-06")
563
+ # SQL: WHERE field < '2026-05-06'
564
+ ```
565
+
566
+ Each date part accepts ISO (`2026-04-01`, `2026-04-01T00:00:00Z`) and display format (`01.04.2026 00:00`).
567
+
568
+ - `FilterDatetimeRange` — produces UTC-aware datetimes
569
+ - `FilterNaiveDatetimeRange` — produces naive (timezone-unaware) datetimes
543
570
 
544
571
  ## Eager Loading
545
572
 
@@ -655,7 +682,8 @@ result = await soh.get_objects(session, User, order_by=order)
655
682
 
656
683
  ### Filter Types
657
684
 
658
- - `soh.FilterInt`, `soh.FilterStr`, `soh.FilterDate`, `soh.FilterDatetime`, `soh.FilterTimedelta`, `soh.FilterBool`, `soh.FilterExists`
685
+ - `soh.FilterInt`, `soh.FilterStr`, `soh.FilterDate`, `soh.FilterDatetime`, `soh.FilterNaiveDatetime`, `soh.FilterTimedelta`, `soh.FilterBool`, `soh.FilterExists`
686
+ - `soh.FilterDatetimeRange`, `soh.FilterNaiveDatetimeRange` -- Range filters that parse `"FROM,TO"` strings into `gt`/`lt` operators
659
687
  - `soh.OrderAsc`, `soh.OrderDesc`, `soh.OrderBy`
660
688
  - `soh.LogicalFilter`
661
689
  - `soh.TimeFilter` -- `created_after`, `created_before`, `updated_after`, `updated_before` with half-open interval `[after, before)`
@@ -668,7 +696,19 @@ result = await soh.get_objects(session, User, order_by=order)
668
696
 
669
697
  - `soh.Pagination` -- Request model (page, per\_page)
670
698
  - `soh.PaginationR` -- Response model (page, per\_page, total)
671
- - `soh.GetAllPagination[T]` -- Generic wrapper (data: list[T], pagination: PaginationR | None)
699
+ - `soh.GetAllPagination[T]` -- Generic wrapper (table: TableMeta | None, columns: list[ColumnMeta] | None, data: list[T], pagination: PaginationR | None)
700
+
701
+ ### Table Metadata Types
702
+
703
+ - `soh.TableMeta` -- Table-level metadata (name, header, row_link)
704
+ - `soh.ColumnMeta` -- Column metadata (json_path, label, type, lookup_dict, lookup_path, bool_labels)
705
+ - `soh.ColumnType` -- StrEnum of column data types (string, integer, float, boolean, date, datetime)
706
+ - `soh.BoolLabels` -- Display labels for boolean columns (true_label, false_label)
707
+
708
+ ### Lookup Types
709
+
710
+ - `soh.LookupMeta` -- Lookup metadata (name used as cache key, matches `ColumnMeta.lookup_dict`)
711
+ - `soh.LookupResponse[T]` -- Generic wrapper for lookup endpoints (meta: LookupMeta, data: list[T])
672
712
 
673
713
  ### Projection Types
674
714
 
@@ -720,6 +760,19 @@ This project follows [Semantic Versioning](https://semver.org/) (MAJOR.MINOR.PAT
720
760
  - **MINOR** — new features (backwards compatible)
721
761
  - **MAJOR** — breaking changes
722
762
 
763
+ ## Changelog
764
+
765
+ ### 0.0.5
766
+
767
+ - **FilterDatetimeRange** / **FilterNaiveDatetimeRange** — range filters that parse comma-separated date strings (`"2026-04-01,2026-05-06"`) into `gt`/`lt` operators for SQL filtering
768
+ - **ColumnMeta** / **TableMeta** / **ColumnType** / **BoolLabels** — dynamic table metadata: backend describes columns, types, labels, lookups, and row navigation so the frontend renders any table without hardcoding
769
+ - **GetAllPagination** — now includes optional `table` and `columns` fields for delivering table metadata alongside paginated data
770
+ - **LookupMeta** / **LookupResponse** — standard `{meta, data}` wrapper for lookup (`_lkp`) endpoints, enabling unified frontend caching of dictionaries with `meta.name` as cache key
771
+
772
+ ### 0.0.4
773
+
774
+ - Initial public release
775
+
723
776
  ## License
724
777
 
725
778
  This project is licensed under the **PolyForm Noncommercial License 1.0.0**.
@@ -505,7 +505,34 @@ class UserFilter(BaseModel):
505
505
  posts: soh.FilterExists | None = None # exists: bool
506
506
  ```
507
507
 
508
- Available: `FilterInt`, `FilterStr`, `FilterDate`, `FilterDatetime`, `FilterTimedelta`, `FilterBool`, `FilterExists`.
508
+ Available: `FilterInt`, `FilterStr`, `FilterDate`, `FilterDatetime`, `FilterNaiveDatetime`, `FilterTimedelta`, `FilterBool`, `FilterExists`.
509
+
510
+ #### Range Filters
511
+
512
+ `FilterDatetimeRange` and `FilterNaiveDatetimeRange` parse a comma-separated date string into `gt`/`lt` operators:
513
+
514
+ ```python
515
+ import sqlmodel_object_helpers as soh
516
+
517
+ # Full range: "FROM,TO" → gt + lt
518
+ f = soh.FilterDatetimeRange.model_validate("2026-04-01,2026-05-06")
519
+ f.model_dump(exclude_none=True)
520
+ # {"gt": datetime(2026, 4, 1, tzinfo=UTC), "lt": datetime(2026, 5, 6, tzinfo=UTC)}
521
+ # SQL: WHERE field > '2026-04-01' AND field < '2026-05-06'
522
+
523
+ # Open end: "FROM," → gt only
524
+ f = soh.FilterDatetimeRange.model_validate("2026-04-01,")
525
+ # SQL: WHERE field > '2026-04-01'
526
+
527
+ # Open start: ",TO" → lt only
528
+ f = soh.FilterDatetimeRange.model_validate(",2026-05-06")
529
+ # SQL: WHERE field < '2026-05-06'
530
+ ```
531
+
532
+ Each date part accepts ISO (`2026-04-01`, `2026-04-01T00:00:00Z`) and display format (`01.04.2026 00:00`).
533
+
534
+ - `FilterDatetimeRange` — produces UTC-aware datetimes
535
+ - `FilterNaiveDatetimeRange` — produces naive (timezone-unaware) datetimes
509
536
 
510
537
  ## Eager Loading
511
538
 
@@ -621,7 +648,8 @@ result = await soh.get_objects(session, User, order_by=order)
621
648
 
622
649
  ### Filter Types
623
650
 
624
- - `soh.FilterInt`, `soh.FilterStr`, `soh.FilterDate`, `soh.FilterDatetime`, `soh.FilterTimedelta`, `soh.FilterBool`, `soh.FilterExists`
651
+ - `soh.FilterInt`, `soh.FilterStr`, `soh.FilterDate`, `soh.FilterDatetime`, `soh.FilterNaiveDatetime`, `soh.FilterTimedelta`, `soh.FilterBool`, `soh.FilterExists`
652
+ - `soh.FilterDatetimeRange`, `soh.FilterNaiveDatetimeRange` -- Range filters that parse `"FROM,TO"` strings into `gt`/`lt` operators
625
653
  - `soh.OrderAsc`, `soh.OrderDesc`, `soh.OrderBy`
626
654
  - `soh.LogicalFilter`
627
655
  - `soh.TimeFilter` -- `created_after`, `created_before`, `updated_after`, `updated_before` with half-open interval `[after, before)`
@@ -634,7 +662,19 @@ result = await soh.get_objects(session, User, order_by=order)
634
662
 
635
663
  - `soh.Pagination` -- Request model (page, per\_page)
636
664
  - `soh.PaginationR` -- Response model (page, per\_page, total)
637
- - `soh.GetAllPagination[T]` -- Generic wrapper (data: list[T], pagination: PaginationR | None)
665
+ - `soh.GetAllPagination[T]` -- Generic wrapper (table: TableMeta | None, columns: list[ColumnMeta] | None, data: list[T], pagination: PaginationR | None)
666
+
667
+ ### Table Metadata Types
668
+
669
+ - `soh.TableMeta` -- Table-level metadata (name, header, row_link)
670
+ - `soh.ColumnMeta` -- Column metadata (json_path, label, type, lookup_dict, lookup_path, bool_labels)
671
+ - `soh.ColumnType` -- StrEnum of column data types (string, integer, float, boolean, date, datetime)
672
+ - `soh.BoolLabels` -- Display labels for boolean columns (true_label, false_label)
673
+
674
+ ### Lookup Types
675
+
676
+ - `soh.LookupMeta` -- Lookup metadata (name used as cache key, matches `ColumnMeta.lookup_dict`)
677
+ - `soh.LookupResponse[T]` -- Generic wrapper for lookup endpoints (meta: LookupMeta, data: list[T])
638
678
 
639
679
  ### Projection Types
640
680
 
@@ -686,6 +726,19 @@ This project follows [Semantic Versioning](https://semver.org/) (MAJOR.MINOR.PAT
686
726
  - **MINOR** — new features (backwards compatible)
687
727
  - **MAJOR** — breaking changes
688
728
 
729
+ ## Changelog
730
+
731
+ ### 0.0.5
732
+
733
+ - **FilterDatetimeRange** / **FilterNaiveDatetimeRange** — range filters that parse comma-separated date strings (`"2026-04-01,2026-05-06"`) into `gt`/`lt` operators for SQL filtering
734
+ - **ColumnMeta** / **TableMeta** / **ColumnType** / **BoolLabels** — dynamic table metadata: backend describes columns, types, labels, lookups, and row navigation so the frontend renders any table without hardcoding
735
+ - **GetAllPagination** — now includes optional `table` and `columns` fields for delivering table metadata alongside paginated data
736
+ - **LookupMeta** / **LookupResponse** — standard `{meta, data}` wrapper for lookup (`_lkp`) endpoints, enabling unified frontend caching of dictionaries with `meta.name` as cache key
737
+
738
+ ### 0.0.4
739
+
740
+ - Initial public release
741
+
689
742
  ## License
690
743
 
691
744
  This project is licensed under the **PolyForm Noncommercial License 1.0.0**.
@@ -1,6 +1,6 @@
1
1
  """sqlmodel-object-helpers — reusable query helpers for SQLModel projects."""
2
2
 
3
- __version__ = "0.0.4"
3
+ __version__ = "0.0.5"
4
4
 
5
5
  from .constants import QueryHelperSettings, settings
6
6
  from .exceptions import (
@@ -30,9 +30,11 @@ from .types.filters import (
30
30
  FilterBool,
31
31
  FilterDate,
32
32
  FilterDatetime,
33
+ FilterDatetimeRange,
33
34
  FilterExists,
34
35
  FilterInt,
35
36
  FilterNaiveDatetime,
37
+ FilterNaiveDatetimeRange,
36
38
  FilterStr,
37
39
  FilterTimedelta,
38
40
  LogicalFilter,
@@ -41,8 +43,11 @@ from .types.filters import (
41
43
  OrderDesc,
42
44
  TimeFilter,
43
45
  )
46
+ from .types.columns import BoolLabels, ColumnMeta, ColumnType, TableMeta
44
47
  from .types.pagination import (
45
48
  GetAllPagination,
49
+ LookupMeta,
50
+ LookupResponse,
46
51
  Pagination,
47
52
  PaginationR,
48
53
  )
@@ -57,7 +62,10 @@ __all__ = [
57
62
  "build_load_chain",
58
63
  "build_load_options",
59
64
  "check_for_related_records",
65
+ "BoolLabels",
66
+ "ColumnMeta",
60
67
  "ColumnSpec",
68
+ "ColumnType",
61
69
  "configure",
62
70
  "count_objects",
63
71
  "create_session_dependency",
@@ -68,9 +76,11 @@ __all__ = [
68
76
  "FilterBool",
69
77
  "FilterDate",
70
78
  "FilterDatetime",
79
+ "FilterDatetimeRange",
71
80
  "FilterExists",
72
81
  "FilterInt",
73
82
  "FilterNaiveDatetime",
83
+ "FilterNaiveDatetimeRange",
74
84
  "FilterStr",
75
85
  "FilterTimedelta",
76
86
  "flatten_filters",
@@ -81,6 +91,8 @@ __all__ = [
81
91
  "InvalidFilterError",
82
92
  "InvalidLoadPathError",
83
93
  "LogicalFilter",
94
+ "LookupMeta",
95
+ "LookupResponse",
84
96
  "MutationError",
85
97
  "ObjectNotFoundError",
86
98
  "Operator",
@@ -94,6 +106,7 @@ __all__ = [
94
106
  "settings",
95
107
  "SPECIAL_OPERATORS",
96
108
  "SUPPORTED_OPERATORS",
109
+ "TableMeta",
97
110
  "TimeFilter",
98
111
  "update_object",
99
112
  "update_objects",
@@ -0,0 +1,50 @@
1
+ from .columns import BoolLabels, ColumnMeta, ColumnType, TableMeta
2
+ from .datetime import UTCDatetime
3
+ from .filters import (
4
+ FilterBool,
5
+ FilterDate,
6
+ FilterDatetime,
7
+ FilterDatetimeRange,
8
+ FilterExists,
9
+ FilterInt,
10
+ FilterNaiveDatetime,
11
+ FilterNaiveDatetimeRange,
12
+ FilterStr,
13
+ FilterTimedelta,
14
+ LogicalFilter,
15
+ OrderAsc,
16
+ OrderBy,
17
+ OrderDesc,
18
+ TimeFilter,
19
+ )
20
+ from .pagination import GetAllPagination, LookupMeta, LookupResponse, Pagination, PaginationR
21
+ from .projections import ColumnSpec
22
+
23
+ __all__ = [
24
+ "BoolLabels",
25
+ "ColumnMeta",
26
+ "ColumnSpec",
27
+ "ColumnType",
28
+ "FilterBool",
29
+ "FilterDate",
30
+ "FilterDatetime",
31
+ "FilterDatetimeRange",
32
+ "FilterExists",
33
+ "FilterInt",
34
+ "FilterNaiveDatetime",
35
+ "FilterNaiveDatetimeRange",
36
+ "FilterStr",
37
+ "FilterTimedelta",
38
+ "GetAllPagination",
39
+ "LogicalFilter",
40
+ "LookupMeta",
41
+ "LookupResponse",
42
+ "OrderAsc",
43
+ "OrderBy",
44
+ "OrderDesc",
45
+ "Pagination",
46
+ "PaginationR",
47
+ "TableMeta",
48
+ "TimeFilter",
49
+ "UTCDatetime",
50
+ ]
@@ -0,0 +1,79 @@
1
+ """Column and table metadata for dynamic frontend table rendering."""
2
+
3
+ from enum import StrEnum
4
+
5
+ from pydantic import BaseModel, ConfigDict
6
+
7
+
8
+ class ColumnType(StrEnum):
9
+ """Data type of a table column — determines how the frontend renders and filters it."""
10
+
11
+ STRING = "string"
12
+ INTEGER = "integer"
13
+ FLOAT = "float"
14
+ BOOLEAN = "boolean"
15
+ DATE = "date"
16
+ DATETIME = "datetime"
17
+
18
+
19
+ class BoolLabels(BaseModel):
20
+ """Display labels for boolean columns.
21
+
22
+ Source: PostgreSQL domain comments, e.g.
23
+ ``COMMENT ON DOMAIN is_paid_domain IS 'Оплачено|Не оплачено'``
24
+ """
25
+
26
+ model_config = ConfigDict(extra="forbid")
27
+
28
+ true_label: str
29
+ false_label: str
30
+
31
+
32
+ class TableMeta(BaseModel):
33
+ """Table-level metadata.
34
+
35
+ Attributes:
36
+ name: Stable identifier used as a key for saving user settings
37
+ (e.g. ``"attempts"``).
38
+ header: Human-readable table title for the UI
39
+ (e.g. ``"Реестр попыток"``).
40
+ row_link: Optional URL template that makes each row clickable.
41
+ Uses ``$id`` as placeholder for the row identifier
42
+ (e.g. ``"/lead/$id"``, ``"/mail-template-lead/$id/edit"``).
43
+ """
44
+
45
+ model_config = ConfigDict(extra="forbid")
46
+
47
+ name: str
48
+ header: str
49
+ row_link: str | None = None
50
+
51
+
52
+ class ColumnMeta(BaseModel):
53
+ """Metadata for a single table column sent to the frontend.
54
+
55
+ Attributes:
56
+ json_path: Dot-notation path to the value inside each ``data`` item
57
+ (e.g. ``"application.applicant.full_name"``). Also serves as
58
+ the column identifier.
59
+ label: Human-readable column header (Russian name for the UI).
60
+ type: Data type — drives the filter widget and cell renderer on the
61
+ frontend.
62
+ lookup_dict: Optional name of the LKP dictionary the frontend
63
+ already has cached (e.g. ``"statuses"``). When present the
64
+ frontend renders a ``<select>`` filter instead of free text.
65
+ lookup_path: Optional dot-notation path to the ID field used for
66
+ filtering when ``lookup_dict`` is set. The frontend displays
67
+ ``json_path`` but sends ``lookup_path`` in filter requests.
68
+ bool_labels: Optional display labels for boolean values
69
+ (e.g. ``"Оплачено"`` / ``"Не оплачено"``).
70
+ """
71
+
72
+ model_config = ConfigDict(extra="forbid")
73
+
74
+ json_path: str
75
+ label: str
76
+ type: ColumnType = ColumnType.STRING
77
+ lookup_dict: str | None = None
78
+ lookup_path: str | None = None
79
+ bool_labels: BoolLabels | None = None
@@ -1,4 +1,4 @@
1
- from datetime import date, datetime, timedelta
1
+ from datetime import date, datetime, timedelta, timezone
2
2
  from typing import Any, Self
3
3
 
4
4
  from pydantic import BaseModel, ConfigDict, field_validator, model_validator
@@ -7,6 +7,38 @@ from ..constants import settings
7
7
  from .datetime import UTCDatetime
8
8
 
9
9
 
10
+ def _parse_range_part(s: str) -> datetime:
11
+ """Parse a single date/datetime from a range string part.
12
+
13
+ Accepts ISO formats (``2026-04-01``, ``2026-04-01T00:00:00``,
14
+ ``2026-04-01T00:00:00.000Z``) and display format (``01.04.2026 00:00``).
15
+ """
16
+ s = s.strip()
17
+ if not s:
18
+ msg = "Empty date string"
19
+ raise ValueError(msg)
20
+
21
+ # ISO formats: 2026-04-01, 2026-04-01T00:00:00, 2026-04-01T00:00:00.000Z, +03:00 etc.
22
+ try:
23
+ normalized = s.removesuffix("Z") + "+00:00" if s.endswith("Z") else s
24
+ return datetime.fromisoformat(normalized)
25
+ except ValueError:
26
+ pass
27
+
28
+ # Display formats: DD.MM.YYYY, DD.MM.YYYY HH:MM, DD.MM.YYYY HH:MM:SS
29
+ for fmt in ("%d.%m.%Y %H:%M", "%d.%m.%Y %H:%M:%S", "%d.%m.%Y"):
30
+ try:
31
+ return datetime.strptime(s, fmt) # noqa: DTZ007
32
+ except ValueError:
33
+ continue
34
+
35
+ msg = (
36
+ f"Invalid date format: {s!r}. "
37
+ "Expected YYYY-MM-DD, ISO datetime, or DD.MM.YYYY HH:MM"
38
+ )
39
+ raise ValueError(msg)
40
+
41
+
10
42
  class FilterInt(BaseModel):
11
43
  eq: int | None = None
12
44
  ne: int | None = None
@@ -97,6 +129,125 @@ class FilterTimedelta(BaseModel):
97
129
  le: timedelta | None = None
98
130
 
99
131
 
132
+ def _split_range(range_str: str) -> tuple[str, str]:
133
+ """Split a range string by comma into (left, right) parts.
134
+
135
+ Returns stripped parts; either side may be empty for open-ended ranges.
136
+ """
137
+ if "," not in range_str:
138
+ msg = f"Invalid range format: {range_str!r}. Expected 'FROM,TO', 'FROM,', or ',TO'"
139
+ raise ValueError(msg)
140
+ left, right = range_str.split(",", maxsplit=1)
141
+ return left.strip(), right.strip()
142
+
143
+
144
+ def _make_utc(dt: datetime) -> datetime:
145
+ """Ensure a datetime is UTC-aware. Naive datetimes are assumed UTC."""
146
+ if dt.tzinfo is None:
147
+ return dt.replace(tzinfo=timezone.utc)
148
+ return dt.astimezone(timezone.utc)
149
+
150
+
151
+ class FilterDatetimeRange(BaseModel):
152
+ """
153
+ Range filter for datetime fields, parsed from a comma-separated string.
154
+
155
+ Supported string formats (separator is ``,``)::
156
+
157
+ "2026-04-01,2026-04-30" — full range (gt + lt)
158
+ "2026-04-01," — from date (gt only, open end)
159
+ ",2026-04-30" — to date (lt only, open start)
160
+
161
+ Each date part accepts ISO (``2026-04-01``, ``2026-04-01T00:00:00Z``)
162
+ and display format (``01.04.2026 00:00``).
163
+
164
+ After validation the model contains UTC-aware ``gt`` and/or ``lt`` fields
165
+ that map directly to SQLAlchemy operators via ``model_dump(exclude_none=True)``.
166
+ """
167
+
168
+ gt: UTCDatetime | None = None
169
+ lt: UTCDatetime | None = None
170
+
171
+ @model_validator(mode="before")
172
+ @classmethod
173
+ def parse_range_string(cls, data: Any) -> Any:
174
+ if isinstance(data, str):
175
+ return cls._parse_range(data)
176
+ return data
177
+
178
+ @staticmethod
179
+ def _parse_range(range_str: str) -> dict[str, datetime]:
180
+ range_str = range_str.strip()
181
+ if not range_str:
182
+ msg = "Range string must not be empty"
183
+ raise ValueError(msg)
184
+
185
+ left, right = _split_range(range_str)
186
+ result: dict[str, datetime] = {}
187
+
188
+ if left:
189
+ result["gt"] = _make_utc(_parse_range_part(left))
190
+ if right:
191
+ result["lt"] = _make_utc(_parse_range_part(right))
192
+
193
+ return result
194
+
195
+ @model_validator(mode="after")
196
+ def validate_range(self) -> Self:
197
+ if self.gt is None and self.lt is None:
198
+ msg = "At least one of gt or lt must be set"
199
+ raise ValueError(msg)
200
+ if self.gt is not None and self.lt is not None and self.gt > self.lt:
201
+ msg = "Range start (gt) must not be after range end (lt)"
202
+ raise ValueError(msg)
203
+ return self
204
+
205
+
206
+ class FilterNaiveDatetimeRange(BaseModel):
207
+ """
208
+ Same as :class:`FilterDatetimeRange` but produces **naive** (timezone-unaware) datetimes.
209
+
210
+ If the input contains timezone info, it is stripped (replaced with None).
211
+ """
212
+
213
+ gt: datetime | None = None
214
+ lt: datetime | None = None
215
+
216
+ @model_validator(mode="before")
217
+ @classmethod
218
+ def parse_range_string(cls, data: Any) -> Any:
219
+ if isinstance(data, str):
220
+ return cls._parse_range(data)
221
+ return data
222
+
223
+ @staticmethod
224
+ def _parse_range(range_str: str) -> dict[str, datetime]:
225
+ range_str = range_str.strip()
226
+ if not range_str:
227
+ msg = "Range string must not be empty"
228
+ raise ValueError(msg)
229
+
230
+ left, right = _split_range(range_str)
231
+ result: dict[str, datetime] = {}
232
+
233
+ if left:
234
+ result["gt"] = _parse_range_part(left).replace(tzinfo=None)
235
+ if right:
236
+ result["lt"] = _parse_range_part(right).replace(tzinfo=None)
237
+
238
+ return result
239
+
240
+ @model_validator(mode="after")
241
+ def validate_range(self) -> Self:
242
+ if self.gt is None and self.lt is None:
243
+ msg = "At least one of gt or lt must be set"
244
+ raise ValueError(msg)
245
+ if self.gt is not None and self.lt is not None and self.gt > self.lt:
246
+ msg = "Range start (gt) must not be after range end (lt)"
247
+ raise ValueError(msg)
248
+ return self
249
+
250
+
100
251
  class FilterBool(BaseModel):
101
252
  eq: bool | None = None
102
253
  ne: bool | None = None
@@ -0,0 +1,49 @@
1
+ from pydantic import BaseModel, ConfigDict, Field
2
+
3
+ from .columns import ColumnMeta, TableMeta
4
+
5
+
6
+ class Pagination(BaseModel):
7
+ page: int | None = Field(default=1, ge=1)
8
+ per_page: int | None = Field(default=10, ge=1)
9
+
10
+
11
+ class PaginationR(Pagination):
12
+ total: int
13
+
14
+
15
+ class GetAllPagination[T](BaseModel):
16
+ table: TableMeta | None = None
17
+ columns: list[ColumnMeta] | None = None
18
+ data: list[T]
19
+ pagination: PaginationR | None = None
20
+
21
+
22
+ class LookupMeta(BaseModel):
23
+ """Metadata for a lookup dictionary response.
24
+
25
+ Attributes:
26
+ name: Stable identifier used as the cache key on the frontend
27
+ (e.g. ``"lead_email_types"``). Must match
28
+ ``ColumnMeta.lookup_dict`` values.
29
+ """
30
+
31
+ model_config = ConfigDict(extra="forbid")
32
+
33
+ name: str
34
+
35
+
36
+ class LookupResponse[T](BaseModel):
37
+ """Standard response wrapper for lookup (``_lkp``) endpoints.
38
+
39
+ Mirrors the ``{meta, data}`` pattern the frontend uses for caching::
40
+
41
+ store.lookups["lead_email_types"] = {meta: {name: "lead_email_types"}, data: [...]}
42
+
43
+ Attributes:
44
+ meta: Lookup metadata (name used as cache key).
45
+ data: List of lookup records.
46
+ """
47
+
48
+ meta: LookupMeta
49
+ data: list[T]