Skip to content

Commit 2a870a4

Browse files
taimoor-ahmed-1Faraz32123
authored andcommitted
feat: apply ADR 0036 (nested JSON normalization) across 6 standardized APIs (#38773)
* feat: apply ADR 0036 to Xblock v1 (?view=minimal) Adds a ``?view=minimal`` query parameter on ``XblockViewSet.retrieve`` that filters the (tree-shaped) xblock response down to a small set of structural fields — id, display_name, category, children, has_children, studio_url — dropping heavy/contextual fields such as data, metadata, fields, student_view_data, edited_on, published. Default response shape is unchanged (full xblock payload) to avoid breaking the existing Studio frontend. The pre-existing ``?fields=...`` query parameter retains its legacy "type of response" semantics (?fields=graderType, ?fields=ancestorInfo, ?fields=customReadToken); ADR 0036's CSV-subset interpretation is deferred to a future API version to avoid breaking those callers. Adds 4 regression tests covering: default response untouched, minimal strips heavy fields, minimal keeps structural fields, minimal is a no-op for non-dict response bodies (legacy ?fields=graderType path). * feat: apply ADR 0036 to CourseHome v3 (?fields= on list action) The v3 ``HomeViewSet.list`` action returns a wide ``StudioHomeSerializer`` payload with ~25 top-level keys (courses, archived_courses, libraries, allowed_organizations, allowed_organizations_for_libraries, plus Studio settings). Adds a ``?fields=`` query parameter so clients can request a subset of those keys explicitly. The flat-list ``courses`` and ``libraries`` actions are out of scope (each returns a single-key dict wrapping a list of small items — no nested or wide structure to filter). Adds a shared ``apply_field_selection`` helper to ``v3/utils.py`` so future v3 viewsets can opt into the same convention without re-implementing it. Adds 2 regression tests: default keeps all keys, ``?fields=courses,libraries`` restricts to exactly those keys. * docs: audit CourseHome v4 against ADR 0036 (out of scope) Adds an ADR 0036 entry to ``HomeCoursesViewSet``'s compliance list explicitly marking this endpoint as out of scope. Rationale: the v4 home endpoint returns a flat paginated list governed by ADR 0032 (DefaultPagination 7-field envelope). ADR 0036 excludes flat lists from its ``?view=`` / ``?depth=`` / minimal-by-default requirements — those apply to tree-shaped responses or wide flat objects with embedded sub-objects. Each course item is a thin 9-field record with no nested children, no embedded sub-objects, and no tree structure to bound. Per-item ``?fields=`` subset filtering remains a possible follow-up (would require a dynamic-fields serializer mixin and per-field schema documentation) but is deferred to keep the v4 contract stable for the existing Studio frontend. * feat: apply ADR 0036 to Enrollment v2 (?view=minimal flattens course_details) Each enrollment record returned by the v2 ``EnrollmentViewSet.list`` and ``EnrollmentRetrieveView.get`` actions embeds a full ``course_details`` sub-object (which itself includes a ``course_modes`` list and other heavy course-overview fields). Server-to-server callers and AI agents that only need to know which courses a user is enrolled in shouldn't have to parse the embedded sub-object on every row. Adds a ``?view=minimal`` query parameter on both actions that collapses the embedded ``course_details`` to a single ``course_id`` string. The enrollment-level fields (``created``, ``mode``, ``is_active``, ``user``) are preserved. Default response shape is unchanged. Adds 2 regression tests (mocked, MongoDB-free): default list keeps the full shape, ``?view=minimal`` collapses each row's course_details to a flat course_id and the heavy fields are dropped. * feat: apply ADR 0036 to Course Detail v3 (?view=minimal + ?fields=) The v3 ``CourseDetailsViewSet.retrieve`` action returns a wide ``CourseDetailsSerializer`` payload with ~40 top-level fields plus a nested ``instructor_info`` sub-object (instructor names, bios, image URLs) and a ``learning_info`` long-form list. The default full payload is preserved; two new opt-in query parameters apply ADR 0036: * ``?view=minimal`` drops heavy fields (overview, syllabus, description, short_description, instructor_info, learning_info, banner_image_name / banner_image_asset_path, video_thumbnail assets, license) — leaving only identification (course_id, org, run, title, subtitle, language), schedule (start_date, end_date, enrollment_start, enrollment_end, certificate_available_date), and flags (self_paced, certificates_display_behavior, has_changes). * ``?fields=a,b,c`` keeps an explicit CSV subset of top-level keys. Composes with ``?view=minimal`` — the preset is applied first, then the explicit subset. Reuses ``apply_field_selection`` from ``v3/utils.py`` (introduced in the CourseHome v3 commit) so the convention is consistent across v3. Adds 3 regression tests (mocked, MongoDB-free): default keeps all fields, ?view=minimal drops the heavy/embedded ones and keeps identification/schedule/flags, ?fields=course_id,title restricts to exactly those keys. * docs: audit AuthorGrading v3 against ADR 0036 (largely out of scope) Adds an ADR 0036 entry to ``AuthoringGradingViewSet``'s compliance list. Rationale: the v3 grading response is a single top-level ``graders`` list of small fixed-shape objects (type, min_count, drop_count, short_label, weight, id) — no tree nesting, no embedded sub-objects, no ``children`` field, no wide flat object. ``?view=minimal`` and ``?fields=`` would have no fields to drop; the only ADR 0036 concern that applies is anti-pattern #3 (unbounded child list). In practice each course has ≤8 graders (Homework, Lab, Exam, etc.) and the update flow is exercised only by course-authoring staff, so the real-world payload is always small. The hard cap is enforced upstream by ``CourseGradingModel.update_from_json``; documented as a note rather than re-implemented at the view layer.
1 parent 2454b50 commit 2a870a4

11 files changed

Lines changed: 605 additions & 16 deletions

File tree

cms/djangoapps/contentstore/rest_api/v1/views/tests/test_xblock_viewset.py

Lines changed: 74 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -142,3 +142,77 @@ def test_error_body_has_no_developer_message(self):
142142
def test_instance_field_is_request_path(self):
143143
response = self.client.get(_detail_url())
144144
assert response.json()["instance"] == _detail_url()
145+
146+
147+
# ---------------------------------------------------------------------------
148+
# ADR 0036 — minimal-view regression tests
149+
# ---------------------------------------------------------------------------
150+
class TestXblockViewSetMinimalView(ModuleStoreTestCase, APITestCase):
151+
"""
152+
ADR 0036 — verify ``?view=minimal`` strips the xblock response down to
153+
the structural fields enumerated in ``_MINIMAL_VIEW_FIELDS`` and leaves
154+
the default (full) response untouched.
155+
"""
156+
157+
_FULL_PAYLOAD = {
158+
"id": TEST_LOCATOR,
159+
"display_name": "Problem 1",
160+
"category": "problem",
161+
"children": [],
162+
"has_children": False,
163+
"studio_url": "/studio/...",
164+
# Heavy / contextual fields that ``?view=minimal`` MUST drop:
165+
"data": "<problem>...</problem>",
166+
"metadata": {"weight": 1.0},
167+
"fields": {"showanswer": "always"},
168+
"student_view_data": {"...": "..."},
169+
"edited_on": "2026-06-17T00:00:00Z",
170+
"published": True,
171+
}
172+
173+
def setUp(self):
174+
super().setUp()
175+
self.author = GlobalStaffFactory.create()
176+
self.client.force_authenticate(user=self.author)
177+
178+
@patch(f"{_VIEW_MODULE}.retrieve_xblock_response")
179+
def test_default_response_is_unchanged(self, mock_retrieve):
180+
"""Without ``?view=minimal`` the response is the full handler payload."""
181+
mock_retrieve.return_value = JsonResponse(self._FULL_PAYLOAD)
182+
response = self.client.get(_detail_url())
183+
assert response.status_code == status.HTTP_200_OK
184+
body = response.json()
185+
# Heavy fields must still be present in the default response.
186+
assert "data" in body
187+
assert "metadata" in body
188+
assert "student_view_data" in body
189+
190+
@patch(f"{_VIEW_MODULE}.retrieve_xblock_response")
191+
def test_minimal_view_strips_heavy_fields(self, mock_retrieve):
192+
"""``?view=minimal`` drops data, metadata, fields, student_view_data, edited_on, published."""
193+
mock_retrieve.return_value = JsonResponse(self._FULL_PAYLOAD)
194+
response = self.client.get(_detail_url(), {"view": "minimal"})
195+
assert response.status_code == status.HTTP_200_OK
196+
body = response.json()
197+
# Heavy fields MUST be dropped.
198+
for dropped in ("data", "metadata", "fields", "student_view_data", "edited_on", "published"):
199+
assert dropped not in body, f"ADR 0036: ?view=minimal must drop '{dropped}'"
200+
201+
@patch(f"{_VIEW_MODULE}.retrieve_xblock_response")
202+
def test_minimal_view_keeps_structural_fields(self, mock_retrieve):
203+
"""``?view=minimal`` keeps id, display_name, category, children, has_children, studio_url."""
204+
mock_retrieve.return_value = JsonResponse(self._FULL_PAYLOAD)
205+
response = self.client.get(_detail_url(), {"view": "minimal"})
206+
body = response.json()
207+
for kept in ("id", "display_name", "category", "children", "has_children", "studio_url"):
208+
assert kept in body, f"ADR 0036: ?view=minimal must keep '{kept}'"
209+
assert body["id"] == TEST_LOCATOR
210+
assert body["category"] == "problem"
211+
212+
@patch(f"{_VIEW_MODULE}.retrieve_xblock_response")
213+
def test_minimal_view_is_noop_for_non_json_payload(self, mock_retrieve):
214+
"""Legacy ``?fields=graderType`` returns a non-dict body — minimal must be a no-op."""
215+
mock_retrieve.return_value = JsonResponse("notgraded", safe=False)
216+
response = self.client.get(_detail_url(), {"view": "minimal", "fields": "graderType"})
217+
assert response.status_code == status.HTTP_200_OK
218+
assert response.json() == "notgraded"

cms/djangoapps/contentstore/rest_api/v1/views/xblock.py

Lines changed: 64 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -8,10 +8,24 @@
88
* ADR 0026 - explicit authentication_classes + permission_classes
99
* ADR 0028 - consolidated into XblockViewSet via DefaultRouter
1010
* ADR 0029 - standardized error envelope via StandardizedErrorMixin
11+
* ADR 0036 - minimal/flattened views. ``retrieve`` accepts a ``?view=minimal``
12+
query parameter that strips the (tree-shaped) xblock response to a small
13+
set of structural fields. The full xblock response is kept as the default
14+
for backwards compatibility with the existing Studio frontend; new clients
15+
SHOULD opt into ``?view=minimal`` whenever the full nested payload is not
16+
required.
17+
18+
Note on ``?fields=`` — the underlying ``retrieve_xblock_response`` already
19+
interprets ``?fields=`` with **legacy semantics** as a "type of response"
20+
selector (``?fields=graderType``, ``?fields=ancestorInfo``,
21+
``?fields=customReadToken``). To avoid breaking existing callers, v1 does
22+
NOT repurpose ``?fields=`` as the ADR 0036 CSV-subset selector — use
23+
``?view=minimal`` instead. A future v2 may reconcile these names.
1124
"""
1225
import json
1326
import logging
1427

28+
from django.http import JsonResponse
1529
from edx_rest_framework_extensions.auth.jwt.authentication import JwtAuthentication
1630
from edx_rest_framework_extensions.auth.session.authentication import SessionAuthenticationAllowInactiveUser
1731
from opaque_keys import InvalidKeyError
@@ -33,6 +47,39 @@
3347

3448
log = logging.getLogger(__name__)
3549

50+
# ADR 0036 — top-level keys kept when ``?view=minimal`` is requested. Chosen so
51+
# the response is structurally complete (callers can navigate the tree by id
52+
# and fetch full nodes on demand) without any heavy/contextual fields
53+
# (student_view_data, completion, OLX metadata, etc.).
54+
_MINIMAL_VIEW_FIELDS = frozenset({
55+
"id",
56+
"display_name",
57+
"category",
58+
"children",
59+
"has_children",
60+
"studio_url",
61+
})
62+
63+
64+
def _apply_minimal_view(response):
65+
"""
66+
ADR 0036 — when ``?view=minimal`` was requested, drop every top-level key
67+
not in :data:`_MINIMAL_VIEW_FIELDS` from ``response``. No-op for non-JSON
68+
or non-2xx responses.
69+
"""
70+
if not isinstance(response, JsonResponse) or response.status_code >= 300:
71+
return response
72+
try:
73+
body = json.loads(response.content.decode("utf-8") or "{}")
74+
except (ValueError, AttributeError):
75+
return response
76+
if not isinstance(body, dict):
77+
# If the handler returned a non-object payload (e.g. `?fields=graderType`
78+
# which returns the grader-type value directly), there's nothing to
79+
# filter — return the response untouched.
80+
return response
81+
return JsonResponse({k: v for k, v in body.items() if k in _MINIMAL_VIEW_FIELDS})
82+
3683

3784
class XblockViewSet(StandardizedErrorMixin, viewsets.ViewSet):
3885
"""
@@ -44,6 +91,12 @@ class XblockViewSet(StandardizedErrorMixin, viewsets.ViewSet):
4491
PUT /api/contentstore/v1/xblock/{usage_key_string}/ → update
4592
PATCH /api/contentstore/v1/xblock/{usage_key_string}/ → partial_update
4693
DELETE /api/contentstore/v1/xblock/{usage_key_string}/ → destroy
94+
95+
Query parameters (ADR 0036, GET only):
96+
?view=minimal Drop heavy / contextual fields from the response,
97+
keeping only structural fields (id, display_name,
98+
category, children, has_children, studio_url).
99+
Default response is the full xblock payload.
47100
"""
48101

49102
authentication_classes = (
@@ -98,8 +151,17 @@ def create(self, request):
98151

99152
@expect_json_in_class_view
100153
def retrieve(self, request, usage_key_string=None):
101-
"""Retrieve an xblock by its usage key."""
102-
return retrieve_xblock_response(request, usage_key_string)
154+
"""
155+
Retrieve an xblock by its usage key.
156+
157+
ADR 0036 — honours ``?view=minimal``; everything else is delegated to
158+
``retrieve_xblock_response`` (which keeps its legacy ``?fields=`` /
159+
``?fields=ancestorInfo`` / ``?fields=customReadToken`` semantics).
160+
"""
161+
response = retrieve_xblock_response(request, usage_key_string)
162+
if request.GET.get("view") == "minimal":
163+
response = _apply_minimal_view(response)
164+
return response
103165

104166
@expect_json_in_class_view
105167
@validate_request_with_serializer

cms/djangoapps/contentstore/rest_api/v3/utils.py

Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,10 @@
1313
* :data:`COMMON_ERROR_RESPONSES` – the shared ``@extend_schema(responses=...)``
1414
fragment for the 401 / 403 / 404 cases every v3 course-scoped viewset
1515
can raise.
16+
* :func:`apply_field_selection` – ADR 0036 helper. Drops every top-level
17+
key not listed in the caller's ``?fields=`` CSV. No-op when ``?fields=``
18+
is absent. Use this when an action returns a wide flat object and clients
19+
want to request a subset (e.g. ``?fields=id,display_name,courses``).
1620
"""
1721

1822
from drf_spectacular.utils import OpenApiResponse
@@ -53,3 +57,31 @@ def resolve_course_key(course_key: str) -> CourseKey:
5357
403: OpenApiResponse(description="The requester cannot access the specified course."),
5458
404: OpenApiResponse(description="The requested course does not exist."),
5559
}
60+
61+
62+
def apply_field_selection(data, fields_csv):
63+
"""
64+
ADR 0036 — drop every top-level key not listed in ``fields_csv``.
65+
66+
Args:
67+
data: a ``dict`` (typically ``serializer.data``). Anything else is
68+
returned untouched.
69+
fields_csv: the raw value of the ``?fields=`` query parameter. ``None``
70+
or empty string → no filtering (the full ``data`` is returned).
71+
72+
Returns:
73+
A new ``dict`` containing only the requested top-level keys, or the
74+
original ``data`` if filtering is not applicable.
75+
76+
Note:
77+
Only top-level keys are honoured. Dotted paths (``?fields=children.x``)
78+
are stripped to their first segment (``children``) — full dotted-path
79+
traversal is intentionally left to a future implementation per the
80+
ADR 0036 guidance to "reject silent over-fetching" via that syntax.
81+
"""
82+
if not fields_csv or not isinstance(data, dict):
83+
return data
84+
wanted = {name.strip().split(".", 1)[0] for name in fields_csv.split(",") if name.strip()}
85+
if not wanted:
86+
return data
87+
return {key: value for key, value in data.items() if key in wanted}

