mirror of
				https://gitlab.crans.org/bde/nk20
				synced 2025-10-31 15:50:03 +01:00 
			
		
		
		
	
		
			
				
	
	
		
			521 lines
		
	
	
		
			20 KiB
		
	
	
	
		
			Python
		
	
	
	
	
	
			
		
		
	
	
			521 lines
		
	
	
		
			20 KiB
		
	
	
	
		
			Python
		
	
	
	
	
	
| # Copyright (C) 2018-2025 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 import transaction
 | |
| from django.db.models import F, Q
 | |
| from django.http import HttpResponse, JsonResponse
 | |
| from django.urls import reverse_lazy
 | |
| from django.utils import timezone
 | |
| from django.utils.decorators import method_decorator
 | |
| from django.utils.translation import gettext_lazy as _
 | |
| from django.views import View
 | |
| from django.views.decorators.cache import cache_page
 | |
| from django.views.generic import DetailView, TemplateView, UpdateView
 | |
| from django.views.generic.list import ListView
 | |
| from django_tables2.views import MultiTableMixin, SingleTableMixin
 | |
| from api.viewsets import is_regex
 | |
| 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, Opener
 | |
| from .tables import ActivityTable, EntryTable, GuestTable, OpenerTable
 | |
| 
 | |
| 
 | |
| 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(),
 | |
|         )
 | |
| 
 | |
|     @transaction.atomic
 | |
|     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, MultiTableMixin, ListView):
 | |
|     """
 | |
|     Displays all Activities, and classify if they are on-going or upcoming ones.
 | |
|     """
 | |
|     model = Activity
 | |
|     tables = [
 | |
|         lambda data: ActivityTable(data, prefix="all-"),
 | |
|         lambda data: ActivityTable(data, prefix="upcoming-"),
 | |
|         lambda data: ActivityTable(data, prefix="search-"),
 | |
|     ]
 | |
|     extra_context = {"title": _("Activities")}
 | |
| 
 | |
|     def get_queryset(self, **kwargs):
 | |
|         """
 | |
|         Filter the user list with the given pattern.
 | |
|         """
 | |
|         return super().get_queryset().distinct()
 | |
| 
 | |
|     def get_tables_data(self):
 | |
|         # first table = all activities, second table = upcoming, third table = search
 | |
| 
 | |
|         # table search
 | |
|         qs = self.get_queryset().order_by('-date_start')
 | |
|         if "search" in self.request.GET and self.request.GET['search']:
 | |
|             pattern = self.request.GET['search']
 | |
| 
 | |
|             # check regex
 | |
|             valid_regex = is_regex(pattern)
 | |
|             suffix = '__iregex' if valid_regex else '__istartswith'
 | |
|             prefix = '^' if valid_regex else ''
 | |
|             qs = qs.filter(Q(**{f'name{suffix}': prefix + pattern})
 | |
|                            | Q(**{f'organizer__name{suffix}': prefix + pattern})
 | |
|                            | Q(**{f'organizer__note__alias__name{suffix}': prefix + pattern}))
 | |
|         else:
 | |
|             qs = qs.none()
 | |
|         search_table = qs.filter(PermissionBackend.filter_queryset(self.request, Activity, 'view'))
 | |
| 
 | |
|         return [
 | |
|             self.get_queryset().order_by("-date_start"),
 | |
|             Activity.objects.filter(date_end__gt=timezone.now())
 | |
|                             .filter(PermissionBackend.filter_queryset(self.request, Activity, "view"))
 | |
|                             .distinct()
 | |
|                             .order_by("date_start"),
 | |
|             search_table,
 | |
|         ]
 | |
| 
 | |
|     def get_context_data(self, **kwargs):
 | |
|         context = super().get_context_data(**kwargs)
 | |
| 
 | |
|         tables = context["tables"]
 | |
|         for name, table in zip(["all", "upcoming", "table"], tables):
 | |
|             context[name] = table
 | |
| 
 | |
|         started_activities = self.get_queryset().filter(open=True, valid=True).distinct().all()
 | |
