Skip to content
Merged
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
19 changes: 19 additions & 0 deletions docs/topics/api/developers.rst
Original file line number Diff line number Diff line change
Expand Up @@ -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: User's chosen display name. Required if the user doesn't have a display name yet. Errors if they already have one.
:<json string last_developer_agreement_change: The date of the last agreement change.

.. http:get:: /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.

Comment on lines +17 to +27

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.

I think this feedback is targeted more towards the ticket itself than this work, but this doesn't match what I had in mind (and again, we should have fleshed this out better - one for a team retro imo).

Feel free to turn this into a proper discussion, I might have misunderstood things.

My expectations (these are just examples, I haven't validated these are real);

http:post::
  :>json string user_uuid: something unique to identify the user with
  :>json string accepted_agreement_version: version of the developer agreement accepted (or YYYY-MM-DD)
  server inferred; UTC date developer agreement accepted

http:get:: (should be public, not internal only per ticket comment from @diox)
  :>query/parameter:>json string user_uuid
  :>returns: is_latest_accepted: boolean
  :>returns: agreement_version: version of the developer agreement  (or YYYY-MM-DD)
  :>optional return (as in I'm questioning the value of this): agreement_content: markdown content of the latest developer agreement

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

should be public, not internal only

Right now it (and the other API work I've done so far) are generally exclusively using SessionIDAuthentication, i.e. 'internal use'. Making it external is mainly adding JWT auth. Is that more in line with what would be needed? In which case I'd need to update those endpoints as well.

See: #25374 (comment)

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.

@chrstinalin agree on your other comment about user_uuid being extraneous when it's an authenticated request. But from the original, I don't know / understand why you'd want the user display_name?

Regarding internal/external use, I'm basing that requirement on this comment from Mat; mozilla/addons#16377 (comment)

@chrstinalin chrstinalin Sep 15, 2026

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

display_name is currently needed when its the first time the user has registered, I ended up modifying the behaviour a bit so that's a bit more clear (there's comments throughout), but you can see it here, for a fresh user:

image

@chrstinalin chrstinalin Sep 15, 2026

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Regarding internal/external use, I'm basing that requirement on this comment from Mat; mozilla/addons#16377 (comment)

Ahh, right. I forgot to mention. For that, I did see that read_dev_agreement is exposed already through /api/v5/accounts/account/(int:user_id|string:username)/ . So it is possible to get that information via JWT without this endpoint.

--------
Support
--------
Expand Down
13 changes: 13 additions & 0 deletions src/olympia/api/throttling.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)
3 changes: 2 additions & 1 deletion src/olympia/devhub/api_urls.py
Original file line number Diff line number Diff line change
@@ -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'),
]
42 changes: 41 additions & 1 deletion src/olympia/devhub/serializers.py
Original file line number Diff line number Diff line change
@@ -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')),
Expand All @@ -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
83 changes: 83 additions & 0 deletions src/olympia/devhub/tests/test_serializers.py
Original file line number Diff line number Diff line change
@@ -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
165 changes: 164 additions & 1 deletion src/olympia/devhub/tests/test_views.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
Loading
Loading