diff --git a/integreat_cms/cms/constants/roles.py b/integreat_cms/cms/constants/roles.py index 102d95a90c..ad175ed7af 100644 --- a/integreat_cms/cms/constants/roles.py +++ b/integreat_cms/cms/constants/roles.py @@ -165,6 +165,7 @@ #: The permissions of the service team SERVICE_TEAM_PERMISSIONS: Final[list[str]] = APP_TEAM_PERMISSIONS + [ + "change_externalcalendar", "change_language", "change_languagetreenode", "change_offertemplate", @@ -174,6 +175,7 @@ "delete_chatmessage", "delete_directory", "delete_event", + "delete_externalcalendar", "delete_feedback", "delete_imprintpage", "delete_languagetreenode", @@ -190,6 +192,7 @@ "change_contact", "delete_contact", "view_contact", + "view_externalcalendar", ] #: The permissions of the cms team diff --git a/integreat_cms/cms/forms/__init__.py b/integreat_cms/cms/forms/__init__.py index 1a2accdc96..8334077334 100644 --- a/integreat_cms/cms/forms/__init__.py +++ b/integreat_cms/cms/forms/__init__.py @@ -9,6 +9,7 @@ from .events.event_filter_form import EventFilterForm from .events.event_form import EventForm from .events.event_translation_form import EventTranslationForm +from .events.external_calendar_form import ExternalCalendarForm from .events.recurrence_rule_form import RecurrenceRuleForm from .feedback.admin_feedback_filter_form import AdminFeedbackFilterForm from .feedback.region_feedback_filter_form import RegionFeedbackFilterForm diff --git a/integreat_cms/cms/forms/events/event_form.py b/integreat_cms/cms/forms/events/event_form.py index 1364c9af16..3d79fcd43b 100644 --- a/integreat_cms/cms/forms/events/event_form.py +++ b/integreat_cms/cms/forms/events/event_form.py @@ -76,6 +76,8 @@ class Meta: "end", "icon", "location", + "external_calendar", + "external_event_id", ] #: The widgets which are used in this form widgets = { @@ -111,6 +113,12 @@ def __init__(self, **kwargs: Any) -> None: self.fields["is_all_day"].initial = self.instance.is_all_day self.fields["is_recurring"].initial = self.instance.is_recurring self.fields["has_not_location"].initial = not self.instance.has_location + self.fields["external_calendar"].initial = ( + self.instance.external_calendar.pk + if self.instance.external_calendar + else None + ) + self.fields["external_event_id"].initial = self.instance.external_event_id def clean(self) -> dict[str, Any]: """ diff --git a/integreat_cms/cms/forms/events/external_calendar_form.py b/integreat_cms/cms/forms/events/external_calendar_form.py new file mode 100644 index 0000000000..e85c597596 --- /dev/null +++ b/integreat_cms/cms/forms/events/external_calendar_form.py @@ -0,0 +1,21 @@ +from __future__ import annotations + +from ...models import ExternalCalendar +from ..custom_model_form import CustomModelForm + + +class ExternalCalendarForm(CustomModelForm): + """ + Form for adding a new external calendar + """ + + class Meta: + """ + This class contains additional meta configuration of the form class, see the :class:`django.forms.ModelForm` + for more information. + """ + + #: The model of this :class:`django.forms.ModelForm` + model = ExternalCalendar + #: The fields of the model which should be handled by this form + fields = ["name", "url", "import_filter_category"] diff --git a/integreat_cms/cms/migrations/0101_external_calendar.py b/integreat_cms/cms/migrations/0101_external_calendar.py new file mode 100644 index 0000000000..1196a31289 --- /dev/null +++ b/integreat_cms/cms/migrations/0101_external_calendar.py @@ -0,0 +1,122 @@ +# Generated by Django 4.2.13 on 2024-06-09 21:21 + +import django.db.models.deletion +from django.apps.registry import Apps +from django.core.management.sql import emit_post_migrate_signal +from django.db import migrations, models +from django.db.backends.base.schema import BaseDatabaseSchemaEditor + +from integreat_cms.cms.constants import roles + + +# pylint: disable=unused-argument +def update_roles(apps: Apps, schema_editor: BaseDatabaseSchemaEditor) -> None: + """ + Add permissions for managing external calendars + + :param apps: The configuration of installed applications + :param schema_editor: The database abstraction layer that creates actual SQL code + """ + Group = apps.get_model("auth", "Group") + Permission = apps.get_model("auth", "Permission") + + # Emit post-migrate signal to make sure the Permission objects are created before they can be assigned + emit_post_migrate_signal(2, False, "default") + + # Clear and update permissions according to new constants + for role_name in dict(roles.CHOICES): + group, _ = Group.objects.get_or_create(name=role_name) + # Clear permissions + group.permissions.clear() + # Set permissions + group.permissions.add( + *Permission.objects.filter(codename__in=roles.PERMISSIONS[role_name]) + ) + + +class Migration(migrations.Migration): + """ + Adds the external calendar model and related functionality to other models + """ + + dependencies = [ + ("cms", "0100_organization_archived"), + ] + + operations = [ + migrations.AddField( + model_name="event", + name="external_event_id", + field=models.CharField( + blank=True, + max_length=255, + verbose_name="The ID of this event in the external calendar", + ), + ), + migrations.CreateModel( + name="ExternalCalendar", + fields=[ + ( + "id", + models.BigAutoField( + auto_created=True, + primary_key=True, + serialize=False, + verbose_name="ID", + ), + ), + ( + "name", + models.CharField( + default="", max_length=255, verbose_name="calendar name" + ), + ), + ( + "url", + models.URLField(max_length=250, verbose_name="URL"), + ), + ( + "import_filter_category", + models.CharField( + blank=True, + default="integreat", + max_length=255, + verbose_name="The category that events need to have to get imported (Leave blank to import all events)", + ), + ), + ( + "region", + models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, + to="cms.region", + verbose_name="region", + related_name="external_calendars", + ), + ), + ( + "errors", + models.CharField( + blank=True, default="", verbose_name="import errors" + ), + ), + ], + options={ + "verbose_name": "external calendar", + "verbose_name_plural": "external calendars", + "default_permissions": ("change", "delete", "view"), + }, + ), + migrations.AddField( + model_name="event", + name="external_calendar", + field=models.ForeignKey( + blank=True, + null=True, + on_delete=django.db.models.deletion.CASCADE, + related_name="events", + to="cms.externalcalendar", + verbose_name="external calendar", + ), + ), + migrations.RunPython(update_roles, migrations.RunPython.noop), + ] diff --git a/integreat_cms/cms/models/__init__.py b/integreat_cms/cms/models/__init__.py index bf9ea7c1cc..8849ac8f20 100644 --- a/integreat_cms/cms/models/__init__.py +++ b/integreat_cms/cms/models/__init__.py @@ -14,6 +14,7 @@ from .events.event import Event from .events.event_translation import EventTranslation from .events.recurrence_rule import RecurrenceRule +from .external_calendars.external_calendar import ExternalCalendar from .feedback.event_feedback import EventFeedback from .feedback.event_list_feedback import EventListFeedback from .feedback.feedback import Feedback diff --git a/integreat_cms/cms/models/events/event.py b/integreat_cms/cms/models/events/event.py index a694941871..90ede454b1 100644 --- a/integreat_cms/cms/models/events/event.py +++ b/integreat_cms/cms/models/events/event.py @@ -13,6 +13,7 @@ from ...constants import status from ...utils.slug_utils import generate_unique_slug from ..abstract_content_model import AbstractContentModel, ContentQuerySet +from ..external_calendars.external_calendar import ExternalCalendar from ..media.media_file import MediaFile from ..pois.poi import POI from .event_translation import EventTranslation @@ -112,6 +113,21 @@ class Event(AbstractContentModel): ) archived = models.BooleanField(default=False, verbose_name=_("archived")) + external_calendar = models.ForeignKey( + ExternalCalendar, + null=True, + blank=True, + on_delete=models.CASCADE, + related_name="events", + verbose_name=_("external calendar"), + ) + + external_event_id = models.CharField( + max_length=255, + blank=True, + verbose_name=_("The ID of this event in the external calendar"), + ) + #: The default manager objects = EventQuerySet.as_manager() diff --git a/integreat_cms/cms/models/external_calendars/__init__.py b/integreat_cms/cms/models/external_calendars/__init__.py new file mode 100644 index 0000000000..7357c5642b --- /dev/null +++ b/integreat_cms/cms/models/external_calendars/__init__.py @@ -0,0 +1,4 @@ +""" +This package contains all external calendar related data models: +:class:`~integreat_cms.cms.models.external_calendars.external_calendar.ExternalCalendar` +""" diff --git a/integreat_cms/cms/models/external_calendars/external_calendar.py b/integreat_cms/cms/models/external_calendars/external_calendar.py new file mode 100644 index 0000000000..8f7ee4d311 --- /dev/null +++ b/integreat_cms/cms/models/external_calendars/external_calendar.py @@ -0,0 +1,71 @@ +import icalendar +import requests +from django.conf import settings +from django.db import models +from django.utils.translation import gettext_lazy as _ + +from integreat_cms.cms.models.abstract_base_model import AbstractBaseModel +from integreat_cms.cms.models.regions.region import Region + + +class ExternalCalendar(AbstractBaseModel): + """ + Model for representing external calendars, from which events can be imported. + """ + + name = models.CharField(max_length=255, verbose_name=_("calendar name"), default="") + region = models.ForeignKey( + Region, + on_delete=models.CASCADE, + verbose_name=_("region"), + related_name="external_calendars", + ) + url = models.URLField(max_length=250, verbose_name=_("URL")) + + import_filter_category = models.CharField( + max_length=255, + blank=True, + default=settings.EXTERNAL_CALENDAR_CATEGORY, + verbose_name=_( + "The category that events need to have to get imported (Leave blank to import all events)" + ), + ) + errors = models.CharField(verbose_name=_("import errors"), default="", blank=True) + + def __str__(self) -> str: + """ + String representation of this model. + :return: String that represents the region and the url. + """ + return self.name + + def load_ical(self) -> icalendar.Calendar: + """ + Loads the url and creates an icalendar + :return: The Icalendar returned by the url + :raises OSError: If the url cannot be loaded + :raises ValueError: If the data are not valid icalendar format + """ + response = requests.get(self.url, timeout=60) + if response.status_code != 200: + raise IOError( + f"Failed to load external calendar. Status code: {response.status_code}" + ) + return icalendar.Calendar.from_ical(response.content) + + def get_repr(self) -> str: + """ + This overwrites the default Django ``__repr__()`` method + + :return: The canonical string representation of the external calendar + """ + class_name = type(self).__name__ + return f"<{class_name} (url: {self.url})>" + + class Meta: + #: The verbose name of the model + verbose_name = _("external calendar") + #: The plural verbose name of the model + verbose_name_plural = _("external calendars") + #: The default permissions for this model + default_permissions = ("change", "delete", "view") diff --git a/integreat_cms/cms/templates/_base.html b/integreat_cms/cms/templates/_base.html index 135b4826c2..9ab85522f7 100644 --- a/integreat_cms/cms/templates/_base.html +++ b/integreat_cms/cms/templates/_base.html @@ -241,6 +241,13 @@ {% translate "Organizations" %} {% endif %} + {% if perms.cms.view_externalcalendar %} + + + {% translate "External Calendars" %} + + {% endif %} {% if perms.cms.view_user %}
diff --git a/integreat_cms/cms/templates/events/event_form.html b/integreat_cms/cms/templates/events/event_form.html index 5d80f5e80a..65dfb46720 100644 --- a/integreat_cms/cms/templates/events/event_form.html +++ b/integreat_cms/cms/templates/events/event_form.html @@ -36,34 +36,32 @@

