diff --git a/core/admin.py b/core/admin.py deleted file mode 100644 index e13f0eff..00000000 --- a/core/admin.py +++ /dev/null @@ -1,19 +0,0 @@ -from django.contrib import admin -from django.contrib.flatpages.admin import FlatPageAdmin -from django.contrib.flatpages.models import FlatPage -from django.db import models - -from djangocodemirror.widgets import CodeMirrorAdminWidget -from solo.admin import SingletonModelAdmin - -from .models import SiteConfiguration - -admin.site.register(SiteConfiguration, SingletonModelAdmin) -admin.site.unregister(FlatPage) - - -@admin.register(FlatPage) -class FlatPageAdmin(FlatPageAdmin): - formfield_overrides = { - models.TextField: {'widget': CodeMirrorAdminWidget(config_name='html')}, - } diff --git a/core/admin/__init__.py b/core/admin/__init__.py new file mode 100644 index 00000000..f77d5352 --- /dev/null +++ b/core/admin/__init__.py @@ -0,0 +1,2 @@ +# flake8:noqa +from .admin import * diff --git a/core/admin/admin.py b/core/admin/admin.py new file mode 100644 index 00000000..04bd2a9b --- /dev/null +++ b/core/admin/admin.py @@ -0,0 +1,189 @@ +from typing import cast + +from django.contrib import admin +from django.contrib.flatpages.admin import FlatPageAdmin +from django.contrib.flatpages.models import FlatPage +from django.core.cache import cache +from django.db import models +from django.urls import reverse +from django.utils.html import format_html +from django.utils.translation import gettext_lazy as _ + +from djangocodemirror.widgets import CodeMirrorAdminWidget +from packvers import version +from solo.admin import SingletonModelAdmin + +from hosting.models import Profile + +from ..models import Agreement, Policy, SiteConfiguration, UserBrowser +from .filters import DependentFieldFilter, YearBracketFilter + +admin.site.index_template = 'admin/custom_index.html' +admin.site.disable_action('delete_selected') + +admin.site.register(SiteConfiguration, SingletonModelAdmin) +admin.site.unregister(FlatPage) + + +@admin.register(FlatPage) +class FlatPageAdmin(FlatPageAdmin): + formfield_overrides = { + models.TextField: {'widget': CodeMirrorAdminWidget(config_name='html')}, + } + + +@admin.register(Policy) +class PolicyAdmin(admin.ModelAdmin): + list_display = ( + 'version', 'effective_date', 'requires_consent', + ) + ordering = ('-effective_date', ) + formfield_overrides = { + models.TextField: {'widget': CodeMirrorAdminWidget(config_name='html')}, + } + + def has_delete_permission(self, request, obj=None): + return False + + def save_model(self, request, obj, form, change): + super().save_model(request, obj, form, change) + if 'version' in form.changed_data or 'effective_date' in form.changed_data: + # Bust the cache of references to all policies; + # these references are cached indefinitely otherwise. + cache.delete('all-policies') + + +@admin.register(Agreement) +class AgreementAdmin(admin.ModelAdmin): + list_display = ( + 'id', 'policy_link', 'user_link', 'created', 'modified', 'withdrawn', + ) + ordering = ('-policy_version', '-modified', 'user__username') + search_fields = ('user__username',) + list_filter = ('policy_version',) + date_hierarchy = 'created' + fields = ( + 'user_link', 'policy_link', + 'created', 'modified', 'withdrawn', + ) + readonly_fields = [f.name for f in Agreement._meta.fields] + ['user_link', 'policy_link'] + + @admin.display( + description=_("user"), + ordering='user__username', + ) + def user_link(self, obj: Agreement): + try: + link = reverse('admin:auth_user_change', args=[obj.user.pk]) + account_link = f'{obj.user}' + try: + profile_link = ' ({name})'.format( + url=obj.user.profile.get_admin_url(), name=_("profile")) + except Profile.DoesNotExist: + profile_link = '' + return format_html(" ".join([account_link, profile_link])) + except AttributeError: + return format_html('{userid} ?', userid=obj.user_id) + + @admin.display( + description=_("version of policy"), + ordering='policy_version', + ) + def policy_link(self, obj: Agreement): + try: + cache = self._policies_cache + except AttributeError: + cache = self._policies_cache = {} + if obj.policy_version in cache: + return cache[obj.policy_version] + value = obj.policy_version + try: + policy = Policy.objects.get(version=obj.policy_version) + link = reverse('admin:core_policy_change', args=[policy.pk]) + value = format_html( + '{policy}', + url=link, policy=obj.policy_version) + except Policy.DoesNotExist: + pass + finally: + cache[obj.policy_version] = value + return value + + def get_queryset(self, request): + qs = super().get_queryset(request).select_related('user', 'user__profile') + qs = qs.only( + *[f.name for f in Agreement._meta.fields], + 'user__id', 'user__username', 'user__profile__id') + return qs + + def has_add_permission(self, request): + return False + + def has_change_permission(self, request, obj=None): + return False + + +@admin.register(UserBrowser) +class UserBrowserAdmin(admin.ModelAdmin): + list_display = ( + 'user', 'os_name', 'os_version', 'browser_name', 'browser_version', 'added_on', + ) + ordering = ('user__username', '-added_on') + search_fields = ('user__username__exact',) + list_filter = ( + 'os_name', + DependentFieldFilter.configure( + 'os_name', 'os_version', + coerce=lambda version_string: version.parse(version_string), + sort=True, sort_reverse=True, + ), + 'browser_name', + DependentFieldFilter.configure( + 'browser_name', 'browser_version', + coerce=lambda version_string: version.parse(version_string), + sort=True, sort_reverse=True, + ), + ('added_on', YearBracketFilter.configure(YearBracketFilter.Brackets.SINCE)), + ) + show_full_result_count = False + fields = ( + 'user_agent_string', 'user_agent_hash', + 'os_name', 'os_version', 'browser_name', 'browser_version', 'device_type', + 'geolocation', + ) + raw_id_fields = ('user',) + readonly_fields = ('added_on',) + + @admin.display( + description=_("user"), + ordering='user__username', + ) + def user_link(self, obj: UserBrowser): + try: + link = reverse('admin:auth_user_change', args=[obj.user.pk]) + return format_html('{user}', link=link, user=obj.user) + except AttributeError: + return format_html('{userid} ?', userid=obj.user_id) + + def get_queryset(self, request): + qs = super().get_queryset(request).select_related('user') + qs = qs.only( + *[f.name for f in UserBrowser._meta.fields], + 'user__id', 'user__username') + return qs + + def get_fields(self, request, obj=None): + return ( + ('user' if obj is None else 'user_link',) + + cast(tuple, self.fields) + + (('added_on',) if obj is not None else ()) + ) + + def has_change_permission(self, request, obj=None): + return False + + def get_form(self, request, *args, **kwargs): + form = super().get_form(request, *args, **kwargs) + if form.base_fields: + form.base_fields['user_agent_string'].widget.attrs['style'] = "width: 50em;" + return form diff --git a/core/admin/filters.py b/core/admin/filters.py new file mode 100644 index 00000000..1d0e42ba --- /dev/null +++ b/core/admin/filters.py @@ -0,0 +1,154 @@ +from enum import Enum +from typing import Callable, Optional, TypedDict, cast + +from django.contrib import admin +from django.db.models import Model, QuerySet +from django.http import HttpRequest +from django.utils.functional import lazy +from django.utils.translation import gettext_lazy as _ + + +class YearBracketFilter(admin.DateFieldListFilter): + class Brackets(str, Enum): + EXACT = 'exact' + SINCE = 'since' + UNTIL = 'until' + + @classmethod + def configure(cls, bracket=Brackets.EXACT): + if bracket not in (cls.Brackets.SINCE, cls.Brackets.UNTIL): + bracket = cls.Brackets.EXACT + return type( + f'YearBracket{bracket.capitalize()}Filter', + (cls,), + { + 'bracket': bracket, + } + ) + + def __init__(self, field, request, params, model, model_admin, field_path): + original_field_path = field_path + field_path = f'{field_path}__year' + super().__init__(field, request, params, model, model_admin, field_path) + qs = ( + model_admin + .get_queryset(request) + .select_related(None) + .order_by(f'-{original_field_path}') + .only(field_path) + ) + qs.query.annotations.clear() + all_years = list(dict.fromkeys(qs.values_list(field_path, flat=True))) + if not hasattr(self, 'bracket'): + self.bracket = self.Brackets.EXACT + self.links = ( + (_('Any year'), {}), + ) + lookup_kwarg = '' + if self.bracket is self.Brackets.SINCE: + label_string = _("from %(date)s") + self.lookup_kwarg_since = self.field_generic + 'gte' + lookup_kwarg = self.lookup_kwarg_since + if self.bracket is self.Brackets.UNTIL: + label_string = _("until %(date)s") + self.lookup_kwarg_until = self.field_generic + 'lte' + lookup_kwarg = self.lookup_kwarg_until + if self.bracket is self.Brackets.EXACT: + self.links += tuple( + (str(year), { + self.lookup_kwarg_since: str(year), + self.lookup_kwarg_until: str(year + 1), + }) + for year in all_years if year is not None + ) + else: + def label(year): + return lazy( + lambda date=year: (label_string % {'date': date}).capitalize(), + str) + self.links += tuple( + (label(year), {lookup_kwarg: str(year)}) + for year in all_years if year is not None + ) + if field.null: + self.links += ( + (_('No date'), {self.lookup_kwarg_isnull: str(True)}), + (_('Has date'), {self.lookup_kwarg_isnull: str(False)}), + ) + + +class DependentFieldFilter(admin.SimpleListFilter): + origin_field: str + related_field: str + parameter_name: str + coerce_value: Callable + SortConfig = TypedDict('SortConfig', {'enabled': bool, 'reverse': bool}) + sorting: SortConfig + + @classmethod + def configure( + cls, + field: str, + related_field: str, + coerce: Optional[Callable] = None, + sort: bool = False, + sort_reverse: Optional[bool] = None, + ): + if coerce is None or not callable(coerce): + coerce = lambda v: v + return type( + ''.join(related_field.split('_')).capitalize() + + 'Per' + ''.join(field.split('_')).capitalize() + + 'Filter', + (cls,), + { + 'origin_field': field, + 'related_field': related_field, + 'parameter_name': related_field, + 'coerce_value': lambda filter, value: coerce(value), + 'sorting': { + 'enabled': bool(sort), + 'reverse': bool(sort_reverse), + }, + } + ) + + def lookups(self, request: HttpRequest, model_admin: admin.ModelAdmin): + qs = ( + cast(type[Model], model_admin.model)._default_manager.all() + .filter(**{self.origin_field: self.dependent_on_value}) + ) + all_values = map( + self.coerce_value, + qs.values_list(self.related_field, flat=True).distinct()) + if self.sorting['enabled']: + all_values = sorted(all_values, reverse=self.sorting['reverse']) + for val in all_values: + yield (val, str(val)) + + def has_output(self): + return bool(self.dependent_on_value) + + def value(self): + val = self.used_parameters.get(self.parameter_name) + if val in (ch[0] for ch in self.lookup_choices): + return val + else: + return None + + def queryset(self, request: HttpRequest, queryset: QuerySet): + if self.value(): + return queryset.filter(**{self.parameter_name: self.value()}) + else: + return queryset + + def __init__( + self, + request: HttpRequest, + params: dict[str, str], + model: type[Model], + model_admin: admin.ModelAdmin, + ): + self.title = model._meta.get_field(self.related_field).verbose_name + self.dependent_on_value = request.GET.get(self.origin_field) + super().__init__(request, params, model, model_admin) diff --git a/core/auth.py b/core/auth.py index de9feaa5..9d3cd07c 100644 --- a/core/auth.py +++ b/core/auth.py @@ -4,6 +4,7 @@ import warnings from enum import Enum from functools import total_ordering +from typing import Literal, Union from django.conf import settings from django.contrib.auth.backends import ModelBackend @@ -43,6 +44,9 @@ class AuthRole(Enum): STAFF = 40 ADMIN = 50 + parent: Union['AuthRole', None] + do_not_call_in_templates: Literal[True] + def __new__(cls, value, subvalue=None): obj = object.__new__(cls) obj._value_ = (len(cls) + 1, subvalue) if subvalue is not None else value @@ -233,6 +237,7 @@ def get_role_in_context(request, profile=None, place=None, no_obj_context=False) class AuthMixin(AccessMixin): minimum_role = AuthRole.OWNER + exact_role: AuthRole | tuple[AuthRole] allow_anonymous = False redirect_field_name = settings.REDIRECT_FIELD_NAME display_permission_denied = True @@ -328,7 +333,9 @@ def _auth_verify(self, object, context_omitted=False): ), self ) elif self.display_permission_denied and self.request.user.has_perm(PERM_SUPERVISOR): - raise PermissionDenied(self.get_permission_denied_message(object, context_omitted), self) + raise PermissionDenied( + self.get_permission_denied_message(object, context_omitted), + self) else: raise Http404("Operation not allowed.") diff --git a/core/managers.py b/core/managers.py index f0d34b0c..23246eca 100644 --- a/core/managers.py +++ b/core/managers.py @@ -1,19 +1,43 @@ +from typing import TYPE_CHECKING, Any + +from django.core.cache import cache from django.db import models -from django.db.models import functions as dbf +from django.utils import timezone +if TYPE_CHECKING: + from .models import Policy -class PoliciesManager(models.Manager): - """ Adds the 'policy_version' calculated field. """ +class PoliciesManager(models.Manager): use_in_migrations = True - def get_queryset(self): - return ( - super().get_queryset() - .filter(url__startswith='/privacy-policy-') - .annotate( - version=dbf.Substr( # Poor man's regex ^/privacy-policy-(.+)/$ - dbf.Substr('url', 1, dbf.Length('url') - 1, output_field=models.CharField()), - len('/privacy-policy-') + 1) + def latest_efective(self, requiring_consent: bool = False) -> 'Policy': + policy_filter: dict[str, Any] = { + 'effective_date__lte': timezone.now(), + } + if requiring_consent: + policy_filter['requires_consent'] = True + return self.filter(**policy_filter).latest() + + def all_effective(self) -> tuple[list[str], 'PoliciesManager']: + today = timezone.now() + cache_key = f'all-effective-policies_{today:%Y-%m-%d}' + cached_policy_ids = cache.get(cache_key) + if cached_policy_ids is not None: + policies = self.filter(version__in=cached_policy_ids) + else: + latest_policy_requiring_consent = ( + self + .filter(effective_date__lte=today, requires_consent=True) + .order_by('-effective_date') + )[0:1] + policies = self.filter( + effective_date__lte=today, + effective_date__gte=latest_policy_requiring_consent.values('effective_date'), ) - ) + # Store the effective policies in the cache for one day. + # If a version identifier of a policy is changed during that day, users + # might see an incorrect policy – but that shouldn't happen in practice. + cached_policy_ids = list(policies.values_list('version', flat=True)) + cache.set(cache_key, cached_policy_ids, int(24.5 * 60 * 60)) + return (cached_policy_ids, policies.order_by('-effective_date')) diff --git a/core/middleware.py b/core/middleware.py index 34872994..7bc528bb 100644 --- a/core/middleware.py +++ b/core/middleware.py @@ -1,16 +1,20 @@ from hashlib import md5 +from typing import cast from django.conf import settings from django.contrib.auth.views import ( LoginView, LogoutView, redirect_to_login as redirect_to_intercept, ) from django.core.exceptions import PermissionDenied, ValidationError +from django.http import HttpRequest from django.template.response import TemplateResponse from django.urls import Resolver404, resolve, reverse from django.utils import timezone from django.utils.deprecation import MiddlewareMixin +from django.utils.functional import SimpleLazyObject from django.utils.text import format_lazy from django.utils.translation import gettext_lazy as _ +from django.views import View import geocoder import user_agents @@ -97,26 +101,12 @@ def process_request(self, request): )) # Has the user consented to the most up-to-date usage policy? - policy = (Policy.objects.order_by('-id').values('version', 'content'))[0:1] if trouble_view is not None: - agreement = Agreement.objects.filter( - user=request.user, policy_version__in=policy.values_list('version'), withdrawn__isnull=True) - if not agreement.exists(): - # Policy will be needed to display the following page anyway, - # so it is immediately fetched from the database. - request.user.consent_required = [policy.first()] - if request.user.consent_required[0] is None: - raise RuntimeError("Service misconfigured: No user agreement was defined.") - if trouble_view.func.view_class != AgreementView: - return redirect_to_intercept( - request.get_full_path(), - reverse('agreement'), - redirect_field_name=settings.REDIRECT_FIELD_NAME - ) - else: - # Policy most probably will not be needed, so it is lazily - # evaluated to spare a superfluous query on the database. - request.user.consent_obtained = policy + redirect_response = ( + self._verify_usage_policy_consent(request, trouble_view.func.view_class) + ) + if redirect_response is not None: + return redirect_response # Is the user trying to use the internal communicator and has a # properly configured profile? @@ -137,7 +127,55 @@ def process_request(self, request): t.render() return t - def _update_connection_info(self, request): + def _verify_usage_policy_consent(self, request: HttpRequest, requested_view: type[View]): + policy_versions, policies = Policy.objects.all_effective() + agreement = ( + Agreement.objects + .filter( + user=request.user, + withdrawn__isnull=True, + ) + .order_by('-created') + .values_list('policy_version', flat=True) + ) + + if not set(agreement) & set(policy_versions): + if requested_view != AgreementView: + return redirect_to_intercept( + request.get_full_path(), + reverse('agreement'), + redirect_field_name=settings.REDIRECT_FIELD_NAME, + ) + # Policy will be needed to display the Agreement page anyway, + # so the currently effective policies are immediately fetched + # from the database. + current_policy = list(policies)[0] if policies else None + setattr(request.user, 'consent_required', { + 'given_for': agreement.first(), + 'current': [current_policy], + 'summary': [ + (p.effective_date, p.changes_summary) + for p in policies if p.changes_summary + ], + }) + if current_policy is None: + raise RuntimeError("Service misconfigured: No user agreement was defined.") + else: + # Policy most probably will not be needed, so it is lazily + # evaluated to spare a superfluous query on the database. + current_policy = policies[0:1] + policy_summary = SimpleLazyObject(lambda: [ # pragma: no branch + (p.effective_date, p.changes_summary) + for p in cast(Policy.objects.__class__, current_policy) + if p.changes_summary + ]), + setattr(request.user, 'consent_obtained', { + 'given_for': agreement.first(), + 'current': current_policy, + 'summary': policy_summary, + }) + + def _update_connection_info(self, request: HttpRequest): """ Store information about the browser and device the user is employing and where the user is connecting from. diff --git a/core/migrations/0013_policy_model.py b/core/migrations/0013_policy_model.py new file mode 100644 index 00000000..96fb46bf --- /dev/null +++ b/core/migrations/0013_policy_model.py @@ -0,0 +1,113 @@ +# Generated by Django 3.2.20 on 2024-02-08 18:26 + +from typing import cast +import core.managers +from django.db import migrations, models +from django.db.models import functions as dbf, Value as V, F +from django.apps.registry import Apps +import re + +from django.utils import timezone + + +def migrate_policy_flatpages(app_registry: Apps, schema_editor): + FlatpagePolicy = app_registry.get_model('core', 'FlatpagePolicy') + StandalonePolicy = app_registry.get_model('core', 'Policy') + + policies = ( + FlatpagePolicy.objects + .filter(url__startswith='/privacy-policy-') + .annotate( + version=dbf.Substr( # Poor man's regex ^/privacy-policy-(.+)/$ + dbf.Substr('url', 1, dbf.Length('url') - 1, output_field=models.CharField()), + len('/privacy-policy-') + 1) + ) + .order_by('id') + .values('id', 'content', 'version') + ) + previous_policy_effective_date = timezone.datetime.fromisoformat('2015-12-31') + for policy in policies: + try: + m = re.match(r'^{#\s+([0-9-]+)\s+#}\s*(.+)', policy['content'], re.DOTALL) + date, content = cast(re.Match[str], m).groups() + policy['date'] = timezone.datetime.strptime(date, r'%Y-%m-%d').date() + policy['content'] = content + except Exception: + pass + current_policy_effective_date = policy.get( + 'date', + previous_policy_effective_date + timezone.timedelta(days=1)) + StandalonePolicy.objects.create( + version=policy['version'], + effective_date=current_policy_effective_date, + content=policy['content'], + ) + previous_policy_effective_date = current_policy_effective_date + FlatpagePolicy.objects.filter(pk=policy['id']).update( + title=dbf.Concat(V("[BACKUP] "), F('title')) + ) + + +def restore_policy_flatpages(app_registry: Apps, schema_editor): + FlatpagePolicy = app_registry.get_model('core', 'FlatpagePolicy') + FlatpagePolicy.objects.filter(url__startswith='/privacy-policy-').update( + title=dbf.Replace(F('title'), V("[BACKUP] ")) + ) + + +class Migration(migrations.Migration): + + dependencies = [ + ('flatpages', '0001_initial'), + ('core', '0012_geo_api_keys_as_json'), + ] + + operations = [ + migrations.RenameModel( + old_name='Policy', + new_name='FlatpagePolicy', + ), + # migrations.DeleteModel( + # name='Policy', + # ), + # migrations.CreateModel( + # name='OldPolicy', + # fields=[ + # ], + # options={ + # 'proxy': True, + # 'indexes': [], + # 'constraints': [], + # }, + # bases=('flatpages.flatpage',), + # managers=[ + # ('objects', core.managers.PoliciesManager()), + # ], + # ), + + migrations.CreateModel( + name='Policy', + fields=[ + ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('version', models.SlugField(help_text='Avoid modifying already-existing versions.', max_length=50, unique=True, verbose_name='version of policy')), + ('effective_date', models.DateField(unique=True, verbose_name='in effect from date')), + ('changes_summary', models.TextField(blank=True, verbose_name='summary of changes')), + ('content', models.TextField(verbose_name='content')), + ('requires_consent', models.BooleanField(default=True, verbose_name='consent is required')), + ], + options={ + 'verbose_name': 'policy', + 'verbose_name_plural': 'policies', + 'get_latest_by': 'effective_date', + }, + managers=[ + ('objects', core.managers.PoliciesManager()), + ], + ), + + migrations.RunPython(migrate_policy_flatpages, reverse_code=restore_policy_flatpages), + + migrations.DeleteModel( + name='FlatpagePolicy', + ), + ] diff --git a/core/mixins.py b/core/mixins.py index 7af02c20..b4548aea 100644 --- a/core/mixins.py +++ b/core/mixins.py @@ -1,4 +1,4 @@ -from typing import TypedDict +from typing import Protocol, TypedDict from django.conf import settings from django.contrib.auth import get_user_model @@ -50,12 +50,24 @@ def get_success_url(self, *args, **kwargs): return reverse_lazy('profile_create') +class FlatpageAsTemplateMixin: + class DictWithContent(TypedDict): + content: str + + class HasContent(Protocol): + content: str + + def render_flat_page(self, page: DictWithContent | HasContent) -> str: + ... + + def flatpages_as_templates(cls: type[View]): """ View decorator: Facilitates rendering flat pages as Django templates, including usage of tags and the view's context. Performs some magic to capture the specific view's custom context and provides a helper function `render_flat_page`. + This helper function shouldn't be called from within get_context_data()! """ context_func_name = 'get_context_data' context_func = getattr(cls, context_func_name, None) @@ -66,18 +78,20 @@ def _get_context_data_superfunc(self, **kwargs): return context setattr(cls, context_func_name, _get_context_data_superfunc) - DictWithContent = TypedDict('HasContent', {'content': str}) - - def render_flat_page(self, page: DictWithContent): + def render_flat_page( + self, + page: FlatpageAsTemplateMixin.DictWithContent | FlatpageAsTemplateMixin.HasContent, + ): if not page: return '' from django.template import engines - template = engines.all()[0].from_string(page['content']) + content = page['content'] if isinstance(page, dict) else page.content + template = engines.all()[0].from_string(content) return template.render( getattr(self, '_flat_page_context', render_flat_page._view_context), self.request) - cls.render_flat_page = render_flat_page - cls.render_flat_page._view_context = {} + setattr(cls, 'render_flat_page', render_flat_page) + getattr(cls, 'render_flat_page')._view_context = {} return cls diff --git a/core/models.py b/core/models.py index ef532eaf..8ab7f775 100644 --- a/core/models.py +++ b/core/models.py @@ -1,13 +1,9 @@ -import re -import warnings from collections import namedtuple -from datetime import datetime, timedelta +from datetime import timedelta from django.conf import settings -from django.contrib.flatpages.models import FlatPage from django.db import models -from django.utils.functional import cached_property -from django.utils.translation import gettext_lazy as _ +from django.utils.translation import gettext, gettext_lazy as _ from django_extensions.db.models import TimeStampedModel from solo.models import SingletonModel @@ -17,7 +13,7 @@ from .managers import PoliciesManager -def default_api_keys(): +def default_api_keys(): # pragma: no cover return dict( opencage='a27f7e361bdfe11881a987a6e86fb5fd', openmaptiles='iQbjILhp2gs0dgNfTlIV', @@ -74,30 +70,38 @@ class Meta: verbose_name = _("Site Configuration") -class Policy(FlatPage): - EFFECTIVE_DATE_PATTERN = r'^{#\s+([0-9-]+)\s+' - EFFECTIVE_DATE_FORMAT = '%Y-%m-%d' +class Policy(models.Model): + version = models.SlugField( + _("version of policy"), + max_length=50, unique=True, + help_text=_("Avoid modifying already-existing versions.")) + effective_date = models.DateField( + _("in effect from date"), + unique=True) + changes_summary = models.TextField( + _("summary of changes"), + blank=True) + content = models.TextField( + _("content")) + requires_consent = models.BooleanField( + _("consent is required"), + default=True) objects = PoliciesManager() class Meta: - proxy = True - - @cached_property - def effective_date(self): - return self.get_effective_date_for_policy(self.content) + verbose_name = _("policy") + verbose_name_plural = _("policies") + get_latest_by = 'effective_date' - @classmethod - def get_effective_date_for_policy(cls, policy_content): - try: - date = re.match(cls.EFFECTIVE_DATE_PATTERN, policy_content).group(1) - return datetime.strptime(date, cls.EFFECTIVE_DATE_FORMAT).date() - except AttributeError: - warnings.warn("Policy does not indicate a date it takes effect on!") - return None - except ValueError as err: - warnings.warn("Policy effective date '{}' is invalid; {}".format(date, err)) - return None + def __str__(self): + if self.requires_consent: + # xgettext:python-brace-format + description = gettext("Policy {version} binding from {date:%Y-%m-%d}") + else: + # xgettext:python-brace-format + description = gettext("Policy {version} effective from {date:%Y-%m-%d}") + return description.format(version=self.version, date=self.effective_date) class Agreement(TimeStampedModel): @@ -119,10 +123,10 @@ class Meta: def __str__(self): # xgettext:python-brace-format - return str(_("User {user} agreed to '{policy}' on {date:%Y-%m-%d}")).format( + return gettext("User {user} agreed to '{policy}' on {date:%Y-%m-%d}").format( user=self.user, policy=self.policy_version, - date=self.created + date=self.created, ) diff --git a/core/static/js/scripts.js b/core/static/js/scripts.js index 0a6d245e..fbf1a701 100644 --- a/core/static/js/scripts.js +++ b/core/static/js/scripts.js @@ -333,6 +333,62 @@ $(document).ready(function() { } window.setTimeout(function() { $container.show(); }, 1750); }); + $('[aria-controls="policy-changes"]').on('keydown', function(event) { + if (!/(13|32|40|38)/.test(event.which)) + return; + var $this = $(this); + var $changesPanel = $('#policy-changes'); + // In transition: do nothing. + if ($changesPanel.hasClass('collapsing')) { + event.preventDefault(); + event.stopPropagation(); + return; + } + // Enter and Space keys are handled as arrow keys, + // depending on whether the panel is collapsed or not. + if (event.which == 13 || event.which == 32) { + if ($changesPanel.hasClass('in')) + event.which = 38; + else + event.which = 40; + } + // Down arrow key. + if (event.which == 40) { + var aboveViewportBottom = + this.getBoundingClientRect().bottom < document.documentElement.clientHeight; + if (!$changesPanel.hasClass('in') && aboveViewportBottom) { + event.preventDefault(); + event.stopPropagation(); + $this.trigger('click'); + } + else { + return; // Let the page scroll down normally. + } + } + // Up arrow key. + if (event.which == 38) { + var header = document.getElementsByTagName('header')[0]; + var belowHeader = + this.getBoundingClientRect().top > header.getBoundingClientRect().bottom; + if ($changesPanel.hasClass('in') && belowHeader) { + event.preventDefault(); + event.stopPropagation(); + $this.trigger('click'); + } + else { + return; // Let the page scroll up normally. + } + } + }); + $('#policy-changes').on('show.bs.collapse hide.bs.collapse', function(event) { + var $changesSwitch = $('[aria-controls='+this.id+'] .switch'); + if ($changesSwitch.length == 0) + return; + var label = $changesSwitch.attr('aria-label'); + $changesSwitch.attr('aria-label', $changesSwitch.data('aria-label-inactive')) + .data('aria-label-inactive', label); + $changesSwitch[0].classList.toggle('fa-rotate-90', event.type == 'show'); + }); $('#family-panel-small').each(function() { var familyKey = 'place.ID.family-members.expanded'; familyKey = familyKey.replace('ID', $('.place-detail').data('id')); diff --git a/core/static/sass/_all.scss b/core/static/sass/_all.scss index 6345a24f..483fe286 100644 --- a/core/static/sass/_all.scss +++ b/core/static/sass/_all.scss @@ -1101,6 +1101,45 @@ a.contact-details:not(:hover) { } +/* Policy & Agreement */ +html.js-enabled #policy-changes-header[data-toggle="collapse"] { + cursor: pointer; + .switch { + transition: all 0.3s ease-in; + } +} +html:not(.js-enabled) .policy-switcher { + &[hidden] { + display: none !important; + } + .control-label { + margin-bottom: 0; + vertical-align: middle; + } + .form-control { + display: inline-block; + @media (max-width: #{$BOOTSTRAP-XS - 1}) { + width: 100%; + } + @media (min-width: #{$BOOTSTRAP-XS}) { + width: auto; + } + vertical-align: middle; + } +} +html:not(.js-enabled) noscript.policy-switcher-container + .panel { + margin-top: 20px; +} +html.js-enabled .policy-switcher { + &[hidden] { + display: block; + } + &:not([hidden]) { + display: none; + } +} + + /* Supervisors */ .staff.label .fa.fa-lg { vertical-align: -20%; diff --git a/core/static/sass/_print.scss b/core/static/sass/_print.scss index 484919f7..24a44d5b 100644 --- a/core/static/sass/_print.scss +++ b/core/static/sass/_print.scss @@ -185,6 +185,11 @@ blockquote { } } +#policy-changes.collapse { + display: block; + height: auto !important; +} + .mapboxgl-ctrl-group > button + button { border-top: 0 !important; } diff --git a/core/templates/account/consent.html b/core/templates/account/consent.html index 45488b65..a1263601 100644 --- a/core/templates/account/consent.html +++ b/core/templates/account/consent.html @@ -1,5 +1,5 @@ {% extends 'core/base.html' %} -{% load i18n %} +{% load i18n utils expression variable %} {% block head_title %}{% trans "Agreement between You and" %}{% endblock %} {% block head_title_separator %}{% endblock %} @@ -10,10 +10,17 @@ {% if consent_required %}

