From d87ae76c50bc6c5cfcac37b9e2578cbb2ed13f81 Mon Sep 17 00:00:00 2001 From: rgraber Date: Fri, 10 Jul 2026 09:15:43 -0400 Subject: [PATCH 1/6] feat(TOS): allows admins to force TOS reaccpetance DEV-2178 --- hub/admin/sitewide_message.py | 33 +++++++++++++++++++ kobo/apps/accounts/tests/test_current_user.py | 12 +++++++ kobo/settings/base.py | 1 + kpi/serializers/current_user.py | 12 ++++--- 4 files changed, 54 insertions(+), 4 deletions(-) diff --git a/hub/admin/sitewide_message.py b/hub/admin/sitewide_message.py index 37cefbd3a3..d0ccdaa81d 100644 --- a/hub/admin/sitewide_message.py +++ b/hub/admin/sitewide_message.py @@ -1,5 +1,9 @@ from __future__ import annotations +from constance import config +from django.contrib import admin +from django.utils import timezone + from kobo.apps.markdownx_uploader.admin import MarkdownxModelAdminBase from ..models import SitewideMessage @@ -7,3 +11,32 @@ class SitewideMessageAdmin(MarkdownxModelAdminBase): model = SitewideMessage + actions = ['require_terms_of_service_reacceptance'] + + def changelist_view(self, request, extra_context=None): + actions = self.get_actions(request) + # cheating: since the require_terms_of_service_reacceptance doesn't apply to + # individual SitewideMessages, always act as if all of them have been selected + # to avoid 'Items must be selected in order to perform actions on them.' error + if ( + actions + and request.method == 'POST' + and 'index' in request.POST + and request.POST['action'] == 'require_terms_of_service_reacceptance' + ): + data = request.POST.copy() + data['select_across'] = '1' + request.POST = data + response = self.response_action( + request, queryset=self.get_queryset(request) + ) + if response: + return response + return super().changelist_view(request, extra_context) + + @admin.action(description='Require all users to reaccept the Terms of Service') + def require_terms_of_service_reacceptance(self, request, *args, **kwargs): + setattr(config, 'LAST_TOS_UPDATE', timezone.now()) + self.message_user( + request, 'All users will be prompted to re-accept the Terms of Service' + ) diff --git a/kobo/apps/accounts/tests/test_current_user.py b/kobo/apps/accounts/tests/test_current_user.py index 22093b57b5..00ff8db409 100644 --- a/kobo/apps/accounts/tests/test_current_user.py +++ b/kobo/apps/accounts/tests/test_current_user.py @@ -1,4 +1,5 @@ import re +from datetime import timedelta import dateutil from constance.test import override_config @@ -6,6 +7,7 @@ from django.core import mail from django.urls import reverse from django.utils import timezone +from freezegun import freeze_time from model_bakery import baker from rest_framework import status from rest_framework.test import APITestCase @@ -148,6 +150,16 @@ def now_without_microseconds(): response = self.client.get(self.url) assert response.data['accepted_tos'] == True + # require reacceptance + with override_config(LAST_TOS_UPDATE=timezone.now()): + response = self.client.get(self.url) + assert response.data['accepted_tos'] == False + # make it a bit later and re-accept + with freeze_time(timezone.now() + timedelta(seconds=1)): + self.client.post(reverse('tos')) + response = self.client.get(self.url) + assert response.data['accepted_tos'] == True + @override_config( USER_METADATA_FIELDS=[ {'name': 'organization', 'required': True}, diff --git a/kobo/settings/base.py b/kobo/settings/base.py index af112352d2..073c7b0f3a 100644 --- a/kobo/settings/base.py +++ b/kobo/settings/base.py @@ -218,6 +218,7 @@ 'if field is not blank' ), 'TERMS_OF_SERVICE_URL': ('', 'URL for terms of service document'), + 'LAST_TOS_UPDATE': (None, 'Date of the most recent update to the terms of service'), 'PRIVACY_POLICY_URL': ('', 'URL for privacy policy'), 'SOURCE_CODE_URL': ( 'https://github.com/kobotoolbox/', diff --git a/kpi/serializers/current_user.py b/kpi/serializers/current_user.py index 34d1ea1c11..f0ddac78c2 100644 --- a/kpi/serializers/current_user.py +++ b/kpi/serializers/current_user.py @@ -2,6 +2,7 @@ from zoneinfo import ZoneInfo import constance +from constance import config from django.conf import settings from django.contrib.auth import update_session_auth_hash from django.contrib.auth.password_validation import validate_password @@ -91,10 +92,13 @@ def get_accepted_tos(self, obj: User) -> bool: user_extra_details = obj.extra_details except obj.extra_details.RelatedObjectDoesNotExist: return False - accepted_tos = ( - 'last_tos_accept_time' in user_extra_details.private_data.keys() - ) - return accepted_tos + most_recent_tos_update = config.LAST_TOS_UPDATE + last_accepted = user_extra_details.private_data.get('last_tos_accept_time') + if not last_accepted: + return False + if not most_recent_tos_update: + return True + return last_accepted > most_recent_tos_update.strftime('%Y-%m-%dT%H:%M:%SZ') @extend_schema_field(DateJoinedField) def get_date_joined(self, obj): From e5b90aeb1987c85985d736d5cfddd7beb932b06b Mon Sep 17 00:00:00 2001 From: rgraber Date: Fri, 10 Jul 2026 10:26:00 -0400 Subject: [PATCH 2/6] fixup!: fixes --- .../0023_alter_sitewidemessage_slug.py | 18 ++++++++++++++++++ hub/models/sitewide_message.py | 2 +- kobo/apps/accounts/tests/test_current_user.py | 8 ++++---- kobo/settings/base.py | 2 +- kpi/serializers/current_user.py | 2 +- 5 files changed, 25 insertions(+), 7 deletions(-) create mode 100644 hub/migrations/0023_alter_sitewidemessage_slug.py diff --git a/hub/migrations/0023_alter_sitewidemessage_slug.py b/hub/migrations/0023_alter_sitewidemessage_slug.py new file mode 100644 index 0000000000..40b29cfef2 --- /dev/null +++ b/hub/migrations/0023_alter_sitewidemessage_slug.py @@ -0,0 +1,18 @@ +# Generated by Django 5.2.13 on 2026-07-10 13:24 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('hub', '0022_delete_v1usertracker'), + ] + + operations = [ + migrations.AlterField( + model_name='sitewidemessage', + name='slug', + field=models.CharField(max_length=50, unique=True), + ), + ] diff --git a/hub/models/sitewide_message.py b/hub/models/sitewide_message.py index 0f0cf5c549..064398ee7e 100644 --- a/hub/models/sitewide_message.py +++ b/hub/models/sitewide_message.py @@ -7,7 +7,7 @@ class SitewideMessage(AbstractMarkdownxModel): - slug = models.CharField(max_length=50) + slug = models.CharField(max_length=50, unique=True) body = MarkdownxField() markdown_fields = ['body'] diff --git a/kobo/apps/accounts/tests/test_current_user.py b/kobo/apps/accounts/tests/test_current_user.py index 00ff8db409..8a6ab30876 100644 --- a/kobo/apps/accounts/tests/test_current_user.py +++ b/kobo/apps/accounts/tests/test_current_user.py @@ -127,7 +127,7 @@ def test_validated_password_becomes_true_on_password_change(self): def test_accepted_tos(self): # Ensure accepted_tos is initially False response = self.client.get(self.url) - assert response.data['accepted_tos'] == False + assert response.data['accepted_tos'] is False assert ( 'last_tos_accept_time' not in self.user.extra_details.private_data ) @@ -148,17 +148,17 @@ def now_without_microseconds(): # Ensure accepted_tos is now True after accepting ToS response = self.client.get(self.url) - assert response.data['accepted_tos'] == True + assert response.data['accepted_tos'] is True # require reacceptance with override_config(LAST_TOS_UPDATE=timezone.now()): response = self.client.get(self.url) - assert response.data['accepted_tos'] == False + assert response.data['accepted_tos'] is False # make it a bit later and re-accept with freeze_time(timezone.now() + timedelta(seconds=1)): self.client.post(reverse('tos')) response = self.client.get(self.url) - assert response.data['accepted_tos'] == True + assert response.data['accepted_tos'] is True @override_config( USER_METADATA_FIELDS=[ diff --git a/kobo/settings/base.py b/kobo/settings/base.py index 073c7b0f3a..f68db58314 100644 --- a/kobo/settings/base.py +++ b/kobo/settings/base.py @@ -218,7 +218,7 @@ 'if field is not blank' ), 'TERMS_OF_SERVICE_URL': ('', 'URL for terms of service document'), - 'LAST_TOS_UPDATE': (None, 'Date of the most recent update to the terms of service'), + 'LAST_TOS_UPDATE': ('', 'Date of the most recent update to the terms of service'), 'PRIVACY_POLICY_URL': ('', 'URL for privacy policy'), 'SOURCE_CODE_URL': ( 'https://github.com/kobotoolbox/', diff --git a/kpi/serializers/current_user.py b/kpi/serializers/current_user.py index f0ddac78c2..e747668a80 100644 --- a/kpi/serializers/current_user.py +++ b/kpi/serializers/current_user.py @@ -92,10 +92,10 @@ def get_accepted_tos(self, obj: User) -> bool: user_extra_details = obj.extra_details except obj.extra_details.RelatedObjectDoesNotExist: return False - most_recent_tos_update = config.LAST_TOS_UPDATE last_accepted = user_extra_details.private_data.get('last_tos_accept_time') if not last_accepted: return False + most_recent_tos_update = config.LAST_TOS_UPDATE if not most_recent_tos_update: return True return last_accepted > most_recent_tos_update.strftime('%Y-%m-%dT%H:%M:%SZ') From bd8b2f6f294c8df0d10aeeac7642c6696f560e9b Mon Sep 17 00:00:00 2001 From: rgraber Date: Fri, 10 Jul 2026 10:36:59 -0400 Subject: [PATCH 3/6] fixup!: sigh --- hub/admin/sitewide_message.py | 4 +++- kpi/serializers/current_user.py | 2 +- 2 files changed, 4 insertions(+), 2 deletions(-) diff --git a/hub/admin/sitewide_message.py b/hub/admin/sitewide_message.py index d0ccdaa81d..6675b06053 100644 --- a/hub/admin/sitewide_message.py +++ b/hub/admin/sitewide_message.py @@ -36,7 +36,9 @@ def changelist_view(self, request, extra_context=None): @admin.action(description='Require all users to reaccept the Terms of Service') def require_terms_of_service_reacceptance(self, request, *args, **kwargs): - setattr(config, 'LAST_TOS_UPDATE', timezone.now()) + setattr( + config, 'LAST_TOS_UPDATE', timezone.now().strftime('%Y-%m-%dT%H:%M:%SZ') + ) self.message_user( request, 'All users will be prompted to re-accept the Terms of Service' ) diff --git a/kpi/serializers/current_user.py b/kpi/serializers/current_user.py index e747668a80..a83e4496f7 100644 --- a/kpi/serializers/current_user.py +++ b/kpi/serializers/current_user.py @@ -98,7 +98,7 @@ def get_accepted_tos(self, obj: User) -> bool: most_recent_tos_update = config.LAST_TOS_UPDATE if not most_recent_tos_update: return True - return last_accepted > most_recent_tos_update.strftime('%Y-%m-%dT%H:%M:%SZ') + return last_accepted > most_recent_tos_update @extend_schema_field(DateJoinedField) def get_date_joined(self, obj): From 5363fccbb7932122e2aa3d78b54e682cc9590911 Mon Sep 17 00:00:00 2001 From: rgraber Date: Fri, 10 Jul 2026 10:55:19 -0400 Subject: [PATCH 4/6] fixup!: unit test better --- hub/admin/sitewide_message.py | 20 +++++++---- hub/tests/test_sitewide_message_admin.py | 43 ++++++++++++++++++++++++ 2 files changed, 57 insertions(+), 6 deletions(-) create mode 100644 hub/tests/test_sitewide_message_admin.py diff --git a/hub/admin/sitewide_message.py b/hub/admin/sitewide_message.py index 6675b06053..c373f4cb06 100644 --- a/hub/admin/sitewide_message.py +++ b/hub/admin/sitewide_message.py @@ -36,9 +36,17 @@ def changelist_view(self, request, extra_context=None): @admin.action(description='Require all users to reaccept the Terms of Service') def require_terms_of_service_reacceptance(self, request, *args, **kwargs): - setattr( - config, 'LAST_TOS_UPDATE', timezone.now().strftime('%Y-%m-%dT%H:%M:%SZ') - ) - self.message_user( - request, 'All users will be prompted to re-accept the Terms of Service' - ) + tos_exists = SitewideMessage.objects.filter(slug='terms_of_service').exists() + if tos_exists: + setattr( + config, 'LAST_TOS_UPDATE', timezone.now().strftime('%Y-%m-%dT%H:%M:%SZ') + ) + self.message_user( + request, 'All users will be prompted to re-accept the Terms of Service' + ) + else: + self.message_user( + request, + 'Add a SitewideMessage with the slug terms_of_service' + ' before requiring reacceptance', + ) diff --git a/hub/tests/test_sitewide_message_admin.py b/hub/tests/test_sitewide_message_admin.py new file mode 100644 index 0000000000..c015fa4bea --- /dev/null +++ b/hub/tests/test_sitewide_message_admin.py @@ -0,0 +1,43 @@ +from constance import config +from constance.test import override_config +from django.contrib.admin import site +from django.contrib.messages import get_messages +from django.test import TestCase +from django.utils import timezone +from freezegun import freeze_time + +from hub.admin import SitewideMessageAdmin +from hub.models import SitewideMessage + + +class SitewideMessageAdminTest(TestCase): + @override_config(LAST_TOS_UPDATE='') + def test_require_reacceptance_with_no_tos_message(self): + admin_instance = SitewideMessageAdmin(SitewideMessage, site) + request = self.client.request().wsgi_request + admin_instance.require_terms_of_service_reacceptance( + request, SitewideMessage.objects.none() + ) + messages_list = [m.message for m in get_messages(request)] + expected_message = ( + 'Add a SitewideMessage with the slug terms_of_service ' + 'before requiring reacceptance' + ) + assert expected_message in messages_list + assert config.LAST_TOS_UPDATE == '' + + def test_require_reacceptance_with_tos_message(self): + SitewideMessage.objects.create(slug='terms_of_service') + admin_instance = SitewideMessageAdmin(SitewideMessage, site) + request = self.client.request().wsgi_request + now = timezone.now() + with freeze_time(now): + admin_instance.require_terms_of_service_reacceptance( + request, SitewideMessage.objects.none() + ) + messages_list = [m.message for m in get_messages(request)] + expected_message = ( + 'All users will be prompted to re-accept' ' the Terms of Service' + ) + assert expected_message in messages_list + assert config.LAST_TOS_UPDATE == now.strftime('%Y-%m-%dT%H:%M:%SZ') From eabbfcb07aa4998bdf10b0b9506b5df1b7bd4de9 Mon Sep 17 00:00:00 2001 From: rgraber Date: Fri, 10 Jul 2026 11:09:21 -0400 Subject: [PATCH 5/6] fixup!: everything is strftime --- kobo/apps/accounts/tests/test_current_user.py | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/kobo/apps/accounts/tests/test_current_user.py b/kobo/apps/accounts/tests/test_current_user.py index 8a6ab30876..78e46c6fdc 100644 --- a/kobo/apps/accounts/tests/test_current_user.py +++ b/kobo/apps/accounts/tests/test_current_user.py @@ -151,7 +151,9 @@ def now_without_microseconds(): assert response.data['accepted_tos'] is True # require reacceptance - with override_config(LAST_TOS_UPDATE=timezone.now()): + with override_config( + LAST_TOS_UPDATE=timezone.now().strftime('%Y-%m-%dT%H:%M:%SZ') + ): response = self.client.get(self.url) assert response.data['accepted_tos'] is False # make it a bit later and re-accept From d5b5362dacfb6702ae3ec1592c44f57f73c12b51 Mon Sep 17 00:00:00 2001 From: rgraber Date: Fri, 10 Jul 2026 13:23:37 -0400 Subject: [PATCH 6/6] fixup!: um --- hub/admin/sitewide_message.py | 6 +----- 1 file changed, 1 insertion(+), 5 deletions(-) diff --git a/hub/admin/sitewide_message.py b/hub/admin/sitewide_message.py index c373f4cb06..060bb8a6bc 100644 --- a/hub/admin/sitewide_message.py +++ b/hub/admin/sitewide_message.py @@ -26,12 +26,8 @@ def changelist_view(self, request, extra_context=None): ): data = request.POST.copy() data['select_across'] = '1' + data['_selected_action'] = '1' request.POST = data - response = self.response_action( - request, queryset=self.get_queryset(request) - ) - if response: - return response return super().changelist_view(request, extra_context) @admin.action(description='Require all users to reaccept the Terms of Service')