|         context["started_activities"] = started_activities
 | |
| 
 | |
|         entries_count = {}
 | |
|         show_entries = {}
 | |
|         for activity in started_activities:
 | |
|             if activity.activity_type.manage_entries:
 | |
|                 entries = Entry.objects.filter(activity=activity)
 | |
|                 entries_count[activity] = entries.count()
 | |
| 
 | |
|                 show_entries[activity] = True
 | |
|         context["entries_count"] = entries_count
 | |
|         context["show_entries"] = show_entries
 | |
| 
 | |
|         return context
 | |
| 
 | |
| 
 | |
| class ActivityDetailView(ProtectQuerysetMixin, LoginRequiredMixin, MultiTableMixin, DetailView):
 | |
|     """
 | |
|     Shows details about one activity. Add guest to context
 | |
|     """
 | |
|     model = Activity
 | |
|     context_object_name = "activity"
 | |
|     extra_context = {"title": _("Activity detail")}
 | |
|     export_formats = ["csv"]
 | |
| 
 | |
|     tables = [
 | |
|         GuestTable,
 | |
|         OpenerTable,
 | |
|     ]
 | |
| 
 | |
|     def get_tables(self):
 | |
|         tables = super().get_tables()
 | |
|         tables[0].prefix = "guests"
 | |
|         tables[1].prefix = "opener"
 | |
|         return tables
 | |
| 
 | |
|     def get_tables_data(self):
 | |
|         return [
 | |
|             Guest.objects.filter(activity=self.object)
 | |
|                          .filter(PermissionBackend.filter_queryset(self.request, Guest, "view")),
 | |
|             self.object.opener.filter(activity=self.object)
 | |
|                               .filter(PermissionBackend.filter_queryset(self.request, Opener, "view")),
 | |
|         ]
 | |
| 
 | |
|     def render_to_response(self, context, **response_kwargs):
 | |
|         """
 | |
|         Gère l'export CSV manuel pour MultiTableMixin.
 | |
|         """
 | |
|         if "_export" in self.request.GET:
 | |
|             import tablib
 | |
|             table_name = self.request.GET.get("table")
 | |
|             if table_name:
 | |
|                 tables = self.get_tables()
 | |
|                 data_list = self.get_tables_data()
 | |
| 
 | |
|                 for t, d in zip(tables, data_list):
 | |
|                     if t.prefix == table_name:
 | |
|                         # Préparer le CSV
 | |
|                         dataset = tablib.Dataset()
 | |
|                         columns = list(t.base_columns)  # noms des colonnes
 | |
|                         dataset.headers = columns
 | |
| 
 | |
|                         for row in d:
 | |
|                             values = []
 | |
|                             for col in columns:
 | |
|                                 try:
 | |
|                                     val = getattr(row, col, "")
 | |
|                                     # Gestion spéciale pour la colonne 'entry'
 | |
|                                     if col == "entry":
 | |
|                                         if getattr(row, "has_entry", False):
 | |
|                                             val = timezone.localtime(row.entry.time).strftime("%Y-%m-%d %H:%M:%S")
 | |
|                                         else:
 | |
|                                             val = ""
 | |
|                                     values.append(str(val) if val is not None else "")
 | |
|                                 except Exception:  # RelatedObjectDoesNotExist ou autre
 | |
|                                     values.append("")
 | |
|                             dataset.append(values)
 | |
| 
 | |
|                         csv_bytes = dataset.export("csv")
 | |
|                         if isinstance(csv_bytes, str):
 | |
|                             csv_bytes = csv_bytes.encode("utf-8")
 | |
| 
 | |
|                         response = HttpResponse(csv_bytes, content_type="text/csv")
 | |
|                         response["Content-Disposition"] = f'attachment; filename="{table_name}.csv"'
 | |
|                         return response
 | |
| 
 | |
|         # Sinon rendu normal
 | |
|         return super().render_to_response(context, **response_kwargs)
 | |
| 
 | |
