diff --git a/.circleci/config.yml b/.circleci/config.yml index bf5641cd45..38d5451577 100644 --- a/.circleci/config.yml +++ b/.circleci/config.yml @@ -11,7 +11,7 @@ jobs: steps: - checkout - restore_cache: - key: pipenv-{{ checksum "Pipfile.lock" }}-v1 + key: pipenv-{{ checksum "Pipfile.lock" }}-v2 - run: name: Install pip dependencies command: | @@ -26,7 +26,7 @@ jobs: fi fi - save_cache: - key: pipenv-{{ checksum "Pipfile.lock" }}-v1 + key: pipenv-{{ checksum "Pipfile.lock" }}-v2 paths: - .venv - integreat_cms.egg-info @@ -97,7 +97,7 @@ jobs: - checkout - restore_cache: keys: - - npm-{{ checksum "package-lock.json" }}-v1 + - npm-{{ checksum "package-lock.json" }}-v2 - run: name: Install npm dependencies command: | @@ -112,7 +112,7 @@ jobs: fi fi - save_cache: - key: npm-{{ checksum "package-lock.json" }}-v1 + key: npm-{{ checksum "package-lock.json" }}-v2 paths: - node_modules - run: diff --git a/CHANGELOG.md b/CHANGELOG.md index 5d99ca9e19..5a9efc0a14 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,6 +1,20 @@ UNRELEASED ---------- +* [ [#1759](https://github.com/digitalfabrik/integreat-cms/issues/1759) ] Add line break between images in PDF exports +* [ [#1537](https://github.com/digitalfabrik/integreat-cms/issues/1537) ] Fix broken inline icons in PDF exports +* [ [#951](https://github.com/digitalfabrik/integreat-cms/issues/951) ] Add possibility to create categories for POIs +* [ [#1742](https://github.com/digitalfabrik/integreat-cms/issues/1742) ] Add last modified date to media sidebar +* [ [#1703](https://github.com/digitalfabrik/integreat-cms/issues/1703) ] Remove pending account activation warning when user form is submitted with errors +* [ [#1684](https://github.com/digitalfabrik/integreat-cms/issues/1684) ] Set filesize limit for uploads to 3MB +* [ [#1000](https://github.com/digitalfabrik/integreat-cms/issues/1000) ] Auto-complete address and coordinates of locations +* [ [#1434](https://github.com/digitalfabrik/integreat-cms/issues/1434) ] Add display of HIX values to nudge users to write easier texts +* [ [#1770](https://github.com/digitalfabrik/integreat-cms/issues/1770) ] Fix error in SUMM.AI translation if paragraph contains only special characters +* [ [#1710](https://github.com/digitalfabrik/integreat-cms/issues/1710) ] Add spacing to sidebar to improve view on small screens +* [ [#1526](https://github.com/digitalfabrik/integreat-cms/issues/1526)] Fix sending push notifications in one language +* [ [#1630](https://github.com/digitalfabrik/integreat-cms/issues/1630)] Fix not recognized sent status of push notifications +* [ [#1683](https://github.com/digitalfabrik/integreat-cms/issues/1683) ] Improve organization management + 2022.10.0 --------- diff --git a/dev-tools/_functions.sh b/dev-tools/_functions.sh index b7b1f3702b..c4814c77d1 100644 --- a/dev-tools/_functions.sh +++ b/dev-tools/_functions.sh @@ -240,6 +240,7 @@ function wait_for_docker_container { # This function creates a new postgres database docker container function create_docker_container { echo "Creating new PostgreSQL database docker container..." | print_info + mkdir -p "${BASE_DIR}/.postgres" # Run new container docker run -d --name "${DOCKER_CONTAINER_NAME}" -e "POSTGRES_USER=integreat" -e "POSTGRES_PASSWORD=password" -e "POSTGRES_DB=integreat" -v "${BASE_DIR}/.postgres:/var/lib/postgresql" -p 5433:5432 postgres > /dev/null wait_for_docker_container diff --git a/example-configs/integreat-cms.ini b/example-configs/integreat-cms.ini index 38eed78b34..5f60cbd903 100644 --- a/example-configs/integreat-cms.ini +++ b/example-configs/integreat-cms.ini @@ -126,6 +126,12 @@ SUMM_AI_EASY_GERMAN_LANGUAGE_SLUG = "de-si" #: The separator which is used to split compound words [optional, defaults to "hyphen", must be one of ["hyphen", "interpunct"] SUMM_AI_SEPARATOR = "hyphen" +[textlab] +# If you want to get the hix score for your texts, set your API key here [optional, defaults to None] +TEXTLAB_API_KEY = +# Username for the textlab api [optional, defaults to "Integreat"] +TEXTLAB_API_USERNAME = + [xliff] # Which XLIFF version to use for export [optional, defaults to "xliff-1.2"] XLIFF_EXPORT_VERSION = xliff-1.2 diff --git a/integreat_cms/api/urls.py b/integreat_cms/api/urls.py index ed6d15540d..7b500e9b8f 100644 --- a/integreat_cms/api/urls.py +++ b/integreat_cms/api/urls.py @@ -28,6 +28,7 @@ push_page_translation_content, ) from .v3.pdf_export import pdf_export +from .v3.location_categories import location_categories from .v3.push_notifications import sent_push_notifications from .v3.regions import regions, liveregions, hiddenregions from .v3.offers import offers @@ -39,6 +40,7 @@ content_api_urlpatterns = [ path("pages/", pages, name="pages"), path("locations/", locations, name="locations"), + path("location-categories/", location_categories, name="location_categories"), path("events/", events, name="events"), path("page/", single_page, name="single_page"), path("post/", single_page, name="single_page"), diff --git a/integreat_cms/api/v3/location_categories.py b/integreat_cms/api/v3/location_categories.py new file mode 100644 index 0000000000..eefd61fd2c --- /dev/null +++ b/integreat_cms/api/v3/location_categories.py @@ -0,0 +1,61 @@ +""" +This module includes the POI category API endpoint. +""" +from django.http import JsonResponse + +from ...cms.models import POICategory +from ..decorators import json_response + + +def transform_location_category(location_category, language_slug): + """ + Function to create a JSON from a single location category object. + + :param location_category: The location category object which should be converted + :type location_category: ~integreat_cms.cms.models.poi_categories.poi_category.POICategory + + :param language_slug: The slug of the requested language + :type language_slug: str + + :return: Data necessary for API + :rtype: dict + """ + if not location_category: + return None + category_translation = location_category.get_translation(language_slug) + return { + "id": location_category.id, + "name": category_translation.name + if category_translation + else location_category.name, + } + + +@json_response +# pylint: disable=unused-argument +def location_categories(request, region_slug, language_slug): + """ + Function to return all POI categories as JSON. + + :param request: The current request + :type request: ~django.http.HttpRequest + + :param region_slug: The slug of the requested region + :type region_slug: str + + :param language_slug: The slug of the requested language + :type language_slug: str + + :return: JSON object of all POI categories + :rtype: ~django.http.JsonResponse + """ + region = request.region + # Throw a 404 error when the language does not exist or is disabled + region.get_language_or_404(language_slug, only_active=True) + result = [ + transform_location_category(location_category, language_slug) + for location_category in POICategory.objects.all() + ] + return JsonResponse( + result, safe=False + ) # Turn off Safe-Mode to allow serializing arrays diff --git a/integreat_cms/api/v3/locations.py b/integreat_cms/api/v3/locations.py index 3a83747631..c9c24820e3 100644 --- a/integreat_cms/api/v3/locations.py +++ b/integreat_cms/api/v3/locations.py @@ -5,7 +5,9 @@ from django.conf import settings from django.http import JsonResponse from django.utils import timezone + from ..decorators import json_response +from .location_categories import transform_location_category def transform_poi(poi): @@ -72,6 +74,9 @@ def transform_poi_translation(poi_translation): "website": poi.website if poi.website else None, "email": poi.email if poi.email else None, "phone_number": poi.phone_number if poi.phone_number else None, + "category": transform_location_category( + poi.category, poi_translation.language.slug + ), "location": transform_poi(poi), "hash": None, } diff --git a/integreat_cms/api/v3/push_notifications.py b/integreat_cms/api/v3/push_notifications.py index fbb01d6c0b..5e61924402 100644 --- a/integreat_cms/api/v3/push_notifications.py +++ b/integreat_cms/api/v3/push_notifications.py @@ -54,8 +54,8 @@ def transform_notification(pnt): """ return { "id": str(pnt.pk), - "title": pnt.title, - "message": pnt.text, + "title": pnt.get_title(), + "message": pnt.get_text(), "timestamp": pnt.last_updated, # deprecated field in the future "last_updated": timezone.localtime(pnt.last_updated), "channel": pnt.push_notification.channel, diff --git a/integreat_cms/cms/apps.py b/integreat_cms/cms/apps.py index 7b971b9af1..e938a10564 100644 --- a/integreat_cms/cms/apps.py +++ b/integreat_cms/cms/apps.py @@ -1,4 +1,6 @@ import logging +import os +import sys from django.apps import AppConfig from django.contrib.auth.signals import ( @@ -21,13 +23,26 @@ class CmsConfig(AppConfig): :type name: str """ + #: The name of this app config name = "integreat_cms.cms" + #: Whether the availability of external APIs should be checked + test_external_apis = False + # pylint: disable=unused-import,import-outside-toplevel def ready(self): # Implicitly connect a signal handlers decorated with @receiver. from .signals import feedback_signals + # Determine whether the availability of external APIs should be checked + self.test_external_apis = ( + # Either the dev server is started with the "runserver" command, + # but it's not the main process (to ignore autoreloads) + ("runserver" in sys.argv and "RUN_MAIN" not in os.environ) + # or the prod server is started via wsgi + or "APACHE_PID_FILE" in os.environ + ) + authlog = logging.getLogger("auth") diff --git a/integreat_cms/cms/constants/roles.py b/integreat_cms/cms/constants/roles.py index cf9694fbc3..da143cfad1 100644 --- a/integreat_cms/cms/constants/roles.py +++ b/integreat_cms/cms/constants/roles.py @@ -73,16 +73,19 @@ MANAGEMENT_PERMISSIONS = EDITOR_PERMISSIONS + [ "change_feedback", "change_imprintpage", + "change_organization", "change_pushnotification", "change_user", "change_chatmessage", "delete_directory", "delete_feedback", "delete_mediafile", + "delete_organization", "grant_page_permissions", "send_push_notification", "view_feedback", "view_imprintpage", + "view_organization", "view_pushnotification", "view_user", ] @@ -101,6 +104,7 @@ "view_organization", "view_page", "view_poi", + "view_poicategory", "view_pushnotification", "view_region", "view_user", @@ -132,6 +136,7 @@ "change_languagetreenode", "change_offertemplate", "change_organization", + "change_poicategory", "change_user", "delete_chatmessage", "delete_directory", @@ -140,8 +145,10 @@ "delete_imprintpage", "delete_languagetreenode", "delete_offertemplate", + "delete_organization", "delete_page", "delete_poi", + "delete_poicategory", "delete_pushnotification", "delete_region", "delete_user", diff --git a/integreat_cms/cms/fixtures/test_data.json b/integreat_cms/cms/fixtures/test_data.json index 4aa679e548..cbe9e88188 100644 --- a/integreat_cms/cms/fixtures/test_data.json +++ b/integreat_cms/cms/fixtures/test_data.json @@ -2660,7 +2660,8 @@ "country": "Deutschland", "latitude": 1.0, "longitude": 1.0, - "archived": false + "archived": false, + "category": 2 } }, { @@ -2779,6 +2780,75 @@ "creator": 1 } }, + { + "model": "cms.poicategory", + "pk": 1, + "fields": {} + }, + { + "model": "cms.poicategory", + "pk": 2, + "fields": {} + }, + { + "model": "cms.poicategory", + "pk": 3, + "fields": {} + }, + { + "model": "cms.poicategorytranslation", + "pk": 1, + "fields": { + "category": 1, + "language": 1, + "name": "Beratungsstelle" + } + }, + { + "model": "cms.poicategorytranslation", + "pk": 2, + "fields": { + "category": 1, + "language": 2, + "name": "Advice Center" + } + }, + { + "model": "cms.poicategorytranslation", + "pk": 3, + "fields": { + "category": 2, + "language": 1, + "name": "Behörde/Amt" + } + }, + { + "model": "cms.poicategorytranslation", + "pk": 4, + "fields": { + "category": 2, + "language": 2, + "name": "Authority/Office" + } + }, + { + "model": "cms.poicategorytranslation", + "pk": 5, + "fields": { + "category": 3, + "language": 1, + "name": "Gastronomie" + } + }, + { + "model": "cms.poicategorytranslation", + "pk": 6, + "fields": { + "category": 3, + "language": 2, + "name": "Gastronomy" + } + }, { "model": "linkcheck.url", "pk": 1, diff --git a/integreat_cms/cms/forms/__init__.py b/integreat_cms/cms/forms/__init__.py index e57eec7b71..70f66639ca 100644 --- a/integreat_cms/cms/forms/__init__.py +++ b/integreat_cms/cms/forms/__init__.py @@ -57,3 +57,8 @@ from .users.password_reset_form import CustomPasswordResetForm from .object_search_form import ObjectSearchForm + +from .poi_categories.poi_category_translation_form import ( + POICategoryTranslationForm, + poi_category_translation_formset_factory, +) diff --git a/integreat_cms/cms/forms/media/replace_media_file_form.py b/integreat_cms/cms/forms/media/replace_media_file_form.py index 9233faa91b..224c954f22 100644 --- a/integreat_cms/cms/forms/media/replace_media_file_form.py +++ b/integreat_cms/cms/forms/media/replace_media_file_form.py @@ -1,12 +1,12 @@ import logging import os -from datetime import datetime import magic from django import forms from django.conf import settings +from django.utils import timezone from django.utils.translation import ugettext as _ from ...constants import allowed_media @@ -112,7 +112,7 @@ def clean(self): # Add the calculated file_size to the form data if cleaned_data.get("file"): cleaned_data["file_size"] = cleaned_data.get("file").size - cleaned_data["last_modified"] = datetime.now() + cleaned_data["last_modified"] = timezone.now() logger.debug( "ReplaceMediaFileForm validated [2] with cleaned data %r", cleaned_data diff --git a/integreat_cms/cms/forms/media/upload_media_file_form.py b/integreat_cms/cms/forms/media/upload_media_file_form.py index 8f9bcd7079..dcd0acc297 100644 --- a/integreat_cms/cms/forms/media/upload_media_file_form.py +++ b/integreat_cms/cms/forms/media/upload_media_file_form.py @@ -1,12 +1,12 @@ import logging import mimetypes from os.path import splitext -from datetime import datetime import magic from django import forms from django.conf import settings +from django.utils import timezone from django.utils.translation import ugettext as _ from ...constants import allowed_media @@ -141,7 +141,7 @@ def clean(self): # Add the calculated file_size and the modification date to the form data if cleaned_data.get("file"): cleaned_data["file_size"] = cleaned_data.get("file").size - cleaned_data["last_modified"] = datetime.now() + cleaned_data["last_modified"] = timezone.now() logger.debug( "UploadMediaFileForm validated [2] with cleaned data %r", cleaned_data diff --git a/integreat_cms/cms/forms/organizations/organization_form.py b/integreat_cms/cms/forms/organizations/organization_form.py index bd3024a0d7..96e0fb5103 100644 --- a/integreat_cms/cms/forms/organizations/organization_form.py +++ b/integreat_cms/cms/forms/organizations/organization_form.py @@ -1,6 +1,14 @@ +import logging +from django import forms +from django.utils.translation import ugettext_lazy as _ + from ...models import Organization from ..icon_widget import IconWidget from ..custom_model_form import CustomModelForm +from ...utils.slug_utils import generate_unique_slug_helper + + +logger = logging.getLogger(__name__) class OrganizationForm(CustomModelForm): @@ -26,3 +34,46 @@ class Meta: widgets = { "icon": IconWidget(), } + + def __init__(self, **kwargs): + r""" + Initialize organization form + + :param \**kwargs: The supplied keyword arguments + :type \**kwargs: dict + """ + super().__init__(**kwargs) + self.fields["slug"].required = False + + def clean_slug(self): + """ + Validate the slug field (see :ref:`overriding-modelform-clean-method`) + + :return: A unique slug based on the input value + :rtype: str + """ + return generate_unique_slug_helper(self, "organization") + + def clean_name(self): + """ + Validate if form fields name is not already in use for another organization in the same region + (see :ref:`overriding-modelform-clean-method`) + :return: The name which is unique per region + :rtype: str + """ + cleaned_name = self.cleaned_data["name"] + if ( + Organization.objects.exclude(id=self.instance.id) + .filter(region=self.instance.region, name=cleaned_name) + .exists() + ): + self.add_error( + "name", + forms.ValidationError( + _( + "An organization with the same name already exists in this region. Please choose another name." + ), + code="invalid", + ), + ) + return cleaned_name diff --git a/integreat_cms/cms/forms/poi_categories/__init__.py b/integreat_cms/cms/forms/poi_categories/__init__.py new file mode 100644 index 0000000000..bd2b8cf596 --- /dev/null +++ b/integreat_cms/cms/forms/poi_categories/__init__.py @@ -0,0 +1,3 @@ +""" +Forms for creating and modifying POI Category and POI Category Translation objects +""" diff --git a/integreat_cms/cms/forms/poi_categories/poi_category_translation_form.py b/integreat_cms/cms/forms/poi_categories/poi_category_translation_form.py new file mode 100644 index 0000000000..12fe7875bb --- /dev/null +++ b/integreat_cms/cms/forms/poi_categories/poi_category_translation_form.py @@ -0,0 +1,126 @@ +import logging + +from django.core.exceptions import ValidationError +from django.forms import inlineformset_factory +from django.forms.models import BaseInlineFormSet +from django.forms.formsets import DELETION_FIELD_NAME +from django.utils.translation import gettext_lazy as _ + +from ...models import Language, POICategory, POICategoryTranslation +from ..custom_model_form import CustomModelForm + +logger = logging.getLogger(__name__) + + +class POICategoryTranslationForm(CustomModelForm): + """ + Form for creating and modifying POI category translation objects + """ + + def __init__(self, **kwargs): + r""" + Initialize POI category translation form + + :param \**kwargs: The supplied keyword arguments + :type \**kwargs: dict + """ + + # Instantiate CustomModelForm + super().__init__(**kwargs) + + # Do not require category and name fields + self.fields["category"].required = False + self.fields["name"].required = False + + # Set custom language labels + language_name = self.instance.language.translated_name + self.fields["name"].widget.attrs.update( + {"placeholder": _("Enter name in {} here").format(language_name)} + ) + self.fields["name"].label = _("Translation in {}").format(language_name) + + def clean(self): + """ + This method extends the ``clean()``-method to delete translations with an empty name. + + :return: The cleaned data (see :ref:`overriding-modelform-clean-method`) + :rtype: dict + """ + cleaned_data = super().clean() + # If the name field is empty, delete the form + if not cleaned_data.get("name") and "name" not in self.errors: + cleaned_data[DELETION_FIELD_NAME] = True + return cleaned_data + + class Meta: + """ + This class contains additional meta configuration of the form class, see the :class:`django.forms.ModelForm` + for more information. + """ + + #: The model of this :class:`django.forms.ModelForm` + model = POICategoryTranslation + #: The fields of the model which should be handled by this form + fields = ["category", "language", "name"] + + +class BaseInlinePOICategoryTranslationFormSet(BaseInlineFormSet): + """ + A formset for translations of POI categories + """ + + def get_form_kwargs(self, index): + """ + Return additional keyword arguments for each individual formset form. + (see :meth:`~django.views.generic.edit.ModelFormMixin.get_form_kwargs` and + :ref:`django:custom-formset-form-kwargs`) + + :param index: The index of the initialized form + (will be ``None`` if the form being constructed is a new empty form) + :type index: int + + :return: The form kwargs + :rtype: dict + """ + kwargs = super().get_form_kwargs(index) + # Only add the additional instances for extra forms which do not have the initial data + if index >= self.initial_form_count(): + # Get the relative index of all extra forms + rel_index = index - self.initial_form_count() + # Get all remaining languages + languages = Language.objects.exclude( + id__in=self.instance.translations.values_list("language__id", flat=True) + ) + # Assign the language to the form with this index + kwargs["additional_instance_attributes"] = { + "language": languages[rel_index] + } + return kwargs + + def clean(self): + """ + Make sure that at least one translation is given + + :raises ~django.core.exceptions.ValidationError: When not a single form contains a valid text + """ + super().clean() + if not any(form.cleaned_data.get("name") for form in self): + raise ValidationError(_("At least one translation is required.")) + + +def poi_category_translation_formset_factory(): + """ + Build the formset class + + :returns: The POICategoryTranslationFormset class + :rtype: type + """ + num_languages = Language.objects.count() + return inlineformset_factory( + parent_model=POICategory, + model=POICategoryTranslation, + form=POICategoryTranslationForm, + formset=BaseInlinePOICategoryTranslationFormSet, + min_num=num_languages, + max_num=num_languages, + ) diff --git a/integreat_cms/cms/forms/pois/poi_form.py b/integreat_cms/cms/forms/pois/poi_form.py index 4e8d2663a6..2cddeba908 100644 --- a/integreat_cms/cms/forms/pois/poi_form.py +++ b/integreat_cms/cms/forms/pois/poi_form.py @@ -44,6 +44,7 @@ class Meta: "website", "email", "phone_number", + "category", ] #: The widgets which are used in this form widgets = { diff --git a/integreat_cms/cms/forms/push_notifications/push_notification_form.py b/integreat_cms/cms/forms/push_notifications/push_notification_form.py index 5f1283505d..0bf04ba628 100644 --- a/integreat_cms/cms/forms/push_notifications/push_notification_form.py +++ b/integreat_cms/cms/forms/push_notifications/push_notification_form.py @@ -7,6 +7,21 @@ class PushNotificationForm(CustomModelForm): Form for creating and modifying push notification objects """ + def __init__(self, **kwargs): + r""" + Initialize push notification form + + :param \**kwargs: The supplied keyword arguments + :type \**kwargs: dict + """ + # Instantiate CustomModelForm + super().__init__(**kwargs) + + # Make fields disabled when push notification was already sent + if self.instance.sent_date: + self.fields["channel"].disabled = True + self.fields["mode"].disabled = True + class Meta: model = PushNotification fields = ["channel", "mode"] diff --git a/integreat_cms/cms/forms/push_notifications/push_notification_translation_form.py b/integreat_cms/cms/forms/push_notifications/push_notification_translation_form.py index 63f2ae52ba..4d25410bc7 100644 --- a/integreat_cms/cms/forms/push_notifications/push_notification_translation_form.py +++ b/integreat_cms/cms/forms/push_notifications/push_notification_translation_form.py @@ -4,6 +4,7 @@ from django.utils.translation import override, ugettext_lazy as _ from ..custom_model_form import CustomModelForm +from ...constants import push_notifications from ...models import PushNotificationTranslation logger = logging.getLogger(__name__) @@ -50,3 +51,19 @@ def add_error_messages(self, request): logger.debug( "PushNotificationTranslationForm submitted with errors: %r", self.errors ) + + def has_changed(self): + """ + Return ``True`` if submitted data differs from initial data. + If the main language should be used as fallback for missing translations, this always return ``True``. + + :return: Whether the form has changed + :rtype: bool + """ + if ( + hasattr(self.instance, "push_notification") + and self.instance.push_notification.mode + == push_notifications.USE_MAIN_LANGUAGE + ): + return True + return super().has_changed() diff --git a/integreat_cms/cms/forms/regions/region_form.py b/integreat_cms/cms/forms/regions/region_form.py index 62de7f98b2..7cbd31fe26 100644 --- a/integreat_cms/cms/forms/regions/region_form.py +++ b/integreat_cms/cms/forms/regions/region_form.py @@ -119,6 +119,7 @@ class Meta: "timezone", "fallback_translations_enabled", "summ_ai_enabled", + "hix_enabled", ] #: The widgets which are used in this form widgets = { @@ -149,6 +150,10 @@ def __init__(self, *args, **kwargs): self.instance and self.instance.summ_ai_enabled ): self.fields["summ_ai_enabled"].disabled = True + if not settings.TEXTLAB_API_ENABLED and not ( + self.instance and self.instance.hix_enabled + ): + self.fields["hix_enabled"].disabled = True def save(self, commit=True): """ @@ -395,6 +400,21 @@ def clean_summ_ai_enabled(self): return False return self.cleaned_data.get("summ_ai_enabled") + def clean_hix_enabled(self): + """ + Validate the hix_enabled field (see :ref:`overriding-modelform-clean-method`). + + :return: The validated field + :rtype: bool + """ + cleaned_hix_enabled = self.cleaned_data["hix_enabled"] + # Check whether someone tries to activate hix when no API key is set + if cleaned_hix_enabled and not settings.TEXTLAB_API_ENABLED: + self.add_error( + "hix_enabled", _("No Textlab API key is set on this system.") + ) + return cleaned_hix_enabled + @staticmethod def autofill_bounding_box(cleaned_data): """ diff --git a/integreat_cms/cms/migrations/0039_poi_category.py b/integreat_cms/cms/migrations/0039_poi_category.py new file mode 100644 index 0000000000..db0ab0790e --- /dev/null +++ b/integreat_cms/cms/migrations/0039_poi_category.py @@ -0,0 +1,126 @@ +# Generated by Django 3.2.16 on 2022-10-07 22:46 + +from django.core.management.sql import emit_post_migrate_signal +from django.db import migrations, models +import django.db.models.deletion + +from ..constants import roles + + +# pylint: disable=unused-argument +def update_roles(apps, schema_editor): + """ + Update permissions for service and management group + + :param apps: The configuration of installed applications + :type apps: ~django.apps.registry.Apps + + :param schema_editor: The database abstraction layer that creates actual SQL code + :type schema_editor: ~django.db.backends.base.schema.BaseDatabaseSchemaEditor + """ + Group = apps.get_model("auth", "Group") + Permission = apps.get_model("auth", "Permission") + + # Emit post-migrate signal to make sure the Permission objects are created before they can be assigned + emit_post_migrate_signal(2, False, "default") + + # Clear and update permissions according to new constants + for role_name in dict(roles.CHOICES): + group = Group.objects.filter(name=role_name).first() + # Clear permissions + group.permissions.clear() + # Set permissions + group.permissions.add( + *Permission.objects.filter(codename__in=roles.PERMISSIONS[role_name]) + ) + + +class Migration(migrations.Migration): + """ + Add the POICategory and POICategoryTranslation models + """ + + dependencies = [ + ("cms", "0038_region_summ_ai_enabled"), + ] + + operations = [ + migrations.CreateModel( + name="POICategory", + fields=[ + ( + "id", + models.BigAutoField( + auto_created=True, + primary_key=True, + serialize=False, + verbose_name="ID", + ), + ), + ], + options={ + "verbose_name": "location category", + "verbose_name_plural": "location categories", + "default_permissions": ("change", "delete", "view"), + }, + ), + migrations.CreateModel( + name="POICategoryTranslation", + fields=[ + ( + "id", + models.BigAutoField( + auto_created=True, + primary_key=True, + serialize=False, + verbose_name="ID", + ), + ), + ( + "name", + models.CharField( + help_text="The name of the POI category.", + max_length=250, + verbose_name="category name", + ), + ), + ( + "category", + models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, + related_name="translations", + to="cms.poicategory", + verbose_name="category", + ), + ), + ( + "language", + models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, + related_name="poi_category_translations", + to="cms.language", + verbose_name="language", + ), + ), + ], + options={ + "verbose_name": "location category translation", + "verbose_name_plural": "location category translations", + "ordering": ["category"], + "default_permissions": ("change", "delete", "view"), + }, + ), + migrations.AddField( + model_name="poi", + name="category", + field=models.ForeignKey( + blank=True, + null=True, + on_delete=django.db.models.deletion.SET_NULL, + related_name="pois", + to="cms.poicategory", + verbose_name="category", + ), + ), + migrations.RunPython(update_roles, migrations.RunPython.noop), + ] diff --git a/integreat_cms/cms/migrations/0040_limit_file_size.py b/integreat_cms/cms/migrations/0040_limit_file_size.py new file mode 100644 index 0000000000..ed3cb44cc5 --- /dev/null +++ b/integreat_cms/cms/migrations/0040_limit_file_size.py @@ -0,0 +1,37 @@ +# Generated by Django 3.2.15 on 2022-10-15 13:00 + +from django.db import migrations, models +import integreat_cms.cms.models.media.media_file + + +class Migration(migrations.Migration): + """ + Add file size check + """ + + dependencies = [ + ("cms", "0039_poi_category"), + ] + + operations = [ + migrations.AlterField( + model_name="mediafile", + name="file", + field=models.FileField( + max_length=512, + upload_to=integreat_cms.cms.models.media.media_file.upload_path, + validators=[integreat_cms.cms.models.media.media_file.file_size_limit], + verbose_name="file", + ), + ), + migrations.AlterField( + model_name="mediafile", + name="thumbnail", + field=models.FileField( + max_length=512, + upload_to=integreat_cms.cms.models.media.media_file.upload_path_thumbnail, + validators=[integreat_cms.cms.models.media.media_file.file_size_limit], + verbose_name="thumbnail file", + ), + ), + ] diff --git a/integreat_cms/cms/migrations/0041_region_hix_enabled.py b/integreat_cms/cms/migrations/0041_region_hix_enabled.py new file mode 100644 index 0000000000..6d7ba18bad --- /dev/null +++ b/integreat_cms/cms/migrations/0041_region_hix_enabled.py @@ -0,0 +1,25 @@ +# Generated by Django 3.2.14 on 2022-09-06 11:01 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + """ + Adds the hix_enabled region setting to the model. + """ + + dependencies = [ + ("cms", "0040_limit_file_size"), + ] + + operations = [ + migrations.AddField( + model_name="region", + name="hix_enabled", + field=models.BooleanField( + default=False, + help_text="Allow users of this region to analyze understandability of text content via TextLab API.", + verbose_name="Activate HIX analysis", + ), + ), + ] diff --git a/integreat_cms/cms/migrations/0042_alter_pushnotificationtranslation_title.py b/integreat_cms/cms/migrations/0042_alter_pushnotificationtranslation_title.py new file mode 100644 index 0000000000..5f3f7d0ca0 --- /dev/null +++ b/integreat_cms/cms/migrations/0042_alter_pushnotificationtranslation_title.py @@ -0,0 +1,24 @@ +""" +Make title for push notification translations optional +""" +# Generated by Django 3.2.15 on 2022-09-23 12:48 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + """ + Make title for push notification translations optional + """ + + dependencies = [ + ("cms", "0041_region_hix_enabled"), + ] + + operations = [ + migrations.AlterField( + model_name="pushnotificationtranslation", + name="title", + field=models.CharField(blank=True, max_length=250, verbose_name="title"), + ), + ] diff --git a/integreat_cms/cms/migrations/0043_update_organization_permissions.py b/integreat_cms/cms/migrations/0043_update_organization_permissions.py new file mode 100644 index 0000000000..c451657910 --- /dev/null +++ b/integreat_cms/cms/migrations/0043_update_organization_permissions.py @@ -0,0 +1,44 @@ +# Generated by Django 3.2.11 on 2022-05-18 00:05 +from django.db import migrations +from ..constants import roles + + +# pylint: disable=unused-argument +def update_roles(apps, schema_editor): + """ + Update the role definitions + + :param apps: The configuration of installed applications + :type apps: ~django.apps.registry.Apps + + :param schema_editor: The database abstraction layer that creates actual SQL code + :type schema_editor: ~django.db.backends.base.schema.BaseDatabaseSchemaEditor + """ + # We can't import the Person model directly as it may be a newer + # version than this migration expects. We use the historical version. + Group = apps.get_model("auth", "Group") + Permission = apps.get_model("auth", "Permission") + + # Assign the correct permissions + for role_name in dict(roles.CHOICES): + group = Group.objects.filter(name=role_name).first() + # Clear permissions + group.permissions.clear() + # Set permissions + group.permissions.add( + *Permission.objects.filter(codename__in=roles.PERMISSIONS[role_name]) + ) + + +class Migration(migrations.Migration): + """ + Migration file to update the role definitions + """ + + dependencies = [ + ("cms", "0042_alter_pushnotificationtranslation_title"), + ] + + operations = [ + migrations.RunPython(update_roles, migrations.RunPython.noop), + ] diff --git a/integreat_cms/cms/models/__init__.py b/integreat_cms/cms/models/__init__.py index 91a8d11a1d..18b7f7d7cb 100644 --- a/integreat_cms/cms/models/__init__.py +++ b/integreat_cms/cms/models/__init__.py @@ -47,3 +47,6 @@ from .users.organization import Organization from .users.role import Role from .users.user_mfa_key import UserMfaKey + +from .poi_categories.poi_category import POICategory +from .poi_categories.poi_category_translation import POICategoryTranslation diff --git a/integreat_cms/cms/models/abstract_base_model.py b/integreat_cms/cms/models/abstract_base_model.py index 78daec4f81..ce79746cc8 100644 --- a/integreat_cms/cms/models/abstract_base_model.py +++ b/integreat_cms/cms/models/abstract_base_model.py @@ -13,6 +13,22 @@ class AbstractBaseModel(models.Model): Abstract base class for all models """ + @classmethod + def get_model_name_plural(cls): + """ + Get the plural representation of this model name + + :returns: The plural model name + :rtype: str + """ + model_name = cls._meta.model_name + # Build correct plural of models ending with "y" + if model_name.endswith("y"): + model_name_plural = f"{model_name[:-1]}ies" + else: + model_name_plural = f"{model_name}s" + return model_name_plural + def get_repr(self): """ Returns the canonical string representation of the content object diff --git a/integreat_cms/cms/models/media/media_file.py b/integreat_cms/cms/models/media/media_file.py index 3db1dc43e6..3871af53eb 100644 --- a/integreat_cms/cms/models/media/media_file.py +++ b/integreat_cms/cms/models/media/media_file.py @@ -14,7 +14,7 @@ from django.utils.formats import localize from django.utils.translation import ugettext_lazy as _ from django.template.defaultfilters import filesizeformat - +from django.core.exceptions import ValidationError from ...constants import allowed_media from ..abstract_base_model import AbstractBaseModel @@ -97,6 +97,24 @@ def upload_path_thumbnail(instance, filename): return path +def file_size_limit(value): + """ + This function checks if the uploaded file exceeds the file size limit + + :param value: the size of upload file + :type value: int + + :raises ~django.core.exceptions.ValidationError: when the file size exceeds the size given in the settings. + + """ + if value.size > settings.MEDIA_MAX_UPLOAD_SIZE: + raise ValidationError( + _("File too large. Size should not exceed {}.").format( + filesizeformat(settings.MEDIA_MAX_UPLOAD_SIZE) + ) + ) + + class MediaFile(AbstractBaseModel): """ The MediaFile model is used to store basic information about files which are uploaded to the CMS. This is only a @@ -106,11 +124,13 @@ class MediaFile(AbstractBaseModel): file = models.FileField( upload_to=upload_path, + validators=[file_size_limit], verbose_name=_("file"), max_length=512, ) thumbnail = models.FileField( upload_to=upload_path_thumbnail, + validators=[file_size_limit], verbose_name=_("thumbnail file"), max_length=512, ) @@ -195,6 +215,7 @@ def serialize(self): "url": self.url, "fileSize": filesizeformat(self.file_size), "uploadedDate": localize(timezone.localtime(self.uploaded_date)), + "lastModified": localize(timezone.localtime(self.last_modified)), "isGlobal": not self.region, } diff --git a/integreat_cms/cms/models/poi_categories/__init__.py b/integreat_cms/cms/models/poi_categories/__init__.py new file mode 100644 index 0000000000..7d0c5f2cd6 --- /dev/null +++ b/integreat_cms/cms/models/poi_categories/__init__.py @@ -0,0 +1,7 @@ +""" +This package contains page-related data models: + + +* :class:`~integreat_cms.cms.models.poi_categories.poi_category.POICategory` and + :class:`~integreat_cms.cms.models.poi_categories.poi_category_translation.POICategoryTranslation` +""" diff --git a/integreat_cms/cms/models/poi_categories/poi_category.py b/integreat_cms/cms/models/poi_categories/poi_category.py new file mode 100644 index 0000000000..378de1ac59 --- /dev/null +++ b/integreat_cms/cms/models/poi_categories/poi_category.py @@ -0,0 +1,86 @@ +from django.utils.functional import cached_property +from django.utils.translation import get_language, gettext_lazy as _ + +from ..abstract_base_model import AbstractBaseModel + + +class POICategory(AbstractBaseModel): + """ + Data model representing a POI category. + """ + + @cached_property + def name(self): + """ + This function returns the name of the category in the "best" translation + + :return: The name of the category + :rtype: str + """ + return ( + self.best_translation.name + if self.best_translation + else str(_("POI category")) + ) + + def get_translation(self, language_slug): + """ + Get the translation of this category in a given language + + :param language_slug: language in which the poi category is to be shown + :type language_slug: Language + + :return: translation of the poi category in the language + if no translation is saved for the language, the category name of the POICategory + :rtype: ~integreat_cms.cms.models.poi_categories.poi_category_translation.POICategoryTranslation + """ + return self.translations.filter(language__slug=language_slug).first() + + @cached_property + def backend_translation(self): + """ + This function returns the translation of this content object in the current backend language. + + :return: The backend translation of a content object + :rtype: ~integreat_cms.cms.models.poi_categories.poi_category_translation.POICategoryTranslation + """ + return self.get_translation(get_language()) + + @cached_property + def best_translation(self): + """ + This function returns the translation of this category in the current backend language and if it doesn't + exist, it provides a fallback to the first translation. + + :return: The "best" translation of this category for displaying in the backend + :rtype: ~integreat_cms.cms.models.poi_categories.poi_category_translation.POICategoryTranslation + """ + return self.backend_translation or self.translations.first() + + def __str__(self): + """ + This overwrites the default Django :meth:`~django.db.models.Model.__str__` method which would return ``POICategory object (id)``. + It is used in the Django admin backend and as label for ModelChoiceFields. + + :return: A readable string representation of the category + :rtype: str + """ + return self.name + + def get_repr(self): + """ + This overwrites the default Django ``__repr__()`` method which would return ````. + It is used for logging. + + :return: The canonical string representation of the category + :rtype: str + """ + return f"" + + class Meta: + #: The verbose name of the model + verbose_name = _("location category") + #: The plural verbose name of the model + verbose_name_plural = _("location categories") + #: The default permissions for this model + default_permissions = ("change", "delete", "view") diff --git a/integreat_cms/cms/models/poi_categories/poi_category_translation.py b/integreat_cms/cms/models/poi_categories/poi_category_translation.py new file mode 100644 index 0000000000..e42923052e --- /dev/null +++ b/integreat_cms/cms/models/poi_categories/poi_category_translation.py @@ -0,0 +1,60 @@ +from django.db import models +from django.utils.translation import ugettext_lazy as _ + +from ..abstract_base_model import AbstractBaseModel +from ..languages.language import Language +from .poi_category import POICategory + + +class POICategoryTranslation(AbstractBaseModel): + """ + Data model representing a POI category translation. + """ + + category = models.ForeignKey( + POICategory, + on_delete=models.CASCADE, + related_name="translations", + verbose_name=_("category"), + ) + language = models.ForeignKey( + Language, + on_delete=models.CASCADE, + related_name="poi_category_translations", + verbose_name=_("language"), + ) + name = models.CharField( + max_length=250, + verbose_name=_("category name"), + help_text=_("The name of the POI category."), + ) + + def __str__(self): + """ + This overwrites the default Django :meth:`~django.db.models.Model.__str__` method which would return ``POICategoryTranslation object (name)``. + It is used in the Django admin backend and as label for ModelChoiceFields. + + :return: A readable string representation of the category translation + :rtype: str + """ + return self.name + + def get_repr(self): + """ + This overwrites the default Django ``__repr__()`` method which would return ````. + It is used for logging. + + :return: The canonical string representation of the category translation + :rtype: str + """ + return f"" + + class Meta: + #: The verbose name of the model + verbose_name = _("location category translation") + #: The plural verbose name of the model + verbose_name_plural = _("location category translations") + #: The default permissions for this model + default_permissions = ("change", "delete", "view") + #: The default sorting for this model + ordering = ["category"] diff --git a/integreat_cms/cms/models/pois/poi.py b/integreat_cms/cms/models/pois/poi.py index 561cedabcd..2ab69cbca8 100644 --- a/integreat_cms/cms/models/pois/poi.py +++ b/integreat_cms/cms/models/pois/poi.py @@ -5,6 +5,7 @@ from ..abstract_content_model import AbstractContentModel from ..media.media_file import MediaFile from ..pois.poi_translation import POITranslation +from ..poi_categories.poi_category import POICategory class POI(AbstractContentModel): @@ -58,6 +59,14 @@ class POI(AbstractContentModel): phone_number = models.CharField( max_length=250, blank=True, verbose_name=_("phone number") ) + category = models.ForeignKey( + POICategory, + null=True, + blank=True, + on_delete=models.SET_NULL, + related_name="pois", + verbose_name=_("category"), + ) @property def fallback_translations_enabled(self): diff --git a/integreat_cms/cms/models/push_notifications/push_notification.py b/integreat_cms/cms/models/push_notifications/push_notification.py index 5a5b459181..d6898b0d18 100644 --- a/integreat_cms/cms/models/push_notifications/push_notification.py +++ b/integreat_cms/cms/models/push_notifications/push_notification.py @@ -1,3 +1,6 @@ +""" +The model for the push notificatiion +""" from django.conf import settings from django.db import models from django.utils.functional import cached_property diff --git a/integreat_cms/cms/models/push_notifications/push_notification_translation.py b/integreat_cms/cms/models/push_notifications/push_notification_translation.py index ac45f2b5e1..8126cdd909 100644 --- a/integreat_cms/cms/models/push_notifications/push_notification_translation.py +++ b/integreat_cms/cms/models/push_notifications/push_notification_translation.py @@ -1,9 +1,11 @@ from django.db import models from django.utils.translation import ugettext_lazy as _ +from django.conf import settings from ..abstract_base_model import AbstractBaseModel from ..languages.language import Language from .push_notification import PushNotification +from ...constants import push_notifications as pnt_const class PushNotificationTranslation(AbstractBaseModel): @@ -11,10 +13,7 @@ class PushNotificationTranslation(AbstractBaseModel): Data model representing a push notification translation """ - title = models.CharField( - max_length=250, - verbose_name=_("title"), - ) + title = models.CharField(max_length=250, blank=True, verbose_name=_("title")) text = models.TextField( max_length=250, blank=True, @@ -56,6 +55,43 @@ def search(cls, region, language_slug, query): title__icontains=query, ) + def get_title(self): + """ + Get the title of the notification translation. + + :return: A title for the push notification + :rtype: str + """ + if ( + self.push_notification.mode == pnt_const.USE_MAIN_LANGUAGE + and self.title == "" + and self.push_notification.default_translation + ): + return self.push_notification.default_translation.title + return self.title + + def get_text(self): + """ + Get the text of the notification. Construct a fallback text if possible. + + :return: A text for the push notification + :rtype: str + """ + if ( + self.push_notification.mode == pnt_const.USE_MAIN_LANGUAGE + and self.text == "" + ): + translations = "\n".join( + [ + f"{translation.language.native_name}: {settings.WEBAPP_URL}{translation.get_absolute_url()}" + for translation in self.push_notification.translations.exclude( + text="" + ) + ] + ) + return f"{self.language.message_content_not_available}\n{translations}" + return self.text + def get_absolute_url(self): """ Generates the absolute url to a news object in the app diff --git a/integreat_cms/cms/models/regions/region.py b/integreat_cms/cms/models/regions/region.py index fde6c391ef..f82c9923fa 100644 --- a/integreat_cms/cms/models/regions/region.py +++ b/integreat_cms/cms/models/regions/region.py @@ -298,6 +298,14 @@ class Region(AbstractBaseModel): ), ) + hix_enabled = models.BooleanField( + default=False, + verbose_name=_("Activate HIX analysis"), + help_text=_( + "Allow users of this region to analyze understandability of text content via TextLab API." + ), + ) + summ_ai_enabled = models.BooleanField( default=False, verbose_name=_("activate automatic translations via SUMM.AI"), diff --git a/integreat_cms/cms/models/users/organization.py b/integreat_cms/cms/models/users/organization.py index 9d945b4de0..728b771c20 100644 --- a/integreat_cms/cms/models/users/organization.py +++ b/integreat_cms/cms/models/users/organization.py @@ -61,6 +61,23 @@ def get_repr(self): """ return f"" + @property + def num_pages(self): + """ + + :return: the current number of maintained pages of an organization object + :rtype: int + """ + return self.pages.count() + + @property + def num_members(self): + """ + :return: the current number of members of an organization object + :rtype: int + """ + return self.members.count() + class Meta: #: The verbose name of the model verbose_name = _("organization") diff --git a/integreat_cms/cms/templates/_base.html b/integreat_cms/cms/templates/_base.html index 4a0c19cb37..ff2926f61c 100644 --- a/integreat_cms/cms/templates/_base.html +++ b/integreat_cms/cms/templates/_base.html @@ -12,7 +12,7 @@ target="_blank" rel="noopener noreferrer" class="relative px-2 pt-4 text-gray-800 hover:bg-gray-200 border-r border-gray-400"> - + {% trans 'Django Admin' %} {% endif %} @@ -34,15 +34,15 @@ {% if request.region %} - + {{ request.region.full_name }} - {% if request.region_selection or user.is_superuser or user.is_staff %}{% endif %} + {% if request.region_selection or user.is_superuser or user.is_staff %}{% endif %} {% else %} - + {% trans 'Network Management' %} - {% if request.region_selection or user.is_superuser or user.is_staff %}{% endif %} + {% if request.region_selection or user.is_superuser or user.is_staff %}{% endif %} {% endif %} @@ -51,7 +51,7 @@ {% for region in request.region_selection %} - + {{ region.full_name }} {% endfor %} @@ -59,7 +59,7 @@ {% if request.region %} - + {% trans 'Network Management' %} {% endif %} @@ -70,20 +70,20 @@ class="relative px-2 text-gray-800 flex flex-col justify-center cursor-pointer hover:bg-gray-200"> - + {{ request.user.full_user_name }} - + @@ -96,11 +96,11 @@
-