diff --git a/messages.pot b/messages.pot index 0bf0fec..1d6c7d3 100644 --- a/messages.pot +++ b/messages.pot @@ -1,14 +1,14 @@ # Translations template for PROJECT. -# Copyright (C) 2021 ORGANIZATION +# Copyright (C) 2022 ORGANIZATION # This file is distributed under the same license as the PROJECT project. -# FIRST AUTHOR , 2021. +# FIRST AUTHOR , 2022. # #, fuzzy msgid "" msgstr "" "Project-Id-Version: PROJECT VERSION\n" "Report-Msgid-Bugs-To: EMAIL@ADDRESS\n" -"POT-Creation-Date: 2021-12-30 15:28+0100\n" +"POT-Creation-Date: 2022-01-11 23:44+0100\n" "PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\n" "Last-Translator: FULL NAME \n" "Language-Team: LANGUAGE \n" @@ -189,33 +189,33 @@ msgstr "" msgid "." msgstr "" -#: project/api/__init__.py:84 +#: project/api/__init__.py:85 msgid "message" msgstr "" -#: project/api/organization/resources.py:371 +#: project/api/organization/resources.py:399 #: project/views/admin_unit_member_invitation.py:85 msgid "You have received an invitation" msgstr "" -#: project/forms/admin.py:10 project/templates/layout.html:312 +#: project/forms/admin.py:10 project/templates/layout.html:313 #: project/views/root.py:37 msgid "Terms of service" msgstr "" -#: project/forms/admin.py:11 project/templates/layout.html:316 +#: project/forms/admin.py:11 project/templates/layout.html:317 #: project/views/root.py:45 msgid "Legal notice" msgstr "" #: project/forms/admin.py:12 project/templates/_macros.html:1392 -#: project/templates/layout.html:320 +#: project/templates/layout.html:321 #: project/templates/widget/event_suggestion/create.html:204 #: project/views/admin_unit.py:73 project/views/root.py:53 msgid "Contact" msgstr "" -#: project/forms/admin.py:13 project/templates/layout.html:324 +#: project/forms/admin.py:13 project/templates/layout.html:325 #: project/views/root.py:61 msgid "Privacy" msgstr "" @@ -1049,7 +1049,7 @@ msgstr "" #: project/templates/_macros.html:489 project/templates/_macros.html:649 #: project/templates/admin_unit/create.html:28 #: project/templates/admin_unit/update.html:29 -#: project/templates/layout.html:260 +#: project/templates/layout.html:261 msgid "Organization" msgstr "" @@ -1350,6 +1350,7 @@ msgstr "" #: project/templates/layout.html:175 project/templates/layout.html:219 #: project/templates/manage/events.html:6 #: project/templates/manage/events.html:41 +#: project/templates/manage/events_vue.html:4 msgid "Events" msgstr "" @@ -1367,7 +1368,7 @@ msgstr "" msgid "Planing" msgstr "" -#: project/templates/layout.html:187 project/templates/layout.html:274 +#: project/templates/layout.html:187 project/templates/layout.html:275 #: project/templates/oauth2_client/list.html:10 #: project/templates/oauth2_client/read.html:10 #: project/templates/oauth2_token/list.html:10 project/templates/profile.html:4 @@ -1396,63 +1397,67 @@ msgstr "" msgid "Create event" msgstr "" -#: project/templates/layout.html:228 +#: project/templates/layout.html:227 +msgid "Import event" +msgstr "" + +#: project/templates/layout.html:229 #: project/templates/manage/event_lists.html:4 msgid "Event lists" msgstr "" -#: project/templates/layout.html:231 +#: project/templates/layout.html:232 msgid "Review suggestions" msgstr "" -#: project/templates/layout.html:241 +#: project/templates/layout.html:242 #: project/templates/manage/references_incoming.html:5 #: project/templates/manage/references_outgoing.html:5 msgid "References" msgstr "" -#: project/templates/layout.html:247 +#: project/templates/layout.html:248 #: project/templates/manage/references_incoming.html:9 msgid "Incoming references" msgstr "" -#: project/templates/layout.html:248 +#: project/templates/layout.html:249 #: project/templates/manage/references_outgoing.html:9 msgid "Outgoing references" msgstr "" -#: project/templates/layout.html:250 +#: project/templates/layout.html:251 #: project/templates/manage/reference_requests_incoming.html:9 msgid "Incoming reference requests" msgstr "" -#: project/templates/layout.html:255 +#: project/templates/layout.html:256 #: project/templates/manage/reference_requests_outgoing.html:9 msgid "Outgoing reference requests" msgstr "" -#: project/templates/layout.html:263 project/templates/manage/organizers.html:5 +#: project/templates/layout.html:264 project/templates/manage/organizers.html:5 #: project/templates/manage/organizers.html:9 msgid "Organizers" msgstr "" #: project/templates/event_place/list.html:3 -#: project/templates/event_place/list.html:7 project/templates/layout.html:264 +#: project/templates/event_place/list.html:7 project/templates/layout.html:265 #: project/templates/manage/places.html:5 #: project/templates/manage/places.html:9 msgid "Places" msgstr "" -#: project/templates/layout.html:266 project/templates/manage/members.html:5 +#: project/templates/layout.html:267 project/templates/manage/members.html:5 #: project/templates/manage/members.html:28 msgid "Members" msgstr "" -#: project/templates/layout.html:267 project/templates/manage/relations.html:4 +#: project/templates/layout.html:268 project/templates/manage/relations.html:4 msgid "Relations" msgstr "" -#: project/templates/layout.html:269 +#: project/templates/layout.html:270 #: project/templates/manage/admin_units.html:17 #: project/templates/manage/organization_invitations.html:4 #: project/templates/user/organization_invitations.html:4 @@ -1464,27 +1469,27 @@ msgstr "" #: project/templates/admin/settings.html:8 #: project/templates/admin_unit/update.html:6 #: project/templates/admin_unit/update.html:23 -#: project/templates/layout.html:271 project/templates/manage/widgets.html:11 +#: project/templates/layout.html:272 project/templates/manage/widgets.html:11 #: project/templates/manage/widgets.html:15 project/templates/profile.html:19 msgid "Settings" msgstr "" -#: project/templates/layout.html:272 +#: project/templates/layout.html:273 #: project/templates/manage/custom_widgets.html:13 msgid "Custom widgets" msgstr "" -#: project/templates/layout.html:273 project/templates/manage/reviews.html:10 +#: project/templates/layout.html:274 project/templates/manage/reviews.html:10 #: project/templates/manage/widgets.html:5 #: project/templates/manage/widgets.html:9 msgid "Widgets" msgstr "" -#: project/templates/layout.html:284 +#: project/templates/layout.html:285 msgid "Switch organization" msgstr "" -#: project/templates/developer/read.html:4 project/templates/layout.html:334 +#: project/templates/developer/read.html:4 project/templates/layout.html:335 #: project/templates/profile.html:29 msgid "Developer" msgstr "" @@ -1957,7 +1962,7 @@ msgstr "" msgid "Organization successfully updated" msgstr "" -#: project/views/admin.py:68 project/views/manage.py:346 +#: project/views/admin.py:68 project/views/manage.py:360 msgid "Settings successfully updated" msgstr "" @@ -2015,27 +2020,27 @@ msgstr "" msgid "Invitation successfully deleted" msgstr "" -#: project/views/event.py:178 +#: project/views/event.py:180 msgid "Event successfully published" msgstr "" -#: project/views/event.py:180 +#: project/views/event.py:182 msgid "Draft successfully saved" msgstr "" -#: project/views/event.py:223 +#: project/views/event.py:225 msgid "Event successfully updated" msgstr "" -#: project/views/event.py:249 +#: project/views/event.py:251 msgid "Event successfully deleted" msgstr "" -#: project/views/event.py:408 +#: project/views/event.py:410 msgid "Referenced event changed" msgstr "" -#: project/views/event.py:431 +#: project/views/event.py:433 msgid "New event report" msgstr "" diff --git a/project/api/__init__.py b/project/api/__init__.py index 9958acd..b567109 100644 --- a/project/api/__init__.py +++ b/project/api/__init__.py @@ -50,17 +50,18 @@ class RestApi(Api): data["message"] = err.description code = err.code - if ( - isinstance(err, UnprocessableEntity) - and err.exc - and isinstance(err.exc, ValidationError) - ): + if isinstance(err, UnprocessableEntity): data["name"] = err.name data["message"] = err.description code = err.code schema = UnprocessableEntityResponseSchema() - self.fill_validation_data(err.exc, data) + if ( + hasattr(err, "exc") + and err.exc + and isinstance(err.exc, ValidationError) + ): + self.fill_validation_data(err.exc, data) else: schema = ErrorResponseSchema() elif isinstance(err, ValidationError): diff --git a/project/api/event/schemas.py b/project/api/event/schemas.py index b273fe1..cdd87ee 100644 --- a/project/api/event/schemas.py +++ b/project/api/event/schemas.py @@ -333,3 +333,17 @@ class EventReportPostSchema(marshmallow.Schema): required=True, validate=validate.Length(min=20, max=1000), ) + + +class EventImportRequestSchema(marshmallow.Schema): + url = fields.URL( + required=True, + metadata={ + "description": "A link to an external website containing more information about the event." + }, + ) + public_status = EnumField( + PublicStatus, + missing=PublicStatus.published, + metadata={"description": "Public status of the event."}, + ) diff --git a/project/api/organization/resources.py b/project/api/organization/resources.py index 0e72036..9e6c78a 100644 --- a/project/api/organization/resources.py +++ b/project/api/organization/resources.py @@ -1,3 +1,4 @@ +from flask import abort, request from flask_apispec import doc, marshal_with, use_kwargs from flask_babelex import gettext from sqlalchemy import and_ @@ -20,6 +21,7 @@ from project.api.custom_widget.schemas import ( from project.api.event.resources import api_can_read_private_events from project.api.event.schemas import ( EventIdSchema, + EventImportRequestSchema, EventListRequestSchema, EventListResponseSchema, EventPostRequestSchema, @@ -84,6 +86,7 @@ from project.services.admin_unit import ( ) from project.services.event import get_event_dates_query, get_events_query, insert_event from project.services.event_search import EventSearchParams +from project.services.importer.event_importer import EventImporter from project.services.reference import ( get_reference_incoming_query, get_reference_outgoing_query, @@ -180,6 +183,30 @@ class OrganizationEventListResource(BaseResource): return event, 201 +class OrganizationEventImportResource(BaseResource): + @doc(summary="Import event for organization", tags=["Organizations", "Events"]) + @use_kwargs(EventImportRequestSchema, location="json", apply=False) + @marshal_with(EventIdSchema, 201) + @require_api_access("event:write") + def post(self, id, **kwargs): + admin_unit = AdminUnit.query.get_or_404(id) + access_or_401(admin_unit, "event:create") + + import_request = EventImportRequestSchema().load(request.json) + + try: + importer = EventImporter(admin_unit.id) + event = importer.load_event_from_url(import_request["url"]) + except Exception: + abort(422) + + event.public_status = import_request["public_status"] + insert_event(event) + db.session.commit() + + return event, 201 + + class OrganizationListResource(BaseResource): @doc(summary="List organizations", tags=["Organizations"]) @use_kwargs(OrganizationListRequestSchema, location=("query")) @@ -481,6 +508,11 @@ add_api_resource( "/organizations//events", "api_v1_organization_event_list", ) +add_api_resource( + OrganizationEventImportResource, + "/organizations//events/import", + "api_v1_organization_event_import", +) add_api_resource( OrganizationEventListListResource, "/organizations//event-lists", diff --git a/project/dateutils.py b/project/dateutils.py index 7db2942..2a60cd4 100644 --- a/project/dateutils.py +++ b/project/dateutils.py @@ -2,6 +2,7 @@ from datetime import datetime, timedelta import icalendar import pytz +from dateutil.parser import isoparse from dateutil.relativedelta import relativedelta from dateutil.rrule import rrulestr @@ -79,6 +80,10 @@ def form_input_from_date(date): return date.strftime("%Y-%m-%d") if date else "" +def parse_iso_string(input: str) -> datetime: + return isoparse(input) + + def dates_from_recurrence_rule(start, recurrence_rule): result = list() diff --git a/project/imageutils.py b/project/imageutils.py index c1023d1..182ccda 100644 --- a/project/imageutils.py +++ b/project/imageutils.py @@ -1,4 +1,5 @@ import base64 +import math import re from io import BytesIO @@ -45,6 +46,19 @@ def get_bytes_from_image(image: PIL.Image) -> bytes: return imgByteArr.getvalue() +def resize_image_to_min(image: PIL.Image) -> PIL.Image: + if image.width >= min_image_size or image.height >= min_image_size: + return image + + ratio = max(min_image_size / image.width, min_image_size / image.height) + width = int(math.ceil(image.width * ratio)) + height = int(math.ceil(image.height * ratio)) + format = image.format + result = image.resize((width, height), PIL.Image.LANCZOS) + result.format = format + return result + + def resize_image_to_max(image: PIL.Image): if image.width > max_image_size or image.height > max_image_size: image.thumbnail((max_image_size, max_image_size), PIL.Image.ANTIALIAS) diff --git a/project/services/importer/event_importer.py b/project/services/importer/event_importer.py new file mode 100644 index 0000000..d304c4f --- /dev/null +++ b/project/services/importer/event_importer.py @@ -0,0 +1,86 @@ +from urllib.parse import urlparse + +import requests +from sqlalchemy import and_ + +from project.models import Event, EventOrganizer, EventPlace +from project.services.importer.ld_json_importer import LdJsonImporter + + +class EventImporter: + def __init__(self, admin_unit_id: int): + self.admin_unit_id = admin_unit_id + + def load_event_from_url(self, absolute_url: str): + sanitized_url = self._sanitize_url(absolute_url) + headers = dict() + + if "eventim.de" in absolute_url: + headers[ + "User-Agent" + ] = "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/96.0.4664.110 Safari/537.36" + + response = requests.get(sanitized_url, headers=headers) + + try: + html = response.content.decode("UTF-8") + except Exception: # pragma: no cover + html = response.content.decode(response.apparent_encoding) + + return self.load_event_from_html(html, absolute_url) + + def load_event_from_html(self, html: str, origin_url: str): + importer = LdJsonImporter(html, origin_url) + event = importer.load_event() + + event.admin_unit_id = self.admin_unit_id + self._match_organizer(event) + self._match_place(event) + return event + + def _match_organizer(self, event: Event): + organizer = EventOrganizer.query.filter( + and_( + EventOrganizer.admin_unit_id == self.admin_unit_id, + EventOrganizer.name == event.organizer.name, + ) + ).first() + + if organizer: + event.organizer = organizer + else: + event.organizer.admin_unit_id = self.admin_unit_id + + def _match_place(self, event: Event): + place = EventPlace.query.filter( + and_( + EventPlace.admin_unit_id == self.admin_unit_id, + EventPlace.name == event.event_place.name, + ) + ).first() + + if place: + event.event_place = place + else: + event.event_place.admin_unit_id = self.admin_unit_id + + def _sanitize_url(self, absolute_url: str) -> str: + result = absolute_url + + if "reservix.de" in absolute_url or "facebook.com" in absolute_url: + try: + p = urlparse(absolute_url) + + if p.hostname.endswith("reservix.de"): + result = p._replace( + netloc=p.netloc.replace(p.hostname, "www.reservix.de") + ).geturl() + + if p.hostname == "www.facebook.com": + result = p._replace( + netloc=p.netloc.replace("www.facebook.com", "m.facebook.com") + ).geturl() + except Exception: # pragma: no cover + pass + + return result diff --git a/project/services/importer/ld_json_importer.py b/project/services/importer/ld_json_importer.py new file mode 100644 index 0000000..384175b --- /dev/null +++ b/project/services/importer/ld_json_importer.py @@ -0,0 +1,479 @@ +import json + +import validators +from bs4 import BeautifulSoup + +from project.dateutils import parse_iso_string +from project.imageutils import ( + get_bytes_from_image, + get_image_from_url, + get_mime_type_from_image, + resize_image_to_max, + resize_image_to_min, + validate_image, +) +from project.models import ( + Event, + EventAttendanceMode, + EventCategory, + EventDateDefinition, + EventOrganizer, + EventPlace, + EventStatus, + Image, + Location, +) + + +class LdJsonImporter: + def __init__(self, html: str, origin_url: str): + self.html = html + self.origin_url = origin_url + self.event_type_mapping = { + "ChildrensEvent": "Family", + "ComedyEvent": "Comedy", + "DanceEvent": "Dance", + "EducationEvent": "Lecture", + "ExhibitionEvent": "Exhibition", + "Festival": "Festival", + "FoodEvent": "Dining", + "LiteraryEvent": "Book", + "MusicEvent": "Music", + "SportsEvent": "Sports", + "TheaterEvent": "Theater", + } + self.event_status_mapping = { + "EventScheduled": EventStatus.scheduled, + "EventCancelled": EventStatus.cancelled, + "EventMovedOnline": EventStatus.movedOnline, + "EventPostponed": EventStatus.postponed, + "EventRescheduled": EventStatus.rescheduled, + } + self.event_attendance_mode_mapping = { + "OfflineEventAttendanceMode": EventAttendanceMode.offline, + "OnlineEventAttendanceMode": EventAttendanceMode.online, + "MixedEventAttendanceMode": EventAttendanceMode.mixed, + } + + def load_event(self): + self.ld_json = self.load_ld_json_from_html() + return self.load_event_from_ld_json() + + def load_ld_json_from_html(self): + self.soup = BeautifulSoup(self.html, features="html.parser") + ld_json_scripts = self.soup.find_all("script", {"type": "application/ld+json"}) + + for ld_json_script in ld_json_scripts: + try: + ld_json = self._load_ld_json_from_script(ld_json_script) + if ld_json: + break + except Exception: + pass + + ld_json = self._strip_ld_json(ld_json) + + if "description" in ld_json: + desc_soup = BeautifulSoup(ld_json["description"], features="html.parser") + for br in desc_soup.find_all("br"): + br.replace_with("\n" + br.text) + ld_json["description"] = desc_soup.text + + if "harzinfo.de" in self.origin_url: + coordinate_div = self.soup.find("div", attrs={"data-position": True}) + if coordinate_div: + ld_json["coordinate"] = coordinate_div["data-position"] + + return ld_json + + def load_event_from_ld_json(self): + item = self.ld_json + + event = Event() + self.event = event + + event.name = item["name"] + + if "url" in item and validators.url(item["url"]): + event.external_link = item["url"] + else: + event.external_link = self.origin_url + + self._load_organizer() + self._load_place() + self._load_event_status() + self._load_date_definition() + self._add_categories() + self._add_tags() + self._load_event_photo() + self._load_attendance_mode() + + if "description" in item: + event.description = item["description"] + + if "isAccessibleForFree" in item: + event.accessible_for_free = item["isAccessibleForFree"] + + return event + + def _load_date_definition(self): + event = self.event + item = self.ld_json + + definition = EventDateDefinition() + definition.start = parse_iso_string(item["startDate"]) + + if "endDate" in item: + definition.end = parse_iso_string(item["endDate"]) + + event.date_definitions = [definition] + + def _load_organizer(self): + event = self.event + item = self.ld_json + organizer_item = None + + if "organizer" in item: + organizer_item = item["organizer"] + + if not organizer_item and "author" in item: + organizer_item = item["author"] + + if ( + organizer_item + and isinstance(organizer_item, list) + and len(organizer_item) > 0 + and organizer_item[0] + ): + organizer_item = organizer_item[0] + + if organizer_item: + event.organizer = self._load_organizer_from_ld_json(organizer_item) + else: + event.organizer = self._load_organizer_from_html() + + if not event.organizer: + raise Exception("Organizer is missing") + + def _load_organizer_from_ld_json(self, organizer_item: dict) -> EventOrganizer: + organizer = EventOrganizer() + + organizer.name = organizer_item["name"] + + if "url" in organizer_item and validators.url(organizer_item["url"]): + organizer.url = organizer_item["url"] + + if "email" in organizer_item and validators.email(organizer_item["email"]): + organizer.email = organizer_item["email"] + + if "telephone" in organizer_item: + organizer.phone = organizer_item["telephone"] + + if "faxNumber" in organizer_item: + organizer.fax = organizer_item["faxNumber"] + + if "address" in organizer_item: + organizer.location = self._load_location(organizer_item["address"]) + + return organizer + + def _load_organizer_from_html(self) -> EventOrganizer: + if "reservix.de" in self.origin_url: + div = self.soup.find("div", attrs={"class": "c-organizer-info"}) + + if div: + prefix = "Veranstalter:" + text = div.text.strip() + + if text.startswith(prefix): + organizer_text = text[len(prefix) :].strip() + organizer = self._load_organizer_from_text(organizer_text) + + if organizer: + return organizer + + if "eventim.de" in self.origin_url: + div = self.soup.find( + "div", attrs={"data-qa": "additional-info-promoter-content"} + ) + + if div: + header_div = div.find( + lambda tag: tag.name == "div" and "Veranstalter:" in tag.text + ) + + if header_div: + organizer_paragraph = header_div.findNext("p") + + if organizer_paragraph: + organizer_text = organizer_paragraph.text.strip() + organizer = self._load_organizer_from_text(organizer_text) + + if organizer: + return organizer + + if "regiondo.de" in self.origin_url: + span = self.soup.find( + "span", attrs={"itemtype": "http://schema.org/Organization"} + ) + + if span: + organizer_text = span.text.strip() + organizer = self._load_organizer_from_text(organizer_text) + + if organizer: + return organizer + + if "facebook.com" in self.origin_url: + anchor = self.soup.find("a", attrs={"class": "cc"}) + + if anchor: + organizer_text = anchor.text.strip() + organizer = self._load_organizer_from_text(organizer_text) + + if organizer: + return organizer + + return None + + def _load_organizer_from_text(self, organizer_text: str) -> EventOrganizer: + organizer_parts = organizer_text.split(",") + + organizer = EventOrganizer() + organizer.name = organizer_parts[0].strip() + + if len(organizer_parts) == 4: + location = Location() + location.street = organizer_parts[1].strip() + location.city = organizer_parts[2].strip() + location.country = organizer_parts[3].strip() + + if " " in location.city: + city_parts = location.city.split(" ") + location.postalCode = city_parts[0] + location.city = city_parts[1] + + organizer.location = location + + return organizer + + def _load_place(self): + event = self.event + item = self.ld_json + place_item = None + + if "location" in item: + place_item = item["location"] + + if isinstance(place_item, list) and len(place_item) > 0 and place_item[0]: + place_item = place_item[0] + + location_type = place_item["@type"] if "@type" in place_item else None + + place = EventPlace() + + if "name" not in place_item and location_type == "VirtualLocation": + place.name = "Online" + else: + place.name = place_item["name"] + + location = Location() + + if "address" in place_item: + location = self._load_location(place_item["address"]) + + if "geo" in place_item: + geo_item = place_item["geo"] + + if ( + "@type" in geo_item + and geo_item["@type"] == "GeoCoordinates" + and "latitude" in geo_item + and "longitude" in geo_item + ): + latitude = float(geo_item["latitude"]) + longitude = float(geo_item["longitude"]) + if latitude != 0 and longitude != 0: + location.latitude = latitude + location.longitude = longitude + + if "coordinate" in item: + lat_str, lon_str = item["coordinate"].split(",") + latitude = float(lat_str) + longitude = float(lon_str) + if latitude != 0 and longitude != 0: + location.latitude = latitude + location.longitude = longitude + + if "url" in place_item and validators.url(place_item["url"]): + place.url = place_item["url"] + + place.location = location + event.event_place = place + + def _load_location(self, address: dict) -> Location: + location = Location() + + if "streetAddress" in address: + location.street = address["streetAddress"] + + if "postalCode" in address: + location.postalCode = address["postalCode"] + + if "addressLocality" in address: + location.city = address["addressLocality"] + + if "addressCountry" in address: + location.country = address["addressCountry"] + + return location + + def _load_event_photo(self): + event = self.event + item = self.ld_json + + if "image" not in item: + return + + image = item["image"] + + if isinstance(image, list) and len(image) == 0: + return + + image_items = image if isinstance(image, list) else [image] + + for image_item in image_items: + + if isinstance(image_item, str): + image_item = {"url": image_item} + + if "url" not in image_item: + continue + + try: + pillow_image = get_image_from_url(image_item["url"]) + encoding_format = get_mime_type_from_image(pillow_image) + + if pillow_image.width > 200 or pillow_image.height > 200: + pillow_image = resize_image_to_min(pillow_image) + + validate_image(pillow_image) + resize_image_to_max(pillow_image) + data = get_bytes_from_image(pillow_image) + except Exception: + continue + + image = Image() + image.data = data + image.encoding_format = encoding_format + + if "contributor" in image_item: + image.copyright_text = image_item["contributor"] + + event.photo = image + return + + def _load_event_status(self): + event = self.event + item = self.ld_json + + if "eventStatus" in item: + eventStatus = self._get_item_enum_value("eventStatus") + + if eventStatus in self.event_status_mapping: + event.status = self.event_status_mapping[eventStatus] + return + + if eventStatus == "ausgebucht": + event.booked_up = True + + event.status = EventStatus.scheduled + + def _load_attendance_mode(self): + event = self.event + item = self.ld_json + + if "eventAttendanceMode" in item: + eventAttendanceMode = self._get_item_enum_value("eventAttendanceMode") + + if eventAttendanceMode in self.event_attendance_mode_mapping: + event.attendance_mode = self.event_attendance_mode_mapping[ + eventAttendanceMode + ] + + def _add_categories(self): + event = self.event + item = self.ld_json + types_value = item["@type"] + types_list = types_value if isinstance(types_value, list) else [types_value] + + event_categories = list() + + for item_type in types_list: + if item_type in self.event_type_mapping: + category_name = self.event_type_mapping[item_type] + category = EventCategory.query.filter( + EventCategory.name == category_name + ).first() + if category: + event_categories.append(category) + + if len(event_categories) > 0: + event.categories = event_categories + + def _add_tags(self): + event = self.event + item = self.ld_json + + if "keywords" not in item: + return + + tags = list() + + for keyword in item["keywords"].split(","): + keyword = keyword.strip() + if not keyword[0].islower(): + tags.append(keyword) + + if len(tags) > 0: + event.tags = ",".join(tags) + + def _load_ld_json_from_script(self, ld_json_script): + ld_json_object = json.loads(ld_json_script.string) + ld_json = ( + ld_json_object[0] if isinstance(ld_json_object, list) else ld_json_object + ) + + types_value = ld_json["@type"] + types_list = types_value if isinstance(types_value, list) else [types_value] + + for type in types_list: + if type.endswith("Event"): + return ld_json + + return None + + def _strip_ld_json(self, value: any) -> any: + if isinstance(value, str): + return value.strip() + + if isinstance(value, dict): + result = dict() + for k, v in value.items(): + result[k] = self._strip_ld_json(v) + return result + + if isinstance(value, list): + result = list() + for elem in value: + result.append(self._strip_ld_json(elem)) + return result + + return value + + def _get_item_enum_value(self, key: str) -> str: + return ( + self.ld_json[key] + .replace("http://schema.org/", "") + .replace("https://schema.org/", "") + ) diff --git a/project/static/vue/events/import.vue.js b/project/static/vue/events/import.vue.js new file mode 100644 index 0000000..b0fb5f1 --- /dev/null +++ b/project/static/vue/events/import.vue.js @@ -0,0 +1,96 @@ +const EventImport = { + template: ` +
+

