-
Notifications
You must be signed in to change notification settings - Fork 0
Expand file tree
/
Copy pathupdate.py
More file actions
171 lines (144 loc) · 7.2 KB
/
Copy pathupdate.py
File metadata and controls
171 lines (144 loc) · 7.2 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
"""``PATCH /api/v1/<app>/<model>/<pk>/`` — partial update endpoint.
Wire contract: ``docs/api-contract.md`` §5.2.
Hard rules (`SECURITY.md` §3, `ACCEPTANCE.md` §3.1):
- Rule 3: Model resolved through ``admin.site._registry`` (B-7).
- Rule 5: ``has_change_permission(request, obj)`` per-object gate.
- Rule 6: Writes go through ``ModelAdmin.get_form()`` then
``save_model(..., change=True)`` (B-3).
- Rule 10: Queryset starts at ``ModelAdmin.get_queryset(request)`` —
never ``Model.objects.all()`` (B-2).
- Rule 12: Writes to ``readonly`` / ``exclude`` keys → 400 (S-31, B-3).
- CSRF: No ``@csrf_exempt`` — Django's middleware enforces.
"""
from __future__ import annotations
from typing import Any
from django.db import transaction
from django.http import HttpRequest
from django.http import HttpResponse
from django.http import JsonResponse
from django.views.generic import View
from django_admin_react.api.inlines_write import InlinePermissionDenied
from django_admin_react.api.inlines_write import apply_inline_writes
from django_admin_react.api.permissions import forbidden_response
from django_admin_react.api.permissions import is_admin_user
from django_admin_react.api.registry import get_admin_site
from django_admin_react.api.registry import resolve_model
from django_admin_react.api.views.detail import _build_payload
from django_admin_react.api.writes import bad_request
from django_admin_react.api.writes import form_errors_to_envelope
from django_admin_react.api.writes import load_object_or_none
from django_admin_react.api.writes import log_change
from django_admin_react.api.writes import merged_initial_for_update
from django_admin_react.api.writes import not_found_response
from django_admin_react.api.writes import parse_json_body
from django_admin_react.api.writes import readonly_or_excluded_names
from django_admin_react.api.writes import reject_forbidden_keys
from django_admin_react.api.writes import validation_failed
from django_admin_react.api.writes import writable_field_names
class _InlineValidationError(Exception):
"""Carries inline formset errors out of the ``atomic()`` block.
Raised so the transaction unwinds (reverting the parent write), then
caught immediately outside the block and converted to a 400 with the
per-inline error detail. Using an exception rather than an early
return is what guarantees the rollback — a plain return inside
``atomic()`` would commit the parent.
"""
def __init__(self, errors: dict) -> None:
super().__init__("inline formset validation failed")
self.errors = errors
class UpdateView(View):
"""``PATCH /api/v1/<app_label>/<model_name>/<pk>/``."""
http_method_names = ["patch"]
def patch(
self,
request: HttpRequest,
app_label: str,
model_name: str,
pk: str,
*args: Any,
**kwargs: Any,
) -> HttpResponse:
"""Partially update an instance (contract §5.2).
PATCH semantics: any field the payload omits keeps its
current value. The implementation builds form ``initial``
data by overlaying the payload on the instance's current
values, then runs ``ModelAdmin.get_form()`` exactly like the
Django admin change view.
Gates: ``is_admin_user`` → ``resolve_model`` →
``load_object_or_none`` (uses the admin's queryset, never
``Model.objects.all()``) → ``has_change_permission(request,
obj)`` per-object gate (rule 5).
Same payload-shape validation as create (unknown / readonly /
excluded / sensitive keys → 400). Write path goes through
``form.save(commit=False)`` →
``model_admin.save_model(..., change=True)`` (rule 6 / B-3),
wrapped in ``transaction.atomic()``.
"""
admin_site = get_admin_site()
if not is_admin_user(request, admin_site=admin_site):
return forbidden_response(request)
resolved = resolve_model(admin_site, request, app_label, model_name)
if resolved is None:
return not_found_response()
model, model_admin = resolved
obj = load_object_or_none(model, model_admin, request, pk)
if obj is None:
return not_found_response()
if not model_admin.has_change_permission(request, obj):
return forbidden_response(request)
parsed = parse_json_body(request)
if isinstance(parsed, HttpResponse):
return parsed
payload: dict[str, Any] = parsed
# The optional ``inlines`` block is handled by the formset write
# path after the parent form saves; strip it from the parent
# payload so it isn't treated as an unknown parent field key.
inlines_payload = payload.pop("inlines", None)
writable = writable_field_names(model, model_admin, request, obj)
forbidden = readonly_or_excluded_names(model_admin, request, obj)
rejection = reject_forbidden_keys(payload, writable, forbidden)
if rejection is not None:
return rejection
# change=True — PATCH targets an existing object, so mirror
# Django's change view (see detail.py for the rationale; a
# consumer get_form override that branches on `change` must hit
# its change-form path, not the default factory).
form = model_admin.get_form(request, obj=obj, change=True)(
data=merged_initial_for_update(obj, writable, payload, model),
files=None,
instance=obj,
)
if not form.is_valid():
return validation_failed(form_errors_to_envelope(form))
try:
with transaction.atomic():
instance = form.save(commit=False)
model_admin.save_model(request, instance, form, change=True)
form.save_m2m()
log_change(model_admin, request, instance, form)
# Inline formsets (Issue #54 write half) round-trip inside
# the SAME transaction so a per-row permission denial or a
# formset validation failure reverts the parent write too.
if inlines_payload is not None:
inline_errors = apply_inline_writes(
model_admin, request, instance, form, inlines_payload
)
if inline_errors is not None:
# Roll back by raising; convert to a 400 below.
raise _InlineValidationError(inline_errors)
except InlinePermissionDenied:
return forbidden_response(request)
except _InlineValidationError as exc:
return validation_failed({"inlines": exc.errors})
except ValueError:
# Malformed ``inlines`` payload shape (not a 500). Return a
# fixed, generic message — never echo the exception text into
# the response (CodeQL ``py/stack-trace-exposure``). The
# precise shape rules are in api-contract §5.2.1.
return bad_request("Malformed 'inlines' payload.")
response = JsonResponse(
_build_payload(model, model_admin, instance, request),
status=200,
)
response["Cache-Control"] = "no-store"
return response