django-object-preferences 0.1.0__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- django_object_preferences-0.1.0/.gitignore +66 -0
- django_object_preferences-0.1.0/LICENSE +21 -0
- django_object_preferences-0.1.0/PKG-INFO +512 -0
- django_object_preferences-0.1.0/README.md +485 -0
- django_object_preferences-0.1.0/object_preferences/__init__.py +1 -0
- django_object_preferences-0.1.0/object_preferences/apps.py +24 -0
- django_object_preferences-0.1.0/object_preferences/fields.py +252 -0
- django_object_preferences-0.1.0/object_preferences/locale/en/LC_MESSAGES/django.po +48 -0
- django_object_preferences-0.1.0/object_preferences/registry.py +288 -0
- django_object_preferences-0.1.0/object_preferences/templates/object_preferences/preferences_form.html +29 -0
- django_object_preferences-0.1.0/object_preferences/views.py +129 -0
- django_object_preferences-0.1.0/pyproject.toml +128 -0
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
# Temporary and binary files
|
|
2
|
+
*~
|
|
3
|
+
*.ruff_cache
|
|
4
|
+
*.mypy_cache
|
|
5
|
+
*.py[cod]
|
|
6
|
+
*.so
|
|
7
|
+
*.cfg
|
|
8
|
+
!.isort.cfg
|
|
9
|
+
!setup.cfg
|
|
10
|
+
*.orig
|
|
11
|
+
*.log
|
|
12
|
+
*.pot
|
|
13
|
+
**/__pycache__
|
|
14
|
+
.cache/*
|
|
15
|
+
.*.swp
|
|
16
|
+
*/.ipynb_checkpoints/*
|
|
17
|
+
.DS_Store
|
|
18
|
+
.vscode/
|
|
19
|
+
.github/instructions/
|
|
20
|
+
docs/api/
|
|
21
|
+
|
|
22
|
+
# Unittest and coverage
|
|
23
|
+
htmlcov
|
|
24
|
+
.coverage
|
|
25
|
+
.coverage.*
|
|
26
|
+
.tox
|
|
27
|
+
junit*.xml
|
|
28
|
+
coverage.xml
|
|
29
|
+
.pytest_cache/
|
|
30
|
+
|
|
31
|
+
# Build and docs folder/files
|
|
32
|
+
build/*
|
|
33
|
+
dist/*
|
|
34
|
+
sdist/*
|
|
35
|
+
docs/_rst/*
|
|
36
|
+
docs/_build/*
|
|
37
|
+
cover/*
|
|
38
|
+
MANIFEST
|
|
39
|
+
|
|
40
|
+
# Per-project virtualenvs
|
|
41
|
+
.venv*/
|
|
42
|
+
.conda*/
|
|
43
|
+
.python-version
|
|
44
|
+
|
|
45
|
+
# Django stuff:
|
|
46
|
+
*.log
|
|
47
|
+
local_settings.py
|
|
48
|
+
db.sqlite3
|
|
49
|
+
db.sqlite3-journal
|
|
50
|
+
media/
|
|
51
|
+
/static/
|
|
52
|
+
/*.sqlite3
|
|
53
|
+
|
|
54
|
+
# */static/vendor/*
|
|
55
|
+
|
|
56
|
+
# Environments
|
|
57
|
+
.env
|
|
58
|
+
.venv
|
|
59
|
+
env/
|
|
60
|
+
venv/
|
|
61
|
+
ENV/
|
|
62
|
+
env.bak/
|
|
63
|
+
venv.bak/
|
|
64
|
+
|
|
65
|
+
dist
|
|
66
|
+
example/db.sqlite3
|
|
@@ -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,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).
|