From 5e76e83e6a6035bc8f892790a20e49d99c830c65 Mon Sep 17 00:00:00 2001 From: charludo Date: Tue, 12 Nov 2024 09:07:21 +0100 Subject: [PATCH] TinyMCE: Insert contact, but track using linkcheck, v2 --- integreat_cms/cms/models/contact/contact.py | 125 +++++--- .../cms/templates/_tinymce_config.html | 3 +- .../cms/templates/contacts/contact_card.html | 76 +++-- integreat_cms/cms/urls/protected.py | 25 +- integreat_cms/cms/utils/content_utils.py | 89 ++---- .../cms/utils/internal_link_checker.py | 10 +- .../cms/views/contacts/contact_form_view.py | 49 +--- .../cms/views/events/event_form_view.py | 3 +- .../cms/views/imprint/imprint_form_view.py | 5 +- integreat_cms/cms/views/mixins.py | 23 -- .../cms/views/pages/page_form_view.py | 3 +- integreat_cms/cms/views/pois/poi_form_view.py | 3 +- integreat_cms/cms/views/utils/__init__.py | 1 + .../cms/views/utils/contact_utils.py | 97 +++++++ .../cms/views/utils/search_content_ajax.py | 33 --- integreat_cms/core/signals/__init__.py | 8 +- integreat_cms/core/signals/contact_signals.py | 40 +++ integreat_cms/locale/de/LC_MESSAGES/django.po | 4 + .../static/src/css/tinymce_custom.css | 23 +- .../custom_contact_input/plugin.js | 267 +++++------------- 20 files changed, 430 insertions(+), 457 deletions(-) create mode 100644 integreat_cms/cms/views/utils/contact_utils.py create mode 100644 integreat_cms/core/signals/contact_signals.py diff --git a/integreat_cms/cms/models/contact/contact.py b/integreat_cms/cms/models/contact/contact.py index eb6a3c9290..72953d20e9 100644 --- a/integreat_cms/cms/models/contact/contact.py +++ b/integreat_cms/cms/models/contact/contact.py @@ -5,6 +5,7 @@ from typing import 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 @@ -12,10 +13,14 @@ from django.utils.functional import cached_property, classproperty 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: @@ -60,10 +65,6 @@ class Contact(AbstractBaseModel): default=timezone.now, verbose_name=_("creation date") ) - _url_regex = re.compile( - r"^https:\/\/integreat\.app\/([^/?#]+)\/contact\/([0-9]+)\/" - ) - @cached_property def region(self) -> Region: """ @@ -81,36 +82,19 @@ def search(cls, region: Region, query: str) -> QuerySet: :param query: The query string used for filtering the contacts :return: A query for all matching objects """ - searchable_fields = ( - "point_of_contact_for", + 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") ) - - q = models.Q() - - for word in query.split(): - # Every word has to appear in at least one field - OR = [ - models.Q(**{f"{field}__icontains": word}) for field in searchable_fields - ] - # We OR whether it appears in each of the field, and - # AND those expressions corresponding to each word - # because we are not interested in objects where one word is missing - q &= reduce(lambda a, b: a | b, OR) - - # We could add annotations to determine how closely each result matches the query, - # e.g. by finding the length of the longest common substring between each field and the original query, - # taking the square of that value to obtain something representing the "contribution" of that field - # (so longer matches in only a few fields get a much higher value than many short matches all over) - # and then summing those together to obtain an overall score of how relevant that object is to the query, - # but that would require us find the longest common substring on the db level, - # and that feels a bit overkill for now (it will likely be confusing to re-discover and maintain, - # especially if we were to also employ fuzzy matching – which would be much preferred, if we can do it) - - return cls.objects.filter(q, location__region=region) def __str__(self) -> str: """ @@ -169,6 +153,78 @@ 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_list("object_id", flat=True) + ), + ) + + @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_list("object_id", flat=True) + ), + ) + + @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_list("object_id", flat=True) + ), + ) + def archive(self) -> None: """ Archives the contact @@ -191,10 +247,6 @@ def copy(self) -> None: self.point_of_contact_for = self.point_of_contact_for + " " + _("(Copy)") self.save() - @classproperty - def url_regex(cls) -> re.Pattern: - return cls._url_regex - @cached_property def url_prefix(self) -> str: """ @@ -236,8 +288,8 @@ def base_link(self) -> str: :return: the base link of the content """ if not self.id: - return settings.WEBAPP_URL + "/" - return settings.WEBAPP_URL + self.url_prefix + return settings.BASE_URL + "/" + return settings.BASE_URL + self.url_prefix def get_absolute_url(self) -> str: """ @@ -264,7 +316,8 @@ def full_url(self) -> str: :return: The full url """ - return settings.WEBAPP_URL + self.get_absolute_url() + # f"{settings.WEBAPP_URL}/{self.location.region.slug}/contact/{self.id}/" + return settings.BASE_URL + self.get_absolute_url() class Meta: verbose_name = _("contact") diff --git a/integreat_cms/cms/templates/_tinymce_config.html b/integreat_cms/cms/templates/_tinymce_config.html index ed7d6a42e6..06ff535ac1 100644 --- a/integreat_cms/cms/templates/_tinymce_config.html +++ b/integreat_cms/cms/templates/_tinymce_config.html @@ -53,10 +53,9 @@ 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_content_ajax' region_slug=request.region.slug language_slug=language.slug %}" + 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-contact-url-regex="{{ contact_url_regex }}" 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/contacts/contact_card.html b/integreat_cms/cms/templates/contacts/contact_card.html index 2b4324248c..08fe137dc9 100644 --- a/integreat_cms/cms/templates/contacts/contact_card.html +++ b/integreat_cms/cms/templates/contacts/contact_card.html @@ -1,43 +1,31 @@ +{% load settings_tags %} {% load static %} {% spaceless %} -
- Contact +
+ Contact {% if contact %} -

{% if contact.point_of_contact_for and contact.point_of_contact_for.strip %} +

+ {% if contact.point_of_contact_for and contact.point_of_contact_for.strip %} {{ contact.point_of_contact_for.strip }}: - {% endif %}{% if contact.name and contact.name.strip %}{{ contact.name.strip }} + {% endif %} + {% if contact.name and contact.name.strip %} + {{ contact.name.strip }} {% endif %}

{% if contact.email and contact.email.strip %}

- Email + + + Email: + +   {{ contact.email.strip }} @@ -47,10 +35,14 @@

{% if contact.point_of_contact_for and contact.point_of_contact_for.strip %} {% endif %} {% if contact.phone_number and contact.phone_number.strip %}

- Phone Number + + + Phone Number: + +   {{ contact.phone_number.strip }} @@ -60,10 +52,14 @@

{% if contact.point_of_contact_for and contact.point_of_contact_for.strip %} {% endif %} {% if contact.website and contact.website.strip %}

- Email + + + Website: + +   {{ contact.website.strip }} @@ -72,5 +68,5 @@

{% if contact.point_of_contact_for and contact.point_of_contact_for.strip %}

{% endif %} {% endif %} -

+
{% endspaceless %} diff --git a/integreat_cms/cms/urls/protected.py b/integreat_cms/cms/urls/protected.py index 4e3ae8c9b8..0e09cbeaac 100644 --- a/integreat_cms/cms/urls/protected.py +++ b/integreat_cms/cms/urls/protected.py @@ -19,15 +19,7 @@ POITranslationForm, RegionForm, ) -from ..models import ( - Event, - Language, - OfferTemplate, - Page, - POI, - POICategory, - Role, -) +from ..models import Event, Language, OfferTemplate, Page, POI, POICategory, Role from ..views import ( analytics, bulk_action_views, @@ -759,6 +751,11 @@ utils.search_content_ajax, name="search_content_ajax", ), + path( + "search/contact/", + utils.search_contact_ajax, + name="search_contact_ajax", + ), path( "dismiss-tutorial//", settings.DismissTutorial.as_view(), @@ -1461,6 +1458,16 @@ "/", include( [ + path( + "", + utils.get_contact, + name="get_contact", + ), + path( + "raw/", + utils.get_contact_raw, + name="get_contact_raw", + ), path( "edit/", contacts.ContactFormView.as_view(), diff --git a/integreat_cms/cms/utils/content_utils.py b/integreat_cms/cms/utils/content_utils.py index 347befa707..d0475d2848 100644 --- a/integreat_cms/cms/utils/content_utils.py +++ b/integreat_cms/cms/utils/content_utils.py @@ -1,6 +1,4 @@ import logging -import re -from copy import deepcopy from html import unescape from urllib.parse import unquote, urlparse @@ -144,84 +142,43 @@ def update_internal_links(link: HtmlElement, language_slug: str) -> None: link.append(new_html) -def render_contact_card(contact_id: int, fetched_contacts: dict[int, Contact]) -> str: +def render_contact_card(contact_id: int) -> HtmlElement: """ Produces a rendered html element for the contact. :param contact_id: The id of the contact to render the card for - :param fetched_contacts: A dictionary of pre-fetched contact objects, indexed by their id - - .. Note:: - - This function does not fetch any contacts itself if the provided id is not in ``fetched_contacts``, - nor will a contact at that key be double-checked to actually have the expected id. - If the contact for the id is not provided, the contact will be assumed missing from our database and be labelled as invalid. """ template = loader.get_template("contacts/contact_card.html") - context = { - "contact_id": contact_id, - "contact": ( - fetched_contacts[contact_id] if contact_id in fetched_contacts else None - ), - } - return template.render(context, None) + try: + context = { + "contact": Contact.objects.get(pk=contact_id), + } + raw_element = template.render(context) + return fromstring(raw_element) + except Contact.DoesNotExist: + logger.warning("Contact with id=%i does not exist!", contact_id) + return Element("p", contact_id) + except LxmlError as e: + logger.debug( + "Failed to parse rendered HTML for contact card: %r\n→ %s\nEOF", + e, + raw_element, + ) + return Element("pre", raw_element) -def update_contacts(content: HtmlElement, only_ids: tuple[int] | None = None) -> None: +def update_contacts(content: HtmlElement) -> None: """ Inject rendered contact html for given ID :param content: The content whose contacts should be updated - :param only_ids: A list of ids if only certain contacts should be updated, otherwise None """ - nodes_to_update: dict[int, HtmlElement] = {} - for div in content.iter("div"): - children = list(div) - match = None - if ( - children - and children[0].tag == "a" - and (href := children[0].get("href", None)) - ): - match = re.match(Contact.url_regex, href) - if not match: - continue - - try: - contact_id = int(match.group(2)) - except ValueError: - logger.warning("Failed parsing contact id %r as int", contact_id) - - if not isinstance(contact_id, int): - logger.warning("Malformed contact id %r in content", contact_id) - elif only_ids is None or contact_id in only_ids: - # Gather all nodes - if contact_id not in nodes_to_update: - nodes_to_update[contact_id] = [] - nodes_to_update[contact_id].append(div) - - # Get all required contacts in a single query - fetched_contacts = { - contact.pk: contact - for contact in Contact.objects.filter(id__in=nodes_to_update.keys()) - } - - for contact_id, divs in nodes_to_update.items(): - html = render_contact_card(contact_id, fetched_contacts=fetched_contacts) - try: - # We need the parsed form so we can plug it into our existing content tree - new_div = fromstring(html) - except LxmlError as e: - logger.debug( - "Failed to parse rendered HTML for contact card: %r\n→ %s\nEOF", - e, - html, - ) - new_div = Element("pre", html) + contact_cards = content.xpath("//div[@data-contact-id]") + contact_ids = [int(card.get("data-contact-id")) for card in contact_cards] - # Finally, inject the card in every occurence - for div in divs: - div.getparent().replace(div, deepcopy(new_div)) + for contact_id, contact_card in zip(contact_ids, contact_cards): + contact_card_new = render_contact_card(contact_id) + contact_card.getparent().replace(contact_card, contact_card_new) def fix_alt_texts(content: HtmlElement) -> None: diff --git a/integreat_cms/cms/utils/internal_link_checker.py b/integreat_cms/cms/utils/internal_link_checker.py index 5640076e83..c0e57ac741 100644 --- a/integreat_cms/cms/utils/internal_link_checker.py +++ b/integreat_cms/cms/utils/internal_link_checker.py @@ -11,7 +11,7 @@ from django.utils.translation import gettext_lazy as _ from ..constants import region_status -from ..models import Region +from ..models import Contact, Region if TYPE_CHECKING: from django.db.models.fields.related import RelatedManager @@ -300,7 +300,7 @@ def check_event_or_location( # pylint: disable=too-many-return-statements -def check_internal(url: Url) -> bool | None: +def check_internal(url: Url) -> bool | None: # noqa: PLR0911 """ :param url: The internal URL to check :returns: The status of the URL @@ -342,6 +342,12 @@ def check_internal(url: Url) -> bool | None: if "/" not in language_and_path: language_and_path += "/" language_slug, path = language_and_path.split("/", maxsplit=1) + + if language_slug == "contact" and Contact.objects.filter(pk=path).first(): + logger.debug("Link to a contact is valid.") + mark_valid(url) + return url.status + try: language = region.get_language_or_404( language_slug, only_active=True, only_visible=True diff --git a/integreat_cms/cms/views/contacts/contact_form_view.py b/integreat_cms/cms/views/contacts/contact_form_view.py index 31a52f1c5f..87cea1bf43 100644 --- a/integreat_cms/cms/views/contacts/contact_form_view.py +++ b/integreat_cms/cms/views/contacts/contact_form_view.py @@ -7,24 +7,10 @@ from django.utils.decorators import method_decorator from django.utils.translation import gettext_lazy as _ from django.views.generic import TemplateView -from linkcheck.models import Link from ...decorators import permission_required from ...forms import ContactForm -from ...linklists import ( - EventTranslationLinklist, - PageTranslationLinklist, - POITranslationLinklist, -) -from ...models import ( - Contact, - Event, - EventTranslation, - Page, - PageTranslation, - POI, - POITranslation, -) +from ...models import Contact, Event, Page, POI from ...utils.translation_utils import gettext_many_lazy as __ from .contact_context_mixin import ContactContextMixin @@ -88,14 +74,9 @@ def get(self, request: HttpRequest, *args: Any, **kwargs: Any) -> HttpResponse: referring_pages = ( Page.objects.filter( id__in=( - PageTranslation.objects.filter( - id__in=( - Link.objects.filter( - url__url=contact_instance.full_url, - content_type=PageTranslationLinklist.content_type(), - ).values_list("object_id", flat=True) - ), - ).values_list("page_id", flat=True) + contact_instance.referring_page_translations.values_list( + "page_id", flat=True + ) ), ) if contact_instance @@ -105,14 +86,9 @@ def get(self, request: HttpRequest, *args: Any, **kwargs: Any) -> HttpResponse: referring_locations = ( POI.objects.filter( id__in=( - POITranslation.objects.filter( - id__in=( - Link.objects.filter( - url__url=contact_instance.full_url, - content_type=POITranslationLinklist.content_type(), - ).values_list("object_id", flat=True) - ), - ).values_list("poi_id", flat=True) + contact_instance.referring_poi_translations.values_list( + "poi_id", flat=True + ) ), ) if contact_instance @@ -122,14 +98,9 @@ def get(self, request: HttpRequest, *args: Any, **kwargs: Any) -> HttpResponse: referring_events = ( Event.objects.filter( id__in=( - EventTranslation.objects.filter( - id__in=( - Link.objects.filter( - url__url=contact_instance.full_url, - content_type=EventTranslationLinklist.content_type(), - ).values_list("object_id", flat=True) - ), - ).values_list("event_id", flat=True) + contact_instance.referring_event_translations.values_list( + "event_id", flat=True + ) ), ) if contact_instance diff --git a/integreat_cms/cms/views/events/event_form_view.py b/integreat_cms/cms/views/events/event_form_view.py index f40ad03203..009e1e2c88 100644 --- a/integreat_cms/cms/views/events/event_form_view.py +++ b/integreat_cms/cms/views/events/event_form_view.py @@ -18,7 +18,7 @@ from ...models import Event, EventTranslation, Language, POI, RecurrenceRule from ...utils.translation_utils import translate_link from ..media.media_context_mixin import MediaContextMixin -from ..mixins import ContentEditLockMixin, HtmlEditorMixin +from ..mixins import ContentEditLockMixin from .event_context_mixin import EventContextMixin if TYPE_CHECKING: @@ -36,7 +36,6 @@ class EventFormView( EventContextMixin, MediaContextMixin, ContentEditLockMixin, - HtmlEditorMixin, ): """ Class for rendering the events form diff --git a/integreat_cms/cms/views/imprint/imprint_form_view.py b/integreat_cms/cms/views/imprint/imprint_form_view.py index 63e43a399e..3682c095fc 100644 --- a/integreat_cms/cms/views/imprint/imprint_form_view.py +++ b/integreat_cms/cms/views/imprint/imprint_form_view.py @@ -18,7 +18,6 @@ from ...utils.translation_utils import gettext_many_lazy as __ from ...utils.translation_utils import translate_link from ..media.media_context_mixin import MediaContextMixin -from ..mixins import HtmlEditorMixin from .imprint_context_mixin import ImprintContextMixin if TYPE_CHECKING: @@ -33,9 +32,7 @@ @method_decorator(permission_required("cms.view_imprintpage"), name="dispatch") @method_decorator(permission_required("cms.change_imprintpage"), name="post") -class ImprintFormView( - TemplateView, ImprintContextMixin, MediaContextMixin, HtmlEditorMixin -): +class ImprintFormView(TemplateView, ImprintContextMixin, MediaContextMixin): """ View for the imprint page form and imprint page translation form """ diff --git a/integreat_cms/cms/views/mixins.py b/integreat_cms/cms/views/mixins.py index 6b217792ba..0b4ab299f1 100644 --- a/integreat_cms/cms/views/mixins.py +++ b/integreat_cms/cms/views/mixins.py @@ -12,7 +12,6 @@ from django.views.generic.base import ContextMixin, TemplateResponseMixin from ...core.utils.machine_translation_provider import MachineTranslationProvider -from ..models import Contact if TYPE_CHECKING: from typing import Any @@ -141,25 +140,3 @@ def get_context_data(self, **kwargs: Any) -> dict[str, Any]: ) ) return context - - -class HtmlEditorMixin(ContextMixin): - """ - A mixin that provides some variables required for the HTML editor - """ - - def get_context_data(self, **kwargs: Any) -> dict[str, Any]: - r""" - Returns a dictionary representing the template context - (see :meth:`~django.views.generic.base.ContextMixin.get_context_data`). - - :param \**kwargs: The given keyword arguments - :return: The template context - """ - context = super().get_context_data(**kwargs) - context.update( - { - "contact_url_regex": Contact.url_regex.pattern, - } - ) - return context diff --git a/integreat_cms/cms/views/pages/page_form_view.py b/integreat_cms/cms/views/pages/page_form_view.py index 7d4a3eac93..5012504a9a 100644 --- a/integreat_cms/cms/views/pages/page_form_view.py +++ b/integreat_cms/cms/views/pages/page_form_view.py @@ -20,7 +20,7 @@ from ...utils.translation_utils import gettext_many_lazy as __ from ...utils.translation_utils import translate_link from ..media.media_context_mixin import MediaContextMixin -from ..mixins import ContentEditLockMixin, HtmlEditorMixin +from ..mixins import ContentEditLockMixin from .page_context_mixin import PageContextMixin if TYPE_CHECKING: @@ -41,7 +41,6 @@ class PageFormView( PageContextMixin, MediaContextMixin, ContentEditLockMixin, - HtmlEditorMixin, ): """ View for the page form and page translation form diff --git a/integreat_cms/cms/views/pois/poi_form_view.py b/integreat_cms/cms/views/pois/poi_form_view.py index aa0499ffcf..99115c617e 100644 --- a/integreat_cms/cms/views/pois/poi_form_view.py +++ b/integreat_cms/cms/views/pois/poi_form_view.py @@ -21,7 +21,7 @@ from ...utils.translation_utils import gettext_many_lazy as __ from ...utils.translation_utils import translate_link from ..media.media_context_mixin import MediaContextMixin -from ..mixins import ContentEditLockMixin, HtmlEditorMixin +from ..mixins import ContentEditLockMixin from .poi_context_mixin import POIContextMixin if TYPE_CHECKING: @@ -39,7 +39,6 @@ class POIFormView( POIContextMixin, MediaContextMixin, ContentEditLockMixin, - HtmlEditorMixin, ): """ View for editing POIs diff --git a/integreat_cms/cms/views/utils/__init__.py b/integreat_cms/cms/views/utils/__init__.py index c07447dec4..5301ac261a 100644 --- a/integreat_cms/cms/views/utils/__init__.py +++ b/integreat_cms/cms/views/utils/__init__.py @@ -1,5 +1,6 @@ from __future__ import annotations +from .contact_utils import get_contact, get_contact_raw, search_contact_ajax from .content_edit_lock import content_edit_lock_heartbeat, content_edit_lock_release from .hix import get_hix_score from .machine_translations import build_json_for_machine_translation diff --git a/integreat_cms/cms/views/utils/contact_utils.py b/integreat_cms/cms/views/utils/contact_utils.py new file mode 100644 index 0000000000..939aa49ce4 --- /dev/null +++ b/integreat_cms/cms/views/utils/contact_utils.py @@ -0,0 +1,97 @@ +from __future__ import annotations + +import json +import logging +from typing import TYPE_CHECKING + +from django.core.exceptions import PermissionDenied +from django.http import HttpResponse, JsonResponse +from django.shortcuts import get_object_or_404, render +from django.views.decorators.http import require_POST + +from ...models import ( + Contact, +) + +if TYPE_CHECKING: + from typing import Any, Literal + + from django.http import HttpRequest + + from ...models.abstract_content_translation import AbstractContentTranslation + +logger = logging.getLogger(__name__) + +MAX_RESULT_COUNT: int = 20 + + +@require_POST +def search_contact_ajax( + request: HttpRequest, + region_slug: str | None = None, +) -> JsonResponse: + """ + Searches contacts that match the search query. + + :param request: The current request + :param region_slug: The slug of the current region + :raises ~django.core.exceptions.PermissionDenied: If the user has no permission to the object type + :return: Json object containing all matching elements, of shape {title: str, url: str, type: str} + """ + # pylint: disable=unused-argument + + body = json.loads(request.body.decode("utf-8")) + if (query := body["query_string"]) is None: + return JsonResponse({"data": []}) + + logger.debug("Ajax call: Live search for contact with query %r", query) + + if not request.user.has_perm("cms.view_contact"): + raise PermissionDenied + assert request.region is not None + + results = Contact.search(request.region, query)[:MAX_RESULT_COUNT] + return JsonResponse( + { + "data": [ + { + "url": result.full_url, + "name": result.get_repr_short, + } + for result in results + ] + } + ) + + +def get_contact( + request: HttpRequest, contact_id: int, region_slug: str | None = None +) -> str: + """ + Retrieves the rendered HTML representation of a contact. + + :param request: The current request + :param contact_id: The ID of the contact to retrieve + :param region_slug: The slug of the current region + :return: HTML representation of the requested contact + """ + # pylint: disable=unused-argument + contact = get_object_or_404(Contact, pk=contact_id) + return render(request, "contacts/contact_card.html", {"contact": contact}) + + +def get_contact_raw( + request: HttpRequest, contact_id: int, region_slug: str | None = None +) -> str: + """ + Retrieves the short representation of a single contact, and returns it + in the same format as a contact search single completion. + + :param request: The current request + :param contact_id: The ID of the contact to retrieve + :param region_slug: The slug of the current region + :return: Short representation of the requested contact + """ + # pylint: disable=unused-argument + contact = get_object_or_404(Contact, pk=contact_id) + return HttpResponse(contact.get_repr_short) diff --git a/integreat_cms/cms/views/utils/search_content_ajax.py b/integreat_cms/cms/views/utils/search_content_ajax.py index 392eccf812..fcd083dd58 100644 --- a/integreat_cms/cms/views/utils/search_content_ajax.py +++ b/integreat_cms/cms/views/utils/search_content_ajax.py @@ -12,7 +12,6 @@ from ...constants import status from ...models import ( - Contact, Directory, EventTranslation, Feedback, @@ -106,38 +105,6 @@ def search_content_ajax( results: list[dict[str, Any]] = [] user = request.user - if "contact" in object_types: - object_types.remove("contact") - if not user.has_perm("cms.view_contact"): - raise PermissionDenied - assert region is not None - results.extend( - [ - { - "id": contact.id, - "title": str(contact), - "point_of_contact_for": contact.point_of_contact_for, - "name": contact.name, - "location_id": contact.location_id, - "email": contact.email, - "phone_number": contact.phone_number, - "website": contact.website, - "archived": contact.archived, - "last_updated": contact.last_updated, - "created_date": contact.created_date, - "url": contact.full_url, - "type": "contact", - } - for contact in ( - Contact.search(region, query).filter(archived=archived_flag) - if isinstance(query, str) - # This is dirty and shouldn't be done this way, - # but it's just so convenient to use as a fallback - else [Contact.objects.get(id=query)] - ) - ] - ) - if "event" in object_types: if TYPE_CHECKING: assert language_slug diff --git a/integreat_cms/core/signals/__init__.py b/integreat_cms/core/signals/__init__.py index 2f686ef24d..7011ec90dd 100644 --- a/integreat_cms/core/signals/__init__.py +++ b/integreat_cms/core/signals/__init__.py @@ -4,4 +4,10 @@ from __future__ import annotations -from . import auth_signals, feedback_signals, hix_signals, organization_signals +from . import ( + auth_signals, + contact_signals, + feedback_signals, + hix_signals, + organization_signals, +) diff --git a/integreat_cms/core/signals/contact_signals.py b/integreat_cms/core/signals/contact_signals.py new file mode 100644 index 0000000000..15243eef4e --- /dev/null +++ b/integreat_cms/core/signals/contact_signals.py @@ -0,0 +1,40 @@ +""" +This module contains signal handlers related to contact objects. +""" + +from __future__ import annotations + +import logging +from typing import TYPE_CHECKING + +from django.db.models.signals import post_save +from django.dispatch import receiver + +from ...cms.models import Contact +from ...cms.utils.content_utils import clean_content +from ..utils.decorators import disable_for_loaddata + +logger = logging.getLogger(__name__) + +if TYPE_CHECKING: + from typing import Any + + +@receiver(post_save, sender=Contact) +@disable_for_loaddata +def contact_save_handler(instance: Contact, **kwargs: Any) -> None: + r""" + Update contact details in content objects after changing contact details + + :param instance: The page translation that gets saved + :param \**kwargs: The supplied keyword arguments + """ + referring_objects = ( + list(instance.referring_page_translations) + + list(instance.referring_poi_translations) + + list(instance.referring_event_translations) + ) + for referrer in referring_objects: + logger.debug("Updating %r, since if references %r.", referrer, instance) + referrer.content = clean_content(referrer.content, referrer.language.slug) + referrer.save(update_fields=["content"]) diff --git a/integreat_cms/locale/de/LC_MESSAGES/django.po b/integreat_cms/locale/de/LC_MESSAGES/django.po index ca0dcec0c8..6776eb4f4a 100644 --- a/integreat_cms/locale/de/LC_MESSAGES/django.po +++ b/integreat_cms/locale/de/LC_MESSAGES/django.po @@ -4897,6 +4897,10 @@ msgstr "Aktualisieren" msgid "Submit" msgstr "Speichern" +#: cms/templates/_tinymce_config.html +msgid "- no results -" +msgstr "- keine Ergebnisse -" + #: cms/templates/_tinymce_config.html msgid "Link..." msgstr "Link..." diff --git a/integreat_cms/static/src/css/tinymce_custom.css b/integreat_cms/static/src/css/tinymce_custom.css index 2f9dc75a21..13d698ba30 100644 --- a/integreat_cms/static/src/css/tinymce_custom.css +++ b/integreat_cms/static/src/css/tinymce_custom.css @@ -11,9 +11,10 @@ white-space: pre-wrap; } -div:has(> a[href^="https://integreat.app/"][href*="/contact/"]) [translate="no"] -{ +div:has(> a[href^="https://integreat.app/"][href*="/contact/"]) [translate="no"], +.contact-card [translate="no"] { background-color: initial; + white-space: initial; } div:has(> a[href^="https://integreat.app/"][href*="/contact/"])[data-mce-selected] { @@ -23,3 +24,21 @@ div:has(> a[href^="https://integreat.app/"][href*="/contact/"])[data-mce-selecte [contenteditable="false"] a { pointer-events: none; } + +.contact-card { + @apply inline-block box-border py-0 px-4 rounded bg-no-repeat; + background-color: rgba(127, 127, 127, 0.15); + background-image: linear-gradient(to right, rgba(255, 255, 255, 0.9) 0 100%), url("../svg/contact.svg") !important; + background-blend-mode: difference; + background-position: calc(100% + 2em) calc(100% + 1em); + background-size: 7em; + box-shadow: 0 0.1em 0.1em rgba(0, 0, 0, 0.4); + cursor: default !important; + color: initial; + text-decoration: initial; + min-width: 50%; + + .marker-link { + display: none; + } +} diff --git a/integreat_cms/static/src/js/tinymce-plugins/custom_contact_input/plugin.js b/integreat_cms/static/src/js/tinymce-plugins/custom_contact_input/plugin.js index 497acc6c1e..ec2de83d81 100644 --- a/integreat_cms/static/src/js/tinymce-plugins/custom_contact_input/plugin.js +++ b/integreat_cms/static/src/js/tinymce-plugins/custom_contact_input/plugin.js @@ -3,147 +3,64 @@ import { getCsrfToken } from "../../utils/csrf-token"; (() => { const tinymceConfig = document.getElementById("tinymce-config-options"); - - const contactUrlRegex = new RegExp(tinymceConfig.getAttribute("data-contact-url-regex")); - - const seenContacts = {}; - - const cacheContact = (contact) => { - if (contact && contact.id) { - seenContacts[contact.id] = contact; - } - }; - const getCachedContact = (id) => { - if (id && id in seenContacts) { - return seenContacts[id]; - } - return null; - }; + const completionUrl = tinymceConfig.getAttribute("data-contact-ajax-url"); const getCompletions = async (query) => { - const url = tinymceConfig.getAttribute("data-contact-ajax-url"); - - const response = await fetch(url, { + const response = await fetch(completionUrl, { method: "POST", headers: { "X-CSRFToken": getCsrfToken(), }, - body: JSON.stringify({ - query_string: query, - object_types: ["contact"], - archived: false, - }), + body: JSON.stringify({ query_string: query }), }); + const HTTP_STATUS_OK = 200; if (response.status !== HTTP_STATUS_OK) { return []; } const data = await response.json(); - return data.data; }; - const renderContactLine = (contact) => { - const point_of_contact_for = contact.point_of_contact_for && contact.point_of_contact_for.trim() !== "" ? `${contact.point_of_contact_for.trim()}: ` : ""; - const name = contact.name && contact.name.trim() !== "" ? contact.name.trim() : ""; - const details = ["email", "phone_number", "website"].map((k) => contact[k]).filter((e) => e && e.trim() !== ""); - return `${point_of_contact_for}${name} (${details.join(", ")})`; - }; - - const renderSegmentedContactLine = (contact, escape) => { - const point_of_contact_for = - contact.point_of_contact_for && contact.point_of_contact_for.trim() !== "" - ? `${escape(contact.point_of_contact_for.trim())}: ` - : ""; - const name = - contact.name && contact.name.trim() !== "" - ? `${escape(contact.name.trim())}` - : ""; - const details = ["email", "phone_number", "website"].reduce((list, k) => { - const value = contact[k]; - if (value && value.trim() !== "") { - list.push(`${escape(value.trim())}`); - } - return list; - }, []); - return `${point_of_contact_for}${name} (${details.join(", ")})`; - }; - - const notranslate = (html) => `${html}`; - - const updateContact = (editor, id, elm) => { - const contact = getCachedContact(id) || {}; - const url = contact && contact.url ? contact.url : ""; - const marker = `Contact`; + const getContactHtml = async (url) => { + const response = await fetch(url, { + method: "GET", + headers: { + "X-CSRFToken": getCsrfToken(), + }, + }); - const point_of_contact_for = contact && contact.point_of_contact_for && contact.point_of_contact_for.trim() !== "" ? `${contact.point_of_contact_for.trim()}: ` : ""; - const name = contact && contact.name && contact.name.trim() !== "" ? notranslate(contact.name.trim()) : ""; - const email = - contact && contact.email && contact.email.trim() !== "" - ? `

Email ${notranslate(contact.email.trim())}

` - : ""; - const phoneNumber = - contact && contact.phone_number && contact.phone_number.trim() !== "" - ? `

Phone Number ${notranslate(contact.phone_number.trim())}

` - : ""; - const website = - contact && contact.website && contact.website.trim() !== "" - ? `

Website ${notranslate(contact.website.trim())}

` - : ""; - const innerHTML = ` -

${point_of_contact_for}${name}

- ${email} - ${phoneNumber} - ${website} - `; - if (elm) { - elm.innerHTML = `${marker}${innerHTML}`; - editor.selection.select(elm); + const HTTP_STATUS_OK = 200; + if (response.status !== HTTP_STATUS_OK) { + return ""; } - const styles = [ - "display: inline-block;", - "box-sizing: border-box;", - "min-width: 50%;", - "padding: 0.1em 1em;", - "border-radius: 0.3em;", - "background: rgba(127, 127, 127, 0.15);", - "box-shadow: 0 .1em .1em rgba(0,0,0,0.4);", - "cursor: default !important;", - "color: initial;", - "text-decoration: initial;", - `background-image: linear-gradient(to right, rgba(255,255,255,0.9) 0 100%), url(${tinymceConfig.getAttribute(`data-contact-icon-src`)}) !important;`, - "background-blend-mode: difference;", - "background-position: calc(100% + 2em) calc(100% + 1em);", - "background-size: 7em;", - "background-repeat: no-repeat;", - ].join(" "); - return `
${marker}${innerHTML}
`; + + const data = await response.text(); + return data; }; + const getContactRaw = async (url) => getContactHtml(`${url}raw/`); + tinymce.PluginManager.add("custom_contact_input", (editor, _url) => { - const isContact = (node) => - node.nodeName.toLowerCase() === "div" && - node.children.length > 0 && - node.children[0].nodeName.toLowerCase() === "a" && - node.children[0].getAttribute("href").match(contactUrlRegex); + const isContact = (node) => "contactId" in node.dataset; const getContact = () => { - let node = editor.selection.getNode(); - while (node !== null) { - if (isContact(node)) { - return node; - } - node = node.parentNode; + const node = editor.selection.getNode(); + if (node.dataset.contactId !== undefined) { + return node; } return null; }; + const closeContextToolbar = () => { + editor.fire("contexttoolbar-hide", { + toolbarKey: "contact_context_toolbar", + }); + }; let tomSelectInstance; - const openDialog = () => { + const openDialog = (_button, initialSelection) => { const contact = getContact(); - const match = contact ? contact.children[0].getAttribute("href").match(contactUrlRegex) : null; - const initialId = match && match[2] ? match[2] : ""; const dialogConfig = { title: tinymceConfig.getAttribute("data-contact-dialog-title-text"), @@ -170,9 +87,6 @@ import { getCsrfToken } from "../../utils/csrf-token"; primary: true, }, ], - initialData: { - id: initialId, - }, onClose: () => { // Destroy TomSelect instance to avoid memory leaks if (tomSelectInstance) { @@ -181,122 +95,87 @@ import { getCsrfToken } from "../../utils/csrf-token"; } }, onSubmit: (api) => { - // Either insert a new link or update the existing one - const contact = getContact(); - const id = tomSelectInstance.getValue(); + const url = tomSelectInstance.getValue(); - if (!id) { + if (!url) { return; } api.close(); - const html = updateContact(editor, id, contact); - if (!contact) { - /* We want to insert the contact card as a new block element, even though it is wrapped in an anchor - * This means if we are multiple levels inside inline elements (e.g.

…), - * these should be split at the cursor, where the contact should be inserted. - * Surprisingly, just inserting a div achieves this – - * probably because it is a block element, while an anchor () is not. - */ - editor.insertContent('

'); - - const elm = editor.$("div[data-bogus-split=5]")[0]; - // If TinyMCEs behaviour changes in the future, it might become necessary to split manually similar to this: - /* - const selectedNode = editor.selection.getNode(); - if (!editor.dom.isBlock(selectedNode)) { - let node = selectedNode; - while (!editor.dom.isBlock(node)) { - node = node.parentElement; - } - editor.dom.split(node, elm); + getContactHtml(url).then((html) => { + if (!contact) { + editor.insertContent(html); + } else { + contact.outerHTML = html; } - */ - // Finally, we can replace the split marker element with the actual contact card - elm.outerHTML = html; - } + }); }, }; setTimeout(() => { - // Get the select and submit elements after TinyMCE rendered them const selectElement = document.getElementById("completions"); const submitElement = document.querySelector( ".tox-dialog:has(#completions) .tox-dialog__footer .tox-button:not(.tox-button--secondary)" ); - const setSubmitDisableStatus = function (value) { - if (submitElement) { - submitElement.disabled = !value; + const setSubmitDisableStatus = (value) => { + if (!submitElement) { + return; + } + + submitElement.disabled = !value; + if (!submitElement.disabled) { + submitElement.focus(); } }; - // Initialize TomSelect on the select element + if (initialSelection) { + selectElement.add(initialSelection); + } + tomSelectInstance = new TomSelect(selectElement, { - valueField: "id", - //labelField: "text", // By which field the object should be represented. We define a custom render function, so we don't need it - searchField: ["point_of_contact_for", "name", "email", "phone_number", "website"], - items: [initialId], + valueField: "url", + labelField: "name", + searchField: ["name"], placeholder: tinymceConfig.getAttribute("data-contact-dialog-search-text"), - options: Object.values(seenContacts), // Initially empty, will populate with API response - create: false, // Users cannot just inline create contacts here - loadThrottle: 300, // How many ms to wait for more input before actually sending a request - preload: true, // Call load() once with empty query on initialization. This fetches the contact so the details are up to date - onInitialize: function () { - selectElement.classList.add("hidden"); - this.control_input.parentElement.classList.add("tox-textfield"); - setSubmitDisableStatus(this.getValue()); - }, - load: function (query, callback) { - if (!typeof query === "string" || query === "") { - /* This is dirty and shouldn't be done this way, - but it's just so convenient to use as a fallback - when we only remember the id we last set. - (We technically know more, but we cannot be sure it's still recent) - */ - query = parseInt(this.getValue() || initialId); - } + loadThrottle: 300, + load: (query, callback) => { getCompletions(query).then((newCompletions) => { - newCompletions.forEach(cacheContact); callback(newCompletions); - if (typeof query === "number" && newCompletions.length > 0) { - // Trigger preview of of initially selected value after fetching its details - this.setValue(query); - } }); }, - onChange: setSubmitDisableStatus, - render: { - option: (data, escape) => { - // How a search result should be represented - return `
${renderSegmentedContactLine(data, escape)}
`; - }, - item: (data, escape) => { - // How a selected item should be represented - return `
${renderSegmentedContactLine(data, escape)}
`; - }, - no_results: (data, escape) => { - // What to display when no results are found - return `
${escape(tinymceConfig.getAttribute("data-contact-no-results-text"))}
`; - }, - }, + onDropdownClose: setSubmitDisableStatus, }); + + selectElement.classList.add("hidden"); + tomSelectInstance.control_input.parentElement.classList.add("tox-textfield"); + tomSelectInstance.control_input.focus(); + setSubmitDisableStatus(tomSelectInstance.getValue()); }, 0); return editor.windowManager.open(dialogConfig); }; - // editor.addShortcut("Meta+C", tinymceConfig.getAttribute("data-contact-menu-text"), openDialog); + editor.addShortcut("Meta+L", tinymceConfig.getAttribute("data-contact-menu-text"), openDialog); editor.ui.registry.addMenuItem("add_contact", { text: tinymceConfig.getAttribute("data-contact-menu-text"), icon: "contact", - // shortcut: "Meta+C", onAction: openDialog, }); editor.ui.registry.addButton("change_contact", { text: tinymceConfig.getAttribute("data-contact-change-text"), icon: "contact", - onAction: openDialog, + onAction: async () => { + const contactUrl = getContact().dataset.contactUrl; + const updatedContact = await getContactRaw(contactUrl); + + const updatedContactOption = document.createElement("option"); + updatedContactOption.value = `${contactUrl}`; + updatedContactOption.text = updatedContact; + + openDialog(null, updatedContactOption); + closeContextToolbar(); + }, }); editor.ui.registry.addButton("remove_contact", { @@ -307,10 +186,10 @@ import { getCsrfToken } from "../../utils/csrf-token"; if (contact) { contact.remove(); } + closeContextToolbar(); }, }); - // This form opens when a link is current selected with the cursor editor.ui.registry.addContextToolbar("contact_context_toolbar", { predicate: isContact, position: "node",