{{ $t("comp.title") }}

+ + + + + {{ $t("comp.draft") }} + {{ $t("comp.published") }} + + + + {{ $t("shared.submit") }} + + + +
+ `, + i18n: { + messages: { + en: { + comp: { + title: "Import event", + url: "URL", + published: "Publish event", + draft: "Save as draft", + importError: "Unfortunately, no event could be imported from the URL.", + }, + }, + de: { + comp: { + title: "Veranstaltung importieren", + url: "URL", + published: "Veranstaltung veröffentlichen", + draft: "Als Entwurf speichern", + importError: "Von der URL konnte leider keine Veranstaltung importiert werden." + }, + }, + }, + }, + data: () => ({ + isSubmitting: false, + form: { + name: null, + publicStatus: "draft", + }, + }), + computed: { + adminUnitId() { + return this.$route.params.admin_unit_id + }, + }, + mounted() { + this.form = { + url: null, + publicStatus: "draft", + } + }, + methods: { + submitForm() { + let data = { + 'url': this.form.url, + 'public_status': this.form.publicStatus, + }; + + axios + .post(`/api/v1/organizations/${this.adminUnitId}/events/import`, + data, + { + withCredentials: true, + handler: this, + handleLoading: this.handleSubmitting, + }) + .then((response) => { + window.location.href = `/event/${response.data.id}`; + }) + }, + handleSubmitting(isLoading) { + this.isSubmitting = isLoading; + }, + handleRequestError(error, message) { + let customMessage = message; + + if (error.response.status == 422) { + customMessage = this.$t("comp.importError"); + } + + this.$root.makeErrorToast(customMessage); + } + } +}; diff --git a/project/templates/layout.html b/project/templates/layout.html index 7d3f9fa..7f257aa 100644 --- a/project/templates/layout.html +++ b/project/templates/layout.html @@ -224,6 +224,7 @@