|     def get_context_data(self, **kwargs):
 | |
|         context = super().get_context_data()
 | |
| 
 | |
|         tables = context["tables"]
 | |
|         for name, table in zip(["guests", "opener"], tables):
 | |
|             context[name] = table
 | |
| 
 | |
|         context["activity_started"] = timezone.now() > timezone.localtime(self.object.date_start)
 | |
| 
 | |
|         context["widget"] = {
 | |
|             "name": "opener",
 | |
|             "resetable": True,
 | |
|             "attrs": {
 | |
|                 "class": "autocomplete form-control",
 | |
|                 "id": "opener",
 | |
|                 "api_url": "/api/note/alias/?note__polymorphic_ctype__model=noteuser",
 | |
|                 "name_field": "name",
 | |
|                 "placeholder": ""
 | |
|             }
 | |
|         }
 | |
|         if self.object.activity_type.manage_entries:
 | |
|             entries = Entry.objects.filter(activity=self.object)
 | |
|             context["entries_count"] = {self.object: entries.count()}
 | |
| 
 | |
|             context["show_entries"] = {self.object: timezone.now() > timezone.localtime(self.object.date_start)}
 | |
|         else:
 | |
|             context["entries_count"] = {self.object: 0}
 | |
|             context["show_entries"] = {self.object: False}
 | |
| 
 | |
|         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 ActivityDeleteView(View):
 | |
|     """
 | |
|     Deletes an Activity
 | |
|     """
 | |
|     def delete(self, request, pk):
 | |
|         try:
 | |
|             activity = Activity.objects.get(pk=pk)
 | |
|             activity.delete()
 | |
|             return JsonResponse({"message": "Activity deleted"})
 | |
|         except Activity.DoesNotExist:
 | |
|             return JsonResponse({"error": "Activity not found"}, status=404)
 | |
| 
 | |
|     def dispatch(self, *args, **kwargs):
 | |
|         """
 | |
|         Don't display the delete button if the user has no right to delete.
 | |
|         """
 | |
|         if not self.request.user.is_authenticated:
 | |
|             return self.handle_no_permission()
 | |
| 
 | |
|         activity = Activity.objects.get(pk=self.kwargs["pk"])
 | |
|         if not PermissionBackend.check_perm(self.request, "activity.delete_activity", activity):
 | |
|             raise PermissionDenied(_("You are not allowed to delete this activity."))
 | |
| 
 | |
|         if activity.valid:
 | |
|             raise PermissionDenied(_("This activity is valid."))
 | |
|         return super().dispatch(*args, **kwargs)
 | |
| 
 | |
| 
 | |
| 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="",
 | |
|             school="",
 | |
|             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, Activity, "view"))\
 | |
|             .filter(pk=self.kwargs["pk"]).first()
 | |
|         form.fields["inviter"].initial = self.request.user.note
 | |
|         return form
 | |
| 
 | |
|     @transaction.atomic
 | |
|     def form_valid(self, form):
 | |
|         form.instance.activity = Activity.objects\
 | |
