django-object-preferences 0.1.0__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.
- django_object_preferences-0.1.0.dist-info/METADATA +512 -0
- django_object_preferences-0.1.0.dist-info/RECORD +11 -0
- django_object_preferences-0.1.0.dist-info/WHEEL +4 -0
- django_object_preferences-0.1.0.dist-info/licenses/LICENSE +21 -0
- object_preferences/__init__.py +1 -0
- object_preferences/apps.py +24 -0
- object_preferences/fields.py +252 -0
- object_preferences/locale/en/LC_MESSAGES/django.po +48 -0
- object_preferences/registry.py +288 -0
- object_preferences/templates/object_preferences/preferences_form.html +29 -0
- object_preferences/views.py +129 -0
|
@@ -0,0 +1,512 @@
|
|
|
1
|
+
Metadata-Version: 2.5
|
|
2
|
+
Name: django-object-preferences
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Preferences stored on any Django model instance, declared with forms
|
|
5
|
+
Project-URL: Homepage, https://github.com/django-mvp/django-object-preferences
|
|
6
|
+
Project-URL: Repository, https://github.com/django-mvp/django-object-preferences
|
|
7
|
+
Project-URL: Issues, https://github.com/django-mvp/django-object-preferences/issues
|
|
8
|
+
Author-email: Samuel Jennings <samuel.scott.jennings@gmail.com>
|
|
9
|
+
License-Expression: MIT
|
|
10
|
+
License-File: LICENSE
|
|
11
|
+
Keywords: django,forms,jsonfield,preferences,settings
|
|
12
|
+
Classifier: Development Status :: 3 - Alpha
|
|
13
|
+
Classifier: Environment :: Web Environment
|
|
14
|
+
Classifier: Framework :: Django
|
|
15
|
+
Classifier: Framework :: Django :: 5.2
|
|
16
|
+
Classifier: Framework :: Django :: 6.0
|
|
17
|
+
Classifier: Framework :: Django :: 6.1
|
|
18
|
+
Classifier: Intended Audience :: Developers
|
|
19
|
+
Classifier: License :: OSI Approved :: MIT License
|
|
20
|
+
Classifier: Natural Language :: English
|
|
21
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
22
|
+
Classifier: Programming Language :: Python :: 3.13
|
|
23
|
+
Classifier: Topic :: Internet :: WWW/HTTP :: Dynamic Content
|
|
24
|
+
Requires-Python: <4.0,>=3.12
|
|
25
|
+
Requires-Dist: django>=5.2
|
|
26
|
+
Description-Content-Type: text/markdown
|
|
27
|
+
|
|
28
|
+
# django-object-preferences
|
|
29
|
+
|
|
30
|
+
[](https://github.com/django-mvp/django-object-preferences/actions/workflows/tests.yml)
|
|
31
|
+
[](https://github.com/django-mvp/django-object-preferences/actions/workflows/build.yml)
|
|
32
|
+
[](https://github.com/django-mvp/django-object-preferences/actions/workflows/publish.yml)
|
|
33
|
+
[](https://codecov.io/gh/django-mvp/django-object-preferences)
|
|
34
|
+
[](https://pypi.org/project/django-object-preferences/)
|
|
35
|
+
[](https://pypi.org/project/django-object-preferences/)
|
|
36
|
+
[](https://pypi.org/project/django-object-preferences/)
|
|
37
|
+
[](https://pypi.org/project/django-object-preferences/)
|
|
38
|
+
[](https://github.com/astral-sh/ruff)
|
|
39
|
+
[](https://github.com/django-mvp/django-object-preferences/blob/main/LICENSE)
|
|
40
|
+
|
|
41
|
+
Preferences stored on any Django model instance, declared with forms.
|
|
42
|
+
|
|
43
|
+
A person's theme, language and profile visibility, or how a project's or a
|
|
44
|
+
dataset's pages behave: settings like these belong to one record and are read
|
|
45
|
+
far more often than they are changed. This package keeps them in a single JSON
|
|
46
|
+
field on the record itself. Any installed app can declare its own group of
|
|
47
|
+
preferences without adding a model, a table or a migration.
|
|
48
|
+
|
|
49
|
+
> **Status:** early development. Nothing is published yet, and the first
|
|
50
|
+
> release will be 0.1.0.
|
|
51
|
+
|
|
52
|
+
## Scope & philosophy
|
|
53
|
+
|
|
54
|
+
django-object-preferences stores small, per-record settings and gives people a
|
|
55
|
+
page to change them. A record can be any model: the user model, or something
|
|
56
|
+
like a project or a dataset. Each app declares the preferences it needs with
|
|
57
|
+
an ordinary Django form, which already says what type each value is, how it is
|
|
58
|
+
validated, what its choices are and what it defaults to. Reading a preference
|
|
59
|
+
costs no extra query, because the values travel with the record.
|
|
60
|
+
|
|
61
|
+
What it deliberately is not:
|
|
62
|
+
|
|
63
|
+
- **A home for data the server selects records by.** A value the project filters
|
|
64
|
+
or orders on belongs in a real column with an index. Whether a profile is
|
|
65
|
+
public is the usual example. Preferences are read from a record already in
|
|
66
|
+
hand.
|
|
67
|
+
- **Site-wide settings.** Values that apply to the whole site belong in Django
|
|
68
|
+
settings or in a package built for that, such as django-constance.
|
|
69
|
+
- **A place for anything that sits between two records.** A person's choice
|
|
70
|
+
about one particular project relates two records, and needs a model of its own.
|
|
71
|
+
- **A user interface framework.** The page it ships is plain Django template
|
|
72
|
+
markup, which a project or a UI package restyles.
|
|
73
|
+
|
|
74
|
+
When two designs conflict, the one an experienced Django developer would
|
|
75
|
+
recognise wins over the cleverer one. Forms, model fields and class-based views
|
|
76
|
+
are used the way Django intends, and nothing is added that a project could do
|
|
77
|
+
just as well with what Django already provides.
|
|
78
|
+
|
|
79
|
+
## Requirements
|
|
80
|
+
|
|
81
|
+
- Python 3.12 or later.
|
|
82
|
+
- Django 5.2, 6.0 or 6.1.
|
|
83
|
+
|
|
84
|
+
## Installation
|
|
85
|
+
|
|
86
|
+
```bash
|
|
87
|
+
pip install django-object-preferences
|
|
88
|
+
```
|
|
89
|
+
|
|
90
|
+
Then add it to `INSTALLED_APPS`:
|
|
91
|
+
|
|
92
|
+
```python
|
|
93
|
+
INSTALLED_APPS = [
|
|
94
|
+
# ...
|
|
95
|
+
"object_preferences",
|
|
96
|
+
]
|
|
97
|
+
```
|
|
98
|
+
|
|
99
|
+
## Usage
|
|
100
|
+
|
|
101
|
+
### Turning preferences on for a model
|
|
102
|
+
|
|
103
|
+
Add a `PreferencesField` to the model whose records should carry preferences,
|
|
104
|
+
then make and run the migration:
|
|
105
|
+
|
|
106
|
+
```python
|
|
107
|
+
from django.db import models
|
|
108
|
+
|
|
109
|
+
from object_preferences.fields import PreferencesField
|
|
110
|
+
|
|
111
|
+
|
|
112
|
+
class Project(models.Model):
|
|
113
|
+
name = models.CharField(max_length=100)
|
|
114
|
+
preferences = PreferencesField()
|
|
115
|
+
```
|
|
116
|
+
|
|
117
|
+
The field takes no arguments. It is one JSON column, and a model needs only
|
|
118
|
+
one. Its migration reads `PreferencesField()`, and it is the only migration
|
|
119
|
+
this package ever asks of your project: declaring preferences later adds no
|
|
120
|
+
table and no migration.
|
|
121
|
+
|
|
122
|
+
Reading `project.preferences` returns a `RecordPreferences`, which belongs to
|
|
123
|
+
that one record and is installed by the field's `PreferencesDescriptor`. It
|
|
124
|
+
knows its `record` and its `field`, and its `stored` property is the
|
|
125
|
+
dictionary the record keeps. A new record stores an empty dictionary, because
|
|
126
|
+
a record holds only the values someone has changed from their defaults:
|
|
127
|
+
|
|
128
|
+
```python
|
|
129
|
+
project = Project(name="Atlas")
|
|
130
|
+
project.preferences.stored # {}
|
|
131
|
+
```
|
|
132
|
+
|
|
133
|
+
The field is not editable, so a `ModelForm` for the model leaves it out.
|
|
134
|
+
|
|
135
|
+
### Declaring a group of preferences
|
|
136
|
+
|
|
137
|
+
A group of preferences is an ordinary Django form, and each of its fields is
|
|
138
|
+
one preference. Register it for the model the way you would register a model
|
|
139
|
+
admin, in a module called `preferences.py` inside any installed app. The
|
|
140
|
+
package imports that module for every installed app when Django starts, so the
|
|
141
|
+
project's settings never list it:
|
|
142
|
+
|
|
143
|
+
```python
|
|
144
|
+
# projects/preferences.py
|
|
145
|
+
from django import forms
|
|
146
|
+
|
|
147
|
+
from object_preferences.registry import register
|
|
148
|
+
|
|
149
|
+
from .models import Project
|
|
150
|
+
|
|
151
|
+
|
|
152
|
+
@register(Project, name="display")
|
|
153
|
+
class DisplayPreferences(forms.Form):
|
|
154
|
+
theme = forms.ChoiceField(
|
|
155
|
+
choices=[("light", "Light"), ("dark", "Dark")], initial="light"
|
|
156
|
+
)
|
|
157
|
+
page_size = forms.IntegerField(initial=25, min_value=5, max_value=100)
|
|
158
|
+
show_archived = forms.BooleanField(required=False)
|
|
159
|
+
```
|
|
160
|
+
|
|
161
|
+
`register` is the decorator form of `registry.register(model, name, form_class)`,
|
|
162
|
+
where `registry` is the project-wide `PreferenceRegistry`. A group's name is
|
|
163
|
+
unique for its model. Registering returns a `PreferenceGroup`, which knows its
|
|
164
|
+
`model`, its `name` and its `form_class`.
|
|
165
|
+
|
|
166
|
+
Startup stops with an `ImproperlyConfigured` error that names the problem when
|
|
167
|
+
two groups for one model share a name, when the model has no `PreferencesField`,
|
|
168
|
+
or when the form has a field that refers to other records or carries an upload
|
|
169
|
+
(`ModelChoiceField`, `FileField` and `MultiValueField`, with their subclasses).
|
|
170
|
+
|
|
171
|
+
To look groups up, ask the registry:
|
|
172
|
+
|
|
173
|
+
```python
|
|
174
|
+
from object_preferences.registry import registry
|
|
175
|
+
|
|
176
|
+
registry.get_groups(Project) # every PreferenceGroup, in registration order
|
|
177
|
+
registry.get_group(Project, "display") # one group, or a KeyError naming it
|
|
178
|
+
registry.get_field(Project) # the model's PreferencesField
|
|
179
|
+
```
|
|
180
|
+
|
|
181
|
+
Groups belong to exactly the model they were registered for. A subclass or a
|
|
182
|
+
proxy of that model does not inherit them.
|
|
183
|
+
|
|
184
|
+
### Reading preferences
|
|
185
|
+
|
|
186
|
+
`project.preferences` maps each group's name to a read-only mapping of that
|
|
187
|
+
group's values:
|
|
188
|
+
|
|
189
|
+
```python
|
|
190
|
+
project.preferences["display"]["theme"] # "light"
|
|
191
|
+
project.preferences["display"]["page_size"] # 25
|
|
192
|
+
list(project.preferences) # ["display"]
|
|
193
|
+
```
|
|
194
|
+
|
|
195
|
+
Reading never queries the database, however many preferences it reads, because
|
|
196
|
+
the values travel with the record. Asking for a group or a preference that was
|
|
197
|
+
never declared raises a `KeyError` that names it.
|
|
198
|
+
|
|
199
|
+
A preference nobody has changed reads its default, and the default comes from
|
|
200
|
+
the field. It is the field's `initial` value. A field with no `initial` reads
|
|
201
|
+
the value an empty submission of the form would give it, so a `BooleanField`
|
|
202
|
+
reads `False`, an optional `DurationField` reads `None` and an optional
|
|
203
|
+
`MultipleChoiceField` reads an empty list. A required field with no `initial`
|
|
204
|
+
reads as empty: an empty string, or `None` for a number or a date. Reading never
|
|
205
|
+
raises for want of a default.
|
|
206
|
+
|
|
207
|
+
A changed value reads back as the type its field produces, so a `DateField`
|
|
208
|
+
gives a `datetime.date` and a `DecimalField` a `decimal.Decimal`, although the
|
|
209
|
+
record stores them as JSON. Change a field's `initial` and every record that has
|
|
210
|
+
not stored its own value reads the new default, with no migration and no data
|
|
211
|
+
change.
|
|
212
|
+
|
|
213
|
+
### Changing preferences from code
|
|
214
|
+
|
|
215
|
+
`change` sets some of a group's preferences and leaves the rest as they were.
|
|
216
|
+
It validates the values with the group's form, and it changes the record in
|
|
217
|
+
memory only, so `save` writes it:
|
|
218
|
+
|
|
219
|
+
```python
|
|
220
|
+
project.preferences.change("display", theme="dark", page_size=50)
|
|
221
|
+
project.preferences.save()
|
|
222
|
+
```
|
|
223
|
+
|
|
224
|
+
`save` writes only the preferences, with `update_fields`, so another field you
|
|
225
|
+
changed on the record and have not saved is left alone. Calling it on a record
|
|
226
|
+
that has no primary key raises a `ValueError`, and on one whose key is set but
|
|
227
|
+
that has no row (a UUID key is set before the first save) raises a
|
|
228
|
+
`DatabaseError`. A preference changed to its default
|
|
229
|
+
has no stored value afterwards, so `change("display", theme="light")` removes
|
|
230
|
+
`theme` from what the record stores. Asking to change a preference or a group
|
|
231
|
+
that was never declared raises a `KeyError` that names it.
|
|
232
|
+
|
|
233
|
+
A value the form rejects raises Django's `ValidationError`, and the record is
|
|
234
|
+
left as it was. Its `error_dict` is keyed by field name, and an error from the
|
|
235
|
+
form's own `clean()` is under `NON_FIELD_ERRORS`. Each error carries the code
|
|
236
|
+
the form gave it, which is what to test for rather than the message:
|
|
237
|
+
|
|
238
|
+
```python
|
|
239
|
+
from django.core.exceptions import ValidationError
|
|
240
|
+
|
|
241
|
+
try:
|
|
242
|
+
project.preferences.change("display", page_size=3)
|
|
243
|
+
except ValidationError as error:
|
|
244
|
+
error.error_dict["page_size"][0].code # "min_value"
|
|
245
|
+
```
|
|
246
|
+
|
|
247
|
+
To work with a whole submission, use the group's form itself. `get_form`
|
|
248
|
+
returns the group's form starting from the record's current values, bound to
|
|
249
|
+
`data` when you pass it, and `apply` validates a bound form and stores its
|
|
250
|
+
values on the record. Both work on their own, so a page can bind the form of
|
|
251
|
+
every group, check them all, then `apply` each and `save` once:
|
|
252
|
+
|
|
253
|
+
```python
|
|
254
|
+
form = project.preferences.get_form("display", data=request.POST, prefix="display")
|
|
255
|
+
if form.is_valid():
|
|
256
|
+
project.preferences.apply("display", form)
|
|
257
|
+
project.preferences.save()
|
|
258
|
+
```
|
|
259
|
+
|
|
260
|
+
`apply` raises a `TypeError` for a form that is not the group's own, a
|
|
261
|
+
`ValueError` for a form bound to no data, and the same `ValidationError` as
|
|
262
|
+
`change` for an invalid one. Applying a group
|
|
263
|
+
replaces that group's stored values and leaves every other group alone, and it
|
|
264
|
+
drops stored keys that the group no longer declares. A `JSONField` preference is
|
|
265
|
+
stored as the JSON text its form field shows, and reads back as the value you
|
|
266
|
+
set.
|
|
267
|
+
|
|
268
|
+
### Changing a declaration
|
|
269
|
+
|
|
270
|
+
Records keep what they stored when you change a form, so removing, renaming or
|
|
271
|
+
tightening a preference never needs a data migration. Reading never raises
|
|
272
|
+
because of what is stored:
|
|
273
|
+
|
|
274
|
+
- A stored value the field no longer accepts reads the default. That covers a
|
|
275
|
+
choice you removed, a number above a lowered maximum and a value of a changed
|
|
276
|
+
type, including one the new field would only accept by converting it, such as
|
|
277
|
+
a list stored for what is now a text field.
|
|
278
|
+
- A stored preference or group that is no longer declared is ignored. It stays
|
|
279
|
+
in the stored dictionary until its group is saved.
|
|
280
|
+
- A stored dictionary, or a group's entry in it, that is not a dictionary reads
|
|
281
|
+
as if nothing were stored.
|
|
282
|
+
- Values are read one by one, so a stored combination that the form's `clean()`
|
|
283
|
+
would now reject still reads value by value.
|
|
284
|
+
|
|
285
|
+
Saving a group with `change` or `apply` rewrites that group's stored values
|
|
286
|
+
from the form, so its stale values are cleared. Other groups are left alone.
|
|
287
|
+
Renaming a preference therefore reads the new name's default until a person sets
|
|
288
|
+
it again.
|
|
289
|
+
|
|
290
|
+
## The preferences page
|
|
291
|
+
|
|
292
|
+
`PreferencesView` is a page where a person changes a record's preferences. The
|
|
293
|
+
package ships no URL, so you route it in your own `urls.py` and name the model
|
|
294
|
+
with `as_view(model=...)`:
|
|
295
|
+
|
|
296
|
+
```python
|
|
297
|
+
# urls.py
|
|
298
|
+
from django.urls import path
|
|
299
|
+
from object_preferences.views import PreferencesView
|
|
300
|
+
from projects.models import Project
|
|
301
|
+
|
|
302
|
+
urlpatterns = [
|
|
303
|
+
path(
|
|
304
|
+
"projects/<int:pk>/preferences/",
|
|
305
|
+
PreferencesView.as_view(model=Project),
|
|
306
|
+
name="project-preferences",
|
|
307
|
+
),
|
|
308
|
+
]
|
|
309
|
+
```
|
|
310
|
+
|
|
311
|
+
It is a Django `DetailView`, so the record is found by `pk` or `slug`, an
|
|
312
|
+
unknown record is a 404, and `queryset` or `get_queryset()` limits which
|
|
313
|
+
records the page can open. The page shows every group declared for the model as
|
|
314
|
+
a fieldset of its own, each control starting from the stored value or the
|
|
315
|
+
default. A model with no preference field raises `ImproperlyConfigured` naming
|
|
316
|
+
the model, and a record whose model declares no groups opens with no controls.
|
|
317
|
+
|
|
318
|
+
Saving is all or nothing. The page validates every group with its own form. If
|
|
319
|
+
any is invalid it saves nothing and shows the page again with the values the
|
|
320
|
+
person entered and every error at once. Otherwise it applies each group and
|
|
321
|
+
writes the preferences with one `save`, which touches no other field. Groups
|
|
322
|
+
that declare a preference of the same name keep separate values, because each
|
|
323
|
+
group's controls are prefixed with the group's name.
|
|
324
|
+
|
|
325
|
+
After a save the person is sent back to the same page. Set `success_url` to
|
|
326
|
+
send them elsewhere:
|
|
327
|
+
|
|
328
|
+
```python
|
|
329
|
+
PreferencesView.as_view(model=Project, success_url="/projects/")
|
|
330
|
+
```
|
|
331
|
+
|
|
332
|
+
A success message is added at the `SUCCESS` level when Django's messages
|
|
333
|
+
framework is installed, and saving works without it. Change the wording with
|
|
334
|
+
`success_message`.
|
|
335
|
+
|
|
336
|
+
The page is the template `object_preferences/preferences_form.html`, a plain
|
|
337
|
+
HTML document with no styling. To use your own, put a template of that name
|
|
338
|
+
earlier on the template path, or set `template_name` on the view. It receives
|
|
339
|
+
`record`, the record, and `forms`, a dictionary of each group's form by group
|
|
340
|
+
name. Render each form inside its own `<fieldset>`, and include
|
|
341
|
+
`{% csrf_token %}` in the `<form>`. The view checks the token itself, so a
|
|
342
|
+
forged post is refused even when the CSRF middleware is not installed.
|
|
343
|
+
|
|
344
|
+
### Deciding who may use the page
|
|
345
|
+
|
|
346
|
+
The page applies no access rule of its own. A page you route with no rule is
|
|
347
|
+
open to anyone who can reach its address, who can then read and change that
|
|
348
|
+
record's preferences. Decide who may use it before you route it.
|
|
349
|
+
|
|
350
|
+
Because `PreferencesView` is an ordinary Django class-based view, Django's own
|
|
351
|
+
tools do the deciding and refuse the way they always do. To require a signed-in
|
|
352
|
+
person, add `LoginRequiredMixin` first:
|
|
353
|
+
|
|
354
|
+
```python
|
|
355
|
+
from django.contrib.auth.mixins import LoginRequiredMixin
|
|
356
|
+
from object_preferences.views import PreferencesView
|
|
357
|
+
|
|
358
|
+
|
|
359
|
+
class ProjectPreferencesView(LoginRequiredMixin, PreferencesView):
|
|
360
|
+
model = Project
|
|
361
|
+
```
|
|
362
|
+
|
|
363
|
+
To require a permission, use `PermissionRequiredMixin`. A person without it
|
|
364
|
+
gets a 403 response, and an anonymous visitor is sent to sign in:
|
|
365
|
+
|
|
366
|
+
```python
|
|
367
|
+
from django.contrib.auth.mixins import PermissionRequiredMixin
|
|
368
|
+
|
|
369
|
+
|
|
370
|
+
class ProjectPreferencesView(PermissionRequiredMixin, PreferencesView):
|
|
371
|
+
model = Project
|
|
372
|
+
permission_required = "projects.change_project"
|
|
373
|
+
```
|
|
374
|
+
|
|
375
|
+
To let a person change only some records, test them against the record with
|
|
376
|
+
`UserPassesTestMixin`. Here only the person whose record it is may use the
|
|
377
|
+
page:
|
|
378
|
+
|
|
379
|
+
```python
|
|
380
|
+
from django.contrib.auth.mixins import UserPassesTestMixin
|
|
381
|
+
|
|
382
|
+
|
|
383
|
+
class OwnPreferencesView(UserPassesTestMixin, PreferencesView):
|
|
384
|
+
model = User
|
|
385
|
+
|
|
386
|
+
def test_func(self):
|
|
387
|
+
return self.request.user == self.get_object()
|
|
388
|
+
```
|
|
389
|
+
|
|
390
|
+
A test that looks the record up runs before the refusal, so a person who is
|
|
391
|
+
refused can still tell a record that exists (403, or a redirect to sign in)
|
|
392
|
+
from one that does not (404). Where that matters, limit the records in
|
|
393
|
+
`get_queryset()` as below, and every record a person may not use is a 404.
|
|
394
|
+
|
|
395
|
+
To limit which records the page can open at all, limit them in
|
|
396
|
+
`get_queryset()`. A record outside the queryset is a 404:
|
|
397
|
+
|
|
398
|
+
```python
|
|
399
|
+
class OwnProjectPreferencesView(LoginRequiredMixin, PreferencesView):
|
|
400
|
+
model = Project
|
|
401
|
+
|
|
402
|
+
def get_queryset(self):
|
|
403
|
+
return Project.objects.filter(owner=self.request.user)
|
|
404
|
+
```
|
|
405
|
+
|
|
406
|
+
If you route the view with `as_view()` instead of subclassing it, wrap it with
|
|
407
|
+
Django's decorators:
|
|
408
|
+
|
|
409
|
+
```python
|
|
410
|
+
from django.contrib.auth.decorators import login_required, permission_required
|
|
411
|
+
|
|
412
|
+
path(
|
|
413
|
+
"projects/<int:pk>/preferences/",
|
|
414
|
+
login_required(
|
|
415
|
+
permission_required("projects.change_project", raise_exception=True)(
|
|
416
|
+
PreferencesView.as_view(model=Project)
|
|
417
|
+
)
|
|
418
|
+
),
|
|
419
|
+
name="project-preferences",
|
|
420
|
+
)
|
|
421
|
+
```
|
|
422
|
+
|
|
423
|
+
### A page for each person's own preferences
|
|
424
|
+
|
|
425
|
+
The common case is a page where each signed-in person changes their own
|
|
426
|
+
preferences, at one address with no record id. Return the signed-in person from
|
|
427
|
+
`get_object()`, so the page needs no `model`, and require sign-in:
|
|
428
|
+
|
|
429
|
+
```python
|
|
430
|
+
from django.contrib.auth.mixins import LoginRequiredMixin
|
|
431
|
+
from object_preferences.views import PreferencesView
|
|
432
|
+
|
|
433
|
+
|
|
434
|
+
class MyPreferencesView(LoginRequiredMixin, PreferencesView):
|
|
435
|
+
"""Show the signed-in person their own preferences."""
|
|
436
|
+
|
|
437
|
+
def get_object(self, queryset=None):
|
|
438
|
+
"""Return the signed-in person as the record the page is for.
|
|
439
|
+
|
|
440
|
+
Args:
|
|
441
|
+
queryset: Unused, as the record is not looked up.
|
|
442
|
+
|
|
443
|
+
Returns:
|
|
444
|
+
The signed-in person.
|
|
445
|
+
"""
|
|
446
|
+
return self.request.user
|
|
447
|
+
```
|
|
448
|
+
|
|
449
|
+
Route it at a path with no record id:
|
|
450
|
+
|
|
451
|
+
```python
|
|
452
|
+
# urls.py
|
|
453
|
+
urlpatterns = [
|
|
454
|
+
path("preferences/", MyPreferencesView.as_view(), name="my-preferences"),
|
|
455
|
+
]
|
|
456
|
+
```
|
|
457
|
+
|
|
458
|
+
Your user model must carry a `PreferencesField`, and the groups must be
|
|
459
|
+
registered for it. An anonymous visitor is sent to `LOGIN_URL`, and a signed-in
|
|
460
|
+
person can only ever reach their own record, because no other is looked up.
|
|
461
|
+
|
|
462
|
+
## Example project
|
|
463
|
+
|
|
464
|
+
The repository holds a small Django project in `example/`. It declares groups
|
|
465
|
+
for a project and for a person, reads them, changes them from code, and routes
|
|
466
|
+
both kinds of preferences page: a project's page behind a permission, and each
|
|
467
|
+
person's own page. From a checkout:
|
|
468
|
+
|
|
469
|
+
```bash
|
|
470
|
+
uv run python manage.py migrate
|
|
471
|
+
uv run python manage.py seed_example
|
|
472
|
+
uv run python manage.py runserver
|
|
473
|
+
```
|
|
474
|
+
|
|
475
|
+
Open the address it prints to see four projects, the preferences each one
|
|
476
|
+
reads and the dictionary each one stores, which holds only what was changed.
|
|
477
|
+
The last project stored a theme that the form no longer offers, so it reads the
|
|
478
|
+
default theme while the stored column still shows the old value. Each project
|
|
479
|
+
links to its preferences page, and "Your preferences" opens the page for the
|
|
480
|
+
person signed in.
|
|
481
|
+
|
|
482
|
+
`seed_example` also creates four accounts, each with the password `password`:
|
|
483
|
+
|
|
484
|
+
| Email | Who they are |
|
|
485
|
+
| --- | --- |
|
|
486
|
+
| `regular.user@example.com` | A person with no permissions. A project's page refuses them. |
|
|
487
|
+
| `regular.user2@example.com` | The same, with a stored notification choice of their own. |
|
|
488
|
+
| `staff.user@example.com` | May change projects, so may use a project's page. |
|
|
489
|
+
| `super.user@example.com` | A superuser. |
|
|
490
|
+
|
|
491
|
+
Running it again deletes and recreates the projects and the accounts.
|
|
492
|
+
|
|
493
|
+
## Contributing
|
|
494
|
+
|
|
495
|
+
Standards for this repository live in
|
|
496
|
+
[CONSTITUTION.md](https://github.com/django-mvp/django-object-preferences/blob/main/CONSTITUTION.md),
|
|
497
|
+
and the vocabulary to use in issues and commits lives in
|
|
498
|
+
[CONTEXT.md](https://github.com/django-mvp/django-object-preferences/blob/main/CONTEXT.md).
|
|
499
|
+
|
|
500
|
+
```bash
|
|
501
|
+
uv sync
|
|
502
|
+
uv run pytest
|
|
503
|
+
uv run pre-commit install
|
|
504
|
+
```
|
|
505
|
+
|
|
506
|
+
## Changelog
|
|
507
|
+
|
|
508
|
+
See [CHANGELOG.md](https://github.com/django-mvp/django-object-preferences/blob/main/CHANGELOG.md).
|
|
509
|
+
|
|
510
|
+
## License
|
|
511
|
+
|
|
512
|
+
MIT. See [LICENSE](https://github.com/django-mvp/django-object-preferences/blob/main/LICENSE).
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
object_preferences/__init__.py,sha256=c1DlKcKrwMHjcW5ViiZ85p4vVD1LiPTCL9DNxk9ZOJI,76
|
|
2
|
+
object_preferences/apps.py,sha256=8awk4MYgkfMZoFCx555XMmudywFIaUWxv7ouHMgeGTc,910
|
|
3
|
+
object_preferences/fields.py,sha256=ei1Ig2ZfjCmSCC1X0oaKaEAxZyTk1JPXew9SQdOXvto,9909
|
|
4
|
+
object_preferences/registry.py,sha256=fahKSQpLh07zHC8705j_EEGjShfyYEjx25T3vLNN97Q,10421
|
|
5
|
+
object_preferences/views.py,sha256=fFVfNg6mNMtzD7KL9QOm3OfbIDOP48KpMVIVmPd0zBc,4761
|
|
6
|
+
object_preferences/locale/en/LC_MESSAGES/django.po,sha256=mJqA3Vgj9Npv2qzfsafziHQyDy9M0Axrp3ZSRQDX5-w,1174
|
|
7
|
+
object_preferences/templates/object_preferences/preferences_form.html,sha256=R-3zJ74raSDALwWbAkVrcv_e_xkVVWZZ3aP_3ZW2RmE,879
|
|
8
|
+
django_object_preferences-0.1.0.dist-info/METADATA,sha256=yycDq39lnsMqcI9n7C_NscTT7JcQin1ZJsQXbJX0ni0,20828
|
|
9
|
+
django_object_preferences-0.1.0.dist-info/WHEEL,sha256=W3fkpkm7-wf9vBI5Z-7s0eWkeM-spu78I8Neb98DeEg,87
|
|
10
|
+
django_object_preferences-0.1.0.dist-info/licenses/LICENSE,sha256=qemtG2b6JtnfvKfiv-3gDEqeaJDv7GBWvcC4a7Qkb74,1072
|
|
11
|
+
django_object_preferences-0.1.0.dist-info/RECORD,,
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Samuel Jennings
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
"""Preferences stored on any Django model instance, declared with forms."""
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
"""App configuration for django-object-preferences."""
|
|
2
|
+
|
|
3
|
+
from django.apps import AppConfig
|
|
4
|
+
from django.utils.module_loading import autodiscover_modules
|
|
5
|
+
from django.utils.translation import gettext_lazy as _
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
class ObjectPreferencesConfig(AppConfig):
|
|
9
|
+
"""What a project gets when it adds this package to INSTALLED_APPS.
|
|
10
|
+
|
|
11
|
+
The label is set explicitly rather than left to Django's default, which is
|
|
12
|
+
the last segment of the module path. Two installed apps whose paths end in
|
|
13
|
+
the same word collide on that default, and the error names neither of them
|
|
14
|
+
clearly.
|
|
15
|
+
"""
|
|
16
|
+
|
|
17
|
+
name = "object_preferences"
|
|
18
|
+
label = "object_preferences"
|
|
19
|
+
verbose_name = _("Object Preferences")
|
|
20
|
+
default_auto_field = "django.db.models.BigAutoField"
|
|
21
|
+
|
|
22
|
+
def ready(self) -> None:
|
|
23
|
+
"""Import each installed app's ``preferences`` module to register its groups."""
|
|
24
|
+
autodiscover_modules("preferences")
|
|
@@ -0,0 +1,252 @@
|
|
|
1
|
+
"""The model field that turns preferences on for a model."""
|
|
2
|
+
|
|
3
|
+
from collections.abc import Iterator, Mapping, Sequence
|
|
4
|
+
from types import MappingProxyType
|
|
5
|
+
from typing import Any
|
|
6
|
+
|
|
7
|
+
from django import forms
|
|
8
|
+
from django.core.exceptions import ValidationError
|
|
9
|
+
from django.core.serializers.json import DjangoJSONEncoder
|
|
10
|
+
from django.db import models
|
|
11
|
+
from django.db.models.query_utils import DeferredAttribute
|
|
12
|
+
from django.utils.translation import gettext_lazy as _
|
|
13
|
+
|
|
14
|
+
from object_preferences.registry import registry
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
class RecordPreferences(Mapping[str, Mapping[str, Any]]):
|
|
18
|
+
"""One record's preferences, as the record's attribute returns them.
|
|
19
|
+
|
|
20
|
+
It maps each group name declared for the record's model to a read-only
|
|
21
|
+
mapping of that group's values.
|
|
22
|
+
|
|
23
|
+
Attributes:
|
|
24
|
+
record: The record the preferences belong to.
|
|
25
|
+
field: The field that stores them.
|
|
26
|
+
"""
|
|
27
|
+
|
|
28
|
+
def __init__(self, record: models.Model, field: "PreferencesField") -> None:
|
|
29
|
+
"""Bind the accessor to a record and the field that stores its preferences.
|
|
30
|
+
|
|
31
|
+
Args:
|
|
32
|
+
record: The record the preferences belong to.
|
|
33
|
+
field: The field that stores them.
|
|
34
|
+
"""
|
|
35
|
+
self.record = record
|
|
36
|
+
self.field = field
|
|
37
|
+
|
|
38
|
+
@property
|
|
39
|
+
def stored(self) -> Any:
|
|
40
|
+
"""The record's stored dictionary, as it is stored."""
|
|
41
|
+
return self.record.__dict__[self.field.attname]
|
|
42
|
+
|
|
43
|
+
def __getitem__(self, name: str) -> Mapping[str, Any]:
|
|
44
|
+
"""Return one group's values, read-only.
|
|
45
|
+
|
|
46
|
+
Args:
|
|
47
|
+
name: The group's name.
|
|
48
|
+
|
|
49
|
+
Returns:
|
|
50
|
+
Each of the group's preferences by name, stored or default.
|
|
51
|
+
|
|
52
|
+
Raises:
|
|
53
|
+
KeyError: If no group of that name is declared for the record's model.
|
|
54
|
+
"""
|
|
55
|
+
group = registry.get_group(type(self.record), name)
|
|
56
|
+
stored = self.stored
|
|
57
|
+
return MappingProxyType(
|
|
58
|
+
group.read(
|
|
59
|
+
stored.get(name, {}) if isinstance(stored, dict) else {},
|
|
60
|
+
self.field.encoder,
|
|
61
|
+
)
|
|
62
|
+
)
|
|
63
|
+
|
|
64
|
+
def get_form(
|
|
65
|
+
self, name: str, /, data: Mapping[str, Any] | None = None, **kwargs: Any
|
|
66
|
+
) -> forms.BaseForm:
|
|
67
|
+
"""Return one group's form, starting from the record's current values.
|
|
68
|
+
|
|
69
|
+
Args:
|
|
70
|
+
name: The group's name.
|
|
71
|
+
data: Submitted data to bind the form to. The form is unbound when
|
|
72
|
+
omitted.
|
|
73
|
+
**kwargs: Further keyword arguments for the form. An ``initial``
|
|
74
|
+
is laid over the record's current values.
|
|
75
|
+
|
|
76
|
+
Returns:
|
|
77
|
+
An instance of the group's form.
|
|
78
|
+
|
|
79
|
+
Raises:
|
|
80
|
+
KeyError: If no group of that name is declared for the record's model.
|
|
81
|
+
"""
|
|
82
|
+
group = registry.get_group(type(self.record), name)
|
|
83
|
+
initial = {**self[name], **kwargs.pop("initial", {})}
|
|
84
|
+
return group.form_class(data=data, initial=initial, **kwargs)
|
|
85
|
+
|
|
86
|
+
def apply(self, name: str, form: forms.BaseForm) -> None:
|
|
87
|
+
"""Replace one group's stored values with those of a submitted form.
|
|
88
|
+
|
|
89
|
+
The record is changed in memory only. The group's stored values are
|
|
90
|
+
rewritten from the form, so a value the form does not declare is
|
|
91
|
+
dropped. Every other group's stored values are left as they are.
|
|
92
|
+
|
|
93
|
+
Args:
|
|
94
|
+
name: The group's name.
|
|
95
|
+
form: A bound form of the group's form class.
|
|
96
|
+
|
|
97
|
+
Raises:
|
|
98
|
+
KeyError: If no group of that name is declared for the record's model.
|
|
99
|
+
TypeError: If the form is not an instance of the group's form class.
|
|
100
|
+
ValueError: If the form is not bound to submitted data.
|
|
101
|
+
ValidationError: If the form is not valid, with the form's errors.
|
|
102
|
+
"""
|
|
103
|
+
group = registry.get_group(type(self.record), name)
|
|
104
|
+
if not isinstance(form, group.form_class):
|
|
105
|
+
raise TypeError(
|
|
106
|
+
f"Expected a {group.form_class.__name__} for group {name!r}, "
|
|
107
|
+
f"not a {type(form).__name__}."
|
|
108
|
+
)
|
|
109
|
+
if not form.is_bound:
|
|
110
|
+
raise ValueError(f"The form for group {name!r} is not bound to any data.")
|
|
111
|
+
if not form.is_valid():
|
|
112
|
+
raise ValidationError(form.errors.as_data())
|
|
113
|
+
stored = self.stored
|
|
114
|
+
changed = dict(stored) if isinstance(stored, dict) else {}
|
|
115
|
+
values = group.to_stored(form, self.field.encoder)
|
|
116
|
+
if values:
|
|
117
|
+
changed[name] = values
|
|
118
|
+
else:
|
|
119
|
+
changed.pop(name, None)
|
|
120
|
+
setattr(self.record, self.field.attname, changed)
|
|
121
|
+
|
|
122
|
+
def change(self, name: str, /, **values: Any) -> None:
|
|
123
|
+
"""Change some of one group's preferences, keeping the rest as they are.
|
|
124
|
+
|
|
125
|
+
The values are validated by the group's form. The record is changed in
|
|
126
|
+
memory only.
|
|
127
|
+
|
|
128
|
+
Args:
|
|
129
|
+
name: The group's name.
|
|
130
|
+
**values: The new value of each preference to change, by name.
|
|
131
|
+
|
|
132
|
+
Raises:
|
|
133
|
+
KeyError: If no group of that name is declared for the record's
|
|
134
|
+
model, or a value names a preference the group does not declare.
|
|
135
|
+
ValidationError: If the form does not accept a value.
|
|
136
|
+
"""
|
|
137
|
+
group = registry.get_group(type(self.record), name)
|
|
138
|
+
fields = group.form_class().fields
|
|
139
|
+
unknown = [key for key in values if key not in fields]
|
|
140
|
+
if unknown:
|
|
141
|
+
raise KeyError(
|
|
142
|
+
f"No preference {', '.join(map(repr, unknown))} is declared in "
|
|
143
|
+
f"group {name!r} of {type(self.record)._meta.label}."
|
|
144
|
+
)
|
|
145
|
+
data = {**self[name], **values}
|
|
146
|
+
# Typed values go to the form as they are: a datetime prepared as text
|
|
147
|
+
# in the active timezone can land in an hour that is ambiguous there.
|
|
148
|
+
# Only a JSONField needs its value as JSON text.
|
|
149
|
+
for key, field in fields.items():
|
|
150
|
+
if isinstance(field, forms.JSONField):
|
|
151
|
+
data[key] = field.prepare_value(data[key])
|
|
152
|
+
self.apply(name, self.get_form(name, data=data))
|
|
153
|
+
|
|
154
|
+
def save(self) -> None:
|
|
155
|
+
"""Write the record's preferences to the database, and nothing else.
|
|
156
|
+
|
|
157
|
+
Raises:
|
|
158
|
+
ValueError: If the record has no primary key.
|
|
159
|
+
django.db.DatabaseError: If it has one but no row exists.
|
|
160
|
+
"""
|
|
161
|
+
self.record.save(update_fields=[self.field.attname], force_update=True)
|
|
162
|
+
|
|
163
|
+
def __iter__(self) -> Iterator[str]:
|
|
164
|
+
"""Iterate over the names of the groups declared for the record's model."""
|
|
165
|
+
return iter(group.name for group in registry.get_groups(type(self.record)))
|
|
166
|
+
|
|
167
|
+
def __len__(self) -> int:
|
|
168
|
+
"""Return how many groups are declared for the record's model."""
|
|
169
|
+
return len(registry.get_groups(type(self.record)))
|
|
170
|
+
|
|
171
|
+
|
|
172
|
+
class PreferencesDescriptor(DeferredAttribute):
|
|
173
|
+
"""Return a record's preferences accessor instead of the raw stored value."""
|
|
174
|
+
|
|
175
|
+
field: "PreferencesField"
|
|
176
|
+
|
|
177
|
+
def __get__(self, instance: models.Model | None, cls: Any = None) -> Any:
|
|
178
|
+
"""Return the accessor for a record, or the descriptor on the class."""
|
|
179
|
+
if instance is None:
|
|
180
|
+
return self
|
|
181
|
+
super().__get__(instance, cls)
|
|
182
|
+
return RecordPreferences(instance, self.field)
|
|
183
|
+
|
|
184
|
+
def __set__(self, instance: models.Model, value: Any) -> None:
|
|
185
|
+
"""Store a dictionary, or the stored dictionary of an accessor."""
|
|
186
|
+
instance.__dict__[self.field.attname] = PreferencesField.unwrap(value)
|
|
187
|
+
|
|
188
|
+
|
|
189
|
+
class PreferencesField(models.JSONField):
|
|
190
|
+
"""The one field a model needs to carry preferences.
|
|
191
|
+
|
|
192
|
+
It holds only the values a person has changed, as a dictionary of groups.
|
|
193
|
+
A record with nothing changed stores an empty dictionary.
|
|
194
|
+
"""
|
|
195
|
+
|
|
196
|
+
own_defaults: dict[str, Any] = {
|
|
197
|
+
"default": dict,
|
|
198
|
+
"blank": True,
|
|
199
|
+
"editable": False,
|
|
200
|
+
"encoder": DjangoJSONEncoder,
|
|
201
|
+
"verbose_name": _("preferences"),
|
|
202
|
+
"help_text": _("The preferences this record has changed from their defaults."),
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
descriptor_class = PreferencesDescriptor
|
|
206
|
+
|
|
207
|
+
def __init__(self, *args: Any, **kwargs: Any) -> None:
|
|
208
|
+
"""Apply this field's defaults to whatever the host did not set.
|
|
209
|
+
|
|
210
|
+
Args:
|
|
211
|
+
*args: Positional arguments for ``JSONField``.
|
|
212
|
+
**kwargs: Keyword arguments for ``JSONField``, which win over the
|
|
213
|
+
defaults.
|
|
214
|
+
"""
|
|
215
|
+
for key, value in self.own_defaults.items():
|
|
216
|
+
kwargs.setdefault(key, value)
|
|
217
|
+
super().__init__(*args, **kwargs)
|
|
218
|
+
|
|
219
|
+
def deconstruct(self) -> tuple[str, str, Sequence[Any], dict[str, Any]]:
|
|
220
|
+
"""Leave out each keyword argument that still holds the field's own value."""
|
|
221
|
+
name, path, args, kwargs = super().deconstruct()
|
|
222
|
+
# Django omits these when they equal its own default, which differs from ours.
|
|
223
|
+
for key in ("blank", "editable"):
|
|
224
|
+
kwargs.setdefault(key, getattr(self, key))
|
|
225
|
+
for key, value in self.own_defaults.items():
|
|
226
|
+
if key in kwargs and kwargs[key] == value:
|
|
227
|
+
del kwargs[key]
|
|
228
|
+
return name, path, args, kwargs
|
|
229
|
+
|
|
230
|
+
@staticmethod
|
|
231
|
+
def unwrap(value: Any) -> Any:
|
|
232
|
+
"""Return the stored dictionary when given an accessor, else the value.
|
|
233
|
+
|
|
234
|
+
Args:
|
|
235
|
+
value: A stored value or a ``RecordPreferences``.
|
|
236
|
+
|
|
237
|
+
Returns:
|
|
238
|
+
The value that is stored.
|
|
239
|
+
"""
|
|
240
|
+
return value.stored if isinstance(value, RecordPreferences) else value
|
|
241
|
+
|
|
242
|
+
def get_prep_value(self, value: Any) -> Any:
|
|
243
|
+
"""Unwrap an accessor before Django prepares the value for the database."""
|
|
244
|
+
return super().get_prep_value(self.unwrap(value))
|
|
245
|
+
|
|
246
|
+
def to_python(self, value: Any) -> Any:
|
|
247
|
+
"""Unwrap an accessor before Django converts the value."""
|
|
248
|
+
return super().to_python(self.unwrap(value))
|
|
249
|
+
|
|
250
|
+
def value_from_object(self, obj: models.Model) -> Any:
|
|
251
|
+
"""Return the stored dictionary rather than the accessor."""
|
|
252
|
+
return self.unwrap(super().value_from_object(obj))
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
# SOME DESCRIPTIVE TITLE.
|
|
2
|
+
# Copyright (C) YEAR THE PACKAGE'S COPYRIGHT HOLDER
|
|
3
|
+
# This file is distributed under the same license as the PACKAGE package.
|
|
4
|
+
# FIRST AUTHOR <EMAIL@ADDRESS>, YEAR.
|
|
5
|
+
#
|
|
6
|
+
#, fuzzy
|
|
7
|
+
msgid ""
|
|
8
|
+
msgstr ""
|
|
9
|
+
"Project-Id-Version: PACKAGE VERSION\n"
|
|
10
|
+
"Report-Msgid-Bugs-To: \n"
|
|
11
|
+
"POT-Creation-Date: 2026-10-15 07:20-0500\n"
|
|
12
|
+
"PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\n"
|
|
13
|
+
"Last-Translator: FULL NAME <EMAIL@ADDRESS>\n"
|
|
14
|
+
"Language-Team: LANGUAGE <LL@li.org>\n"
|
|
15
|
+
"Language: \n"
|
|
16
|
+
"MIME-Version: 1.0\n"
|
|
17
|
+
"Content-Type: text/plain; charset=UTF-8\n"
|
|
18
|
+
"Content-Transfer-Encoding: 8bit\n"
|
|
19
|
+
"Plural-Forms: nplurals=2; plural=(n != 1);\n"
|
|
20
|
+
|
|
21
|
+
#: apps.py:19
|
|
22
|
+
msgid "Object Preferences"
|
|
23
|
+
msgstr ""
|
|
24
|
+
|
|
25
|
+
#: fields.py:201
|
|
26
|
+
msgid "preferences"
|
|
27
|
+
msgstr ""
|
|
28
|
+
|
|
29
|
+
#: fields.py:202
|
|
30
|
+
msgid "The preferences this record has changed from their defaults."
|
|
31
|
+
msgstr ""
|
|
32
|
+
|
|
33
|
+
#: templates/object_preferences/preferences_form.html:7
|
|
34
|
+
msgid "Preferences"
|
|
35
|
+
msgstr ""
|
|
36
|
+
|
|
37
|
+
#: templates/object_preferences/preferences_form.html:11
|
|
38
|
+
#, python-format
|
|
39
|
+
msgid "Preferences for %(record)s"
|
|
40
|
+
msgstr ""
|
|
41
|
+
|
|
42
|
+
#: templates/object_preferences/preferences_form.html:25
|
|
43
|
+
msgid "Save"
|
|
44
|
+
msgstr ""
|
|
45
|
+
|
|
46
|
+
#: views.py:35
|
|
47
|
+
msgid "The preferences were saved."
|
|
48
|
+
msgstr ""
|
|
@@ -0,0 +1,288 @@
|
|
|
1
|
+
"""Preference groups and the registry that holds them."""
|
|
2
|
+
|
|
3
|
+
import contextlib
|
|
4
|
+
import json
|
|
5
|
+
from collections.abc import Callable
|
|
6
|
+
from dataclasses import dataclass
|
|
7
|
+
from typing import TYPE_CHECKING, Any
|
|
8
|
+
|
|
9
|
+
from django import forms
|
|
10
|
+
from django.core.exceptions import ImproperlyConfigured, ValidationError
|
|
11
|
+
from django.db import models
|
|
12
|
+
from django.utils.dateparse import parse_date, parse_datetime, parse_time
|
|
13
|
+
|
|
14
|
+
if TYPE_CHECKING:
|
|
15
|
+
from object_preferences.fields import PreferencesField
|
|
16
|
+
|
|
17
|
+
UNSUPPORTED_FIELDS = (
|
|
18
|
+
forms.ModelChoiceField,
|
|
19
|
+
forms.FileField,
|
|
20
|
+
forms.MultiValueField,
|
|
21
|
+
)
|
|
22
|
+
|
|
23
|
+
# The encoder stores these as ISO strings, which a field with its own
|
|
24
|
+
# input_formats may not accept, so they are parsed before the field cleans them.
|
|
25
|
+
TEMPORAL_PARSERS = (
|
|
26
|
+
(forms.DateTimeField, parse_datetime),
|
|
27
|
+
(forms.DateField, parse_date),
|
|
28
|
+
(forms.TimeField, parse_time),
|
|
29
|
+
)
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
@dataclass(frozen=True)
|
|
33
|
+
class PreferenceGroup:
|
|
34
|
+
"""A named form, declared by one app for one model, whose fields are preferences.
|
|
35
|
+
|
|
36
|
+
Attributes:
|
|
37
|
+
model: The model whose records carry the group.
|
|
38
|
+
name: The group's name, unique for the model.
|
|
39
|
+
form_class: The form that declares the group's preferences.
|
|
40
|
+
"""
|
|
41
|
+
|
|
42
|
+
model: type[models.Model]
|
|
43
|
+
name: str
|
|
44
|
+
form_class: type[forms.BaseForm]
|
|
45
|
+
|
|
46
|
+
def defaults(self, form: forms.BaseForm | None = None) -> dict[str, Any]:
|
|
47
|
+
"""Return what each preference reads when nothing is stored for it.
|
|
48
|
+
|
|
49
|
+
A preference's default is its field's initial value, or the value the
|
|
50
|
+
field gives an empty submission when it has none. A required field with
|
|
51
|
+
neither reads as empty rather than raising.
|
|
52
|
+
|
|
53
|
+
Args:
|
|
54
|
+
form: A form of the group to read the fields from. A fresh one is
|
|
55
|
+
made when omitted.
|
|
56
|
+
|
|
57
|
+
Returns:
|
|
58
|
+
Each preference's default by name.
|
|
59
|
+
"""
|
|
60
|
+
if form is None:
|
|
61
|
+
form = self.form_class()
|
|
62
|
+
defaults = {}
|
|
63
|
+
for name, field in form.fields.items():
|
|
64
|
+
value = form.get_initial_for_field(field, name)
|
|
65
|
+
if value is None:
|
|
66
|
+
value = field.widget.value_from_datadict({}, {}, name)
|
|
67
|
+
try:
|
|
68
|
+
defaults[name] = field.clean(value)
|
|
69
|
+
except ValidationError:
|
|
70
|
+
defaults[name] = field.to_python(value)
|
|
71
|
+
return defaults
|
|
72
|
+
|
|
73
|
+
def read(
|
|
74
|
+
self, stored: Any, encoder: type[json.JSONEncoder] | None
|
|
75
|
+
) -> dict[str, Any]:
|
|
76
|
+
"""Return every preference of the group, typed as its field produces it.
|
|
77
|
+
|
|
78
|
+
Args:
|
|
79
|
+
stored: The values stored for this group. Anything but a dictionary
|
|
80
|
+
reads as nothing stored. A stored value the field no longer
|
|
81
|
+
accepts, or would only accept by converting it, reads as the
|
|
82
|
+
default.
|
|
83
|
+
encoder: The JSON encoder of the record's preferences field.
|
|
84
|
+
|
|
85
|
+
Returns:
|
|
86
|
+
Each preference's value by name, stored or default.
|
|
87
|
+
"""
|
|
88
|
+
form = self.form_class()
|
|
89
|
+
values = self.defaults(form)
|
|
90
|
+
if not isinstance(stored, dict):
|
|
91
|
+
return values
|
|
92
|
+
for name, field in form.fields.items():
|
|
93
|
+
if name in stored:
|
|
94
|
+
# Stored data is untrusted and cleaning can raise more than
|
|
95
|
+
# ValidationError, so no value may break a read (FR-016, SC-004).
|
|
96
|
+
with contextlib.suppress(Exception):
|
|
97
|
+
value = stored[name]
|
|
98
|
+
for field_class, parser in TEMPORAL_PARSERS:
|
|
99
|
+
if isinstance(field, field_class) and isinstance(value, str):
|
|
100
|
+
value = parser(value) or value
|
|
101
|
+
cleaned = field.clean(value)
|
|
102
|
+
# A field converts some values it was never given, such as a
|
|
103
|
+
# list for a CharField, so only a value that stores back the
|
|
104
|
+
# same is the one that was written.
|
|
105
|
+
if self.encode(field, cleaned, encoder) == stored[name]:
|
|
106
|
+
values[name] = cleaned
|
|
107
|
+
return values
|
|
108
|
+
|
|
109
|
+
@staticmethod
|
|
110
|
+
def encode(
|
|
111
|
+
field: forms.Field, value: Any, encoder: type[json.JSONEncoder] | None
|
|
112
|
+
) -> Any:
|
|
113
|
+
"""Return a cleaned value as the database returns it once stored.
|
|
114
|
+
|
|
115
|
+
Args:
|
|
116
|
+
field: The form field the value was cleaned by.
|
|
117
|
+
value: The cleaned value.
|
|
118
|
+
encoder: The JSON encoder of the record's preferences field.
|
|
119
|
+
|
|
120
|
+
Returns:
|
|
121
|
+
The value after a round trip through JSON.
|
|
122
|
+
"""
|
|
123
|
+
# A JSONField value is kept as its JSON text, so a plain string reads
|
|
124
|
+
# back as itself. Every other value goes through the encoder whole,
|
|
125
|
+
# which keeps a datetime's offset.
|
|
126
|
+
if isinstance(field, forms.JSONField):
|
|
127
|
+
value = field.prepare_value(value)
|
|
128
|
+
return json.loads(json.dumps(value, cls=encoder))
|
|
129
|
+
|
|
130
|
+
def to_stored(
|
|
131
|
+
self, form: forms.BaseForm, encoder: type[json.JSONEncoder] | None
|
|
132
|
+
) -> dict[str, Any]:
|
|
133
|
+
"""Return what a valid form of the group leaves stored on a record.
|
|
134
|
+
|
|
135
|
+
Only values that differ from their default are kept, in the form the
|
|
136
|
+
database returns them, so that the record reads the same before and
|
|
137
|
+
after it is saved.
|
|
138
|
+
|
|
139
|
+
Args:
|
|
140
|
+
form: A valid form of the group.
|
|
141
|
+
encoder: The JSON encoder of the record's preferences field.
|
|
142
|
+
|
|
143
|
+
Returns:
|
|
144
|
+
The values to store, by preference name.
|
|
145
|
+
"""
|
|
146
|
+
defaults = self.defaults()
|
|
147
|
+
stored = {}
|
|
148
|
+
for name, field in form.fields.items():
|
|
149
|
+
value = form.cleaned_data[name]
|
|
150
|
+
if value != defaults[name]:
|
|
151
|
+
stored[name] = self.encode(field, value, encoder)
|
|
152
|
+
return stored
|
|
153
|
+
|
|
154
|
+
|
|
155
|
+
class PreferenceRegistry:
|
|
156
|
+
"""The preference groups declared for each model.
|
|
157
|
+
|
|
158
|
+
Attributes:
|
|
159
|
+
declarations: Each model's groups by name, in registration order.
|
|
160
|
+
"""
|
|
161
|
+
|
|
162
|
+
def __init__(self) -> None:
|
|
163
|
+
"""Start with nothing declared."""
|
|
164
|
+
self.declarations: dict[type[models.Model], dict[str, PreferenceGroup]] = {}
|
|
165
|
+
|
|
166
|
+
def get_field(self, model: type[models.Model]) -> "PreferencesField":
|
|
167
|
+
"""Return the one preferences field a model carries.
|
|
168
|
+
|
|
169
|
+
Args:
|
|
170
|
+
model: The model to look at.
|
|
171
|
+
|
|
172
|
+
Returns:
|
|
173
|
+
The model's ``PreferencesField``.
|
|
174
|
+
|
|
175
|
+
Raises:
|
|
176
|
+
ImproperlyConfigured: If the model has none, or more than one.
|
|
177
|
+
"""
|
|
178
|
+
# fields.py imports this module, so the import cannot sit at the top.
|
|
179
|
+
from object_preferences.fields import PreferencesField
|
|
180
|
+
|
|
181
|
+
fields = [
|
|
182
|
+
field
|
|
183
|
+
for field in model._meta.get_fields()
|
|
184
|
+
if isinstance(field, PreferencesField)
|
|
185
|
+
]
|
|
186
|
+
if len(fields) != 1:
|
|
187
|
+
raise ImproperlyConfigured(
|
|
188
|
+
f"{model._meta.label} must have exactly one PreferencesField "
|
|
189
|
+
f"to carry preferences, but has {len(fields)}."
|
|
190
|
+
)
|
|
191
|
+
return fields[0]
|
|
192
|
+
|
|
193
|
+
def register(
|
|
194
|
+
self, model: type[models.Model], name: str, form_class: type[forms.BaseForm]
|
|
195
|
+
) -> PreferenceGroup:
|
|
196
|
+
"""Declare a group of preferences for a model.
|
|
197
|
+
|
|
198
|
+
Args:
|
|
199
|
+
model: The model whose records carry the group.
|
|
200
|
+
name: The group's name, unique for the model.
|
|
201
|
+
form_class: The form whose fields are the group's preferences.
|
|
202
|
+
|
|
203
|
+
Returns:
|
|
204
|
+
The registered group.
|
|
205
|
+
|
|
206
|
+
Raises:
|
|
207
|
+
ImproperlyConfigured: If the model has no single preferences field,
|
|
208
|
+
the name is taken, or the form has a field that refers to other
|
|
209
|
+
records or carries an upload.
|
|
210
|
+
"""
|
|
211
|
+
try:
|
|
212
|
+
self.get_field(model)
|
|
213
|
+
except ImproperlyConfigured as error:
|
|
214
|
+
raise ImproperlyConfigured(
|
|
215
|
+
f"Cannot register group {name!r} with {form_class!r}: {error}"
|
|
216
|
+
) from error
|
|
217
|
+
taken = self.declarations.get(model, {}).get(name)
|
|
218
|
+
if taken is not None:
|
|
219
|
+
raise ImproperlyConfigured(
|
|
220
|
+
f"A preference group named {name!r} is already registered for "
|
|
221
|
+
f"{model._meta.label} with {taken.form_class!r}, so "
|
|
222
|
+
f"{form_class!r} cannot be registered under it."
|
|
223
|
+
)
|
|
224
|
+
for field_name, field in form_class().fields.items():
|
|
225
|
+
if isinstance(field, UNSUPPORTED_FIELDS):
|
|
226
|
+
raise ImproperlyConfigured(
|
|
227
|
+
f"{form_class.__name__}.{field_name} is a "
|
|
228
|
+
f"{type(field).__name__}, which cannot be a preference in "
|
|
229
|
+
f"group {name!r} of {model._meta.label}."
|
|
230
|
+
)
|
|
231
|
+
group = PreferenceGroup(model, name, form_class)
|
|
232
|
+
self.declarations.setdefault(model, {})[name] = group
|
|
233
|
+
return group
|
|
234
|
+
|
|
235
|
+
def get_groups(self, model: type[models.Model]) -> list[PreferenceGroup]:
|
|
236
|
+
"""List the groups declared for a model.
|
|
237
|
+
|
|
238
|
+
Args:
|
|
239
|
+
model: The model to list for. Parents and proxies are not included.
|
|
240
|
+
|
|
241
|
+
Returns:
|
|
242
|
+
The groups, in registration order.
|
|
243
|
+
"""
|
|
244
|
+
return list(self.declarations.get(model, {}).values())
|
|
245
|
+
|
|
246
|
+
def get_group(self, model: type[models.Model], name: str) -> PreferenceGroup:
|
|
247
|
+
"""Return one group declared for a model.
|
|
248
|
+
|
|
249
|
+
Args:
|
|
250
|
+
model: The model the group was declared for.
|
|
251
|
+
name: The group's name.
|
|
252
|
+
|
|
253
|
+
Returns:
|
|
254
|
+
The group.
|
|
255
|
+
|
|
256
|
+
Raises:
|
|
257
|
+
KeyError: If no group of that name was declared for the model.
|
|
258
|
+
"""
|
|
259
|
+
try:
|
|
260
|
+
return self.declarations[model][name]
|
|
261
|
+
except KeyError:
|
|
262
|
+
raise KeyError(
|
|
263
|
+
f"No preference group {name!r} is declared for {model._meta.label}."
|
|
264
|
+
) from None
|
|
265
|
+
|
|
266
|
+
|
|
267
|
+
registry = PreferenceRegistry()
|
|
268
|
+
|
|
269
|
+
|
|
270
|
+
def register(
|
|
271
|
+
model: type[models.Model], *, name: str
|
|
272
|
+
) -> Callable[[type[forms.Form]], type[forms.Form]]:
|
|
273
|
+
"""Register the decorated form as a preference group of the module registry.
|
|
274
|
+
|
|
275
|
+
Args:
|
|
276
|
+
model: The model whose records carry the group.
|
|
277
|
+
name: The group's name, unique for the model.
|
|
278
|
+
|
|
279
|
+
Returns:
|
|
280
|
+
A decorator that registers the form and returns it unchanged.
|
|
281
|
+
"""
|
|
282
|
+
|
|
283
|
+
def decorator(form_class: type[forms.Form]) -> type[forms.Form]:
|
|
284
|
+
"""Register the form and hand it back."""
|
|
285
|
+
registry.register(model, name, form_class)
|
|
286
|
+
return form_class
|
|
287
|
+
|
|
288
|
+
return decorator
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
{% load i18n %}{% get_current_language as language_code %}
|
|
2
|
+
<!DOCTYPE html>
|
|
3
|
+
<html lang="{{ language_code }}">
|
|
4
|
+
<head>
|
|
5
|
+
<meta charset="utf-8">
|
|
6
|
+
<meta name="viewport" content="width=device-width, initial-scale=1">
|
|
7
|
+
<title>{% translate "Preferences" %}</title>
|
|
8
|
+
</head>
|
|
9
|
+
<body>
|
|
10
|
+
<main>
|
|
11
|
+
<h1>{% blocktranslate %}Preferences for {{ record }}{% endblocktranslate %}</h1>
|
|
12
|
+
{% if messages %}
|
|
13
|
+
<ul role="status">
|
|
14
|
+
{% for message in messages %}<li>{{ message }}</li>{% endfor %}
|
|
15
|
+
</ul>
|
|
16
|
+
{% endif %}
|
|
17
|
+
<form method="post">
|
|
18
|
+
{% csrf_token %}
|
|
19
|
+
{% for name, form in forms.items %}
|
|
20
|
+
<fieldset>
|
|
21
|
+
<legend>{{ name|capfirst }}</legend>
|
|
22
|
+
{{ form }}
|
|
23
|
+
</fieldset>
|
|
24
|
+
{% endfor %}
|
|
25
|
+
<button type="submit">{% translate "Save" %}</button>
|
|
26
|
+
</form>
|
|
27
|
+
</main>
|
|
28
|
+
</body>
|
|
29
|
+
</html>
|
|
@@ -0,0 +1,129 @@
|
|
|
1
|
+
"""The page where a person changes a record's preferences."""
|
|
2
|
+
|
|
3
|
+
from typing import Any
|
|
4
|
+
|
|
5
|
+
from django.contrib import messages
|
|
6
|
+
from django.forms import BaseForm
|
|
7
|
+
from django.http import HttpRequest, HttpResponse, HttpResponseRedirect
|
|
8
|
+
from django.utils.decorators import method_decorator
|
|
9
|
+
from django.utils.translation import gettext_lazy as _
|
|
10
|
+
from django.views.decorators.csrf import csrf_protect
|
|
11
|
+
from django.views.generic import DetailView
|
|
12
|
+
|
|
13
|
+
from object_preferences.fields import RecordPreferences
|
|
14
|
+
from object_preferences.registry import registry
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
@method_decorator(csrf_protect, name="dispatch")
|
|
18
|
+
class PreferencesView(DetailView):
|
|
19
|
+
"""Show every preference group of a record's model and save them together.
|
|
20
|
+
|
|
21
|
+
Route it with ``as_view(model=...)``. The view applies no access rule, so a
|
|
22
|
+
host project composes Django's own mixins or decorators with it.
|
|
23
|
+
|
|
24
|
+
Attributes:
|
|
25
|
+
template_name: The template that renders the page.
|
|
26
|
+
context_object_name: The name of the record in the template context.
|
|
27
|
+
success_url: Where to send the person after saving. The page itself
|
|
28
|
+
when ``None``.
|
|
29
|
+
success_message: The message added after a save.
|
|
30
|
+
"""
|
|
31
|
+
|
|
32
|
+
template_name = "object_preferences/preferences_form.html"
|
|
33
|
+
context_object_name = "record"
|
|
34
|
+
success_url: str | None = None
|
|
35
|
+
success_message = _("The preferences were saved.")
|
|
36
|
+
|
|
37
|
+
def get_preferences(self) -> RecordPreferences:
|
|
38
|
+
"""Return the preferences of the record the page is for.
|
|
39
|
+
|
|
40
|
+
Returns:
|
|
41
|
+
The record's preferences accessor.
|
|
42
|
+
|
|
43
|
+
Raises:
|
|
44
|
+
ImproperlyConfigured: If the record's model has no preferences field.
|
|
45
|
+
"""
|
|
46
|
+
model = self.object._meta.model
|
|
47
|
+
preferences: RecordPreferences = getattr(
|
|
48
|
+
self.object, registry.get_field(model).attname
|
|
49
|
+
)
|
|
50
|
+
return preferences
|
|
51
|
+
|
|
52
|
+
def get_forms(self, data: Any = None) -> dict[str, BaseForm]:
|
|
53
|
+
"""Build the form of every group, prefixed with the group's name.
|
|
54
|
+
|
|
55
|
+
Args:
|
|
56
|
+
data: Submitted data to bind the forms to. They are unbound when
|
|
57
|
+
omitted.
|
|
58
|
+
|
|
59
|
+
Returns:
|
|
60
|
+
Each group's form by group name, in registration order.
|
|
61
|
+
"""
|
|
62
|
+
preferences = self.get_preferences()
|
|
63
|
+
return {
|
|
64
|
+
name: preferences.get_form(name, data=data, prefix=name)
|
|
65
|
+
for name in preferences
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
def get_context_data(self, **kwargs: Any) -> dict[str, Any]:
|
|
69
|
+
"""Add the forms of the record's groups unless they were passed in."""
|
|
70
|
+
if "forms" not in kwargs:
|
|
71
|
+
kwargs["forms"] = self.get_forms()
|
|
72
|
+
return super().get_context_data(**kwargs)
|
|
73
|
+
|
|
74
|
+
def post(self, request: HttpRequest, *args: Any, **kwargs: Any) -> HttpResponse:
|
|
75
|
+
"""Validate every group's form, then save them all or none.
|
|
76
|
+
|
|
77
|
+
Args:
|
|
78
|
+
request: The request, whose POST data holds every group's values.
|
|
79
|
+
*args: Positional arguments from the URL.
|
|
80
|
+
**kwargs: Keyword arguments from the URL.
|
|
81
|
+
|
|
82
|
+
Returns:
|
|
83
|
+
A redirect when every group is valid, else the page with the errors.
|
|
84
|
+
"""
|
|
85
|
+
self.object = self.get_object()
|
|
86
|
+
forms = self.get_forms(data=request.POST)
|
|
87
|
+
# A list rather than all() over a generator, so that every form is
|
|
88
|
+
# validated before deciding, not only those up to the first invalid one.
|
|
89
|
+
valid = [form.is_valid() for form in forms.values()]
|
|
90
|
+
if all(valid):
|
|
91
|
+
return self.forms_valid(forms)
|
|
92
|
+
return self.forms_invalid(forms)
|
|
93
|
+
|
|
94
|
+
def forms_valid(self, forms: dict[str, BaseForm]) -> HttpResponse:
|
|
95
|
+
"""Apply every group's form to the record, save once and redirect.
|
|
96
|
+
|
|
97
|
+
Args:
|
|
98
|
+
forms: Each group's valid form by group name.
|
|
99
|
+
|
|
100
|
+
Returns:
|
|
101
|
+
A redirect to the success URL.
|
|
102
|
+
"""
|
|
103
|
+
preferences = self.get_preferences()
|
|
104
|
+
for name, form in forms.items():
|
|
105
|
+
preferences.apply(name, form)
|
|
106
|
+
preferences.save()
|
|
107
|
+
messages.success(self.request, self.success_message, fail_silently=True)
|
|
108
|
+
return HttpResponseRedirect(self.get_success_url())
|
|
109
|
+
|
|
110
|
+
def forms_invalid(self, forms: dict[str, BaseForm]) -> HttpResponse:
|
|
111
|
+
"""Show the page again with the forms and their errors.
|
|
112
|
+
|
|
113
|
+
Args:
|
|
114
|
+
forms: Each group's bound form by group name.
|
|
115
|
+
|
|
116
|
+
Returns:
|
|
117
|
+
The rendered page.
|
|
118
|
+
"""
|
|
119
|
+
return self.render_to_response(self.get_context_data(forms=forms))
|
|
120
|
+
|
|
121
|
+
def get_success_url(self) -> str:
|
|
122
|
+
"""Send the person to ``success_url``, or back to this page.
|
|
123
|
+
|
|
124
|
+
Returns:
|
|
125
|
+
The URL to redirect to after a save.
|
|
126
|
+
"""
|
|
127
|
+
if self.success_url:
|
|
128
|
+
return str(self.success_url)
|
|
129
|
+
return self.request.path
|