Skip to content

Commit 3a18fbb

Browse files
committed
api, ui: improve base view classes (bug 2039353)
- add BaseLandoViewMixin - add mixin to base view classes - use self.response where applicable - replace usages of Http404 with helper (bug 2039353)
1 parent 47ca83c commit 3a18fbb

7 files changed

Lines changed: 72 additions & 34 deletions

File tree

src/lando/api/views.py

Lines changed: 25 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -33,12 +33,13 @@
3333
)
3434
from lando.utils.landing_checks import LandingChecks
3535
from lando.utils.phabricator import PHABRICATOR_API_KEY_HEADER, get_phabricator_client
36+
from lando.utils.views import BaseLandoViewMixin
3637

3738

38-
class APIView(View):
39+
class APIView(View, BaseLandoViewMixin):
3940
"""A base class for API views."""
4041

41-
pass
42+
response_class = JsonResponse
4243

4344

4445
def phabricator_api_key_required(func: Callable) -> Callable:
@@ -47,14 +48,16 @@ def phabricator_api_key_required(func: Callable) -> Callable:
4748
@wraps(func)
4849
def _wrapper(self: View, request: WSGIRequest, *args, **kwargs) -> Callable:
4950
if PHABRICATOR_API_KEY_HEADER not in request.headers:
50-
return JsonResponse(
51+
return self.response(
5152
{"error": f"{PHABRICATOR_API_KEY_HEADER} missing."}, status=400
5253
)
5354

5455
api_key = request.headers[PHABRICATOR_API_KEY_HEADER]
5556
client = get_phabricator_client(api_key=api_key)
5657
if not client.verify_api_token():
57-
return JsonResponse({"error": "Invalid Phabricator API token."}, status=401)
58+
return self.response(
59+
{"error": "Invalid Phabricator API token."}, status=401
60+
)
5861

5962
return func(self, request, *args, **kwargs)
6063

@@ -93,7 +96,7 @@ def generate_warnings_and_blockers(
9396

9497

9598
@method_decorator(csrf_exempt, name="dispatch")
96-
class LegacyDiffWarningView(View):
99+
class LegacyDiffWarningView(APIView):
97100
"""
98101
This class provides the API controllers for the legacy `DiffWarning` model.
99102
@@ -131,20 +134,20 @@ def data_validator(data):
131134
if form.is_valid():
132135
data = form.cleaned_data
133136
warning = DiffWarning.objects.create(**data)
134-
return JsonResponse(warning.serialize(), status=201)
137+
return self.response(warning.serialize(), status=201)
135138

136-
return JsonResponse({"errors": dict(form.errors)}, status=400)
139+
return self.response({"errors": dict(form.errors)}, status=400)
137140

138141
@phabricator_api_key_required
139142
def delete(self, request: WSGIRequest, diff_warning_id: int) -> JsonResponse:
140143
"""Archive a `DiffWarning` based on provided pk."""
141144
warning = DiffWarning.objects.get(pk=diff_warning_id)
142145
if not warning:
143-
return JsonResponse({}, status=404)
146+
return self.response({}, status=404)
144147

145148
warning.status = DiffWarningStatus.ARCHIVED
146149
warning.save()
147-
return JsonResponse(warning.serialize(), status=200)
150+
return self.response(warning.serialize(), status=200)
148151

149152
@phabricator_api_key_required
150153
def get(self, request: WSGIRequest, **kwargs) -> JsonResponse:
@@ -158,14 +161,14 @@ class Form(forms.Form):
158161
form = Form(request.GET)
159162
if form.is_valid():
160163
warnings = DiffWarning.objects.filter(**form.cleaned_data).all()
161-
return JsonResponse(
164+
return self.response(
162165
[warning.serialize() for warning in warnings], status=200, safe=False
163166
)
164167

165-
return JsonResponse({"errors": dict(form.errors)}, status=400)
168+
return self.response({"errors": dict(form.errors)}, status=400)
166169

167170

168-
class CommitMapBaseView(View):
171+
class CommitMapBaseView(APIView):
169172
"""CommitMap base view to be extended for bidirectional git - hg mapping."""
170173

171174
scm: str
@@ -177,16 +180,16 @@ def get(
177180
commit = CommitMap.map_hash_from(self.scm, git_repo_name, commit_hash)
178181
except CommitMap.DoesNotExist as exc:
179182
error_detail = f"No commit found in {self.scm} for {commit_hash} in {git_repo_name}: {exc}"
180-
return JsonResponse(
183+
return self.response(
181184
{"error": "No commits found", "detail": error_detail}, status=404
182185
)
183186
except CommitMap.MultipleObjectsReturned as exc:
184187
error_detail = f"Multiple commits found in {self.scm} for {commit_hash} in {git_repo_name}: {exc}"
185-
return JsonResponse(
188+
return self.response(
186189
{"error": "Multiple commits found", "detail": error_detail}, status=400
187190
)
188191

189-
return JsonResponse(commit.serialize(), status=200)
192+
return self.response(commit.serialize(), status=200)
190193

191194

192195
@method_decorator(csrf_exempt, name="dispatch")
@@ -203,7 +206,7 @@ class hg2gitCommitMapView(CommitMapBaseView):
203206
scm = SCMType.HG
204207

205208

206-
class PullRequestAPIView(View):
209+
class PullRequestAPIView(APIView):
207210
"""Set various common attributes for views that extend this one."""
208211

209212
target_repo: Repo
@@ -245,7 +248,7 @@ def get(
245248
status = str(_status).lower()
246249
break
247250

248-
return JsonResponse({"status": status}, status=200)
251+
return self.response({"status": status}, status=200)
249252

250253
@method_decorator(require_authenticated_user)
251254
def post(
@@ -268,12 +271,12 @@ class Form(forms.Form):
268271

269272
if blockers:
270273
# Pull request has blockers that prevent it from landing.
271-
return JsonResponse({"errors": blockers}, status=400)
274+
return self.response({"errors": blockers}, status=400)
272275

273276
form = Form(json.loads(request.body))
274277

275278
if not form.is_valid():
276-
return JsonResponse(form.errors, 400)
279+
return self.response(form.errors, 400)
277280

278281
job = LandingJob.objects.create(
279282
target_repo=self.target_repo,
@@ -311,7 +314,7 @@ class Form(forms.Form):
311314
job.status = JobStatus.SUBMITTED
312315
job.save()
313316

314-
return JsonResponse({"id": job.id}, status=201)
317+
return self.response({"id": job.id}, status=201)
315318

316319

317320
class PullRequestChecksAPIView(PullRequestAPIView):
@@ -324,5 +327,5 @@ def get(
324327
)
325328
except PullRequest.StaleMetadataException as exc:
326329
# The StaleMetadataException error message is safe for user consumption.
327-
return JsonResponse({"errors": [str(exc)]}, status=500)
328-
return JsonResponse(warnings_and_blockers)
330+
return self.response({"errors": [str(exc)]}, status=500)
331+
return self.response(warnings_and_blockers)

src/lando/ui/jobs.py

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -50,7 +50,7 @@ def get(self, request: WSGIRequest, job_id: int) -> TemplateResponse:
5050
queue = queue[: queue.index(job)]
5151
context["queue"] = queue
5252

53-
return TemplateResponse(
53+
return self.response(
5454
request=request,
5555
template="jobs/job.html",
5656
context=context,
@@ -108,7 +108,7 @@ def get(
108108
queue = queue[: queue.index(landing_job)]
109109
context["queue"] = queue
110110

111-
return TemplateResponse(
111+
return self.response(
112112
request=request,
113113
template="jobs/job.html",
114114
context=context,

src/lando/ui/legacy/pages.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -24,4 +24,4 @@ def get(self, request: WSGIRequest) -> TemplateResponse:
2424
status__in=JobStatus.final(),
2525
).order_by("-updated_at")[: self.MAX_JOBS_HISTORY]
2626

27-
return TemplateResponse(request=request, template="home.html", context=context)
27+
return self.response(request=request, template="home.html", context=context)

src/lando/ui/legacy/revisions.py

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -293,7 +293,7 @@ def get(self, request: WSGIRequest) -> TemplateResponse:
293293
"assessment": assessment_instance,
294294
}
295295

296-
return TemplateResponse(
296+
return self.response(
297297
request=request,
298298
template="uplift/request.html",
299299
context=context,
@@ -532,7 +532,7 @@ def get(
532532
),
533533
}
534534

535-
return TemplateResponse(
535+
return self.response(
536536
request=request,
537537
template="stack/stack.html",
538538
context=context,

src/lando/ui/pull_requests.py

Lines changed: 6 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,6 @@
11
import logging
22

33
from django.core.handlers.wsgi import WSGIRequest
4-
from django.http import Http404
54
from django.template.response import TemplateResponse
65
from requests import HTTPError
76

@@ -26,10 +25,10 @@ def get(
2625
try:
2726
target_repo = Repo.objects.get(name=repo_name)
2827
except Repo.DoesNotExist:
29-
raise Http404(f"Repository {repo_name} doesn't exist.")
28+
self.raise_http404(f"Repository {repo_name} doesn't exist.")
3029

3130
if not target_repo.pr_enabled:
32-
raise Http404(
31+
self.raise_http404(
3332
f"Pull Requests are not supported for repository {repo_name}."
3433
)
3534

@@ -39,7 +38,9 @@ def get(
3938
pull_request = client.build_pull_request(number)
4039
except HTTPError as e:
4140
if e.response.status_code == 404:
42-
raise Http404(f"Pull request {repo_name}#{number} doesn't exist") from e
41+
self.raise_http404(
42+
f"Pull request {repo_name}#{number} doesn't exist", e
43+
)
4344
raise e
4445

4546
landing_jobs = get_jobs_for_pull(target_repo, number)
@@ -50,7 +51,7 @@ def get(
5051
"landing_jobs": landing_jobs,
5152
}
5253

53-
return TemplateResponse(
54+
return self.response(
5455
request=request,
5556
template="stack/pull_request.html",
5657
context=context,

src/lando/ui/views.py

Lines changed: 7 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,10 @@
1+
from django.template.response import TemplateResponse
12
from django.views import View
23

4+
from lando.utils.views import BaseLandoViewMixin
35

4-
class LandoView(View):
5-
pass
6+
7+
class LandoView(View, BaseLandoViewMixin):
8+
"""A base class for UI views."""
9+
10+
response_class = TemplateResponse

src/lando/utils/views.py

Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,29 @@
1+
from django.core.exceptions import PermissionDenied
2+
from django.http import Http404, JsonResponse
3+
from django.template.response import TemplateResponse
4+
5+
6+
class BaseLandoViewMixin:
7+
"""Provide helper methods for returning HTTP responses."""
8+
9+
def _raise_handled_exception(self, exception: Exception, original: Exception):
10+
"""Handle the special case of raising exceptions that are handled by middleware."""
11+
if original:
12+
raise exception from original
13+
raise exception
14+
15+
def response(self, *args, **kwargs) -> TemplateResponse | JsonResponse:
16+
"""Return a response object based on the base response class."""
17+
return self.response_class(*args, **kwargs)
18+
19+
def raise_http404(self, message: str = "", original: Exception | None = None):
20+
"""Raise django.http.Http404."""
21+
# Note: this will use the 404 template available to Lando.
22+
exception = Http404(message)
23+
self._raise_handled_exception(exception, original)
24+
25+
def raise_permission_denied(self, message: str, original: Exception | None = None):
26+
"""Raise django.core.exceptions.PermissionDenied which is handled by middleware."""
27+
# Note: this will use the 403 template available to Lando.
28+
exception = PermissionDenied(message)
29+
self._raise_handled_exception(exception, original)

0 commit comments

Comments
 (0)