cms/djangoapps/contentstore/rest_api/v3/views/authoring_grading.py

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,19 @@
1717
legacy ``course_id``. Since this is a brand-new versioned API, no
1818
deprecated alias is needed — clients on the v0 endpoint continue to use
1919
``course_id`` there.
20+
* ADR 0036 – **largely out of scope.** The ``CourseGradingModelSerializer``
21+
response is a single top-level ``graders`` list of small fixed-shape
22+
objects (type, min_count, drop_count, short_label, weight, id) — no
23+
tree nesting, no embedded sub-objects, no ``children`` field, no wide
24+
flat object that would benefit from ``?view=minimal`` / ``?fields=``.
25+
26+
The one ADR 0036 concern is anti-pattern #3 (unbounded child list): the
27+
``graders`` array has no upper bound in the serializer. In practice each
28+
course has typically ≤8 graders (Homework, Lab, Exam, etc.) and the
29+
update flow is exercised only by course-authoring staff, so the
30+
real-world payload is always small. A hard cap is enforced upstream of
31+
this endpoint by :func:`CourseGradingModel.update_from_json`; we surface
32+
that as a documentation note rather than re-implement the bound here.
2033
2134
Permission model note:
2235
PR #38363 proposed a class-level ``HasStudioReadAccess`` permission. The

