diff --git a/docs/topics/api/developers.rst b/docs/topics/api/developers.rst index b4f598c723b4..bff86c532580 100644 --- a/docs/topics/api/developers.rst +++ b/docs/topics/api/developers.rst @@ -6,6 +6,25 @@ Developers These APIs are subject to change at any time and are for internal use only. +--------- +Agreement +--------- + +.. _developer-agreement: + +This endpoint allows users to accept the developer agreement. + +.. http:post:: /api/v5/developers/agreement + + :json string|null display_name: The user's display name, if set. + :>json boolean has_read_developer_agreement: Whether the user has agreed to the latest agreement. + :>json string last_developer_agreement_change: The date of the last agreement change. + -------- Support -------- diff --git a/src/olympia/api/throttling.py b/src/olympia/api/throttling.py index 888bf56294c9..0ac4943d6048 100644 --- a/src/olympia/api/throttling.py +++ b/src/olympia/api/throttling.py @@ -241,3 +241,16 @@ class APIKeyIPThrottle(GranularIPRateThrottle): api_key_throttles = (APIKeyUserThrottle, APIKeyIPThrottle) + + +class DeveloperAgreementUserThrottle(GranularUserRateThrottle): + scope = 'user_api_key' + rate = '4/day' + + +class DeveloperAgreementIPThrottle(GranularIPRateThrottle): + scope = 'ip_api_key' + rate = '8/day' + + +dev_agreement_throttles = (DeveloperAgreementUserThrottle, DeveloperAgreementIPThrottle) diff --git a/src/olympia/devhub/api_urls.py b/src/olympia/devhub/api_urls.py index 611e6cd3ee06..eead20cb8760 100644 --- a/src/olympia/devhub/api_urls.py +++ b/src/olympia/devhub/api_urls.py @@ -1,8 +1,9 @@ from django.urls import re_path -from .views import developer_support +from .views import developer_agreement_api, developer_support urlpatterns = [ re_path(r'support/', developer_support, name='developer-support'), + re_path(r'agreement/', developer_agreement_api, name='developer-agreement'), ] diff --git a/src/olympia/devhub/serializers.py b/src/olympia/devhub/serializers.py index 737a2dab68f9..81d7e312344c 100644 --- a/src/olympia/devhub/serializers.py +++ b/src/olympia/devhub/serializers.py @@ -1,7 +1,12 @@ -from django.utils.translation import gettext_lazy as _ +from django.core.exceptions import ValidationError as DjangoValidationError +from django.utils.translation import gettext, gettext_lazy as _ from rest_framework import serializers +from olympia.devhub.utils import get_dev_agreement_change_date +from olympia.users.models import UserProfile +from olympia.users.utils import validate_user_name + SUPPORT_CATEGORY_CHOICES = [ ('policy', _('Technical support for making your add-on compliant')), @@ -14,3 +19,38 @@ class SupportSerializer(serializers.Serializer): summary = serializers.CharField(max_length=255) body = serializers.CharField(max_length=10000) category = serializers.ChoiceField(choices=SUPPORT_CATEGORY_CHOICES) + + +class DeveloperAgreementSerializer(serializers.ModelSerializer): + last_developer_agreement_change = serializers.DateTimeField() + + class Meta: + model = UserProfile + fields = ['display_name', 'last_developer_agreement_change'] + + def validate_display_name(self, value): + request = self.context['request'] + # See: AgreementForm + if request.user.is_authenticated and request.user.display_name: + raise serializers.ValidationError('User already has display_name.') + try: + return validate_user_name( + value, error_message=gettext('This display name cannot be used.') + ) + except DjangoValidationError as exc: + raise serializers.ValidationError(exc.messages) from exc + + def validate(self, attrs): + request_user = self.context['request'].user + if request_user.has_anonymous_display_name and 'display_name' not in attrs: + raise serializers.ValidationError( + {'display_name': ['display_name is required.']} + ) + return attrs + + def validate_last_developer_agreement_change(self, value): + if value != get_dev_agreement_change_date(): + raise serializers.ValidationError( + 'Invalid last_developer_agreement_change.' + ) + return value diff --git a/src/olympia/devhub/tests/test_serializers.py b/src/olympia/devhub/tests/test_serializers.py new file mode 100644 index 000000000000..adc0372aa011 --- /dev/null +++ b/src/olympia/devhub/tests/test_serializers.py @@ -0,0 +1,83 @@ +from datetime import datetime, timedelta + +from django.conf import settings + +from rest_framework import serializers +from rest_framework.test import APIRequestFactory + +from olympia import amo +from olympia.amo.tests import TestCase, user_factory +from olympia.devhub.serializers import DeveloperAgreementSerializer +from olympia.zadmin.models import set_config + + +class TestDeveloperAgreementSerializer(TestCase): + def setUp(self): + self.request = APIRequestFactory().get('/') + self.request.user = user_factory(display_name=None) + self.change_date = datetime(2025, 8, 4, 0, 0) + set_config( + amo.config_keys.LAST_DEV_AGREEMENT_CHANGE_DATE, + self.change_date.isoformat(), + ) + self.serializer = DeveloperAgreementSerializer( + context={'request': self.request} + ) + + def test_validate_last_developer_agreement_change(self): + # Passes when matches agreement change date. + assert ( + self.serializer.validate_last_developer_agreement_change(self.change_date) + == self.change_date + ) + + # Errors on mismatch. + with self.assertRaises(serializers.ValidationError): + self.serializer.validate_last_developer_agreement_change( + self.change_date + timedelta(days=1) + ) + + # When none is configured, uses the fallback. + set_config(amo.config_keys.LAST_DEV_AGREEMENT_CHANGE_DATE, None) + assert ( + self.serializer.validate_last_developer_agreement_change( + settings.DEV_AGREEMENT_CHANGE_FALLBACK + ) + == settings.DEV_AGREEMENT_CHANGE_FALLBACK + ) + + # Or, if the configured date is in the future, still uses the fallback. + set_config( + amo.config_keys.LAST_DEV_AGREEMENT_CHANGE_DATE, + (datetime.now() + timedelta(days=10)).strftime('%Y-%m-%d %H:%M'), + ) + assert ( + self.serializer.validate_last_developer_agreement_change( + settings.DEV_AGREEMENT_CHANGE_FALLBACK + ) + == settings.DEV_AGREEMENT_CHANGE_FALLBACK + ) + + def _serializer(self, **data): + data['last_developer_agreement_change'] = self.change_date.isoformat() + return DeveloperAgreementSerializer( + data=data, context={'request': self.request} + ) + + def test_validate_display_name_required_for_anonymous_user(self): + assert self.request.user.has_anonymous_display_name + serializer = self._serializer() + assert not serializer.is_valid() + assert serializer.errors['display_name'] == ['display_name is required.'] + + def test_validate_display_name_rejected_when_user_already_has_one(self): + self.request.user.update(display_name='user') + serializer = self._serializer(display_name='newuser') + assert not serializer.is_valid() + assert serializer.errors['display_name'] == ['User already has display_name.'] + + def test_valid_when_user_already_has_display_name_and_field_omitted(self): + self.request.user.update(display_name='user') + serializer = self._serializer() + assert serializer.is_valid(), serializer.errors + assert 'display_name' not in serializer.validated_data diff --git a/src/olympia/devhub/tests/test_views.py b/src/olympia/devhub/tests/test_views.py index 9c3b0643bdd7..48447ec243c4 100644 --- a/src/olympia/devhub/tests/test_views.py +++ b/src/olympia/devhub/tests/test_views.py @@ -47,7 +47,7 @@ from olympia.devhub.forms import APIKeyForm, SupportForm from olympia.devhub.models import BlogPost, SurveyResponse from olympia.devhub.tasks import validate -from olympia.devhub.views import get_next_version_number +from olympia.devhub.views import developer_agreement_api, get_next_version_number from olympia.files.models import FileUpload from olympia.files.tests.test_models import UploadMixin from olympia.ratings.models import Rating @@ -3075,3 +3075,166 @@ def test_api_post_throttled_ip(self): HTTP_X_FORWARDED_FOR=f'5.6.7.8, {get_random_ip()}', ) assert response.status_code == 429 + + +class TestDeveloperAgreementAPI(TestCase): + client_class = APITestClientSessionID + + def setUp(self): + super().setUp() + self.api_url = reverse_ns('developer-agreement') + self.change_date = datetime(2020, 1, 1, 0, 0) + self.fallback = datetime.strftime( + settings.DEV_AGREEMENT_CHANGE_FALLBACK, '%Y-%m-%d %H:%M' + ) + set_config( + 'last_dev_agreement_change_date', + self.change_date.strftime('%Y-%m-%d %H:%M'), + ) + self.data = {'last_developer_agreement_change': self.change_date.isoformat()} + + def _post(self, **kwargs): + return self.client.post( + self.api_url, + data=json.dumps(self.data), + content_type='application/json', + **kwargs, + ) + + def test_anon_returns_forbidden(self): + response = self._post() + assert response.status_code == 401 + + response = self.client.get(self.api_url) + assert response.status_code == 401 + + @mock.patch('olympia.users.utils.RestrictionChecker.is_submission_allowed') + def test_submission_not_allowed(self, is_submission_allowed_mock): + is_submission_allowed_mock.return_value = False + user = user_factory(read_dev_agreement=None) + self.client.login_api(user) + + # Cannot accept if submission is not allowed. + response = self._post() + assert response.status_code == 400 + user.reload() + assert not user.read_dev_agreement + + # ...but can still get + response = self.client.get(self.api_url) + assert response.status_code == 200 + + @mock.patch('olympia.users.utils.RestrictionChecker.is_submission_allowed') + def test_already_accepted_agreement(self, is_submission_allowed_mock): + is_submission_allowed_mock.return_value = True + user = user_factory(read_dev_agreement=self.days_ago(1)) + self.client.login_api(user) + + # Post returns 400 if already accepted the newest agreement. + response = self._post() + assert response.status_code == 400 + user.reload() + self.assertCloseToNow(user.read_dev_agreement, now=self.days_ago(1)) + + # ...but can still get + response = self.client.get(self.api_url) + assert response.status_code == 200 + + @mock.patch('olympia.users.utils.RestrictionChecker.is_submission_allowed') + def test_basic(self, is_submission_allowed_mock): + is_submission_allowed_mock.return_value = True + user = user_factory(display_name=None, read_dev_agreement=None) + self.client.login_api(user) + + # First get. + response = self.client.get(self.api_url) + assert response.status_code == 200 + assert response.json() == { + 'display_name': None, + 'has_read_developer_agreement': False, + 'last_developer_agreement_change': self.change_date.isoformat(), + } + + # Rejects with no display name. + response = self._post() + assert response.status_code == 400 + + # Can accept with display name. + self.data['display_name'] = 'myuser' + response = self._post() + assert response.status_code == 202 + + # Reflected in get + response = self.client.get(self.api_url) + assert response.status_code == 200 + assert response.json() == { + 'display_name': 'myuser', + 'has_read_developer_agreement': True, + 'last_developer_agreement_change': self.change_date.isoformat(), + } + + # If the dev agreement is updated, get reflects this + # (i.e user has not accepted newest agreement). + with time_machine.travel(datetime.now() + timedelta(5), tick=False): + update_day = datetime.now().replace(second=0, microsecond=0) + set_config( + 'last_dev_agreement_change_date', update_day.strftime('%Y-%m-%d %H:%M') + ) + response = self.client.get(self.api_url) + assert response.status_code == 200 + assert response.json() == { + 'display_name': 'myuser', + 'has_read_developer_agreement': False, + 'last_developer_agreement_change': update_day.isoformat(), + } + + # Cannot accept with display name, since it exists + response = self._post() + assert response.status_code == 400 + + # Can accept. + self.data.pop('display_name') + self.data['last_developer_agreement_change'] = update_day.isoformat() + response = self._post() + assert response.status_code == 202 + + response = self.client.get(self.api_url) + assert response.status_code == 200 + assert response.json() == { + 'display_name': 'myuser', + 'has_read_developer_agreement': True, + 'last_developer_agreement_change': update_day.isoformat(), + } + + def test_throttled_user(self): + user = user_factory(read_dev_agreement=None) + self.client.login_api(user) + with time_machine.travel(datetime.now(), tick=False): + for _x in range(4): + self._add_fake_throttling_action( + view_class=developer_agreement_api.cls, + url=self.api_url, + user=user, + remote_addr='1.2.3.4', + ) + response = self._post() + assert response.status_code == 429 + assert user.has_anonymous_display_name + + def test_throttled_ip(self): + with time_machine.travel(datetime.now(), tick=False): + for _x in range(8): + self._add_fake_throttling_action( + view_class=developer_agreement_api.cls, + url=self.api_url, + user=user_factory(), + remote_addr='5.6.7.8', + ) + user = user_factory(read_dev_agreement=None) + self.client.login_api(user) + response = self._post( + REMOTE_ADDR='5.6.7.8', + HTTP_X_FORWARDED_FOR=f'5.6.7.8, {get_random_ip()}', + ) + assert response.status_code == 429 + assert user.has_anonymous_display_name diff --git a/src/olympia/devhub/utils.py b/src/olympia/devhub/utils.py index aa8fb0fc3c88..faddc88e652f 100644 --- a/src/olympia/devhub/utils.py +++ b/src/olympia/devhub/utils.py @@ -1,4 +1,5 @@ import uuid +from datetime import datetime from django.conf import settings from django.db import transaction @@ -24,6 +25,7 @@ ) from olympia.versions.models import Version from olympia.versions.utils import process_color_value +from olympia.zadmin.models import get_config from . import tasks @@ -361,6 +363,31 @@ def create_version_for_upload(*, addon, upload, channel, client_info=None): return version +def get_dev_agreement_change_date(): + + last_agreement_change_config = None + try: + last_agreement_change_config = get_config( + amo.config_keys.LAST_DEV_AGREEMENT_CHANGE_DATE + ) + change_config_date = datetime.strptime( + last_agreement_change_config, '%Y-%m-%d %H:%M' + ) + + # If the config date is in the future, instead + # check against the fallback date + if change_config_date > datetime.now(): + return settings.DEV_AGREEMENT_CHANGE_FALLBACK + + return change_config_date + except (ValueError, TypeError): + log.exception( + 'last_developer_agreement_change misconfigured, "%s" is not a datetime', + last_agreement_change_config, + ) + return settings.DEV_AGREEMENT_CHANGE_FALLBACK + + def get_activity_feed(action, addons): if not isinstance(addons, (list, tuple)): # MySQL 8.0.21 (and maybe higher) doesn't optimize the join with diff --git a/src/olympia/devhub/views.py b/src/olympia/devhub/views.py index ef44add9e988..3c72cd10ba00 100644 --- a/src/olympia/devhub/views.py +++ b/src/olympia/devhub/views.py @@ -67,10 +67,8 @@ send_mail, send_mail_jinja, ) -from olympia.api.authentication import ( - SessionIDAuthentication, -) -from olympia.api.throttling import contact_support_throttles +from olympia.api.authentication import SessionIDAuthentication +from olympia.api.throttling import contact_support_throttles, dev_agreement_throttles from olympia.devhub.decorators import ( dev_required, no_admin_disabled, @@ -81,6 +79,7 @@ from olympia.devhub.utils import ( extract_theme_properties, get_activity_feed, + get_dev_agreement_change_date, wizard_unsupported_properties, ) from olympia.files.models import File, FileUpload @@ -105,7 +104,7 @@ from olympia.zadmin.models import get_config from . import feeds, forms, tasks -from .serializers import SupportSerializer +from .serializers import DeveloperAgreementSerializer, SupportSerializer log = olympia.core.logger.getLogger('z.devhub') @@ -2362,3 +2361,39 @@ def developer_support(request): 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) + + +@api_view(['POST', 'GET']) +@authentication_classes([SessionIDAuthentication]) +@permission_classes((IsAuthenticated,)) +@throttle_classes(dev_agreement_throttles) +def developer_agreement_api(request): + if request.method == 'GET': + return Response( + { + 'display_name': request.user.display_name, + 'has_read_developer_agreement': ( + request.user.has_read_developer_agreement() + ), + 'last_developer_agreement_change': get_dev_agreement_change_date(), + }, + status=status.HTTP_200_OK, + ) + else: + if ( + not RestrictionChecker(request=request).is_submission_allowed() + or request.user.has_read_developer_agreement() + ): + return Response(status=status.HTTP_400_BAD_REQUEST) + + serializer = DeveloperAgreementSerializer( + data=request.data, context={'request': request} + ) + serializer.is_valid(raise_exception=True) + + data = {'read_dev_agreement': datetime.datetime.now()} + if 'display_name' in serializer.validated_data: + data['display_name'] = serializer.validated_data['display_name'] + + request.user.update(**data) + return Response(status=status.HTTP_202_ACCEPTED) diff --git a/src/olympia/users/models.py b/src/olympia/users/models.py index 116396001b04..0d547af02a52 100644 --- a/src/olympia/users/models.py +++ b/src/olympia/users/models.py @@ -522,31 +522,11 @@ def has_module_perms(self, app_label): ) def has_read_developer_agreement(self): - from olympia.zadmin.models import get_config + from olympia.devhub.utils import get_dev_agreement_change_date if self.read_dev_agreement is None: return False - last_agreement_change_config = None - try: - last_agreement_change_config = get_config( - amo.config_keys.LAST_DEV_AGREEMENT_CHANGE_DATE - ) - change_config_date = datetime.strptime( - last_agreement_change_config, '%Y-%m-%d %H:%M' - ) - - # If the config date is in the future, instead check against the - # fallback date - if change_config_date > datetime.now(): - return self.read_dev_agreement > settings.DEV_AGREEMENT_CHANGE_FALLBACK - - return self.read_dev_agreement > change_config_date - except (ValueError, TypeError): - log.exception( - 'last_developer_agreement_change misconfigured, "%s" is not a datetime', - last_agreement_change_config, - ) - return self.read_dev_agreement > settings.DEV_AGREEMENT_CHANGE_FALLBACK + return self.read_dev_agreement > get_dev_agreement_change_date() def get_session_auth_hash(self): """Return a hash used to invalidate sessions of users when necessary.