diff --git a/.circleci/config.yml b/.circleci/config.yml index f2935e841d..c940e212e0 100644 --- a/.circleci/config.yml +++ b/.circleci/config.yml @@ -19,6 +19,7 @@ jobs: else python3 -m venv .venv source .venv/bin/activate + pip install --upgrade pip pip install -e .[dev-pinned,pinned] fi - save_cache: diff --git a/.github/ISSUE_TEMPLATE/bug-report.md b/.github/ISSUE_TEMPLATE/bug-report.md index 8148eb5a0d..b79f7c5d61 100644 --- a/.github/ISSUE_TEMPLATE/bug-report.md +++ b/.github/ISSUE_TEMPLATE/bug-report.md @@ -34,3 +34,7 @@ labels: 'bug' ``` + +### Related Issues + + diff --git a/.github/ISSUE_TEMPLATE/feature-request.md b/.github/ISSUE_TEMPLATE/feature-request.md index 00b8789785..e227c53c3c 100644 --- a/.github/ISSUE_TEMPLATE/feature-request.md +++ b/.github/ISSUE_TEMPLATE/feature-request.md @@ -28,3 +28,8 @@ As a [type of user] I want [goals or objectives] so that [values or benefits]. - ### Design Requirements + +### Related Issues + + + diff --git a/.gitignore b/.gitignore index a61ca98cfe..8dc9526001 100644 --- a/.gitignore +++ b/.gitignore @@ -68,3 +68,6 @@ integreat_cms/xliff/download # Postgres folder .postgres + +# Celery +celerybeat-schedule.db diff --git a/docs/src/debugging.rst b/docs/src/debugging.rst index 0e02062648..82772399da 100644 --- a/docs/src/debugging.rst +++ b/docs/src/debugging.rst @@ -45,6 +45,40 @@ VSCodium ^^^^^^^^ Coming soon(TM)? +PyCharm (Professional) +^^^^^^^^^^^^^^^^^^^^^^ + +PyCharm Professional has built-in support for django projects, but it needs some configuration to work with the integreat CMS. +This is unfortunately not the case for the free edition of PyCharm. Students can get a free license for the professional version, however. + +Enable Django Support +--------------------- + +#. Go to settings → Languages & Frameworks → Django +#. Click the ``Enable Django Support`` Checkbox +#. Set the root ``integreat_cms`` directory as the project root +#. For ``Settings`` use ``integreat_cms/core/docker_settings.py`` +#. Check ``Do not use Django test runner`` +#. For ``Manage script``, use ``integreat_cms/integreat-cms-cli``. If PyCharm does not let you select this script, because it does not end in .py, you can manually specify it in ``.idea/integreat-cms.iml``. + +Your configuration should now look similar to this: + + .. image:: images/debugging/debug-pycharm-01-django-config.png + :alt: Django configuration + +Create a Run Configuration +-------------------------- + +#. Create a new ``Django Server`` run configuration +#. Use a different port (For example 8001) to avoid conflicts with the non-debug server at port 8000 +#. At `Environment Variables`, add these: ``PYTHONUNBUFFERED=1;DJANGO_SETTINGS_MODULE=integreat_cms.core.docker_settings;INTEGREAT_CMS_DEBUG=True`` + +Start Debugging +--------------- + +#. First execute the ``./tools/run.sh`` to make sure that the database is available and all assets are compiled. +#. Once the server has started, you can start debugging by launching the run configuration + Neovim ^^^^^^ diff --git a/docs/src/images/debugging/debug-pycharm-01-django-config.png b/docs/src/images/debugging/debug-pycharm-01-django-config.png new file mode 100644 index 0000000000..4264d2b032 Binary files /dev/null and b/docs/src/images/debugging/debug-pycharm-01-django-config.png differ diff --git a/integreat_cms/api/v3/chat/chat_bot.py b/integreat_cms/api/v3/chat/chat_bot.py deleted file mode 100644 index da5023279f..0000000000 --- a/integreat_cms/api/v3/chat/chat_bot.py +++ /dev/null @@ -1,63 +0,0 @@ -""" -Wrapper for the Chat Bot / LLM API -""" - -import requests -from django.conf import settings - -from ....cms.models import Region -from ....cms.utils.content_translation_utils import get_public_translation_for_link - - -class ChatBot: - """ - API Wrapper for the LLM / Chat Bot - """ - - def __init__(self, hostname: str = "igchat-inference.tuerantuer.org"): - self.hostname = hostname - - def automatic_answer(self, message: str, region: Region, language_slug: str) -> str: - """ - Get automatic answer to question - """ - url = f"https://{self.hostname}/chatanswers/extract_answer/" - body = {"message": message, "language": language_slug, "region": region.slug} - r = requests.post(url, json=body, timeout=30) - return self.format_message(r.json()) - - def format_message(self, response: dict) -> str: - """ - Transform JSON into readable message - """ - if "answer" not in response or not response["answer"]: - return "" - if "sources" not in response or not response["sources"]: - return response["answer"] - sources = "".join( - [ - f"
  • {title}
  • " - for path in response["sources"] - if ( - title := get_public_translation_for_link(settings.WEBAPP_URL + path) - ) - ] - ) - return f"{response['answer']}\n" - - def automatic_translation( - self, message: str, source_lang_slug: str, target_lang_slug: str - ) -> str: - """ - Use LLM to translate message - """ - url = f"https://{self.hostname}/chatanswers/translate_message/" - body = { - "message": message, - "source_language": source_lang_slug, - "target_language": target_lang_slug, - } - response = requests.post(url, json=body, timeout=30).json() - if "status" in response and response["status"] == "success": - return response["translation"] - return "" diff --git a/integreat_cms/api/v3/chat/user_chat.py b/integreat_cms/api/v3/chat/user_chat.py index 49743186db..31c0f27b83 100644 --- a/integreat_cms/api/v3/chat/user_chat.py +++ b/integreat_cms/api/v3/chat/user_chat.py @@ -7,7 +7,6 @@ import json import logging import random -import socket from typing import TYPE_CHECKING from django.http import HttpResponse, JsonResponse @@ -17,8 +16,8 @@ from ....cms.models import ABTester, AttachmentMap, Language, Region, UserChat from ...decorators import json_response -from .chat_bot import ChatBot -from .zammad_api import ZammadChatAPI +from .utils.chat_bot import process_answer, process_user_message +from .utils.zammad_api import ZammadChatAPI if TYPE_CHECKING: from django.http import HttpRequest @@ -227,15 +226,13 @@ def zammad_webhook(request: HttpRequest) -> JsonResponse: """ Receive webhooks from Zammad to update the latest article translation """ - zammad_url = ( - f"https://{socket.getnameinfo((request.META.get('REMOTE_ADDR'), 0), 0)[0]}" + region = get_object_or_404( + Region, zammad_webhook_token=request.GET.get("token", None) ) - region = get_object_or_404(Region, zammad_url=zammad_url) - client = ZammadChatAPI(region) + if not region.integreat_chat_enabled: + return JsonResponse({"status": "Integreat Chat disabled"}) webhook_message = json.loads(request.body) message_text = webhook_message["article"]["body"] - zammad_chat = UserChat.objects.get(zammad_id=webhook_message["ticket"]["id"]) - chat_bot = ChatBot() actions = [] if webhook_message["article"]["internal"]: @@ -249,34 +246,14 @@ def zammad_webhook(request: HttpRequest) -> JsonResponse: webhook_message["article"]["created_by"]["login"] == "tech+integreat-cms@tuerantuer.org" ): - actions.append("question translation") - client.send_message( - zammad_chat.zammad_id, - chat_bot.automatic_translation( - message_text, zammad_chat.language.slug, region.default_language.slug - ), - True, - True, + actions.append("question translation queued") + process_user_message.apply_async( + args=[message_text, region.slug, webhook_message["ticket"]["id"]] ) - if answer := chat_bot.automatic_answer( - message_text, region, zammad_chat.language.slug - ): - actions.append("automatic answer") - client.send_message( - zammad_chat.zammad_id, - answer, - False, - True, - ) else: - actions.append("answer translation") - client.send_message( - zammad_chat.zammad_id, - chat_bot.automatic_translation( - message_text, region.default_language.slug, zammad_chat.language.slug - ), - False, - True, + actions.append("answer translation queued") + process_answer.apply_async( + args=[message_text, region.slug, webhook_message["ticket"]["id"]] ) return JsonResponse( { diff --git a/integreat_cms/api/v3/chat/utils/__init__.py b/integreat_cms/api/v3/chat/utils/__init__.py new file mode 100644 index 0000000000..15126b51d8 --- /dev/null +++ b/integreat_cms/api/v3/chat/utils/__init__.py @@ -0,0 +1,3 @@ +""" +Utils for the Integreat Chat +""" diff --git a/integreat_cms/api/v3/chat/utils/chat_bot.py b/integreat_cms/api/v3/chat/utils/chat_bot.py new file mode 100644 index 0000000000..146bb5a1f4 --- /dev/null +++ b/integreat_cms/api/v3/chat/utils/chat_bot.py @@ -0,0 +1,111 @@ +""" +Wrapper for the Chat Bot / LLM API +""" + +from __future__ import annotations + +import requests +from celery import shared_task +from django.conf import settings + +from integreat_cms.cms.models import Region, UserChat +from integreat_cms.cms.utils.content_translation_utils import ( + get_public_translation_for_link, +) + +from .zammad_api import ZammadChatAPI + + +def format_message(response: dict) -> str: + """ + Transform JSON into readable message + """ + if "answer" not in response or not response["answer"]: + raise ValueError("Could not format message, no answer attribute in response") + if "sources" not in response or not response["sources"]: + return response["answer"] + sources = "".join( + [ + f"
  • {title}
  • " + for path in response["sources"] + if (title := get_public_translation_for_link(settings.WEBAPP_URL + path)) + ] + ) + return f"{response['answer']}\n" + + +def automatic_answer(message: str, region: Region, language_slug: str) -> str | None: + """ + Get automatic answer to question + """ + url = ( + f"https://{settings.INTEGREAT_CHAT_BACK_END_DOMAIN}/chatanswers/extract_answer/" + ) + body = {"message": message, "language": language_slug, "region": region.slug} + r = requests.post(url, json=body, timeout=120) + return format_message(r.json()) + + +def automatic_translation( + message: str, source_language_slug: str, target_language_slug: str +) -> str: + """ + Use LLM to translate message + """ + url = f"https://{settings.INTEGREAT_CHAT_BACK_END_DOMAIN}/chatanswers/translate_message/" + body = { + "message": message, + "source_language": source_language_slug, + "target_language": target_language_slug, + } + response = requests.post(url, json=body, timeout=120).json() + if "status" in response and response["status"] == "success": + return response["translation"] + raise ValueError("Did not receive success response for translation request.") + + +@shared_task +def process_user_message( + message_text: str, region_slug: str, zammad_ticket_id: int +) -> None: + """ + Process the message from an Integreat App user + """ + zammad_chat = UserChat.objects.get(zammad_id=zammad_ticket_id) + region = Region.objects.get(slug=region_slug) + client = ZammadChatAPI(region) + if translation := automatic_translation( + message_text, zammad_chat.language.slug, region.default_language.slug + ): + client.send_message( + zammad_chat.zammad_id, + translation, + True, + True, + ) + if answer := automatic_answer(message_text, region, zammad_chat.language.slug): + client.send_message( + zammad_chat.zammad_id, + answer, + False, + True, + ) + + +@shared_task +def process_answer(message_text: str, region_slug: str, zammad_ticket_id: int) -> None: + """ + Process automatic or counselor answers + """ + zammad_chat = UserChat.objects.get(zammad_id=zammad_ticket_id) + region = Region.objects.get(slug=region_slug) + client = ZammadChatAPI(region) + if translation := automatic_translation( + message_text, region.default_language.slug, zammad_chat.language.slug + ): + client.send_message( + zammad_chat.zammad_id, + translation, + False, + True, + ) diff --git a/integreat_cms/api/v3/chat/zammad_api.py b/integreat_cms/api/v3/chat/utils/zammad_api.py similarity index 90% rename from integreat_cms/api/v3/chat/zammad_api.py rename to integreat_cms/api/v3/chat/utils/zammad_api.py index 808f37dce9..5f49cd3c83 100644 --- a/integreat_cms/api/v3/chat/zammad_api.py +++ b/integreat_cms/api/v3/chat/utils/zammad_api.py @@ -13,7 +13,7 @@ from requests.exceptions import HTTPError from zammad_py import ZammadAPI -from ....cms.models import AttachmentMap, Region, UserChat +from integreat_cms.cms.models import AttachmentMap, Region, UserChat logger = logging.getLogger(__name__) @@ -166,10 +166,20 @@ def get_messages(self, chat: UserChat) -> dict[str, dict | list[dict]]: # pylint: disable=method-hidden def send_message( - self, chat_id: int, message: str, internal: bool = False, auto: bool = False + self, + chat_id: int, + message: str, + internal: bool = False, + automatic_message: bool = False, ) -> dict: """ Post a new message to the given ticket + + param chat_id: Zammad ID of the chat + param message: The message body + param internal: keep the message internal in Zammad (do not show to user) + param automatic_message: sets title to "automatically generated message" + return: dict with Zammad article data """ params = { "ticket_id": chat_id, @@ -178,9 +188,11 @@ def send_message( "content_type": "text/html", "internal": internal, "subject": ( - "automatically generated message" if auto else "app user message" + "automatically generated message" + if automatic_message + else "app user message" ), - "sender": "Customer" if not auto else "Agent", + "sender": "Customer" if not automatic_message else "Agent", } return self._parse_response( # type: ignore[return-value] self._attempt_call(self.client.ticket_article.create, params=params) diff --git a/integreat_cms/cms/constants/frequency.py b/integreat_cms/cms/constants/frequency.py index 3326003aad..0c5deafb16 100644 --- a/integreat_cms/cms/constants/frequency.py +++ b/integreat_cms/cms/constants/frequency.py @@ -14,6 +14,7 @@ from django.utils.functional import Promise +# The frequencies must adhere to the icalendar naming: https://icalendar.org/iCalendar-RFC-5545/3-3-10-recurrence-rule.html #: Daily DAILY: Final = "DAILY" diff --git a/integreat_cms/cms/constants/language_color.py b/integreat_cms/cms/constants/language_color.py index 40725f6ef3..8013358ea1 100644 --- a/integreat_cms/cms/constants/language_color.py +++ b/integreat_cms/cms/constants/language_color.py @@ -48,6 +48,16 @@ PINE_GREEN: Final = "#20B2AA" ALMOND: Final = "#FFDAB9" CHERRY: Final = "#D62728" +DUSTY_ORANGE: Final = "#DB6D4B" +DUST_STORM: Final = "#E0D3C0" +SILVER: Final = "#C9C7C1" +LIGHT_GRAY: Final = "#CED6DA" +DARK_GRAY: Final = "#91B1C0" +DEEP_PINK: Final = "#F72681" +DARK_YELLOWISH_GREEN: Final = "#26662C" +MODERATE_REDDISH_PURPLE: Final = "#9C5B87" +STRONG_YELLOW: Final = "#C8B03C" +BRILLIANT_PURPLISH_BLUE = "#6F79AB" TOTAL_ACCESS: Final = "#000000" WEB_APP_ACCESS: Final = "#FF00A8" @@ -87,4 +97,14 @@ (PINE_GREEN, _("Pine green")), (ALMOND, _("Almond")), (CHERRY, _("Cherry")), + (DUSTY_ORANGE, _("Dusty orange")), + (DUST_STORM, _("Dust storm")), + (SILVER, _("Silver")), + (LIGHT_GRAY, _("Light gray")), + (DARK_GRAY, _("Dark gray")), + (DEEP_PINK, _("Deep pink")), + (DARK_YELLOWISH_GREEN, _("Dark yellowish green")), + (MODERATE_REDDISH_PURPLE, _("Moderate reddish purple")), + (STRONG_YELLOW, _("Strong yellow")), + (BRILLIANT_PURPLISH_BLUE, _("Brilliant purplish blue")), ] diff --git a/integreat_cms/cms/constants/weekdays.py b/integreat_cms/cms/constants/weekdays.py index c1bc2c1f72..fc15963a9d 100644 --- a/integreat_cms/cms/constants/weekdays.py +++ b/integreat_cms/cms/constants/weekdays.py @@ -45,3 +45,14 @@ #: Weekend: Saturday and Sunday WEEKEND: Final[list[int]] = [SATURDAY, SUNDAY] + +#: A mapping from an ical recurrence rule weekday string to a cms weekday constant +RRULE_WEEKDAY_TO_WEEKDAY = { + "MO": MONDAY, + "TU": TUESDAY, + "WE": WEDNESDAY, + "TH": THURSDAY, + "FR": FRIDAY, + "SA": SATURDAY, + "SU": SUNDAY, +} diff --git a/integreat_cms/cms/constants/weeks.py b/integreat_cms/cms/constants/weeks.py index 84bb56b1b3..e3e8c9efe8 100644 --- a/integreat_cms/cms/constants/weeks.py +++ b/integreat_cms/cms/constants/weeks.py @@ -42,3 +42,6 @@ FOURTH: 4, LAST: -1, } + +#: A mapping from the expected rrule values to our week constants +RRULE_WEEK_TO_WEEK = {1: FIRST, 2: SECOND, 3: THIRD, 4: FOURTH, -1: LAST} diff --git a/integreat_cms/cms/fixtures/test_data.json b/integreat_cms/cms/fixtures/test_data.json index 0c25c7d85d..f87b91b954 100644 --- a/integreat_cms/cms/fixtures/test_data.json +++ b/integreat_cms/cms/fixtures/test_data.json @@ -143,6 +143,7 @@ "integreat_chat_enabled": true, "zammad_url": "https://zammad.example.com", "zammad_access_token": "dummytoken", + "zammad_webhook_token": "07cce1d7-ec79-4ae3-98cf-04d340ae6655", "zammad_chat_handlers": "handler@example.com", "chat_beta_tester_percentage": 50, "offers": [3, 2, 1, 5] @@ -194,8 +195,9 @@ "machine_translate_events": 1, "machine_translate_pois": 1, "integreat_chat_enabled": false, - "zammad_url": null, + "zammad_url": "", "zammad_access_token": "", + "zammad_webhook_token": "eaea8b88-5f17-4d55-b04c-face1264ca45", "zammad_chat_handlers": "", "chat_beta_tester_percentage": 0, "offers": [3, 4, 2] @@ -247,8 +249,9 @@ "machine_translate_events": 1, "machine_translate_pois": 1, "integreat_chat_enabled": false, - "zammad_url": null, + "zammad_url": "", "zammad_access_token": "", + "zammad_webhook_token": "bf21d6b8-0dcf-4ac9-ba00-1befac1ee25a", "zammad_chat_handlers": "", "chat_beta_tester_percentage": 0, "offers": [] @@ -300,8 +303,9 @@ "machine_translate_events": 1, "machine_translate_pois": 1, "integreat_chat_enabled": false, - "zammad_url": null, + "zammad_url": "", "zammad_access_token": "", + "zammad_webhook_token": "4f130b2e-b85e-4a0e-8a31-63591adb6b3c", "zammad_chat_handlers": "", "chat_beta_tester_percentage": 0, "offers": [] @@ -353,8 +357,9 @@ "machine_translate_events": 1, "machine_translate_pois": 1, "integreat_chat_enabled": false, - "zammad_url": null, + "zammad_url": "", "zammad_access_token": "", + "zammad_webhook_token": "9de4a0be-c394-44a3-8e35-434c5325d8af", "zammad_chat_handlers": "", "chat_beta_tester_percentage": 0, "offers": [1] @@ -406,8 +411,9 @@ "machine_translate_events": 1, "machine_translate_pois": 1, "integreat_chat_enabled": false, - "zammad_url": null, + "zammad_url": "", "zammad_access_token": "", + "zammad_webhook_token": "249bdf60-511d-45a4-a4df-d3b6feca26a1", "zammad_chat_handlers": "", "chat_beta_tester_percentage": 0, "offers": [] @@ -459,8 +465,9 @@ "machine_translate_events": 1, "machine_translate_pois": 1, "integreat_chat_enabled": false, - "zammad_url": null, + "zammad_url": "", "zammad_access_token": "", + "zammad_webhook_token": "ef0a7824-1516-45e2-b878-e1ad5a34b70b", "zammad_chat_handlers": "", "chat_beta_tester_percentage": 0, "offers": [] @@ -512,8 +519,9 @@ "machine_translate_events": 1, "machine_translate_pois": 1, "integreat_chat_enabled": false, - "zammad_url": null, + "zammad_url": "", "zammad_access_token": "", + "zammad_webhook_token": "1a6940d4-9114-470a-9077-42db05037511", "zammad_chat_handlers": "", "chat_beta_tester_percentage": 0, "offers": [] @@ -821,6 +829,20 @@ "archived": true } }, + { + "model": "cms.organization", + "pk": 3, + "fields": { + "name": "Not Referenced Organisation", + "slug": "not-referenced-organisation", + "icon": 1, + "last_updated": "2024-08-08T07:57:42.456Z", + "region": 1, + "created_date": "2024-08-08T07:57:42.456Z", + "website": "https://integreat-app.de/", + "archived": false + } + }, { "model": "cms.poi", "pk": 4, diff --git a/integreat_cms/cms/forms/regions/region_form.py b/integreat_cms/cms/forms/regions/region_form.py index 144befc6c5..1c839827d9 100644 --- a/integreat_cms/cms/forms/regions/region_form.py +++ b/integreat_cms/cms/forms/regions/region_form.py @@ -210,6 +210,7 @@ class Meta: "integreat_chat_enabled", "zammad_url", "zammad_access_token", + "zammad_webhook_token", "zammad_chat_handlers", "chat_beta_tester_percentage", ] @@ -397,12 +398,14 @@ def clean(self) -> dict[str, Any]: # Integreat Chat can only be enabled if Zammad URL and access key are set if cleaned_data["integreat_chat_enabled"] and ( - not cleaned_data["zammad_url"] or not cleaned_data["zammad_access_token"] + not cleaned_data["zammad_url"] + or not cleaned_data["zammad_access_token"] + or not cleaned_data["zammad_webhook_token"] ): self.add_error( "integreat_chat_enabled", _( - "A Zammad URL and Access Token are required in order to enable the Integreat Chat." + "A Zammad URL, Zammad Webhook Token and Access Token are required in order to enable the Integreat Chat." ), ) @@ -607,14 +610,14 @@ def clean_hix_enabled(self) -> bool: ) return cleaned_hix_enabled - def clean_zammad_url(self) -> str | None: + def clean_zammad_url(self) -> str: """ Validate the zammad_url field (see :ref:`overriding-modelform-clean-method`). :return: The validated field """ if not (cleaned_zammad_url := self.cleaned_data["zammad_url"]): - return None + return "" # Remove superfluous path parts cleaned_zammad_url = cleaned_zammad_url.split("/api/v1")[0] cleaned_zammad_url = cleaned_zammad_url.rstrip("/") diff --git a/integreat_cms/cms/migrations/0110_region_zammad_webhook_token_alter_region_zammad_url.py b/integreat_cms/cms/migrations/0110_region_zammad_webhook_token_alter_region_zammad_url.py new file mode 100644 index 0000000000..60016f4994 --- /dev/null +++ b/integreat_cms/cms/migrations/0110_region_zammad_webhook_token_alter_region_zammad_url.py @@ -0,0 +1,67 @@ +# Generated by Django 4.2.13 on 2024-11-08 12:41 + +import uuid +from typing import Callable + +from django.apps.registry import Apps +from django.db import migrations, models + + +def set_zammad_urls( + apps: Apps, schema_editor: Callable # pylint: disable=unused-argument +) -> None: + """ + Update empty Zammad URLs to Null + """ + Region = apps.get_model("cms", "Region") + Region.objects.filter(zammad_url=None).update(zammad_url="") + for region in Region.objects.all(): + region.zammad_webhook_token = uuid.uuid4() + region.save() + + +class Migration(migrations.Migration): + """ + Remove zammad_url unique constraint, replace Null values with empty strings, remove null=true + """ + + dependencies = [ + ("cms", "0109_custom_truncating_char_field"), + ] + + operations = [ + migrations.AddField( + model_name="region", + name="zammad_webhook_token", + field=models.UUIDField( + blank=True, + default=uuid.uuid4, + help_text="Token used by Zammad webhooks to inform the Integreat CMS about changed tickets. The token has to be appended with a token= GET parameter to the webhook path.", + verbose_name="Token used by Zammad webhook", + ), + ), + migrations.AlterField( + model_name="region", + name="zammad_url", + field=models.URLField( + blank=True, + default=None, + help_text="URL pointing to this region's Zammad instance. Setting this enables Zammad form offers.", + max_length=256, + null=True, + verbose_name="Zammad-URL", + ), + ), + migrations.RunPython(set_zammad_urls), + migrations.AlterField( + model_name="region", + name="zammad_url", + field=models.URLField( + blank=True, + default="", + help_text="URL pointing to this region's Zammad instance. Setting this enables Zammad form offers.", + max_length=256, + verbose_name="Zammad-URL", + ), + ), + ] diff --git a/integreat_cms/cms/migrations/0111_alter_language_language_color.py b/integreat_cms/cms/migrations/0111_alter_language_language_color.py new file mode 100644 index 0000000000..6a3d99b821 --- /dev/null +++ b/integreat_cms/cms/migrations/0111_alter_language_language_color.py @@ -0,0 +1,71 @@ +# Generated by Django 4.2.16 on 2024-11-26 07:19 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + """ + Add additional language colors + """ + + dependencies = [ + ("cms", "0110_region_zammad_webhook_token_alter_region_zammad_url"), + ] + + operations = [ + migrations.AlterField( + model_name="language", + name="language_color", + field=models.CharField( + choices=[ + ("#FFBB78", "Mellow apricot"), + ("#2CA02C", "Forest green"), + ("#FF9896", "Rose"), + ("#C5B0D5", "Tropical violet"), + ("#FF4500", "Red"), + ("#FFA500", "Orange"), + ("#17157D", "Dark blue"), + ("#1F77B4", "Green blue"), + ("#FFD700", "Yellow"), + ("#008080", "Teal"), + ("#9EDAE5", "Arctic"), + ("#5894E3", "Azure"), + ("#17BECF", "Pacific blue"), + ("#FF6347", "Orange red"), + ("#98DF8A", "Light green"), + ("#9467BD", "Violet"), + ("#ADFF2F", "Lime"), + ("#E377C2", "Lavender"), + ("#8C564B", "Brown"), + ("#FFA07A", "Pink orange"), + ("#FFE4F0", "Pastel pink"), + ("#F0E68C", "Khaki"), + ("#BCBD22", "Yellow green"), + ("#800080", "Mauve"), + ("#BA55D3", "Purple"), + ("#DBDB8D", "Primrose"), + ("#4B5563", "Fiord"), + ("#C49C94", "Quicksand"), + ("#7F7F7F", "Grey"), + ("#26FCFF", "Aqua"), + ("#20B2AA", "Pine green"), + ("#FFDAB9", "Almond"), + ("#D62728", "Cherry"), + ("#DB6D4B", "Dusty orange"), + ("#E0D3C0", "Dust storm"), + ("#C9C7C1", "Silver"), + ("#CED6DA", "Light gray"), + ("#91B1C0", "Dark gray"), + ("#F72681", "Deep pink"), + ("#26662C", "Dark yellowish green"), + ("#9C5B87", "Moderate reddish purple"), + ("#C8B03C", "Strong yellow"), + ("#6F79AB", "Brilliant purplish blue"), + ], + default="#000000", + help_text="This color is used to represent the color label of the chosen language", + max_length=7, + verbose_name="language color", + ), + ), + ] diff --git a/integreat_cms/cms/models/contact/contact.py b/integreat_cms/cms/models/contact/contact.py index 510cb93ab6..c6f63ad9f0 100644 --- a/integreat_cms/cms/models/contact/contact.py +++ b/integreat_cms/cms/models/contact/contact.py @@ -3,6 +3,7 @@ from django.db.utils import DataError from django.utils import timezone from django.utils.functional import cached_property +from django.utils.translation import gettext from django.utils.translation import gettext_lazy as _ from ..abstract_base_model import AbstractBaseModel @@ -91,6 +92,21 @@ def get_additional_attribute(self) -> str: return "" + def label_in_reference_list(self) -> str: + """ + This function returns a display name of this contact for the poi contacts list + """ + + label = ( + _("General contact information") + if not self.point_of_contact_for + else f"{self.point_of_contact_for} {self.name}" + ) + if self.archived: + label += " (⚠ " + gettext("Archived") + ")" + + return label + def get_repr(self) -> str: """ This overwrites the default Django ``__repr__()`` method which would return ````. diff --git a/integreat_cms/cms/models/regions/region.py b/integreat_cms/cms/models/regions/region.py index 24d7e40444..51b83104e5 100644 --- a/integreat_cms/cms/models/regions/region.py +++ b/integreat_cms/cms/models/regions/region.py @@ -1,6 +1,7 @@ from __future__ import annotations import logging +import uuid from datetime import datetime from html import escape from typing import TYPE_CHECKING @@ -418,9 +419,7 @@ class Region(AbstractBaseModel): zammad_url = models.URLField( max_length=256, blank=True, - null=True, - default=None, - unique=True, + default="", verbose_name=_("Zammad-URL"), help_text=_( "URL pointing to this region's Zammad instance. Setting this enables Zammad form offers." @@ -435,6 +434,15 @@ class Region(AbstractBaseModel): 'Access token for a Zammad user account. In Zammad, the account must be part of the "Agent" role and have full group permissions for the group:' ), ) + zammad_webhook_token = models.UUIDField( + max_length=64, + blank=True, + default=uuid.uuid4, + verbose_name=_("Token used by Zammad webhook"), + help_text=_( + "Token used by Zammad webhooks to inform the Integreat CMS about changed tickets. The token has to be appended with a token= GET parameter to the webhook path." + ), + ) zammad_chat_handlers = models.CharField( max_length=1024, blank=True, diff --git a/integreat_cms/cms/templates/_tinymce_config.html b/integreat_cms/cms/templates/_tinymce_config.html index 8ab3df522a..5856b9ace8 100644 --- a/integreat_cms/cms/templates/_tinymce_config.html +++ b/integreat_cms/cms/templates/_tinymce_config.html @@ -51,6 +51,8 @@ data-speech-icon-text='{% translate "Spoken Languages" %}' data-speech-icon-src="{% get_base_url %}{% static 'svg/speech.svg' %}" data-speech-icon-alt="{% translate "Spoken Languages" %}" + data-fax-icon-text='{% translate "Fax Number" %}' + data-fax-icon-src="{% get_base_url %}{% static 'svg/fax.svg' %}" data-update-text='{% translate "Update" %}' data-dialog-submit-text='{% translate "Submit" %}' data-dialog-cancel-text='{% translate "Cancel" %}' diff --git a/integreat_cms/cms/templates/events/event_list.html b/integreat_cms/cms/templates/events/event_list.html index 2e3c723d82..94ca91e258 100644 --- a/integreat_cms/cms/templates/events/event_list.html +++ b/integreat_cms/cms/templates/events/event_list.html @@ -7,16 +7,30 @@
    @@ -28,18 +42,20 @@

    {% translate "Show filters" %} {% translate "Hide filters" %} - {% if request.region.default_language == language and perms.cms.change_event %} - - {% translate "Create event" %} - - {% elif perms.cms.change_event %} - {% blocktranslate trimmed asvar disabled_button_title with request.region.default_language.translated_name as default_language %} - You can only create events in the default language ({{ default_language }}). - {% endblocktranslate %} - + {% if not is_archive %} + {% if request.region.default_language == language and perms.cms.change_event %} + + {% translate "Create event" %} + + {% elif perms.cms.change_event %} + {% blocktranslate trimmed asvar disabled_button_title with request.region.default_language.translated_name as default_language %} + You can only create events in the default language ({{ default_language }}). + {% endblocktranslate %} + + {% endif %} {% endif %}

    @@ -57,7 +73,7 @@

    class="w-full mt-4 rounded border border-solid border-gray-200 shadow bg-white"> - + @@ -115,11 +131,19 @@

    {% endfor %} {% else %} - + {% if filter_form.has_changed %} - {% translate "No events found with these filters." %} + {% if not is_archive %} + {% translate "No events found with these filters." %} + {% else %} + {% translate "No archived events found with these filters." %} + {% endif %} {% else %} - {% translate "No upcoming events available." %} + {% if not is_archive %} + {% translate "No upcoming events available." %} + {% else %} + {% translate "No upcoming events archived." %} + {% endif %} {% endif %} @@ -140,41 +164,47 @@

    - - {% if request.user.is_superuser or request.user.is_staff %} - {% translate "events" as content_type %} - - - {% endif %} - {% if MT_PERMITTED %} - {% translate "events" as content_type %} - {% if MT_PROVIDER %} - - {% else %} - {% endif %} + {% if MT_PERMITTED %} + {% translate "events" as content_type %} + {% if MT_PROVIDER %} + + {% else %} + + {% endif %} + {% endif %} + {% else %} + {% endif %} -

    - -
    - {% include "events/_event_filter_form.html" %} -
    - {% endwith %} -
    -
    - {% csrf_token %} -
    - - - - - - {% get_current_language as LANGUAGE_CODE %} - {% get_language LANGUAGE_CODE as backend_language %} - {% if backend_language and backend_language != language %} - - {% endif %} - - - - - - - - - - - {% if events %} - {% for event in events %} - {% get_translation event language.slug as event_translation %} - {% include "events/event_list_archived_row.html" with event=event event_translation=event_translation %} - {% endfor %} - {% else %} - - - - {% endif %} - -
    - - - {% translate "Title in" %} {{ language.translated_name }} - - {% translate "Title in" %} {{ backend_language.translated_name }} - -
    - {% spaceless %} - {% for lang in languages %} - {% if lang != request.region.default_language %} - - - - {% endif %} - {% endfor %} - {% endspaceless %} -
    -
    - {% translate "Status" %} - - {% translate "Event location" %} - - {% translate "Start" %} - - {% translate "End" %} - - {% translate "Recurrence" %} - - {% translate "Options" %} -
    - {% if filter_form.has_changed %} - {% translate "No archived events found with these filters." %} - {% else %} - {% translate "No upcoming events archived." %} - {% endif %} -
    -
    - {% if perms.cms.change_event %} -
    - - -
    - {% endif %} - {% include "../generic_confirmation_dialog.html" %} - {% url "events" as url %} - {% include "pagination.html" with chunk=events %} -{% endblock content %} diff --git a/integreat_cms/cms/templates/events/event_list_archived_row.html b/integreat_cms/cms/templates/events/event_list_archived_row.html deleted file mode 100644 index 11f165583b..0000000000 --- a/integreat_cms/cms/templates/events/event_list_archived_row.html +++ /dev/null @@ -1,126 +0,0 @@ -{% load i18n %} -{% load rules %} -{% load content_filters %} -{% load poi_filters %} - - - - - - - {% if event_translation %} - {{ event_translation.title|truncatechars:35 }} - {% else %} - {% translate "Translation not available" %} - {% endif %} - - - {% get_current_language as LANGUAGE_CODE %} - {% get_language LANGUAGE_CODE as backend_language %} - {% if backend_language and backend_language != language %} - - - {% if event.backend_translation %} - {{ event.backend_translation.title|truncatechars:35 }} - {% else %} - {% translate "Translation not available" %} - {% endif %} - - - {% endif %} - - {% spaceless %} - {% for other_language in languages %} - {% if other_language != request.region.default_language %} - - {% get_translation event other_language.slug as other_translation %} - {% if other_translation %} - {% if other_translation.currently_in_translation %} - - - - {% elif other_translation.is_outdated %} - - - - {% else %} - - - - {% endif %} - {% else %} - - - - {% endif %} - - {% endif %} - {% endfor %} - {% endspaceless %} - - - {{ event_translation.get_status_display }} - - - {% if event.location %} - {% get_language LANGUAGE_CODE as current_language %} - {{ event.location|poi_translation_title:current_language }} - {% else %} - {% translate "Not specified" %} - {% endif %} - - - {{ event.start_date|date:'d.m.Y' }} - {% if not event.is_all_day %} - {{ event.start_time|time:'H:i' }} - {% endif %} - - - {{ event.end_date|date:'d.m.Y' }} - {% if not event.is_all_day %} - {{ event.end_time|time:'H:i' }} - {% endif %} - - - {% if event.recurrence_rule %} - {{ event.recurrence_rule.get_frequency_display }} - {% else %} - {% translate "One-time" %} - {% endif %} - - - {% has_perm 'cms.change_event' request.user as can_edit_event %} - {% if can_edit_event %} - - {% endif %} - {% if perms.cms.delete_event %} - - {% endif %} - - diff --git a/integreat_cms/cms/templates/events/event_list_row.html b/integreat_cms/cms/templates/events/event_list_row.html index e1e8aacb90..2a56ad2a2c 100644 --- a/integreat_cms/cms/templates/events/event_list_row.html +++ b/integreat_cms/cms/templates/events/event_list_row.html @@ -24,10 +24,10 @@ {% get_current_language as LANGUAGE_CODE %} {% get_language LANGUAGE_CODE as backend_language %} {% if backend_language and backend_language != language %} - + + class="block text-gray-800"> {% if event.backend_translation %} {{ event.backend_translation.title|truncatechars:35 }} {% else %} @@ -124,44 +124,57 @@ {% endif %} - {% if event_translation.status == PUBLIC %} - - - - {% else %} - + {% if not is_archive %} + {% if event_translation.status == PUBLIC %} + + + + {% else %} + + {% endif %} {% endif %} {% has_perm 'cms.change_event' request.user as can_edit_event %} {% if can_edit_event %} - {% if not event.external_calendar %} -
    - {% csrf_token %} - +
    + {% else %} + - + {% endif %} + {% else %} - {% endif %} - {% endif %} {% if perms.cms.delete_event %} - +{% if request.region.statistics_enabled and perms.cms.view_statistics %} + {% block content %} +
    +
    +

    + {% translate "Statistics" %} +

    -
    -
    -

    - {% translate "Export" %} -

    +
    +
    + {% include "statistics/statistics_chart.html" with box_id="statistics_chart" %}
    -
    - - - - +
    + {% include "statistics/statistics_sidebar.html" with box_id="statistics_sidebar" %}
    -
    -{% endblock content %} + {% endblock content %} +{% endif %} diff --git a/integreat_cms/cms/templates/statistics/statistics_sidebar.html b/integreat_cms/cms/templates/statistics/statistics_sidebar.html new file mode 100644 index 0000000000..7b6052c00f --- /dev/null +++ b/integreat_cms/cms/templates/statistics/statistics_sidebar.html @@ -0,0 +1,81 @@ +{% load i18n %} +{% load static %} +{% load widget_tweaks %} + +
    +
    +

    + {% translate "Adjust time period" %} +

    +
    +
    + {% csrf_token %} + + {% render_field form.start_date|add_error_class:"border-red-500" %} + + + {% render_field form.end_date|add_error_class:"border-red-500" %} + + + {% render_field form.period|add_error_class:"border-red-500" %} + + +
    +
    +
    +
    +

    + {% translate "Export" %} +

    +
    +
    + + + + +
    +
    diff --git a/integreat_cms/cms/urls/protected.py b/integreat_cms/cms/urls/protected.py index e8844df98b..4e3ae8c9b8 100644 --- a/integreat_cms/cms/urls/protected.py +++ b/integreat_cms/cms/urls/protected.py @@ -1140,6 +1140,21 @@ ), name="archived_organizations", ), + path( + "bulk-archive/", + organizations.ArchiveBulkAction.as_view(), + name="bulk_archive_organization", + ), + path( + "bulk-restore/", + organizations.RestoreBulkAction.as_view(), + name="bulk_restore_organization", + ), + path( + "bulk-delete/", + organizations.DeleteBulkAction.as_view(), + name="bulk_delete_organization", + ), path( "/", include( diff --git a/integreat_cms/cms/utils/external_calendar_utils.py b/integreat_cms/cms/utils/external_calendar_utils.py index 0957568144..0817d6a94a 100644 --- a/integreat_cms/cms/utils/external_calendar_utils.py +++ b/integreat_cms/cms/utils/external_calendar_utils.py @@ -7,14 +7,24 @@ import dataclasses import datetime import logging +from typing import Any, Self import icalendar.cal from django.utils.translation import gettext as _ -from icalendar.prop import vCategory - -from integreat_cms.cms.constants import status -from integreat_cms.cms.forms import EventForm, EventTranslationForm -from integreat_cms.cms.models import EventTranslation, ExternalCalendar +from icalendar.prop import ( + vCategory, + vDDDTypes, + vFrequency, + vInt, + vRecur, + vWeekday, +) + +from integreat_cms.cms.constants import frequency, status +from integreat_cms.cms.constants.weekdays import RRULE_WEEKDAY_TO_WEEKDAY +from integreat_cms.cms.constants.weeks import RRULE_WEEK_TO_WEEK +from integreat_cms.cms.forms import EventForm, EventTranslationForm, RecurrenceRuleForm +from integreat_cms.cms.models import EventTranslation, ExternalCalendar, RecurrenceRule from integreat_cms.cms.utils.content_utils import clean_content @@ -33,6 +43,7 @@ class IcalEventData: end_date: datetime.date end_time: datetime.time | None is_all_day: bool + recurrence_rule: RecurrenceRuleData | None categories: list[str] external_calendar_id: int @@ -43,7 +54,7 @@ def from_ical_event( language_slug: str, external_calendar_id: int, logger: logging.Logger, - ) -> IcalEventData: + ) -> Self: """ Reads an ical event and constructs an instance of this class from it :param event: The ical event @@ -51,6 +62,7 @@ def from_ical_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 + :raises ValueError: If the data are invalid """ # pylint: disable=too-many-locals event_id = event.decoded("UID").decode("utf-8") @@ -97,6 +109,12 @@ def from_ical_event( start_date, start_time = start.date(), start.time() end_date, end_time = end.date(), end.time() + recurrence_rule = None + if "RRULE" in event: + recurrence_rule = RecurrenceRuleData.from_ical_rrule( + event.decoded("RRULE"), logger + ) + return cls( event_id=event_id, title=title, @@ -108,6 +126,7 @@ def from_ical_event( is_all_day=is_all_day, external_calendar_id=external_calendar_id, categories=categories, + recurrence_rule=recurrence_rule, ) def to_event_form_data(self) -> dict: @@ -121,6 +140,7 @@ def to_event_form_data(self) -> dict: "end_date": self.end_date, "end_time": self.end_time, "is_all_day": self.is_all_day, + "is_recurring": bool(self.recurrence_rule), "has_not_location": True, "external_calendar": self.external_calendar_id, "external_event_id": self.event_id, @@ -133,6 +153,138 @@ def to_event_translation_form_data(self) -> dict: """ return {"title": self.title, "status": status.PUBLIC, "content": self.content} + def to_recurrence_rule_form_data(self) -> dict: + """ + Returns a dictionary of relevant data for the recurrence rule form + :return: Dict of relevant data + :raises ValueError: If the recurrence rule cannot be mapped to form data + """ + if not self.recurrence_rule: + return {} + + return self.recurrence_rule.to_form_data() + + +@dataclasses.dataclass(frozen=True, kw_only=True) +class RecurrenceRuleData: + """ + This dataclass contains all relevant data for producing arguments for a recurrence rule from an ical rrule. + """ + + frequency: vFrequency + interval: vInt + until: vDDDTypes | None + by_day: list[vWeekday] | None + by_set_pos: vInt | None + + @classmethod + def from_ical_rrule(cls, recurrence_rule: vRecur, logger: logging.Logger) -> Self: + """ + Constructs this class from an ical recurrence rule. + :return: An instance of this class + :raises ValueError: If the recurrence rule cannot be mapped to this class + """ + + def pop_single_value(name: str, *, required: bool = False) -> Any: + """ + Removes the key from the recurrence rule and returns a single value or ``None``, if ``required`` is True + :return: A single value from the recurrence rule or ``None`` + :raises ValueError: If the recurrence rule contains multiple value for the given name + """ + match recurrence_rule.pop(name): + case [] | None if not required: + return None + case [single]: + return single + case other: + raise ValueError(f"Expected a single value for {name}, got {other}") + + logger.debug("Recurrence rule: %s", recurrence_rule) + frequency_ = pop_single_value("FREQ", required=True) + interval = pop_single_value("INTERVAL") or 1 + until = pop_single_value("UNTIL") + by_day = recurrence_rule.pop("BYDAY") + by_set_pos = pop_single_value("BYSETPOS") + + # ByMonth cannot be handled right now, but it is sometimes included with yearly repeating events, so we have to pop it. + # If it differs from the event start month, it will be silently ignored :( + recurrence_rule.pop("BYMONTH") + + if len(recurrence_rule) > 0: + raise ValueError( + f"Recurrence rule contained unsupported attribute(s): {list(recurrence_rule.keys())}" + ) + + return cls( + frequency=frequency_, + interval=interval, + until=until, + by_day=by_day, + by_set_pos=by_set_pos, + ) + + def to_form_data(self) -> dict: + """ + Creates a dictionary that can be passed as form data to the recurrence rule form + :return: Dict of relevant data + :raises ValueError: If the recurrence rule cannot be mapped to form data + """ + weekdays_for_weekly = None + weekday_for_monthly = None + week_for_monthly = None + match self.frequency: + case frequency.DAILY: + pass + case frequency.WEEKLY: + weekdays_for_weekly = self.decode_by_day() + case frequency.MONTHLY: + week_for_monthly = self.decode_by_set_pos() + + weekdays = self.decode_by_day() + if len(weekdays) != 1: + raise ValueError(f"Unsupported weekday for monthly: {self.by_day}") + weekday_for_monthly = weekdays[0] + case frequency.YEARLY: + pass + case other: + raise ValueError(f"Unsupported frequency: {other}") + + return { + "frequency": self.frequency, + "interval": self.interval, + "recurrence_end_date": self.until, + "has_recurrence_end_date": bool(self.until), + "weekdays_for_weekly": weekdays_for_weekly, + "weekday_for_monthly": weekday_for_monthly, + "week_for_monthly": week_for_monthly, + } + + def decode_by_set_pos(self) -> int: + """ + :return: The correct ``cms.constants.weeks`` value for the set pos + :raises ValueError: If the by_set_pos value is not supported + """ + if self.by_set_pos is None: + raise ValueError("by_set_pos must not be None") + if not (decoded := RRULE_WEEK_TO_WEEK.get(self.by_set_pos)): + raise ValueError(f"Unknown value for by_set_pos: {self.by_set_pos}") + return decoded + + def decode_by_day(self) -> list[int]: + """ + :return: The correct ``cms.constants.weeks`` value for the day of the week + :raises ValueError: If the by_day value is not supported + """ + if not self.by_day: + raise ValueError("Missing required value for by_day") + weekdays_or_none = [ + RRULE_WEEKDAY_TO_WEEKDAY.get(weekday) for weekday in self.by_day + ] + weekdays = [weekday for weekday in weekdays_or_none if weekday is not None] + if len(weekdays) != len(weekdays_or_none): + raise ValueError(f"Unknown value for weekday: {self.by_day}") + return weekdays + def import_events(calendar: ExternalCalendar, logger: logging.Logger) -> None: """ @@ -207,6 +359,7 @@ def import_event( errors: list[str], logger: logging.Logger, ) -> str | None: + # pylint: disable=too-many-return-statements """ Imports an event from the external calendar @@ -219,9 +372,17 @@ def import_event( """ language = calendar.region.default_language - event_data = IcalEventData.from_ical_event( - event, language.slug, calendar.pk, logger - ) + try: + event_data = IcalEventData.from_ical_event( + event, language.slug, calendar.pk, logger + ) + except ValueError as e: + logger.error("Could not import event: %r due to error: %s", event, e) + errors.append(_("Could not import '{}': {}").format(event.get("SUMMARY"), e)) + try: + return event.decoded("UID").decode("utf-8") + except (KeyError, UnicodeError): + return None # Skip this event if it does not have the required tag if calendar.import_filter_category and not any( @@ -245,6 +406,10 @@ def import_event( if previously_imported_event_translation else None ) + previously_imported_recurrence_rule = RecurrenceRule.objects.filter( + event__external_calendar=calendar, + event__external_event_id=event_data.event_id, + ).first() event_form = EventForm( data=event_data.to_event_form_data(), @@ -258,6 +423,38 @@ def import_event( ) return event_data.event_id + try: + recurrence_rule_form_data = event_data.to_recurrence_rule_form_data() + except ValueError as e: + logger.error("Could not import event due to unsupported recurrence rule: %r", e) + errors.append( + _("Could not import '{}': Unsupported recurrence rule").format( + event_data.title, e + ) + ) + return event_data.event_id + recurrence_rule_form = RecurrenceRuleForm( + data=recurrence_rule_form_data, + instance=previously_imported_recurrence_rule, + event_start_date=event_form.cleaned_data.get("start_date"), + ) + if recurrence_rule_form_data and not recurrence_rule_form.is_valid(): + logger.error( + "Could not import recurrence rule: %r", recurrence_rule_form.errors + ) + errors.append( + _("Could not import '{}': {}").format( + event_data.title, recurrence_rule_form.errors + ) + ) + return event_data.event_id + + if recurrence_rule_form_data: + event_form.instance.recurrence_rule = recurrence_rule_form.save() + elif event_form.instance.recurrence_rule: + event_form.instance.recurrence_rule.delete() + event_form.instance.recurrence_rule = None + event = event_form.save() event_translation_form = EventTranslationForm( @@ -279,9 +476,13 @@ def import_event( # 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(): + if ( + event_form.has_changed() + or event_translation_form.has_changed() + or recurrence_rule_form.has_changed() + ): event_translation = event_translation_form.save() - logger.success("Imported event %r, %r", event, event_translation) # type: ignore[attr-defined] + logger.success("Imported event %r, %r, %r", event, event_translation, event.recurrence_rule) # type: ignore[attr-defined] else: logger.info("Event %r has not changed", event_translation_form.instance) diff --git a/integreat_cms/cms/views/events/event_list_view.py b/integreat_cms/cms/views/events/event_list_view.py index b1398f64d8..f315eb1a70 100644 --- a/integreat_cms/cms/views/events/event_list_view.py +++ b/integreat_cms/cms/views/events/event_list_view.py @@ -34,24 +34,12 @@ class EventListView(TemplateView, EventContextMixin, MachineTranslationContextMi """ #: Template for list of non-archived events - template = "events/event_list.html" - #: Template for list of archived events - template_archived = "events/event_list_archived.html" + template_name = "events/event_list.html" #: Whether or not to show archived events archived = False #: The translation model of this list view (used to determine whether machine translations are permitted) translation_model = EventTranslation - @property - def template_name(self) -> str: - """ - Select correct HTML template, depending on :attr:`~integreat_cms.cms.views.events.event_list_view.EventListView.archived` flag - (see :class:`~django.views.generic.base.TemplateResponseMixin`) - - :return: Path to HTML template - """ - return self.template_archived if self.archived else self.template - def get(self, request: HttpRequest, *args: Any, **kwargs: Any) -> HttpResponse: r""" Render events list for HTTP GET requests @@ -117,5 +105,6 @@ def get(self, request: HttpRequest, *args: Any, **kwargs: Any) -> HttpResponse: "search_query": query, "source_language": region.get_source_language(language.slug), "content_type": "events", + "is_archive": self.archived, }, ) diff --git a/integreat_cms/cms/views/organizations/__init__.py b/integreat_cms/cms/views/organizations/__init__.py index 5bd2046be1..f5e59e0086 100644 --- a/integreat_cms/cms/views/organizations/__init__.py +++ b/integreat_cms/cms/views/organizations/__init__.py @@ -3,5 +3,10 @@ """ from .organization_actions import archive, delete, restore +from .organization_bulk_actions import ( + ArchiveBulkAction, + DeleteBulkAction, + RestoreBulkAction, +) from .organization_form_view import OrganizationFormView from .organization_list_view import OrganizationListView diff --git a/integreat_cms/cms/views/organizations/organization_actions.py b/integreat_cms/cms/views/organizations/organization_actions.py index 4fd4eb3471..ef8973d0cc 100644 --- a/integreat_cms/cms/views/organizations/organization_actions.py +++ b/integreat_cms/cms/views/organizations/organization_actions.py @@ -94,7 +94,7 @@ def delete( messages.success(request, _("Organization was successfully deleted")) else: logger.info("%r couldn't be deleted by %r", organization, request.user) - messages.success( + messages.error( request, _("Organization couldn't be deleted as it's used by a page, poi or user"), ) diff --git a/integreat_cms/cms/views/organizations/organization_bulk_actions.py b/integreat_cms/cms/views/organizations/organization_bulk_actions.py new file mode 100644 index 0000000000..4689222a87 --- /dev/null +++ b/integreat_cms/cms/views/organizations/organization_bulk_actions.py @@ -0,0 +1,180 @@ +from __future__ import annotations + +import logging +from typing import TYPE_CHECKING + +from django.contrib import messages +from django.utils.translation import gettext_lazy as _ +from django.utils.translation import ngettext_lazy + +from integreat_cms.cms.utils.stringify_list import iter_to_string + +from ...models import Organization +from ..bulk_action_views import BulkActionView + +if TYPE_CHECKING: + from typing import Any + + from django.http import HttpRequest, HttpResponse + from django.http.response import HttpResponseRedirect + +logger = logging.getLogger(__name__) + + +class OrganizationBulkAction(BulkActionView): + """ + View for executing organization bulk actions + """ + + #: The model of this :class:`~integreat_cms.cms.views.bulk_action_views.BulkActionView` + model = Organization + + +class ArchiveBulkAction(OrganizationBulkAction): + """ + Bulk action for archiving multiple organizations + """ + + def post(self, request: HttpRequest, *args: Any, **kwargs: Any) -> HttpResponse: + r""" + Function to archive multiple organizations at once + + :param request: The current request + :param \*args: The supplied arguments + :param \**kwargs: The supplied keyword arguments + :return: The redirect + """ + archive_successful = [] + archive_failed = [] + + for content_object in self.get_queryset(): + if content_object.archive(): + archive_successful.append(content_object) + else: + archive_failed.append(content_object) + + if archive_successful: + messages.success( + request, + ngettext_lazy( + "{model_name} {object_names} was successfully archived.", + "The following {model_name_plural} were successfully archived: {object_names}.", + len(archive_successful), + ).format( + model_name=self.model._meta.verbose_name.title(), + model_name_plural=self.model._meta.verbose_name_plural, + object_names=iter_to_string(archive_successful), + ), + ) + + if archive_failed: + messages.error( + request, + ngettext_lazy( + "{model_name} {object_names} couldn't be archived as it's used by a page, poi or user.", + "The following {model_name_plural} couldn't be archived as they're used by a page, poi or user: {object_names}.", + len(archive_failed), + ).format( + model_name=self.model._meta.verbose_name.title(), + model_name_plural=self.model._meta.verbose_name_plural, + object_names=iter_to_string(archive_failed), + ), + ) + return super().post(request, *args, **kwargs) + + +class RestoreBulkAction(OrganizationBulkAction): + """ + Bulk action for restoring multiple organizations + """ + + def post(self, request: HttpRequest, *args: Any, **kwargs: Any) -> HttpResponse: + r""" + Function to restore multiple organizations at once + + :param request: The current request + :param \*args: The supplied arguments + :param \**kwargs: The supplied keyword arguments + :return: The redirect + """ + restore_successful = [] + + for content_object in self.get_queryset(): + content_object.restore() + restore_successful.append(content_object) + + if restore_successful: + messages.success( + request, + ngettext_lazy( + "{model_name} {object_names} was successfully restored.", + "The following {model_name_plural} were successfully restored: {object_names}.", + len(restore_successful), + ).format( + model_name=self.model._meta.verbose_name.title(), + model_name_plural=self.model._meta.verbose_name_plural, + object_names=iter_to_string(restore_successful), + ), + ) + + return super().post(request, *args, **kwargs) + + +class DeleteBulkAction(OrganizationBulkAction): + """ + Bulk action for deleting multiple organizations + """ + + def get_permission_required(self) -> tuple[str]: + r""" + This method overwrites get_permission_required() + :return: The needed permission to delete contacts + """ + return (f"cms.delete_{self.model._meta.model_name}",) + + def post(self, request: HttpRequest, *args: Any, **kwargs: Any) -> HttpResponse: + r""" + Function to delete multiple organizations at once + + :param request: The current request + :param \*args: The supplied arguments + :param \**kwargs: The supplied keyword arguments + :return: The redirect + """ + delete_successful = [] + delete_failed = [] + + for content_object in self.get_queryset(): + if content_object.delete(): + delete_successful.append(content_object) + else: + delete_failed.append(content_object) + + if delete_successful: + messages.success( + request, + ngettext_lazy( + "{model_name} {object_names} was successfully deleted.", + "The following {model_name_plural} were successfully deleted: {object_names}.", + len(delete_successful), + ).format( + model_name=self.model._meta.verbose_name.title(), + model_name_plural=self.model._meta.verbose_name_plural, + object_names=iter_to_string(delete_successful), + ), + ) + + if delete_failed: + messages.error( + request, + ngettext_lazy( + "{model_name} {object_names} couldn't be deleted as it's used by a page, poi or user.", + "The following {model_name_plural} couldn't be deleted as they're used by a page, poi or user: {object_names}.", + len(delete_failed), + ).format( + model_name=self.model._meta.verbose_name.title(), + model_name_plural=self.model._meta.verbose_name_plural, + object_names=iter_to_string(delete_failed), + ), + ) + return super().post(request, *args, **kwargs) diff --git a/integreat_cms/cms/views/organizations/organization_form_view.py b/integreat_cms/cms/views/organizations/organization_form_view.py index a748409370..e01e9cf8f4 100644 --- a/integreat_cms/cms/views/organizations/organization_form_view.py +++ b/integreat_cms/cms/views/organizations/organization_form_view.py @@ -119,13 +119,20 @@ def post(self, request: HttpRequest, **kwargs: Any) -> HttpResponse: organization_form.instance ), ) - else: - messages.success( - request, - _('Organization "{}" was successfully saved').format( - organization_form.instance - ), + return redirect( + "edit_organization", + **{ + "region_slug": region.slug, + "organization_id": organization_form.instance.id, + }, ) + + messages.success( + request, + _('Organization "{}" was successfully saved').format( + organization_form.instance + ), + ) else: organization_form.add_error_messages(request) diff --git a/integreat_cms/core/settings.py b/integreat_cms/core/settings.py index 9403a5e0a9..dabcb0a3b3 100644 --- a/integreat_cms/core/settings.py +++ b/integreat_cms/core/settings.py @@ -20,7 +20,8 @@ from django.core.exceptions import ImproperlyConfigured from django.utils.translation import gettext_lazy as _ -from ..nominatim_api.utils import BoundingBox +from integreat_cms.nominatim_api.utils import BoundingBox + from .logging_formatter import ColorFormatter, RequestFormatter from .utils.strtobool import strtobool @@ -372,6 +373,7 @@ "integreat_cms.nominatim_api", "integreat_cms.summ_ai_api", "integreat_cms.textlab_api", + "integreat_cms.integreat_celery", # Installed Django apps "django.contrib.auth", "django.contrib.contenttypes", @@ -924,6 +926,11 @@ #: Authentication token for the DeepL API. If not set, automatic translations via DeepL are disabled DEEPL_AUTH_KEY: str | None = os.environ.get("INTEGREAT_CMS_DEEPL_AUTH_KEY") +#: Whether to enable deepl glossaries +DEEPL_GLOSSARIES_ENABLED: Final[bool] = strtobool( + os.environ.get("INTEGREAT_CMS_DEEPL_GLOSSARIES_ENABLED", "False") +) + #: Whether automatic translations via DeepL are enabled. #: This is ``True`` if :attr:`~integreat_cms.core.settings.DEEPL_AUTH_KEY` is set, ``False`` otherwise. DEEPL_ENABLED: bool = bool(DEEPL_AUTH_KEY) @@ -1330,3 +1337,46 @@ #: Zammad ticket group used for Integreat chat messages USER_CHAT_TICKET_GROUP: Final[str] = "integreat-chat" + +#: Integreat Chat (app) backend server domain +INTEGREAT_CHAT_BACK_END_DOMAIN = "igchat-inference.tuerantuer.org" + +########## +# CELERY # +########## + +#: Configure Celery to use a custom time zone. The timezone value can be any time zone supported by the pytz library. +#: If not set the UTC timezone is used. For backwards compatibility there is also a CELERY_ENABLE_UTC setting, +#: and this is set to false the system local timezone is used instead. +CELERY_TIMEZONE = "UTC" + +#: If True the task will report its status as ``started`` when the task is executed by a worker. +#: The default value is False as the normal behavior is to not report that level of granularity. +#: Tasks are either pending, finished, or waiting to be retried. +#: Having a ``started`` state can be useful for when there are long running tasks +#: and there’s a need to report what task is currently running. +CELERY_TASK_TRACK_STARTED = True + +#: Task hard time limit in seconds. The worker processing the task will be killed +#: and replaced with a new one when this is exceeded. +CELERY_TASK_TIME_LIMIT = 60 * 60 * 1 + +#: Default broker URL. +CELERY_BROKER_URL = os.environ.get( + "CELERY_REDIS_URL", + ( + "redis+socket:///var/run/redis/redis-server.sock" + if not DEBUG + else "redis://localhost:6379/0" + ), +) + +#: The backend used to store task results (tombstones). Disabled by default. +CELERY_RESULT_BACKEND = os.environ.get( + "CELERY_REDIS_URL", + ( + "redis+socket:///var/run/redis/redis-server.sock" + if not DEBUG + else "redis://localhost:6379/0" + ), +) diff --git a/integreat_cms/deepl_api/apps.py b/integreat_cms/deepl_api/apps.py index c28bd9c182..0dcbf4c6c5 100644 --- a/integreat_cms/deepl_api/apps.py +++ b/integreat_cms/deepl_api/apps.py @@ -9,7 +9,7 @@ import sys from typing import TYPE_CHECKING -from deepl import Translator +from deepl import GlossaryInfo, Translator from deepl.exceptions import DeepLException from django.apps import AppConfig from django.conf import settings @@ -36,6 +36,29 @@ class DeepLApiClientConfig(AppConfig): supported_source_languages: list[str] = [] #: The supported target languages supported_target_languages: list[str] = [] + #: The supported glossaries, a map from (source_language, target_language) to glossary info + supported_glossaries: dict[tuple[str, str], GlossaryInfo] = {} + + def get_glossary( + self, source_language: str, target_language: str + ) -> GlossaryInfo | None: + """ + Looks up a glossary for the specified source language and target language pair. + This method also returns the correct glossary for region variants (for example en-gb) + + :param source_language: The source language + :param target_language: The target language + :return: A :class:`~deepl.glossaries.GlossaryInfo` object or None + """ + + def normalize(language: str) -> str: + """ + Normalizes the language by converting it to lower-case and only keeping the non-regional part + """ + return language.lower().split("-")[0] + + key = normalize(source_language), normalize(target_language) + return self.supported_glossaries.get(key) def ready(self) -> None: """ @@ -49,34 +72,12 @@ def ready(self) -> None: auth_key=settings.DEEPL_AUTH_KEY, server_url=settings.DEEPL_API_URL, ) - self.supported_source_languages = [ - source_language.code.lower() - for source_language in deepl_translator.get_source_languages() - ] - logger.debug( - "Supported source languages by DeepL: %r", - self.supported_source_languages, - ) - assert self.supported_source_languages - self.supported_target_languages = [ - target_languages.code.lower() - for target_languages in deepl_translator.get_target_languages() - ] - logger.debug( - "Supported target languages by DeepL: %r", - self.supported_target_languages, - ) - assert self.supported_target_languages - usage = deepl_translator.get_usage() - if usage.any_limit_reached: - logger.warning("DeepL API translation limit reached") - # pylint: disable=protected-access - logger.info( - "DeepL API is available at: %r (character usage: %s of %s)", - deepl_translator._server_url, - usage.character.count, - usage.character.limit, - ) + + self.init_supported_source_languages(deepl_translator) + self.init_supported_target_languages(deepl_translator) + self.init_supported_glossaries(deepl_translator) + + self.assert_usage_limit_not_reached(deepl_translator) except (DeepLException, AssertionError) as e: logger.error(e) logger.error( @@ -85,3 +86,73 @@ def ready(self) -> None: ) else: logger.info("DeepL API is disabled.") + + def init_supported_source_languages(self, translator: Translator) -> None: + """ + Requests the supported sources languages from the translator and sets them + + :param translator: The deepl translator + """ + self.supported_source_languages = [ + source_language.code.lower() + for source_language in translator.get_source_languages() + ] + logger.debug( + "Supported source languages by DeepL: %r", + self.supported_source_languages, + ) + assert self.supported_source_languages + + def init_supported_target_languages(self, translator: Translator) -> None: + """ + Requests the supported target languages from the translator and sets them + + :param translator: The deepl translator + """ + self.supported_target_languages = [ + target_languages.code.lower() + for target_languages in translator.get_target_languages() + ] + logger.debug( + "Supported target languages by DeepL: %r", + self.supported_target_languages, + ) + assert self.supported_target_languages + + def init_supported_glossaries(self, translator: Translator) -> None: + """ + Requests the supported glossaries from the translator and sets them + + :param translator: The deepl translator + """ + if not settings.DEEPL_GLOSSARIES_ENABLED: + logger.debug("No glossaries loaded because they are disabled in settings") + return + + glossaries = translator.list_glossaries() + self.supported_glossaries = { + (glossary.source_lang.lower(), glossary.target_lang.lower()): glossary + for glossary in glossaries + if glossary.ready + } + logger.debug( + "Supported glossaries by DeepL: %s", list(self.supported_glossaries.keys()) + ) + + @staticmethod + def assert_usage_limit_not_reached(translator: Translator) -> None: + """ + Requests the usage from the translator and asserts that no limit was reached + + :param translator: The deepl translator + """ + usage = translator.get_usage() + if usage.any_limit_reached: + logger.warning("DeepL API translation limit reached") + # pylint: disable=protected-access + logger.info( + "DeepL API is available at: %r (character usage: %s of %s)", + translator._server_url, + usage.character.count, + usage.character.limit, + ) diff --git a/integreat_cms/deepl_api/deepl_api_client.py b/integreat_cms/deepl_api/deepl_api_client.py index 8bdd946c89..0884f5ce6e 100644 --- a/integreat_cms/deepl_api/deepl_api_client.py +++ b/integreat_cms/deepl_api/deepl_api_client.py @@ -17,6 +17,7 @@ from ..core.utils.machine_translation_api_client import MachineTranslationApiClient from ..core.utils.machine_translation_provider import MachineTranslationProvider from ..textlab_api.utils import check_hix_score +from .apps import DeepLApiClientConfig if TYPE_CHECKING: from django.forms.models import ModelFormMetaclass @@ -80,6 +81,8 @@ def translate_queryset( :param queryset: The content QuerySet :param language_slug: The target language slug """ + deepl_config: DeepLApiClientConfig = apps.get_app_config("deepl_api") + with transaction.atomic(): # Re-select the region from db to prevent simultaneous # requests exceeding the DeepL usage limit @@ -146,11 +149,16 @@ def translate_queryset( ): try: # data has to be unescaped for DeepL to recognize Umlaute + glossary = deepl_config.get_glossary( + source_language.slug, target_language_key + ) + logger.debug("Used glossary for translation: %s", glossary) data[attr] = self.translator.translate_text( unescape(getattr(source_translation, attr)), source_lang=source_language.slug, target_lang=target_language_key, tag_handling="html", + glossary=glossary, ) except DeepLException as e: messages.error( diff --git a/integreat_cms/integreat_celery/__init__.py b/integreat_cms/integreat_celery/__init__.py new file mode 100644 index 0000000000..5568b6d791 --- /dev/null +++ b/integreat_cms/integreat_celery/__init__.py @@ -0,0 +1,5 @@ +# This will make sure the app is always imported when +# Django starts so that shared_task will use this app. +from .celery import app as celery_app + +__all__ = ("celery_app",) diff --git a/integreat_cms/integreat_celery/apps.py b/integreat_cms/integreat_celery/apps.py new file mode 100644 index 0000000000..d51b317c63 --- /dev/null +++ b/integreat_cms/integreat_celery/apps.py @@ -0,0 +1,14 @@ +""" +Set up celery app +""" + +from django.apps import AppConfig + + +class IntegreatCeleryConfig(AppConfig): + """ + Configuration for Celery + """ + + default_auto_field = "django.db.models.BigAutoField" + name = "integreat_cms.integreat_celery" diff --git a/integreat_cms/integreat_celery/celery.py b/integreat_cms/integreat_celery/celery.py new file mode 100644 index 0000000000..353837c17e --- /dev/null +++ b/integreat_cms/integreat_celery/celery.py @@ -0,0 +1,39 @@ +""" +Celery worker +""" + +import configparser +import os + +from celery import Celery + +# Read config from config file +config = configparser.ConfigParser(interpolation=None) +config.read("/etc/integreat-cms.ini") +for section in config.sections(): + for KEY, VALUE in config.items(section): + os.environ.setdefault(f"INTEGREAT_CMS_{KEY.upper()}", VALUE) +os.environ.setdefault("DJANGO_SETTINGS_MODULE", "integreat_cms.core.settings") +app = Celery("celery_app") +app.config_from_object("django.conf:settings", namespace="CELERY") +app.autodiscover_tasks() + + +# @app.task +# def wrapper_create_statistics(): +# """ +# Periodic task to generate region statistics +# """ +# print("create statistics") +# +# +# @app.on_after_configure.connect +# def setup_periodic_tasks(sender, **kwargs): +# """ +# Set up a periodic job to look for new videos +# """ +# sender.add_periodic_task( +# 84600, +# wrapper_create_statistics.s(), +# name="wrapper_create_statistics", +# ) diff --git a/integreat_cms/integreat_celery/migrations/__init__.py b/integreat_cms/integreat_celery/migrations/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/integreat_cms/locale/de/LC_MESSAGES/django.po b/integreat_cms/locale/de/LC_MESSAGES/django.po index 9b5749b470..6d86960a72 100644 --- a/integreat_cms/locale/de/LC_MESSAGES/django.po +++ b/integreat_cms/locale/de/LC_MESSAGES/django.po @@ -1368,6 +1368,46 @@ msgstr "Mandel" msgid "Cherry" msgstr "Kirsche" +#: cms/constants/language_color.py +msgid "Dusty orange" +msgstr "Stauborange" + +#: cms/constants/language_color.py +msgid "Dust storm" +msgstr "Staubsturm" + +#: cms/constants/language_color.py +msgid "Silver" +msgstr "Silber" + +#: cms/constants/language_color.py +msgid "Light gray" +msgstr "Hellgrau" + +#: cms/constants/language_color.py +msgid "Dark gray" +msgstr "Dunkelgrau" + +#: cms/constants/language_color.py +msgid "Deep pink" +msgstr "Tiefrosa" + +#: cms/constants/language_color.py +msgid "Dark yellowish green" +msgstr "Dunkelgelbliches Grün" + +#: cms/constants/language_color.py +msgid "Moderate reddish purple" +msgstr "Mittleres Purpurrot" + +#: cms/constants/language_color.py +msgid "Strong yellow" +msgstr "Starkes Gelb" + +#: cms/constants/language_color.py +msgid "Brilliant purplish blue" +msgstr "Leuchtendes Purpurblau" + #: cms/constants/linkcheck.py msgid "" "New Connection Error: Failed to establish a new connection: [Errno -2] Name " @@ -1640,9 +1680,9 @@ msgstr "Aktiv" msgid "Hidden" msgstr "Versteckt" -#: cms/constants/region_status.py cms/models/pages/page_translation.py -#: cms/models/regions/region.py cms/templates/events/event_form.html -#: cms/templates/pages/page_form.html +#: cms/constants/region_status.py cms/models/contact/contact.py +#: cms/models/pages/page_translation.py cms/models/regions/region.py +#: cms/templates/events/event_form.html cms/templates/pages/page_form.html #: cms/templates/pages/page_tree_archived_node.html #: cms/templates/pois/poi_form.html msgid "Archived" @@ -1715,7 +1755,6 @@ msgid "Right to left" msgstr "Rechts nach Links" #: cms/constants/translation_status.py cms/templates/_form_language_tabs.html -#: cms/templates/events/event_list_archived_row.html #: cms/templates/events/event_list_row.html #: cms/templates/imprint/imprint_sbs.html cms/templates/pages/page_sbs.html #: cms/templates/pages/page_tree_archived_node.html @@ -1724,7 +1763,6 @@ msgid "Translation up-to-date" msgstr "Übersetzung ist aktuell" #: cms/constants/translation_status.py cms/templates/_form_language_tabs.html -#: cms/templates/events/event_list_archived_row.html #: cms/templates/events/event_list_row.html #: cms/templates/imprint/imprint_sbs.html cms/templates/pages/page_sbs.html #: cms/templates/pages/page_tree_archived_node.html @@ -1733,7 +1771,6 @@ msgid "Currently in translation" msgstr "Wird derzeit übersetzt" #: cms/constants/translation_status.py cms/templates/_form_language_tabs.html -#: cms/templates/events/event_list_archived_row.html #: cms/templates/events/event_list_row.html #: cms/templates/imprint/imprint_sbs.html cms/templates/pages/page_sbs.html #: cms/templates/pages/page_tree_archived_node.html @@ -1742,7 +1779,6 @@ msgid "Translation outdated" msgstr "Übersetzung ist veraltet" #: cms/constants/translation_status.py cms/templates/_form_language_tabs.html -#: cms/templates/events/event_list_archived_row.html #: cms/templates/events/event_list_row.html #: cms/templates/pages/page_tree_archived_node.html #: cms/templates/pages/page_tree_node.html @@ -1961,7 +1997,7 @@ msgstr "Alle Regionen" #: cms/templates/feedback/region_feedback_list_archived.html #: cms/templates/languagetreenodes/languagetreenode_list.html #: cms/templates/pages/_page_xliff_import_diff.html -#: cms/templates/statistics/statistics_overview.html +#: cms/templates/statistics/statistics_sidebar.html #: cms/views/feedback/feedback_resource.py msgid "Language" msgstr "Sprache" @@ -1985,7 +2021,6 @@ msgid "Category" 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 @@ -2334,11 +2369,11 @@ msgstr "" #: cms/forms/regions/region_form.py msgid "" -"A Zammad URL and Access Token are required in order to enable the Integreat " -"Chat." +"A Zammad URL, Zammad Webhook Token and Access Token are required in order to " +"enable the Integreat Chat." msgstr "" -"Eine Zammad URL und ein Zugangstoken sind erforderlich um, den Integreat " -"Chat aktivieren." +"Eine Zammad URL, ein Zammad Webhook Token und ein Zugangstoken sind " +"erforderlich um, den Integreat Chat aktivieren." #: cms/forms/regions/region_form.py msgid "" @@ -2777,6 +2812,10 @@ msgstr "mit Telefonnummer: {}" msgid "with website: {}" msgstr "mit Website: {}" +#: cms/models/contact/contact.py +msgid "General contact information" +msgstr "Allgemeine Kontaktinformationen" + #: cms/models/contact/contact.py msgid "(Copy)" msgstr "(Kopie)" @@ -3450,7 +3489,6 @@ msgstr "Ob die Seite explizit archiviert ist oder nicht" #: cms/models/pages/abstract_base_page_translation.py #: cms/templates/events/event_form.html cms/templates/events/event_list.html -#: cms/templates/events/event_list_archived.html #: cms/templates/pages/page_form.html cms/templates/pages/page_tree.html #: cms/templates/pages/page_tree_archived.html cms/templates/pois/poi_form.html #: cms/templates/pois/poi_list.html cms/templates/pois/poi_list_archived.html @@ -4225,6 +4263,20 @@ msgstr "" "Teil der \"Agent\" Rolle sein und volle Gruppenberechtigungen für folgende " "Gruppe haben:" +#: cms/models/regions/region.py +msgid "Token used by Zammad webhook" +msgstr "Von Zammad Webhooks verwendeter Token" + +#: cms/models/regions/region.py +msgid "" +"Token used by Zammad webhooks to inform the Integreat CMS about changed " +"tickets. The token has to be appended with a token= GET parameter to the " +"webhook path." +msgstr "" +"Token der von Zammad Webhooks verwendet wird, um das Integreat CMS über " +"geänderte Tickets zu informieren. Der Token muss als token= GET parameter " +"dem Webhook-Pfad angefügt werden." + #: cms/models/regions/region.py msgid "Zammad chat handlers" msgstr "Zammad Chat-Zuständige" @@ -4462,6 +4514,7 @@ msgstr "Analyse" #: cms/templates/_base.html cms/templates/regions/region_form.html #: cms/templates/statistics/_statistics_widget.html #: cms/templates/statistics/statistics_overview.html +#: cms/templates/statistics/statistics_sidebar.html msgid "Statistics" msgstr "Statistiken" @@ -4722,7 +4775,6 @@ msgid "View content" msgstr "Inhalt ansehen" #: cms/templates/_related_contents_table.html -#: cms/templates/events/event_list_archived_row.html #: cms/templates/events/event_list_row.html #: cms/templates/pages/page_tree_archived_node.html #: cms/templates/pages/page_tree_node.html @@ -4797,6 +4849,10 @@ msgstr "Kontaktperson" msgid "Spoken Languages" msgstr "Gesprochene Sprachen" +#: cms/templates/_tinymce_config.html +msgid "Fax Number" +msgstr "Faxnummer" + #: cms/templates/_tinymce_config.html cms/templates/contacts/contact_form.html #: cms/templates/events/event_form.html cms/templates/hix_widget.html #: cms/templates/imprint/imprint_form.html @@ -4916,7 +4972,7 @@ msgstr "Detailansicht" #: cms/templates/analytics/_broken_links_widget.html #: cms/templates/chat/_chat_widget.html #: cms/templates/statistics/_statistics_widget.html -#: cms/templates/statistics/statistics_overview.html +#: cms/templates/statistics/statistics_chart.html #: cms/views/media/media_context_mixin.py msgid "A network error has occurred." msgstr "Ein Netzwerkfehler ist aufgetreten." @@ -4924,7 +4980,7 @@ msgstr "Ein Netzwerkfehler ist aufgetreten." #: cms/templates/analytics/_broken_links_widget.html #: cms/templates/chat/_chat_widget.html cms/templates/hix_widget.html #: cms/templates/statistics/_statistics_widget.html -#: cms/templates/statistics/statistics_overview.html +#: cms/templates/statistics/statistics_chart.html #: cms/views/media/media_context_mixin.py msgid "Please try again later." msgstr "Bitte versuchen Sie es später erneut." @@ -4932,14 +4988,14 @@ msgstr "Bitte versuchen Sie es später erneut." #: cms/templates/analytics/_broken_links_widget.html #: cms/templates/chat/_chat_widget.html #: cms/templates/statistics/_statistics_widget.html -#: cms/templates/statistics/statistics_overview.html +#: cms/templates/statistics/statistics_chart.html msgid "A server error has occurred." msgstr "Ein Serverfehler ist aufgetreten." #: cms/templates/analytics/_broken_links_widget.html #: cms/templates/chat/_chat_widget.html #: cms/templates/statistics/_statistics_widget.html -#: cms/templates/statistics/statistics_overview.html xliff/utils.py +#: cms/templates/statistics/statistics_chart.html xliff/utils.py msgid "Please contact the administrator." msgstr "Bitte kontaktieren Sie eine:n Administrator:in." @@ -4947,7 +5003,7 @@ msgstr "Bitte kontaktieren Sie eine:n Administrator:in." #: cms/templates/dashboard/_news_widget.html cms/templates/hix_widget.html #: cms/templates/pois/poi_form_sidebar/position_box.html #: cms/templates/statistics/_statistics_widget.html -#: cms/templates/statistics/statistics_overview.html +#: cms/templates/statistics/statistics_chart.html msgid "Loading..." msgstr "Laden..." @@ -5352,7 +5408,6 @@ msgstr "Webseite" #: cms/templates/contacts/contact_list.html #: cms/templates/events/event_list.html -#: cms/templates/events/event_list_archived.html #: cms/templates/languages/language_list.html #: cms/templates/linkcheck/links_by_filter.html #: cms/templates/offertemplates/offertemplate_list.html @@ -5377,7 +5432,6 @@ msgstr "Kontakte ausgewählt" #: cms/templates/contacts/contact_list.html #: cms/templates/events/event_list.html -#: cms/templates/events/event_list_archived.html #: cms/templates/feedback/admin_feedback_list.html #: cms/templates/feedback/admin_feedback_list_archived.html #: cms/templates/feedback/region_feedback_list.html @@ -5405,7 +5459,6 @@ msgstr "Kontakte löschen" #: cms/templates/contacts/contact_list.html #: cms/templates/events/event_list.html -#: cms/templates/events/event_list_archived.html #: cms/templates/feedback/admin_feedback_list.html #: cms/templates/feedback/admin_feedback_list_archived.html #: cms/templates/feedback/region_feedback_list.html @@ -5819,7 +5872,6 @@ msgstr "Zeitraum" #: cms/templates/events/_event_filter_form.html #: cms/templates/events/event_list.html -#: cms/templates/events/event_list_archived.html msgid "Event location" msgstr "Veranstaltungsort" @@ -5911,7 +5963,7 @@ msgid "Actions" msgstr "Aktionen" #: cms/templates/events/event_form_sidebar/actions_box.html -#: cms/templates/events/event_list_archived_row.html +#: cms/templates/events/event_list_row.html msgid "Restore event" msgstr "Veranstaltung wiederherstellen" @@ -5929,7 +5981,6 @@ msgid "Archive this event" msgstr "Diese Veranstaltung archivieren" #: cms/templates/events/event_form_sidebar/actions_box.html -#: cms/templates/events/event_list_archived_row.html #: cms/templates/events/event_list_row.html msgid "Delete event" msgstr "Veranstaltung löschen" @@ -5949,12 +6000,14 @@ msgid "Select all languages" msgstr "Alle Sprachen auswählen" #: cms/templates/events/event_list.html -#: cms/templates/events/event_list_archived.html msgid "Archived events" msgstr "Archivierte Veranstaltungen" #: cms/templates/events/event_list.html -#: cms/templates/events/event_list_archived.html +msgid "Back to events" +msgstr "Zurück zu Veranstaltungen" + +#: cms/templates/events/event_list.html #: cms/templates/feedback/admin_feedback_list.html #: cms/templates/feedback/admin_feedback_list_archived.html #: cms/templates/feedback/region_feedback_list.html @@ -5966,7 +6019,6 @@ msgid "Show filters" msgstr "Filter einblenden" #: cms/templates/events/event_list.html -#: cms/templates/events/event_list_archived.html #: cms/templates/feedback/admin_feedback_list.html #: cms/templates/feedback/admin_feedback_list_archived.html #: cms/templates/feedback/region_feedback_list.html @@ -5990,17 +6042,14 @@ msgstr "" "(%(default_language)s) anlegen." #: cms/templates/events/event_list.html -#: cms/templates/events/event_list_archived.html msgid "Start" msgstr "Beginn" #: cms/templates/events/event_list.html -#: cms/templates/events/event_list_archived.html msgid "End" msgstr "Ende" #: cms/templates/events/event_list.html -#: cms/templates/events/event_list_archived.html msgid "Recurrence" msgstr "Wiederholung" @@ -6012,10 +6061,18 @@ msgstr "Externer Kalender" msgid "No events found with these filters." msgstr "Keine Veranstaltungen mit diesen Filtern gefunden." +#: cms/templates/events/event_list.html +msgid "No archived events found with these filters." +msgstr "Keine archivierten Veranstaltungen mit diesen Filtern gefunden." + #: cms/templates/events/event_list.html msgid "No upcoming events available." msgstr "Keine zukünftigen Veranstaltungen vorhanden." +#: cms/templates/events/event_list.html +msgid "No upcoming events archived." +msgstr "Keine zukünftigen Veranstaltungen archiviert." + #: cms/templates/events/event_list.html msgid "Events selected" msgstr "Veranstaltungen ausgewählt" @@ -6057,28 +6114,14 @@ msgstr "" msgid "Machine translate %(content_type)s to %(language)s" msgstr "%(content_type)s maschinell auf %(language)s übersetzen" -#: cms/templates/events/event_list_archived.html -msgid "Back to events" -msgstr "Zurück zu Veranstaltungen" - -#: cms/templates/events/event_list_archived.html -msgid "No archived events found with these filters." -msgstr "Keine archivierten Veranstaltungen mit diesen Filtern gefunden." - -#: cms/templates/events/event_list_archived.html -msgid "No upcoming events archived." -msgstr "Keine zukünftigen Veranstaltungen archiviert." - -#: cms/templates/events/event_list_archived.html +#: cms/templates/events/event_list.html msgid "Restore events" msgstr "Veranstaltungen wiederherstellen" -#: cms/templates/events/event_list_archived_row.html #: cms/templates/events/event_list_row.html msgid "Not specified" msgstr "Nicht festgelegt" -#: cms/templates/events/event_list_archived_row.html #: cms/templates/events/event_list_row.html msgid "One-time" msgstr "Einmalig" @@ -6749,16 +6792,16 @@ msgid "Valid" msgstr "Gültig" #: cms/templates/linkcheck/links_by_filter.html -msgid "Invalid" -msgstr "Ungültig" +msgid "Verification needed" +msgstr "Überprüfung notwendig" #: cms/templates/linkcheck/links_by_filter.html msgid "Unchecked" msgstr "Nicht geprüft" #: cms/templates/linkcheck/links_by_filter.html -msgid "Ignored" -msgstr "Ignoriert" +msgid "Link verified" +msgstr "Link überprüft" #: cms/templates/linkcheck/links_by_filter.html msgid "" @@ -6854,16 +6897,16 @@ msgid "Links selected" msgstr "Links ausgewählt" #: cms/templates/linkcheck/links_by_filter.html -msgid "Unignore" -msgstr "Nicht ignorieren" +msgid "Revoke verification" +msgstr "Verifizierung entziehen" #: cms/templates/linkcheck/links_by_filter.html msgid "Recheck" msgstr "Erneut prüfen" #: cms/templates/linkcheck/links_by_filter.html -msgid "Ignore" -msgstr "Ignorieren" +msgid "Mark as verified" +msgstr "Als überprüft markieren" #: cms/templates/linkcheck/search_and_replace_link.html msgid "Search & replace in links" @@ -7018,6 +7061,18 @@ msgstr "Noch keine Organisationen vorhanden." msgid "Organizations selected" msgstr "Organisationen ausgewählt" +#: cms/templates/organizations/organization_list.html +msgid "Restore organizations" +msgstr "Organisationen wiederherstellen" + +#: cms/templates/organizations/organization_list.html +msgid "Archive organizations" +msgstr "Organisationen archivieren" + +#: cms/templates/organizations/organization_list.html +msgid "Delete organizations" +msgstr "Organisationen löschen" + #: cms/templates/organizations/organization_list_row.html msgid "Restore organization" msgstr "Organisation wiederherstellen" @@ -7709,6 +7764,18 @@ msgstr "Nur Koordinaten übernehmen" msgid "Please choose one option or cancel" msgstr "Bitte eine Option auswählen oder abbrechen" +#: cms/templates/pois/poi_form_sidebar/related_contacts_box.html +msgid "Related contacts" +msgstr "Zugehörige Kontakte" + +#: cms/templates/pois/poi_form_sidebar/related_contacts_box.html +msgid "This location is currently referred to by the following contacts." +msgstr "Dieser Ort wird in folgenden Kontakten verwendet." + +#: cms/templates/pois/poi_form_sidebar/related_contacts_box.html +msgid "This location is not currently referred to in any contact." +msgstr "Zur Zeit gibt es keine Kontakte zu diesem Ort." + #: cms/templates/pois/poi_list.html cms/templates/pois/poi_list_archived.html msgid "Archived locations" msgstr "Archivierte Orte" @@ -8385,43 +8452,43 @@ msgid "Number of total accesses over the last 14 days." msgstr "Anzahl der Gesamtzugriffe der letzten 14 Tage." #: cms/templates/statistics/_statistics_widget.html -#: cms/templates/statistics/statistics_overview.html +#: cms/templates/statistics/statistics_chart.html msgid "The statistics network is currently experiencing heavy traffic." msgstr "Der Statistik-Server hat aktuell eine hohe Auslastung." -#: cms/templates/statistics/statistics_overview.html +#: cms/templates/statistics/statistics_chart.html msgid "Total accesses" msgstr "Gesamtzugriffe" -#: cms/templates/statistics/statistics_overview.html +#: cms/templates/statistics/statistics_sidebar.html msgid "Adjust shown data" msgstr "Angezeigte Daten anpassen" -#: cms/templates/statistics/statistics_overview.html +#: cms/templates/statistics/statistics_sidebar.html msgid "Adjust time period" msgstr "Zeitraum anpassen" -#: cms/templates/statistics/statistics_overview.html +#: cms/templates/statistics/statistics_sidebar.html msgid "Customize view" msgstr "Ansicht anpassen" -#: cms/templates/statistics/statistics_overview.html +#: cms/templates/statistics/statistics_sidebar.html msgid "Export" msgstr "Exportieren" -#: cms/templates/statistics/statistics_overview.html +#: cms/templates/statistics/statistics_sidebar.html msgid "Choose format" msgstr "Format auswählen" -#: cms/templates/statistics/statistics_overview.html +#: cms/templates/statistics/statistics_sidebar.html msgid "please select" msgstr "bitte auswählen" -#: cms/templates/statistics/statistics_overview.html +#: cms/templates/statistics/statistics_sidebar.html msgid "Image/PNG" msgstr "Bild/PNG" -#: cms/templates/statistics/statistics_overview.html +#: cms/templates/statistics/statistics_sidebar.html msgid "Table Document/CSV" msgstr "Tabellendokument/CSV" @@ -8748,6 +8815,10 @@ msgstr "" msgid "Could not import '{}': {}" msgstr "'{}' konnte nicht importiert werden: {}" +#: cms/utils/external_calendar_utils.py +msgid "Could not import '{}': Unsupported recurrence rule" +msgstr "'{}' konnte nicht importiert werden: Nicht unterstützte Wiederholung" + #: cms/utils/internal_link_checker.py msgid "Imprint does not exist or is not public in this language" msgstr "" @@ -8971,6 +9042,7 @@ msgid "The selected {} were successfully {}" msgstr "Die ausgewählten {} wurden erfolgreich {}" #: cms/views/bulk_action_views.py cms/views/contacts/contact_bulk_actions.py +#: cms/views/organizations/organization_bulk_actions.py #, python-brace-format msgid "{model_name} {object_names} was successfully archived." msgid_plural "" @@ -9020,6 +9092,7 @@ msgstr[1] "" "Veranstaltung oder einem Kontakt verwendet werden: \"{object_names}\"" #: cms/views/bulk_action_views.py cms/views/contacts/contact_bulk_actions.py +#: cms/views/organizations/organization_bulk_actions.py #, python-brace-format msgid "{model_name} {object_names} was successfully restored." msgid_plural "" @@ -9072,6 +9145,7 @@ msgid "Contact {0} was successfully copied" msgstr "Kontakt {0} wurde erfolgreich kopiert" #: cms/views/contacts/contact_bulk_actions.py +#: cms/views/organizations/organization_bulk_actions.py #, python-brace-format msgid "{model_name} {object_names} was successfully deleted." msgid_plural "" @@ -9786,6 +9860,36 @@ msgstr "" "Die Organisation konnte nicht gelöscht werden, da sie von einer Seite, einem " "Ort oder eines Users verwendet wird" +#: cms/views/organizations/organization_bulk_actions.py +#, python-brace-format +msgid "" +"{model_name} {object_names} couldn't be archived as it's used by a page, poi " +"or user." +msgid_plural "" +"The following {model_name_plural} couldn't be archived as they're used by a " +"page, poi or user: {object_names}." +msgstr[0] "" +"Die {model_name} {object_names} konnte nicht archiviert werden, da sie von " +"einer Seite, einem Ort oder eines Users verwendet wird" +msgstr[1] "" +"Die folgenden {model_name_plural} konnten nicht archiviert werden, da sie " +"von einer Seite, einem Ort oder eines Users verwendet werden: {object_names}" + +#: cms/views/organizations/organization_bulk_actions.py +#, python-brace-format +msgid "" +"{model_name} {object_names} couldn't be deleted as it's used by a page, poi " +"or user." +msgid_plural "" +"The following {model_name_plural} couldn't be deleted as they're used by a " +"page, poi or user: {object_names}." +msgstr[0] "" +"Die {model_name} {object_names} konnte nicht gelöscht werden, da sie von " +"einer Seite, einem Ort oder eines Users verwendet wird" +msgstr[1] "" +"Die folgenden {model_name_plural} konnten nicht gelöscht werden, da sie von " +"einer Seite, einem Ort oder eines Users verwendet werden: {object_names}" + #: cms/views/organizations/organization_content_mixin.py msgid "Please confirm that you really want to archive this organization" msgstr "Bitte bestätigen Sie, dass diese Organisation archiviert werden soll." @@ -11033,17 +11137,27 @@ msgstr "" "Diese Seite konnte nicht importiert werden, da sie zu einer anderen Region " "gehört ({})." +#~ msgid "Invalid" +#~ msgstr "Ungültig" + +#~ msgid "Ignored" +#~ msgstr "Ignoriert" + +#~ msgid "Unignore" +#~ msgstr "Nicht ignorieren" + +#~ msgid "Ignore" +#~ msgstr "Ignorieren" + #~ msgid "{}{}" #~ msgstr "{}{}" #~ msgid " " #~ msgstr " " -#, python-format #~ msgid "%s %s" #~ msgstr "%s %s" -#, python-format #~ msgid "%s with %s" #~ msgstr "%s mit %s" @@ -11476,9 +11590,6 @@ msgstr "" #~ msgid "Broken redirect" #~ msgstr "Fehlerhafte Weiterleitung" -#~ msgid "Email link (not automatically checked)" -#~ msgstr "E-Mail-Link (nicht automatisch geprüft)" - #~ msgid "Empty link" #~ msgstr "Leerer Link" diff --git a/integreat_cms/release_notes/current/unreleased/1274.yml b/integreat_cms/release_notes/current/unreleased/1274.yml new file mode 100644 index 0000000000..3fa5a44ddd --- /dev/null +++ b/integreat_cms/release_notes/current/unreleased/1274.yml @@ -0,0 +1,2 @@ +en: Update the tinymce editor from version 5 to 7 +de: Aktualisiere den tinymce Texteditor von Version 5 zu 7 diff --git a/integreat_cms/release_notes/current/unreleased/2588.yml b/integreat_cms/release_notes/current/unreleased/2588.yml new file mode 100644 index 0000000000..e4daf8fe76 --- /dev/null +++ b/integreat_cms/release_notes/current/unreleased/2588.yml @@ -0,0 +1,2 @@ +en: Improve link checker GUI +de: Verbessere die Benutzungsoberfläche des Link-Checkers diff --git a/integreat_cms/release_notes/current/unreleased/2858.yml b/integreat_cms/release_notes/current/unreleased/2858.yml new file mode 100644 index 0000000000..cedd1bbfb8 --- /dev/null +++ b/integreat_cms/release_notes/current/unreleased/2858.yml @@ -0,0 +1,2 @@ +en: Add additional language colors +de: Füge zusätzliche Sprachfarben hinzu diff --git a/integreat_cms/release_notes/current/unreleased/2950.yml b/integreat_cms/release_notes/current/unreleased/2950.yml new file mode 100644 index 0000000000..08d7613df0 --- /dev/null +++ b/integreat_cms/release_notes/current/unreleased/2950.yml @@ -0,0 +1,2 @@ +en: Add bulk actions for organizations. +de: Füge Mehrfachaktionen für Organisationen hinzu. diff --git a/integreat_cms/release_notes/current/unreleased/3118.yml b/integreat_cms/release_notes/current/unreleased/3118.yml new file mode 100644 index 0000000000..d5014f1729 --- /dev/null +++ b/integreat_cms/release_notes/current/unreleased/3118.yml @@ -0,0 +1,2 @@ +en: Fix error at bulk action in the broken link list +de: Behebe Fehler bei Mehrfachaktionen in Fehlerhafte Links diff --git a/integreat_cms/release_notes/current/unreleased/3131.yml b/integreat_cms/release_notes/current/unreleased/3131.yml new file mode 100644 index 0000000000..f4b451493f --- /dev/null +++ b/integreat_cms/release_notes/current/unreleased/3131.yml @@ -0,0 +1,2 @@ +en: Make access number box of statistics collapsible +de: Mache Zugriffsnummernfeld der Statistik zusammenklappbar diff --git a/integreat_cms/release_notes/current/unreleased/3190.yml b/integreat_cms/release_notes/current/unreleased/3190.yml new file mode 100644 index 0000000000..246a1eb362 --- /dev/null +++ b/integreat_cms/release_notes/current/unreleased/3190.yml @@ -0,0 +1,2 @@ +en: Fix display of start and end of archived events +de: Korrigiere die Anzeige der Start- und Endzeit für archivierte Veranstaltungen diff --git a/integreat_cms/release_notes/current/unreleased/3192.yml b/integreat_cms/release_notes/current/unreleased/3192.yml new file mode 100644 index 0000000000..37540d838b --- /dev/null +++ b/integreat_cms/release_notes/current/unreleased/3192.yml @@ -0,0 +1,2 @@ +en: Replace unique Zammad URL constraint with Zammad webhook token +de: Ersetze eindeutige Zammad-URL-Bedingung durch Zammad Webhook Token diff --git a/integreat_cms/release_notes/current/unreleased/3213.yml b/integreat_cms/release_notes/current/unreleased/3213.yml new file mode 100644 index 0000000000..5c2300e75e --- /dev/null +++ b/integreat_cms/release_notes/current/unreleased/3213.yml @@ -0,0 +1,2 @@ +en: Fix false positive error in statistics in dashboard +de: Behebe den Netzwerkfehler in den Statistiken im Dashboard diff --git a/integreat_cms/release_notes/current/unreleased/3215.yml b/integreat_cms/release_notes/current/unreleased/3215.yml new file mode 100644 index 0000000000..4b3dba257f --- /dev/null +++ b/integreat_cms/release_notes/current/unreleased/3215.yml @@ -0,0 +1,2 @@ +en: Fix spacing error in list of archived events +de: Behebe Abstandsfehler in der Liste der archivierten Veranstaltungen diff --git a/integreat_cms/release_notes/current/unreleased/3225.yml b/integreat_cms/release_notes/current/unreleased/3225.yml new file mode 100644 index 0000000000..a5db392acc --- /dev/null +++ b/integreat_cms/release_notes/current/unreleased/3225.yml @@ -0,0 +1,2 @@ +en: Add icon for fax numbers +de: Füge Icon für Faxnummern hinzu diff --git a/integreat_cms/static/src/js/analytics/statistics-charts.ts b/integreat_cms/static/src/js/analytics/statistics-charts.ts index 5ee9a4b333..1d32dec86d 100644 --- a/integreat_cms/static/src/js/analytics/statistics-charts.ts +++ b/integreat_cms/static/src/js/analytics/statistics-charts.ts @@ -95,7 +95,7 @@ const updateChart = async (): Promise => { } const items = chart.options.plugins.legend.labels.generateLabels(chart); items.forEach((item) => { - document.querySelector(`[data-chart-item="${item.text}"]`).addEventListener("change", () => { + document.querySelector(`[data-chart-item="${item.text}"]`)?.addEventListener("change", () => { chart.setDatasetVisibility(item.datasetIndex, !chart.isDatasetVisible(item.datasetIndex)); chart.update(); }); diff --git a/integreat_cms/static/src/js/forms/tinymce-init.ts b/integreat_cms/static/src/js/forms/tinymce-init.ts index e164fea974..6f9b60f862 100644 --- a/integreat_cms/static/src/js/forms/tinymce-init.ts +++ b/integreat_cms/static/src/js/forms/tinymce-init.ts @@ -3,18 +3,17 @@ import tinymce, { Editor } from "tinymce"; import "tinymce/icons/default"; import "tinymce/themes/silver"; +import "tinymce/models/dom"; import "tinymce/plugins/fullscreen"; import "tinymce/plugins/autosave"; import "tinymce/plugins/charmap"; import "tinymce/plugins/code"; import "tinymce/plugins/directionality"; -import "tinymce/plugins/hr"; import "tinymce/plugins/image"; import "tinymce/plugins/link"; import "tinymce/plugins/lists"; import "tinymce/plugins/media"; -import "tinymce/plugins/paste"; import "tinymce/plugins/preview"; import "tinymce/plugins/wordcount"; import "tinymce-i18n/langs/de.js"; @@ -72,6 +71,8 @@ window.addEventListener("load", () => { if (tinymceConfig) { tinymce.init({ selector: ".tinymce_textarea", + license_key: "gpl", + promotion: false, deprecation_warnings: false, menubar: "edit view insert format icon", menu: { @@ -81,11 +82,11 @@ window.addEventListener("load", () => { }, icon: { title: "Icons", - items: "pin www email call clock idea group contact speech", + items: "pin www email call fax clock idea group contact speech", }, format: { title: "Format", - items: "bold italic underline strikethrough superscript | formats | forecolor backcolor | notranslate", + items: "bold italic underline strikethrough superscript | styles | forecolor backcolor | notranslate", }, insert: { title: "Insert", @@ -94,23 +95,22 @@ window.addEventListener("load", () => { }, link_title: false, autosave_interval: "120s", - forced_root_block: true, - plugins: "code fullscreen autosave preview media image lists directionality wordcount hr charmap paste", + plugins: "code fullscreen autosave preview media image lists directionality wordcount charmap", external_plugins: { autolink_tel: tinymceConfig.getAttribute("data-custom-plugins"), mediacenter: tinymceConfig.getAttribute("data-custom-plugins"), custom_link_input: tinymceConfig.getAttribute("data-custom-plugins"), }, link_default_protocol: "https", - target_list: false, - default_link_target: "", + link_target_list: false, + link_default_target: "", document_base_url: tinymceConfig.getAttribute("data-webapp-url"), relative_urls: false, remove_script_host: false, branding: false, paste_as_text: true, toolbar: - "bold italic underline forecolor | bullist numlist | styleselect | removeformat | undo redo | ltr rtl notranslate | aligncenter indent outdent | link openmediacenter | export ", + "bold italic underline forecolor | bullist numlist | styles | removeformat | undo redo | ltr rtl notranslate | aligncenter indent outdent | link openmediacenter | export ", style_formats: [ { title: "Headings", @@ -181,6 +181,7 @@ window.addEventListener("load", () => { addIcon(editor, tinymceConfig, "group", "meta+alt+7", ""); addIcon(editor, tinymceConfig, "contact", "meta+alt+8", ""); addIcon(editor, tinymceConfig, "speech", "meta+alt+9", ""); + addIcon(editor, tinymceConfig, "fax", "meta+alt+0", ""); /* eslint-disable-next-line @typescript-eslint/no-var-requires, global-require */ editor.ui.registry.addIcon("no-translate", parseSvg(require(`../../svg/no-translate.svg`))); editor.ui.registry.addButton("notranslate", { @@ -223,6 +224,9 @@ window.addEventListener("load", () => { editor.shortcuts.add("meta+alt+9", "Add speech bubble icon", () => { insertIcon(editor, tinymceConfig, "speech"); }); + editor.shortcuts.add("meta+alt+0", "Add fax icon", () => { + insertIcon(editor, tinymceConfig, "fax"); + }); document.querySelectorAll("[data-content-changed]").forEach((element) => { element.dispatchEvent(new Event("tinyMCEInitialized")); }); diff --git a/integreat_cms/static/src/js/pages/sbs-copy-content.ts b/integreat_cms/static/src/js/pages/sbs-copy-content.ts index f806a1eb98..0e2f54699d 100644 --- a/integreat_cms/static/src/js/pages/sbs-copy-content.ts +++ b/integreat_cms/static/src/js/pages/sbs-copy-content.ts @@ -9,7 +9,7 @@ window.addEventListener("load", () => { const copyTranslation = document.getElementById("copy-translation-content"); const sourceTinyMce = tinymce.get("source_translation_tinymce"); if (sourceTinyMce) { - sourceTinyMce.setMode("readonly"); + sourceTinyMce.mode.set("readonly"); } if (copyTranslation) { diff --git a/integreat_cms/static/src/js/tinymce-plugins/custom_link_input/plugin.js b/integreat_cms/static/src/js/tinymce-plugins/custom_link_input/plugin.js index 7cf2660c04..cc04d050dd 100644 --- a/integreat_cms/static/src/js/tinymce-plugins/custom_link_input/plugin.js +++ b/integreat_cms/static/src/js/tinymce-plugins/custom_link_input/plugin.js @@ -89,7 +89,7 @@ import { getCsrfToken } from "../../utils/csrf-token"; let data = api.getData(); if (data.autoupdate) { - api.disable("text"); + api.setEnabled("text", false); if (!prevAutoupdateValue) { api.setData({ url: "", @@ -97,7 +97,7 @@ import { getCsrfToken } from "../../utils/csrf-token"; }); } } else { - api.enable("text"); + api.setEnabled("text", true); } prevAutoupdateValue = data.autoupdate; @@ -155,11 +155,9 @@ import { getCsrfToken } from "../../utils/csrf-token"; // Disable the submit button if either one of the url or text are empty data = api.getData(); - if (data.url.trim() && (textDisabled || data.text.trim())) { - api.enable("submit"); - } else { - api.disable("submit"); - } + + const enableSubmit = data.url.trim() && (textDisabled || data.text.trim()); + api.setEnabled("submit", enableSubmit); // make new ajax request on user input if (data.search !== prevSearchText && data.search !== "") { @@ -191,11 +189,7 @@ import { getCsrfToken } from "../../utils/csrf-token"; api.focus("search"); prevSearchText = data.search; - if (completionDisabled) { - api.disable("completions"); - } else { - api.enable("completions"); - } + api.setEnabled("completions", !completionDisabled); updateDialog(api); }); @@ -327,7 +321,7 @@ import { getCsrfToken } from "../../utils/csrf-token"; primary: true, onSetup: (buttonApi) => { const nodeChangeHandler = () => { - buttonApi.setDisabled(editor.readonly); + buttonApi.setEnabled(!editor.readonly); }; editor.on("nodechange", nodeChangeHandler); return () => { diff --git a/integreat_cms/static/src/svg/fax.svg b/integreat_cms/static/src/svg/fax.svg new file mode 100644 index 0000000000..42d5b379ec --- /dev/null +++ b/integreat_cms/static/src/svg/fax.svg @@ -0,0 +1,11 @@ + + + + + + + + + + diff --git a/package-lock.json b/package-lock.json index d6b528edd5..2b1c92c2d3 100644 --- a/package-lock.json +++ b/package-lock.json @@ -64,7 +64,7 @@ "style-loader": "^4.0.0", "tailwindcss": "^3.4.3", "terser-webpack-plugin": "^5.3.10", - "tinymce": "^5.10.9", + "tinymce": "^7.3.0", "tinymce-i18n": "^24.6.3", "ts-loader": "^9.5.1", "typescript": "^5.4.5", @@ -18127,11 +18127,11 @@ "license": "MIT" }, "node_modules/tinymce": { - "version": "5.10.9", - "resolved": "https://registry.npmjs.org/tinymce/-/tinymce-5.10.9.tgz", - "integrity": "sha512-5bkrors87X9LhYX2xq8GgPHrIgJYHl87YNs+kBcjQ5I3CiUgzo/vFcGvT3MZQ9QHsEeYMhYO6a5CLGGffR8hMg==", + "version": "7.3.0", + "resolved": "https://registry.npmjs.org/tinymce/-/tinymce-7.3.0.tgz", + "integrity": "sha512-Ls4PgYlpk73XAxBSBqbVmSl8Mb3DuNfgF01GZ0lY6/MOEVRl3IL+VxC1Oe6165e8WqbqVsxO3Qj/PmoYNvQKGQ==", "dev": true, - "license": "LGPL-2.1" + "license": "GPL-2.0-or-later" }, "node_modules/tinymce-i18n": { "version": "24.9.30", diff --git a/package.json b/package.json index f15c98793f..0523596f2d 100644 --- a/package.json +++ b/package.json @@ -66,7 +66,7 @@ "style-loader": "^4.0.0", "tailwindcss": "^3.4.3", "terser-webpack-plugin": "^5.3.10", - "tinymce": "^5.10.9", + "tinymce": "^7.3.0", "tinymce-i18n": "^24.6.3", "ts-loader": "^9.5.1", "typescript": "^5.4.5", diff --git a/pyproject.toml b/pyproject.toml index a6bff8cc45..e82c411f6b 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -39,10 +39,12 @@ dependencies = [ "aiohttp", "argon2-cffi", "bcrypt", + "celery", "cffi", "deepl", "Django>=4.2,<5.0", "django-cacheops", + "django-celery", "django-cors-headers", "django-db-mutex", "django-debug-toolbar", diff --git a/tests/api/test_api_chat.py b/tests/api/test_api_chat.py index 2fa116e101..8d87202173 100644 --- a/tests/api/test_api_chat.py +++ b/tests/api/test_api_chat.py @@ -64,7 +64,9 @@ def test_api_chat_incorrect_auth_error(load_test_data: None) -> None: :param load_test_data: The fixture providing the test data (see :meth:`~tests.conftest.load_test_data`) """ mock_api = MagicMock() - with patch("integreat_cms.api.v3.chat.zammad_api.ZammadAPI", return_value=mock_api): + with patch( + "integreat_cms.api.v3.chat.utils.zammad_api.ZammadAPI", return_value=mock_api + ): mock_api.user.me.side_effect = HTTPError() client = Client() @@ -88,7 +90,9 @@ def test_api_chat_first_chat(load_test_data: None) -> None: :param load_test_data: The fixture providing the test data (see :meth:`~tests.conftest.load_test_data`) """ mock_api = MagicMock() - with patch("integreat_cms.api.v3.chat.zammad_api.ZammadAPI", return_value=mock_api): + with patch( + "integreat_cms.api.v3.chat.utils.zammad_api.ZammadAPI", return_value=mock_api + ): mock_api.user.all.return_value = [] mock_api.user.me.return_value = {"login": "bot-user"} mock_api.ticket.create.return_value = {"id": 111} @@ -112,7 +116,9 @@ def test_api_chat_force_new_chat(load_test_data: None) -> None: :param load_test_data: The fixture providing the test data (see :meth:`~tests.conftest.load_test_data`) """ mock_api = MagicMock() - with patch("integreat_cms.api.v3.chat.zammad_api.ZammadAPI", return_value=mock_api): + with patch( + "integreat_cms.api.v3.chat.utils.zammad_api.ZammadAPI", return_value=mock_api + ): mock_api.user.all.return_value = [] mock_api.user.me.return_value = {"login": "bot-user"} mock_api.ticket.create.return_value = {"id": 222} @@ -139,7 +145,9 @@ def test_api_chat_send_message(load_test_data: None) -> None: """ mock_api = MagicMock() previous_chat = UserChat.objects.current_chat(default_kwargs["device_id"]).zammad_id - with patch("integreat_cms.api.v3.chat.zammad_api.ZammadAPI", return_value=mock_api): + with patch( + "integreat_cms.api.v3.chat.utils.zammad_api.ZammadAPI", return_value=mock_api + ): mock_api.user.all.return_value = [] mock_api.user.me.return_value = {"login": "bot-user"} mock_api.ticket_article.create.return_value = {} @@ -166,7 +174,9 @@ def test_api_chat_get_messages_success(load_test_data: None) -> None: :param load_test_data: The fixture providing the test data (see :meth:`~tests.conftest.load_test_data`) """ mock_api = MagicMock() - with patch("integreat_cms.api.v3.chat.zammad_api.ZammadAPI", return_value=mock_api): + with patch( + "integreat_cms.api.v3.chat.utils.zammad_api.ZammadAPI", return_value=mock_api + ): mock_api.user.all.return_value = [] mock_api.user.me.return_value = {"login": "bot-user"} mock_api.ticket.articles.return_value = [] @@ -189,7 +199,9 @@ def test_api_chat_get_messages_failure(load_test_data: None) -> None: :param load_test_data: The fixture providing the test data (see :meth:`~tests.conftest.load_test_data`) """ mock_api = MagicMock() - with patch("integreat_cms.api.v3.chat.zammad_api.ZammadAPI", return_value=mock_api): + with patch( + "integreat_cms.api.v3.chat.utils.zammad_api.ZammadAPI", return_value=mock_api + ): mock_api.user.all.return_value = [] mock_api.user.me.return_value = {"login": "bot-user"} @@ -214,7 +226,9 @@ def test_api_chat_create_attachment_success(load_test_data: None) -> None: :param load_test_data: The fixture providing the test data (see :meth:`~tests.conftest.load_test_data`) """ mock_api = MagicMock() - with patch("integreat_cms.api.v3.chat.zammad_api.ZammadAPI", return_value=mock_api): + with patch( + "integreat_cms.api.v3.chat.utils.zammad_api.ZammadAPI", return_value=mock_api + ): mock_api.user.all.return_value = [] mock_api.user.me.return_value = {"login": "bot-user"} mock_api.ticket.articles.return_value = [ @@ -253,7 +267,9 @@ def test_api_chat_get_attachment_success(load_test_data: None) -> None: :param load_test_data: The fixture providing the test data (see :meth:`~tests.conftest.load_test_data`) """ mock_api = MagicMock() - with patch("integreat_cms.api.v3.chat.zammad_api.ZammadAPI", return_value=mock_api): + with patch( + "integreat_cms.api.v3.chat.utils.zammad_api.ZammadAPI", return_value=mock_api + ): mock_api.user.all.return_value = [] mock_api.user.me.return_value = {"login": "bot-user"} mock_api.ticket_article_attachment.download.return_value = b"\00" @@ -279,7 +295,9 @@ def test_api_chat_get_attachment_incorrect_chat_failure(load_test_data: None) -> :param load_test_data: The fixture providing the test data (see :meth:`~tests.conftest.load_test_data`) """ mock_api = MagicMock() - with patch("integreat_cms.api.v3.chat.zammad_api.ZammadAPI", return_value=mock_api): + with patch( + "integreat_cms.api.v3.chat.utils.zammad_api.ZammadAPI", return_value=mock_api + ): mock_api.user.all.return_value = [] mock_api.user.me.return_value = {"login": "bot-user"} @@ -308,7 +326,9 @@ def test_api_chat_get_attachment_missing_attachment_failure( :param load_test_data: The fixture providing the test data (see :meth:`~tests.conftest.load_test_data`) """ mock_api = MagicMock() - with patch("integreat_cms.api.v3.chat.zammad_api.ZammadAPI", return_value=mock_api): + with patch( + "integreat_cms.api.v3.chat.utils.zammad_api.ZammadAPI", return_value=mock_api + ): mock_api.user.all.return_value = [] mock_api.user.me.return_value = {"login": "bot-user"} @@ -331,7 +351,9 @@ def test_api_chat_ratelimiting(load_test_data: None) -> None: :param load_test_data: The fixture providing the test data (see :meth:`~tests.conftest.load_test_data`) """ mock_api = MagicMock() - with patch("integreat_cms.api.v3.chat.zammad_api.ZammadAPI", return_value=mock_api): + with patch( + "integreat_cms.api.v3.chat.utils.zammad_api.ZammadAPI", return_value=mock_api + ): mock_api.user.all.return_value = [] mock_api.user.me.return_value = {"login": "bot-user"} mock_api.ticket.create.return_value = {"id": 333} diff --git a/tests/cms/views/organizations/test_organization_actions.py b/tests/cms/views/organizations/test_organization_actions.py new file mode 100644 index 0000000000..a86c568b88 --- /dev/null +++ b/tests/cms/views/organizations/test_organization_actions.py @@ -0,0 +1,408 @@ +from __future__ import annotations + +from typing import TYPE_CHECKING + +if TYPE_CHECKING: + from _pytest.logging import LogCaptureFixture + from django.test.client import Client + from pytest_django.fixtures import SettingsWrapper + +import pytest +from django.test.client import Client +from django.urls import reverse + +from integreat_cms.cms.models import Organization +from tests.conftest import ANONYMOUS, HIGH_PRIV_STAFF_ROLES, MANAGEMENT +from tests.utils import assert_message_in_log + +REGION_SLUG = "augsburg" + +REFERENCED_ORGANIZATION_ID = 1 +NOT_REFERENCED_ORGANIZATION_ID = 3 +ARCHIVED_ORGANIZATION_ID = 2 + +test_archive_parameters = [ + (REFERENCED_ORGANIZATION_ID, False), + (NOT_REFERENCED_ORGANIZATION_ID, True), +] + + +@pytest.mark.django_db +@pytest.mark.parametrize("parameter", test_archive_parameters) +def test_archive_organization( + load_test_data: None, + login_role_user: tuple[Client, str], + settings: SettingsWrapper, + caplog: LogCaptureFixture, + parameter: tuple[int, bool], +) -> None: + """ + Test whether archiving an organization is working as expected + """ + client, role = login_role_user + organization_id, should_be_archived = parameter + + # Set the language setting to English so assertion does not fail because of corresponding German sentence appearing instead the english one. + settings.LANGUAGE_CODE = "en" + + archive_organization = reverse( + "archive_organization", + kwargs={ + "organization_id": organization_id, + "region_slug": REGION_SLUG, + }, + ) + response = client.post(archive_organization) + + if role in HIGH_PRIV_STAFF_ROLES + [MANAGEMENT]: + assert response.status_code == 302 + + redirect_url = response.headers.get("location") + + if should_be_archived: + assert_message_in_log( + "SUCCESS Organization was successfully archived", + caplog, + ) + assert "Organization was successfully archived" in client.get( + redirect_url + ).content.decode("utf-8") + assert Organization.objects.filter(id=organization_id).first().archived + else: + assert_message_in_log( + "ERROR Organization couldn't be archived as it's used by a page, poi or user", + caplog, + ) + assert "Organization couldn't be archived as it's used by a page, poi or user" in client.get( + redirect_url + ).content.decode( + "utf-8" + ) + assert not Organization.objects.filter(id=organization_id).first().archived + + elif role == ANONYMOUS: + assert response.status_code == 302 + assert ( + response.headers.get("location") + == f"{settings.LOGIN_URL}?next={archive_organization}" + ) + else: + assert response.status_code == 403 + + +test_delete_parameters = [ + (REFERENCED_ORGANIZATION_ID, False), + (NOT_REFERENCED_ORGANIZATION_ID, True), + (ARCHIVED_ORGANIZATION_ID, True), +] + + +@pytest.mark.django_db +@pytest.mark.parametrize("parameter", test_delete_parameters) +def test_delete_organization( + load_test_data: None, + login_role_user: tuple[Client, str], + settings: SettingsWrapper, + caplog: LogCaptureFixture, + parameter: tuple[int, bool], +) -> None: + """ + Test whether deleting an organization is working as expected + """ + client, role = login_role_user + organization_id, should_be_deleted = parameter + + # Set the language setting to English so assertion does not fail because of corresponding German sentence appearing instead the english one. + settings.LANGUAGE_CODE = "en" + + delete_organization = reverse( + "delete_organization", + kwargs={ + "organization_id": organization_id, + "region_slug": REGION_SLUG, + }, + ) + response = client.post(delete_organization) + + if role in HIGH_PRIV_STAFF_ROLES + [MANAGEMENT]: + assert response.status_code == 302 + + redirect_url = response.headers.get("location") + + if should_be_deleted: + assert_message_in_log( + "SUCCESS Organization was successfully deleted", + caplog, + ) + assert "Organization was successfully deleted" in client.get( + redirect_url + ).content.decode("utf-8") + assert not Organization.objects.filter(id=organization_id).first() + else: + assert_message_in_log( + "ERROR Organization couldn't be deleted as it's used by a page, poi or user", + caplog, + ) + assert "Organization couldn't be deleted as it's used by a page, poi or user" in client.get( + redirect_url + ).content.decode( + "utf-8" + ) + assert Organization.objects.filter(id=organization_id).first() + + elif role == ANONYMOUS: + assert response.status_code == 302 + assert ( + response.headers.get("location") + == f"{settings.LOGIN_URL}?next={delete_organization}" + ) + else: + assert response.status_code == 403 + + +@pytest.mark.django_db +def test_restore_organization( + load_test_data: None, + login_role_user: tuple[Client, str], + settings: SettingsWrapper, + caplog: LogCaptureFixture, +) -> None: + """ + Test whether restoring an organization is working as expected + """ + client, role = login_role_user + + # Set the language setting to English so assertion does not fail because of corresponding German sentence appearing instead the english one. + settings.LANGUAGE_CODE = "en" + + archived_organization = Organization.objects.filter( + region__slug=REGION_SLUG, archived=True + ).first() + assert archived_organization + + archived_organization_id = archived_organization.id + + restore_organization = reverse( + "restore_organization", + kwargs={ + "organization_id": archived_organization_id, + "region_slug": REGION_SLUG, + }, + ) + response = client.post(restore_organization) + + if role in HIGH_PRIV_STAFF_ROLES + [MANAGEMENT]: + response.status_code == 302 + redirect_url = response.headers.get("location") + assert_message_in_log( + "SUCCESS Organization was successfully restored", + caplog, + ) + assert "Organization was successfully restored" in client.get( + redirect_url + ).content.decode("utf-8") + assert ( + not Organization.objects.filter(id=archived_organization_id) + .first() + .archived + ) + + elif role == ANONYMOUS: + assert response.status_code == 302 + assert ( + response.headers.get("location") + == f"{settings.LOGIN_URL}?next={restore_organization}" + ) + else: + assert response.status_code == 403 + + +BULK_ARCHIVE_SELECTED_IDS = [REFERENCED_ORGANIZATION_ID, NOT_REFERENCED_ORGANIZATION_ID] + + +@pytest.mark.django_db +def test_bulk_archive_organizations( + load_test_data: None, + login_role_user: tuple[Client, str], + settings: SettingsWrapper, + caplog: LogCaptureFixture, +) -> None: + """ + Test whether bulk archiving of organizations is working as expected + """ + client, role = login_role_user + + # Set the language setting to English so assertion does not fail because of corresponding German sentence appearing instead the english one. + settings.LANGUAGE_CODE = "en" + + bulk_archive_organization = reverse( + "bulk_archive_organization", + kwargs={ + "region_slug": REGION_SLUG, + }, + ) + response = client.post( + bulk_archive_organization, + data={"selected_ids[]": BULK_ARCHIVE_SELECTED_IDS}, + ) + + if role in HIGH_PRIV_STAFF_ROLES + [MANAGEMENT]: + response.status_code == 302 + redirect_url = response.headers.get("location") + redirect_page = client.get(redirect_url).content.decode("utf-8") + + assert_message_in_log( + "ERROR Organization \"Nicht archivierte Organisation\" couldn't be archived as it's used by a page, poi or user.", + caplog, + ) + assert ( + "Organization "Nicht archivierte Organisation" couldn't be archived as it's used by a page, poi or user." + in redirect_page + ) + assert ( + not Organization.objects.filter(id=REFERENCED_ORGANIZATION_ID) + .first() + .archived + ) + assert_message_in_log( + 'SUCCESS Organization "Not Referenced Organisation" was successfully archived.', + caplog, + ) + assert ( + "Organization "Not Referenced Organisation" was successfully archived." + in redirect_page + ) + assert ( + Organization.objects.filter(id=NOT_REFERENCED_ORGANIZATION_ID) + .first() + .archived + ) + elif role == ANONYMOUS: + assert response.status_code == 302 + assert ( + response.headers.get("location") + == f"{settings.LOGIN_URL}?next={bulk_archive_organization}" + ) + else: + assert response.status_code == 403 + + +BULK_DELETE_SELECTED_IDS = [REFERENCED_ORGANIZATION_ID, NOT_REFERENCED_ORGANIZATION_ID] + + +@pytest.mark.django_db +def test_bulk_delete_organizations( + load_test_data: None, + login_role_user: tuple[Client, str], + settings: SettingsWrapper, + caplog: LogCaptureFixture, +) -> None: + """ + Test whether bulk deleting of organizations is working as expected + """ + client, role = login_role_user + + # Set the language setting to English so assertion does not fail because of corresponding German sentence appearing instead the english one. + settings.LANGUAGE_CODE = "en" + + bulk_delete_organization = reverse( + "bulk_delete_organization", + kwargs={ + "region_slug": REGION_SLUG, + }, + ) + response = client.post( + bulk_delete_organization, + data={"selected_ids[]": BULK_DELETE_SELECTED_IDS}, + ) + + if role in HIGH_PRIV_STAFF_ROLES + [MANAGEMENT]: + response.status_code == 302 + redirect_url = response.headers.get("location") + redirect_page = client.get(redirect_url).content.decode("utf-8") + + assert_message_in_log( + "ERROR Organization \"Nicht archivierte Organisation\" couldn't be deleted as it's used by a page, poi or user.", + caplog, + ) + assert ( + "Organization "Nicht archivierte Organisation" couldn't be deleted as it's used by a page, poi or user." + in redirect_page + ) + assert Organization.objects.filter(id=REFERENCED_ORGANIZATION_ID).exists() + assert_message_in_log( + 'SUCCESS Organization "Not Referenced Organisation" was successfully deleted.', + caplog, + ) + assert ( + "Organization "Not Referenced Organisation" was successfully deleted." + in redirect_page + ) + assert not Organization.objects.filter( + id=NOT_REFERENCED_ORGANIZATION_ID + ).exists() + elif role == ANONYMOUS: + assert response.status_code == 302 + assert ( + response.headers.get("location") + == f"{settings.LOGIN_URL}?next={bulk_delete_organization}" + ) + else: + assert response.status_code == 403 + + +BULK_RESTORE_SELECTED_IDS = [ARCHIVED_ORGANIZATION_ID] + + +@pytest.mark.django_db +def test_bulk_restore_organizations( + load_test_data: None, + login_role_user: tuple[Client, str], + settings: SettingsWrapper, + caplog: LogCaptureFixture, +) -> None: + """ + Test whether bulk restoring of organizations is working as expected + """ + client, role = login_role_user + + # Set the language setting to English so assertion does not fail because of corresponding German sentence appearing instead the english one. + settings.LANGUAGE_CODE = "en" + + bulk_restore_organization = reverse( + "bulk_restore_organization", + kwargs={ + "region_slug": REGION_SLUG, + }, + ) + response = client.post( + bulk_restore_organization, + data={"selected_ids[]": BULK_RESTORE_SELECTED_IDS}, + ) + + if role in HIGH_PRIV_STAFF_ROLES + [MANAGEMENT]: + response.status_code == 302 + redirect_url = response.headers.get("location") + redirect_page = client.get(redirect_url).content.decode("utf-8") + + assert_message_in_log( + 'SUCCESS Organization "Archivierte Organisation" was successfully restored.', + caplog, + ) + assert ( + "Organization "Archivierte Organisation" was successfully restored." + in redirect_page + ) + assert ( + not Organization.objects.filter(id=ARCHIVED_ORGANIZATION_ID) + .first() + .archived + ) + elif role == ANONYMOUS: + assert response.status_code == 302 + assert ( + response.headers.get("location") + == f"{settings.LOGIN_URL}?next={bulk_restore_organization}" + ) + else: + assert response.status_code == 403 diff --git a/tests/cms/views/organizations/test_organization_form.py b/tests/cms/views/organizations/test_organization_form.py index 306cf4a35d..a506841138 100644 --- a/tests/cms/views/organizations/test_organization_form.py +++ b/tests/cms/views/organizations/test_organization_form.py @@ -3,6 +3,7 @@ from typing import TYPE_CHECKING if TYPE_CHECKING: + from _pytest.logging import LogCaptureFixture from django.test.client import Client from pytest_django.fixtures import SettingsWrapper @@ -11,36 +12,38 @@ from django.urls import reverse from integreat_cms.cms.models import MediaFile, Organization, Page, POI, Region -from tests.conftest import ANONYMOUS, MANAGEMENT, STAFF_ROLES +from tests.conftest import ANONYMOUS, HIGH_PRIV_STAFF_ROLES, MANAGEMENT, STAFF_ROLES +from tests.utils import assert_message_in_log + +# Choose a region +REGION_SLUG = "augsburg" +# An organization which has a page and a poi assigned and belongs to the above chosen region +REFERENCED_ORGANIZATION_ID = 1 +# An organization which does not have a page and a poi assigned and belongs to the above chosen region +NOT_REFERENCED_ORGANIZATION_ID = 3 +# Choose a name which is not used by any organization +NEW_ORGANIZATION_NAME = "New Organization" @pytest.mark.django_db -def test_poi_form_shows_no_contents( +def test_organization_form_shows_no_contents( load_test_data: None, login_role_user: tuple[Client, str], settings: SettingsWrapper, ) -> None: """ - Test that no contents are shown in the organization form if it does not have any content assinged + Test that no contents are shown in the organization form if it does not have any content assigned """ client, role = login_role_user # Set the language setting to English so assertion does not fail because of corresponding German sentence appearing instead the english one. settings.LANGUAGE_CODE = "en" - new_organization = Organization.objects.create( - name="New Organization", - slug="new-organization", - icon=MediaFile.objects.filter(id=1).first(), - region=Region.objects.filter(id=1).first(), - website="https://integreat-app.de/", - ) - edit_organization = reverse( "edit_organization", kwargs={ - "organization_id": new_organization.id, - "region_slug": new_organization.region.slug, + "organization_id": NOT_REFERENCED_ORGANIZATION_ID, + "region_slug": REGION_SLUG, }, ) response = client.get(edit_organization) @@ -75,8 +78,6 @@ def test_organization_form_shows_associated_contents( Nicht archivierte Organisation in Augsburg has two pages and one location. They must be shown in the form. """ - # Choose an organization which has a page and a poi assigned - ORGANIZATION_ID = 1 # To check contents that do not belong to the organization do not appear in the form, # choose a page and a POI @@ -94,7 +95,7 @@ def test_organization_form_shows_associated_contents( # Set the language setting to English so assertion does not fail because of corresponding German sentence appearing instead the english one. settings.LANGUAGE_CODE = "en" - organization = Organization.objects.filter(id=ORGANIZATION_ID).first() + organization = Organization.objects.filter(id=REFERENCED_ORGANIZATION_ID).first() organization_pages = list(organization.pages.all()) organization_pois = list(organization.pois.all()) @@ -106,8 +107,8 @@ def test_organization_form_shows_associated_contents( edit_organization = reverse( "edit_organization", kwargs={ - "organization_id": organization.id, - "region_slug": region.slug, + "organization_id": REFERENCED_ORGANIZATION_ID, + "region_slug": REGION_SLUG, }, ) response = client.get(edit_organization) @@ -141,3 +142,171 @@ def test_organization_form_shows_associated_contents( else: assert response.status_code == 403 + + +@pytest.mark.django_db +def test_create_new_organization( + load_test_data: None, + login_role_user: tuple[Client, str], + settings: SettingsWrapper, + caplog: LogCaptureFixture, +) -> None: + """ + Test an organization will be created as expected + """ + client, role = login_role_user + + settings.LANGUAGE_CODE = "en" + + assert not Organization.objects.filter(name=NEW_ORGANIZATION_NAME).exists() + + new_organization = reverse( + "new_organization", + kwargs={ + "region_slug": REGION_SLUG, + }, + ) + response = client.post( + new_organization, + data={ + "name": NEW_ORGANIZATION_NAME, + "slug": "new-organization", + "icon": 1, + "website": "https://integreat-app.de/", + }, + ) + + if role in HIGH_PRIV_STAFF_ROLES + [MANAGEMENT]: + assert response.status_code == 302 + assert_message_in_log( + f'SUCCESS Organization "{NEW_ORGANIZATION_NAME}" was successfully created', + caplog, + ) + edit_url = response.headers.get("location") + response = client.get(edit_url) + assert ( + f"Organization "{NEW_ORGANIZATION_NAME}" was successfully created" + in response.content.decode("utf-8") + ) + assert Organization.objects.filter(name=NEW_ORGANIZATION_NAME).exists() + + elif role == ANONYMOUS: + assert response.status_code == 302 + assert ( + response.headers.get("location") + == f"{settings.LOGIN_URL}?next={new_organization}" + ) + else: + assert response.status_code == 403 + + +@pytest.mark.django_db +def test_cannot_create_organization_with_duplicate_name( + load_test_data: None, + login_role_user: tuple[Client, str], + settings: SettingsWrapper, + caplog: LogCaptureFixture, +) -> None: + """ + No organization should be created with the same name with an existing organization. + """ + client, role = login_role_user + + settings.LANGUAGE_CODE = "en" + + existing_organization = Organization.objects.filter( + region__slug=REGION_SLUG + ).first() + assert existing_organization + + new_organization = reverse( + "new_organization", + kwargs={ + "region_slug": REGION_SLUG, + }, + ) + response = client.post( + new_organization, + data={ + "name": existing_organization.name, + "slug": existing_organization.slug, + "icon": 1, + "website": "https://integreat-app.de/", + }, + ) + + if role in HIGH_PRIV_STAFF_ROLES + [MANAGEMENT]: + assert response.status_code == 200 + assert_message_in_log( + "ERROR Name: An organization with the same name already exists in this region. Please choose another name.", + caplog, + ) + assert ( + "An organization with the same name already exists in this region. Please choose another name." + 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_organization}" + ) + else: + assert response.status_code == 403 + + +@pytest.mark.django_db +def test_edit_organization( + load_test_data: None, + login_role_user: tuple[Client, str], + settings: SettingsWrapper, + caplog: LogCaptureFixture, +) -> None: + """ + Test an existing organization is updated as expected + """ + client, role = login_role_user + + settings.LANGUAGE_CODE = "en" + + edit_organization = reverse( + "edit_organization", + kwargs={ + "organization_id": NOT_REFERENCED_ORGANIZATION_ID, + "region_slug": REGION_SLUG, + }, + ) + response = client.post( + edit_organization, + data={ + "name": "I got a new name", + "slug": "i-got-a-new-name", + "icon": 1, + "website": "https://integreat-app.de/", + }, + ) + + if role in HIGH_PRIV_STAFF_ROLES + [MANAGEMENT]: + assert response.status_code == 200 + assert_message_in_log( + 'SUCCESS Organization "I got a new name" was successfully saved', + caplog, + ) + assert ( + "Organization "I got a new name" was successfully saved" + in response.content.decode("utf-8") + ) + assert ( + Organization.objects.filter(id=NOT_REFERENCED_ORGANIZATION_ID).first().name + == "I got a new name" + ) + + elif role == ANONYMOUS: + assert response.status_code == 302 + assert ( + response.headers.get("location") + == f"{settings.LOGIN_URL}?next={edit_organization}" + ) + else: + assert response.status_code == 403 diff --git a/tests/cms/views/poi/test_poi_form.py b/tests/cms/views/poi/test_poi_form.py index 41d6411616..c0e7bf6e4e 100644 --- a/tests/cms/views/poi/test_poi_form.py +++ b/tests/cms/views/poi/test_poi_form.py @@ -218,3 +218,107 @@ def test_poi_in_use_not_bulk_archived( # Check the POI is not archived assert not POI.objects.filter(id=poi_id).first().archived + + +@pytest.mark.django_db +def test_poi_form_shows_associated_contacts( + load_test_data: None, + login_role_user: tuple[Client, str], + settings: SettingsWrapper, +) -> None: + """ + POI "Draft location" (id=6) has three related contacts. Test whether they are shown in the POI form. + """ + client, role = login_role_user + + # Choose a POI which has related contacts + POI_ID = 6 + + # Set the language setting to English so assertion does not fail because of corresponding German sentence appearing instead the english one. + settings.LANGUAGE_CODE = "en" + + poi = POI.objects.filter(id=POI_ID).first() + related_contacts = list(poi.contacts.all()) + + assert len(related_contacts) > 0 + + edit_poi = reverse( + "edit_poi", + kwargs={ + "poi_id": poi.id, + "region_slug": poi.region.slug, + "language_slug": poi.region.default_language.slug, + }, + ) + response = client.get(edit_poi) + + if role == ANONYMOUS: + assert response.status_code == 302 + assert ( + response.headers.get("location") == f"{settings.LOGIN_URL}?next={edit_poi}" + ) + # probably needs adjustment after #2958 + elif role in HIGH_PRIV_STAFF_ROLES: + for contact in related_contacts: + if contact.point_of_contact_for: + assert ( + f"{contact.point_of_contact_for} {contact.name}" + in response.content.decode("utf-8") + ) + else: + assert "General contact information" in response.content.decode("utf-8") + else: + assert ( + "This location is currently referred to in those contacts." + not in response.content.decode("utf-8") + ) + + +@pytest.mark.django_db +def test_poi_form_shows_no_associated_contacts( + load_test_data: None, + login_role_user: tuple[Client, str], + settings: SettingsWrapper, +) -> None: + """ + POI "Test location" (id=4) has no related contacts. Test whether the correct message is shown in the POi form. + """ + client, role = login_role_user + + # Choose a POI which has related contacts + POI_ID = 4 + + # Set the language setting to English so assertion does not fail because of corresponding German sentence appearing instead the english one. + settings.LANGUAGE_CODE = "en" + + poi = POI.objects.filter(id=POI_ID).first() + related_contacts = list(poi.contacts.all()) + + assert len(related_contacts) == 0 + + edit_poi = reverse( + "edit_poi", + kwargs={ + "poi_id": poi.id, + "region_slug": poi.region.slug, + "language_slug": poi.region.default_language.slug, + }, + ) + response = client.get(edit_poi) + + if role == ANONYMOUS: + assert response.status_code == 302 + assert ( + response.headers.get("location") == f"{settings.LOGIN_URL}?next={edit_poi}" + ) + # probably needs adjustment after #2958 + elif role in HIGH_PRIV_STAFF_ROLES: + assert ( + "This location is not currently referred to in any contact." + in response.content.decode("utf-8") + ) + else: + assert ( + "This location is not currently referred to in any contact." + not in response.content.decode("utf-8") + ) diff --git a/tests/core/management/commands/assets/calendars/recurrence_rules.ics b/tests/core/management/commands/assets/calendars/recurrence_rules.ics new file mode 100644 index 0000000000..dd422ed1a4 --- /dev/null +++ b/tests/core/management/commands/assets/calendars/recurrence_rules.ics @@ -0,0 +1,86 @@ +BEGIN:VCALENDAR +VERSION:2.0 +CALSCALE:GREGORIAN +PRODID:-//SabreDAV//SabreDAV//EN +X-WR-CALNAME:Persönlich +X-APPLE-CALENDAR-COLOR:#0082c9 +REFRESH-INTERVAL;VALUE=DURATION:PT4H +X-PUBLISHED-TTL:PT4H +BEGIN:VTIMEZONE +TZID:Europe/Berlin +BEGIN:DAYLIGHT +TZOFFSETFROM:+0100 +TZOFFSETTO:+0200 +TZNAME:CEST +DTSTART:19700329T020000 +RRULE:FREQ=YEARLY;BYMONTH=3;BYDAY=-1SU +END:DAYLIGHT +BEGIN:STANDARD +TZOFFSETFROM:+0200 +TZOFFSETTO:+0100 +TZNAME:CET +DTSTART:19701025T030000 +RRULE:FREQ=YEARLY;BYMONTH=10;BYDAY=-1SU +END:STANDARD +END:VTIMEZONE +BEGIN:VEVENT +CREATED:20241113T130524Z +DTSTAMP:20241113T130530Z +LAST-MODIFIED:20241113T130530Z +SEQUENCE:2 +UID:1d216ee4-d340-40ff-9e9f-6974e6c9bb16 +DTSTART;VALUE=DATE:20241114 +DTEND;VALUE=DATE:20241115 +STATUS:CONFIRMED +SUMMARY:Singular event +END:VEVENT +BEGIN:VEVENT +CREATED:20241113T124153Z +DTSTAMP:20241113T124251Z +LAST-MODIFIED:20241113T124251Z +SEQUENCE:2 +UID:25ac1882-5429-4264-91e0-358ac44972c9 +DTSTART;VALUE=DATE:20241113 +DTEND;VALUE=DATE:20241114 +STATUS:CONFIRMED +SUMMARY:Every first Monday +RRULE:FREQ=MONTHLY;BYDAY=MO;BYSETPOS=1 +END:VEVENT +BEGIN:VEVENT +CREATED:20241113T124002Z +DTSTAMP:20241113T124042Z +LAST-MODIFIED:20241113T124042Z +SEQUENCE:2 +UID:187e9e6f-6723-4526-a365-cd8780731ebe +DTSTART;TZID=Europe/Berlin:20241113T140002 +DTEND;TZID=Europe/Berlin:20241113T150002 +STATUS:CONFIRMED +SUMMARY:Every second day +RRULE:FREQ=DAILY;INTERVAL=2 +END:VEVENT +BEGIN:VEVENT +CREATED:20241113T130302Z +DTSTAMP:20241113T130302Z +LAST-MODIFIED:20241113T130302Z +SEQUENCE:0 +UID:ccec9b8a-9f16-4408-a167-b60d947382ca +DTSTART;TZID=Europe/Berlin:20241113T200000 +DTEND;TZID=Europe/Berlin:20241113T210000 +STATUS:CONFIRMED +RRULE:FREQ=WEEKLY;BYDAY=FR,WE,MO +SUMMARY:Weekly event +RELATED-TO;RELTYPE=SIBLING:b9220321-cf0f-4084-923d-91d150e8e11f +END:VEVENT +BEGIN:VEVENT +CREATED:20241113T130016Z +DTSTAMP:20241113T130159Z +LAST-MODIFIED:20241113T130159Z +SEQUENCE:2 +UID:6d6abd00-27af-4eff-868f-396ba9cd7012 +DTSTART;VALUE=DATE:20241113 +DTEND;VALUE=DATE:20241114 +STATUS:CONFIRMED +SUMMARY:Every year until 2034 +RRULE:FREQ=YEARLY;BYMONTH=11;UNTIL=20341112T230000Z +END:VEVENT +END:VCALENDAR diff --git a/tests/core/management/commands/assets/calendars/single_recurring_event_a.ics b/tests/core/management/commands/assets/calendars/single_recurring_event_a.ics new file mode 100644 index 0000000000..60e6a6ef20 --- /dev/null +++ b/tests/core/management/commands/assets/calendars/single_recurring_event_a.ics @@ -0,0 +1,21 @@ +BEGIN:VCALENDAR +VERSION:2.0 +CALSCALE:GREGORIAN +PRODID:-//SabreDAV//SabreDAV//EN +X-WR-CALNAME:testkalender +X-APPLE-CALENDAR-COLOR:#6EA68F +REFRESH-INTERVAL;VALUE=DURATION:PT4H +X-PUBLISHED-TTL:PT4H +BEGIN:VEVENT +CREATED:20241113T171416Z +DTSTAMP:20241113T171435Z +LAST-MODIFIED:20241113T171435Z +SEQUENCE:2 +UID:04c845b3-eca0-4780-af51-a84bb16faa2a +DTSTART;VALUE=DATE:20241114 +DTEND;VALUE=DATE:20241115 +STATUS:CONFIRMED +SUMMARY:Repeating event? +RRULE:FREQ=DAILY +END:VEVENT +END:VCALENDAR diff --git a/tests/core/management/commands/assets/calendars/single_recurring_event_b.ics b/tests/core/management/commands/assets/calendars/single_recurring_event_b.ics new file mode 100644 index 0000000000..8fd249eb7c --- /dev/null +++ b/tests/core/management/commands/assets/calendars/single_recurring_event_b.ics @@ -0,0 +1,20 @@ +BEGIN:VCALENDAR +VERSION:2.0 +CALSCALE:GREGORIAN +PRODID:-//SabreDAV//SabreDAV//EN +X-WR-CALNAME:testkalender +X-APPLE-CALENDAR-COLOR:#6EA68F +REFRESH-INTERVAL;VALUE=DURATION:PT4H +X-PUBLISHED-TTL:PT4H +BEGIN:VEVENT +CREATED:20241113T171416Z +DTSTAMP:20241113T171758Z +LAST-MODIFIED:20241113T171758Z +SEQUENCE:3 +UID:04c845b3-eca0-4780-af51-a84bb16faa2a +DTSTART;VALUE=DATE:20241114 +DTEND;VALUE=DATE:20241115 +STATUS:CONFIRMED +SUMMARY:Repeating event? +END:VEVENT +END:VCALENDAR diff --git a/tests/core/management/commands/test_import_events.py b/tests/core/management/commands/test_import_events.py index 7d3eb9920f..8a38a0216a 100644 --- a/tests/core/management/commands/test_import_events.py +++ b/tests/core/management/commands/test_import_events.py @@ -3,7 +3,12 @@ import pytest from pytest_httpserver import HTTPServer -from integreat_cms.cms.models import EventTranslation, ExternalCalendar, Region +from integreat_cms.cms.models import ( + EventTranslation, + ExternalCalendar, + RecurrenceRule, + Region, +) from ..utils import get_command_output @@ -23,11 +28,44 @@ CALENDAR_MULTIPLE_CATEGORIES = ( "tests/core/management/commands/assets/calendars/event_with_multiple_categories.ics" ) +CALENDAR_RECURRENCE_RULES = ( + "tests/core/management/commands/assets/calendars/recurrence_rules.ics" +) +CALENDAR_SINGLE_RECURRING_EVENT_A = ( + "tests/core/management/commands/assets/calendars/single_recurring_event_a.ics" +) +CALENDAR_SINGLE_RECURRING_EVENT_B = ( + "tests/core/management/commands/assets/calendars/single_recurring_event_b.ics" +) + +#: A Collection of (Calendar file, events in that file, recurrence rules in that file) CALENDARS = [ - (CALENDAR_V1, [CALENDAR_V1_EVENT_NAME]), - (CALENDAR_v2, [CALENDAR_V2_EVENT_NAME]), - (CALENDAR_EMPTY, []), - (CALENDAR_WRONG_CATEGORY, [CALENDAR_WRONG_CATEGORY_EVENT_NAME]), + (CALENDAR_V1, [CALENDAR_V1_EVENT_NAME], {}), + (CALENDAR_v2, [CALENDAR_V2_EVENT_NAME], {}), + (CALENDAR_EMPTY, [], {}), + (CALENDAR_WRONG_CATEGORY, [CALENDAR_WRONG_CATEGORY_EVENT_NAME], {}), + ( + CALENDAR_RECURRENCE_RULES, + [ + "Singular event", + "Every first Monday", + "Every second day", + "Weekly event", + "Every year until 2034", + ], + { + "DTSTART:20241112T230000\nRRULE:FREQ=MONTHLY;BYDAY=+1MO", + "DTSTART:20241113T130002\nRRULE:FREQ=DAILY;INTERVAL=2", + "DTSTART:20241113T190000\nRRULE:FREQ=WEEKLY;BYDAY=MO,WE,FR", + "DTSTART:20241112T230000\nRRULE:FREQ=YEARLY;UNTIL=20341112T235959", + }, + ), + ( + CALENDAR_SINGLE_RECURRING_EVENT_A, + ["Repeating event?"], + {"DTSTART:20241113T230000\nRRULE:FREQ=DAILY"}, + ), + (CALENDAR_SINGLE_RECURRING_EVENT_B, ["Repeating event?"], {}), ] @@ -69,7 +107,9 @@ def test_import_without_calendars() -> None: @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]] + httpserver: HTTPServer, + load_test_data: None, + calendar_data: tuple[str, list[str], set[str]], ) -> None: """ Tests that the calendars in the test data can be imported correctly @@ -77,7 +117,7 @@ def test_import_successful( :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_file, event_names, recurrence_rules = calendar_data calendar_url = serve(httpserver, calendar_file) calendar = setup_calendar(calendar_url) @@ -85,6 +125,11 @@ def test_import_successful( event__region=calendar.region, title__in=event_names ).exists(), "Event should not exist before import" + for rule in RecurrenceRule.objects.all(): + assert ( + rule.to_ical_rrule_string() not in recurrence_rules + ), "Recurrence rule should not exist before import" + _, err = get_command_output("import_events") assert not err @@ -95,6 +140,11 @@ def test_import_successful( for title in event_names ), "Events should exist after import" + new_rules = {rule.to_ical_rrule_string() for rule in RecurrenceRule.objects.all()} + print(new_rules) + for rule in recurrence_rules: + assert rule in new_rules, "Recurrence rule should exist after import" + @pytest.mark.django_db def test_update_event(httpserver: HTTPServer, load_test_data: None) -> None: @@ -248,10 +298,43 @@ def test_import_event_with_multiple_categories( :param load_test_data: The fixture providing the test data (see :meth:`~tests.conftest.load_test_data`) """ calendar_url = serve(httpserver, CALENDAR_MULTIPLE_CATEGORIES) - calendar = setup_calendar(calendar_url) - calendar.save() + setup_calendar(calendar_url) out, err = get_command_output("import_events") print(err) assert not err assert "Imported event" in out + + +@pytest.mark.django_db +def test_import_and_remove_recurrence_rule( + httpserver: HTTPServer, load_test_data: None +) -> None: + """ + Imports an event with a recurrence rule and later the same event without recurrence rule. + Tests that the recurrence rule gets deleted when it is not needed anymore after the second import. + :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_SINGLE_RECURRING_EVENT_A) + calendar = setup_calendar(calendar_url) + + _, err = get_command_output("import_events") + assert not err + + assert RecurrenceRule.objects.filter( + event__external_calendar=calendar + ).exists(), "The recurrence rule should exist after import" + event = calendar.events.first() + assert event, "Event should have been created" + + # Now, import the updated calendar where the recurrence rule was removed + serve(httpserver, CALENDAR_SINGLE_RECURRING_EVENT_B) + _, err = get_command_output("import_events") + assert not err + + assert not RecurrenceRule.objects.filter( + event__external_calendar=calendar + ).exists(), "The recurrence rule should not exist anymore after update" + new_event = calendar.events.first() + assert event.id == new_event.id, "The event should still exist" diff --git a/tools/run.sh b/tools/run.sh index 88a5ab8bc5..0193842443 100755 --- a/tools/run.sh +++ b/tools/run.sh @@ -46,5 +46,8 @@ done # Show success message once dev server is up listen_for_devserver & +# Run Celery worker process +celery -A integreat_cms.integreat_celery worker -l INFO -B --concurrency=1 & + # Start Integreat CMS development webserver deescalate_privileges integreat-cms-cli runserver "localhost:${INTEGREAT_CMS_PORT}" diff --git a/webpack.config.js b/webpack.config.js index 5946e5da8c..e212e918f8 100644 --- a/webpack.config.js +++ b/webpack.config.js @@ -101,12 +101,12 @@ module.exports = { new CopyPlugin({ patterns: [ { - from: "node_modules/tinymce/skins/ui/oxide/skin.min.css", - to: "skins/ui/oxide/skin.min.css", + from: "node_modules/tinymce/skins/ui/oxide/skin.css", + to: "skins/ui/oxide/skin.css", }, { - from: "node_modules/tinymce/skins/ui/oxide/content.min.css", - to: "skins/ui/oxide/content.min.css", + from: "node_modules/tinymce/skins/ui/oxide/content.css", + to: "skins/ui/oxide/content.css", }, { from: "integreat_cms/static/src/svg", to: "svg" }, { from: "integreat_cms/static/src/images", to: "images" },