cms/djangoapps/contentstore/rest_api/v3/views/course_details.py

Lines changed: 94 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -39,7 +39,11 @@
3939

4040
from cms.djangoapps.contentstore.rest_api.v1.serializers import CourseDetailsSerializer
4141
from cms.djangoapps.contentstore.rest_api.v1.views.course_details import _classify_update
42-
from cms.djangoapps.contentstore.rest_api.v3.utils import COMMON_ERROR_RESPONSES, resolve_course_key
42+
from cms.djangoapps.contentstore.rest_api.v3.utils import (
43+
COMMON_ERROR_RESPONSES,
44+
apply_field_selection,
45+
resolve_course_key,
46+
)
4347
from cms.djangoapps.contentstore.utils import update_course_details
4448
from openedx.core.djangoapps.authz.constants import LegacyAuthoringPermission
4549
from openedx.core.djangoapps.authz.decorators import user_has_course_permission
@@ -55,6 +59,68 @@
5559
location=OpenApiParameter.PATH,
5660
)
5761

62+
# ADR 0036 — document the minimal/full response variants in OpenAPI (decision #3).
63+
# Declaring these as query parameters is what makes the presets discoverable by
64+
# OpenAPI consumers (Swagger UI, generated SDK clients, etc.). The 200 response
65+
# schema below points at the full ``CourseDetailsSerializer``; ``?view=minimal``
66+
# returns the subset of top-level keys listed in :data:`_MINIMAL_VIEW_FIELDS`.
67+
_VIEW_QUERY_PARAMETER = OpenApiParameter(
68+
name="view",
69+
description=(
70+
"ADR 0036 response preset. ``minimal`` drops heavy fields (overview, "
71+
"syllabus, description, instructor_info, learning_info, banner/video "
72+
"assets, license) leaving only identification, schedule, and flags. "
73+
"Omit the parameter to receive the full response."
74+
),
75+
required=False,
76+
type=str,
77+
location=OpenApiParameter.QUERY,
78+
enum=["minimal"],
79+
)
80+
_FIELDS_QUERY_PARAMETER = OpenApiParameter(
81+
name="fields",
82+
description=(
83+
"ADR 0036 explicit field selection. Comma-separated list of top-level "
84+
"keys to include in the response (e.g. ``course_id,title,start_date``). "
85+
"When combined with ``?view=``, the preset is applied first and "
86+
"``?fields=`` is applied to the result. Unknown keys are silently "
87+
"skipped."
88+
),
89+
required=False,
90+
type=str,
91+
location=OpenApiParameter.QUERY,
92+
)
93+
94+
# ADR 0036 — the ``CourseDetailsSerializer`` has ~40 top-level fields plus a
95+
# nested ``instructor_info`` sub-object with bios and image URLs and a
96+
# ``learning_info`` long-form list. When ``?view=minimal`` is requested,
97+
# everything outside :data:`_MINIMAL_VIEW_FIELDS` is dropped so server-to-server
98+
# and AI-agent callers can fetch just the identification + schedule + flags
99+
# without paying for the heavy text and embedded sub-objects.
100+
_MINIMAL_VIEW_FIELDS = frozenset({
101+
"course_id",
102+
"org",
103+
"run",
104+
"title",
105+
"subtitle",
106+
"language",
107+
"self_paced",
108+
"start_date",
109+
"end_date",
110+
"enrollment_start",
111+
"enrollment_end",
112+
"certificate_available_date",
113+
"certificates_display_behavior",
114+
"has_changes",
115+
})
116+
117+
118+
def _apply_view_preset(data, view_preset):
119+
"""ADR 0036 — drop everything outside ``_MINIMAL_VIEW_FIELDS`` when ``?view=minimal``."""
120+
if view_preset != "minimal" or not isinstance(data, dict):
121+
return data
122+
return {key: value for key, value in data.items() if key in _MINIMAL_VIEW_FIELDS}
123+
58124