{% translate "Create new event" %} {% endif %}

- {% if perms.cms.change_event %} - {% if not event_form.instance.id or not event_form.instance.archived %} -
- {% include "generic_auto_save_note.html" with form_instance=event_form.instance %} - {% if perms.cms.publish_event %} - - - {% else %} - - {% endif %} -
- {% endif %} + {% if not disabled %} +
+ {% include "generic_auto_save_note.html" with form_instance=event_form.instance %} + {% if perms.cms.publish_event %} + + + {% else %} + + {% endif %} +
{% endif %}
@@ -169,7 +167,7 @@

data-unsaved-warning> {{ media_config_data|json_script:"media_config_data" }} - {% if not perms.cms.change_event or event_form.instance.id and event_form.instance.archived %} + {% if disabled %} {% include "../_tinymce_config.html" with readonly=1 %} {% else %} {% include "../_tinymce_config.html" %} diff --git a/integreat_cms/cms/templates/events/event_list.html b/integreat_cms/cms/templates/events/event_list.html index f1e1a08f69..af77ce953d 100644 --- a/integreat_cms/cms/templates/events/event_list.html +++ b/integreat_cms/cms/templates/events/event_list.html @@ -97,6 +97,11 @@

{% translate "Recurrence" %} + {% if perms.cms.view_externalcalendar %} + + {% translate "External calendar" %} + + {% endif %} {% translate "Options" %} diff --git a/integreat_cms/cms/templates/events/event_list_row.html b/integreat_cms/cms/templates/events/event_list_row.html index 9e50b1df1f..04fcde4490 100644 --- a/integreat_cms/cms/templates/events/event_list_row.html +++ b/integreat_cms/cms/templates/events/event_list_row.html @@ -114,6 +114,13 @@ {% translate "One-time" %} {% endif %} + {% if perms.cms.view_externalcalendar %} + + {% if event.external_calendar %} + {{ event.external_calendar.name }} + {% endif %} + + {% endif %} {% if event_translation.status == PUBLIC %} + {% csrf_token %} +
+
+

+ {% if external_calendar_form.instance.id %} + {% blocktranslate trimmed with calendar=external_calendar_form.instance %} + Edit external calendar "{{ calendar }}" + {% endblocktranslate %} + {% else %} + {% translate "Add new external calendar" %} + {% endif %} +

+
+
+
+ {% if external_calendar_form.instance.id and perms.cms.delete_externalcalendar %} +
+ +
+ {% endif %} + +
+
+
+
+
+
+
+

+ {% translate "New external calendar" %} +

