From 094471670df108db4d06cb5e46ab46464397f39b Mon Sep 17 00:00:00 2001 From: Peter Nerlich Date: Thu, 10 Oct 2024 23:06:35 +0200 Subject: [PATCH] Feature: Insert contact cards into TinyMCE --- integreat_cms/cms/models/contact/contact.py | 130 ++++++++++- integreat_cms/cms/models/pois/poi.py | 19 ++ .../cms/models/pois/poi_translation.py | 11 + .../templates/_related_contents_table.html | 104 ++++----- .../cms/templates/_tinymce_config.html | 7 + .../ajax_poi_form/_poi_address_container.html | 4 +- .../cms/templates/contacts/contact_card.html | 56 +++++ .../organizations/organization_form.html | 24 +-- integreat_cms/cms/templatetags/svg_tags.py | 23 ++ integreat_cms/cms/urls/protected.py | 15 ++ integreat_cms/cms/utils/content_utils.py | 45 +++- .../cms/utils/internal_link_checker.py | 10 +- .../cms/views/contacts/contact_form_view.py | 44 +++- integreat_cms/cms/views/utils/__init__.py | 1 + .../cms/views/utils/contact_utils.py | 98 +++++++++ integreat_cms/core/signals/__init__.py | 8 +- integreat_cms/core/signals/contact_signals.py | 37 ++++ integreat_cms/locale/de/LC_MESSAGES/django.po | 28 ++- integreat_cms/static/src/css/contact_card.css | 25 +++ integreat_cms/static/src/css/style.scss | 2 + .../static/src/css/tinymce_custom.css | 9 +- integreat_cms/static/src/css/tomselect.scss | 99 +++++++++ integreat_cms/static/src/editor_content.ts | 1 + .../static/src/js/forms/tinymce-init.ts | 4 +- .../custom_contact_input/plugin.js | 202 ++++++++++++++++++ .../custom_link_input/plugin.js | 2 +- package-lock.json | 33 +++ package.json | 1 + 28 files changed, 961 insertions(+), 81 deletions(-) create mode 100644 integreat_cms/cms/templates/contacts/contact_card.html create mode 100644 integreat_cms/cms/templatetags/svg_tags.py create mode 100644 integreat_cms/cms/views/utils/contact_utils.py create mode 100644 integreat_cms/core/signals/contact_signals.py create mode 100644 integreat_cms/static/src/css/contact_card.css create mode 100644 integreat_cms/static/src/css/tomselect.scss create mode 100644 integreat_cms/static/src/js/tinymce-plugins/custom_contact_input/plugin.js diff --git a/integreat_cms/cms/models/contact/contact.py b/integreat_cms/cms/models/contact/contact.py index c6f63ad9f0..0e3217c271 100644 --- a/integreat_cms/cms/models/contact/contact.py +++ b/integreat_cms/cms/models/contact/contact.py @@ -1,16 +1,30 @@ +from __future__ import annotations + +from typing import Generator, TYPE_CHECKING + +from django.conf import settings +from django.contrib.postgres.search import SearchQuery, SearchRank, SearchVector from django.db import models from django.db.models import Q -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 linkcheck.models import Link from ..abstract_base_model import AbstractBaseModel +from ..events.event_translation import EventTranslation from ..fields.truncating_char_field import TruncatingCharField +from ..pages.page_translation import PageTranslation from ..pois.poi import POI +from ..pois.poi_translation import POITranslation from ..regions.region import Region +if TYPE_CHECKING: + from django.db.models.query import QuerySet + + from ..abstract_content_translation import AbstractContentTranslation + class Contact(AbstractBaseModel): """ @@ -59,6 +73,28 @@ def region(self) -> Region: """ return self.location.region + @classmethod + def search(cls, region: Region, query: str) -> QuerySet: + """ + Searches for all contacts which match the given `query` in their comment. + :param region: The current region + :param query: The query string used for filtering the contacts + :return: A query for all matching objects + """ + vector = SearchVector( + "name", + "email", + "phone_number", + "website", + "point_of_contact_for", + ) + query = SearchQuery(query) + return ( + Contact.objects.filter(location__region=region, archived=False) + .annotate(rank=SearchRank(vector, query)) + .order_by("-rank") + ) + def __str__(self) -> str: """ This overwrites the default Django :meth:`~django.db.models.Model.__str__` method which would return ``Contact object (id)``. @@ -116,6 +152,89 @@ def get_repr(self) -> str: """ return f"" + @cached_property + def get_repr_short(self) -> str: + """ + Returns a short representation only contaiing the relevant data, no field names. + + :return: The short representation of the contact + """ + point_of_contact_for = ( + f"{self.point_of_contact_for}: " if self.point_of_contact_for else "" + ) + name = f"{self.name} " if self.name else "" + details = [ + detail for detail in [self.email, self.phone_number, self.website] if detail + ] + details_repr = f"({', '.join(details)})" if details else "" + + return f"{point_of_contact_for}{name}{details_repr}".strip() + + @cached_property + def referring_page_translations(self) -> QuerySet[PageTranslation]: + """ + Returns a queryset containing all :class:`~integreat_cms.cms.models.pages.page_translation.PageTranslation` objects which reference this contact + + :return: all PageTranslation objects referencing this contact + """ + from ...linklists import PageTranslationLinklist + + return PageTranslation.objects.filter( + id__in=( + Link.objects.filter( + url__url=self.full_url, + content_type=PageTranslationLinklist.content_type(), + ).values("object_id") + ), + ) + + @cached_property + def referring_poi_translations(self) -> QuerySet[POITranslation]: + """ + Returns a queryset containing all :class:`~integreat_cms.cms.models.pois.poi_translation.POITranslation` objects which reference this contact + + :return: all POITranslation objects referencing this contact + """ + from ...linklists import POITranslationLinklist + + return POITranslation.objects.filter( + id__in=( + Link.objects.filter( + url__url=self.full_url, + content_type=POITranslationLinklist.content_type(), + ).values("object_id") + ), + ) + + @cached_property + def referring_event_translations(self) -> QuerySet[EventTranslation]: + """ + Returns a queryset containing all :class:`~integreat_cms.cms.models.events.event_translation.EventTranslation` objects which reference this contact + + :return: all EventTranslation objects referencing this contact + """ + from ...linklists import EventTranslationLinklist + + return EventTranslation.objects.filter( + id__in=( + Link.objects.filter( + url__url=self.full_url, + content_type=EventTranslationLinklist.content_type(), + ).values("object_id") + ), + ) + + @cached_property + def referring_objects(self) -> Generator[AbstractContentTranslation]: + """ + Returns a list of all objects linking to this contact. + + :return: all objects referring to this contact + """ + return ( + link.content_object for link in Link.objects.filter(url__url=self.full_url) + ) + def archive(self) -> None: """ Archives the contact @@ -138,6 +257,15 @@ def copy(self) -> None: self.point_of_contact_for = self.point_of_contact_for + " " + _("(Copy)") self.save() + @cached_property + def full_url(self) -> str: + """ + This property returns the full url of this contact + + :return: The full url + """ + return f"{settings.BASE_URL}/{self.location.region.slug}/contact/{self.id}/" + class Meta: verbose_name = _("contact") default_related_name = "contact" diff --git a/integreat_cms/cms/models/pois/poi.py b/integreat_cms/cms/models/pois/poi.py index 07278df213..04d7dd171c 100644 --- a/integreat_cms/cms/models/pois/poi.py +++ b/integreat_cms/cms/models/pois/poi.py @@ -5,6 +5,7 @@ from django.core.validators import MaxValueValidator, MinValueValidator from django.db import models +from django.utils.functional import cached_property if TYPE_CHECKING: from typing import Any @@ -200,6 +201,24 @@ def is_used(self) -> bool: """ return self.events.exists() or self.contacts.exists() + @cached_property + def short_address(self) -> str: + """ + :return: one-line representation of this POI's address + """ + return f"{self.address}, {self.postcode} {self.city}" + + @cached_property + def map_url(self) -> str: + """ + :return: the link to the POI of the default (public) translation + """ + return ( + self.default_public_translation.map_url + if self.default_public_translation + else self.default_translation.map_url + ) + class Meta: #: The verbose name of the model verbose_name = _("location") diff --git a/integreat_cms/cms/models/pois/poi_translation.py b/integreat_cms/cms/models/pois/poi_translation.py index 6e0ec2f3ec..ce40eaecae 100644 --- a/integreat_cms/cms/models/pois/poi_translation.py +++ b/integreat_cms/cms/models/pois/poi_translation.py @@ -2,6 +2,7 @@ from typing import TYPE_CHECKING +from django.conf import settings from django.contrib.contenttypes.fields import GenericRelation from django.db import models from django.db.models import Q @@ -17,6 +18,7 @@ from .. import POI, Region +from ...constants import status from ...utils.translation_utils import gettext_many_lazy as __ from ..abstract_content_translation import AbstractContentTranslation from ..decorators import modify_fields @@ -98,6 +100,15 @@ def backend_edit_link(self) -> str: }, ) + @cached_property + def map_url(self) -> str: + """ + :return: the link to the POI on the Integreat map (if it exists), to google maps otherwise + """ + if not self.poi.location_on_map and not self.status == status.DRAFT: + return f"{settings.WEBAPP_URL}{self.get_absolute_url()}" + return f"https://www.google.com/maps/search/?api=1&query={self.poi.address},{self.poi.city},{self.poi.country}" + @staticmethod def default_icon() -> str | None: """ diff --git a/integreat_cms/cms/templates/_related_contents_table.html b/integreat_cms/cms/templates/_related_contents_table.html index 55f252ac72..430df914ba 100644 --- a/integreat_cms/cms/templates/_related_contents_table.html +++ b/integreat_cms/cms/templates/_related_contents_table.html @@ -1,49 +1,38 @@ {% load i18n %} {% load content_filters %} -
-
- -
- - - - - {% if region_default_language != backend_language %} +{% get_current_language as LANGUAGE_CODE %} +{% get_language LANGUAGE_CODE as backend_language %} +{% with request.region.default_language as region_default_language %} +
+
+ +
+
- {% translate "Name in " %} {{ region_default_language.translated_name }} -
+ + - {% endif %} - - - - {% for content in contents %} - {% get_translation content region_default_language.slug as content_translation %} - {% get_translation content backend_language.slug as backendlang_content_translation %} - - {% if content_translation %} - - {% else %} - - {% endif %} {% if region_default_language != backend_language %} - {% if backendlang_content_translation %} + + {% endif %} + + + + {% for content in contents %} + {% get_translation content region_default_language.slug as content_translation %} + {% get_translation content backend_language.slug as backendlang_content_translation %} + + {% if content_translation %} {% else %} @@ -51,15 +40,30 @@ {% translate "Translation not available" %} {% endif %} - {% endif %} - - {% empty %} - - - - {% endfor %} - -
- {% translate "Name in " %} {{ backend_language.translated_name }} + {% translate "Name in " %} {{ region_default_language.translated_name }}
- - {{ content_translation.title }} - - - {% translate "Translation not available" %} - + {% translate "Name in " %} {{ backend_language.translated_name }} +
- - {{ backendlang_content_translation.title }} + {{ content_translation.title }}
- {% trans no_content_message %} -
-
+ {% if region_default_language != backend_language %} + {% if backendlang_content_translation %} + + + {{ backendlang_content_translation.title }} + + + {% else %} + + {% translate "Translation not available" %} + + {% endif %} + {% endif %} + + {% empty %} + + + {% trans no_content_message %} + + + {% endfor %} + + + +{% endwith %} diff --git a/integreat_cms/cms/templates/_tinymce_config.html b/integreat_cms/cms/templates/_tinymce_config.html index 0ee4af4f3f..0cfb4a254e 100644 --- a/integreat_cms/cms/templates/_tinymce_config.html +++ b/integreat_cms/cms/templates/_tinymce_config.html @@ -45,9 +45,16 @@ data-group-icon-text='{% translate "Group" %}' data-group-icon-src="{% get_base_url %}{% static 'svg/group.svg' %}" data-group-icon-alt="{% translate "A group of people" %}" + data-contact-dialog-search-text='{% translate "Search for contact" %}' + data-contact-dialog-title-text='{% translate "Add Contact" %}' + data-contact-change-text='{% translate "Change Contact" %}' + data-contact-remove-text='{% translate "Remove Contact" %}' data-contact-icon-text='{% translate "Contact Person" %}' data-contact-icon-src="{% get_base_url %}{% static 'svg/contact.svg' %}" data-contact-icon-alt="{% translate "Contact Person" %}" + data-contact-ajax-url="{% url 'search_contact_ajax' region_slug=request.region.slug %}" + data-contact-menu-text='{% translate "Contact..." %}' + data-contact-no-results-text='{% translate "no results" %}' 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" %}" diff --git a/integreat_cms/cms/templates/ajax_poi_form/_poi_address_container.html b/integreat_cms/cms/templates/ajax_poi_form/_poi_address_container.html index 9a08021ad6..3b4278b525 100644 --- a/integreat_cms/cms/templates/ajax_poi_form/_poi_address_container.html +++ b/integreat_cms/cms/templates/ajax_poi_form/_poi_address_container.html @@ -17,11 +17,11 @@ {{ poi.country }} {% endif %} - - {% translate "Open on Google Maps" %} + {% translate "Open on Maps" %}