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
Changes from 1 commit
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
Next Next commit
feat(accounts,ToU): add ToU update view, form and middleware #141 #161
Signed-off-by: David Wallace <[email protected]>
MyPyDavid committed Jan 23, 2025
commit 1deac58d84469d3ce5e8d57041191dcdd206ba4b
36 changes: 35 additions & 1 deletion rdmo/accounts/forms.py
Original file line number Diff line number Diff line change
@@ -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__)

@@ -87,3 +87,37 @@ 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 UpdateConsentForm(forms.Form):

consent = forms.BooleanField(
label="I agree to the terms of use",
required=False, # not required because it won't be submitted during a delete
)

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.")

# pre-fill the 'consent' field based on the user's existing consent
self.fields["consent"].initial = ConsentFieldValue.objects.filter(
user=self.user
).exists()

def save(self) -> bool:

if "delete" in self.data:
ConsentFieldValue.objects.filter(user=self.user).delete()
return False # consent was revoked

if self.cleaned_data.get("consent"):
ConsentFieldValue.objects.update_or_create(
user=self.user, defaults={"consent": True}
)
return True # consent was accepted

return False
74 changes: 74 additions & 0 deletions rdmo/accounts/middleware.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,74 @@
"""Terms and Conditions Middleware"""

# ref: https://github.com/cyface/django-termsandconditions/blob/main/termsandconditions/middleware.py
import logging

from django.conf import settings
from django.http import HttpResponseRedirect
from django.urls import reverse
from django.utils.deprecation import MiddlewareMixin

from .utils import user_has_accepted_terms

LOGGER = logging.getLogger(__name__)


ACCEPT_TERMS_PATH = getattr(settings, "ACCEPT_TERMS_PATH", reverse("terms_of_use_update"))
TERMS_EXCLUDE_URL_PREFIX_LIST = getattr(
settings,
"TERMS_EXCLUDE_URL_PREFIX_LIST",
{"/admin", "/i18n", "/static", "/account"},
)
TERMS_EXCLUDE_URL_CONTAINS_LIST = getattr(
settings, "TERMS_EXCLUDE_URL_CONTAINS_LIST", {}
)
TERMS_EXCLUDE_URL_LIST = getattr(
settings,
"TERMS_EXCLUDE_URL_LIST",
{"/", settings.LOGOUT_URL},
)


class TermsAndConditionsRedirectMiddleware(MiddlewareMixin):

def process_request(self, request):
"""Process each request to app to ensure terms have been accepted"""

if not settings.ACCOUNT_TERMS_OF_USE:
return None # If terms are not enabled, consider them accepted.

current_path = request.META["PATH_INFO"]

if request.user.is_authenticated and is_path_protected(current_path):
if not user_has_accepted_terms(request.user, request.session):
# Redirect to update consent page if consent is missing
return HttpResponseRedirect(reverse("terms_of_use_update"))

return None


def is_path_protected(path):
"""
returns True if given path is to be protected, otherwise False
The path is not to be protected when it appears on:
TERMS_EXCLUDE_URL_PREFIX_LIST, TERMS_EXCLUDE_URL_LIST, TERMS_EXCLUDE_URL_CONTAINS_LIST or as
ACCEPT_TERMS_PATH
"""
protected = True

for exclude_path in TERMS_EXCLUDE_URL_PREFIX_LIST:
if path.startswith(exclude_path):
protected = False

for contains_path in TERMS_EXCLUDE_URL_CONTAINS_LIST:
if contains_path in path:
protected = False

if path in TERMS_EXCLUDE_URL_LIST:
protected = False

if path.startswith(ACCEPT_TERMS_PATH):
protected = False

return protected
13 changes: 13 additions & 0 deletions rdmo/accounts/signals.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
from django.contrib.auth.signals import user_logged_in
from django.dispatch import receiver
from django.http import HttpResponseRedirect
from django.urls import reverse

from .utils import user_has_accepted_terms