+
+
+ + {% render_field external_calendar_form.name %} + + {% render_field external_calendar_form.url %} + + {% render_field external_calendar_form.import_filter_category %} +
+
+
+
+ + {% include "../generic_confirmation_dialog.html" %} +{% endblock content %} diff --git a/integreat_cms/cms/templates/events/external_calendar_list.html b/integreat_cms/cms/templates/events/external_calendar_list.html new file mode 100644 index 0000000000..9b9b3b9a67 --- /dev/null +++ b/integreat_cms/cms/templates/events/external_calendar_list.html @@ -0,0 +1,49 @@ +{% extends "_base.html" %} +{% load i18n %} +{% load static %} +{% load content_filters %} +{% block content %} +
+
+ + + + + + + + + + + {% for calendar in external_calendars %} + {% include "events/external_calendar_list_row.html" %} + {% empty %} + + + + {% endfor %} + +
+ {% translate "Name" %} + + {% translate "URL" %} + + {% translate "Status" %} + + {% translate "Imported events" %} +
+ {% translate "No external calendars available yet." %} +
+
+ {% include "../generic_confirmation_dialog.html" %} +{% endblock content %} diff --git a/integreat_cms/cms/templates/events/external_calendar_list_row.html b/integreat_cms/cms/templates/events/external_calendar_list_row.html new file mode 100644 index 0000000000..f291d65fd0 --- /dev/null +++ b/integreat_cms/cms/templates/events/external_calendar_list_row.html @@ -0,0 +1,45 @@ +{% load i18n %} + + + + {{ calendar.name }} + + + + + {{ calendar.url }} + + + + + {% if calendar.errors %} +
+ {{ calendar.errors | safe }} +
+ {% else %} + + {% endif %} +
+ + + + {{ calendar.events.count }} + + + + {% if perms.cms.delete_externalcalendar %} + + {% endif %} + + diff --git a/integreat_cms/cms/urls/protected.py b/integreat_cms/cms/urls/protected.py index 5d170b24cf..a3f9cad2c5 100644 --- a/integreat_cms/cms/urls/protected.py +++ b/integreat_cms/cms/urls/protected.py @@ -35,6 +35,7 @@ dashboard, delete_views, events, + external_calendars, feedback, form_views, imprint, @@ -60,7 +61,6 @@ if TYPE_CHECKING: from django.urls.resolvers import URLPattern - #: The media library ajax url patterns are reused twice (for the admin media library and the region media library) media_ajax_urlpatterns: list[URLPattern] = [ path( @@ -792,6 +792,40 @@ ], ), ), + path( + "external-calendars/", + include( + [ + path( + "", + external_calendars.ExternalCalendarList.as_view(), + name="external_calendar_list", + ), + path( + "new/", + external_calendars.ExternalCalendarFormView.as_view(), + name="new_external_calendar", + ), + path( + "/", + include( + [ + path( + "edit/", + external_calendars.ExternalCalendarFormView.as_view(), + name="edit_external_calendar", + ), + path( + "delete/", + external_calendars.delete_external_calendar, + name="delete_external_calendar", + ), + ] + ), + ), + ] + ), + ), path( "pages/", include( diff --git a/integreat_cms/cms/utils/external_calendar_utils.py b/integreat_cms/cms/utils/external_calendar_utils.py new file mode 100644 index 0000000000..d74af50e90 --- /dev/null +++ b/integreat_cms/cms/utils/external_calendar_utils.py @@ -0,0 +1,264 @@ +""" +Utility functions for working with external calendars +""" + +from __future__ import annotations + +import dataclasses +import datetime +import logging + +import icalendar.cal +from django.utils.translation import gettext as _ + +from integreat_cms.cms.constants import status +from integreat_cms.cms.forms import EventForm, EventTranslationForm +from integreat_cms.cms.models import EventTranslation, ExternalCalendar +from integreat_cms.cms.utils.content_utils import clean_content + + +# pylint: disable=too-many-instance-attributes +@dataclasses.dataclass(frozen=True, kw_only=True) +class IcalEventData: + """ + Holds data extracted from ical events + """ + + event_id: str + title: str + content: str + start_date: datetime.date + start_time: datetime.time | None + end_date: datetime.date + end_time: datetime.time | None + is_all_day: bool + categories: list[str] + external_calendar_id: int + + @classmethod + def from_ical_event( + cls, + event: icalendar.cal.Component, + language_slug: str, + external_calendar_id: int, + logger: logging.Logger, + ) -> IcalEventData: + """ + Reads an ical event and constructs an instance of this class from it + :param event: The ical event + :param language_slug: The slug of the language of this event + :param external_calendar_id: The id of the external calendar of this event + :param logger: The logger to use + :return: An instance of IcalEventData + """ + event_id = event.decoded("UID").decode("utf-8") + title = event.decoded("SUMMARY").decode("utf-8") + content = clean_content( + content=( + event.decoded("DESCRIPTION").decode("utf-8") + if "DESCRIPTION" in event + else "" + ).replace("\n", "
"), + language_slug=language_slug, + ) + start = event.decoded("DTSTART") + end = event.decoded("DTEND") + categories = event.get("categories").cats if event.get("categories") else [] + logger.debug( + "Event(event_id=%s, title=%s, start=%s, end=%s, content=%s...)", + event_id, + title, + start, + end, + content[:32], + ) + + return cls( + event_id=event_id, + title=title, + content=content, + start_date=start.date() if isinstance(start, datetime.datetime) else start, + start_time=start.time() if isinstance(start, datetime.datetime) else None, + end_date=end.date() if isinstance(end, datetime.datetime) else end, + end_time=end.time() if isinstance(end, datetime.datetime) else None, + is_all_day=not isinstance(start, datetime.datetime), + external_calendar_id=external_calendar_id, + categories=categories, + ) + + def to_event_form_data(self) -> dict: + """ + Returns a dictionary of relevant data for the event form + :return: Dict of relevant data + """ + return { + "start_date": self.start_date, + "start_time": self.start_time, + "end_date": self.end_date, + "end_time": self.end_time, + "is_all_day": self.is_all_day, + "has_not_location": True, + "external_calendar": self.external_calendar_id, + "external_event_id": self.event_id, + } + + def to_event_translation_form_data(self) -> dict: + """ + Returns a dictionary of relevant data for the event translation form + :return: Dict of relevant data + """ + return {"title": self.title, "status": status.PUBLIC, "content": self.content} + + +def import_events(calendar: ExternalCalendar, logger: logging.Logger) -> None: + """ + Imports events from this calendar and sets or clears the errors field of the calendar + + :param calendar: The external calendar + :param logger: The logger to use + """ + + errors: list[str] = [] + + _import_events(calendar, errors, logger) + + if errors: + calendar.errors = "\n".join(errors) + else: + calendar.errors = "" + + calendar.save() + + +def _import_events( + calendar: ExternalCalendar, errors: list[str], logger: logging.Logger +) -> None: + """ + Imports events from this calendar and sets or clears the errors field of the calendar + + :param calendar: The external calendar + :param logger: The logger to use + """ + try: + ical = calendar.load_ical() + except IOError as e: + logger.error("Could not import events from %s: %s", calendar, e) + errors.append(_("Could not access the url of this external calendar")) + return + except ValueError as e: + logger.error("Malformed calendar %s: %s", calendar, e) + errors.append( + _("The data provided by the url of this external calendar is invalid") + ) + return + + calendar_events = set() + for event in ical.walk("VEVENT"): + try: + if (event_uid := import_event(calendar, event, errors, logger)) is not None: + calendar_events.add(event_uid) + except KeyError as e: + logger.error( + "Could not import event because it does not have a required field: %s, missing field: %r", + event, + e, + ) + errors.append( + _( + "Could not import event because it is missing a required field: {}" + ).format(e) + ) + continue + + events_to_delete = calendar.events.exclude(external_event_id__in=calendar_events) + logger.info( + "Deleting %s unused events: %r", events_to_delete.count(), events_to_delete + ) + events_to_delete.delete() + + +def import_event( + calendar: ExternalCalendar, + event: icalendar.cal.Component, + errors: list[str], + logger: logging.Logger, +) -> str | None: + """ + Imports an event from the external calendar + + :param calendar: The external calendar + :param event: The event that should be imported + :param errors: A list to which errors will be logged + :param logger: The logger to use + + :return: The uid of the event + """ + language = calendar.region.default_language + + event_data = IcalEventData.from_ical_event( + event, language.slug, calendar.pk, logger + ) + + # Skip this event if it does not have the required tag + if calendar.import_filter_category and not any( + category == calendar.import_filter_category + for category in event_data.categories + ): + logger.info( + "Skipping event %s with tags: [%s]", + event_data.title, + ", ".join(event_data.categories), + ) + return None + + previously_imported_event_translation = EventTranslation.objects.filter( + event__external_calendar=calendar, + event__external_event_id=event_data.event_id, + language=language, + ).first() + previously_imported_event = ( + previously_imported_event_translation.event + if previously_imported_event_translation + else None + ) + + event_form = EventForm( + data=event_data.to_event_form_data(), + instance=previously_imported_event, + additional_instance_attributes={"region": calendar.region}, + ) + if not event_form.is_valid(): + logger.error("Could not import event: %r", event_form.errors) + errors.append( + _("Could not import '{}': {}").format(event_data.title, event_form.errors) + ) + return event_data.event_id + + event = event_form.save() + + event_translation_form = EventTranslationForm( + data=event_data.to_event_translation_form_data(), + instance=previously_imported_event_translation, + additional_instance_attributes={ + "language": language, + "event": event, + }, + ) + if not event_translation_form.is_valid(): + logger.error("Could not import event: %r", event_translation_form.errors) + errors.append( + _("Could not import '{}': {}").format( + event_data.title, event_translation_form.errors + ) + ) + return event_data.event_id + + # We could look at the sequence number of the ical event too, to see if it has changed. + # If it hasn't, we don't need to create forms and can quickly skip it + if event_form.has_changed() or event_translation_form.has_changed(): + event_translation = event_translation_form.save() + logger.success("Imported event %r, %r", event, event_translation) # type: ignore[attr-defined] + else: + logger.info("Event %r has not changed", event_translation_form.instance) + + return event_data.event_id diff --git a/integreat_cms/cms/views/events/__init__.py b/integreat_cms/cms/views/events/__init__.py index 92cae0131b..1c7977c721 100644 --- a/integreat_cms/cms/views/events/__init__.py +++ b/integreat_cms/cms/views/events/__init__.py @@ -4,7 +4,13 @@ from __future__ import annotations -from .event_actions import archive, copy, delete, restore, search_poi_ajax +from .event_actions import ( + archive, + copy, + delete, + restore, + search_poi_ajax, +) from .event_form_view import EventFormView from .event_list_view import EventListView from .event_version_view import EventVersionView diff --git a/integreat_cms/cms/views/events/event_form_view.py b/integreat_cms/cms/views/events/event_form_view.py index bac0ed47a6..58a5e748d3 100644 --- a/integreat_cms/cms/views/events/event_form_view.py +++ b/integreat_cms/cms/views/events/event_form_view.py @@ -73,6 +73,14 @@ def get(self, request: HttpRequest, *args: Any, **kwargs: Any) -> HttpResponse: messages.warning( request, _("You cannot edit this event because it is archived.") ) + elif event_instance and event_instance.external_calendar: + disabled = True + messages.warning( + request, + _( + "You cannot edit this event because it was imported from an external calendar." + ), + ) elif not request.user.has_perm("cms.change_event"): disabled = True messages.warning( @@ -119,6 +127,7 @@ def get(self, request: HttpRequest, *args: Any, **kwargs: Any) -> HttpResponse: "translation_states": ( event_instance.translation_states if event_instance else [] ), + "disabled": disabled, }, ) diff --git a/integreat_cms/cms/views/external_calendars/__init__.py b/integreat_cms/cms/views/external_calendars/__init__.py new file mode 100644 index 0000000000..029241a430 --- /dev/null +++ b/integreat_cms/cms/views/external_calendars/__init__.py @@ -0,0 +1,7 @@ +""" +This package contains all views related to external calendars +""" + +from .external_calendar_actions import delete_external_calendar +from .external_calendar_form_view import ExternalCalendarFormView +from .external_calendar_list_view import ExternalCalendarList diff --git a/integreat_cms/cms/views/external_calendars/external_calendar_actions.py b/integreat_cms/cms/views/external_calendars/external_calendar_actions.py new file mode 100644 index 0000000000..f20b7248d6 --- /dev/null +++ b/integreat_cms/cms/views/external_calendars/external_calendar_actions.py @@ -0,0 +1,40 @@ +import logging + +from django.contrib import messages +from django.http import HttpRequest, HttpResponseRedirect +from django.shortcuts import get_object_or_404, redirect +from django.utils.translation import gettext_lazy as _ +from django.views.decorators.http import require_POST + +from ...decorators import permission_required + +logger = logging.getLogger(__name__) + + +@require_POST +@permission_required("cms.delete_externalcalendar") +def delete_external_calendar( + request: HttpRequest, calendar_id: int, region_slug: str +) -> HttpResponseRedirect: + """ + Delete external calendar + + :param request: The current request + :param calendar_id: The id of the calendar that should be deleted + :param region_slug: The slug of the current region + :return: A redirection to the :class:`~integreat_cms.cms.views.events.external_calendars.ExternalCalendarList` + """ + region = request.region + calendar = get_object_or_404(region.external_calendars, id=calendar_id) + + logger.info("%r deleted by %r", calendar, request.user) + + calendar.delete() + messages.success(request, _("External calendar was successfully deleted")) + + return redirect( + "external_calendar_list", + **{ + "region_slug": region_slug, + }, + ) diff --git a/integreat_cms/cms/views/external_calendars/external_calendar_form_view.py b/integreat_cms/cms/views/external_calendars/external_calendar_form_view.py new file mode 100644 index 0000000000..3807fd8ea4 --- /dev/null +++ b/integreat_cms/cms/views/external_calendars/external_calendar_form_view.py @@ -0,0 +1,141 @@ +from __future__ import annotations + +import logging +from typing import TYPE_CHECKING + +from django.contrib import messages +from django.shortcuts import redirect, render +from django.utils.decorators import method_decorator +from django.utils.translation import gettext_lazy as _ +from django.views.generic import TemplateView + +from integreat_cms.cms.decorators import permission_required +from integreat_cms.cms.forms import ExternalCalendarForm +from integreat_cms.cms.utils.external_calendar_utils import import_events + +if TYPE_CHECKING: + from typing import Any + + from django.http import HttpRequest, HttpResponse + + +logger = logging.getLogger(__name__) + + +@method_decorator(permission_required("cms.view_externalcalendar"), name="get") +@method_decorator(permission_required("cms.change_externalcalendar"), name="post") +class ExternalCalendarFormView(TemplateView): + """ + Form view for new external calendars in a region. + """ + + template_name = "events/external_calendar_form.html" + + def get(self, request: HttpRequest, *args: Any, **kwargs: Any) -> HttpResponse: + r""" + Render :class:`~integreat_cms.cms.forms.events.external_calendar_form.ExternalCalendarForm` + + :param request: The current request + :param \*args: The supplied arguments + :param \**kwargs: The supplied keyword arguments + :return: The rendered template response + """ + region = request.region + external_calendar_instance = region.external_calendars.filter( + id=kwargs.get("calendar_id") + ).first() + external_calendar_form = ExternalCalendarForm( + instance=external_calendar_instance + ) + return render( + request, + self.template_name, + { + "external_calendar_form": external_calendar_form, + "current_menu_item": "external_calendar_list", + "delete_dialog_title": _( + "Please confirm that you really want to delete this external calendar" + ), + }, + ) + + def post(self, request: HttpRequest, *args: Any, **kwargs: Any) -> HttpResponse: + r""" + Submit :class:`~integreat_cms.cms.forms.events.external_calendar_form.ExternalCalendarForm` and save :class:`~django.contrib.auth.models.events.ExternalCalendar` object + + :param request: The current request + :param \*args: The supplied arguments + :param \**kwargs: The supplied keyword arguments + :return: The rendered template response + """ + region = request.region + external_calendar_instance = region.external_calendars.filter( + id=kwargs.get("calendar_id") + ).first() + external_calendar_form = ExternalCalendarForm( + data=request.POST, instance=external_calendar_instance + ) + if not external_calendar_form.is_valid(): + external_calendar_form.add_error_messages(request) + elif not external_calendar_form.has_changed(): + messages.info(request, _("No changes made")) + + import_events(external_calendar_form.instance, logger) + if external_calendar_form.instance.errors: + messages.error( + request, + _( + "An error occurred while importing events from this external calendar" + ), + ) + else: + messages.success( + request, + _("Successfully imported events from this external calendar"), + ) + else: + external_calendar_form.instance.region = self.request.region + external_calendar_form.save() + if not external_calendar_instance: + messages.success( + request, + _('External calendar "{}" was successfully created').format( + external_calendar_form.instance + ), + ) + else: + messages.success( + request, + _('External calendar "{}" was successfully saved').format( + external_calendar_form.instance + ), + ) + + import_events(external_calendar_form.instance, logger) + if external_calendar_form.instance.errors: + messages.error( + request, + _( + "An error occurred while importing events from this external calendar" + ), + ) + else: + messages.success( + request, + _("Successfully imported events from this external calendar"), + ) + + return redirect( + "edit_external_calendar", + region_slug=self.request.region.slug, + calendar_id=external_calendar_form.instance.id, + ) + + return render( + request, + self.template_name, + { + "external_calendar_form": external_calendar_form, + "current_menu_item": "external_calendar_list", + }, + ) diff --git a/integreat_cms/cms/views/external_calendars/external_calendar_list_view.py b/integreat_cms/cms/views/external_calendars/external_calendar_list_view.py new file mode 100644 index 0000000000..f4202c3fa0 --- /dev/null +++ b/integreat_cms/cms/views/external_calendars/external_calendar_list_view.py @@ -0,0 +1,39 @@ +from __future__ import annotations + +from typing import TYPE_CHECKING + +from django.utils.decorators import method_decorator +from django.utils.translation import gettext_lazy as _ +from django.views.generic import TemplateView + +from integreat_cms.cms.decorators import permission_required + +if TYPE_CHECKING: + from typing import Any + + +@method_decorator(permission_required("cms.view_externalcalendar"), name="get") +class ExternalCalendarList(TemplateView): + """ + View for external calendars in regions. + """ + + template_name = "events/external_calendar_list.html" + + def get_context_data(self, **kwargs: Any) -> dict[str, Any]: + r""" + Get external calendar list context data + + :return: The context dictionary + """ + context = super().get_context_data(**kwargs) + context.update( + { + "current_menu_item": "external_calendar_list", + "external_calendars": self.request.region.external_calendars.all(), + "delete_dialog_title": _( + "Please confirm that you really want to delete this calendar" + ), + } + ) + return context diff --git a/integreat_cms/core/management/commands/import_events.py b/integreat_cms/core/management/commands/import_events.py new file mode 100644 index 0000000000..305edb7fff --- /dev/null +++ b/integreat_cms/core/management/commands/import_events.py @@ -0,0 +1,39 @@ +from __future__ import annotations + +import logging +from typing import TYPE_CHECKING + +from cacheops import invalidate_model + +from ....cms.models import Event, EventTranslation, ExternalCalendar +from ....cms.utils.external_calendar_utils import import_events +from ..log_command import LogCommand + +if TYPE_CHECKING: + from typing import Any + +logger = logging.getLogger(__name__) + + +class Command(LogCommand): + """ + Management command to import events from external calendars + """ + + help: str = "Import events from external calendars" + + def handle(self, *args: Any, **options: Any) -> None: + r""" + Try to run the command + + :param \*args: The supplied arguments + :param \**options: The supplied keyword options + """ + self.set_logging_stream() + calendars = ExternalCalendar.objects.all() + for calendar in calendars: + import_events(calendar, logger) + + invalidate_model(Event) + invalidate_model(EventTranslation) + invalidate_model(ExternalCalendar) diff --git a/integreat_cms/core/settings.py b/integreat_cms/core/settings.py index cf48e36ec2..9403a5e0a9 100644 --- a/integreat_cms/core/settings.py +++ b/integreat_cms/core/settings.py @@ -195,6 +195,9 @@ strtobool(os.environ.get("INTEGREAT_CMS_BACKGROUND_TASKS_ENABLED", "True")) ) +#: The tag that events from external calendars need to get imported +EXTERNAL_CALENDAR_CATEGORY: Final[str] = BRANDING + ############################################################## # Firebase Push Notifications (Firebase Cloud Messaging FCM) # ############################################################## diff --git a/integreat_cms/core/signals/feedback_signals.py b/integreat_cms/core/signals/feedback_signals.py index f73bb03fd7..be0ff02401 100644 --- a/integreat_cms/core/signals/feedback_signals.py +++ b/integreat_cms/core/signals/feedback_signals.py @@ -63,7 +63,12 @@ def feedback_create_handler(sender: ModelBase, **kwargs: Any) -> None: :param sender: The class of the feedback that was deleted :param \**kwargs: The supplied keyword arguments """ - if (instance := kwargs.get("instance")) and ( - feedback_ptr := getattr(instance, "feedback_ptr", None) - ): - invalidate_obj(feedback_ptr) + try: + if (instance := kwargs.get("instance")) and ( + feedback_ptr := getattr(instance, "feedback_ptr", None) + ): + invalidate_obj(feedback_ptr) + except Feedback.DoesNotExist: + # When installing fixtures, the related feedback might not yet be initialized. + # In that case cache invalidation is not necessary though + pass diff --git a/integreat_cms/locale/de/LC_MESSAGES/django.po b/integreat_cms/locale/de/LC_MESSAGES/django.po index 5dc1d01fa8..de68a1216c 100644 --- a/integreat_cms/locale/de/LC_MESSAGES/django.po +++ b/integreat_cms/locale/de/LC_MESSAGES/django.po @@ -1973,6 +1973,7 @@ msgstr "Kategorie" #: cms/forms/feedback/region_feedback_filter_form.py #: cms/templates/events/event_list_archived.html +#: cms/templates/events/external_calendar_list.html #: cms/templates/imprint/imprint_form.html #: cms/templates/imprint/imprint_sbs.html #: cms/templates/linkcheck/links_by_filter.html @@ -2523,6 +2524,7 @@ msgid "The new passwords do not match." msgstr "Die Passwörter stimmen nicht überein." #: cms/models/abstract_content_model.py cms/models/abstract_tree_node.py +#: cms/models/external_calendars/external_calendar.py #: cms/models/feedback/feedback.py cms/models/media/directory.py #: cms/models/media/media_file.py cms/models/regions/region.py #: cms/models/users/organization.py @@ -2755,6 +2757,15 @@ msgstr "Wiederholungs-Regel" msgid "icon" msgstr "Icon" +#: cms/models/events/event.py +#: cms/models/external_calendars/external_calendar.py +msgid "external calendar" +msgstr "Externer Kalender" + +#: cms/models/events/event.py +msgid "The ID of this event in the external calendar" +msgstr "Die ID dieses Events im externen Kalender" + #: cms/models/events/event.py msgid "copy" msgstr "Kopie" @@ -2860,6 +2871,35 @@ msgstr "Wiederholungs-Regel von \"{}\" ({})" msgid "recurrence rules" msgstr "Wiederholungs-Regeln" +#: cms/models/external_calendars/external_calendar.py +msgid "calendar name" +msgstr "Name des Kalenders" + +#: cms/models/external_calendars/external_calendar.py +#: cms/models/offers/offer_template.py cms/templates/_tinymce_config.html +#: cms/templates/events/external_calendar_list.html +#: cms/templates/linkcheck/links_by_filter.html +#: cms/templates/offertemplates/offertemplate_list.html +#: cms/views/media/media_context_mixin.py +msgid "URL" +msgstr "URL" + +#: cms/models/external_calendars/external_calendar.py +msgid "" +"The category that events need to have to get imported (Leave blank to import " +"all events)" +msgstr "" +"Die Kategorie, der ein Event zugeordnet sein muss, um importiert zu werden " +"(Leer lassen, um alle Veranstaltungen zu importieren)" + +#: cms/models/external_calendars/external_calendar.py +msgid "import errors" +msgstr "Importfehler" + +#: cms/models/external_calendars/external_calendar.py +msgid "external calendars" +msgstr "Externe Kalender" + #: cms/models/feedback/event_feedback.py msgid "event feedback" msgstr "Veranstaltungs-Feedback" @@ -3263,13 +3303,6 @@ msgstr "" msgid "thumbnail URL" msgstr "Vorschaubild-URL" -#: cms/models/offers/offer_template.py cms/templates/_tinymce_config.html -#: cms/templates/linkcheck/links_by_filter.html -#: cms/templates/offertemplates/offertemplate_list.html -#: cms/views/media/media_context_mixin.py -msgid "URL" -msgstr "URL" - #: cms/models/offers/offer_template.py msgid "This will be an external API endpoint in most cases." msgstr "Dies wird in den meisten Fällen ein externer API-Endpunkt sein." @@ -4398,6 +4431,10 @@ msgstr "Maschinelle Übersetzungen" msgid "Organizations" msgstr "Organisationen" +#: cms/templates/_base.html +msgid "External Calendars" +msgstr "Externe Kalender" + #: cms/templates/_base.html msgid "Users" msgstr "Benutzer:innen" @@ -5679,6 +5716,10 @@ msgstr "Ende" msgid "Recurrence" msgstr "Wiederholung" +#: cms/templates/events/event_list.html +msgid "External calendar" +msgstr "Externer Kalender" + #: cms/templates/events/event_list.html #: cms/templates/events/event_list_archived.html #: cms/templates/languages/language_list.html @@ -5811,6 +5852,73 @@ msgstr "" msgid "Copy event" msgstr "Veranstaltung kopieren" +#: cms/templates/events/external_calendar_form.html +#, python-format +msgid "Edit external calendar \"%(calendar)s\"" +msgstr "Externen Kalender \"%(calendar)s\" bearbeiten" + +#: cms/templates/events/external_calendar_form.html +#: cms/templates/events/external_calendar_list.html +msgid "Add new external calendar" +msgstr "Neuen externen Kalender hinzufügen" + +#: cms/templates/events/external_calendar_form.html +#: cms/templates/events/external_calendar_list_row.html +msgid "Delete external calendar" +msgstr "Externen Kalender löschen" + +#: cms/templates/events/external_calendar_form.html +#: cms/templates/feedback/admin_feedback_list.html +#: cms/templates/feedback/admin_feedback_list_archived.html +#: cms/templates/feedback/region_feedback_list.html +#: cms/templates/feedback/region_feedback_list_archived.html +#: cms/templates/languagetreenodes/languagetreenode_list.html +#: cms/templates/organizations/organization_form.html +msgid "Delete" +msgstr "Löschen" + +#: cms/templates/events/external_calendar_form.html +msgid "Update & Import" +msgstr "Aktualisieren & Importieren" + +#: cms/templates/events/external_calendar_form.html +#: cms/templates/languages/language_form.html +#: cms/templates/linkcheck/link_list_row.html +#: cms/templates/offertemplates/offertemplate_form.html +#: cms/templates/organizations/organization_form.html +#: cms/templates/poicategories/poicategory_form.html +#: cms/templates/regions/region_form.html cms/templates/roles/role_form.html +#: cms/templates/translations/translations_management.html +#: cms/templates/users/region_user_form.html cms/templates/users/user_form.html +#: cms/views/pois/poi_context_mixin.py +msgid "Save" +msgstr "Speichern" + +#: cms/templates/events/external_calendar_form.html +msgid "New external calendar" +msgstr "Neuer externer Kalender" + +#: cms/templates/events/external_calendar_list.html +msgid "External calendars" +msgstr "Externe Kalender" + +#: cms/templates/events/external_calendar_list.html +#: cms/templates/languages/language_form.html +#: cms/templates/offertemplates/offertemplate_list.html +#: cms/templates/organizations/organization_list.html +#: cms/templates/poicategories/poicategory_list.html +#: cms/templates/regions/region_list.html cms/templates/roles/role_list.html +msgid "Name" +msgstr "Name" + +#: cms/templates/events/external_calendar_list.html +msgid "Imported events" +msgstr "Importierte Termine" + +#: cms/templates/events/external_calendar_list.html +msgid "No external calendars available yet." +msgstr "Noch keine externen Kalender vorhanden." + #: cms/templates/feedback/_feedback_widget.html msgid "Unread technical feedback" msgstr "Ungelesenes technisches Feedback" @@ -5909,15 +6017,6 @@ msgstr "Als ungelesen markieren" msgid "Archive" msgstr "Archivieren" -#: cms/templates/feedback/admin_feedback_list.html -#: cms/templates/feedback/admin_feedback_list_archived.html -#: cms/templates/feedback/region_feedback_list.html -#: cms/templates/feedback/region_feedback_list_archived.html -#: cms/templates/languagetreenodes/languagetreenode_list.html -#: cms/templates/organizations/organization_form.html -msgid "Delete" -msgstr "Löschen" - #: cms/templates/feedback/admin_feedback_list_archived.html #: cms/templates/feedback/region_feedback_list_archived.html msgid "No archived feedback found with these filters." @@ -6171,26 +6270,6 @@ msgstr "Sprache \"%(translated_language_name)s\" bearbeiten" msgid "Create new language" msgstr "Neue Sprache erstellen" -#: cms/templates/languages/language_form.html -#: cms/templates/linkcheck/link_list_row.html -#: cms/templates/offertemplates/offertemplate_form.html -#: cms/templates/organizations/organization_form.html -#: cms/templates/poicategories/poicategory_form.html -#: cms/templates/regions/region_form.html cms/templates/roles/role_form.html -#: cms/templates/translations/translations_management.html -#: cms/templates/users/region_user_form.html cms/templates/users/user_form.html -#: cms/views/pois/poi_context_mixin.py -msgid "Save" -msgstr "Speichern" - -#: cms/templates/languages/language_form.html -#: cms/templates/offertemplates/offertemplate_list.html -#: cms/templates/organizations/organization_list.html -#: cms/templates/poicategories/poicategory_list.html -#: cms/templates/regions/region_list.html cms/templates/roles/role_list.html -msgid "Name" -msgstr "Name" - #: cms/templates/languages/language_form.html msgid "Identifier" msgstr "Bezeichner" @@ -8396,6 +8475,24 @@ msgstr "Ein SMTP-Fehler ist aufgetreten." msgid "The email server refused the connection." msgstr "Der E-Mail-Server hat die Verbindung abgelehnt." +#: cms/utils/external_calendar_utils.py +msgid "Could not access the url of this external calendar" +msgstr "Auf die URL dieses externen Kalenders konnte nicht zugegriffen werden" + +#: cms/utils/external_calendar_utils.py +msgid "The data provided by the url of this external calendar is invalid" +msgstr "Die Daten unter der URL des externen Kalenders sind fehlerhaft" + +#: cms/utils/external_calendar_utils.py +msgid "Could not import event because it is missing a required field: {}" +msgstr "" +"Ein Event konnte nicht importiert werden, da ein erforderliches Feld fehlt: " +"{}" + +#: cms/utils/external_calendar_utils.py +msgid "Could not import '{}': {}" +msgstr "'{}' konnte nicht importiert werden: {}" + #: cms/utils/internal_link_checker.py msgid "Imprint does not exist or is not public in this language" msgstr "" @@ -8830,6 +8927,13 @@ msgid "You cannot edit this event because it is archived." msgstr "" "Sie können diese Veranstaltung nicht bearbeiten, weil sie archiviert ist." +#: cms/views/events/event_form_view.py +msgid "" +"You cannot edit this event because it was imported from an external calendar." +msgstr "" +"Sie können diese Veranstaltung nicht bearbeiten, weil sie von einem externen " +"Kalender importiert worden ist." + #: cms/views/events/event_form_view.py msgid "You don't have the permission to edit events." msgstr "" @@ -8879,6 +8983,44 @@ msgstr "" msgid "Back to the event form" msgstr "Zurück zum Veranstaltungs-Formular" +#: cms/views/external_calendars/external_calendar_actions.py +msgid "External calendar was successfully deleted" +msgstr "Externer Kalender wurde erfolgreich gelöscht" + +#: cms/views/external_calendars/external_calendar_form_view.py +msgid "Please confirm that you really want to delete this external calendar" +msgstr "" +"Bitte bestätigen Sie, dass dieser externer Kalendar gelöscht werden soll" + +#: cms/views/external_calendars/external_calendar_form_view.py +#: cms/views/form_views.py cms/views/imprint/imprint_sbs_view.py +#: cms/views/pages/page_sbs_view.py +#: cms/views/poi_categories/poi_category_form_view.py +#: cms/views/roles/role_form_view.py cms/views/settings/user_settings_view.py +#: cms/views/users/region_user_form_view.py cms/views/users/user_form_view.py +msgid "No changes made" +msgstr "Keine Änderungen vorgenommen" + +#: cms/views/external_calendars/external_calendar_form_view.py +msgid "An error occurred while importing events from this external calendar" +msgstr "Ein Fehler mit dem Import von Veranstaltungen ist aufgetreten" + +#: cms/views/external_calendars/external_calendar_form_view.py +msgid "Successfully imported events from this external calendar" +msgstr "Veranstaltungen des externen Kalenders wurden erfolgreich importiert" + +#: cms/views/external_calendars/external_calendar_form_view.py +msgid "External calendar \"{}\" was successfully created" +msgstr "Externer Kalender \"{}\" wurde erfolgreich erstellt" + +#: cms/views/external_calendars/external_calendar_form_view.py +msgid "External calendar \"{}\" was successfully saved" +msgstr "Externer Kalender \"{}\" wurde erfolgreich gespeichert" + +#: cms/views/external_calendars/external_calendar_list_view.py +msgid "Please confirm that you really want to delete this calendar" +msgstr "Bitte bestätigen Sie, dass dieser Kalendar gelöscht werden soll" + #: cms/views/feedback/admin_feedback_actions.py #: cms/views/feedback/region_feedback_actions.py msgid "Feedback was successfully marked as read" @@ -8908,14 +9050,6 @@ msgstr "Feedback wurde erfolgreich gelöscht" msgid "Read by" msgstr "Gelesen von" -#: cms/views/form_views.py cms/views/imprint/imprint_sbs_view.py -#: cms/views/pages/page_sbs_view.py -#: cms/views/poi_categories/poi_category_form_view.py -#: cms/views/roles/role_form_view.py cms/views/settings/user_settings_view.py -#: cms/views/users/region_user_form_view.py cms/views/users/user_form_view.py -msgid "No changes made" -msgstr "Keine Änderungen vorgenommen" - #: cms/views/form_views.py cms/views/poi_categories/poi_category_form_view.py msgid "{} \"{}\" was successfully saved" msgstr "{} \"{}\" wurde erfolgreich gespeichert" @@ -9287,7 +9421,9 @@ msgstr "Die Organisation wurde erfolgreich archiviert" #: cms/views/organizations/organization_actions.py msgid "Organization couldn't be archived as it's used by a page, poi or user" -msgstr "Die Organisation konnte nicht archiviert werden, da sie von einer Seite, einem Ort oder eines Users verwendet wird" +msgstr "" +"Die Organisation konnte nicht archiviert werden, da sie von einer Seite, " +"einem Ort oder eines Users verwendet wird" #: cms/views/organizations/organization_actions.py msgid "Organization was successfully restored" @@ -9299,7 +9435,9 @@ msgstr "Organisation wurde erfolgreich gespeichert" #: cms/views/organizations/organization_actions.py msgid "Organization couldn't be deleted as it's used by a page, poi or user" -msgstr "Die Organisation konnte nicht gelöscht werden, da sie von einer Seite, einem Ort oder eines Users verwendet wird" +msgstr "" +"Die Organisation konnte nicht gelöscht werden, da sie von einer Seite, einem " +"Ort oder eines Users verwendet wird" #: cms/views/organizations/organization_content_mixin.py msgid "Please confirm that you really want to archive this organization" @@ -10524,6 +10662,12 @@ msgstr "" #~ msgid "You cannot translate into the default language" #~ msgstr "Sie können nicht in die Standard-Sprache übersetzen" +#~ msgid "Import external calendars" +#~ msgstr "Externen Kalender importieren" + +#~ msgid "Import calendar" +#~ msgstr "Kalender importieren" + #~ msgid "Find more information about this" #~ msgstr "Mehr Informationen finden Sie" @@ -11489,9 +11633,6 @@ msgstr "" #~ msgid "upcoming events" #~ msgstr "Zukünftige Termine" -#~ msgid "past events" -#~ msgstr "Vergangene Termine" - #~ msgid "Event is not public." #~ msgstr "Veranstaltung ist nicht veröffentlicht." @@ -11961,9 +12102,6 @@ msgstr "" #~ "Erlaube keine ungültigen SSL-Zertifikate bei der Interaktion mit der " #~ "Matomo API" -#~ msgid "Calendar week" -#~ msgstr "Kalenderwoche" - #~ msgid "Month view" #~ msgstr "Monatsansicht" diff --git a/integreat_cms/static/src/css/style.scss b/integreat_cms/static/src/css/style.scss index 26f1aa817d..32ddd6c593 100644 --- a/integreat_cms/static/src/css/style.scss +++ b/integreat_cms/static/src/css/style.scss @@ -334,8 +334,6 @@ label:not([for]) { } table { - width: inherit !important; - tr { &.level-2 { > td.hierarchy { diff --git a/pyproject.toml b/pyproject.toml index fcf1defc56..aeaff261cc 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -57,6 +57,7 @@ dependencies = [ "geopy", "google-auth", "google-cloud-translate", + "icalendar", "idna", "ipython", "jsonschema", @@ -162,6 +163,7 @@ pinned = [ "grpcio==1.64.1", "grpcio-status==1.62.2", "html5lib==1.1", + "icalendar==5.0.12", "idna==3.7", "ipython==8.25.0", "jedi==0.19.1", diff --git a/tests/cms/views/events/external_calendar.py b/tests/cms/views/events/external_calendar.py new file mode 100644 index 0000000000..ef2a467602 --- /dev/null +++ b/tests/cms/views/events/external_calendar.py @@ -0,0 +1,111 @@ +import pytest +from django.conf import settings +from django.test.client import Client +from django.urls import resolve, reverse + +from integreat_cms.cms.models import ExternalCalendar +from tests.conftest import ANONYMOUS, CMS_TEAM, ROOT, SERVICE_TEAM + + +@pytest.mark.django_db +def test_permissions_for_external_calendar_list( + load_test_data: None, + login_role_user: tuple[Client, str], +) -> None: + client, role = login_role_user + external_calendars_list = reverse( + "external_calendar_list", + kwargs={"region_slug": "augsburg"}, + ) + response = client.get(external_calendars_list) + + if role in [CMS_TEAM, ROOT, SERVICE_TEAM]: + assert response.status_code == 200 + assert "Externe Kalender" in response.content.decode("utf-8") + elif role == ANONYMOUS: + assert response.status_code == 302 + assert ( + response.headers.get("location") + == f"{settings.LOGIN_URL}?next={external_calendars_list}" + ) + else: + assert response.status_code == 403 + + +@pytest.mark.django_db +def test_permissions_for_creating_new_external_calendar( + load_test_data: None, + login_role_user: tuple[Client, str], +) -> None: + client, role = login_role_user + new_external_calendar = reverse( + "new_external_calendar", + kwargs={"region_slug": "augsburg"}, + ) + response = client.get(new_external_calendar) + + if role in [CMS_TEAM, ROOT, SERVICE_TEAM]: + assert response.status_code == 200 + assert "Neuen externen Kalender hinzufügen" in response.content.decode("utf-8") + elif role == ANONYMOUS: + assert response.status_code == 302 + assert ( + response.headers.get("location") + == f"{settings.LOGIN_URL}?next={new_external_calendar}" + ) + else: + assert response.status_code == 403 + + +@pytest.mark.django_db +def test_creating_new_external_calendar( + load_test_data: None, + login_role_user: tuple[Client, str], +) -> None: + client, role = login_role_user + + new_external_calendar = reverse( + "new_external_calendar", + kwargs={"region_slug": "augsburg"}, + ) + + response = client.post( + new_external_calendar, + data={ + "name": "Test external calendar", + "url": "https://integreat.app/", + "import_filter_category": "integreat", + }, + ) + + if role in [CMS_TEAM, ROOT, SERVICE_TEAM]: + assert response.status_code == 302, "We should be redirected to the edit view" + + edit_url = response.headers.get("location") + url_params = resolve(edit_url) + assert ( + url_params.url_name == "edit_external_calendar" + ), "We should be redirected to the edit view" + assert ( + url_params.kwargs["region_slug"] == "augsburg" + ), "The region shouldn't be different from the request" + id_of_external_calendar = url_params.kwargs["calendar_id"] + + external_calendar = ExternalCalendar.objects.get(id=id_of_external_calendar) + assert ( + external_calendar.name == "Test external calendar" + ), "Name should be successfully set on the model" + assert ( + external_calendar.url == "https://integreat.app/" + ), "URL should be successfully set on the model" + assert ( + external_calendar.import_filter_category == "integreat" + ), "Filter category should be successfully set on the model" + elif role == ANONYMOUS: + assert response.status_code == 302, "We should be redirected to the login view" + assert ( + response.headers.get("location") + == f"{settings.LOGIN_URL}?next={new_external_calendar}" + ) + else: + assert response.status_code == 403 diff --git a/tests/core/management/commands/assets/calendars/corrupted_event.ics b/tests/core/management/commands/assets/calendars/corrupted_event.ics new file mode 100755 index 0000000000..942d9e480b --- /dev/null +++ b/tests/core/management/commands/assets/calendars/corrupted_event.ics @@ -0,0 +1,19 @@ +BEGIN:VCALENDAR +VERSION:2.0 +CALSCALE:GREGORIAN +PRODID:-//SabreDAV//SabreDAV//EN +X-WR-CALNAME:Testkalender (9A35688E-054E-103B-8A52-DF79B8C14328) +X-APPLE-CALENDAR-COLOR:#6EA68F +REFRESH-INTERVAL;VALUE=DURATION:PT4H +X-PUBLISHED-TTL:PT4H +BEGIN:VEVENT +CREATED:20240515T134422Z +DTSTAMP:20240515T134427Z +LAST-MODIFIED:20240515T134427Z +SEQUENCE:2 +UID:8a237523-d648-4f11-8547-8685c9e96c1a +DTEND;VALUE=DATE:20240601 +STATUS:CONFIRMED +SUMMARY:Testevent +END:VEVENT +END:VCALENDAR diff --git a/tests/core/management/commands/assets/calendars/empty_calendar.ics b/tests/core/management/commands/assets/calendars/empty_calendar.ics new file mode 100755 index 0000000000..cb0255399e --- /dev/null +++ b/tests/core/management/commands/assets/calendars/empty_calendar.ics @@ -0,0 +1,9 @@ +BEGIN:VCALENDAR +VERSION:2.0 +CALSCALE:GREGORIAN +PRODID:-//SabreDAV//SabreDAV//EN +X-WR-CALNAME:Testkalender (9A35688E-054E-103B-8A52-DF79B8C14328) +X-APPLE-CALENDAR-COLOR:#6EA68F +REFRESH-INTERVAL;VALUE=DURATION:PT4H +X-PUBLISHED-TTL:PT4H +END:VCALENDAR diff --git a/tests/core/management/commands/assets/calendars/event_with_wrong_category.ics b/tests/core/management/commands/assets/calendars/event_with_wrong_category.ics new file mode 100755 index 0000000000..6fb09b5fd0 --- /dev/null +++ b/tests/core/management/commands/assets/calendars/event_with_wrong_category.ics @@ -0,0 +1,21 @@ +BEGIN:VCALENDAR +VERSION:2.0 +CALSCALE:GREGORIAN +PRODID:-//SabreDAV//SabreDAV//EN +X-WR-CALNAME:Testkalender (9A35688E-054E-103B-8A52-DF79B8C14328) +X-APPLE-CALENDAR-COLOR:#6EA68F +REFRESH-INTERVAL;VALUE=DURATION:PT4H +X-PUBLISHED-TTL:PT4H +BEGIN:VEVENT +CREATED:20240515T134422Z +DTSTAMP:20240515T134427Z +LAST-MODIFIED:20240515T134427Z +SEQUENCE:2 +UID:8a237523-d648-4f11-8547-8685c9e96c1a +DTSTART;VALUE=DATE:20240531 +DTEND;VALUE=DATE:20240601 +STATUS:CONFIRMED +SUMMARY:wrong_category +CATEGORIES:private +END:VEVENT +END:VCALENDAR diff --git a/tests/core/management/commands/assets/calendars/single_event.ics b/tests/core/management/commands/assets/calendars/single_event.ics new file mode 100755 index 0000000000..e816cd7b56 --- /dev/null +++ b/tests/core/management/commands/assets/calendars/single_event.ics @@ -0,0 +1,20 @@ +BEGIN:VCALENDAR +VERSION:2.0 +CALSCALE:GREGORIAN +PRODID:-//SabreDAV//SabreDAV//EN +X-WR-CALNAME:Testkalender (9A35688E-054E-103B-8A52-DF79B8C14328) +X-APPLE-CALENDAR-COLOR:#6EA68F +REFRESH-INTERVAL;VALUE=DURATION:PT4H +X-PUBLISHED-TTL:PT4H +BEGIN:VEVENT +CREATED:20240515T134422Z +DTSTAMP:20240515T134427Z +LAST-MODIFIED:20240515T134427Z +SEQUENCE:2 +UID:8a237523-d648-4f11-8547-8685c9e96c1a +DTSTART;VALUE=DATE:20240531 +DTEND;VALUE=DATE:20240601 +STATUS:CONFIRMED +SUMMARY:Testevent +END:VEVENT +END:VCALENDAR diff --git a/tests/core/management/commands/assets/calendars/single_event_v2.ics b/tests/core/management/commands/assets/calendars/single_event_v2.ics new file mode 100755 index 0000000000..24723c75df --- /dev/null +++ b/tests/core/management/commands/assets/calendars/single_event_v2.ics @@ -0,0 +1,20 @@ +BEGIN:VCALENDAR +VERSION:2.0 +CALSCALE:GREGORIAN +PRODID:-//SabreDAV//SabreDAV//EN +X-WR-CALNAME:Testkalender (9A35688E-054E-103B-8A52-DF79B8C14328) +X-APPLE-CALENDAR-COLOR:#6EA68F +REFRESH-INTERVAL;VALUE=DURATION:PT4H +X-PUBLISHED-TTL:PT4H +BEGIN:VEVENT +CREATED:20240515T134422Z +DTSTAMP:20240515T134427Z +LAST-MODIFIED:20240515T134427Z +SEQUENCE:2 +UID:8a237523-d648-4f11-8547-8685c9e96c1a +DTSTART;VALUE=DATE:20240531 +DTEND;VALUE=DATE:20240601 +STATUS:CONFIRMED +SUMMARY:Testeventv2 +END:VEVENT +END:VCALENDAR diff --git a/tests/core/management/commands/test_import_events.py b/tests/core/management/commands/test_import_events.py new file mode 100644 index 0000000000..62ea44a9ff --- /dev/null +++ b/tests/core/management/commands/test_import_events.py @@ -0,0 +1,235 @@ +from __future__ import annotations + +import pytest +from pytest_httpserver import HTTPServer + +from integreat_cms.cms.models import EventTranslation, ExternalCalendar, Region + +from ..utils import get_command_output + +CALENDAR_V1_EVENT_NAME = "Testevent" +CALENDAR_V1 = "tests/core/management/commands/assets/calendars/single_event.ics" +CALENDAR_V2_EVENT_NAME = "Testeventv2" +CALENDAR_v2 = "tests/core/management/commands/assets/calendars/single_event_v2.ics" +CALENDAR_EMPTY = "tests/core/management/commands/assets/calendars/empty_calendar.ics" +CALENDAR_WRONG_CATEGORY = ( + "tests/core/management/commands/assets/calendars/event_with_wrong_category.ics" +) +CALENDAR_WRONG_CATEGORY_EVENT_NAME = "wrong_category" +CALENDAR_WRONG_CATEGORY_TAG = "private" +CALENDAR_CORRUPTED = ( + "tests/core/management/commands/assets/calendars/corrupted_event.ics" +) +CALENDARS = [ + (CALENDAR_V1, [CALENDAR_V1_EVENT_NAME]), + (CALENDAR_v2, [CALENDAR_V2_EVENT_NAME]), + (CALENDAR_EMPTY, []), + (CALENDAR_WRONG_CATEGORY, [CALENDAR_WRONG_CATEGORY_EVENT_NAME]), +] + + +def serve(server: HTTPServer, file: str) -> str: + """ + Serves the given file + :param server: The server + :param file: The file to serve + :return: The url of the served file + """ + with open(file, "r", encoding="utf-8") as f: + server.expect_oneshot_request("/get_calendar").respond_with_data(f.read()) + return server.url_for("/get_calendar") + + +def setup_calendar(url: str) -> ExternalCalendar: + """ + Creates a Calendar instance + :param url: The url of the external calendar + :return: An External Calendar object + """ + region = Region.objects.get(slug="testumgebung") + calendar = ExternalCalendar.objects.create( + region=region, url=url, name="Test Calendar", import_filter_category="" + ) + calendar.save() + return calendar + + +@pytest.mark.django_db +def test_import_without_calendars() -> None: + """ + Tests that the import command does not fail if no external calendars are configured + """ + _, err = get_command_output("import_events") + assert not err + + +@pytest.mark.parametrize("calendar_data", CALENDARS) +@pytest.mark.django_db +def test_import_successful( + httpserver: HTTPServer, load_test_data: None, calendar_data: tuple[str, list[str]] +) -> None: + """ + Tests that the calendars in the test data can be imported correctly + :param httpserver: The server + :param load_test_data: The fixture providing the test data (see :meth:`~tests.conftest.load_test_data`) + :param calendar_data: A tuple of calendar path and event names of this calendar + """ + calendar_file, event_names = calendar_data + calendar_url = serve(httpserver, calendar_file) + calendar = setup_calendar(calendar_url) + + assert not EventTranslation.objects.filter( + event__region=calendar.region, title__in=event_names + ).exists(), "Event should not exist before import" + + _, err = get_command_output("import_events") + assert not err + + assert all( + EventTranslation.objects.filter( + event__region=calendar.region, title=title + ).exists() + for title in event_names + ), "Events should exist after import" + + +@pytest.mark.django_db +def test_update_event(httpserver: HTTPServer, load_test_data: None) -> None: + """ + Tests that an event gets updated if it is updated in the ical file + :param httpserver: The server + :param load_test_data: The fixture providing the test data (see :meth:`~tests.conftest.load_test_data`) + """ + calendar_url = serve(httpserver, CALENDAR_V1) + calendar = setup_calendar(calendar_url) + + out, err = get_command_output("import_events") + assert not err + assert "Imported event" in out + + event_translation = EventTranslation.objects.filter( + event__region=calendar.region, title=CALENDAR_V1_EVENT_NAME + ).first() + assert event_translation is not None, "Event should exist after import" + + serve(httpserver, CALENDAR_v2) + out, err = get_command_output("import_events") + assert not err + assert "Imported event" in out + + assert ( + event_translation.latest_version.title == CALENDAR_V2_EVENT_NAME + ), "event should be renamed" + + +@pytest.mark.django_db +def test_delete_event(httpserver: HTTPServer, load_test_data: None) -> None: + """ + Tests that an event gets deleted if it is deleted in the ical file + :param httpserver: The server + :param load_test_data: The fixture providing the test data (see :meth:`~tests.conftest.load_test_data`) + """ + calendar_url = serve(httpserver, CALENDAR_V1) + calendar = setup_calendar(calendar_url) + + out, err = get_command_output("import_events") + assert not err + + event_translation = EventTranslation.objects.filter( + event__region=calendar.region, title=CALENDAR_V1_EVENT_NAME + ).first() + assert event_translation is not None, "Event should exist after import" + + serve(httpserver, CALENDAR_EMPTY) + out, err = get_command_output("import_events") + assert not err + assert "Deleting 1 unused events: " in out + + assert not EventTranslation.objects.filter( + slug=event_translation.slug + ).exists(), "Event should be deleted" + + +@pytest.mark.django_db +def test_import_corrupted_event(httpserver: HTTPServer, load_test_data: None) -> None: + """ + Tests that an invalid event gets handled correctly and does not cause the command to crash + :param httpserver: The server + :param load_test_data: The fixture providing the test data (see :meth:`~tests.conftest.load_test_data`) + """ + calendar_url = serve(httpserver, CALENDAR_CORRUPTED) + setup_calendar(calendar_url) + + _, err = get_command_output("import_events") + assert "Could not import event because it does not have a required field: " in err + + +@pytest.mark.django_db +def test_import_event_without_tags( + httpserver: HTTPServer, load_test_data: None +) -> None: + """ + Tests that an event does not get imported if it does not have tags, but tags are required + :param httpserver: The server + :param load_test_data: The fixture providing the test data (see :meth:`~tests.conftest.load_test_data`) + """ + calendar_url = serve(httpserver, CALENDAR_V1) + calendar = setup_calendar(calendar_url) + + calendar.import_filter_category = "integreat" + calendar.save() + + out, err = get_command_output("import_events") + assert not err + assert f"Skipping event {CALENDAR_V1_EVENT_NAME} with tags: []" in out + + +@pytest.mark.django_db +def test_import_event_with_wrong_tag( + httpserver: HTTPServer, load_test_data: None +) -> None: + """ + Tests that an event does not get imported if it does not have the right tag + :param httpserver: The server + :param load_test_data: The fixture providing the test data (see :meth:`~tests.conftest.load_test_data`) + """ + calendar_url = serve(httpserver, CALENDAR_WRONG_CATEGORY) + calendar = setup_calendar(calendar_url) + + calendar.import_filter_category = "integreat" + calendar.save() + + out, err = get_command_output("import_events") + assert not err + assert ( + f"Skipping event {CALENDAR_WRONG_CATEGORY_EVENT_NAME} with tags: [{CALENDAR_WRONG_CATEGORY_TAG}]" + in out + ) + + +@pytest.mark.django_db +def test_import_event_with_correct_tag( + httpserver: HTTPServer, load_test_data: None +) -> None: + """ + Tests that an event gets imported if it has the right tag + :param httpserver: The server + :param load_test_data: The fixture providing the test data (see :meth:`~tests.conftest.load_test_data`) + """ + calendar_url = serve(httpserver, CALENDAR_WRONG_CATEGORY) + calendar = setup_calendar(calendar_url) + + calendar.import_filter_category = CALENDAR_WRONG_CATEGORY_TAG + calendar.save() + + assert not EventTranslation.objects.filter( + event__region=calendar.region, title=CALENDAR_WRONG_CATEGORY_EVENT_NAME + ).exists(), "Event should not exist before import" + + out, err = get_command_output("import_events") + assert not err + assert "Imported event" in out + + assert EventTranslation.objects.filter( + event__region=calendar.region, title=CALENDAR_WRONG_CATEGORY_EVENT_NAME + ).exists(), "Event should exist after import"