django-modelsheet 0.3.0a1__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.
Files changed (40) hide show
  1. django_modelsheet-0.3.0a1.dist-info/METADATA +196 -0
  2. django_modelsheet-0.3.0a1.dist-info/RECORD +40 -0
  3. django_modelsheet-0.3.0a1.dist-info/WHEEL +5 -0
  4. django_modelsheet-0.3.0a1.dist-info/licenses/LICENSE +21 -0
  5. django_modelsheet-0.3.0a1.dist-info/top_level.txt +1 -0
  6. modelsheet/__init__.py +1 -0
  7. modelsheet/adapters.py +121 -0
  8. modelsheet/admin.py +325 -0
  9. modelsheet/apps.py +14 -0
  10. modelsheet/background.py +44 -0
  11. modelsheet/bulk.py +410 -0
  12. modelsheet/checks.py +90 -0
  13. modelsheet/conf.py +42 -0
  14. modelsheet/errors.py +18 -0
  15. modelsheet/formula/__init__.py +3 -0
  16. modelsheet/formula/engine.py +392 -0
  17. modelsheet/locale/ru/LC_MESSAGES/django.mo +0 -0
  18. modelsheet/locale/ru/LC_MESSAGES/django.po +8 -0
  19. modelsheet/locale/ru/LC_MESSAGES/djangojs.mo +0 -0
  20. modelsheet/locale/ru/LC_MESSAGES/djangojs.po +188 -0
  21. modelsheet/management/__init__.py +0 -0
  22. modelsheet/management/commands/__init__.py +0 -0
  23. modelsheet/management/commands/modelsheet_cleanup.py +24 -0
  24. modelsheet/management/commands/modelsheet_roles.py +47 -0
  25. modelsheet/metadata.py +42 -0
  26. modelsheet/migrations/0001_initial.py +114 -0
  27. modelsheet/migrations/__init__.py +0 -0
  28. modelsheet/models.py +63 -0
  29. modelsheet/permissions.py +72 -0
  30. modelsheet/py.typed +0 -0
  31. modelsheet/query.py +133 -0
  32. modelsheet/services.py +490 -0
  33. modelsheet/static/modelsheet/THIRD_PARTY_LICENSES.txt +49 -0
  34. modelsheet/static/modelsheet/sheet.css +1 -0
  35. modelsheet/static/modelsheet/sheet.js +13482 -0
  36. modelsheet/templates/modelsheet/audit.html +14 -0
  37. modelsheet/templates/modelsheet/change_list.html +6 -0
  38. modelsheet/templates/modelsheet/sheet.html +11 -0
  39. modelsheet/templates/modelsheet/unavailable.html +2 -0
  40. modelsheet/views.py +91 -0
