Skip to content

Commit 405184c

Browse files
authored
Merge 6bb4871 into c0939a7
2 parents c0939a7 + 6bb4871 commit 405184c

File tree

10 files changed

+195
-86
lines changed

10 files changed

+195
-86
lines changed

kobo/apps/audit_log/views.py

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@
1515
)
1616
from kpi.paginators import FastPagination, Paginated
1717
from kpi.permissions import IsAuthenticated
18+
from kpi.renderers import BasicHTMLRenderer
1819
from kpi.tasks import export_task_in_background
1920
from kpi.utils.schema_extensions.markdown import read_md
2021
from kpi.utils.schema_extensions.response import (
@@ -368,7 +369,10 @@ class BaseAccessLogsExportViewSet(viewsets.ViewSet):
368369
# the schema, even if the viewset doesn’t override the renderers or return content
369370
# that would need them. Without this, it falls back to the default DRF settings,
370371
# which may not reflect the actual behavior of the viewset.
371-
renderer_classes = (JSONRenderer,)
372+
renderer_classes = (
373+
JSONRenderer,
374+
BasicHTMLRenderer,
375+
)
372376

373377
def create_task(self, request, get_all_logs):
374378

kobo/apps/organizations/views.py

Lines changed: 0 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,6 @@
1010
)
1111
from rest_framework import status, viewsets
1212
from rest_framework.decorators import action
13-
from rest_framework.renderers import JSONRenderer
1413
from rest_framework.request import Request
1514
from rest_framework.response import Response
1615

@@ -202,9 +201,6 @@ class OrganizationViewSet(viewsets.ModelViewSet):
202201
lookup_field = 'id'
203202
permission_classes = [HasOrgRolePermission]
204203
http_method_names = ['get', 'patch']
205-
renderer_classes = [
206-
JSONRenderer,
207-
]
208204

