Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
21 changes: 21 additions & 0 deletions docs/topics/api/developers.rst
Original file line number Diff line number Diff line change
@@ -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`.
1 change: 1 addition & 0 deletions docs/topics/api/index.rst
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,7 @@ using the API.
blocklist
categories
collections
developers
discovery
licenses
ratings
Expand Down
1 change: 1 addition & 0 deletions src/olympia/api/urls.py
Original file line number Diff line number Diff line change
Expand Up @@ -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')),
]


Expand Down
8 changes: 8 additions & 0 deletions src/olympia/devhub/api_urls.py
Original file line number Diff line number Diff line change
@@ -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'),
]
10 changes: 2 additions & 8 deletions src/olympia/devhub/forms.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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(
Expand All @@ -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(
Expand Down
16 changes: 16 additions & 0 deletions src/olympia/devhub/serializers.py
Original file line number Diff line number Diff line change
@@ -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)
107 changes: 107 additions & 0 deletions src/olympia/devhub/tests/test_views.py
Original file line number Diff line number Diff line change
Expand Up @@ -32,9 +32,11 @@
urlparams,
)
from olympia.amo.tests import (
APITestClientSessionID,
TestCase,
addon_factory,
get_random_ip,
reverse_ns,
user_factory,
version_factory,
)
Expand Down Expand Up @@ -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
57 changes: 46 additions & 11 deletions src/olympia/devhub/views.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -58,6 +67,11 @@
send_mail,
send_mail_jinja,
)
from olympia.api.authentication import (
JWTKeyAuthentication,

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

are for internal use only.

Do we want JWTAuthentication then? That's auth method is specifically for external use.

SessionIDAuthentication,
)
from olympia.api.throttling import contact_support_throttles
from olympia.devhub.decorators import (
dev_required,
no_admin_disabled,
Expand Down Expand Up @@ -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')
Expand Down Expand Up @@ -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 (
Expand All @@ -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(
Expand All @@ -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)
Loading