Skip to content
Merged
Show file tree
Hide file tree
Changes from 5 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
43 changes: 43 additions & 0 deletions hub/admin/sitewide_message.py
Original file line number Diff line number Diff line change
@@ -1,9 +1,52 @@
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


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)
Comment thread
greptile-apps[bot] marked this conversation as resolved.
Outdated

@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',
)
18 changes: 18 additions & 0 deletions hub/migrations/0023_alter_sitewidemessage_slug.py
Original file line number Diff line number Diff line change
@@ -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),
),
Comment thread
greptile-apps[bot] marked this conversation as resolved.
]
2 changes: 1 addition & 1 deletion hub/models/sitewide_message.py
Original file line number Diff line number Diff line change
Expand Up @@ -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']
Expand Down
43 changes: 43 additions & 0 deletions hub/tests/test_sitewide_message_admin.py
Original file line number Diff line number Diff line change
@@ -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')
18 changes: 16 additions & 2 deletions kobo/apps/accounts/tests/test_current_user.py
Original file line number Diff line number Diff line change
@@ -1,11 +1,13 @@
import re
from datetime import timedelta

import dateutil
from constance.test import override_config
from django.conf import settings
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
Expand Down Expand Up @@ -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
)
Expand All @@ -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=[
Expand Down
1 change: 1 addition & 0 deletions kobo/settings/base.py
Original file line number Diff line number Diff line change
Expand Up @@ -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/',
Expand Down
12 changes: 8 additions & 4 deletions kpi/serializers/current_user.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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):
Expand Down
Loading