59125
class CourseDetailsViewSet(StandardizedErrorMixin, viewsets.ViewSet):
60126
"""
@@ -78,12 +144,21 @@ class CourseDetailsViewSet(StandardizedErrorMixin, viewsets.ViewSet):
78144

79145
@extend_schema(
80146
summary="Retrieve a course's details",
81-
description="Get an object containing all the course details for the specified course.",
82-
parameters=[_COURSE_ID_PARAMETER],
147+
description=(
148+
"Get an object containing the course details for the specified course. "
149+
"Supports the ADR 0036 ``?view=minimal`` preset and ``?fields=`` "
150+
"explicit field selection (see the parameter descriptions for details)."
151+
),
152+
parameters=[_COURSE_ID_PARAMETER, _VIEW_QUERY_PARAMETER, _FIELDS_QUERY_PARAMETER],
83153
responses={
84154
200: OpenApiResponse(
85155
response=CourseDetailsSerializer,
86-
description="Course details retrieved successfully.",
156+
description=(
157+
"Course details retrieved successfully. The schema below is "
158+
"the full default response; when ``?view=minimal`` and/or "
159+
"``?fields=`` is supplied, the response contains a subset of "
160+
"these top-level keys (see ADR 0036)."
161+
),
87162
),
88163
**COMMON_ERROR_RESPONSES,
89164
},
@@ -95,6 +170,16 @@ def retrieve(self, request: Request, course_id: str):
95170
**Example Request**
96171
97172
GET /api/contentstore/v3/course_details/{course_id}/
173+
GET /api/contentstore/v3/course_details/{course_id}/?view=minimal
174+
GET /api/contentstore/v3/course_details/{course_id}/?fields=course_id,title
175+
176+
ADR 0036:
177+
* ``?view=minimal`` drops heavy fields (overview, syllabus, description,
178+
instructor_info, learning_info, banner/video assets, license, etc.)
179+
leaving only identification, schedule, and flags.
180+
* ``?fields=...`` keeps an arbitrary CSV subset of top-level keys.
181+
* ``?fields=`` and ``?view=`` may be combined — ``?view=minimal``
182+
is applied first, then ``?fields=`` is applied to the result.
98183
"""
99184
course_key = resolve_course_key(course_id)
100185
if not user_has_course_permission(
@@ -106,8 +191,11 @@ def retrieve(self, request: Request, course_id: str):
106191
self.permission_denied(request)
107192

108193
course_details = CourseDetails.fetch(course_key)
109-
serializer = self.serializer_class(course_details)
110-
return Response(serializer.data)
194+
data = self.serializer_class(course_details).data
195+
# ADR 0036 — preset first, then explicit CSV subset.
196+
data = _apply_view_preset(data, request.query_params.get("view"))
197+
data = apply_field_selection(data, request.query_params.get("fields"))
198+
return Response(data)
111199

112200
@extend_schema(
113201
summary="Update a course's details",

0 commit comments

Comments
 (0)