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.
@@ -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
+ [![Tests](https://github.com/django-mvp/django-object-preferences/actions/workflows/tests.yml/badge.svg)](https://github.com/django-mvp/django-object-preferences/actions/workflows/tests.yml)
31
+ [![Build](https://github.com/django-mvp/django-object-preferences/actions/workflows/build.yml/badge.svg)](https://github.com/django-mvp/django-object-preferences/actions/workflows/build.yml)
32
+ [![Publish](https://github.com/django-mvp/django-object-preferences/actions/workflows/publish.yml/badge.svg)](https://github.com/django-mvp/django-object-preferences/actions/workflows/publish.yml)
33
+ [![Coverage](https://codecov.io/gh/django-mvp/django-object-preferences/branch/main/graph/badge.svg)](https://codecov.io/gh/django-mvp/django-object-preferences)
34
+ [![PyPI](https://img.shields.io/pypi/v/django-object-preferences)](https://pypi.org/project/django-object-preferences/)
35
+ [![Development status](https://img.shields.io/pypi/status/django-object-preferences)](https://pypi.org/project/django-object-preferences/)
36
+ [![Python](https://img.shields.io/pypi/pyversions/django-object-preferences)](https://pypi.org/project/django-object-preferences/)
37
+ [![Django](https://img.shields.io/pypi/djversions/django-object-preferences)](https://pypi.org/project/django-object-preferences/)
38
+ [![Ruff](https://img.shields.io/endpoint?url=https://raw.githubusercontent.com/astral-sh/ruff/main/assets/badge/v2.json)](https://github.com/astral-sh/ruff)
39
+ [![Licence](https://img.shields.io/github/license/django-mvp/django-object-preferences)](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,4 @@
1
+ Wheel-Version: 1.0
2
+ Generator: hatchling 1.32.4
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
@@ -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