- {% blocktrans trimmed %} - Dear member of the PS-community, your attention is required. - Since {{ effective_date }} a new policy and conditions of use are in effect. - {% endblocktrans %} + {% if consent_required.given_for %} + {% blocktrans trimmed %} + Dear member of the PS-community, your attention is required. Since + {{ effective_date }} a new policy and conditions of use are in effect. + {% endblocktrans %} + {% else %} + {% blocktrans trimmed %} + Dear member of the PS-community, your attention to the policy and + conditions of use is required. + {% endblocktrans %} + {% endif %}


@@ -26,6 +33,32 @@ Your continuous use of Pasporta Servo indicates your confirmation that you read the document, understand it, and agree to the conditions herein. {% endblocktrans %} + {% if consent_required.given_for %} + {% asvar privacy_link trimmed %} + {% spaceless %} + + + {% endspaceless %} + {% endasvar %} + {% asvar end_privacy_link trimmed %} + {% spaceless %} + + + {% endspaceless %} + {% endasvar %} + {% blocktrans trimmed %} + Earlier versions can be reviewed at the + {{ privacy_link }}privacy policy{{ end_privacy_link }} page. + {% endblocktrans %} + {% endif %} +

+ {% elif consent_obtained.given_for != consent_obtained.current.first.version %} +

+ {% blocktrans trimmed %} + You have already indicated your consent to be bound by the policy, published + earlier. An updated text of that policy as of {{ effective_date }} (below) + includes some adjustments but does not introduce any changes in the substance. + {% endblocktrans %}

{% else %}

@@ -37,11 +70,62 @@ {% endif %} - {# TODO: Add navigation to the previous versions (stored separately). They should be accessible but not actionable. #} + + {% if view.terms %} +

+ {% endif %}