Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Feat: add Terms of Use accept view and middleware #1217

Merged
merged 17 commits into from
Feb 18, 2025
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
17 commits
Select commit Hold shift + click to select a range
1deac58
feat(accounts,ToU): add ToU update view, form and middleware #141 #161
MyPyDavid Dec 18, 2024
187c630
tests(accounts,ToU): add tests for ToU update and middleware
MyPyDavid Dec 18, 2024
8c2a84b
refactor(accounts, middleware): change ToU middleware to callable class
MyPyDavid Dec 18, 2024
3c2a3bc
feat(accounts,ToU): add updated and created to Consent Model
MyPyDavid Jan 23, 2025
0d694f7
feat(accounts,admin): update list display for ConsentFieldValue
MyPyDavid Feb 3, 2025
e52f940
feat(accounts,ToU): disable revocation and rename to accept
MyPyDavid Feb 3, 2025
22b63d4
feat(accounts,ToU): add version date check, refactor and fix tests
MyPyDavid Feb 3, 2025
fa8b7d1
refactor(core,accounts): move ToU settings and date parser to core an…
MyPyDavid Feb 7, 2025
c510027
feat(sociallaccount): add ToU to social signup form
MyPyDavid Feb 10, 2025
1512135
tests(accounts): fix helpers and code style
MyPyDavid Feb 11, 2025
e86843c
tests(accounts): add allauth.socialaccount to base config and fix soc…
MyPyDavid Feb 13, 2025
f2c4ffb
tests(accounts): fix import and code style
MyPyDavid Feb 13, 2025
3aa760a
refactor(core): rename to CoreConfig and remove ready method
MyPyDavid Feb 17, 2025
ead1f71
refactor(core,ToU): add ToU middleware to MIDDLEWARE by default
MyPyDavid Feb 17, 2025
28ecef0
fix(core,utils): use input date arg
MyPyDavid Feb 17, 2025
356f92e
tests(core,utils): add tests for the date parser
MyPyDavid Feb 17, 2025
b9cdafc
refactor(accounts): rename adapter.py to account.py
MyPyDavid Feb 17, 2025
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
20 changes: 2 additions & 18 deletions rdmo/accounts/adapter.py → rdmo/accounts/account.py
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,6 @@
from allauth.account.adapter import DefaultAccountAdapter
from allauth.account.forms import LoginForm as AllauthLoginForm
from allauth.account.forms import SignupForm as AllauthSignupForm
from allauth.socialaccount.adapter import DefaultSocialAccountAdapter

from .forms import ProfileForm
from .models import ConsentFieldValue
Expand All @@ -25,21 +24,6 @@ def save_user(self, request, user, form, commit=True):

return user

class SocialAccountAdapter(DefaultSocialAccountAdapter):

def is_open_for_signup(self, request, sociallogin):
return settings.SOCIALACCOUNT_SIGNUP

def save_user(self, request, sociallogin, form=None):
user = super().save_user(request, sociallogin, form)

if settings.SOCIALACCOUNT_GROUPS:
provider = str(sociallogin.account.provider)
groups = Group.objects.filter(name__in=settings.SOCIALACCOUNT_GROUPS.get(provider, []))
user.groups.set(groups)

return user


class LoginForm(AllauthLoginForm):

Expand Down Expand Up @@ -68,5 +52,5 @@ def signup(self, request, user):

# store the consent field
if settings.ACCOUNT_TERMS_OF_USE:
consent = ConsentFieldValue(user=user, consent=self.cleaned_data['consent'])
consent.save()
if self.cleaned_data['consent']:
ConsentFieldValue.create_consent(user=user, session=request.session)
6 changes: 5 additions & 1 deletion rdmo/accounts/admin.py
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,11 @@ class AdditionalFieldValueAdmin(admin.ModelAdmin):

@admin.register(ConsentFieldValue)
class ConsentFieldValueAdmin(admin.ModelAdmin):
readonly_fields = ('user', 'consent')
readonly_fields = ('user', 'consent', 'updated', 'created')

search_fields = ('user__username', 'user__email')
list_display = ('user', 'consent', 'updated')
list_filter = ('consent',)

def has_add_permission(self, request, obj=None):
return False
Expand Down
25 changes: 24 additions & 1 deletion rdmo/accounts/forms.py
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@
from django.contrib.auth import get_user_model
from django.utils.translation import gettext_lazy as _

from .models import AdditionalField, AdditionalFieldValue
from .models import AdditionalField, AdditionalFieldValue, ConsentFieldValue

log = logging.getLogger(__name__)

Expand Down Expand Up @@ -87,3 +87,26 @@ def __init__(self, *args, **kwargs):

consent = forms.BooleanField(required=True)
consent.label = _("I confirm that I want my profile to be completely removed. This can not be undone!")


class AcceptConsentForm(forms.Form):

consent = forms.BooleanField(
label="I agree to the terms of use",
required=True,
)

