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 %}
+ {% translate "Name" %} + | ++ {% translate "URL" %} + | ++ {% translate "Status" %} + | ++ {% translate "Imported events" %} + | +
---|---|---|---|
+ {% translate "No external calendars available yet." %} + | +