209205
@action(
210206
detail=True, methods=['GET'], permission_classes=[IsOrgAdminPermission]
@@ -385,9 +381,6 @@ class OrganizationMemberViewSet(viewsets.ModelViewSet):
385381
permission_classes = [OrganizationNestedHasOrgRolePermission]
386382
http_method_names = ['get', 'patch', 'delete']
387383
lookup_field = 'user__username'
388-
renderer_classes = [
389-
JSONRenderer,
390-
]
391384

392385
def paginate_queryset(self, queryset):
393386
page = super().paginate_queryset(queryset)
@@ -604,7 +597,6 @@ class OrgMembershipInviteViewSet(viewsets.ModelViewSet):
604597
serializer_class = OrgMembershipInviteSerializer
605598
http_method_names = ['get', 'post', 'patch', 'delete']
606599
lookup_field = 'guid'
607-
renderer_classes = (JSONRenderer,)
608600

609601
def create(self, request, *args, **kwargs):
610602
serializer = self.get_serializer(data=request.data)

kobo/settings/base.py

Lines changed: 29 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -992,7 +992,9 @@ def __init__(self, *args, **kwargs):
992992
'kpi.authentication.OAuth2Authentication',
993993
],
994994
'DEFAULT_RENDERER_CLASSES': [
995-
'rest_framework.renderers.JSONRenderer',
995+
'rest_framework.renderers.JSONRenderer',
996+
# "BasicHTMLRenderer" must always come after JSONRenderer
997+
'kpi.renderers.BasicHTMLRenderer',
996998
],
997999
'DEFAULT_VERSIONING_CLASS': 'kpi.versioning.APIAutoVersioning',
9981000
# Cannot be placed in kpi.exceptions.py because of circular imports
@@ -1030,6 +1032,32 @@ def __init__(self, *args, **kwargs):
10301032
'InviteStatusChoicesEnum': 'kobo.apps.organizations.models.OrganizationInviteStatusChoices.choices', # noqa
10311033
'InviteeRoleEnum': 'kpi.schema_extensions.v2.members.schema.ROLE_CHOICES_PAYLOAD_ENUM', # noqa
10321034
},
1035+
# We only want to blacklist BasicHTMLRenderer, but nothing like RENDERER_WHITELIST
1036+
# exists 🤦
1037+
# List all the renderers that are used by documented API
1038+
'RENDERER_WHITELIST': [
1039+
'rest_framework.renderers.JSONRenderer',
1040+
'rest_framework.renderers.StaticHTMLRenderer',
1041+
'kpi.renderers.MediaFileRenderer',
1042+
'kpi.renderers.MP3ConversionRenderer',
1043+
'kpi.renderers.OpenRosaRenderer',
1044+
'kpi.renderers.OpenRosaFormListRenderer',
1045+
'kpi.renderers.OpenRosaManifestRenderer',
1046+
'kpi.renderers.SSJsonRenderer',
1047+
'kpi.renderers.SubmissionGeoJsonRenderer',
1048+
'kpi.renderers.DoNothingRenderer',
1049+
'kpi.renderers.SubmissionXLSXRenderer',
1050+
'kpi.renderers.SubmissionCSVRenderer',
1051+
'kpi.renderers.SubmissionXMLRenderer',
1052+
'kpi.renderers.XMLRenderer',
1053+
'kpi.renderers.XFormRenderer',
1054+
'kpi.renderers.XlsRenderer',
1055+
'kobo.apps.openrosa.libs.renderers.renderers.XLSRenderer',
1056+
'kobo.apps.openrosa.libs.renderers.renderers.XLSXRenderer',
1057+
'kobo.apps.openrosa.libs.renderers.renderers.CSVRenderer',
1058+
'kobo.apps.openrosa.libs.renderers.renderers.RawXMLRenderer',
1059+
'kobo.apps.openrosa.libs.renderers.renderers.TemplateXMLRenderer'
1060+
],
10331061
'TAGS': [
10341062
{
10351063
'name': 'Manage projects and library content',

kpi/negotiation.py

Lines changed: 21 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -7,14 +7,17 @@ class DefaultContentNegotiation(UpstreamDefaultContentNegociation):
77

88
def select_renderer(self, request, renderers, format_suffix=None):
99
"""
10-
Overrides the default DRF `select_renderer` to handle cases where the client
11-
does not specify a compatible `Accept` header nor a format suffix.
10+
Overrides DRF's `select_renderer` to customize content negotiation.
1211
13-
If the first available renderer is not included in the accepted media types
14-
and no explicit format suffix is given, this override will forcibly return
15-
the first renderer in the list.
12+
- If the client request comes from a browser (i.e., the Accept header
13+
includes `text/html`) and `BasicHTMLRenderer` is available, use it
14+
as the renderer.
1615
17-
Otherwise, the default DRF negotiation logic is applied.
16+
- If the first available renderer is not included in the accepted media
17+
types and no explicit format suffix is provided, fall back to the
18+
first renderer in the list.
19+
20+
- In all other cases, defer to DRF's default content negotiation logic.
1821
"""
1922
format_query_param = self.settings.URL_FORMAT_OVERRIDE
2023
format_ = format_suffix or request.query_params.get(format_query_param)
@@ -23,9 +26,19 @@ def select_renderer(self, request, renderers, format_suffix=None):
2326
first_renderer = renderers[0]
2427
first_renderer_format_allowed = first_renderer.format in accepts
2528

29+
# Force HTML if the request comes from a browser
30+
# (i.e., the Accept header includes HTML) and `BasicHTMLRenderer` is available
31+
# in the list of renderers.
32+
if (
33+
'text/html' in accepts
34+
and len(renderers) > 1
35+
and renderers[1].__class__.__name__ == 'BasicHTMLRenderer'
36+
):
37+
return renderers[1], renderers[1].media_type
38+
39+
# Force fallback to the first renderer
2640
if not first_renderer_format_allowed and not format_:
27-
# force fallback to first renderer
2841
return first_renderer, first_renderer.media_type
2942

30-
# otherwise fallback to DRF's default content negotiation
43+
# Otherwise fallback to DRF's default content negotiation
3144
return super().select_renderer(request, renderers, format_suffix)

kpi/renderers.py

Lines changed: 64 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,18 +4,81 @@
44
from collections.abc import Callable
55
from io import StringIO
66

7-
import formpack
87
from dict2xml import dict2xml
8+
from django.core.serializers.json import DjangoJSONEncoder
9+
from django.template.loader import get_template
910
from django.utils.xmlutils import SimplerXMLGenerator
1011
from rest_framework import renderers, status
1112
from rest_framework.exceptions import ErrorDetail, ParseError
1213
from rest_framework_xml.renderers import XMLRenderer as DRFXMLRenderer
1314

15+
import formpack
1416
from kobo.apps.reports.report_data import build_formpack
1517
from kpi.constants import GEO_QUESTION_TYPES
1618
from kpi.utils.xml import add_xml_declaration
1719

1820

21+
class BasicHTMLRenderer(renderers.BaseRenderer):
22+
media_type = 'text/html'
23+
format = 'html'
24+
charset = 'utf-8'
25+
template_name = 'renderers/basic.html'
26+
27+
PARAM_REGEXES = [
28+
# (?P<uid>[^/.]+) -> {uid}
29+
(re.compile(r'\(\?P<(?P<name>\w+)>(?:[^)]+)\)'), r'{\g<name>}'),
30+
# <converter:name> or <name> -> {name}
31+
(re.compile(r'<(?:\w+:)?(?P<name>\w+)>'), r'{\g<name>}'),
32+
]
33+
34+
def render(self, data, accepted_media_type=None, renderer_context=None):
35+
request = renderer_context.get('request') if renderer_context else None
36+
resolver_match = getattr(request, 'resolver_match', None)
37+
url_pattern_clean = None
38+
39+
if resolver_match:
40+
url_pattern_clean = self._clean_route(resolver_match.route)
41+
42+
try:
43+
pretty = json.dumps(data, indent=2, cls=DjangoJSONEncoder)
44+
except:
45+
pretty = str(data)
46+
47+
context = {'pretty': pretty, 'q_param': url_pattern_clean or ''}
48+
49+
tpl = get_template(self.template_name)
50+
return tpl.render(context)
51+
52+
@classmethod
53+
def _clean_route(cls, raw: str | None) -> str | None:
54+
if not raw:
55+
return None
56+
s = raw.strip()
57+
# strip leading ^ and trailing $
58+
s = s.lstrip('^').rstrip('$')
59+
# Replace named groups and path converters by {name}
60+
for rx, repl in cls.PARAM_REGEXES:
61+
s = rx.sub(repl, s)
62+
# Unescape slashes if a regex had them escaped
63+
s = s.replace(r'\/', '/')
64+
# Normalize multiple slashes (just in case)
65+
s = re.sub(r'/{2,}', '/', s)
66+
67+
return s
68+
69+
@staticmethod
70+
def _extract_raw_route(resolver) -> str | None:
71+
"""
72+
Try to get the raw route/regex from ResolverMatch across Django versions.
73+
74+
"""
75+
76+
if not resolver:
77+
return None
78+
79+
return resolver.route
80+
81+
1982
class MediaFileRenderer(renderers.BaseRenderer):
2083

2184
media_type = '*/*'

kpi/static/js/swagger/swagger-smart-search.js

Lines changed: 41 additions & 24 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@ window.onload = () => {
22
const interval = setInterval(() => {
33
const oldInput = document.querySelector('.operation-filter-input')
44
const tags = document.querySelectorAll('.opblock-tag-section')
5+
const params = new URLSearchParams(window.location.search)
56
if (!oldInput || tags.length === 0) return
67

78
clearInterval(interval)
@@ -10,35 +11,51 @@ window.onload = () => {
1011
oldInput.replaceWith(newInput)
1112
newInput.placeholder = 'Filter by tag, summary, or path'
1213

14+
const filter_query = params.get('q')
15+
if (filter_query && filter_query.trim() !== '') {
16+
newInput.value = filter_query
17+
doSearch(filter_query.toLowerCase(), tags)
18+
}
19+
1320
newInput.addEventListener('input', () => {
1421
const query = newInput.value.toLowerCase()
22+
doSearch(query, tags)
23+
})
24+
}, 200)
25+
}
26+
27+
function doSearch(query, tags) {
28+
// When tags are collapsed, their content is not in the DOM and we cannot search
29+
// for keywords.
30+
if (query !== '') {
31+
expandAllTags()
32+
} else {
33+
collapseAllTags()
34+
}
35+
36+
const endpointsInterval = setInterval(() => {
37+
const endpoints = document.querySelectorAll('.opblock-tag')
38+
if (endpoints.length === 0) return
1539

16-
// When tags are collapsed, their content is not in the DOM and we cannot search
17-
// for keywords.
18-
if (query !== '') {
19-
expandAllTags()
20-
} else {
21-
collapseAllTags()
22-
}
23-
24-
tags.forEach((tag) => {
25-
let showTag = false
26-
const operations = tag.querySelectorAll('.opblock')
27-
28-
operations.forEach((op) => {
29-
const summary = op.querySelector('.opblock-summary-description')?.textContent.toLowerCase() || ''
30-
const path = op.querySelector('.opblock-summary-path')?.textContent.toLowerCase() || ''
31-
const tagName = tag.querySelector('.opblock-tag')?.textContent.toLowerCase() || ''
32-
const match = summary.includes(query) || path.includes(query) || tagName.includes(query)
33-
34-
op.style.display = match ? '' : 'none'
35-
if (match) showTag = true
36-
})
37-
38-
tag.style.display = showTag ? '' : 'none'
40+
clearInterval(endpointsInterval)
41+
42+
tags.forEach((tag) => {
43+
let showTag = !query
44+
const operations = tag.querySelectorAll('.opblock')
45+
46+
operations.forEach((op) => {
47+
const summary = op.querySelector('.opblock-summary-description')?.textContent.toLowerCase() || ''
48+
const path = op.querySelector('.opblock-summary-path')?.textContent.toLowerCase() || ''
49+
const tagName = tag.querySelector('.opblock-tag')?.textContent.toLowerCase() || ''
50+
const match = summary.includes(query) || path.includes(query) || tagName.includes(query)
51+
52+
op.style.display = match ? '' : 'none'
53+
if (match) showTag = true
3954
})
55+
56+
tag.style.display = showTag ? '' : 'none'
4057
})
41-
}, 200)
58+
})
4259
}
4360

4461
function expandAllTags() {

kpi/templates/renderers/basic.html

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,17 @@
1+
{% extends "rest_framework/base.html" %}
2+
3+
{% load static %}
4+
{% load i18n %}
5+
6+
{% block navbar %}
7+
{% endblock %}
8+
9+
{% block breadcrumbs %}
10+
{% endblock %}
11+
12+
{% block content %}
13+
<h1>KoboToolbox API</h1>
14+
<p>For a general introduction to our API, please visit <a href="https://support.kobotoolbox.org/api.html">https://support.kobotoolbox.org/api.html</a>.</p>
15+
<p>Detailed, developer-focused documentation can now be found on our <a href="{% url 'swagger-ui' %}?q={{ q_param }}">new API documentation pages</a>.</p>
16+
<pre>{{ pretty }}</pre>
17+
{% endblock %}

kpi/urls/router_api_v2.py

Lines changed: 10 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@
1414
)
1515
from kobo.apps.project_ownership.urls import router as project_ownership_router
1616
from kobo.apps.project_views.views import ProjectViewViewSet
17+
from kpi.renderers import BasicHTMLRenderer
1718
from kpi.views.v2.asset import AssetViewSet
1819
from kpi.views.v2.asset_counts import AssetCountsViewSet
1920
from kpi.views.v2.asset_export_settings import AssetExportSettingsViewSet
@@ -211,17 +212,23 @@ def get_urls(self, *args, **kwargs):
211212
enketo_url_aliases = [
212213
path(
213214
'assets/<parent_lookup_asset>/data/<pk>/edit/',
214-
DataViewSet.as_view({'get': 'enketo_edit'}, renderer_classes=[JSONRenderer]),
215+
DataViewSet.as_view(
216+
{'get': 'enketo_edit'}, renderer_classes=[JSONRenderer, BasicHTMLRenderer]
217+
),
215218
name='submission-enketo-edit-legacy',
216219
),
217220
path(
218221
'assets/<parent_lookup_asset>/data/<pk>/enketo/redirect/edit/',
219-
DataViewSet.as_view({'get': 'enketo_edit'}, renderer_classes=[JSONRenderer]),
222+
DataViewSet.as_view(
223+
{'get': 'enketo_edit'}, renderer_classes=[JSONRenderer]
224+
),
220225
name='submission-enketo-edit-redirect',
221226
),
222227
path(
223228
'assets/<parent_lookup_asset>/data/<pk>/enketo/redirect/view/',
224-
DataViewSet.as_view({'get': 'enketo_view'}, renderer_classes=[JSONRenderer]),
229+
DataViewSet.as_view(
230+
{'get': 'enketo_view'}, renderer_classes=[JSONRenderer]
231+
),
225232
name='submission-enketo-view-redirect',
226233
),
227234
]

0 commit comments

Comments
 (0)