clear-skies 2.0.5__py3-none-any.whl → 2.0.6__py3-none-any.whl
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.
Potentially problematic release.
This version of clear-skies might be problematic. Click here for more details.
- {clear_skies-2.0.5.dist-info → clear_skies-2.0.6.dist-info}/METADATA +1 -1
- clear_skies-2.0.6.dist-info/RECORD +251 -0
- clearskies/__init__.py +61 -0
- clearskies/action.py +7 -0
- clearskies/authentication/__init__.py +15 -0
- clearskies/authentication/authentication.py +46 -0
- clearskies/authentication/authorization.py +16 -0
- clearskies/authentication/authorization_pass_through.py +20 -0
- clearskies/authentication/jwks.py +163 -0
- clearskies/authentication/public.py +5 -0
- clearskies/authentication/secret_bearer.py +553 -0
- clearskies/autodoc/__init__.py +8 -0
- clearskies/autodoc/formats/__init__.py +5 -0
- clearskies/autodoc/formats/oai3_json/__init__.py +7 -0
- clearskies/autodoc/formats/oai3_json/oai3_json.py +87 -0
- clearskies/autodoc/formats/oai3_json/oai3_schema_resolver.py +15 -0
- clearskies/autodoc/formats/oai3_json/parameter.py +35 -0
- clearskies/autodoc/formats/oai3_json/request.py +68 -0
- clearskies/autodoc/formats/oai3_json/response.py +28 -0
- clearskies/autodoc/formats/oai3_json/schema/__init__.py +11 -0
- clearskies/autodoc/formats/oai3_json/schema/array.py +9 -0
- clearskies/autodoc/formats/oai3_json/schema/default.py +13 -0
- clearskies/autodoc/formats/oai3_json/schema/enum.py +7 -0
- clearskies/autodoc/formats/oai3_json/schema/object.py +35 -0
- clearskies/autodoc/formats/oai3_json/test.json +1985 -0
- clearskies/autodoc/py.typed +0 -0
- clearskies/autodoc/request/__init__.py +15 -0
- clearskies/autodoc/request/header.py +6 -0
- clearskies/autodoc/request/json_body.py +6 -0
- clearskies/autodoc/request/parameter.py +8 -0
- clearskies/autodoc/request/request.py +47 -0
- clearskies/autodoc/request/url_parameter.py +6 -0
- clearskies/autodoc/request/url_path.py +6 -0
- clearskies/autodoc/response/__init__.py +5 -0
- clearskies/autodoc/response/response.py +9 -0
- clearskies/autodoc/schema/__init__.py +31 -0
- clearskies/autodoc/schema/array.py +10 -0
- clearskies/autodoc/schema/base64.py +8 -0
- clearskies/autodoc/schema/boolean.py +5 -0
- clearskies/autodoc/schema/date.py +5 -0
- clearskies/autodoc/schema/datetime.py +5 -0
- clearskies/autodoc/schema/double.py +5 -0
- clearskies/autodoc/schema/enum.py +17 -0
- clearskies/autodoc/schema/integer.py +6 -0
- clearskies/autodoc/schema/long.py +5 -0
- clearskies/autodoc/schema/number.py +6 -0
- clearskies/autodoc/schema/object.py +13 -0
- clearskies/autodoc/schema/password.py +5 -0
- clearskies/autodoc/schema/schema.py +11 -0
- clearskies/autodoc/schema/string.py +5 -0
- clearskies/backends/__init__.py +65 -0
- clearskies/backends/api_backend.py +1178 -0
- clearskies/backends/backend.py +136 -0
- clearskies/backends/cursor_backend.py +335 -0
- clearskies/backends/memory_backend.py +797 -0
- clearskies/backends/secrets_backend.py +106 -0
- clearskies/column.py +1233 -0
- clearskies/columns/__init__.py +71 -0
- clearskies/columns/audit.py +206 -0
- clearskies/columns/belongs_to_id.py +483 -0
- clearskies/columns/belongs_to_model.py +132 -0
- clearskies/columns/belongs_to_self.py +105 -0
- clearskies/columns/boolean.py +113 -0
- clearskies/columns/category_tree.py +275 -0
- clearskies/columns/category_tree_ancestors.py +51 -0
- clearskies/columns/category_tree_children.py +127 -0
- clearskies/columns/category_tree_descendants.py +48 -0
- clearskies/columns/created.py +95 -0
- clearskies/columns/created_by_authorization_data.py +116 -0
- clearskies/columns/created_by_header.py +99 -0
- clearskies/columns/created_by_ip.py +92 -0
- clearskies/columns/created_by_routing_data.py +97 -0
- clearskies/columns/created_by_user_agent.py +92 -0
- clearskies/columns/date.py +234 -0
- clearskies/columns/datetime.py +282 -0
- clearskies/columns/email.py +76 -0
- clearskies/columns/float.py +153 -0
- clearskies/columns/has_many.py +505 -0
- clearskies/columns/has_many_self.py +56 -0
- clearskies/columns/has_one.py +14 -0
- clearskies/columns/integer.py +160 -0
- clearskies/columns/json.py +128 -0
- clearskies/columns/many_to_many_ids.py +337 -0
- clearskies/columns/many_to_many_ids_with_data.py +274 -0
- clearskies/columns/many_to_many_models.py +158 -0
- clearskies/columns/many_to_many_pivots.py +134 -0
- clearskies/columns/phone.py +159 -0
- clearskies/columns/select.py +92 -0
- clearskies/columns/string.py +102 -0
- clearskies/columns/timestamp.py +164 -0
- clearskies/columns/updated.py +110 -0
- clearskies/columns/uuid.py +86 -0
- clearskies/configs/README.md +105 -0
- clearskies/configs/__init__.py +162 -0
- clearskies/configs/actions.py +43 -0
- clearskies/configs/any.py +13 -0
- clearskies/configs/any_dict.py +22 -0
- clearskies/configs/any_dict_or_callable.py +23 -0
- clearskies/configs/authentication.py +23 -0
- clearskies/configs/authorization.py +23 -0
- clearskies/configs/boolean.py +16 -0
- clearskies/configs/boolean_or_callable.py +18 -0
- clearskies/configs/callable_config.py +18 -0
- clearskies/configs/columns.py +34 -0
- clearskies/configs/conditions.py +30 -0
- clearskies/configs/config.py +24 -0
- clearskies/configs/datetime.py +18 -0
- clearskies/configs/datetime_or_callable.py +19 -0
- clearskies/configs/endpoint.py +23 -0
- clearskies/configs/endpoint_list.py +29 -0
- clearskies/configs/float.py +16 -0
- clearskies/configs/float_or_callable.py +18 -0
- clearskies/configs/integer.py +16 -0
- clearskies/configs/integer_or_callable.py +18 -0
- clearskies/configs/joins.py +30 -0
- clearskies/configs/list_any_dict.py +30 -0
- clearskies/configs/list_any_dict_or_callable.py +31 -0
- clearskies/configs/model_class.py +35 -0
- clearskies/configs/model_column.py +65 -0
- clearskies/configs/model_columns.py +56 -0
- clearskies/configs/model_destination_name.py +25 -0
- clearskies/configs/model_to_id_column.py +43 -0
- clearskies/configs/readable_model_column.py +9 -0
- clearskies/configs/readable_model_columns.py +9 -0
- clearskies/configs/schema.py +23 -0
- clearskies/configs/searchable_model_columns.py +9 -0
- clearskies/configs/security_headers.py +39 -0
- clearskies/configs/select.py +26 -0
- clearskies/configs/select_list.py +47 -0
- clearskies/configs/string.py +29 -0
- clearskies/configs/string_dict.py +32 -0
- clearskies/configs/string_list.py +32 -0
- clearskies/configs/string_list_or_callable.py +35 -0
- clearskies/configs/string_or_callable.py +18 -0
- clearskies/configs/timedelta.py +18 -0
- clearskies/configs/timezone.py +18 -0
- clearskies/configs/url.py +23 -0
- clearskies/configs/validators.py +45 -0
- clearskies/configs/writeable_model_column.py +9 -0
- clearskies/configs/writeable_model_columns.py +9 -0
- clearskies/configurable.py +76 -0
- clearskies/contexts/__init__.py +11 -0
- clearskies/contexts/cli.py +117 -0
- clearskies/contexts/context.py +98 -0
- clearskies/contexts/wsgi.py +76 -0
- clearskies/contexts/wsgi_ref.py +82 -0
- clearskies/decorators.py +33 -0
- clearskies/di/__init__.py +14 -0
- clearskies/di/additional_config.py +130 -0
- clearskies/di/additional_config_auto_import.py +17 -0
- clearskies/di/di.py +973 -0
- clearskies/di/inject/__init__.py +23 -0
- clearskies/di/inject/by_class.py +21 -0
- clearskies/di/inject/by_name.py +18 -0
- clearskies/di/inject/di.py +13 -0
- clearskies/di/inject/environment.py +14 -0
- clearskies/di/inject/input_output.py +20 -0
- clearskies/di/inject/now.py +13 -0
- clearskies/di/inject/requests.py +13 -0
- clearskies/di/inject/secrets.py +14 -0
- clearskies/di/inject/utcnow.py +13 -0
- clearskies/di/inject/uuid.py +15 -0
- clearskies/di/injectable.py +29 -0
- clearskies/di/injectable_properties.py +131 -0
- clearskies/di/test_module/__init__.py +6 -0
- clearskies/di/test_module/another_module/__init__.py +2 -0
- clearskies/di/test_module/module_class.py +5 -0
- clearskies/end.py +183 -0
- clearskies/endpoint.py +1314 -0
- clearskies/endpoint_group.py +336 -0
- clearskies/endpoints/__init__.py +25 -0
- clearskies/endpoints/advanced_search.py +526 -0
- clearskies/endpoints/callable.py +388 -0
- clearskies/endpoints/create.py +205 -0
- clearskies/endpoints/delete.py +139 -0
- clearskies/endpoints/get.py +271 -0
- clearskies/endpoints/health_check.py +183 -0
- clearskies/endpoints/list.py +574 -0
- clearskies/endpoints/restful_api.py +427 -0
- clearskies/endpoints/schema.py +189 -0
- clearskies/endpoints/simple_search.py +286 -0
- clearskies/endpoints/update.py +193 -0
- clearskies/environment.py +104 -0
- clearskies/exceptions/__init__.py +19 -0
- clearskies/exceptions/authentication.py +2 -0
- clearskies/exceptions/authorization.py +2 -0
- clearskies/exceptions/client_error.py +2 -0
- clearskies/exceptions/input_errors.py +4 -0
- clearskies/exceptions/missing_dependency.py +2 -0
- clearskies/exceptions/moved_permanently.py +3 -0
- clearskies/exceptions/moved_temporarily.py +3 -0
- clearskies/exceptions/not_found.py +2 -0
- clearskies/functional/__init__.py +7 -0
- clearskies/functional/routing.py +92 -0
- clearskies/functional/string.py +112 -0
- clearskies/functional/validations.py +76 -0
- clearskies/input_outputs/__init__.py +13 -0
- clearskies/input_outputs/cli.py +171 -0
- clearskies/input_outputs/exceptions/__init__.py +2 -0
- clearskies/input_outputs/exceptions/cli_input_error.py +2 -0
- clearskies/input_outputs/exceptions/cli_not_found.py +2 -0
- clearskies/input_outputs/headers.py +45 -0
- clearskies/input_outputs/input_output.py +138 -0
- clearskies/input_outputs/programmatic.py +69 -0
- clearskies/input_outputs/py.typed +0 -0
- clearskies/input_outputs/wsgi.py +77 -0
- clearskies/model.py +1922 -0
- clearskies/py.typed +0 -0
- clearskies/query/__init__.py +12 -0
- clearskies/query/condition.py +223 -0
- clearskies/query/join.py +136 -0
- clearskies/query/query.py +196 -0
- clearskies/query/sort.py +27 -0
- clearskies/schema.py +82 -0
- clearskies/secrets/__init__.py +6 -0
- clearskies/secrets/additional_configs/__init__.py +32 -0
- clearskies/secrets/additional_configs/mysql_connection_dynamic_producer.py +61 -0
- clearskies/secrets/additional_configs/mysql_connection_dynamic_producer_via_ssh_cert_bastion.py +160 -0
- clearskies/secrets/akeyless.py +182 -0
- clearskies/secrets/exceptions/__init__.py +1 -0
- clearskies/secrets/exceptions/not_found.py +2 -0
- clearskies/secrets/secrets.py +38 -0
- clearskies/security_header.py +15 -0
- clearskies/security_headers/__init__.py +11 -0
- clearskies/security_headers/cache_control.py +67 -0
- clearskies/security_headers/cors.py +50 -0
- clearskies/security_headers/csp.py +94 -0
- clearskies/security_headers/hsts.py +22 -0
- clearskies/security_headers/x_content_type_options.py +0 -0
- clearskies/security_headers/x_frame_options.py +0 -0
- clearskies/test_base.py +8 -0
- clearskies/typing.py +11 -0
- clearskies/validator.py +37 -0
- clearskies/validators/__init__.py +33 -0
- clearskies/validators/after_column.py +62 -0
- clearskies/validators/before_column.py +13 -0
- clearskies/validators/in_the_future.py +32 -0
- clearskies/validators/in_the_future_at_least.py +11 -0
- clearskies/validators/in_the_future_at_most.py +10 -0
- clearskies/validators/in_the_past.py +32 -0
- clearskies/validators/in_the_past_at_least.py +10 -0
- clearskies/validators/in_the_past_at_most.py +10 -0
- clearskies/validators/maximum_length.py +26 -0
- clearskies/validators/maximum_value.py +29 -0
- clearskies/validators/minimum_length.py +26 -0
- clearskies/validators/minimum_value.py +29 -0
- clearskies/validators/required.py +34 -0
- clearskies/validators/timedelta.py +59 -0
- clearskies/validators/unique.py +30 -0
- clear_skies-2.0.5.dist-info/RECORD +0 -4
- {clear_skies-2.0.5.dist-info → clear_skies-2.0.6.dist-info}/WHEEL +0 -0
- {clear_skies-2.0.5.dist-info → clear_skies-2.0.6.dist-info}/licenses/LICENSE +0 -0
|
@@ -0,0 +1,274 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from typing import TYPE_CHECKING, Any, Callable, Self, overload
|
|
4
|
+
|
|
5
|
+
import clearskies.decorators
|
|
6
|
+
import clearskies.typing
|
|
7
|
+
from clearskies import configs
|
|
8
|
+
from clearskies.columns.many_to_many_ids import ManyToManyIds
|
|
9
|
+
|
|
10
|
+
if TYPE_CHECKING:
|
|
11
|
+
from clearskies import Model
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
class ManyToManyIdsWithData(ManyToManyIds):
|
|
15
|
+
"""
|
|
16
|
+
A column to represent a many-to-many relationship with information stored in the relationship itself.
|
|
17
|
+
|
|
18
|
+
This is an extention of the many-to-many column, but with one important addition: data about the
|
|
19
|
+
relationship is stored in the pivot table. This creates some differences, which are best
|
|
20
|
+
explained by example:
|
|
21
|
+
|
|
22
|
+
```python
|
|
23
|
+
import clearskies
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
class ThingyWidgets(clearskies.Model):
|
|
27
|
+
id_column_name = "id"
|
|
28
|
+
backend = clearskies.backends.MemoryBackend()
|
|
29
|
+
|
|
30
|
+
id = clearskies.columns.Uuid()
|
|
31
|
+
# these could also be belongs to relationships, but the pivot model
|
|
32
|
+
# is rarely used directly, so I'm being lazy to avoid having to use
|
|
33
|
+
# model references.
|
|
34
|
+
thingy_id = clearskies.columns.String()
|
|
35
|
+
widget_id = clearskies.columns.String()
|
|
36
|
+
name = clearskies.columns.String()
|
|
37
|
+
kind = clearskies.columns.String()
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
class Thingy(clearskies.Model):
|
|
41
|
+
id_column_name = "id"
|
|
42
|
+
backend = clearskies.backends.MemoryBackend()
|
|
43
|
+
|
|
44
|
+
id = clearskies.columns.Uuid()
|
|
45
|
+
name = clearskies.columns.String()
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
class Widget(clearskies.Model):
|
|
49
|
+
id_column_name = "id"
|
|
50
|
+
backend = clearskies.backends.MemoryBackend()
|
|
51
|
+
|
|
52
|
+
id = clearskies.columns.Uuid()
|
|
53
|
+
name = clearskies.columns.String()
|
|
54
|
+
thingy_ids = clearskies.columns.ManyToManyIdsWithData(
|
|
55
|
+
related_model_class=Thingy,
|
|
56
|
+
pivot_model_class=ThingyWidgets,
|
|
57
|
+
readable_pivot_column_names=["id", "thingy_id", "widget_id", "name", "kind"],
|
|
58
|
+
)
|
|
59
|
+
thingies = clearskies.columns.ManyToManyModels("thingy_ids")
|
|
60
|
+
thingy_widgets = clearskies.columns.ManyToManyPivots("thingy_ids")
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
def my_application(widgets: Widget, thingies: Thingy):
|
|
64
|
+
thing_1 = thingies.create({"name": "Thing 1"})
|
|
65
|
+
thing_2 = thingies.create({"name": "Thing 2"})
|
|
66
|
+
thing_3 = thingies.create({"name": "Thing 3"})
|
|
67
|
+
widget = widgets.create({
|
|
68
|
+
"name": "Widget 1",
|
|
69
|
+
"thingy_ids": [
|
|
70
|
+
{"thingy_id": thing_1.id, "name": "Widget Thing 1", "kind": "Special"},
|
|
71
|
+
{"thingy_id": thing_2.id, "name": "Widget Thing 2", "kind": "Also Special"},
|
|
72
|
+
],
|
|
73
|
+
})
|
|
74
|
+
|
|
75
|
+
return widget
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
cli = clearskies.contexts.Cli(
|
|
79
|
+
clearskies.endpoints.Callable(
|
|
80
|
+
my_application,
|
|
81
|
+
model_class=Widget,
|
|
82
|
+
return_records=True,
|
|
83
|
+
readable_column_names=["id", "name", "thingy_widgets"],
|
|
84
|
+
),
|
|
85
|
+
classes=[Widget, Thingy, ThingyWidgets],
|
|
86
|
+
)
|
|
87
|
+
|
|
88
|
+
if __name__ == "__main__":
|
|
89
|
+
cli()
|
|
90
|
+
```
|
|
91
|
+
|
|
92
|
+
As with setting ids in the ManyToManyIds class, any items left out will result in the relationship
|
|
93
|
+
(including all its related data) being removed. An important difference with the ManyToManyWithData
|
|
94
|
+
column is the way you specify which record is being connected. This is easy for the ManyToManyIds column
|
|
95
|
+
because all you provide is the id from the related model. When working with the ManyToManyWithData
|
|
96
|
+
column, you provide a dictionary for each relationship (so you can provide the data that goes in the
|
|
97
|
+
pivot model). To let it know what record is being connected, you therefore explicitly provide
|
|
98
|
+
the id from the related model in a dictionary key with the name of the related model id column in
|
|
99
|
+
the pivot (e.g. `{"thingy_id": id}` in the first example. However, if there are unique columns in the
|
|
100
|
+
related model, you can provide those instead. If you execute the above example you'll get:
|
|
101
|
+
|
|
102
|
+
```json
|
|
103
|
+
{
|
|
104
|
+
"status": "success",
|
|
105
|
+
"error": "",
|
|
106
|
+
"data": {
|
|
107
|
+
"id": "c4be91a8-85a1-4e29-994a-327f59e26ec7",
|
|
108
|
+
"name": "Widget 1",
|
|
109
|
+
"thingy_widgets": [
|
|
110
|
+
{
|
|
111
|
+
"id": "3a8f6f14-9657-49d8-8844-0db3452525fe",
|
|
112
|
+
"thingy_id": "db292ebc-7b2b-4306-aced-8e6d073ec264",
|
|
113
|
+
"widget_id": "c4be91a8-85a1-4e29-994a-327f59e26ec7",
|
|
114
|
+
"name": "Widget Thing 1",
|
|
115
|
+
"kind": "Special",
|
|
116
|
+
},
|
|
117
|
+
{
|
|
118
|
+
"id": "480a0192-70d9-4363-a669-4a59f0b56730",
|
|
119
|
+
"thingy_id": "d469dbe9-556e-46f3-bc48-03f8cb8d8e44",
|
|
120
|
+
"widget_id": "c4be91a8-85a1-4e29-994a-327f59e26ec7",
|
|
121
|
+
"name": "Widget Thing 2",
|
|
122
|
+
"kind": "Also Special",
|
|
123
|
+
},
|
|
124
|
+
],
|
|
125
|
+
},
|
|
126
|
+
"pagination": {},
|
|
127
|
+
"input_errors": {},
|
|
128
|
+
}
|
|
129
|
+
```
|
|
130
|
+
"""
|
|
131
|
+
|
|
132
|
+
""" The list of columns in the pivot model that can be set when saving data from an endpoint. """
|
|
133
|
+
setable_column_names = configs.WriteableModelColumns("pivot_model_class")
|
|
134
|
+
|
|
135
|
+
""" The list of columns in the pivot model that will be included when returning records from an endpoint. """
|
|
136
|
+
readable_pivot_column_names = configs.ReadableModelColumns("pivot_model_class")
|
|
137
|
+
|
|
138
|
+
"""
|
|
139
|
+
Complicated, but probably should be false.
|
|
140
|
+
|
|
141
|
+
Sometimes you have to provide data from the related model class in your save data so that
|
|
142
|
+
clearskies can find the right record. Normally, this lookup column is not persisted to the
|
|
143
|
+
pivot table, because it is assumed to only exist in the related table. In some cases though,
|
|
144
|
+
you may want it in both, in which case you can set this to true.
|
|
145
|
+
"""
|
|
146
|
+
persist_unique_lookup_column_to_pivot_table = configs.Boolean(default=False)
|
|
147
|
+
|
|
148
|
+
default = configs.ListAnyDict(default=None) # type: ignore
|
|
149
|
+
setable = configs.ListAnyDictOrCallable(default=None) # type: ignore
|
|
150
|
+
_descriptor_config_map = None
|
|
151
|
+
|
|
152
|
+
@clearskies.decorators.parameters_to_properties
|
|
153
|
+
def __init__(
|
|
154
|
+
self,
|
|
155
|
+
related_model_class,
|
|
156
|
+
pivot_model_class,
|
|
157
|
+
own_column_name_in_pivot: str = "",
|
|
158
|
+
related_column_name_in_pivot: str = "",
|
|
159
|
+
readable_related_columns: list[str] = [],
|
|
160
|
+
readable_pivot_column_names: list[str] = [],
|
|
161
|
+
setable_column_names: list[str] = [],
|
|
162
|
+
persist_unique_lookup_column_to_pivot_table: bool = False,
|
|
163
|
+
default: list[dict[str, Any]] = [],
|
|
164
|
+
setable: list[dict[str, Any]] | Callable[..., list[dict[str, Any]]] = [],
|
|
165
|
+
is_readable: bool = True,
|
|
166
|
+
is_writeable: bool = True,
|
|
167
|
+
is_temporary: bool = False,
|
|
168
|
+
validators: clearskies.typing.validator | list[clearskies.typing.validator] = [],
|
|
169
|
+
on_change_pre_save: clearskies.typing.action | list[clearskies.typing.action] = [],
|
|
170
|
+
on_change_post_save: clearskies.typing.action | list[clearskies.typing.action] = [],
|
|
171
|
+
on_change_save_finished: clearskies.typing.action | list[clearskies.typing.action] = [],
|
|
172
|
+
created_by_source_type: str = "",
|
|
173
|
+
created_by_source_key: str = "",
|
|
174
|
+
created_by_source_strict: bool = True,
|
|
175
|
+
):
|
|
176
|
+
pass
|
|
177
|
+
|
|
178
|
+
@overload
|
|
179
|
+
def __get__(self, instance: None, cls: type[Model]) -> Self:
|
|
180
|
+
pass
|
|
181
|
+
|
|
182
|
+
@overload
|
|
183
|
+
def __get__(self, instance: Model, cls: type[Model]) -> list[Any]:
|
|
184
|
+
pass
|
|
185
|
+
|
|
186
|
+
def __get__(self, instance, cls):
|
|
187
|
+
return super().__get__(instance, cls)
|
|
188
|
+
|
|
189
|
+
def __set__(self, instance, value: list[dict[str, Any]]) -> None: # type: ignore
|
|
190
|
+
# this makes sure we're initialized
|
|
191
|
+
if "name" not in self._config: # type: ignore
|
|
192
|
+
instance.get_columns()
|
|
193
|
+
|
|
194
|
+
instance._next_data[self.name] = value
|
|
195
|
+
|
|
196
|
+
def post_save(self, data, model, id):
|
|
197
|
+
# if our incoming data is not in the data array or is None, then nothing has been set and we do not want
|
|
198
|
+
# to make any changes
|
|
199
|
+
if self.name not in data or data[self.name] is None:
|
|
200
|
+
return data
|
|
201
|
+
|
|
202
|
+
# figure out what ids need to be created or deleted from the pivot table.
|
|
203
|
+
if not model:
|
|
204
|
+
old_ids = set()
|
|
205
|
+
else:
|
|
206
|
+
old_ids = set(self.__get__(model, model.__class__))
|
|
207
|
+
|
|
208
|
+
# this is trickier for many-to-many-with-data compared to many-to-many. We're generally
|
|
209
|
+
# expecting data[self.name] to be a list of dictionaries. For each entry, we need to find
|
|
210
|
+
# the corresponding entry in the pivot table to decide if we need to delete, create, or update.
|
|
211
|
+
# However, since we have a dictionary there are a variety of ways that we can connect to
|
|
212
|
+
# an entry in the related table - either related id or any unique column from the related
|
|
213
|
+
# table. Technically we might also specify a pivot id, but we're generally trying to be
|
|
214
|
+
# transparent to those, so let's ignore that one.
|
|
215
|
+
related_column_name_in_pivot = self.related_column_name_in_pivot
|
|
216
|
+
own_column_name_in_pivot = self.own_column_name_in_pivot
|
|
217
|
+
unique_related_columns = {
|
|
218
|
+
column.name: column.name for column in self.related_columns.values() if column.is_unique
|
|
219
|
+
}
|
|
220
|
+
related_model = self.related_model
|
|
221
|
+
pivot_model = self.pivot_model
|
|
222
|
+
# minor cheating
|
|
223
|
+
if hasattr(pivot_model.backend, "create_table"):
|
|
224
|
+
pivot_model.backend.create_table(pivot_model)
|
|
225
|
+
new_ids = set()
|
|
226
|
+
for pivot_record in data[self.name]:
|
|
227
|
+
# first we need to identify which related column this belongs to.
|
|
228
|
+
related_column_id = None
|
|
229
|
+
# if they provide the related column id in the pivot data then we're good
|
|
230
|
+
if related_column_name_in_pivot in pivot_record:
|
|
231
|
+
related_column_id = pivot_record[related_column_name_in_pivot]
|
|
232
|
+
elif len(unique_related_columns):
|
|
233
|
+
for pivot_column, pivot_value in pivot_record.items():
|
|
234
|
+
if pivot_column not in unique_related_columns:
|
|
235
|
+
continue
|
|
236
|
+
related = related_model.find(f"{pivot_column}={pivot_value}")
|
|
237
|
+
related_column_id = getattr(related, related.id_column_name)
|
|
238
|
+
if related_column_id:
|
|
239
|
+
# remove this column from the data - it was used to lookup the right
|
|
240
|
+
# record, but mostly won't exist in the model, unless we've been instructed
|
|
241
|
+
# to keep it
|
|
242
|
+
if not self._config.get("persist_unique_lookup_column_to_pivot_table"): # type: ignore
|
|
243
|
+
del pivot_record[pivot_column]
|
|
244
|
+
break
|
|
245
|
+
if not related_column_id:
|
|
246
|
+
column_list = "'" + "', '".join(list(unique_related_columns.keys())) + "'"
|
|
247
|
+
raise ValueError(
|
|
248
|
+
f"Missing data for {self.name}: Unable to match related record for a record in the many-to-many relationship: you must provide either '{related_column_name_in_pivot}' with the id column for the related table, or a value from one of the unique columns: {column_list}"
|
|
249
|
+
)
|
|
250
|
+
pivot = (
|
|
251
|
+
pivot_model.where(f"{related_column_name_in_pivot}={related_column_id}")
|
|
252
|
+
.where(f"{own_column_name_in_pivot}={id}")
|
|
253
|
+
.first()
|
|
254
|
+
)
|
|
255
|
+
new_ids.add(related_column_id)
|
|
256
|
+
# this will either update or create accordingly
|
|
257
|
+
pivot.save(
|
|
258
|
+
{
|
|
259
|
+
**pivot_record,
|
|
260
|
+
related_column_name_in_pivot: related_column_id,
|
|
261
|
+
own_column_name_in_pivot: id,
|
|
262
|
+
}
|
|
263
|
+
)
|
|
264
|
+
|
|
265
|
+
# the above took care of isnerting and updating active records. Now we need to delete
|
|
266
|
+
# records that are no longer needed.
|
|
267
|
+
to_delete = old_ids - new_ids
|
|
268
|
+
if to_delete:
|
|
269
|
+
for model_to_delete in pivot_model.where(
|
|
270
|
+
f"{related_column_name_in_pivot} IN (" + ",".join(map(str, to_delete)) + ")"
|
|
271
|
+
):
|
|
272
|
+
model_to_delete.delete()
|
|
273
|
+
|
|
274
|
+
return data
|
|
@@ -0,0 +1,158 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from collections import OrderedDict
|
|
4
|
+
from typing import TYPE_CHECKING, Any, Callable, Self, overload
|
|
5
|
+
|
|
6
|
+
import clearskies.decorators
|
|
7
|
+
import clearskies.typing
|
|
8
|
+
from clearskies import configs
|
|
9
|
+
from clearskies.autodoc.schema import Array as AutoDocArray
|
|
10
|
+
from clearskies.autodoc.schema import Object as AutoDocObject
|
|
11
|
+
from clearskies.column import Column
|
|
12
|
+
from clearskies.columns.many_to_many_ids import ManyToManyIds
|
|
13
|
+
from clearskies.functional import string
|
|
14
|
+
|
|
15
|
+
if TYPE_CHECKING:
|
|
16
|
+
from clearskies import Model
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
class ManyToManyModels(Column):
|
|
20
|
+
"""
|
|
21
|
+
A companion for the ManyToManyIds column that returns the matching models instead of the ids.
|
|
22
|
+
|
|
23
|
+
See the example in the ManyToManyIds column to understand how to use it.
|
|
24
|
+
"""
|
|
25
|
+
|
|
26
|
+
""" The name of the many-to-many column we are attached to. """
|
|
27
|
+
many_to_many_column_name = configs.ModelColumn(required=True)
|
|
28
|
+
|
|
29
|
+
is_writeable = configs.Boolean(default=False)
|
|
30
|
+
is_searchable = configs.Boolean(default=False)
|
|
31
|
+
_descriptor_config_map = None
|
|
32
|
+
|
|
33
|
+
@clearskies.decorators.parameters_to_properties
|
|
34
|
+
def __init__(
|
|
35
|
+
self,
|
|
36
|
+
many_to_many_column_name,
|
|
37
|
+
):
|
|
38
|
+
pass
|
|
39
|
+
|
|
40
|
+
def finalize_configuration(self, model_class: type, name: str) -> None:
|
|
41
|
+
"""Finalize and check the configuration."""
|
|
42
|
+
getattr(self.__class__, "many_to_many_column_name").set_model_class(model_class)
|
|
43
|
+
self.model_class = model_class
|
|
44
|
+
self.name = name
|
|
45
|
+
self.finalize_and_validate_configuration()
|
|
46
|
+
|
|
47
|
+
# finally, make sure we're really pointed at a many-to-many column
|
|
48
|
+
many_to_many_column = getattr(model_class, self.many_to_many_column_name)
|
|
49
|
+
if not isinstance(many_to_many_column, ManyToManyIds):
|
|
50
|
+
raise ValueError(
|
|
51
|
+
f"Error with configuration for {model_class.__name__}.{name}, which is a ManyToManyModels column. It needs to point to a ManyToManyIds column, and it was told to use {model_class.__name__}.{self.many_to_many_column_name}, but this is not a ManyToManyIds column."
|
|
52
|
+
)
|
|
53
|
+
|
|
54
|
+
@property
|
|
55
|
+
def pivot_model(self):
|
|
56
|
+
return self.di.build(self.many_to_many_column.pivot_model_class, cache=True) # type: ignore
|
|
57
|
+
|
|
58
|
+
@property
|
|
59
|
+
def related_models(self):
|
|
60
|
+
return self.di.build(self.many_to_many_column.related_model_class, cache=True) # type: ignore
|
|
61
|
+
|
|
62
|
+
@property
|
|
63
|
+
def related_columns(self):
|
|
64
|
+
return self.related_models.get_columns()
|
|
65
|
+
|
|
66
|
+
@property
|
|
67
|
+
def many_to_many_column(self) -> ManyToManyIds:
|
|
68
|
+
return getattr(self.model_class, self.many_to_many_column_name)
|
|
69
|
+
|
|
70
|
+
@overload
|
|
71
|
+
def __get__(self, instance: None, cls: type[Model]) -> Self:
|
|
72
|
+
pass
|
|
73
|
+
|
|
74
|
+
@overload
|
|
75
|
+
def __get__(self, instance: Model, cls: type[Model]) -> Model:
|
|
76
|
+
pass
|
|
77
|
+
|
|
78
|
+
def __get__(self, instance, cls):
|
|
79
|
+
if instance is None:
|
|
80
|
+
self.model_class = cls
|
|
81
|
+
return self
|
|
82
|
+
|
|
83
|
+
# this makes sure we're initialized
|
|
84
|
+
if "name" not in self._config: # type: ignore
|
|
85
|
+
instance.get_columns()
|
|
86
|
+
|
|
87
|
+
return self.many_to_many_column.get_related_models(instance) # type: ignore
|
|
88
|
+
|
|
89
|
+
def __set__(self, instance, value: Model | list[Model] | list[dict[str, Any]]) -> None:
|
|
90
|
+
# this makes sure we're initialized
|
|
91
|
+
if "name" not in self._config: # type: ignore
|
|
92
|
+
instance.get_columns()
|
|
93
|
+
|
|
94
|
+
# we allow a list of models or a model, but if it's a model it may represent a single record or a query.
|
|
95
|
+
# if it's a single record then we want to wrap it in a list so we can iterate over it.
|
|
96
|
+
if hasattr(value, "_data") and value._data:
|
|
97
|
+
value = []
|
|
98
|
+
many_to_many_column: ManyToManyIds = self.many_to_many_column # type: ignore
|
|
99
|
+
related_model_class = many_to_many_column.related_model_class
|
|
100
|
+
related_id_column_name = related_model_class.id_column_name
|
|
101
|
+
record_ids = []
|
|
102
|
+
for index, record in enumerate(value):
|
|
103
|
+
if isinstance(record, dict):
|
|
104
|
+
if not record.get(related_id_column_name):
|
|
105
|
+
raise KeyError(
|
|
106
|
+
f"A list of dictionaries was set to '{self.model_class.__name__}.{self.name}', in which case each dictionary should contain the key '{related_id_column_name}', which should be the id of an entry for the '{related_model_class.__name__}' model. However, no such key was found for entry #{index + 1}"
|
|
107
|
+
)
|
|
108
|
+
record_ids.append(record[related_id_column_name])
|
|
109
|
+
continue
|
|
110
|
+
|
|
111
|
+
# if we get here then the entry should be a model for our related model class
|
|
112
|
+
if not isinstance(record, related_model_class):
|
|
113
|
+
raise TypeError(
|
|
114
|
+
f"Models were sent to '{self.model_class.__name__}.{self.name}', in which case it should be a list of models of type {related_model_class.__name__}. However, an object of type '{record.__class__.__name__}' was found for entry #{index + 1}"
|
|
115
|
+
)
|
|
116
|
+
record_ids.append(getattr(record, related_id_column_name))
|
|
117
|
+
setattr(instance, self.many_to_many_column_name, record_ids)
|
|
118
|
+
|
|
119
|
+
def add_search(self, model: Model, value: str, operator: str = "", relationship_reference: str = "") -> Model:
|
|
120
|
+
return self.many_to_many_column.add_search( # type: ignore
|
|
121
|
+
model, value, operator, relationship_reference=relationship_reference
|
|
122
|
+
) # type: ignore
|
|
123
|
+
|
|
124
|
+
def to_json(self, model: Model) -> dict[str, Any]:
|
|
125
|
+
records = []
|
|
126
|
+
many_to_many_column: ManyToManyIds = self.many_to_many_column # type: ignore
|
|
127
|
+
columns = many_to_many_column.related_columns
|
|
128
|
+
related_id_column_name = many_to_many_column.related_model_class.id_column_name
|
|
129
|
+
for related in many_to_many_column.get_related_models(model):
|
|
130
|
+
json = OrderedDict()
|
|
131
|
+
if related_id_column_name not in many_to_many_column.readable_related_column_names:
|
|
132
|
+
json[related_id_column_name] = columns[related_id_column_name].to_json(related)
|
|
133
|
+
for column_name in many_to_many_column.readable_related_column_names:
|
|
134
|
+
column_data = columns[column_name].to_json(related)
|
|
135
|
+
if type(column_data) == dict:
|
|
136
|
+
json = {**json, **column_data} # type: ignore
|
|
137
|
+
else:
|
|
138
|
+
json[column_name] = column_data
|
|
139
|
+
records.append(json)
|
|
140
|
+
return {self.name: records}
|
|
141
|
+
|
|
142
|
+
def documentation(self, name: str | None = None, example: str | None = None, value: str | None = None):
|
|
143
|
+
many_to_many_column = self.many_to_many_column # type: ignore
|
|
144
|
+
columns = many_to_many_column.related_columns
|
|
145
|
+
related_id_column_name = many_to_many_column.related_model_class.id_column_name
|
|
146
|
+
related_properties = [columns[related_id_column_name].documentation()]
|
|
147
|
+
|
|
148
|
+
for column_name in many_to_many_column.readable_related_column_names:
|
|
149
|
+
related_docs = columns[column_name].documentation()
|
|
150
|
+
if type(related_docs) != list:
|
|
151
|
+
related_docs = [related_docs]
|
|
152
|
+
related_properties.extend(related_docs)
|
|
153
|
+
|
|
154
|
+
related_object = AutoDocObject(
|
|
155
|
+
string.title_case_to_nice(many_to_many_column.related_model_class.__name__),
|
|
156
|
+
related_properties,
|
|
157
|
+
)
|
|
158
|
+
return AutoDocArray(name if name is not None else self.name, related_object, value=value)
|
|
@@ -0,0 +1,134 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from collections import OrderedDict
|
|
4
|
+
from typing import TYPE_CHECKING, Any, Callable, Self, overload
|
|
5
|
+
|
|
6
|
+
import clearskies.decorators
|
|
7
|
+
import clearskies.typing
|
|
8
|
+
from clearskies import configs
|
|
9
|
+
from clearskies.autodoc.schema import Array as AutoDocArray
|
|
10
|
+
from clearskies.autodoc.schema import Object as AutoDocObject
|
|
11
|
+
from clearskies.column import Column
|
|
12
|
+
from clearskies.columns.many_to_many_ids import ManyToManyIds
|
|
13
|
+
from clearskies.functional import string
|
|
14
|
+
|
|
15
|
+
if TYPE_CHECKING:
|
|
16
|
+
from clearskies import Model
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
class ManyToManyPivots(Column):
|
|
20
|
+
"""
|
|
21
|
+
A companion for the ManyToManyIds column that returns the matching pivot models instead of the ids.
|
|
22
|
+
|
|
23
|
+
See ManyToManyIdsWithData for an example of how to use it (but note that it works just the same for the
|
|
24
|
+
ManyToManyIds column).
|
|
25
|
+
"""
|
|
26
|
+
|
|
27
|
+
""" The name of the many-to-many column we are attached to. """
|
|
28
|
+
many_to_many_column_name = configs.ModelColumn(required=True)
|
|
29
|
+
|
|
30
|
+
is_writeable = configs.Boolean(default=False)
|
|
31
|
+
is_searchable = configs.Boolean(default=False)
|
|
32
|
+
_descriptor_config_map = None
|
|
33
|
+
|
|
34
|
+
@clearskies.decorators.parameters_to_properties
|
|
35
|
+
def __init__(
|
|
36
|
+
self,
|
|
37
|
+
many_to_many_column_name,
|
|
38
|
+
):
|
|
39
|
+
pass
|
|
40
|
+
|
|
41
|
+
def finalize_configuration(self, model_class: type, name: str) -> None:
|
|
42
|
+
"""Finalize and check the configuration."""
|
|
43
|
+
getattr(self.__class__, "many_to_many_column_name").set_model_class(model_class)
|
|
44
|
+
self.model_class = model_class
|
|
45
|
+
self.name = name
|
|
46
|
+
self.finalize_and_validate_configuration()
|
|
47
|
+
|
|
48
|
+
# finally, make sure we're really pointed at a many-to-many column
|
|
49
|
+
many_to_many_column = getattr(model_class, self.many_to_many_column_name)
|
|
50
|
+
if not isinstance(many_to_many_column, ManyToManyIds):
|
|
51
|
+
raise ValueError(
|
|
52
|
+
f"Error with configuration for {model_class.__name__}.{name}, which is a ManyToManyModels column. It needs to point to a ManyToManyIds column, and it was told to use {model_class.__name__}.{self.many_to_many_column_name}, but this is not a ManyToManyIds column."
|
|
53
|
+
)
|
|
54
|
+
|
|
55
|
+
@property
|
|
56
|
+
def pivot_model(self) -> Model:
|
|
57
|
+
return self.di.build(self.many_to_many_column.pivot_model_class, cache=True) # type: ignore
|
|
58
|
+
|
|
59
|
+
@property
|
|
60
|
+
def related_models(self) -> Model:
|
|
61
|
+
return self.di.build(self.many_to_many_column.related_model_class, cache=True) # type: ignore
|
|
62
|
+
|
|
63
|
+
@property
|
|
64
|
+
def related_columns(self):
|
|
65
|
+
return self.related_models.get_columns()
|
|
66
|
+
|
|
67
|
+
@property
|
|
68
|
+
def many_to_many_column(self) -> ManyToManyIds:
|
|
69
|
+
return getattr(self.model_class, self.many_to_many_column_name)
|
|
70
|
+
|
|
71
|
+
@overload
|
|
72
|
+
def __get__(self, instance: None, cls: type[Model]) -> Self:
|
|
73
|
+
pass
|
|
74
|
+
|
|
75
|
+
@overload
|
|
76
|
+
def __get__(self, instance: Model, cls: type[Model]) -> Model:
|
|
77
|
+
pass
|
|
78
|
+
|
|
79
|
+
def __get__(self, instance, cls):
|
|
80
|
+
if instance is None:
|
|
81
|
+
self.model_class = cls
|
|
82
|
+
return self
|
|
83
|
+
|
|
84
|
+
# this makes sure we're initialized
|
|
85
|
+
if "name" not in self._config: # type: ignore
|
|
86
|
+
instance.get_columns()
|
|
87
|
+
|
|
88
|
+
many_to_many_column = self.many_to_many_column # type: ignore
|
|
89
|
+
own_column_name_in_pivot = many_to_many_column._config("own_column_name_in_pivot")
|
|
90
|
+
my_id = getattr(instance, instance.id_column_name)
|
|
91
|
+
return [model for model in self.pivot_model.where(f"{own_column_name_in_pivot}={my_id}")]
|
|
92
|
+
|
|
93
|
+
def __set__(self, instance, value: Model | list[Model] | list[dict[str, Any]]) -> None:
|
|
94
|
+
raise NotImplementedError("Saving not supported for ManyToManyPivots")
|
|
95
|
+
|
|
96
|
+
def add_search(self, model: Model, value: str, operator: str = "", relationship_reference: str = "") -> Model:
|
|
97
|
+
raise NotImplementedError("Searching not supported for ManyToManyPivots")
|
|
98
|
+
|
|
99
|
+
def to_json(self, model: Model) -> dict[str, Any]:
|
|
100
|
+
records = []
|
|
101
|
+
many_to_many_column = self.many_to_many_column # type: ignore
|
|
102
|
+
columns = many_to_many_column.pivot_columns
|
|
103
|
+
readable_column_names = many_to_many_column.readable_pivot_column_names
|
|
104
|
+
pivot_id_column_name = many_to_many_column.pivot_model_class.id_column_name
|
|
105
|
+
for pivot in many_to_many_column.get_pivot_models(model):
|
|
106
|
+
json = OrderedDict()
|
|
107
|
+
if pivot_id_column_name not in readable_column_names:
|
|
108
|
+
json[pivot_id_column_name] = columns[pivot_id_column_name].to_json(pivot)
|
|
109
|
+
for column_name in readable_column_names:
|
|
110
|
+
column_data = columns[column_name].to_json(pivot)
|
|
111
|
+
if type(column_data) == dict:
|
|
112
|
+
json = {**json, **column_data} # type: ignore
|
|
113
|
+
else:
|
|
114
|
+
json[column_name] = column_data
|
|
115
|
+
records.append(json)
|
|
116
|
+
return {self.name: records}
|
|
117
|
+
|
|
118
|
+
def documentation(self, name: str | None = None, example: str | None = None, value: str | None = None):
|
|
119
|
+
many_to_many_column = self.many_to_many_column # type: ignore
|
|
120
|
+
columns = many_to_many_column.pivot_columns
|
|
121
|
+
pivot_id_column_name = many_to_many_column.pivot_model_class.id_column_name
|
|
122
|
+
pivot_properties = [columns[pivot_id_column_name].documentation()]
|
|
123
|
+
|
|
124
|
+
for column_name in many_to_many_column.readable_pivot_column_names:
|
|
125
|
+
pivot_docs = columns[column_name].documentation()
|
|
126
|
+
if type(pivot_docs) != list:
|
|
127
|
+
pivot_docs = [pivot_docs]
|
|
128
|
+
pivot_properties.extend(pivot_docs)
|
|
129
|
+
|
|
130
|
+
pivot_object = AutoDocObject(
|
|
131
|
+
string.title_case_to_nice(many_to_many_column.pivot_model_class.__name__),
|
|
132
|
+
pivot_properties,
|
|
133
|
+
)
|
|
134
|
+
return AutoDocArray(name if name is not None else self.name, pivot_object, value=value)
|