def __init__(self, *args, user=None, **kwargs):
self.user = user
super().__init__(*args, **kwargs)

if not self.user:
raise ValueError("A user instance is required to initialize the form.")

def save(self, session) -> bool:
if self.cleaned_data['consent']:
success = ConsentFieldValue.create_consent(user=self.user, session=session)
if not success:
self.add_error(None, _("Consent could not be saved. Please try again.")) # Add non-field error
return success
return False
37 changes: 37 additions & 0 deletions rdmo/accounts/middleware.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,37 @@
"""Terms and Conditions Middleware"""
# ref: https://github.com/cyface/django-termsandconditions/blob/main/termsandconditions/middleware.py

from django.conf import settings
from django.http import HttpResponseRedirect
from django.urls import reverse

from .models import ConsentFieldValue


class TermsAndConditionsRedirectMiddleware:
"""Middleware to ensure terms and conditions have been accepted."""

def __init__(self, get_response):
self.get_response = get_response

def __call__(self, request):
if (
settings.ACCOUNT_TERMS_OF_USE # Terms enforcement enabled
and request.user.is_authenticated
and self.is_path_protected(request.path)
and not ConsentFieldValue.has_accepted_terms(request.user, request.session)
):
return HttpResponseRedirect(reverse("terms_of_use_accept"))

# Proceed with the response for non-protected paths or accepted terms
return self.get_response(request)

@staticmethod
def is_path_protected(path):
# all paths should be protected, except what is excluded here
return not (
path == reverse("terms_of_use_accept") or
any(path.startswith(prefix) for prefix in settings.ACCOUNT_TERMS_OF_USE_EXCLUDE_URL_PREFIXES) or
any(substring in path for substring in settings.ACCOUNT_TERMS_OF_USE_EXCLUDE_URL_CONTAINS) or
path in settings.ACCOUNT_TERMS_OF_USE_EXCLUDE_URLS
)
26 changes: 26 additions & 0 deletions rdmo/accounts/migrations/0022_add_created_updated_to_consent.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
# Generated by Django 4.2.17 on 2025-01-23 16:06

from django.db import migrations, models
import django.utils.timezone


class Migration(migrations.Migration):

dependencies = [
('accounts', '0021_alter_help_text'),
]

operations = [
migrations.AddField(
model_name='consentfieldvalue',
name='created',
field=models.DateTimeField(default=django.utils.timezone.now, editable=False, verbose_name='created'),
preserve_default=False,
),
migrations.AddField(
model_name='consentfieldvalue',
name='updated',
field=models.DateTimeField(default=django.utils.timezone.now, editable=False, verbose_name='updated'),
preserve_default=False,
),
]
50 changes: 49 additions & 1 deletion rdmo/accounts/models.py
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,11 @@
from django.dispatch import receiver
from django.utils.translation import gettext_lazy as _

from rdmo.core.models import Model as RDMOTimeStampedModel
from rdmo.core.models import TranslationMixin
from rdmo.core.utils import parse_date_from_string

CONSENT_SESSION_KEY = "user_has_consented"


class AdditionalField(models.Model, TranslationMixin):
Expand Down Expand Up @@ -106,7 +110,7 @@ def __str__(self):
return self.user.username + '/' + self.field.key


class ConsentFieldValue(models.Model):
class ConsentFieldValue(RDMOTimeStampedModel):

user = models.OneToOneField(settings.AUTH_USER_MODEL, on_delete=models.CASCADE)
consent = models.BooleanField(
Expand All @@ -123,6 +127,50 @@ class Meta:
def __str__(self):
return self.user.username

@classmethod
def create_consent(cls, user, session=None) -> bool:
obj, _created = cls.objects.update_or_create(user=user, defaults={"consent": True})

# Validate consent before storing in session
has_valid_consent = (
obj.is_consent_valid()
if settings.ACCOUNT_TERMS_OF_USE_DATE is not None
else True # If terms update date is not enforced, any consent is valid
)

if has_valid_consent:
if session:
session[CONSENT_SESSION_KEY] = True
return True

obj.delete() # Remove when consent is outdated
return False

@classmethod
def has_accepted_terms(cls, user, session) -> bool:
if not settings.ACCOUNT_TERMS_OF_USE:
return True # If terms are disabled, assume accepted.

# Check session cache first
if CONSENT_SESSION_KEY in session:
return session[CONSENT_SESSION_KEY]

consent = cls.objects.filter(user=user).only("updated").first()
has_valid_consent = (
consent.is_consent_valid()
if consent and settings.ACCOUNT_TERMS_OF_USE_DATE is not None
else bool(consent)
)

session[CONSENT_SESSION_KEY] = has_valid_consent # Cache result
return has_valid_consent

def is_consent_valid(self) -> bool:
# optionally check if the terms are outdated
latest_terms_version_date = parse_date_from_string(settings.ACCOUNT_TERMS_OF_USE_DATE)
# Compare only dates (ignores time)
return self.updated and (self.updated.date() >= latest_terms_version_date)


class Role(models.Model):

Expand Down
45 changes: 45 additions & 0 deletions rdmo/accounts/socialaccount.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,45 @@
from django.conf import settings
from django.contrib.auth.models import Group
from django.forms import BooleanField

from allauth.socialaccount.adapter import DefaultSocialAccountAdapter
from allauth.socialaccount.forms import SignupForm as AllauthSocialSignupForm

from rdmo.accounts.forms import ProfileForm
from rdmo.accounts.models import ConsentFieldValue


class SocialAccountAdapter(DefaultSocialAccountAdapter):

def is_open_for_signup(self, request, sociallogin):
return settings.SOCIALACCOUNT_SIGNUP

def save_user(self, request, sociallogin, form=None):
user = super().save_user(request, sociallogin, form)

if settings.SOCIALACCOUNT_GROUPS:
provider = str(sociallogin.account.provider)
groups = Group.objects.filter(name__in=settings.SOCIALACCOUNT_GROUPS.get(provider, []))
user.groups.set(groups)

return user


class SocialSignupForm(AllauthSocialSignupForm, ProfileForm):

use_required_attribute = False

def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)

