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.
@@ -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
+ [![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).