@receiver(user_logged_in)
def check_user_consent(sender, request, user, **kwargs):
# check consent and store it in the session
if not user_has_accepted_terms(user, request.session):
return HttpResponseRedirect(reverse("terms_of_use_update"))
33 changes: 33 additions & 0 deletions rdmo/accounts/templates/account/terms_of_use_update_form.html
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
{% 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>
<form method="post">
{% csrf_token %}
{% if not has_consented %}
<input type="hidden" name="consent" value="true">
<button type="submit" class="btn btn-primary terms-of-use-accept">
{% trans "I agree to the terms of use" %}
</button>
{% else %}
<button type="submit" name="delete" class="btn btn-danger terms-of-use-revoke">
{% trans "Revoke my acceptance of the terms of use" %}
</button>
{% endif %}
</form>
</div>

{% endblock %}
13 changes: 11 additions & 2 deletions rdmo/accounts/urls/__init__.py
Original file line number Diff line number Diff line change
@@ -2,7 +2,15 @@
from django.contrib.auth import views as auth_views
from django.urls import include, re_path

from ..views import profile_update, remove_user, shibboleth_login, shibboleth_logout, terms_of_use, token
from ..views import (
profile_update,
remove_user,
shibboleth_login,
shibboleth_logout,
terms_of_use,
terms_of_use_update,
token,
)

urlpatterns = [
# edit own profile
@@ -12,7 +20,8 @@

if settings.ACCOUNT_TERMS_OF_USE is True:
urlpatterns += [
re_path('^terms-of-use/', terms_of_use, name='terms_of_use')
re_path('^terms-of-use/$', terms_of_use, name='terms_of_use'),
re_path('^terms-of-use/update/$', terms_of_use_update, name='terms_of_use_update'),
]

if settings.SHIBBOLETH:
17 changes: 16 additions & 1 deletion rdmo/accounts/utils.py
Original file line number Diff line number Diff line change
@@ -6,11 +6,12 @@
from django.contrib.contenttypes.models import ContentType
from django.core.exceptions import ObjectDoesNotExist

from .models import Role
from .models import ConsentFieldValue, Role
from .settings import GROUPS

log = logging.getLogger(__name__)

CONSENT_SESSION_KEY = "user_has_consented"

def get_full_name(user):
if user.first_name and user.last_name:
@@ -93,3 +94,17 @@ def get_user_from_db_or_none(username: str, email: str):
except ObjectDoesNotExist:
log.error('Retrieval of user "%s" with email "%s" failed, user does not exist', username, email)
return None


def user_has_accepted_terms(user, session) -> bool:
if not settings.ACCOUNT_TERMS_OF_USE:
return True # If terms are not enabled, consider them accepted.

# check the session for cached consent status
if CONSENT_SESSION_KEY in session:
return session[CONSENT_SESSION_KEY]

has_consented = ConsentFieldValue.objects.filter(user=user).exists()
session[CONSENT_SESSION_KEY] = has_consented # cache the result in the session

return has_consented
36 changes: 32 additions & 4 deletions rdmo/accounts/views.py
Original file line number Diff line number Diff line change
@@ -4,16 +4,17 @@
from django.conf import settings
from django.contrib.auth import logout
from django.contrib.auth.decorators import login_required
from django.http import HttpResponseRedirect
from django.shortcuts import render
from django.http import HttpResponseNotAllowed, HttpResponseRedirect
from django.shortcuts import redirect, render
from django.urls import reverse

from rest_framework.authtoken.models import Token

from rdmo.core.utils import get_next, get_referer_path_info

from .forms import ProfileForm, RemoveForm
from .utils import delete_user
from .forms import ProfileForm, RemoveForm, UpdateConsentForm
from .models import ConsentFieldValue
from .utils import CONSENT_SESSION_KEY, delete_user

log = logging.getLogger(__name__)

@@ -109,3 +110,30 @@ def shibboleth_logout(request):
or re.search(settings.SHIBBOLETH_USERNAME_PATTERN, request.user.username):
logout_url += f'?next={settings.SHIBBOLETH_LOGOUT_URL}'
return HttpResponseRedirect(logout_url)


def terms_of_use_update(request):

if not request.user.is_authenticated:
return redirect("account_login")

if request.method == "POST":
# Use the form to handle both update and delete actions
form = UpdateConsentForm(request.POST, user=request.user)
if form.is_valid():
consent_status = form.save()
request.session[CONSENT_SESSION_KEY] = consent_status
# Update the session to reflect the new consent status
return redirect("home")

elif request.method == "GET":
# Render the consent update form
form = UpdateConsentForm(user=request.user)
has_consented = ConsentFieldValue.objects.filter(user=request.user).exists()
return render(
request,
"account/terms_of_use_update_form.html",
{"form": form, "has_consented": has_consented},
)

return HttpResponseNotAllowed(["GET", "POST"])