@@ -0,0 +1,196 @@
1
+ Metadata-Version: 2.4
2
+ Name: django-modelsheet
3
+ Version: 0.3.0a1
4
+ Summary: Spreadsheet editing, formulas and previewed bulk operations for Django Admin
5
+ License-Expression: MIT
6
+ Project-URL: Repository, https://github.com/svalench/django-modelsheet
7
+ Project-URL: Documentation, https://github.com/svalench/django-modelsheet/blob/main/README.md
8
+ Project-URL: Issues, https://github.com/svalench/django-modelsheet/issues
9
+ Project-URL: Changelog, https://github.com/svalench/django-modelsheet/blob/main/CHANGELOG.md
10
+ Classifier: Development Status :: 3 - Alpha
11
+ Classifier: Framework :: Django
12
+ Classifier: Framework :: Django :: 5.2
13
+ Classifier: Framework :: Django :: 6.0
14
+ Classifier: Framework :: Django :: 6.1
15
+ Classifier: Programming Language :: Python :: 3
16
+ Requires-Python: >=3.11
17
+ Description-Content-Type: text/markdown
18
+ License-File: LICENSE
19
+ Requires-Dist: Django<6.2,>=5.2
20
+ Provides-Extra: test
21
+ Requires-Dist: pytest>=8; extra == "test"
22
+ Requires-Dist: pytest-django>=4.9; extra == "test"
23
+ Requires-Dist: pytest-timeout>=2; extra == "test"
24
+ Requires-Dist: build; extra == "test"
25
+ Requires-Dist: ruff; extra == "test"
26
+ Requires-Dist: playwright; extra == "test"
27
+ Provides-Extra: postgres
28
+ Requires-Dist: psycopg[binary]>=3.2; extra == "postgres"
29
+ Provides-Extra: mysql
30
+ Requires-Dist: mysqlclient>=2.2; extra == "mysql"
31
+ Dynamic: license-file
32
+
33
+ # django-modelsheet
34
+
35
+ Spreadsheet editing for explicitly registered Django Admin models: cell/range editing,
36
+ local undo/redo, Decimal formulas, server-side filters, and previewed bulk changes.
37
+
38
+ **0.3.0a1 is an alpha.** The repository includes a working demo, compiled assets,
39
+ tests, and a compatibility CI matrix. A published PyPI release is not implied.
40
+
41
+ ## Quickstart
42
+
43
+ Install a built wheel (or `pip install .` from this repository). After publication:
44
+
45
+ ```sh
46
+ pip install django-modelsheet
47
+ ```
48
+
49
+ ```python
50
+ # settings.py
51
+ INSTALLED_APPS += ["modelsheet"]
52
+ MODELSHEET = {"models": ["shop.Product"], "page_size": 200}
53
+
54
+ # SQLite only: required for safe writes; PostgreSQL/MySQL use row locks.
55
+ DATABASES["default"].setdefault("OPTIONS", {})["transaction_mode"] = "IMMEDIATE"
56
+ ```
57
+
58
+ ```python
59
+ # admin.py
60
+ from django.contrib import admin
61
+ from modelsheet.admin import ModelSheetAdmin
62
+ from .models import Product
63
+
64
+ @admin.register(Product)
65
+ class ProductAdmin(ModelSheetAdmin):
66
+ list_display = ("name", "price", "stock")
67
+ search_fields = ("name",)
68
+ sheet_fields = ("name", "price", "stock")
69
+ sheet_editable = ("price", "stock")
70
+ sheet_computed_fields = {"inventory_value": "=[price]*[stock]"}
71
+ ```
72
+
73
+ ```sh
74
+ python manage.py migrate
75
+ python manage.py collectstatic --noinput
76
+ python manage.py modelsheet_roles # optional: creates groups, assigns no users
77
+ ```
78
+
79
+ Assign a group through Django Admin and mark the account as staff. Superusers can
80
+ use configured sheets immediately. Open **Spreadsheet** from the model list, or
81
+ visit `/admin/shop/product/sheet/`. No frontend build, CDN or extra URL include is
82
+ required in the consuming project. Admin's existing sessions and CSRF protection apply.
83
+
84
+ ## Using the sheet
85
+
86
+ - Double-click to edit; use the formula bar for a value or expression.
87
+ - Changes stay local until **Save**. Ctrl/Cmd+Z, Ctrl/Cmd+Y and Ctrl/Cmd+S operate on this buffer.
88
+ - Select a range, copy/paste TSV from Excel, or press Ctrl/Cmd+D to fill down.
89
+ - Readonly cells are skipped during paste with a notification. A save is atomic across all changed rows.
90
+ - Use **Sort** for ordered sort fields, or Alt+click a column header (Alt+Shift+click adds a field).
91
+ Ordinary header clicks select columns. Search and filters run on the server.
92
+ - **Bulk change** selects either highlighted page rows or every matching row, including other pages.
93
+ Build a preview, inspect its exact count/values, then confirm it. Editing inputs requires a new preview.
94
+ - **Ordinary Admin** is available only when its list can represent the same filter, ordering and page.
95
+
96
+ Numbers use a decimal point and dates use ISO 8601. Empty pasted cells become
97
+ NULL for nullable fields, otherwise empty text for text fields or a validation error.
98
+ Prefix text beginning with `=` with an apostrophe, for example `'=SUM(A1:A2)`.
99
+
100
+ ## Configuration
101
+
102
+ ```python
103
+ MODELSHEET = {
104
+ "models": ["shop.Product"],
105
+ "page_size": 200,
106
+ "max_page_size": 500,
107
+ "max_save_rows": 500,
108
+ "count_mode": "exact", # exact | none (no approximate COUNT promise)
109
+ "bulk_background_threshold": 5000,
110
+ "max_bulk_rows": 100000,
111
+ "chunk_size": 200,
112
+ "snapshot_ttl_seconds": 1800,
113
+ "preview_ttl_seconds": 1800,
114
+ "history_retention_days": 365,
115
+ "max_operations_per_minute": 30,
116
+ "max_request_bytes": 1000000,
117
+ "formula": {"max_length": 500, "max_depth": 10, "max_steps": 10000, "timeout_ms": 250},
118
+ "background_adapter": None,
119
+ }
120
+ ```
121
+
122
+ Run `python manage.py modelsheet_cleanup` periodically through your scheduler.
123
+ It removes expired snapshots and terminal/unused-preview operations beyond retention.
124
+ Do not reuse old idempotency keys after their operation has been purged.
125
+
126
+ ## Permissions
127
+
128
+ Access requires `is_active`, `is_staff`, `modelsheet.open`, the model's standard
129
+ `view` permission, and `ModelAdmin.has_view_permission()`. Editing additionally
130
+ requires the standard `change` permission and `has_change_permission()`.
131
+
132
+ Package permission codenames include app/model identifiers:
133
+
134
+ ```text
135
+ modelsheet.view_field_shop_product_price
136
+ modelsheet.edit_field_shop_product_price
137
+ modelsheet.bulk_shop_product
138
+ modelsheet.history_shop_product
139
+ ```
140
+
141
+ `get_queryset(request)` bounds every read/write. Object-level Admin hooks and the
142
+ configured policy apply in addition. Hidden fields cannot be used in filters,
143
+ sorting, formulas or history. Field permissions never grant model-level editing.
144
+
145
+ Group templates are opt-in and additive: Viewer, Editor, Operator, Auditor and Admin.
146
+ They grant access to the configured sheet fields; review these grants before
147
+ assigning users, especially sensitive columns. They do not grant superuser status.
148
+
149
+ ## Development and demo
150
+
151
+ ```sh
152
+ python -m pip install -e '.[test]'
153
+ cd frontend
154
+ npm ci
155
+ npm run build
156
+ npm test
157
+ cd ..
158
+ python manage.py migrate
159
+ python manage.py seed_demo --create-user
160
+ python manage.py runserver
161
+ ```
162
+
163
+ The explicit `--create-user` option creates a local-only `admin` / `admin` account.
164
+ The demo settings are not production settings. Open
165
+ `http://127.0.0.1:8000/admin/shop/product/sheet/`.
166
+
167
+ ```sh
168
+ python -m pytest
169
+ python -m ruff check .
170
+ python -m build --no-isolation
171
+ cd frontend
172
+ npx playwright install chromium
173
+ npm run test:e2e
174
+ ```
175
+
176
+ Target matrix: Django 5.2 / Python 3.11–3.14; Django 6.0 and 6.1 / Python
177
+ 3.12–3.14; PostgreSQL, MySQL/InnoDB, SQLite. CI exercises valid combinations and
178
+ the same transactional contract across databases. Consult CI results before
179
+ treating a combination as verified; local results are recorded in `docs/validation.md`.
180
+
181
+ ## Boundaries
182
+
183
+ Safe mode calls `full_clean()` and the instance's `save()` by default, including
184
+ normal model signals. Custom `ModelAdmin.save_model()`, custom Admin forms,
185
+ `save_related()` and external effects need an explicit adapter. Snapshot fingerprints
186
+ cover concrete model fields, not all intermediate history or related-table contents.
187
+
188
+ Sheets use the `default` database. User models require no extra fields or inheritance.
189
+ Existing scalar fields are editable; FK fields can be displayed/filtered but FK/M2M
190
+ editors, server undo, file import/export, fast-save, cursor pagination and persistent
191
+ formulas are deferred. Large operations require your own background adapter.
192
+
193
+ See [formulas](docs/formulas.md), [extension API](docs/extensions.md),
194
+ [background processing](docs/background.md), [HTTP API](docs/api.md), and
195
+ [release procedure](docs/releasing.md). Bundled dependency notices are in
196
+ `modelsheet/static/modelsheet/THIRD_PARTY_LICENSES.txt`.
@@ -0,0 +1,40 @@
1
+ django_modelsheet-0.3.0a1.dist-info/licenses/LICENSE,sha256=MmQND-_DSLg3oiVoo63Z3JCB2OXKqZRSXroN0t1Ai7I,1087
2
+ modelsheet/__init__.py,sha256=AKROLFbAqoTcEJOJJpSjO_mVl8HHekeVnco5HM6gh9A,24
3
+ modelsheet/adapters.py,sha256=k--zM4F04BMAlEUdmnrRh-JGB8bDtxZsTXQ-4YnCNZQ,4457
4
+ modelsheet/admin.py,sha256=WLuzbvFxSDca7i4BMbESKEqgX4CvXmG9_JTWa0vdTXo,13138
5
+ modelsheet/apps.py,sha256=ddPeL-HoSEOP2VVyrrbao4h97dAgKlqM6hluy-ozJH0,455
6
+ modelsheet/background.py,sha256=VBLNlkcnrNiel6LBwTLkMjOAlPwsPxBG7F3YRKLs28c,1919
7
+ modelsheet/bulk.py,sha256=elFhjHJ8dz-pYgQVdXElAK75fwScUk5CyHzftZrz4Gc,17219
8
+ modelsheet/checks.py,sha256=VyR5GUxw9cw-bjz-cFdZHUi4IR76mcl983kyrnvAleE,3983
9
+ modelsheet/conf.py,sha256=GID5IpUDxh0l2dDQC379Pju3oAb_8wSjz5iu3NNTw-Y,1376
10
+ modelsheet/errors.py,sha256=fs0ucDObHYetMqV6olQ3miff0VqOU0Vx0KgzV7Epqmc,614
11
+ modelsheet/metadata.py,sha256=ApFru-G0EfxQoZ6HcK4g2rAb7fcfuvpdyAzW6O0TY40,1405
12
+ modelsheet/models.py,sha256=l9pzD5nMFw3itijKxMaX4cZv4oBAm3YqP1c3RspCPfU,2531
13
+ modelsheet/permissions.py,sha256=9zniYFREwDVg85MN3mOMhVjL2x2DjA68PbzWV2XgcTg,2841
14
+ modelsheet/py.typed,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
15
+ modelsheet/query.py,sha256=dYHKrNmlPhqZEy_liR1bU4pCYozc_PpZ-HPwkBbxN-A,5246
16
+ modelsheet/services.py,sha256=CtoUMKLgQOHuC1yHfDlOWQvYKF_LV1a_km73lyTM9K0,18705
17
+ modelsheet/views.py,sha256=mPgpWX_4x77tMetOuWGL9W1Plirxp7yV5ihmHBfSm_A,3680
18
+ modelsheet/formula/__init__.py,sha256=QyDX-f5Dvqmnovz9zUC-On05OWlIp5I_xDK3qnkG1WY,125
19
+ modelsheet/formula/engine.py,sha256=T4lKLrOgmSSpObgNrDojktVC9zUDk83ROwIH34f0kog,12958
20
+ modelsheet/locale/ru/LC_MESSAGES/django.mo,sha256=CAVfcPAzm7SzCfRXErTD-5xbHlO7lfHh3OOLpbH0CZA,259
21
+ modelsheet/locale/ru/LC_MESSAGES/django.po,sha256=_jEJe0eYK6iuF6MzjtJXFnGMaUY1hUaq3Jw4Bfz7Hhg,246
22
+ modelsheet/locale/ru/LC_MESSAGES/djangojs.mo,sha256=EzdptKmDcsPL68r_NMFECeZqOZ-5uEyBfdR3aNw4r4Q,3431
23
+ modelsheet/locale/ru/LC_MESSAGES/djangojs.po,sha256=SoxFKh-_JOYM2elhdxMqpG7J6C4t_bWtUpeQQ25pWWQ,3536
24
+ modelsheet/management/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
25
+ modelsheet/management/commands/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
26
+ modelsheet/management/commands/modelsheet_cleanup.py,sha256=pwJ-PFU4NBEEp7srW4h8bZPQ9R6WiPDk3S_TWi85ttY,905
27
+ modelsheet/management/commands/modelsheet_roles.py,sha256=RLCj2Dve5RhLxGANk8IK2kmjTco-hDbaoFoyYOr1fyI,2147
28
+ modelsheet/migrations/0001_initial.py,sha256=uP5UZTy3U80YhE5kfft1kOnObt8qlVk94-WFDP8q5zY,4589
29
+ modelsheet/migrations/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
30
+ modelsheet/static/modelsheet/THIRD_PARTY_LICENSES.txt,sha256=0opprOP9KVjqzCKxO-Uz5x_wleW0c4CkJS5sBi7HP2Y,2188
31
+ modelsheet/static/modelsheet/sheet.css,sha256=rNkowXG7WeYNGj6yWcsa0hrwvpBVl_hVz2kScuZWQmg,32501
32
+ modelsheet/static/modelsheet/sheet.js,sha256=VkwWCz419_3T8JHNzVmRGHL9Tys3PbwdkQ7qy2aR37Y,672034
33
+ modelsheet/templates/modelsheet/audit.html,sha256=BbTLwkuAccbglySQWlcwVmsFOSPrWm-AEfH7zXLql5M,753
34
+ modelsheet/templates/modelsheet/change_list.html,sha256=LZh_Jzqi2N0FO9jLoMdu-OlEJAIcHjsvt-seRjnHY8A,231
35
+ modelsheet/templates/modelsheet/sheet.html,sha256=jkpWDt_YdYfIBKI4qYC75v6X6TrVzCj_WTOQnLRCzkQ,531
36
+ modelsheet/templates/modelsheet/unavailable.html,sha256=1Nihf4i4RAobf6qK-zg1AG0fauf7Gp3Xbfr0i5WUXyc,129
37
+ django_modelsheet-0.3.0a1.dist-info/METADATA,sha256=Y9mthOVc3Faj7ShOw8SAMKSp5IgSZ1T-nWO_fpxsj10,7758
38
+ django_modelsheet-0.3.0a1.dist-info/WHEEL,sha256=mOdXR9C3wMVov7cSY8PY-XCzo3SmmWCHvGQr5csJoao,91
39
+ django_modelsheet-0.3.0a1.dist-info/top_level.txt,sha256=bUovMtlnvML9O2UB-RuPHhcidwi_vXCkTlv9V727lak,11
40
+ django_modelsheet-0.3.0a1.dist-info/RECORD,,
@@ -0,0 +1,5 @@
1
+ Wheel-Version: 1.0
2
+ Generator: setuptools (84.2.1)
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
5
+
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 django-modelsheet contributors
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
+ modelsheet
modelsheet/__init__.py ADDED
@@ -0,0 +1 @@
1
+ __version__ = "0.3.0a1"
modelsheet/adapters.py ADDED
@@ -0,0 +1,121 @@
1
+ import datetime as dt
2
+ from decimal import Decimal, ROUND_HALF_EVEN, localcontext
3
+
4
+ from django.core.exceptions import ValidationError
5
+ from django.conf import settings
6
+ from django.db import models
7
+ from django.utils import timezone
8
+
9
+
10
+ def serialize(value):
11
+ if isinstance(value, Decimal):
12
+ return str(value)
13
+ if isinstance(value, (dt.datetime, dt.date, dt.time)):
14
+ return value.isoformat()
15
+ if isinstance(value, (str, int, float, bool)) or value is None:
16
+ return value
17
+ return str(value)
18
+
19
+
20
+ class FieldAdapter:
21
+ def supported(self, field):
22
+ return (
23
+ isinstance(
24
+ field,
25
+ (
26
+ models.CharField,
27
+ models.TextField,
28
+ models.IntegerField,
29
+ models.DecimalField,
30
+ models.FloatField,
31
+ models.BooleanField,
32
+ models.DateField,
33
+ models.TimeField,
34
+ models.UUIDField,
35
+ models.ForeignKey,
36
+ ),
37
+ )
38
+ and not field.primary_key
39
+ )
40
+
41
+ def editable(self, field):
42
+ return self.supported(field) and field.editable and not isinstance(field, models.ForeignKey)
43
+
44
+ def serialize(self, field, instance):
45
+ return serialize(field.value_from_object(instance))
46
+
47
+ def parse(self, field, value):
48
+ if isinstance(value, (dict, list)):
49
+ raise ValidationError("A scalar value is required.")
50
+ if value is None or value == "":
51
+ if field.null:
52
+ return None
53
+ if isinstance(field, (models.CharField, models.TextField)):
54
+ return ""
55
+ raise ValidationError("This field cannot be empty.")
56
+ if isinstance(field, models.BooleanField) and isinstance(value, str):
57
+ if value.lower() in ("true", "1"):
58
+ value = True
59
+ elif value.lower() in ("false", "0"):
60
+ value = False
61
+ if isinstance(field, models.DecimalField):
62
+ value = Decimal(str(value))
63
+ if not value.is_finite():
64
+ raise ValidationError("A finite decimal is required.")
65
+ with localcontext() as context:
66
+ context.prec = max(28, field.max_digits + 2)
67
+ value = value.quantize(
68
+ Decimal(1).scaleb(-field.decimal_places), rounding=ROUND_HALF_EVEN
69
+ )
70
+ if isinstance(field, models.IntegerField) and not isinstance(field, models.BooleanField):
71
+ integer = Decimal(str(value))
72
+ if (
73
+ isinstance(value, bool)
74
+ or not integer.is_finite()
75
+ or integer.adjusted() > 19
76
+ or integer != integer.to_integral_value()
77
+ ):
78
+ raise ValidationError("An integer is required.")
79
+ if isinstance(field, models.FloatField):
80
+ import math
81
+
82
+ if not math.isfinite(float(value)):
83
+ raise ValidationError("A finite number is required.")
84
+ value = field.to_python(value)
85
+ if isinstance(value, dt.datetime) and timezone.is_naive(value) and settings.USE_TZ:
86
+ value = timezone.make_aware(value, dt.timezone.utc)
87
+ elif isinstance(value, dt.datetime) and timezone.is_aware(value) and not settings.USE_TZ:
88
+ value = value.astimezone(dt.timezone.utc).replace(tzinfo=None)
89
+ return value
90
+
91
+ def metadata(self, field):
92
+ return {
93
+ "name": field.name,
94
+ "label": str(field.verbose_name),
95
+ "type": field.get_internal_type(),
96
+ "nullable": field.null,
97
+ "choices": [[serialize(k), str(v)] for k, v in field.flatchoices],
98
+ }
99
+
100
+
101
+ class SaveAdapter:
102
+ """Override for project validation/business logic. External effects must be idempotent."""
103
+
104
+ def validate(self, instance, request):
105
+ instance.full_clean()
106
+
107
+ def save(self, instance, fields, request):
108
+ instance.save()
109
+
110
+
111
+ class BackgroundAdapter:
112
+ """Implement enqueue and capture/restore_context; see docs/background.md."""
113
+
114
+ def capture_context(self, request):
115
+ return {}
116
+
117
+ def restore_context(self, user, context):
118
+ raise NotImplementedError("Restore a request including tenant and project context")
119
+
120
+ def enqueue(self, operation_id, phase):
121
+ raise NotImplementedError("Durably enqueue modelsheet.background.process(id, phase)")