From e6929746bfd480abba3810e114bec936bff12abe Mon Sep 17 00:00:00 2001 From: Christina Lin Date: Thu, 3 Sep 2026 12:04:27 -0400 Subject: [PATCH 1/2] Add `api/v5/developers/support` endpoint --- docs/topics/api/developers.rst | 21 +++++ docs/topics/api/index.rst | 1 + src/olympia/api/urls.py | 1 + src/olympia/devhub/api_urls.py | 8 ++ src/olympia/devhub/forms.py | 10 +-- src/olympia/devhub/serializers.py | 16 ++++ src/olympia/devhub/tests/test_views.py | 107 +++++++++++++++++++++++++ src/olympia/devhub/views.py | 57 ++++++++++--- 8 files changed, 202 insertions(+), 19 deletions(-) create mode 100644 docs/topics/api/developers.rst create mode 100644 src/olympia/devhub/api_urls.py create mode 100644 src/olympia/devhub/serializers.py diff --git a/docs/topics/api/developers.rst b/docs/topics/api/developers.rst new file mode 100644 index 000000000000..b4f598c723b4 --- /dev/null +++ b/docs/topics/api/developers.rst @@ -0,0 +1,21 @@ +========== +Developers +========== + +.. note:: + + These APIs are subject to change at any time and are for internal use only. + +-------- +Support +-------- + +.. _developer-support: + +This endpoint allows users to submit a support ticket to AMO. Echoes the submitted data on success. + +.. http:post:: /api/v5/developers/support + + :>json string summary: Issue summary. + :>json string body: Details about the issue. + :>json string category: Issue category. Can be `policy`, `technical`, or `other`. diff --git a/docs/topics/api/index.rst b/docs/topics/api/index.rst index 92ea3f0f09fb..5ff0844580df 100644 --- a/docs/topics/api/index.rst +++ b/docs/topics/api/index.rst @@ -41,6 +41,7 @@ using the API. blocklist categories collections + developers discovery licenses ratings diff --git a/src/olympia/api/urls.py b/src/olympia/api/urls.py index 483222bafa6e..3d85716a7ba4 100644 --- a/src/olympia/api/urls.py +++ b/src/olympia/api/urls.py @@ -100,6 +100,7 @@ def get_versioned_api_routes(version, url_patterns): re_path(r'^', include(amo_api_patterns)), re_path(r'^scanner/', include('olympia.scanners.api_urls')), re_path(r'^shelves/', include('olympia.shelves.urls')), + re_path(r'^developers/', include('olympia.devhub.api_urls')), ] diff --git a/src/olympia/devhub/api_urls.py b/src/olympia/devhub/api_urls.py new file mode 100644 index 000000000000..611e6cd3ee06 --- /dev/null +++ b/src/olympia/devhub/api_urls.py @@ -0,0 +1,8 @@ +from django.urls import re_path + +from .views import developer_support + + +urlpatterns = [ + re_path(r'support/', developer_support, name='developer-support'), +] diff --git a/src/olympia/devhub/forms.py b/src/olympia/devhub/forms.py index 95490b883dd4..501f5c07e44b 100644 --- a/src/olympia/devhub/forms.py +++ b/src/olympia/devhub/forms.py @@ -53,6 +53,7 @@ ) from olympia.applications.models import AppVersion from olympia.constants.categories import CATEGORIES, CATEGORIES_BY_ID +from olympia.devhub.serializers import SUPPORT_CATEGORY_CHOICES from olympia.devhub.widgets import CategoriesSelectMultiple, IconTypeSelect from olympia.files.models import FileUpload from olympia.files.utils import SafeTar, SafeZip, parse_addon @@ -1774,13 +1775,6 @@ def can_rollback(self): class SupportForm(CheckThrottlesFormMixin, forms.Form): - CATEGORY_CHOICES = [ - ('', _('Choose a category')), - ('policy', _('Technical support for making your add-on compliant')), - ('technical', _('Issue with addons.mozilla.org')), - ('other', _('Other')), - ] - throttle_classes = contact_support_throttles summary = forms.CharField( @@ -1791,7 +1785,7 @@ class SupportForm(CheckThrottlesFormMixin, forms.Form): ), ) category = forms.ChoiceField( - choices=CATEGORY_CHOICES, + choices=[('', _('Choose a category')), *SUPPORT_CATEGORY_CHOICES], label=_('Select category'), ) body = forms.CharField( diff --git a/src/olympia/devhub/serializers.py b/src/olympia/devhub/serializers.py new file mode 100644 index 000000000000..737a2dab68f9 --- /dev/null +++ b/src/olympia/devhub/serializers.py @@ -0,0 +1,16 @@ +from django.utils.translation import gettext_lazy as _ + +from rest_framework import serializers + + +SUPPORT_CATEGORY_CHOICES = [ + ('policy', _('Technical support for making your add-on compliant')), + ('technical', _('Issue with addons.mozilla.org')), + ('other', _('Other')), +] + + +class SupportSerializer(serializers.Serializer): + summary = serializers.CharField(max_length=255) + body = serializers.CharField(max_length=10000) + category = serializers.ChoiceField(choices=SUPPORT_CATEGORY_CHOICES) diff --git a/src/olympia/devhub/tests/test_views.py b/src/olympia/devhub/tests/test_views.py index a91afcac5008..9c3b0643bdd7 100644 --- a/src/olympia/devhub/tests/test_views.py +++ b/src/olympia/devhub/tests/test_views.py @@ -32,9 +32,11 @@ urlparams, ) from olympia.amo.tests import ( + APITestClientSessionID, TestCase, addon_factory, get_random_ip, + reverse_ns, user_factory, version_factory, ) @@ -2968,3 +2970,108 @@ def test_post_throttled_ip(self): 'You have submitted this form too many times recently. ' 'Please try again after some time.' ] + + +@override_switch('enable-devhub-support-form', active=True) +@override_settings(FXA_SUPPORT_SECRET='mysecret') +class TestSupportAPI(TestCase): + client_class = APITestClientSessionID + + def setUp(self): + super().setUp() + self.user = user_factory() + self.api_url = reverse_ns('developer-support') + + self.payload = { + 'summary': 'Something is broken', + 'category': 'technical', + 'body': 'Please help me fix this issue.', + } + + def _post(self, data=None): + if data is None: + data = self.payload + return self.client.post( + self.api_url, + data=json.dumps(data), + content_type='application/json', + ) + + @override_switch('enable-devhub-support-form', active=False) + def test_api_switch_inactive_returns_404(self): + self.client.login_api(self.user) + response = self._post() + assert response.status_code == 404 + + @override_settings(FXA_SUPPORT_SECRET='') + def test_api_no_secret_returns_404(self): + self.client.login_api(self.user) + response = self._post() + assert response.status_code == 404 + + def test_api_post_anonymous_returns_401(self): + response = self._post() + assert response.status_code == 401 + + def test_api_post_invalid_missing_fields(self): + self.client.login_api(self.user) + response = self._post({'summary': '', 'category': '', 'body': ''}) + assert response.status_code == 400 + data = response.json() + assert 'may not be blank' in data['summary'][0] + assert 'may not be blank' in data['body'][0] + assert 'is not a valid choice' in data['category'][0] + + @mock.patch('olympia.devhub.tasks.create_support_ticket.delay') + def test_api_post_success(self, mock_task): + self.client.login_api(self.user) + with self.settings(FXA_SUPPORT_BRAND_ID=None): + response = self._post() + assert response.status_code == 202 + mock_task.assert_called_once() + (payload,) = mock_task.call_args[0] + assert payload['topic'] == 'technical' + assert payload['subject'] == 'Something is broken' + assert payload['email'] == self.user.email + assert 'brand_id' not in payload + + @mock.patch('olympia.devhub.tasks.create_support_ticket.delay') + def test_api_post_success_with_brand_id(self, mock_task): + self.client.login_api(self.user) + with self.settings(FXA_SUPPORT_BRAND_ID=12345): + response = self._post() + assert response.status_code == 202 + (payload,) = mock_task.call_args[0] + assert payload['brand_id'] == 12345 + + def test_api_post_throttled_user(self): + self.client.login_api(self.user) + with time_machine.travel(datetime.now(), tick=False): + for _x in range(10): + self._add_fake_throttling_action( + view_class=SupportForm, + url=self.api_url, + user=self.user, + remote_addr='1.2.3.4', + ) + response = self._post() + assert response.status_code == 429 + + def test_api_post_throttled_ip(self): + with time_machine.travel(datetime.now(), tick=False): + for _x in range(20): + self._add_fake_throttling_action( + view_class=SupportForm, + url=self.api_url, + user=user_factory(), + remote_addr='5.6.7.8', + ) + self.client.login_api(self.user) + response = self.client.post( + self.api_url, + data=json.dumps(self.payload), + content_type='application/json', + REMOTE_ADDR='5.6.7.8', + HTTP_X_FORWARDED_FOR=f'5.6.7.8, {get_random_ip()}', + ) + assert response.status_code == 429 diff --git a/src/olympia/devhub/views.py b/src/olympia/devhub/views.py index e9e2e4665b72..3049ff788264 100644 --- a/src/olympia/devhub/views.py +++ b/src/olympia/devhub/views.py @@ -25,6 +25,15 @@ import waffle from csp.decorators import csp_update from django_statsd.clients import statsd +from rest_framework import status +from rest_framework.decorators import ( + api_view, + authentication_classes, + permission_classes, + throttle_classes, +) +from rest_framework.permissions import IsAuthenticated +from rest_framework.response import Response import olympia.core.logger from olympia import amo @@ -58,6 +67,11 @@ send_mail, send_mail_jinja, ) +from olympia.api.authentication import ( + JWTKeyAuthentication, + SessionIDAuthentication, +) +from olympia.api.throttling import contact_support_throttles from olympia.devhub.decorators import ( dev_required, no_admin_disabled, @@ -91,6 +105,7 @@ from olympia.zadmin.models import get_config from . import feeds, forms, tasks +from .serializers import SupportSerializer log = olympia.core.logger.getLogger('z.devhub') @@ -2318,6 +2333,20 @@ def email_verification(request): return TemplateResponse(request, 'devhub/verify_email.html', context=data) +def send_support_ticket(*, user, category, summary, body): + payload = { + 'productName': settings.FXA_SUPPORT_PRODUCT_NAME, + 'topic': category, + 'subject': summary, + 'message': body, + 'email': user.email, + } + if settings.FXA_SUPPORT_BRAND_ID is not None: + payload['brand_id'] = settings.FXA_SUPPORT_BRAND_ID + + tasks.create_support_ticket.delay(payload) + + @login_required def support(request): if ( @@ -2331,17 +2360,7 @@ def support(request): request=request, ) if request.method == 'POST' and form.is_valid(): - payload = { - 'productName': settings.FXA_SUPPORT_PRODUCT_NAME, - 'topic': form.cleaned_data['category'], - 'subject': form.cleaned_data['summary'], - 'message': form.cleaned_data['body'], - } - if settings.FXA_SUPPORT_BRAND_ID is not None: - payload['brand_id'] = settings.FXA_SUPPORT_BRAND_ID - payload['email'] = request.user.email - - tasks.create_support_ticket.delay(payload) + send_support_ticket(user=request.user, **form.cleaned_data) messages.success( request, gettext( @@ -2364,3 +2383,19 @@ def survey_response(request, survey_id): except IntegrityError: return http.HttpResponse(status=500) return http.HttpResponse(status=201) + + +@api_view(['POST']) +@authentication_classes((SessionIDAuthentication, JWTKeyAuthentication)) +@permission_classes((IsAuthenticated,)) +@throttle_classes(contact_support_throttles) +def developer_support(request): + if ( + not waffle.switch_is_active('enable-devhub-support-form') + or not settings.FXA_SUPPORT_SECRET + ): + raise http.Http404 + serializer = SupportSerializer(data=request.data) + serializer.is_valid(raise_exception=True) + send_support_ticket(user=request.user, **serializer.validated_data) + return Response(serializer.validated_data, status=status.HTTP_202_ACCEPTED) From 05002e58c01a5f834bc33e8008268bf6f8933a5d Mon Sep 17 00:00:00 2001 From: Christina Lin Date: Tue, 8 Sep 2026 11:25:25 -0400 Subject: [PATCH 2/2] Remove JWTJWTKeyAuthentication --- src/olympia/devhub/views.py | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/src/olympia/devhub/views.py b/src/olympia/devhub/views.py index 3049ff788264..850a50728fbb 100644 --- a/src/olympia/devhub/views.py +++ b/src/olympia/devhub/views.py @@ -68,7 +68,6 @@ send_mail_jinja, ) from olympia.api.authentication import ( - JWTKeyAuthentication, SessionIDAuthentication, ) from olympia.api.throttling import contact_support_throttles @@ -2386,7 +2385,7 @@ def survey_response(request, survey_id): @api_view(['POST']) -@authentication_classes((SessionIDAuthentication, JWTKeyAuthentication)) +@authentication_classes([SessionIDAuthentication]) @permission_classes((IsAuthenticated,)) @throttle_classes(contact_support_throttles) def developer_support(request):