|             .filter(PermissionBackend.filter_queryset(self.request, 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, SingleTableMixin, TemplateView):
 | |
|     """
 | |
|     Manages entry to an activity
 | |
|     """
 | |
|     template_name = "activity/activity_entry.html"
 | |
| 
 | |
|     table_class = EntryTable
 | |
| 
 | |
|     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.
 | |
|         """
 | |
|         if not self.request.user.is_authenticated:
 | |
|             return self.handle_no_permission()
 | |
| 
 | |
|         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, "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, Guest, "view"))\
 | |
|             .order_by('last_name', 'first_name')
 | |
| 
 | |
|         if "search" in self.request.GET and self.request.GET["search"]:
 | |
|             pattern = self.request.GET["search"]
 | |
| 
 | |
|             # Check if this is a valid regex. If not, we won't check regex
 | |
|             valid_regex = is_regex(pattern)
 | |
|             suffix = "__iregex" if valid_regex else "__istartswith"
 | |
|             pattern = "^" + pattern if valid_regex and pattern[0] != "^" else pattern
 | |
|             guest_qs = guest_qs.filter(
 | |
|                 Q(**{f"first_name{suffix}": pattern})
 | |
|                 | Q(**{f"last_name{suffix}": pattern})
 | |
|                 | Q(**{f"inviter__alias__name{suffix}": pattern})
 | |
|                 | Q(**{f"inviter__alias__normalized_name{suffix}": Alias.normalize(pattern)})
 | |
|             )
 | |
|         else:
 | |
|             guest_qs = guest_qs.none()
 | |
|         return guest_qs.distinct()
 | |
| 
 | |
|     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 valid 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()).exclude(note__inactivity_reason='forced')
 | |
| 
 | |
|         # Filter with permission backend
 | |
|         note_qs = note_qs.filter(PermissionBackend.filter_queryset(self.request, Alias, "view"))
 | |
| 
 | |
|         if "search" in self.request.GET and self.request.GET["search"]:
 | |
|             pattern = self.request.GET["search"]
 | |
| 
 | |
|             # Check if this is a valid regex. If not, we won't check regex
 | |
|             valid_regex = is_regex(pattern)
 | |
|             suffix = "__iregex" if valid_regex else "__icontains"
 | |
|             note_qs = note_qs.filter(
 | |
|                 Q(**{f"note__noteuser__user__first_name{suffix}": pattern})
 | |
|                 | Q(**{f"note__noteuser__user__last_name{suffix}": pattern})
 | |
|                 | Q(**{f"name{suffix}": pattern})
 | |
|                 | Q(**{f"normalized_name{suffix}": Alias.normalize(pattern)})
 | |
|             )
 | |
|         else:
 | |
|             note_qs = note_qs.none()
 | |
| 
 | |
|         # 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('note__pk')[:20]\
 | |
|             if settings.DATABASES[note_qs.db]["ENGINE"] == 'django.db.backends.postgresql' else note_qs.distinct()[:20]
 | |
|         return note_qs
 | |
| 
 | |
|     def get_table_data(self):
 | |
|         activity = Activity.objects.filter(PermissionBackend.filter_queryset(self.request, Activity, "view"))\
 | |
|             .distinct().get(pk=self.kwargs["pk"])
 | |
| 
 | |
|         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)
 | |
| 
 | |
|         return matched
 | |
| 
 | |
|     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, Activity, "view"))\
 | |
|             .distinct().get(pk=self.kwargs["pk"])
 | |
|         context["activity"] = activity
 | |
| 
 | |
|         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, activity_type__manage_entries=True).filter(
 | |
|             PermissionBackend.filter_queryset(self.request, Activity, "view")).distinct().all()
 | |
|         context["activities_open"] = [a for a in activities_open
 | |
|                                       if PermissionBackend.check_perm(self.request,
 | |
|                                                                       "activity.add_entry",
 | |
|                                                                       Entry(activity=a, note=self.request.user.note,))]
 | |
| 
 | |
|         return context
 | |
| 
 | |
| 
 | |
| # Cache for 1 hour
 | |
| @method_decorator(cache_page(60 * 60), name='dispatch')
 | |
| 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/Paris
 | |
| X-LIC-LOCATION:Europe/Paris
 | |
| 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:{md5((activity.name + "$" + str(activity.id) + str(activity.date_start)).encode("UTF-8")).hexdigest()}
 | |
| SUMMARY;CHARSET=UTF-8:{self.multilines(activity.name, 75, 22)}
 | |
| DTSTART:{"{:%Y%m%dT%H%M%S}Z".format(activity.date_start)}
 | |
| DTEND:{"{:%Y%m%dT%H%M%S}Z".format(activity.date_end)}
 | |
| LOCATION:{self.multilines(activity.location, 75, 9) if activity.location else "Kfet"}
 | |
| DESCRIPTION;CHARSET=UTF-8:""" + self.multilines(activity.description.replace("\n", "\\n"), 75, 26) + f"""
 | |
|  -- {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")
 |