From 70e1a611dd1445758eaac14473bb17a7518ba128 Mon Sep 17 00:00:00 2001 From: Yohann D'ANELLO Date: Fri, 4 Sep 2020 18:36:20 +0200 Subject: [PATCH] Export activites as an ICS Calendar --- apps/activity/urls.py | 1 + apps/activity/views.py | 627 ++++++++++++++++++++++------------------- apps/logs/signals.py | 5 +- 3 files changed, 346 insertions(+), 287 deletions(-) diff --git a/apps/activity/urls.py b/apps/activity/urls.py index f074e8f7..155229d4 100644 --- a/apps/activity/urls.py +++ b/apps/activity/urls.py @@ -14,4 +14,5 @@ urlpatterns = [ path('/entry/', views.ActivityEntryView.as_view(), name='activity_entry'), path('/update/', views.ActivityUpdateView.as_view(), name='activity_update'), path('new/', views.ActivityCreateView.as_view(), name='activity_create'), + path('calendar.ics', views.CalendarView.as_view(), name='calendar_ics'), ] diff --git a/apps/activity/views.py b/apps/activity/views.py index fd218db5..79934245 100644 --- a/apps/activity/views.py +++ b/apps/activity/views.py @@ -1,283 +1,344 @@ -# Copyright (C) 2018-2020 by BDE ENS Paris-Saclay -# SPDX-License-Identifier: GPL-3.0-or-later - -from django.conf import settings -from django.contrib.auth.mixins import LoginRequiredMixin -from django.contrib.contenttypes.models import ContentType -from django.core.exceptions import PermissionDenied -from django.db.models import F, Q -from django.urls import reverse_lazy -from django.utils import timezone -from django.utils.translation import gettext_lazy as _ -from django.views.generic import DetailView, TemplateView, UpdateView -from django_tables2.views import SingleTableView -from note.models import Alias, NoteSpecial, NoteUser -from permission.backends import PermissionBackend -from permission.views import ProtectQuerysetMixin, ProtectedCreateView - -from .forms import ActivityForm, GuestForm -from .models import Activity, Entry, Guest -from .tables import ActivityTable, EntryTable, GuestTable - - -class ActivityCreateView(ProtectQuerysetMixin, ProtectedCreateView): - """ - View to create a new Activity - """ - model = Activity - form_class = ActivityForm - extra_context = {"title": _("Create new activity")} - - def get_sample_object(self): - return Activity( - name="", - description="", - creater=self.request.user, - activity_type_id=1, - organizer_id=1, - attendees_club_id=1, - date_start=timezone.now(), - date_end=timezone.now(), - ) - - def form_valid(self, form): - form.instance.creater = self.request.user - return super().form_valid(form) - - def get_success_url(self, **kwargs): - self.object.refresh_from_db() - return reverse_lazy('activity:activity_detail', kwargs={"pk": self.object.pk}) - - -class ActivityListView(ProtectQuerysetMixin, LoginRequiredMixin, SingleTableView): - """ - Displays all Activities, and classify if they are on-going or upcoming ones. - """ - model = Activity - table_class = ActivityTable - ordering = ('-date_start',) - extra_context = {"title": _("Activities")} - - def get_queryset(self): - return super().get_queryset().distinct() - - def get_context_data(self, **kwargs): - context = super().get_context_data(**kwargs) - - upcoming_activities = Activity.objects.filter(date_end__gt=timezone.now()) - context['upcoming'] = ActivityTable( - data=upcoming_activities.filter(PermissionBackend.filter_queryset(self.request.user, Activity, "view")), - prefix='upcoming-', - ) - - started_activities = Activity.objects\ - .filter(PermissionBackend.filter_queryset(self.request.user, Activity, "view"))\ - .filter(open=True, valid=True).all() - context["started_activities"] = started_activities - - return context - - -class ActivityDetailView(ProtectQuerysetMixin, LoginRequiredMixin, DetailView): - """ - Shows details about one activity. Add guest to context - """ - model = Activity - context_object_name = "activity" - extra_context = {"title": _("Activity detail")} - - def get_context_data(self, **kwargs): - context = super().get_context_data() - - table = GuestTable(data=Guest.objects.filter(activity=self.object) - .filter(PermissionBackend.filter_queryset(self.request.user, Guest, "view"))) - context["guests"] = table - - context["activity_started"] = timezone.now() > timezone.localtime(self.object.date_start) - - return context - - -class ActivityUpdateView(ProtectQuerysetMixin, LoginRequiredMixin, UpdateView): - """ - Updates one Activity - """ - model = Activity - form_class = ActivityForm - extra_context = {"title": _("Update activity")} - - def get_success_url(self, **kwargs): - return reverse_lazy('activity:activity_detail', kwargs={"pk": self.kwargs["pk"]}) - - -class ActivityInviteView(ProtectQuerysetMixin, ProtectedCreateView): - """ - Invite a Guest, The rules to invites someone are defined in `forms:activity.GuestForm` - """ - model = Guest - form_class = GuestForm - template_name = "activity/activity_form.html" - - def get_sample_object(self): - """ Creates a standart Guest binds to the Activity""" - activity = Activity.objects.get(pk=self.kwargs["pk"]) - return Guest( - activity=activity, - first_name="", - last_name="", - inviter=self.request.user.note, - ) - - def get_context_data(self, **kwargs): - context = super().get_context_data(**kwargs) - activity = context["form"].activity - context["title"] = _('Invite guest to the activity "{}"').format(activity.name) - return context - - def get_form(self, form_class=None): - form = super().get_form(form_class) - form.activity = Activity.objects.filter(PermissionBackend.filter_queryset(self.request.user, Activity, "view"))\ - .get(pk=self.kwargs["pk"]) - form.fields["inviter"].initial = self.request.user.note - return form - - def form_valid(self, form): - form.instance.activity = Activity.objects\ - .filter(PermissionBackend.filter_queryset(self.request.user, Activity, "view")).get(pk=self.kwargs["pk"]) - return super().form_valid(form) - - def get_success_url(self, **kwargs): - return reverse_lazy('activity:activity_detail', kwargs={"pk": self.kwargs["pk"]}) - - -class ActivityEntryView(LoginRequiredMixin, TemplateView): - """ - Manages entry to an activity - """ - template_name = "activity/activity_entry.html" - - def dispatch(self, request, *args, **kwargs): - """ - Don't display the entry interface if the user has no right to see it (no right to add an entry for itself), - it is closed or doesn't manage entries. - """ - activity = Activity.objects.get(pk=self.kwargs["pk"]) - - sample_entry = Entry(activity=activity, note=self.request.user.note) - if not PermissionBackend.check_perm(self.request.user, "activity.add_entry", sample_entry): - raise PermissionDenied(_("You are not allowed to display the entry interface for this activity.")) - - if not activity.activity_type.manage_entries: - raise PermissionDenied(_("This activity does not support activity entries.")) - - if not activity.open: - raise PermissionDenied(_("This activity is closed.")) - return super().dispatch(request, *args, **kwargs) - - def get_invited_guest(self, activity): - """ - Retrieves all Guests to the activity - """ - - guest_qs = Guest.objects\ - .annotate(balance=F("inviter__balance"), note_name=F("inviter__user__username"))\ - .filter(activity=activity)\ - .filter(PermissionBackend.filter_queryset(self.request.user, Guest, "view"))\ - .order_by('last_name', 'first_name').distinct() - - if "search" in self.request.GET and self.request.GET["search"]: - pattern = self.request.GET["search"] - if pattern[0] != "^": - pattern = "^" + pattern - guest_qs = guest_qs.filter( - Q(first_name__regex=pattern) - | Q(last_name__regex=pattern) - | Q(inviter__alias__name__regex=pattern) - | Q(inviter__alias__normalized_name__regex=Alias.normalize(pattern)) - ) - else: - guest_qs = guest_qs.none() - return guest_qs - - def get_invited_note(self, activity): - """ - Retrieves all Note that can attend the activity, - they need to have an up-to-date membership in the attendees_club. - """ - note_qs = Alias.objects.annotate(last_name=F("note__noteuser__user__last_name"), - first_name=F("note__noteuser__user__first_name"), - username=F("note__noteuser__user__username"), - note_name=F("name"), - balance=F("note__balance")) - - # Keep only users that have a note - note_qs = note_qs.filter(note__noteuser__isnull=False) - - # Keep only members - note_qs = note_qs.filter( - note__noteuser__user__memberships__club=activity.attendees_club, - note__noteuser__user__memberships__date_start__lte=timezone.now(), - note__noteuser__user__memberships__date_end__gte=timezone.now(), - ) - - # Filter with permission backend - note_qs = note_qs.filter(PermissionBackend.filter_queryset(self.request.user, Alias, "view")) - - if "search" in self.request.GET and self.request.GET["search"]: - pattern = self.request.GET["search"] - note_qs = note_qs.filter( - Q(note__noteuser__user__first_name__regex=pattern) - | Q(note__noteuser__user__last_name__regex=pattern) - | Q(name__regex=pattern) - | Q(normalized_name__regex=Alias.normalize(pattern)) - ) - else: - note_qs = note_qs.none() - - if settings.DATABASES[note_qs.db]["ENGINE"] == 'django.db.backends.postgresql': - note_qs = note_qs.distinct('note__pk')[:20] - else: - # SQLite doesn't support distinct fields. For compatibility reason (in dev mode), the note list will only - # have distinct aliases rather than distinct notes with a SQLite DB, but it can fill the result page. - # In production mode, please use PostgreSQL. - note_qs = note_qs.distinct()[:20] - return note_qs - - def get_context_data(self, **kwargs): - """ - Query the list of Guest and Note to the activity and add information to makes entry with JS. - """ - context = super().get_context_data(**kwargs) - - activity = Activity.objects.filter(PermissionBackend.filter_queryset(self.request.user, Activity, "view"))\ - .distinct().get(pk=self.kwargs["pk"]) - context["activity"] = activity - - matched = [] - - for guest in self.get_invited_guest(activity): - guest.type = "Invité" - matched.append(guest) - - for note in self.get_invited_note(activity): - note.type = "Adhérent" - note.activity = activity - matched.append(note) - - table = EntryTable(data=matched) - context["table"] = table - - context["entries"] = Entry.objects.filter(activity=activity) - - context["title"] = _('Entry for activity "{}"').format(activity.name) - context["noteuser_ctype"] = ContentType.objects.get_for_model(NoteUser).pk - context["notespecial_ctype"] = ContentType.objects.get_for_model(NoteSpecial).pk - - activities_open = Activity.objects.filter(open=True).filter( - PermissionBackend.filter_queryset(self.request.user, Activity, "view")).distinct().all() - context["activities_open"] = [a for a in activities_open - if PermissionBackend.check_perm(self.request.user, - "activity.add_entry", - Entry(activity=a, note=self.request.user.note,))] - - return context +# Copyright (C) 2018-2020 by BDE ENS Paris-Saclay +# SPDX-License-Identifier: GPL-3.0-or-later +from hashlib import md5 + +from django.conf import settings +from django.contrib.auth.mixins import LoginRequiredMixin +from django.contrib.contenttypes.models import ContentType +from django.core.exceptions import PermissionDenied +from django.db.models import F, Q +from django.http import HttpResponse +from django.urls import reverse_lazy +from django.utils import timezone +from django.utils.translation import gettext_lazy as _ +from django.views import View +from django.views.generic import DetailView, TemplateView, UpdateView +from django_tables2.views import SingleTableView +from note.models import Alias, NoteSpecial, NoteUser +from permission.backends import PermissionBackend +from permission.views import ProtectQuerysetMixin, ProtectedCreateView + +from .forms import ActivityForm, GuestForm +from .models import Activity, Entry, Guest +from .tables import ActivityTable, EntryTable, GuestTable + + +class ActivityCreateView(ProtectQuerysetMixin, ProtectedCreateView): + """ + View to create a new Activity + """ + model = Activity + form_class = ActivityForm + extra_context = {"title": _("Create new activity")} + + def get_sample_object(self): + return Activity( + name="", + description="", + creater=self.request.user, + activity_type_id=1, + organizer_id=1, + attendees_club_id=1, + date_start=timezone.now(), + date_end=timezone.now(), + ) + + def form_valid(self, form): + form.instance.creater = self.request.user + return super().form_valid(form) + + def get_success_url(self, **kwargs): + self.object.refresh_from_db() + return reverse_lazy('activity:activity_detail', kwargs={"pk": self.object.pk}) + + +class ActivityListView(ProtectQuerysetMixin, LoginRequiredMixin, SingleTableView): + """ + Displays all Activities, and classify if they are on-going or upcoming ones. + """ + model = Activity + table_class = ActivityTable + ordering = ('-date_start',) + extra_context = {"title": _("Activities")} + + def get_queryset(self): + return super().get_queryset().distinct() + + def get_context_data(self, **kwargs): + context = super().get_context_data(**kwargs) + + upcoming_activities = Activity.objects.filter(date_end__gt=timezone.now()) + context['upcoming'] = ActivityTable( + data=upcoming_activities.filter(PermissionBackend.filter_queryset(self.request.user, Activity, "view")), + prefix='upcoming-', + ) + + started_activities = Activity.objects\ + .filter(PermissionBackend.filter_queryset(self.request.user, Activity, "view"))\ + .filter(open=True, valid=True).all() + context["started_activities"] = started_activities + + return context + + +class ActivityDetailView(ProtectQuerysetMixin, LoginRequiredMixin, DetailView): + """ + Shows details about one activity. Add guest to context + """ + model = Activity + context_object_name = "activity" + extra_context = {"title": _("Activity detail")} + + def get_context_data(self, **kwargs): + context = super().get_context_data() + + table = GuestTable(data=Guest.objects.filter(activity=self.object) + .filter(PermissionBackend.filter_queryset(self.request.user, Guest, "view"))) + context["guests"] = table + + context["activity_started"] = timezone.now() > timezone.localtime(self.object.date_start) + + return context + + +class ActivityUpdateView(ProtectQuerysetMixin, LoginRequiredMixin, UpdateView): + """ + Updates one Activity + """ + model = Activity + form_class = ActivityForm + extra_context = {"title": _("Update activity")} + + def get_success_url(self, **kwargs): + return reverse_lazy('activity:activity_detail', kwargs={"pk": self.kwargs["pk"]}) + + +class ActivityInviteView(ProtectQuerysetMixin, ProtectedCreateView): + """ + Invite a Guest, The rules to invites someone are defined in `forms:activity.GuestForm` + """ + model = Guest + form_class = GuestForm + template_name = "activity/activity_form.html" + + def get_sample_object(self): + """ Creates a standart Guest binds to the Activity""" + activity = Activity.objects.get(pk=self.kwargs["pk"]) + return Guest( + activity=activity, + first_name="", + last_name="", + inviter=self.request.user.note, + ) + + def get_context_data(self, **kwargs): + context = super().get_context_data(**kwargs) + activity = context["form"].activity + context["title"] = _('Invite guest to the activity "{}"').format(activity.name) + return context + + def get_form(self, form_class=None): + form = super().get_form(form_class) + form.activity = Activity.objects.filter(PermissionBackend.filter_queryset(self.request.user, Activity, "view"))\ + .get(pk=self.kwargs["pk"]) + form.fields["inviter"].initial = self.request.user.note + return form + + def form_valid(self, form): + form.instance.activity = Activity.objects\ + .filter(PermissionBackend.filter_queryset(self.request.user, Activity, "view")).get(pk=self.kwargs["pk"]) + return super().form_valid(form) + + def get_success_url(self, **kwargs): + return reverse_lazy('activity:activity_detail', kwargs={"pk": self.kwargs["pk"]}) + + +class ActivityEntryView(LoginRequiredMixin, TemplateView): + """ + Manages entry to an activity + """ + template_name = "activity/activity_entry.html" + + def dispatch(self, request, *args, **kwargs): + """ + Don't display the entry interface if the user has no right to see it (no right to add an entry for itself), + it is closed or doesn't manage entries. + """ + activity = Activity.objects.get(pk=self.kwargs["pk"]) + + sample_entry = Entry(activity=activity, note=self.request.user.note) + if not PermissionBackend.check_perm(self.request.user, "activity.add_entry", sample_entry): + raise PermissionDenied(_("You are not allowed to display the entry interface for this activity.")) + + if not activity.activity_type.manage_entries: + raise PermissionDenied(_("This activity does not support activity entries.")) + + if not activity.open: + raise PermissionDenied(_("This activity is closed.")) + return super().dispatch(request, *args, **kwargs) + + def get_invited_guest(self, activity): + """ + Retrieves all Guests to the activity + """ + + guest_qs = Guest.objects\ + .annotate(balance=F("inviter__balance"), note_name=F("inviter__user__username"))\ + .filter(activity=activity)\ + .filter(PermissionBackend.filter_queryset(self.request.user, Guest, "view"))\ + .order_by('last_name', 'first_name').distinct() + + if "search" in self.request.GET and self.request.GET["search"]: + pattern = self.request.GET["search"] + if pattern[0] != "^": + pattern = "^" + pattern + guest_qs = guest_qs.filter( + Q(first_name__regex=pattern) + | Q(last_name__regex=pattern) + | Q(inviter__alias__name__regex=pattern) + | Q(inviter__alias__normalized_name__regex=Alias.normalize(pattern)) + ) + else: + guest_qs = guest_qs.none() + return guest_qs + + def get_invited_note(self, activity): + """ + Retrieves all Note that can attend the activity, + they need to have an up-to-date membership in the attendees_club. + """ + note_qs = Alias.objects.annotate(last_name=F("note__noteuser__user__last_name"), + first_name=F("note__noteuser__user__first_name"), + username=F("note__noteuser__user__username"), + note_name=F("name"), + balance=F("note__balance")) + + # Keep only users that have a note + note_qs = note_qs.filter(note__noteuser__isnull=False) + + # Keep only members + note_qs = note_qs.filter( + note__noteuser__user__memberships__club=activity.attendees_club, + note__noteuser__user__memberships__date_start__lte=timezone.now(), + note__noteuser__user__memberships__date_end__gte=timezone.now(), + ) + + # Filter with permission backend + note_qs = note_qs.filter(PermissionBackend.filter_queryset(self.request.user, Alias, "view")) + + if "search" in self.request.GET and self.request.GET["search"]: + pattern = self.request.GET["search"] + note_qs = note_qs.filter( + Q(note__noteuser__user__first_name__regex=pattern) + | Q(note__noteuser__user__last_name__regex=pattern) + | Q(name__regex=pattern) + | Q(normalized_name__regex=Alias.normalize(pattern)) + ) + else: + note_qs = note_qs.none() + + if settings.DATABASES[note_qs.db]["ENGINE"] == 'django.db.backends.postgresql': + note_qs = note_qs.distinct('note__pk')[:20] + else: + # SQLite doesn't support distinct fields. For compatibility reason (in dev mode), the note list will only + # have distinct aliases rather than distinct notes with a SQLite DB, but it can fill the result page. + # In production mode, please use PostgreSQL. + note_qs = note_qs.distinct()[:20] + return note_qs + + def get_context_data(self, **kwargs): + """ + Query the list of Guest and Note to the activity and add information to makes entry with JS. + """ + context = super().get_context_data(**kwargs) + + activity = Activity.objects.filter(PermissionBackend.filter_queryset(self.request.user, Activity, "view"))\ + .distinct().get(pk=self.kwargs["pk"]) + context["activity"] = activity + + matched = [] + + for guest in self.get_invited_guest(activity): + guest.type = "Invité" + matched.append(guest) + + for note in self.get_invited_note(activity): + note.type = "Adhérent" + note.activity = activity + matched.append(note) + + table = EntryTable(data=matched) + context["table"] = table + + context["entries"] = Entry.objects.filter(activity=activity) + + context["title"] = _('Entry for activity "{}"').format(activity.name) + context["noteuser_ctype"] = ContentType.objects.get_for_model(NoteUser).pk + context["notespecial_ctype"] = ContentType.objects.get_for_model(NoteSpecial).pk + + activities_open = Activity.objects.filter(open=True).filter( + PermissionBackend.filter_queryset(self.request.user, Activity, "view")).distinct().all() + context["activities_open"] = [a for a in activities_open + if PermissionBackend.check_perm(self.request.user, + "activity.add_entry", + Entry(activity=a, note=self.request.user.note,))] + + return context + + +class CalendarView(View): + """ + Render an ICS calendar with all valid activities. + """ + + def multilines(self, string, maxlength, offset=0): + newstring = string[:maxlength - offset] + string = string[maxlength - offset:] + while string: + newstring += "\r\n " + newstring += string[:maxlength - 1] + string = string[maxlength - 1:] + return newstring + + def get(self, request, *args, **kwargs): + ics = """BEGIN:VCALENDAR +VERSION: 2.0 +PRODID:Note Kfet 2020 +X-WR-CALNAME:Kfet Calendar +NAME:Kfet Calendar +CALSCALE:GREGORIAN +BEGIN:VTIMEZONE +TZID:Europe/Berlin +TZURL:http://tzurl.org/zoneinfo-outlook/Europe/Berlin +X-LIC-LOCATION:Europe/Berlin +BEGIN:DAYLIGHT +TZOFFSETFROM:+0100 +TZOFFSETTO:+0200 +TZNAME:CEST +DTSTART:19700329T020000 +RRULE:FREQ=YEARLY;BYMONTH=3;BYDAY=-1SU +END:DAYLIGHT +BEGIN:STANDARD +TZOFFSETFROM:+0200 +TZOFFSETTO:+0100 +TZNAME:CET +DTSTART:19701025T030000 +RRULE:FREQ=YEARLY;BYMONTH=10;BYDAY=-1SU +END:STANDARD +END:VTIMEZONE +""" + for activity in Activity.objects.filter(valid=True).order_by("-date_start").all(): + ics += f"""BEGIN:VEVENT +DTSTAMP:{"{:%Y%m%dT%H%M%S}".format(activity.date_start)}Z +UID:{activity.id} +SUMMARY;CHARSET=UTF-8:{self.multilines(activity.name, 75, 22)} +DTSTART;TZID=Europe/Berlin:{"{:%Y%m%dT%H%M%S}".format(activity.date_start)} +DTEND;TZID=Europe/Berlin:{"{:%Y%m%dT%H%M%S}".format(activity.date_end)} +LOCATION:{self.multilines(activity.location, 75, 9) if activity.location else "Kfet"} +DESCRIPTION;CHARSET=UTF-8:{self.multilines(activity.description, 75, 26)} + -- {activity.organizer.name} +END:VEVENT +""" + ics += "END:VCALENDAR" + ics = ics.replace("\r", "").replace("\n", "\r\n") + return HttpResponse(ics, content_type="text/calendar; charset=UTF-8") diff --git a/apps/logs/signals.py b/apps/logs/signals.py index 89e7d05d..e58ba7c1 100644 --- a/apps/logs/signals.py +++ b/apps/logs/signals.py @@ -117,10 +117,7 @@ def delete_object(sender, instance, **kwargs): Each time a model is deleted, an entry in the table `Changelog` is added in the database """ # noinspection PyProtectedMember - if instance._meta.label_lower in EXCLUDED: - return - - if hasattr(instance, "_no_log"): + if instance._meta.label_lower in EXCLUDED or hasattr(instance, "_no_log"): return # Si un utilisateur est connecté, on récupère l'utilisateur courant ainsi que son adresse IP