diff --git a/CHANGELOG.rst b/CHANGELOG.rst index 6be885dbead43b7d687e21bec5678dbd52348a16..c92ee051393adf8bcdff7cb3402ced9eecea12dc 100644 --- a/CHANGELOG.rst +++ b/CHANGELOG.rst @@ -13,6 +13,7 @@ Added ~~~~~ * Allow to disable exception mails to admins +* Add possibility to create iCal feeds in all apps and dynamically create user-specific urls. Fixed ~~~~~ diff --git a/README.rst b/README.rst index a6732de3b038f5d1d4f5b59d2c05f3c77840c2b9..290669c5779841ac7713f7f8004cc3141de0b1f9 100644 --- a/README.rst +++ b/README.rst @@ -64,7 +64,7 @@ Licence Copyright © 2017, 2018, 2019, 2020, 2021, 2022 Jonathan Weth <dev@jonathanweth.de> Copyright © 2017, 2018, 2019, 2020 Frank Poetzsch-Heffter <p-h@katharineum.de> Copyright © 2018, 2019, 2020, 2021, 2022 Hangzhi Yu <yuha@katharineum.de> - Copyright © 2018, 2019, 2020, 2021 Julian Leucker <leuckeju@katharineum.de> + Copyright © 2018, 2019, 2020, 2021, 2022 Julian Leucker <leuckeju@katharineum.de> Copyright © 2019, 2020, 2021, 2022 Dominik George <dominik.george@teckids.org> Copyright © 2019, 2020, 2021, 2022 Tom Teichler <tom.teichler@teckids.org> Copyright © 2019 mirabilos <thorsten.glaser@teckids.org> diff --git a/aleksis/core/apps.py b/aleksis/core/apps.py index 77e4b2a6327d9b88254b160375b1ce85be73ed68..08c965a02a7d6d0c4c53fb1bbcc5919ad35f7b7e 100644 --- a/aleksis/core/apps.py +++ b/aleksis/core/apps.py @@ -39,7 +39,7 @@ class CoreConfig(AppConfig): ([2017, 2018, 2019, 2020, 2021, 2022], "Jonathan Weth", "wethjo@katharineum.de"), ([2017, 2018, 2019, 2020], "Frank Poetzsch-Heffter", "p-h@katharineum.de"), ([2018, 2019, 2020, 2021, 2022], "Hangzhi Yu", "yuha@katharineum.de"), - ([2018, 2019, 2020, 2021], "Julian Leucker", "leuckeju@katharineum.de"), + ([2018, 2019, 2020, 2021, 2022], "Julian Leucker", "leuckeju@katharineum.de"), ([2019, 2020, 2021, 2022], "Dominik George", "dominik.george@teckids.org"), ([2019, 2020, 2021, 2022], "Tom Teichler", "tom.teichler@teckids.org"), ([2019], "mirabilos", "thorsten.glaser@teckids.org"), diff --git a/aleksis/core/feeds.py b/aleksis/core/feeds.py new file mode 100644 index 0000000000000000000000000000000000000000..fdfd6bc1f1123683f15e8b4baeada483892fa8f5 --- /dev/null +++ b/aleksis/core/feeds.py @@ -0,0 +1,80 @@ +from django.conf import settings +from django.utils.formats import date_format +from django.utils.functional import classproperty +from django.utils.translation import gettext_lazy as _ + +from django_ical.utils import build_rrule_from_text +from django_ical.views import ICalFeed + +from aleksis.core import models +from aleksis.core.util.core_helpers import get_site_preferences, queryset_rules_filter + + +class PersonalICalFeedBase(ICalFeed): + """Base class for personal iCal feeds.""" + + @property + def product_id(self): + lang = self.request.LANGUAGE_CODE + title = get_site_preferences()["general__title"] + return f"-//AlekSIS//{title}//{lang}" + + link = settings.BASE_URL + timezone = settings.TIME_ZONE + person = None + request = None + + def get_object(self, request, *args, **kwargs): + if kwargs.get("person"): + self.person = kwargs.pop("person") + self.request = request + return super().get_object(request, *args, **kwargs) + + @classproperty + def subclasses_list(cls): + return cls.__subclasses__() + + @classproperty + def subclasses_dict(cls): + return {subclass.__name__: subclass for subclass in cls.subclasses_list} + + @classproperty + def subclass_choices(cls): + return [ + (subclass.__name__, f"{subclass.title} – {subclass.description}") + for subclass in cls.subclasses_list + ] + + +class BirthdayFeed(PersonalICalFeedBase): + """Birthday calendar feed.""" + + title = _("Birthday Calendar") + description = _("A Calendar of Birthdays") + file_name = "birthdays.ics" + + def items(self): + from aleksis.core.models import Person + + return queryset_rules_filter( + obj=self.person.user, + perm="core.view_personal_details_rule", + queryset=Person.objects.filter(date_of_birth__isnull=False), + ) + + def item_title(self, item: "models.Person"): + return _("%(name)s's birthday") % { + "name": item.addressing_name, + } + + def item_description(self, item: "models.Person"): + return _("%(name)s was born on %(birthday)s") % { + "name": item.addressing_name, + "birthday": date_format(item.date_of_birth), + } + + def item_start_datetime(self, item): + return item.date_of_birth + + def item_rrule(self, item): + return build_rrule_from_text("FREQ=YEARLY") diff --git a/aleksis/core/menus.py b/aleksis/core/menus.py index a714559e822ebe69e3e32e7c3d1bdd1791436a1d..58ffe5bcc421b738b367c0e04de76cf83fc2737f 100644 --- a/aleksis/core/menus.py +++ b/aleksis/core/menus.py @@ -315,6 +315,18 @@ MENUS = { "aleksis.core.util.core_helpers.has_person", ], }, + { + "name": _("Calendar Feeds"), + "url": "ical_feed_list", + "svg_icon": "mdi:calendar-multiple", + "validators": [ + "menu_generator.validators.is_authenticated", + ( + "aleksis.core.util.predicates.permission_validator", + "core.view_ical_rule", + ), + ], + }, { "divider": True, "name": _("Logout"), diff --git a/aleksis/core/migrations/0039_personal_ical_url.py b/aleksis/core/migrations/0039_personal_ical_url.py new file mode 100644 index 0000000000000000000000000000000000000000..02d9cb3a9e3589563c7dd68ce211271264c5245e --- /dev/null +++ b/aleksis/core/migrations/0039_personal_ical_url.py @@ -0,0 +1,31 @@ +# Generated by Django 3.2.12 on 2022-02-20 21:04 + +from django.db import migrations, models +import django.db.models.deletion +import uuid + +from aleksis.core.feeds import PersonalICalFeedBase + + +class Migration(migrations.Migration): + + dependencies = [ + ('core', '0038_notification_send_at'), + ] + + operations = [ + migrations.CreateModel( + name='PersonalICalUrl', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('uuid', models.UUIDField(default=uuid.uuid4, editable=False, unique=True, verbose_name='UUID')), + ('name', models.CharField(max_length=255, verbose_name='Name')), + ('ical_feed', models.CharField(choices=PersonalICalFeedBase.subclass_choices, max_length=255, verbose_name='Selected ICal feed')), + ('person', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='calendar_urls', to='core.person', verbose_name='Person')), + ], + options={ + 'verbose_name': 'Personal Calendar URL', + 'verbose_name_plural': 'Personal Calendar URLs', + }, + ), + ] diff --git a/aleksis/core/models.py b/aleksis/core/models.py index 754c4fea9c076adfeb24f038b99d09960117c17b..963c60c99ef8e8b1a9b0ca7ebc31c7c8f298a2f5 100644 --- a/aleksis/core/models.py +++ b/aleksis/core/models.py @@ -1,6 +1,7 @@ # flake8: noqa: DJ01 import base64 import hmac +import uuid from datetime import date, datetime, timedelta from typing import Any, Iterable, List, Optional, Sequence, Union from urllib.parse import urlparse @@ -52,6 +53,7 @@ from polymorphic.models import PolymorphicModel from aleksis.core.data_checks import BrokenDashboardWidgetDataCheck, DataCheck, DataCheckRegistry +from .feeds import PersonalICalFeedBase from .managers import ( CurrentSiteManagerWithoutMigrations, GroupManager, @@ -1351,3 +1353,41 @@ class OAuthRefreshToken(AbstractRefreshToken): """Placeholder for customising the RefreshToken model.""" pass + + +class PersonalICalUrl(models.Model): + """Calendar URL for a person. + + This is used to connect iCalendar subscriptions to a person. A person can have multiple + URLs. The URL is used to generate the iCalendar feed and have personalized results. It + is possible to create multiple URLs for the same person and the same iCal feed, e.g. to + allow a person to share and unshare their calendar with other people. + """ + + person = models.ForeignKey( + "Person", + on_delete=models.CASCADE, + related_name="calendar_urls", + verbose_name=_("Person"), + ) + uuid = models.UUIDField(default=uuid.uuid4, editable=False, verbose_name=_("UUID"), unique=True) + name = models.CharField(max_length=255, verbose_name=_("Name")) + ical_feed = models.CharField( + max_length=255, + verbose_name=_("Selected ICal feed"), + choices=PersonalICalFeedBase.subclass_choices, + ) + + @property + def ical_feed_object(self): + return PersonalICalFeedBase.subclasses_dict.get(self.ical_feed) + + class Meta: + verbose_name = _("Personal Calendar URL") + verbose_name_plural = _("Personal Calendar URLs") + + def __str__(self): + return self.name + + def get_absolute_url(self): + return reverse("ical_feed", kwargs={"slug": self.uuid}) diff --git a/aleksis/core/rules.py b/aleksis/core/rules.py index 022fa11a00671a3acb0775c2229a378bd3981342..a6b478f26144b9fdea74804dccdb7a1913b01c30 100644 --- a/aleksis/core/rules.py +++ b/aleksis/core/rules.py @@ -7,6 +7,7 @@ from .util.predicates import ( has_global_perm, has_object_perm, has_person, + is_assigned_to_current_person, is_current_person, is_group_owner, is_notification_recipient, @@ -357,3 +358,16 @@ rules.add_perm("core.manage_permissions", manage_person_permissions_predicate) test_pdf_generation_predicate = has_person & has_global_perm("core.test_pdf") rules.add_perm("core.test_pdf_rule", test_pdf_generation_predicate) + +# Do CRUD on PersonalICalUrls +view_ical_predicate = has_person +rules.add_perm("core.view_ical_rule", view_ical_predicate) + +create_ical_predicate = view_ical_predicate +rules.add_perm("core.create_ical_rule", create_ical_predicate) + +edit_ical_predicate = view_ical_predicate & is_assigned_to_current_person +rules.add_perm("core.edit_ical_rule", edit_ical_predicate) + +delete_ical_predicate = edit_ical_predicate +rules.add_perm("core.delete_ical_rule", delete_ical_predicate) diff --git a/aleksis/core/static/js/copy_button.js b/aleksis/core/static/js/copy_button.js new file mode 100644 index 0000000000000000000000000000000000000000..554f6230e4f9c8b9bb012875e31da25c4f61c2c6 --- /dev/null +++ b/aleksis/core/static/js/copy_button.js @@ -0,0 +1,16 @@ +$(".copy-button").click((e) => { + const target = $(e.currentTarget); + const input = $("#" + target.data("target")); + const copy_icon = target.children(".copy-icon-copy").first(); + const check_icon = target.children(".copy-icon-success").first(); + + console.log("Copying to clipboard"); + navigator.clipboard.writeText(input.val()).then(r => { + check_icon.show(); + copy_icon.hide(); + setTimeout(() => { + check_icon.hide(); + copy_icon.show(); + }, 1000); + }); +}); diff --git a/aleksis/core/static/public/style.scss b/aleksis/core/static/public/style.scss index 832a80c4b2c5e4fd8395790284c468996a8d5c8e..5a35561c389f5656fd573e8f9b698ae456d42ad5 100644 --- a/aleksis/core/static/public/style.scss +++ b/aleksis/core/static/public/style.scss @@ -24,6 +24,10 @@ rect#background { background-color: lighten($primary-color, 5%); } +.waves-effect.waves-secondary .waves-ripple { + background-color: lighten($secondary-color, 5%); +} + .success { @extend .light-green, .lighten-3 } @@ -974,3 +978,16 @@ svg.iconify { text-align: center; } } + +.btn-small-line-height { + line-height: $button-small-height; +} + +.btn-smaller-padding { + padding: 0 8px; +} + +p.ical-description { + margin: 0; + font-weight: 300; +} diff --git a/aleksis/core/templates/core/ical/ical_create.html b/aleksis/core/templates/core/ical/ical_create.html new file mode 100644 index 0000000000000000000000000000000000000000..64ff80083ebab3e61cd108d46ee1bff158cdd236 --- /dev/null +++ b/aleksis/core/templates/core/ical/ical_create.html @@ -0,0 +1,20 @@ +{% extends 'core/base.html' %} +{% load i18n material_form %} + +{% block page_title %}{% blocktrans %}Create iCal URL{% endblocktrans %}{% endblock page_title %} +{% block browser_title %}{% blocktrans %}Create iCal URL{% endblocktrans %}{% endblock browser_title %} + +{% block content %} + <form method="post"> + {% csrf_token %} + + {% form form=form %}{% endform %} + + {% include "core/partials/save_button.html" %} + <a href="{% url "ical_feed_list" %}" class="btn red"> + <i class="material-icons iconify left" data-icon="mdi:close"></i> + {% blocktrans %}Cancel{% endblocktrans %} + </a> + </form> + +{% endblock content %} diff --git a/aleksis/core/templates/core/ical/ical_edit.html b/aleksis/core/templates/core/ical/ical_edit.html new file mode 100644 index 0000000000000000000000000000000000000000..cd46c475531bc1154f5f36c16ae1476b79e73137 --- /dev/null +++ b/aleksis/core/templates/core/ical/ical_edit.html @@ -0,0 +1,20 @@ +{% extends 'core/base.html' %} +{% load i18n material_form %} + +{% block page_title %}{% blocktrans %}Edit iCal URL {{ object }}{% endblocktrans %}{% endblock page_title %} +{% block browser_title %}{% blocktrans %}Edit iCal URL {{ object }}{% endblocktrans %}{% endblock browser_title %} + +{% block content %} + <form method="post"> + {% csrf_token %} + + {% form form=form %}{% endform %} + + {% include "core/partials/save_button.html" %} + <a href="{% url "ical_feed_list" %}" class="btn red"> + <i class="material-icons iconify left" data-icon="mdi:close"></i> + {% blocktrans %}Cancel{% endblocktrans %} + </a> + </form> + +{% endblock content %} diff --git a/aleksis/core/templates/core/ical/ical_list.html b/aleksis/core/templates/core/ical/ical_list.html new file mode 100644 index 0000000000000000000000000000000000000000..2388feb2daa9c4b31abb80bc830055e2d2d3efc6 --- /dev/null +++ b/aleksis/core/templates/core/ical/ical_list.html @@ -0,0 +1,38 @@ +{% extends 'core/base.html' %} +{% load i18n msg_box static html_helpers %} + +{% block page_title %}{% trans "ICal Feeds" %}{% endblock page_title %} +{% block browser_title %}{% trans "ICal Feeds" %}{% endblock browser_title %} + +{% block content %} + {% trans "These are URLs for different Calendar Feeds in the iCal (.ics) format. You can create as many as you want and import them in your calendar software." as msg %} + {% msg_box msg=msg status="info" %} + <a href="{% url "ical_feed_create" %}" class="btn green"> + <i class="material-icons iconify left" data-icon="mdi:add"></i> + {% trans "Create iCal URL" %} + </a> + <h2>{% trans "Your iCal URLs" %}</h2> + <div class="collection"> + {% for object in object_list %} + <div class="collection-item"> + <span class="title btn-small-line-height"> + {{ object }} + <a href="{% url "ical_feed_delete" object.pk %}" + class="secondary-content btn-flat btn-small red-text btn-smaller-padding waves-effect waves-red"> + <i class="material-icons iconify" data-icon="mdi:delete-outline"></i> + </a> + <a href="{% url "ical_feed_edit" object.pk %}" + class="secondary-content btn-flat btn-small primary-color-text btn-smaller-padding waves-effect waves-primary"> + <i class="material-icons iconify" data-icon="mdi:pencil-outline"></i> + </a> + {% generate_random_id "ical-copy-" as id %} + {% include "core/partials/copy_button.html" with classes="secondary-content btn-flat btn-small secondary-color-text btn-smaller-padding" target=id %} + </span> + <p class="ical-description">{{ object.ical_feed_object.title }} – {{ object.ical_feed_object.description }}</p> + <input type="url" readonly value="https://{{ request.site }}{{ object.get_absolute_url }}" + id="{{ id }}"> + </div> + {% endfor %} + </div> + <script src="{% static "js/copy_button.js" %}" type="text/javascript"></script> +{% endblock content %} diff --git a/aleksis/core/templates/core/partials/copy_button.html b/aleksis/core/templates/core/partials/copy_button.html new file mode 100644 index 0000000000000000000000000000000000000000..2ca0168333bc7215a580b05316c4556db436bb12 --- /dev/null +++ b/aleksis/core/templates/core/partials/copy_button.html @@ -0,0 +1,5 @@ +<button type="button" data-target="{{ target }}" + class="{{ classes }} copy-button waves-effect waves-secondary"> + <i class="material-icons iconify copy-icon-copy" data-icon="mdi:content-copy"></i> + <i class="material-icons iconify copy-icon-success" style="display: none" data-icon="mdi:check"></i> +</button> diff --git a/aleksis/core/templatetags/html_helpers.py b/aleksis/core/templatetags/html_helpers.py index 5f78b1b195f9030cc97d96a0488c14845d450741..34066192527930c2a4096f49deb3d247f883c31e 100644 --- a/aleksis/core/templatetags/html_helpers.py +++ b/aleksis/core/templatetags/html_helpers.py @@ -1,3 +1,6 @@ +import random +import string + from django import template from bs4 import BeautifulSoup @@ -22,3 +25,18 @@ def add_class_to_el(value: str, arg: str) -> str: el["class"] = el.get("class", []) + [cls] return str(soup) + + +@register.simple_tag +def generate_random_id(prefix: str, length: int = 10) -> str: + """Generate a random ID for templates. + + :Example: + + .. code-block:: + + {% generate_random_id "prefix-" %} + """ + return prefix + "".join( + random.choice(string.ascii_lowercase) for i in range(length) # noqa: S311 + ) diff --git a/aleksis/core/urls.py b/aleksis/core/urls.py index f39808e0ef0ed22e9c3b94e91fc5c1161beccdb9..e76da89d18ed2078fc2405db11829aa344725fdf 100644 --- a/aleksis/core/urls.py +++ b/aleksis/core/urls.py @@ -320,6 +320,11 @@ urlpatterns = [ name="assign_permission", ), path("pdfs/<int:pk>/", views.RedirectToPDFFile.as_view(), name="redirect_to_pdf_file"), + path("ical/", views.ICalFeedListView.as_view(), name="ical_feed_list"), + path("ical/create/", views.ICalFeedCreateView.as_view(), name="ical_feed_create"), + path("ical/<int:pk>/edit/", views.ICalFeedEditView.as_view(), name="ical_feed_edit"), + path("ical/<int:pk>/delete/", views.ICalFeedDeleteView.as_view(), name="ical_feed_delete"), + path("ical/<slug:slug>.ics", views.ICalFeedView.as_view(), name="ical_feed"), path("__icons__/", include("dj_iconify.urls")), ] diff --git a/aleksis/core/util/predicates.py b/aleksis/core/util/predicates.py index bcba7e8637d81aa9c554aef65cdb431b059753a9..5ba4271c08a4244ba5f359e9c46e01eff6c6d112 100644 --- a/aleksis/core/util/predicates.py +++ b/aleksis/core/util/predicates.py @@ -154,3 +154,9 @@ def contains_site_preference_value(section: str, pref: str, value: str): def has_activated_2fa(user: User) -> bool: """Check if the user has activated two-factor authentication.""" return user_has_device(user) + + +@predicate +def is_assigned_to_current_person(user: User, obj: Model) -> bool: + """Check if the object is assigned to the current person.""" + return getattr(obj, "person", None) == user.person diff --git a/aleksis/core/views.py b/aleksis/core/views.py index cb5a9169c5a9bb0048a998f289ee9a44432d06e9..ed20571dd83aee3bfc5d3bc4fd1dc2c8a7211b1d 100644 --- a/aleksis/core/views.py +++ b/aleksis/core/views.py @@ -102,6 +102,7 @@ from .models import ( OAuthApplication, PDFFile, Person, + PersonalICalUrl, PersonInvitation, SchoolTerm, TaskUserAssignment, @@ -1566,3 +1567,59 @@ class CustomAuthorizationView(AuthorizationView): context = super().get_context_data(**kwargs) context["no_menu"] = True return context + + +class ICalFeedView(DetailView): + model = PersonalICalUrl + slug_field = "uuid" + + def get(self, request, *args, **kwargs): + obj: PersonalICalUrl = self.get_object() + if obj.ical_feed_object: + kwargs["person"] = obj.person + return obj.ical_feed_object()(request, *args, **kwargs) + else: + return HttpResponse(status=410) + + +class ICalFeedListView(PermissionRequiredMixin, ListView): + model = PersonalICalUrl + template_name = "core/ical/ical_list.html" + permission_required = "core.view_ical_rule" + + def get_queryset(self): + return self.model.objects.filter(person=self.request.user.person) + + +class ICalFeedEditView(PermissionRequiredMixin, AdvancedEditView): + model = PersonalICalUrl + template_name = "core/ical/ical_edit.html" + success_url = reverse_lazy("ical_feed_list") + success_message = _("ICal feed updated successfully") + permission_required = "core.edit_ical_rule" + + fields = ["name", "ical_feed"] + + +class ICalFeedDeleteView(PermissionRequiredMixin, AdvancedDeleteView): + model = PersonalICalUrl + template_name = "core/pages/delete.html" + success_url = reverse_lazy("ical_feed_list") + success_message = _("ICal feed deleted successfully") + permission_required = "core.delete_ical_rule" + + +class ICalFeedCreateView(PermissionRequiredMixin, AdvancedCreateView): + model = PersonalICalUrl + template_name = "core/ical/ical_create.html" + success_url = reverse_lazy("ical_feed_list") + success_message = _("ICal feed created successfully") + permission_required = "core.create_ical_rule" + + fields = ["name", "ical_feed"] + + def form_valid(self, form): + obj = form.save(commit=False) + obj.person = self.request.user.person + obj.save() + return super().form_valid(form) diff --git a/pyproject.toml b/pyproject.toml index 01bf30832e0e8fea53a550b38864dabec66bf022..51f859a3a864acf61fc4755581242f751567d6dd 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -126,6 +126,7 @@ python-gnupg = "^0.4.7" sentry-sdk = {version = "^1.4.3", optional = true} django-cte = "^1.1.5" pycountry = "^22.0.0" +django-ical = "^1.8.3" django-iconify = "^0.2.0" customidenticon = "^0.1.5"