diff --git a/hub/admin/sitewide_message.py b/hub/admin/sitewide_message.py index 37cefbd3a3..060bb8a6bc 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,38 @@ 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' + data['_selected_action'] = '1' + request.POST = data + 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): + 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/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/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') diff --git a/kobo/apps/accounts/tests/test_current_user.py b/kobo/apps/accounts/tests/test_current_user.py index 22093b57b5..78e46c6fdc 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 @@ -125,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 ) @@ -146,7 +148,19 @@ 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().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 + with freeze_time(timezone.now() + timedelta(seconds=1)): + self.client.post(reverse('tos')) + response = self.client.get(self.url) + 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 af112352d2..f68db58314 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': ('', '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..a83e4496f7 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 + 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 @extend_schema_field(DateJoinedField) def get_date_joined(self, obj):