# add a consent field, the label is added in the template
if settings.ACCOUNT_TERMS_OF_USE:
self.fields['consent'] = BooleanField(required=True)

def signup(self, request, user):
self._save_additional_values(user)

# store the consent field
if settings.ACCOUNT_TERMS_OF_USE:
if self.cleaned_data['consent']:
ConsentFieldValue.create_consent(user=user, session=request.session)
46 changes: 46 additions & 0 deletions rdmo/accounts/templates/account/terms_of_use_accept_form.html
Original file line number Diff line number Diff line change
@@ -0,0 +1,46 @@
{% extends 'core/page.html' %}
{% load i18n %}

{% block page %}

<h2>{% trans 'Terms of use' %}</h2>

<p>
{% get_current_language as lang %}
{% if lang == 'en' %}
{% include 'account/terms_of_use_en.html' %}
{% elif lang == 'de' %}
{% include 'account/terms_of_use_de.html' %}
{% endif %}
</p>

<div>
{% if not has_consented %}
<form method="post">
{% csrf_token %}
<div class="form-group">
<label>
<input type="checkbox" name="consent" required>
{% trans "I agree to the terms of use." %}
</label>
</div>
<button type="submit" class="btn btn-primary terms-of-use-accept">
{% trans "I accept" %}
</button>
</form>
{% else %}
<p>
{% trans "You have accepted the terms of use." %}
</p>
{% endif %}

{% if form.non_field_errors %}
<ul class="list-unstyled text-danger">
{% for error in form.non_field_errors %}
<li>{{ error }}</li>
{% endfor %}
</ul>
{% endif %}
</div>

{% endblock %}
64 changes: 64 additions & 0 deletions rdmo/accounts/tests/helpers.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,64 @@
import sys
from importlib import import_module, reload

import pytest

from django.urls import clear_url_caches, get_resolver, reverse


def reload_urlconf(urlconf=None, root_urlconf=None):
clear_url_caches()
if urlconf is None and root_urlconf is None:
from django.conf import settings
urlconf = settings.ROOT_URLCONF
elif urlconf is None and root_urlconf is not None:
# take the settings during pytest run
urlconf = root_urlconf

if urlconf in sys.modules:
reload(sys.modules[urlconf])
else:
import_module(urlconf)


def reload_urls(*app_names: str, root_urlconf = None) -> None:
# reload the urlconf of the app
for _app in app_names:
reload_urlconf(urlconf=_app)

# reload the core urlconf
reload_urlconf(urlconf='rdmo.core.urls')

# reload the testcase settings urlconf
reload_urlconf(root_urlconf=root_urlconf)

get_resolver()._populate()


@pytest.fixture
def enable_terms_of_use(settings): # noqa: PT004
settings.ACCOUNT_TERMS_OF_USE = True
reload_urls('rdmo.accounts.urls')

yield

# revert settings to initial state
settings.ACCOUNT_TERMS_OF_USE = False
# 🔹 Reload URLs to reflect the changes
reload_urls('rdmo.accounts.urls')


@pytest.fixture
def enable_socialaccount(settings): # noqa: PT004
# Arrange: this fixture enable and initializes the allauth.sociallaccount
# INSTALLED_APPS already has "allauth.socialaccount","allauth.socialaccount.providers.dummy"
settings.SOCIALACCOUNT = True
settings.SOCIALACCOUNT_SIGNUP = True

assert reverse("dummy_login") # Ensure the route exists

yield # Run test

# 🔹 Cleanup: reset settings
settings.SOCIALACCOUNT = False
settings.SOCIALACCOUNT_SIGNUP = False
Loading