From 52027ee3b09048485345b485d248237b9f9a5767 Mon Sep 17 00:00:00 2001 From: ray Date: Thu, 23 Nov 2023 17:14:45 -0800 Subject: [PATCH 1/5] DBC22-1260: working model and populate task --- src/backend/apps/cms/migrations/0009_ferry.py | 55 +++++++++++++++++++ ...10_alter_ferry_feed_created_at_and_more.py | 23 ++++++++ .../migrations/0011_alter_ferry_location.py | 21 +++++++ src/backend/apps/cms/models.py | 48 ++++++++++++++++ src/backend/apps/cms/serializers.py | 14 ++++- src/backend/apps/cms/tasks.py | 47 ++++++++++++++++ src/backend/apps/cms/templates/cms/ferry.html | 22 ++++++++ src/backend/apps/event/serializers.py | 11 ++-- src/backend/apps/feed/client.py | 22 +++++++- src/backend/apps/feed/constants.py | 1 + src/backend/apps/feed/fields.py | 17 ++++++ src/backend/apps/feed/serializers.py | 14 +++++ src/backend/apps/feed/tasks.py | 10 +++- src/backend/config/settings/drivebc.py | 1 + 14 files changed, 298 insertions(+), 8 deletions(-) create mode 100644 src/backend/apps/cms/migrations/0009_ferry.py create mode 100644 src/backend/apps/cms/migrations/0010_alter_ferry_feed_created_at_and_more.py create mode 100644 src/backend/apps/cms/migrations/0011_alter_ferry_location.py create mode 100644 src/backend/apps/cms/tasks.py create mode 100644 src/backend/apps/cms/templates/cms/ferry.html diff --git a/src/backend/apps/cms/migrations/0009_ferry.py b/src/backend/apps/cms/migrations/0009_ferry.py new file mode 100644 index 000000000..8975ff310 --- /dev/null +++ b/src/backend/apps/cms/migrations/0009_ferry.py @@ -0,0 +1,55 @@ +# Generated by Django 4.2.3 on 2023-11-22 00:48 + +import django.contrib.gis.db.models.fields +import django.db.models.deletion +import wagtail.fields +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('wagtailcore', '0089_log_entry_data_json_null_to_object'), + ('wagtailimages', '0025_alter_image_file_alter_rendition_file'), + ('cms', '0008_bulletin_image'), + ] + + operations = [ + migrations.CreateModel( + name='Ferry', + fields=[ + ('page_ptr', models.OneToOneField( + auto_created=True, + on_delete=django.db.models.deletion.CASCADE, + parent_link=True, primary_key=True, serialize=False, + to='wagtailcore.page' + )), + ('created_at', models.DateTimeField(auto_now_add=True)), + ('modified_at', models.DateTimeField(auto_now=True)), + ('location', django.contrib.gis.db.models.fields.GeometryField( + srid=4326 + )), + ('url', models.URLField(blank=True)), + ('description', wagtail.fields.RichTextField( + blank=True, max_length=750 + )), + ('seasonal_description', wagtail.fields.RichTextField( + blank=True, max_length=100 + )), + ('service_hours', wagtail.fields.RichTextField( + blank=True, max_length=750 + )), + ('feed_created_at', models.DateTimeField()), + ('feed_modified_at', models.DateTimeField()), + ('image', models.ForeignKey( + blank=True, null=True, + on_delete=django.db.models.deletion.CASCADE, + to='wagtailimages.image' + )), + ], + options={ + 'abstract': False, + }, + bases=('wagtailcore.page', models.Model), + ), + ] diff --git a/src/backend/apps/cms/migrations/0010_alter_ferry_feed_created_at_and_more.py b/src/backend/apps/cms/migrations/0010_alter_ferry_feed_created_at_and_more.py new file mode 100644 index 000000000..9d3e8767d --- /dev/null +++ b/src/backend/apps/cms/migrations/0010_alter_ferry_feed_created_at_and_more.py @@ -0,0 +1,23 @@ +# Generated by Django 4.2.3 on 2023-11-22 03:58 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('cms', '0009_ferry'), + ] + + operations = [ + migrations.AlterField( + model_name='ferry', + name='feed_created_at', + field=models.DateTimeField(auto_now_add=True), + ), + migrations.AlterField( + model_name='ferry', + name='feed_modified_at', + field=models.DateTimeField(auto_now=True), + ), + ] diff --git a/src/backend/apps/cms/migrations/0011_alter_ferry_location.py b/src/backend/apps/cms/migrations/0011_alter_ferry_location.py new file mode 100644 index 000000000..548b4b261 --- /dev/null +++ b/src/backend/apps/cms/migrations/0011_alter_ferry_location.py @@ -0,0 +1,21 @@ +# Generated by Django 4.2.3 on 2023-11-24 01:05 + +import django.contrib.gis.db.models.fields +from django.db import migrations + + +class Migration(migrations.Migration): + + dependencies = [ + ('cms', '0010_alter_ferry_feed_created_at_and_more'), + ] + + operations = [ + migrations.AlterField( + model_name='ferry', + name='location', + field=django.contrib.gis.db.models.fields.GeometryField( + blank=True, null=True, srid=4326 + ), + ), + ] diff --git a/src/backend/apps/cms/models.py b/src/backend/apps/cms/models.py index f4df5e155..531be8f1a 100644 --- a/src/backend/apps/cms/models.py +++ b/src/backend/apps/cms/models.py @@ -72,3 +72,51 @@ def save(self, *args, **kwargs): promote_panels = [] template = 'cms/bulletin.html' + + +class Ferry(Page, BaseModel): + page_body = "Use this page to create or update ferry entries." + + location = models.GeometryField(blank=True, null=True) + + url = models.URLField(blank=True) + image = models.ForeignKey(Image, on_delete=models.CASCADE, blank=True, null=True) + + description = RichTextField(max_length=750, blank=True) + seasonal_description = RichTextField(max_length=100, blank=True) + service_hours = RichTextField(max_length=750, blank=True) + + feed_created_at = models.DateTimeField(auto_now_add=True) + feed_modified_at = models.DateTimeField(auto_now=True) + + def rendered_description(self): + return wagtailcore_tags.richtext(self.description) + + def rendered_seasonal_description(self): + return wagtailcore_tags.richtext(self.seasonal_description) + + def rendered_service_hours(self): + return wagtailcore_tags.richtext(self.service_hours) + + api_fields = [ + APIField('rendered_description'), + APIField('rendered_seasonal_description'), + APIField('rendered_service_hours'), + ] + + def save(self, *args, **kwargs): + super().save(*args, **kwargs) + + # Editor panels configuration + content_panels = [ + FieldPanel("title"), + # FieldPanel("url"), + FieldPanel("image"), + # FieldPanel("location", widget=DriveBCMapWidget), + FieldPanel("description"), + FieldPanel("seasonal_description"), + FieldPanel("service_hours"), + ] + promote_panels = [] + + template = 'cms/ferry.html' diff --git a/src/backend/apps/cms/serializers.py b/src/backend/apps/cms/serializers.py index bd12ffcd2..98d83241f 100644 --- a/src/backend/apps/cms/serializers.py +++ b/src/backend/apps/cms/serializers.py @@ -1,4 +1,4 @@ -from apps.cms.models import Advisory, Bulletin +from apps.cms.models import Advisory, Bulletin, Ferry from rest_framework import serializers from wagtail.templatetags import wagtailcore_tags @@ -53,3 +53,15 @@ class Meta: def get_image_url(self, obj): request = self.context.get("request") return request.build_absolute_uri(obj.image.file.url) if obj.image else '' + + +class FerrySerializer(CMSSerializer): + image_url = serializers.SerializerMethodField('get_image_url') + + class Meta: + model = Ferry + fields = "__all__" + + def get_image_url(self, obj): + request = self.context.get("request") + return request.build_absolute_uri(obj.image.file.url) if obj.image else '' diff --git a/src/backend/apps/cms/tasks.py b/src/backend/apps/cms/tasks.py new file mode 100644 index 000000000..5539824a8 --- /dev/null +++ b/src/backend/apps/cms/tasks.py @@ -0,0 +1,47 @@ +import logging + +from apps.cms.models import Ferry +from apps.feed.client import FeedClient +from django.contrib.contenttypes.models import ContentType +from django.contrib.gis.geos import Point +from django.core.exceptions import ObjectDoesNotExist +from django.utils.text import slugify +from wagtail.models import Page + +logger = logging.getLogger(__name__) + + +def populate_ferry_from_data(ferry_data): + ferry_id = ferry_data.get('id') + + try: + ferry = Ferry.objects.get(id=ferry_id) + + except ObjectDoesNotExist: + # Generate Page associated with ferry obj + root_page = Page.get_root_nodes()[0] + + # New ferry obj + ferry = Ferry( + title=ferry_data['title'], + slug=slugify(ferry_data['title']), + content_type=ContentType.objects.get_for_model(Ferry), + ) + + root_page.add_child(instance=ferry) + ferry.save_revision().publish() + + ferry.location = Point(ferry_data['location']['coordinates']) + ferry.url = ferry_data['url'] + ferry.feed_created_at = ferry_data['feed_created_at'] + ferry.feed_modified_at = ferry_data['feed_modified_at'] + ferry.save() + + +def populate_all_ferry_data(): + feed_data = FeedClient().get_ferries_list()['features'] + for ferry_data in feed_data: + populate_ferry_from_data(ferry_data) + + # Rebuild cache + # FerryAPI().set_list_data() diff --git a/src/backend/apps/cms/templates/cms/ferry.html b/src/backend/apps/cms/templates/cms/ferry.html new file mode 100644 index 000000000..1031eefaf --- /dev/null +++ b/src/backend/apps/cms/templates/cms/ferry.html @@ -0,0 +1,22 @@ +{% load wagtailcore_tags %} + +{% block body_class %}template-blogindexpage{% endblock %} + +{% load static %} + +{% block styles %} + +{% endblock %} + +{% block content %} +
+

{{ page.title|safe }}

+

Published at {{ page.created_at|safe }}

+

Last updated at {{ page.modified_at|safe }}

+
+ {{ page.seasonal_description|safe }} + {{ page.description|safe }} + {{ page.service_hours|safe }} +
+
+{% endblock %} diff --git a/src/backend/apps/event/serializers.py b/src/backend/apps/event/serializers.py index 12c30830c..ec7d106e2 100644 --- a/src/backend/apps/event/serializers.py +++ b/src/backend/apps/event/serializers.py @@ -1,11 +1,14 @@ -from datetime import datetime from apps.event.enums import EVENT_DIRECTION_DISPLAY from apps.event.models import Event from rest_framework import serializers class ScheduleSerializer(serializers.Serializer): - intervals = serializers.ListField(child=serializers.CharField(), required=False, default=[]) + intervals = serializers.ListField( + child=serializers.CharField(), + required=False, default=[] + ) + class EventSerializer(serializers.ModelSerializer): direction_display = serializers.SerializerMethodField() @@ -20,14 +23,14 @@ class Meta: ) def to_representation(self, instance): - representation = super(EventSerializer, self).to_representation(instance) + representation = super().to_representation(instance) schedule = instance.schedule.get('intervals', []) if schedule and isinstance(schedule, list): start, end = schedule[0].split('/') representation['start'] = start representation['end'] = end return representation - + def get_direction_display(self, obj): return EVENT_DIRECTION_DISPLAY[obj.direction] diff --git a/src/backend/apps/feed/client.py b/src/backend/apps/feed/client.py index 032e04566..801e5d597 100644 --- a/src/backend/apps/feed/client.py +++ b/src/backend/apps/feed/client.py @@ -3,10 +3,11 @@ from urllib.parse import urljoin import httpx -from apps.feed.constants import OPEN511, WEBCAM +from apps.feed.constants import INLAND_FERRY, OPEN511, WEBCAM from apps.feed.serializers import ( EventAPISerializer, EventFeedSerializer, + FerryAPISerializer, WebcamAPISerializer, WebcamFeedSerializer, ) @@ -27,6 +28,9 @@ def __init__(self): OPEN511: { "base_url": settings.DRIVEBC_OPEN_511_API_BASE_URL, }, + INLAND_FERRY: { + "base_url": settings.DRIVEBC_INLAND_FERRY_API_BASE_URL, + }, } def _get_auth_headers(self, resource_type): @@ -120,3 +124,19 @@ def get_event_list(self): OPEN511, 'events', EventAPISerializer, {"format": "json", "limit": 500} ) + + # Ferries + def get_ferries_list(self): + return self.get_list_feed( + INLAND_FERRY, + 'geoV05/hwy/ows', + FerryAPISerializer, + { + "service": "WFS", + "version": "1.0.0", + "request": "GetFeature", + "typeName": "hwy:ISS_INLAND_FERRY", + "maxFeatures": 500, + "outputFormat": "application/json", + } + ) diff --git a/src/backend/apps/feed/constants.py b/src/backend/apps/feed/constants.py index 639c25138..85796b1d5 100644 --- a/src/backend/apps/feed/constants.py +++ b/src/backend/apps/feed/constants.py @@ -1,3 +1,4 @@ ROUTE_PLANNER = "route_planner" WEBCAM = "webcam" OPEN511 = "open511" +INLAND_FERRY = "inland_ferry" diff --git a/src/backend/apps/feed/fields.py b/src/backend/apps/feed/fields.py index 9fd3ab7ae..7cb301ea7 100644 --- a/src/backend/apps/feed/fields.py +++ b/src/backend/apps/feed/fields.py @@ -116,3 +116,20 @@ def to_internal_value(self, data): class EventGeographyField(DriveBCField, GeometryField): pass + + +# Ferry +class FerryPropertiesField(serializers.Field): + def to_internal_value(self, data): + res = { + "id": data['FERRY_ID'], + "title": data['NAME'], + "url": data['URL'], + "feed_created_at": data['CREATED_TIMESTAMP'], + "feed_modified_at": data['UPDATED_TIMESTAMP'], + } + return res + + +class FerryGeographyField(DriveBCField, GeometryField): + pass diff --git a/src/backend/apps/feed/serializers.py b/src/backend/apps/feed/serializers.py index e14a97c56..eb810d00a 100644 --- a/src/backend/apps/feed/serializers.py +++ b/src/backend/apps/feed/serializers.py @@ -1,10 +1,13 @@ from datetime import datetime + from apps.feed.fields import ( DriveBCDateField, DriveBCField, DriveBCSingleListField, EventGeographyField, EventRoadsField, + FerryGeographyField, + FerryPropertiesField, WebcamHighwayField, WebcamImageStatsField, WebcamLocationField, @@ -85,5 +88,16 @@ def to_internal_value(self, data): return internal_data + class EventAPISerializer(serializers.Serializer): events = EventFeedSerializer(many=True) + + +# Ferry +class FerryFeedSerializer(serializers.Serializer): + geometry = FerryGeographyField('location', source="*") + properties = FerryPropertiesField(source="*") + + +class FerryAPISerializer(serializers.Serializer): + features = FerryFeedSerializer(many=True) diff --git a/src/backend/apps/feed/tasks.py b/src/backend/apps/feed/tasks.py index 8d4f2e267..f729e7a24 100644 --- a/src/backend/apps/feed/tasks.py +++ b/src/backend/apps/feed/tasks.py @@ -1,8 +1,9 @@ +from apps.cms.tasks import populate_all_ferry_data from apps.event.tasks import populate_all_event_data from apps.webcam.tasks import populate_all_webcam_data, update_all_webcam_data +from django.core.management import call_command from huey import crontab from huey.contrib.djhuey import db_periodic_task -from django.core.management import call_command @db_periodic_task(crontab(hour="*/6")) @@ -20,6 +21,11 @@ def populate_event_task(): populate_all_event_data() +@db_periodic_task(crontab(hour="*/24")) +def populate_ferry_task(): + populate_all_ferry_data() + + @db_periodic_task(crontab(minute="*/1")) def publish_scheduled(): - call_command('publish_scheduled') + call_command('publish_scheduled') diff --git a/src/backend/config/settings/drivebc.py b/src/backend/config/settings/drivebc.py index 60b1583ab..c7b6b7cd3 100644 --- a/src/backend/config/settings/drivebc.py +++ b/src/backend/config/settings/drivebc.py @@ -10,3 +10,4 @@ # Feed API Settings DRIVEBC_WEBCAM_API_BASE_URL = env("DRIVEBC_WEBCAM_API_BASE_URL") DRIVEBC_OPEN_511_API_BASE_URL = env("DRIVEBC_OPEN_511_API_BASE_URL") +DRIVEBC_INLAND_FERRY_API_BASE_URL = env("DRIVEBC_INLAND_FERRY_API_BASE_URL") From 74b8f9324cabd72d345a67f5daa8c29d6a3df2bd Mon Sep 17 00:00:00 2001 From: ray Date: Fri, 24 Nov 2023 14:48:24 -0800 Subject: [PATCH 2/5] DBC22-1260: caching and unit tests for cms models --- .env.example | 1 + src/backend/apps/cms/models.py | 5 ++ src/backend/apps/cms/serializers.py | 29 ++++++++-- src/backend/apps/cms/tasks.py | 3 +- .../apps/cms/tests/test_advisory_api.py | 12 ++-- .../apps/cms/tests/test_bulletin_api.py | 14 ++--- src/backend/apps/cms/tests/test_ferry_api.py | 55 +++++++++++++++++++ src/backend/apps/cms/urls.py | 7 ++- src/backend/apps/cms/views.py | 26 ++++++--- src/backend/apps/shared/enums.py | 4 ++ src/backend/apps/shared/tests.py | 2 + 11 files changed, 129 insertions(+), 29 deletions(-) create mode 100644 src/backend/apps/cms/tests/test_ferry_api.py diff --git a/.env.example b/.env.example index 39ab1dd7f..8c9449b16 100644 --- a/.env.example +++ b/.env.example @@ -49,6 +49,7 @@ REACT_APP_GEOCODER_HOST= REACT_APP_GEOCODER_API_AUTH_KEY= # API +DRIVEBC_INLAND_FERRY_API_BASE_URL= DRIVEBC_IMAGE_API_BASE_URL= DRIVEBC_IMAGE_PROXY_URL= DRIVEBC_WEBCAM_API_BASE_URL= diff --git a/src/backend/apps/cms/models.py b/src/backend/apps/cms/models.py index 531be8f1a..f1e4ecf6c 100644 --- a/src/backend/apps/cms/models.py +++ b/src/backend/apps/cms/models.py @@ -1,6 +1,8 @@ +from apps.shared.enums import CacheKey from apps.shared.models import BaseModel from django.contrib.gis.db import models from django.contrib.gis.forms import OSMWidget +from django.core.cache import cache from wagtail.admin.panels import FieldPanel from wagtail.api import APIField from wagtail.fields import RichTextField @@ -33,6 +35,7 @@ def rendered_body(self): def save(self, *args, **kwargs): super().save(*args, **kwargs) + cache.delete(CacheKey.ADVISORY_LIST) # Editor panels configuration content_panels = [ @@ -61,6 +64,7 @@ def rendered_body(self): def save(self, *args, **kwargs): super().save(*args, **kwargs) + cache.delete(CacheKey.BULLETIN_LIST) # Editor panels configuration content_panels = [ @@ -106,6 +110,7 @@ def rendered_service_hours(self): def save(self, *args, **kwargs): super().save(*args, **kwargs) + cache.delete(CacheKey.FERRY_LIST) # Editor panels configuration content_panels = [ diff --git a/src/backend/apps/cms/serializers.py b/src/backend/apps/cms/serializers.py index 98d83241f..72a06096c 100644 --- a/src/backend/apps/cms/serializers.py +++ b/src/backend/apps/cms/serializers.py @@ -16,16 +16,14 @@ class CMSSerializer(serializers.ModelSerializer): - body = serializers.SerializerMethodField() - def get_host(self): request = self.context.get("request") prefix = "https://" if request and request.is_secure() else "http://" return prefix + request.get_host() if request else 'localhost:8000' - # get rendered html elements for body and access static media foder - def get_body(self, obj): - res = wagtailcore_tags.richtext(obj.body) + # get rendered html elements and access static media folder + def get_richtext(self, text): + res = wagtailcore_tags.richtext(text) res = res.replace( 'href="/drivebc-cms', 'href="' + self.get_host() + '/drivebc-cms' @@ -38,12 +36,18 @@ def get_body(self, obj): class AdvisorySerializer(CMSSerializer): + body = serializers.SerializerMethodField() + class Meta: model = Advisory fields = "__all__" + def get_body(self, obj): + return self.get_richtext(obj.body) + class BulletinSerializer(CMSSerializer): + body = serializers.SerializerMethodField() image_url = serializers.SerializerMethodField('get_image_url') class Meta: @@ -54,8 +58,14 @@ def get_image_url(self, obj): request = self.context.get("request") return request.build_absolute_uri(obj.image.file.url) if obj.image else '' + def get_body(self, obj): + return self.get_richtext(obj.body) + class FerrySerializer(CMSSerializer): + description = serializers.SerializerMethodField() + seasonal_description = serializers.SerializerMethodField() + service_hours = serializers.SerializerMethodField() image_url = serializers.SerializerMethodField('get_image_url') class Meta: @@ -65,3 +75,12 @@ class Meta: def get_image_url(self, obj): request = self.context.get("request") return request.build_absolute_uri(obj.image.file.url) if obj.image else '' + + def get_description(self, obj): + return self.get_richtext(obj.description) + + def get_seasonal_description(self, obj): + return self.get_richtext(obj.seasonal_description) + + def get_service_hours(self, obj): + return self.get_richtext(obj.service_hours) diff --git a/src/backend/apps/cms/tasks.py b/src/backend/apps/cms/tasks.py index 5539824a8..304e4650d 100644 --- a/src/backend/apps/cms/tasks.py +++ b/src/backend/apps/cms/tasks.py @@ -1,6 +1,7 @@ import logging from apps.cms.models import Ferry +from apps.cms.views import FerryAPI from apps.feed.client import FeedClient from django.contrib.contenttypes.models import ContentType from django.contrib.gis.geos import Point @@ -44,4 +45,4 @@ def populate_all_ferry_data(): populate_ferry_from_data(ferry_data) # Rebuild cache - # FerryAPI().set_list_data() + FerryAPI().set_list_data() diff --git a/src/backend/apps/cms/tests/test_advisory_api.py b/src/backend/apps/cms/tests/test_advisory_api.py index 4f134419d..35e2d5e7e 100644 --- a/src/backend/apps/cms/tests/test_advisory_api.py +++ b/src/backend/apps/cms/tests/test_advisory_api.py @@ -1,5 +1,5 @@ from apps.cms.models import Advisory -from apps.event.views import EventAPI +from apps.cms.views import AdvisoryAPI from apps.shared.enums import CacheKey from apps.shared.tests import BaseTest from django.contrib.contenttypes.models import ContentType @@ -41,14 +41,14 @@ def test_advisory_list_caching(self): url = "/api/cms/advisories/" response = self.client.get(url, {}) assert len(response.data) == 2 - assert cache.get(CacheKey.DELAY_LIST) is None + assert cache.get(CacheKey.ADVISORY_LIST) is not None # Cached result - Advisory.objects.filter(id__gte=2).delete() + Advisory.objects.first().delete() response = self.client.get(url, {}) - assert len(response.data) == 0 + assert len(response.data) == 2 # Updated cached result - EventAPI().set_list_data() + AdvisoryAPI().set_list_data() response = self.client.get(url, {}) - assert len(response.data) == 0 + assert len(response.data) == 1 diff --git a/src/backend/apps/cms/tests/test_bulletin_api.py b/src/backend/apps/cms/tests/test_bulletin_api.py index b15d9f35e..9d4927443 100644 --- a/src/backend/apps/cms/tests/test_bulletin_api.py +++ b/src/backend/apps/cms/tests/test_bulletin_api.py @@ -1,5 +1,5 @@ from apps.cms.models import Bulletin -from apps.event.views import EventAPI +from apps.cms.views import BulletinAPI from apps.shared.enums import CacheKey from apps.shared.tests import BaseTest from django.contrib.contenttypes.models import ContentType @@ -32,20 +32,20 @@ def setUp(self): def test_bulletin_list_caching(self): # Empty cache - assert cache.get(CacheKey.DELAY_LIST) is None + assert cache.get(CacheKey.BULLETIN_LIST) is None # Cache miss url = "/api/cms/bulletins/" response = self.client.get(url, {}) assert len(response.data) == 2 - assert cache.get(CacheKey.DELAY_LIST) is None + assert cache.get(CacheKey.BULLETIN_LIST) is not None # Cached result - Bulletin.objects.filter(id__gte=2).delete() + Bulletin.objects.first().delete() response = self.client.get(url, {}) - assert len(response.data) == 0 + assert len(response.data) == 2 # Updated cached result - EventAPI().set_list_data() + BulletinAPI().set_list_data() response = self.client.get(url, {}) - assert len(response.data) == 0 + assert len(response.data) == 1 diff --git a/src/backend/apps/cms/tests/test_ferry_api.py b/src/backend/apps/cms/tests/test_ferry_api.py new file mode 100644 index 000000000..0444cdb87 --- /dev/null +++ b/src/backend/apps/cms/tests/test_ferry_api.py @@ -0,0 +1,55 @@ +from apps.cms.models import Ferry +from apps.cms.views import FerryAPI +from apps.shared.enums import CacheKey +from apps.shared.tests import BaseTest +from django.contrib.contenttypes.models import ContentType +from django.core.cache import cache +from rest_framework.test import APITestCase + + +class TestFerryAPI(APITestCase, BaseTest): + def setUp(self): + super().setUp() + ferry = Ferry.objects.create( + title="Ferry title", + path="000100010001", + depth=3, + content_type=ContentType.objects.get( + app_label='cms', + model='ferry' + ), + live=True, + ) + ferry.save() + + ferry_2 = Ferry.objects.create( + title="Ferry title 2", + path="000100010002", + depth=3, + content_type=ContentType.objects.get( + app_label='cms', + model='ferry' + ), + live=True, + ) + ferry_2.save() + + def test_ferry_list_caching(self): + # Empty cache + assert cache.get(CacheKey.FERRY_LIST) is None + + # Build list of two cache on call + url = "/api/cms/ferries/" + response = self.client.get(url, {}) + assert len(response.data) == 2 + assert cache.get(CacheKey.FERRY_LIST) is not None + + # Cached result, still list of two after deletion + Ferry.objects.first().delete() + response = self.client.get(url, {}) + assert len(response.data) == 2 + + # Updated cached result, only one row left + FerryAPI().set_list_data() + response = self.client.get(url, {}) + assert len(response.data) == 1 diff --git a/src/backend/apps/cms/urls.py b/src/backend/apps/cms/urls.py index 435816f0e..86c6728e2 100644 --- a/src/backend/apps/cms/urls.py +++ b/src/backend/apps/cms/urls.py @@ -8,7 +8,7 @@ from wagtail.documents.api.v2.views import DocumentsAPIViewSet from wagtail.images.api.v2.views import ImagesAPIViewSet -from .views import AdvisoryAPIViewSet, BulletinAPIViewSet +from .views import AdvisoryAPI, BulletinAPI, FerryAPI wagtail_api_router = WagtailAPIRouter('wagtailapi') wagtail_api_router.register_endpoint('pages', PagesAPIViewSet) @@ -16,8 +16,9 @@ wagtail_api_router.register_endpoint('documents', DocumentsAPIViewSet) cms_api_router = routers.DefaultRouter() -cms_api_router.register('advisories', AdvisoryAPIViewSet) -cms_api_router.register('bulletins', BulletinAPIViewSet) +cms_api_router.register('advisories', AdvisoryAPI) +cms_api_router.register('bulletins', BulletinAPI) +cms_api_router.register('ferries', FerryAPI) urlpatterns = [ path('', include(wagtailadmin_urls)), diff --git a/src/backend/apps/cms/views.py b/src/backend/apps/cms/views.py index 8a2c053b7..31c0afec5 100644 --- a/src/backend/apps/cms/views.py +++ b/src/backend/apps/cms/views.py @@ -1,20 +1,32 @@ -from rest_framework.viewsets import ReadOnlyModelViewSet +from apps.cms.models import Advisory, Bulletin, Ferry +from apps.cms.serializers import AdvisorySerializer, BulletinSerializer, FerrySerializer +from apps.shared.enums import CacheKey, CacheTimeout +from apps.shared.views import CachedListModelMixin +from rest_framework import viewsets -from .models import Advisory, Bulletin -from .serializers import AdvisorySerializer, BulletinSerializer - -class CMSViewSet(ReadOnlyModelViewSet): +class CMSViewSet: def get_serializer_context(self): """Adds request to the context of serializer""" return {"request": self.request} -class AdvisoryAPIViewSet(CMSViewSet): +class AdvisoryAPI(CMSViewSet, CachedListModelMixin, viewsets.ReadOnlyModelViewSet): queryset = Advisory.objects.filter(live=True) serializer_class = AdvisorySerializer + cache_key = CacheKey.ADVISORY_LIST + cache_timeout = CacheTimeout.DEFAULT -class BulletinAPIViewSet(CMSViewSet): +class BulletinAPI(CMSViewSet, CachedListModelMixin, viewsets.ReadOnlyModelViewSet): queryset = Bulletin.objects.filter(live=True) serializer_class = BulletinSerializer + cache_key = CacheKey.BULLETIN_LIST + cache_timeout = CacheTimeout.DEFAULT + + +class FerryAPI(CMSViewSet, CachedListModelMixin, viewsets.ReadOnlyModelViewSet): + queryset = Ferry.objects.filter(live=True) + serializer_class = FerrySerializer + cache_key = CacheKey.FERRY_LIST + cache_timeout = CacheTimeout.FERRY_LIST diff --git a/src/backend/apps/shared/enums.py b/src/backend/apps/shared/enums.py index 50344dad0..51dd44756 100644 --- a/src/backend/apps/shared/enums.py +++ b/src/backend/apps/shared/enums.py @@ -45,12 +45,16 @@ class CacheTimeout: DEFAULT = 120 WEBCAM_LIST = 60*7 # 5min buffer + (2*1)min twice of task interval DELAY_LIST = 60*15 # 5min buffer + (2*5)min twice of task interval + FERRY_LIST = 60*60*24 # 24hr class CacheKey: DEFAULT = "default_key" WEBCAM_LIST = "webcam_list" DELAY_LIST = "delay_list" + ADVISORY_LIST = "advisory_list" + BULLETIN_LIST = "bulletin_list" + FERRY_LIST = "ferry_list" TEST_APP_CACHE = "test_app_cache" diff --git a/src/backend/apps/shared/tests.py b/src/backend/apps/shared/tests.py index 55d9199e5..8a6d6a3b6 100644 --- a/src/backend/apps/shared/tests.py +++ b/src/backend/apps/shared/tests.py @@ -1,6 +1,7 @@ import logging from unittest.mock import MagicMock +from apps.cms.models import Ferry from apps.event.models import Event from apps.webcam.models import Webcam from django.core.cache import cache @@ -44,3 +45,4 @@ def tearDown(self): cache.clear() Webcam.objects.all().delete() Event.objects.all().delete() + Ferry.objects.all().delete() From 2f50ba248f4ba330dcedacba9daf87e517ae79a6 Mon Sep 17 00:00:00 2001 From: ray Date: Fri, 24 Nov 2023 17:31:33 -0800 Subject: [PATCH 3/5] DBC22-1261: fixed coordinates, working ferry layer --- .../apps/cms/migrations/0012_ferry_feed_id.py | 19 ++++++++ src/backend/apps/cms/models.py | 2 + src/backend/apps/cms/tasks.py | 14 ++++-- .../apps/cms/tests/test_advisory_api.py | 2 +- src/backend/apps/cms/views.py | 14 ++++-- src/backend/apps/event/tasks.py | 5 +- .../apps/event/tests/test_event_api.py | 12 ++--- src/backend/apps/event/views.py | 4 +- src/backend/apps/feed/client.py | 1 + src/backend/apps/shared/enums.py | 4 +- src/backend/apps/shared/views.py | 2 +- src/frontend/src/Components/Map.js | 23 ++++++++- ...initions.js => featureStyleDefinitions.js} | 34 +++++++++++++ src/frontend/src/Components/data/ferries.js | 11 +++++ src/frontend/src/Components/map/helper.js | 2 +- .../src/Components/map/layers/camerasLayer.js | 2 +- .../src/Components/map/layers/ferriesLayer.js | 45 ++++++++++++++++++ .../src/images/mapIcons/ferry-active.png | Bin 0 -> 11771 bytes .../src/images/mapIcons/ferry-hover.png | Bin 0 -> 9382 bytes .../src/images/mapIcons/ferry-static.png | Bin 0 -> 10463 bytes 20 files changed, 168 insertions(+), 28 deletions(-) create mode 100644 src/backend/apps/cms/migrations/0012_ferry_feed_id.py rename src/frontend/src/Components/data/{eventStyleDefinitions.js => featureStyleDefinitions.js} (91%) create mode 100644 src/frontend/src/Components/data/ferries.js create mode 100644 src/frontend/src/Components/map/layers/ferriesLayer.js create mode 100644 src/frontend/src/images/mapIcons/ferry-active.png create mode 100644 src/frontend/src/images/mapIcons/ferry-hover.png create mode 100644 src/frontend/src/images/mapIcons/ferry-static.png diff --git a/src/backend/apps/cms/migrations/0012_ferry_feed_id.py b/src/backend/apps/cms/migrations/0012_ferry_feed_id.py new file mode 100644 index 000000000..c84f5eef9 --- /dev/null +++ b/src/backend/apps/cms/migrations/0012_ferry_feed_id.py @@ -0,0 +1,19 @@ +# Generated by Django 4.2.3 on 2023-11-25 00:48 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('cms', '0011_alter_ferry_location'), + ] + + operations = [ + migrations.AddField( + model_name='ferry', + name='feed_id', + field=models.PositiveIntegerField(default=None, unique=True), + preserve_default=False, + ), + ] diff --git a/src/backend/apps/cms/models.py b/src/backend/apps/cms/models.py index f1e4ecf6c..493df781c 100644 --- a/src/backend/apps/cms/models.py +++ b/src/backend/apps/cms/models.py @@ -81,6 +81,8 @@ def save(self, *args, **kwargs): class Ferry(Page, BaseModel): page_body = "Use this page to create or update ferry entries." + feed_id = models.PositiveIntegerField(unique=True) + location = models.GeometryField(blank=True, null=True) url = models.URLField(blank=True) diff --git a/src/backend/apps/cms/tasks.py b/src/backend/apps/cms/tasks.py index 304e4650d..f1273ed60 100644 --- a/src/backend/apps/cms/tasks.py +++ b/src/backend/apps/cms/tasks.py @@ -1,10 +1,11 @@ import logging from apps.cms.models import Ferry -from apps.cms.views import FerryAPI from apps.feed.client import FeedClient +from apps.shared.enums import CacheKey from django.contrib.contenttypes.models import ContentType from django.contrib.gis.geos import Point +from django.core.cache import cache from django.core.exceptions import ObjectDoesNotExist from django.utils.text import slugify from wagtail.models import Page @@ -16,7 +17,7 @@ def populate_ferry_from_data(ferry_data): ferry_id = ferry_data.get('id') try: - ferry = Ferry.objects.get(id=ferry_id) + ferry = Ferry.objects.get(feed_id=ferry_id) except ObjectDoesNotExist: # Generate Page associated with ferry obj @@ -24,6 +25,7 @@ def populate_ferry_from_data(ferry_data): # New ferry obj ferry = Ferry( + feed_id=ferry_data['id'], title=ferry_data['title'], slug=slugify(ferry_data['title']), content_type=ContentType.objects.get_for_model(Ferry), @@ -32,7 +34,9 @@ def populate_ferry_from_data(ferry_data): root_page.add_child(instance=ferry) ferry.save_revision().publish() - ferry.location = Point(ferry_data['location']['coordinates']) + point = Point(ferry_data['location']['coordinates'], srid=4326) + + ferry.location = point ferry.url = ferry_data['url'] ferry.feed_created_at = ferry_data['feed_created_at'] ferry.feed_modified_at = ferry_data['feed_modified_at'] @@ -44,5 +48,5 @@ def populate_all_ferry_data(): for ferry_data in feed_data: populate_ferry_from_data(ferry_data) - # Rebuild cache - FerryAPI().set_list_data() + # Reset + cache.delete(CacheKey.FERRY_LIST) diff --git a/src/backend/apps/cms/tests/test_advisory_api.py b/src/backend/apps/cms/tests/test_advisory_api.py index 35e2d5e7e..715766070 100644 --- a/src/backend/apps/cms/tests/test_advisory_api.py +++ b/src/backend/apps/cms/tests/test_advisory_api.py @@ -35,7 +35,7 @@ def setUp(self): def test_advisory_list_caching(self): # Empty cache - assert cache.get(CacheKey.DELAY_LIST) is None + assert cache.get(CacheKey.EVENT_LIST) is None # Cache miss url = "/api/cms/advisories/" diff --git a/src/backend/apps/cms/views.py b/src/backend/apps/cms/views.py index 31c0afec5..091139043 100644 --- a/src/backend/apps/cms/views.py +++ b/src/backend/apps/cms/views.py @@ -5,27 +5,31 @@ from rest_framework import viewsets -class CMSViewSet: +class CMSViewSet(viewsets.ReadOnlyModelViewSet): def get_serializer_context(self): + context = super().get_serializer_context() + """Adds request to the context of serializer""" - return {"request": self.request} + context['request'] = self.request + + return context -class AdvisoryAPI(CMSViewSet, CachedListModelMixin, viewsets.ReadOnlyModelViewSet): +class AdvisoryAPI(CachedListModelMixin, CMSViewSet): queryset = Advisory.objects.filter(live=True) serializer_class = AdvisorySerializer cache_key = CacheKey.ADVISORY_LIST cache_timeout = CacheTimeout.DEFAULT -class BulletinAPI(CMSViewSet, CachedListModelMixin, viewsets.ReadOnlyModelViewSet): +class BulletinAPI(CachedListModelMixin, CMSViewSet): queryset = Bulletin.objects.filter(live=True) serializer_class = BulletinSerializer cache_key = CacheKey.BULLETIN_LIST cache_timeout = CacheTimeout.DEFAULT -class FerryAPI(CMSViewSet, CachedListModelMixin, viewsets.ReadOnlyModelViewSet): +class FerryAPI(CachedListModelMixin, CMSViewSet): queryset = Ferry.objects.filter(live=True) serializer_class = FerrySerializer cache_key = CacheKey.FERRY_LIST diff --git a/src/backend/apps/event/tasks.py b/src/backend/apps/event/tasks.py index 346bae0b6..19a2f3ff4 100644 --- a/src/backend/apps/event/tasks.py +++ b/src/backend/apps/event/tasks.py @@ -3,8 +3,9 @@ from apps.event.enums import EVENT_STATUS from apps.event.models import Event from apps.event.serializers import EventSerializer -from apps.event.views import EventAPI from apps.feed.client import FeedClient +from apps.shared.enums import CacheKey +from django.core.cache import cache from django.core.exceptions import ObjectDoesNotExist logger = logging.getLogger(__name__) @@ -38,4 +39,4 @@ def populate_all_event_data(): .update(status=EVENT_STATUS.INACTIVE) # Rebuild cache - EventAPI().set_list_data() + cache.delete(CacheKey.EVENT_LIST) diff --git a/src/backend/apps/event/tests/test_event_api.py b/src/backend/apps/event/tests/test_event_api.py index b1840369e..b7c8c8938 100644 --- a/src/backend/apps/event/tests/test_event_api.py +++ b/src/backend/apps/event/tests/test_event_api.py @@ -47,22 +47,22 @@ def setUp(self): 2023, 6, 2, 16, 42, 16, tzinfo=zoneinfo.ZoneInfo(key="America/Vancouver") ), - schedule = {"intervals": [ - "2023-05-23T14:00/2023-07-22T14:00" + schedule={ + "intervals": [ + "2023-05-23T14:00/2023-07-22T14:00" ] }, - ) def test_delay_list_caching(self): # Empty cache - assert cache.get(CacheKey.DELAY_LIST) is None + assert cache.get(CacheKey.EVENT_LIST) is None # Cache miss url = "/api/events/" response = self.client.get(url, {}) assert len(response.data) == 10 - assert cache.get(CacheKey.DELAY_LIST) is not None + assert cache.get(CacheKey.EVENT_LIST) is not None # Cached result Event.objects.filter(id__gte=5).delete() @@ -102,4 +102,4 @@ def test_events_list_filtering(self): json.dumps({"route": [[-110.569743, 38.561231], [-110.569743, 39.561231]]}), content_type='application/json' ) - assert len(response.data) == 0 \ No newline at end of file + assert len(response.data) == 0 diff --git a/src/backend/apps/event/views.py b/src/backend/apps/event/views.py index beb971fcc..b3f412912 100644 --- a/src/backend/apps/event/views.py +++ b/src/backend/apps/event/views.py @@ -9,8 +9,8 @@ class EventAPI(CachedListModelMixin): queryset = Event.objects.all().exclude(status=EVENT_STATUS.INACTIVE) serializer_class = EventSerializer - cache_key = CacheKey.DELAY_LIST - cache_timeout = CacheTimeout.DELAY_LIST + cache_key = CacheKey.EVENT_LIST + cache_timeout = CacheTimeout.EVENT_LIST class EventViewSet(EventAPI, viewsets.ReadOnlyModelViewSet): diff --git a/src/backend/apps/feed/client.py b/src/backend/apps/feed/client.py index 801e5d597..5f6d89c79 100644 --- a/src/backend/apps/feed/client.py +++ b/src/backend/apps/feed/client.py @@ -138,5 +138,6 @@ def get_ferries_list(self): "typeName": "hwy:ISS_INLAND_FERRY", "maxFeatures": 500, "outputFormat": "application/json", + "srsName": "EPSG:4326" } ) diff --git a/src/backend/apps/shared/enums.py b/src/backend/apps/shared/enums.py index 51dd44756..2e7459220 100644 --- a/src/backend/apps/shared/enums.py +++ b/src/backend/apps/shared/enums.py @@ -44,14 +44,14 @@ class Orientation: class CacheTimeout: DEFAULT = 120 WEBCAM_LIST = 60*7 # 5min buffer + (2*1)min twice of task interval - DELAY_LIST = 60*15 # 5min buffer + (2*5)min twice of task interval + EVENT_LIST = 60 * 15 # 5min buffer + (2*5)min twice of task interval FERRY_LIST = 60*60*24 # 24hr class CacheKey: DEFAULT = "default_key" WEBCAM_LIST = "webcam_list" - DELAY_LIST = "delay_list" + EVENT_LIST = "event_list" ADVISORY_LIST = "advisory_list" BULLETIN_LIST = "bulletin_list" FERRY_LIST = "ferry_list" diff --git a/src/backend/apps/shared/views.py b/src/backend/apps/shared/views.py index 566ae7fa8..a77a51b68 100644 --- a/src/backend/apps/shared/views.py +++ b/src/backend/apps/shared/views.py @@ -27,7 +27,7 @@ class CachedListModelMixin: cache_timeout = CacheTimeout.DEFAULT def fetch_list_data(self, queryset=None): - serializer = self.serializer_class( + serializer = self.get_serializer( queryset.all() if queryset is not None else self.queryset.all(), many=True ) return serializer.data diff --git a/src/frontend/src/Components/Map.js b/src/frontend/src/Components/Map.js index 738d99c3b..003d7ac86 100644 --- a/src/frontend/src/Components/Map.js +++ b/src/frontend/src/Components/Map.js @@ -24,6 +24,8 @@ import { getCamPopup, getEventPopup } from './map/mapPopup.js' import { getEvents } from './data/events.js'; import { getEventsLayer } from './map/layers/eventsLayer.js'; import { getEventIcon } from './map/helper.js'; +import { getFerries } from './data/ferries.js'; +import { getFerriesLayer } from './map/layers/ferriesLayer.js'; import { getWebcams, groupCameras } from './data/webcams.js'; import { getRouteLayer } from './map/routeLayer.js'; import { MapContext } from '../App.js'; @@ -50,7 +52,7 @@ import VectorTileSource from 'ol/source/VectorTile.js'; import View from 'ol/View'; // Styling -import { cameraStyles } from './data/eventStyleDefinitions.js'; +import { cameraStyles } from './data/featureStyleDefinitions.js'; import './Map.scss'; export default function MapWrapper({ @@ -523,12 +525,14 @@ export default function MapWrapper({ loadEvents(selectedRoute.points); loadCameras(selectedRoute.points); + loadFerries(); fitMap(); } else { loadEvents(); loadCameras(); + loadFerries(); } }, [selectedRoute]); @@ -556,7 +560,6 @@ export default function MapWrapper({ mapRef.current.removeLayer(layers.current['eventsLayer']); } - // Events iterator layers.current['eventsLayer'] = getEventsLayer( eventsData, mapRef.current.getView().getProjection().getCode(), @@ -566,6 +569,22 @@ export default function MapWrapper({ mapRef.current.addLayer(layers.current['eventsLayer']); } + async function loadFerries() { + const ferriesData = await getFerries(); + + if (layers.current['ferriesLayer']) { + mapRef.current.removeLayer(layers.current['ferries']); + } + + layers.current['ferriesLayer'] = getFerriesLayer( + ferriesData, + mapRef.current.getView().getProjection().getCode(), + mapContext + ) + + mapRef.current.addLayer(layers.current['ferriesLayer']); + } + function closePopup() { popup.current.setPosition(undefined); // check for active camera icons diff --git a/src/frontend/src/Components/data/eventStyleDefinitions.js b/src/frontend/src/Components/data/featureStyleDefinitions.js similarity index 91% rename from src/frontend/src/Components/data/eventStyleDefinitions.js rename to src/frontend/src/Components/data/featureStyleDefinitions.js index 081230d1a..3189f3f06 100644 --- a/src/frontend/src/Components/data/eventStyleDefinitions.js +++ b/src/frontend/src/Components/data/featureStyleDefinitions.js @@ -2,6 +2,9 @@ import { Icon, Stroke, Style } from 'ol/style.js'; import cameraIconActive from '../../images/mapIcons/camera-active.png'; import cameraIconHover from '../../images/mapIcons/camera-hover.png'; import cameraIconStatic from '../../images/mapIcons/camera-static.png'; +import ferryIconActive from '../../images/mapIcons/ferry-active.png'; +import ferryIconHover from '../../images/mapIcons/ferry-hover.png'; +import ferryIconStatic from '../../images/mapIcons/ferry-static.png'; import incidentIconActive from '../../images/mapIcons/incident-minor-active.png'; import incidentIconHover from '../../images/mapIcons/incident-minor-hover.png'; import incidentIconStatic from '../../images/mapIcons/incident-minor-static.png'; @@ -337,3 +340,34 @@ segments: { }) } }; + +// Ferry icon styles +export const ferryStyles = { + static: new Style({ + image: new Icon({ + anchor: [88, 88], + anchorXUnits: 'pixels', + anchorYUnits: 'pixels', + scale: 0.25, + src: ferryIconStatic, + }), + }), + hover: new Style({ + image: new Icon({ + anchor: [88, 88], + anchorXUnits: 'pixels', + anchorYUnits: 'pixels', + scale: 0.25, + src: ferryIconHover, + }), + }), + active: new Style({ + image: new Icon({ + anchor: [88, 88], + anchorXUnits: 'pixels', + anchorYUnits: 'pixels', + scale: 0.25, + src: ferryIconActive, + }), + }), +}; diff --git a/src/frontend/src/Components/data/ferries.js b/src/frontend/src/Components/data/ferries.js new file mode 100644 index 000000000..c82c2363b --- /dev/null +++ b/src/frontend/src/Components/data/ferries.js @@ -0,0 +1,11 @@ +import { get } from "./helper.js"; + +export function getFerries() { + const payload = {}; + + return get(`${process.env.REACT_APP_API_HOST}/api/cms/ferries/`, payload) + .then((data) => data) + .catch((error) => { + console.log(error); + }); +} diff --git a/src/frontend/src/Components/map/helper.js b/src/frontend/src/Components/map/helper.js index 339db6e35..dad2a8294 100644 --- a/src/frontend/src/Components/map/helper.js +++ b/src/frontend/src/Components/map/helper.js @@ -1,5 +1,5 @@ // Styling -import { eventStyles } from '../data/eventStyleDefinitions.js'; +import { eventStyles } from '../data/featureStyleDefinitions.js'; export const getEventIcon = (event, state) => { const severity = event.get('severity').toLowerCase(); diff --git a/src/frontend/src/Components/map/layers/camerasLayer.js b/src/frontend/src/Components/map/layers/camerasLayer.js index 1f25f66e3..25bb581ad 100644 --- a/src/frontend/src/Components/map/layers/camerasLayer.js +++ b/src/frontend/src/Components/map/layers/camerasLayer.js @@ -9,7 +9,7 @@ import VectorLayer from 'ol/layer/Vector'; import VectorSource from 'ol/source/Vector'; // Styling -import { cameraStyles } from '../../data/eventStyleDefinitions.js'; +import { cameraStyles } from '../../data/featureStyleDefinitions.js'; export function getCamerasLayer(cameras, projectionCode, mapContext) { return new VectorLayer({ diff --git a/src/frontend/src/Components/map/layers/ferriesLayer.js b/src/frontend/src/Components/map/layers/ferriesLayer.js new file mode 100644 index 000000000..5a1cd35df --- /dev/null +++ b/src/frontend/src/Components/map/layers/ferriesLayer.js @@ -0,0 +1,45 @@ +// Components and functions +import { transformFeature } from '../helper.js'; + +// OpenLayers +import { Point } from 'ol/geom'; +import * as ol from 'ol'; +import GeoJSON from 'ol/format/GeoJSON.js'; +import VectorLayer from 'ol/layer/Vector'; +import VectorSource from 'ol/source/Vector'; + +// Styling +import { ferryStyles } from '../../data/featureStyleDefinitions.js'; + +export function getFerriesLayer(ferriesData, projectionCode, mapContext) { + return new VectorLayer({ + classname: 'ferries', + visible: mapContext.visible_layers.ferriesLayer, + source: new VectorSource({ + format: new GeoJSON(), + loader: function (extent, resolution, projection) { + const vectorSource = this; + vectorSource.clear(); + + ferriesData.forEach(ferry => { + // Build a new OpenLayers feature + const olGeometry = new Point(ferry.location.coordinates); + const olFeature = new ol.Feature({ geometry: olGeometry }); + + // Transfer properties + olFeature.setProperties(ferry); + + // Transform the projection + const olFeatureForMap = transformFeature( + olFeature, + 'EPSG:4326', + projectionCode, + ); + + vectorSource.addFeature(olFeatureForMap); + }); + }, + }), + style: ferryStyles['static'], + }); +} diff --git a/src/frontend/src/images/mapIcons/ferry-active.png b/src/frontend/src/images/mapIcons/ferry-active.png new file mode 100644 index 0000000000000000000000000000000000000000..5e8d9b67229e16b0956ab002b0afb2e362ea248d GIT binary patch literal 11771 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 E02b(O&Hw-a literal 0 HcmV?d00001 diff --git a/src/frontend/src/images/mapIcons/ferry-hover.png b/src/frontend/src/images/mapIcons/ferry-hover.png new file mode 100644 index 0000000000000000000000000000000000000000..ebe22a14a04b9124119d9f64d8543c42cd86182a GIT binary patch literal 9382 zcmch7Wm6p9&o*ug6nD4cvbeju6kDJyi@W<47I#>jB0q{lad#;0ZUtJjxNA$1yZ?{y zym>N{b4~IhlQU;BlUzBmI$Fv&SQJOCFf%fmLYumT@2bk_ECSC{# zoa+Bm#8S!3%70EoFFj>BgxVRZ<9`N;G$er@Xt(%%7=P+FX78tj-XN@ zg{cg%GtePn=BARV0`cSs3Smt7xcMcE$JH-Yju(DgJ7@Wp{p)6*sdOBy5?$u7J{C>67^HFIHvl`(x;9S9}C;mRQS5j1V$vfnU!h4NE9wEo=VFh^lWRbkwK#ewmj;kO)T z5uI7O9yQP%3r@8HyY#n_e%WKduWK}a)ktXbu zrloK9S{Bwq8qeojAFPF(JHyeh**Xl;f#)5Up_xg9jNRyW@O9|f-`R0??oby$o2(oxHRg?tg*K?0EftqeJpCItR zs4x#*)q-Zk{NQWzMYhDcGE+Dp2+%Up*Y|K{V(_QtC2R;<&_CB<9P%*mF>#Y*S@zIE z4N3;aGvoKxIF1XlE$E2#qra)-DV}{5TRo8jrXvSd+qPWvhShkmm8Q}5UTl_~>Cm=} z(cjBvr$wD>2cJKT+Z6TPQQjaugFFU5ofL_toyGi|gW^Kt`cnWb>&@8*?iCOyyIY=Xok3;X6=tM3Q&|lu%&H1ZhwhJmS0R3D9gvAwLJNxs&DIf1cg?1D zTQ1&e^s%5}Vj~0VLFpeW*-MIkX`=FF= zTOy5KS67iQ>;?~7{T{oPi=FTC-HlI`&ISlp9kDtOIP2BW=(&X@3}pkh>vIaa{~|Om z*L!4vy$+^w1gROhbrX>m5NYm5c{-tDhw41^$xx*Av)GkKuFpa)>s+H$UJ1PIBsK2S z#+Jf&kbTKNUz;exd7s|htZ7F`n@XSXC)%r@En77fvtXPlP>kiZR_d3d$-Wd$c6<9P zlaVjs^kTVGYMDEpgE$(CuKYd=mYAZ`^HOyAJb%w)Nc<#Ab4ljVc}oac{UJ=c(PTHP zRlg$QvGeu2Bdnjmxqg&tOx$iJZ+k!9uI_53ZS`s$vZ|dsc1nbgDdHAEuzS>JCGR(zCX4vr6)x&elD zcpakAUFN#=k|&V!vygco#d=jp8)j3>pn!jaDWsl829*~d%F0+Ub){Z^??v*wvYoYg zNrG|jBj_#RUr5*~!b+ZN zTJ7pO4QqI}%RU1fBIBtd@d&ZYarPsLe>OYlkL`15tewS$JReEHN&ElMLGj^HrSWZU zTQnLuK<1@0v#Mn$gQYoBdO~tTn#vp@s~ZCJjqnEAzYmB0k2+NhGdY1MIOGOxldCK= z-68B~Os_Y`%9l4SnK+>19lKoC_f%pJ?QZ!?PouOmdqUA3>Ve&^HeVxU@h{mQ#ckHJ zvg&tACND~OMZ&8hj>~D~Kfd108@N2rn#Uj4c1*I7mK+mudDT7d_rs7E!Glqy8v8?- z<-}S_L?&|HjF)45z1uy@Og!~r^n7ZXk#%HB~#5JWolpdQ$qWO-L8w=J&6$n@%g`<}wy_57g zi%n;YldE#HI%*%cYj*Ct%|b@Q#=pZx>LQu))+3lTC2iw`%tz8w2 z)0VJ9Lin9~61&}sz?%;^I5-5TatdLI@##$``i*!!Iq{aTCJO}CjG=to^PsD5==xNn zU;#^&Wy~{OSpmyAbdafO*uP@AQmDnLh4)es$)X?9MCz0yB$wd%iKLSJ@)QldcX4sS z8R)F2YI#Rv9;0?2oj@&~nSaDeCSacuDXiK>Ym%=}dyn5iutxm>-AU7&0JMcbb5)^R z{5zxbQ3ug|V%Y0QW^ev_XPBpgE6Lg&&ptAZ`jvY}Ll`C@ON6-J1@`*-ddxjZ8(1Vy;zky) z-yg*p7TM3s$yz&bFV{jxtNv7j>Wj{{gQmJeM;GudP@TeYCDxTU z_h{nQRZf~byL|To5@W-oddwF9!R@CZ4`m9;z-D860h)k)tX^FDTGRxr1ocV-0%~k> zL{V8$?Oz#`Fs|oQn#TJm9Lnu};wr|Si>LZ~bYle!rYKaUNQz(*g`_K@X?>-AIm=wM z$x5j-!A3WX)q3RUF3HVC8$k4Y>VxCycE;EBpQ%~}ug51@1hrEQMo|bZml1;BPYBYC zfTj9n8gP^-B{(b3ei5vODVhD{r%Frh5C!}X3^^K&Qo`RoMQ>f}!^on#p+3I*;~odA zcHuc#fsWB#bIl_S15RxErT+)8GM&&9r?_KSoa&7^NyZvCj|O*bwnhx*7U0g-)6M?z zw9S6Fa}#j9$Yi1D2=ma?Snyy9+xFhLZ_;7?6}pLmeICIcB7$s>O%c))5^={ErQ;mL z%?o-obt>5q7Un6Yx(){&HVJO3EhAKOEB#`_YISpj7i?1{XE`AQrW)ye(#ThgxOt?x z+eCOTOpPrg=H^^bnbVC2k-V@>y3IMiX3c=aPPSFkNejbecR6@|XJdQc_#O^~4LCw5 z4;54`xoKQ0LOI@az-!69yoVd%V3oD!cEl zsm9E~(k@*&nQ?2DJ@zu%uTPT1-^$*H(TQby4#^frkBK;RF)|jRxp(`Pnr~h7q+mzR z_rwWYm_3c=&b)D|hu^fiZ1~=M0gfI*aLY5ke`FID6l6Zyt|T|`t7O0N-qri`904HZ zJ~J$`yX|jCqf4Sn%iX00k*1^Jer1Qf*Q9zsj?E-UQWva0b{`HR#h9Nnnnn}*X2}#o z*@X}2COs^)yMFC`%y|Pb{+CZGCf>#+ztMzGFXBw~j)kQTkB9(?gzV;>;<=cWHaBKs zH&ev(Plv@a8}yVRnL;zk?`K*xBJOl9m-bNKqkgcRE-|mSJGG|tIhoksJ!R|BAvsTH zGV@K@k(Swub-g0-*GN0in=r#ZJ1t#dxE5eY@~W!Fq}8DZ|^PWPytY(tmesBbWi1Htc_(V%%*(^(*KOymqtL zn{4LC9@MhfWsRHg@$s?o?c9rsitgLyVMoXmpVYk&WIVn~WJfLfEcSFIzzgfH~s$ zHu~(H1U0O*Mx)KtVuF7Dh>~DK-_KBM^`I*Z`W4M6Fqm9P1QuxRWy=$J_@tjel(cFj z1>NZJMsU|5R?0uqG}yCsg4#&ktSwxOb%PU~!URuRxSSlds9J9)toIcyDW{lPoT)-b zWLuRx33P{%HvXLElDHmJo7J6e|6pdrn|v8*@jC9iv}o3j#a1BN_J`j8O&$vj+3MCaJpUkJKv|Gm&BlJQBlI33q;H)y_x0u6v;@2t~8}|v`9qS`kaxJR-b- z-aO^(#W<8gGH+IQzXM%aV^JZZo+pu`iPAU4Jz_m)J5=oQsYsb*>o+@pH1GaO_dH}~ zb8^xa0iyeYn4(veY?2BTNw>3McXO4J4Skm_zpqOeMOJmv)&J`{6i>dg zE$7OM<@ndUj3@c#JVILgG1ElKCYX4IY~A1J>o*p|LPTQNkxfqVl;kX21>)tb=VS84 z{gi0?u8iKHgB_3p?!*>Kx(|*iXT>vwCpTAiyWy$X`K>@gvJ$SQ27F~))dU*-#fSf_ zXchvtMba}JwlN7Kve@i`_mh5h`h4Bs)kAHu+4~F{-wQ2-{+N`u+3k$hOV?*Gi}F-E zY zjb5V?6(*Rpj60d#x92WS`b!Voi(e7R4KhMI#&zf}Eyj_km$c(b;E51B#ONl~a%Rd= zCZ#=J0iGY5^G{E+QHgHPBms96>mb8d4V>uat}?j37Jd2W$TU`bV41P|be;pcC&$tF z`GrRrQHtxmimf{&^6Xy8eP3s8aJ_A5eqMLMTIoj;wfIT)5PoZN-}b>)AlIgN=sol# z>R7LRaK*@*r!9ay7Q2_G2&SA&L*3++xJqDdl|Cp&*muW)306Eed8Jw*_3H>`>s{1* zlnV#vj&qP0Zxyt5-z{bA&vUFb`wq{3w^s@B`Ya*~6)Fh_WraB*(MD3YpJiw29jtSR z8oh*38Y!1g9SmD?QTkoeLN5W%m6283_KKszV^7TN%tZ`4LX(XMasq5Nr0z-2FD?g5kj@S%#y+r-1a zp6Qoqb89P$zZ?V77i2!$an=d^j^uaM{Dh@K?!cQfe>1R-&E23CPJ%my#YCPMr~iJ9 z$<5g3)&tTymgJX#vX)6V|J&h?qr2#_@D{Eys$BWipFa!6MXk4G%qUCqu|hYQQC7%k zaZZga0+@%~3xcy4JMgcIIUJpw(iYTwud0EXKrjD%%ATQ2kBJnXDCYIOs+S7>pPaRm)`$F68lztUnuS0M&b*7Be+L_*qsT{)DcwA-} zag>HkRjME19fBnXXR(QI(bf~+v9Wk+l;#YF zFh3y!5O-pI;SwTc_r*x}DevCBy6xt@8FjC|s*Rto;KrD>j;me86{UH=P< z1v7`*h#6CmCiD6p5KLl&r*WurE%v;C)cU%liuPW2U?QD}8&;RlLQ2yj4jH8KjKw`}>T%^+xS* zMwLl=j*==TmGVT)i)9Zw^ZKoajsC%^&x$OiU#8n4-fs#vnLjj)2PC#m`_$EwQPUqy z9UB8lNwu&o$1Q$PSl2HeT4^s)luET?$!DcH9f9<@Tkl2BJiC%D(<;1RI1u=CcW&3J z8%p|Jdg_@VlxMX~w(sP0UonaaLOB7Qv&E+p3Bx0al%}3DuRx0C+G@PC|+=!wc5fX>u zw)g8T!*P}J=DDqdLfj-wkwnvN+##I-9znN8uZ|~?Z@m8d5muTio0DrRtxq#HA-~7p zTwS54eAh#+)*$)Es{0J34st#jAlWDEk4TK?+dd_5$fw3H zx3)#7`p5qUrbg#-c$>-%BI_pOH#Tq58PZ!GGNCxecPpnNJM|{c{Qk6dE>YkSL&eK)aRS^FpusJrv=$O0Sf@Eqeg7mfkR%baFlNha2hX1qd?O>&HR9SI8FXo?M_it zfr6W-r~W62u0cGB7xXJe5HwVI5F?m}N4jr&YPDiqL|_{k(3i_xHX$>8)0Zsz@}E=O zSPCH5)Nsnwb1OrLJpm^O3+cVFlM3HTN{&IA1A|K3N<+cTv?)gl zB`p~cuFV02gFQAt79F?CcCOHr{AOJ71ZlOO&FBF&{4&+0sRT-ks7$CHeD)uwT;fX< zfwoiGWNZ-YA3A?elfZ|V-S6s6!Cq&UvC~*x`O@F#@fdFPtkkJIcrEr({v(wI?1`vo z5Jw9(NAi4DFJw7Zu~&(}>UL?o)cAaC!XtSduNZinGn0Y( zcZt|RB`9^Zp}RHvemJGllN-x7yFP`K?F8xs>z0`xuqV$0Gg`AF-GwTGF=~h>Mf=z3N_F;lE%MG|=6Fk?SSH7XC?Hi~fx@gQw`%k9@pN(TGYeHAsV@EY< z=G})JN@4O}#kNRPHX6Xo`gKyblhH!8=JQ`7GTgsNiJpnSH(V|JyqbZt(lt=+wwc!L zj9|5tw{7NK{mWM?FgU|Z+@+T~?(cMkI^dF9ezXLe@0wz&|b2CZI+ zmX7;n+?VGwqL-wcUxM-K8D#Cm0>h?-j9>UH)SF=f;BPJlLN@~r`ad{GNbhk?HEfvE zRN>@0AvJPFgRnzhHb3EXkHEebBvTw1fovvBq1Xuo8kBNOleWgc7HS8p}tAHHJZO z>tGd|I22=1!9Al=tsBRAyc4m0#dl*u$6e@IOnGM3lqtE|B_bVU{#O31@aXeH+wY6t zcqQwgYc-0{jcF#1;GzhkNh|O#>TbPBB9D^Ep-@@`su-3#WgK%GCa7Xm)4-ajmd4*N zG$E9+Jj1al^2>L;8(DHXnn-8Zmv_Sp+hO!gL;J*alq)f_Y^O*3p5JQPBlFg+nyju4 z?%N~EI7PQ522gGBgG4^~O8kAgDE^OqU*oR*S=(#qt_kR13FfQ$;O-*QzxQNL?a(tMf_AlDmP!MxHS>O28Mi~ga_@5k~ece z(V^X?-&;BHu8vW@@)@Uzn-wUfuzx_Cr9&5P6YedOiDTYmAjFjEfE=JHc0@&LvQ%!U zqyLelzkf~gfeS=h{jtx2=1s$Py!$#*KEar(Eh#BU1rLa^&16Y;O4Le?jg< z40W2xakaZPKSy8Fq)0K$fU5yMonXMepV-B6EHu>Wz#z~2SMe#6(12a^JN_yTGj0TST0U< zUVIOtUmTI-s)0aNTevv69F8GHk_-`fLM&)cbzd;3p=)%cW;c!>h3Lhi!WJGUk4`Qy zPX8^cxXnoayvUw^y5%-Fc@_XgyG*WuH{e88iK1Rv%)Gzh?8`0&Ic8?w4a3n5wnZ|A zG2Z=e{jH*&O4fEb3vhzmsfo^daNZUAq26%kdV@kTXrG?)E8o>0TLS9Clv>U`D018^?3+}S_mn1rFd@Qf4JF~khj4z4b z#{mL*_^dE^9LYkXqS8DbQ`FfWl?s3bSUE)3GGY9}30DVv zp&sQFjQ@IQwXcOqYV|B)Z%&P(WYN~^oJlca*D);GLgKJS=x8W+CvzaOQzSCHy35+I z$MBy17||tx6%ixSK_9=eP|e8G9)fL?aRV_Kb5MKu4AJ2CAnxX0AYL#orJb!DQh!XwqL0w02B$d@? z0CFQmEvp;@sG$@Y^jmH;TO)poi#S4CrN|XBBzQY|QVQZR2g2e>ei^W>qo1!lWQD^R;0hgLs3MLDRT;G_`NGvGqyZ`9LbFdjxMv zv@b}ml7)c~u^E_Vf>G@y+Q*#p?b2tpvWbVk7g9p6VU=__DvSum(635a>1|6Pg79DA zq@QdK?dHj{q@*qja68K084Edd;!05C>1vMpJ(55dkRBsI(`d#>C{A8?DzqF(T2xZx$^mN(UVyDI+q52eN*$Otl^{!@Gbf#; z3}b}D+J9?MiV~PZ{S)1@L5Z1Azv>^r#{L|duXV*AjV;3oZ_ggOY95=Wvm?Uj?X81; zGy_{Tc?QMA-x&H?4-h!LXJ@VTtkS)(fdoe>rAXef18gxH3}KD4$i<^H1n*5ZC++_jIv?7^c=h21gf*yr%;pV z^$P3k<_WAP&Zu%ZKD$wwIOP-vD=wceH5HeB`Y0Ox#P$>!u|}BJ-hi-zjWROC{}2<^ ztDtS3^!`1?$Bgk;9jfp(;E4bTg!^kP3ZhXNGevBd|z8X_a+iOsjJ9Lk|-`6%{7c<*1E3gK3xT2q5Y!u zTM-(9E8g9GlS}znRN#=iQ{(Kp$0ir9`gaYjE*1Hu*KcsLl^hpzPQ5@91>XE-Q8tzch}$&Y;ku@a19V7c#z<3m-`d$%blv8 zo|&qCnwjo8=XA81iaaJ7DH;q645k7|M&rXb{I8)Pea!W3dxjql)fuGY1_Q&Y_`e2Q zCYn+8aR}?CAuk0}Hx2mvu|cqsRF;H+fhMBAm>|NyFu5zpNPh8#Jqtih11$Oct3585 zv(BH37!IDLG8;o7OJy`MGtGGDmbp-Azzj@@=HfT>=I6)8n4XxJU`QJgWJJZwAm@}s z3W;~a$x7NLHW`azvrK2>OaZ%>;zr>%$~7sLWdE)H@&5Yy&evw=8%Af@SzU4NrJ~Gc zupt_7^1ZJ&sNs5?h+J%+2B>&Btla48oM@yRBy zm=~&!bsm8;4}EG!T5R~r8-8B9=3YNWOx1b?_WlF{_yCYZ9IDgV?EzKd-9n=5BYyz5 zS?rX|zbOVZ%J1LrDII#QtF?A7TAdNZFtVDZk?S}a?k|~4=e00o4&MUvr}=v*$r( z=q=3McHp{MEzldZlwU5VZijHN)pc>@)P^D3vBbbz$mBI2SCu+hG3|qm>xGLCR1WT^ zx_TNjBDn>05}nt4w*$)-wHvs)Eyu~$p~CGJllD#*=v@5O6C?8dljG_NJKS>>vM`m& z?j1BzbCwLJ<#YQwL!K~hA8*um!s3;)<5Y`^r|JzSeQ4Tq(WsXLcci~-p!ZJ%=l)5R zeG1@`>McyOAnL1Wu(9`5_`JP1*OU5cN@g_d)uVOEVghH6g3F?roo-M-%*XOr(4hxO^YVU3V(X@d$9IaXN0`}3HYrUGXt+UknK>H!gVnU)v1}T_cbTfLKUY#B1Gh&V^ zrMEKue`>hG*Tvosu| z;8*6tM~uJW)sZym9gs2(>=(crxVtcQ=I~5z-ApG*TS3RK60i z-rzHO+n5x#9fNx*Clk9Cej!VZDmGTWu5jugR4pTOY=@oy(Nv)IH6B&c7*^PxQQ!CH zfBWNI2P~9)E2#(hNBvIv9*^g7Ko#Yt@<(i^H+`4&U0HfrzLMi}sTMk8r)#T~z(xe!*oq=K&5?+jPwy@)qk4Z5ce~FCtW*8w z_%#*R%9+QN`*0nYRSZhc^w=6{t`RaK;!fM?NuQS?l=9;Z*#;0uI)w}d)tAkTQ?-T z7k3YRLD%QMcg=|B>RLaN1c5yjjY)Er5if~cIa|Y>4!jSpOVotl-?n6RTj)%Ar>b9VU(u=X{9&C@?at|x^O7X?DwI9?!;>iRodmk8^-tz+vrE?+O zJv}{}1ztvPI=d_NO)~)zrP8Ep_wjdzZW~^d3L**E*aXvCGOGjlho#?_=J21Sjr!Ja z0K`#=$w#jxkM*0qo(tv5MSw=lsq8d7oeUiNkU#&0b~g-eY;5#io_rp(Z0NNSbTra^ zomEdJ>VGtP|3}Nx~uol?ks9G@BTO@?mCE;}7BTglwor__86BY8!w zVdKPii%1Nq$FlG_UU8atH+r4F8*Ex;-|fB!pOOYcM^Q$ED89);A6eIZ;okJ`kC9&A zjNY$~hwHLxgPGRShT+W2j~LthSJi~ya7))QBeRn*~qYu+mgAa!9z~i1BD+Rh4$QAagvo-SE zE)UpFN%RS1XW$&AW?6YQW`#f}DFQArHEr}?p<4%A+NctZ+Yzf9V!sQg?W~`srsezI zS#ADU&lHGSo-EgHx{Q%qQTUy5>0_tToBx1Y_X3XiVHRUm6>qy7QFDk!_4-mENR#!ltMv}6vF!b@8fDQ=o3kaO~l$JoDTw#RejHWU& z3615EJVfFB23S3aC$Z!3jZR~Qg3tQTDnq+1DMdWMSbP$cDrzwYyYtS+^Nyq0=|caY z`BH_`$)dd!!c;zUIVo_qKppRpud#73rAc7myx?62rP!_aE@vw!L1}NFY#MVY2@@p_ zb=hA(#=ntTW=H?^y!J)b`~`fod4EVrxl;5kfqsa8xzc?U ztM5Nqu^|*A?7+8`*puVq!Hq62Wb-dThMJRf7VQWf5RcU$oLv>&9()Z)>CoNT`eTdn zoS-tl4=e_|XaO(-V;x*FPtuj_a9qf{c-3cx=kv&n!cv2aV*a;LgEm-dmQYqVRI_@Lscx`e6z>U&W^=< za0O`vWH>D|;CHN!;(~Sox^ju6wNYmx5CL!9-FcpSbM!Uv!g2Lp6=gu%!%<#_FWQF! z#e(MZ1l_vz*$vq;v{JSvq(+9l)DS9G%x1pSrq+bC=699%M1e^Aqa^BqEH~D9A znMJ6wicg3I4dmI3n)q{?xefwl%A*Uy0|gNbvk6K>hkN5Y3S7uSh1IMpJ)v z5H;lmI3!XrexFZ3;5jrj)KFVHle$Ot(3|Jdmhc-cbrLZl@qe}#Iyi!Zn!L&+$!HJ{Qr7QJaVz7!)cVp`fPbFv z{Sf%Q0a?*jEV;8|?-S$FFR6d`c%MeU3LV6^wn(m^%FeogQ)}v|uUyOBCsOG1`jePa z6ws@zjqQH3Es7J*bpq^U4v?_`r1n43mMXa>*woFzFk!ay(GEl1@p0Cr>En>$828RENH&DopVTk2t(Bz;>uJ2P}8 zEdA0;V~=rVG>|6z7$NZ{D=X(WEP#%wraJ)1^Vu>BwDDg|0P(j4;F18V^i#Dd?x;{G)dasC<9C zOv)H+>|O7?jgue=jsO6k|1B*?q4hkUMw3rwpb_P6-Ry6X3-Z1LVYl3*vlY--!Z%Pc z^^fch3$_Uo5AER5l^UrVQ%Tme+1bR|zhYvb$Km7U-Y>DQJrU?f4A1J$ar1UH4aCz?r4OqJtc*t(S54F-)KA&tBTZ z-{4b_!r$HkffR@_GspxgEJ=MD)(#Gsa|4>(m3JEIrGJNIZyqiM9M-;Q6jxmLCnH+Q zCyYB37fH|f+-jP^y_=ro?y4B;&$5-GstVgA2>QA3h;N!i*E1Fu)#qTjHyZ!La2uG< zNZCW%d|u>#Nn`|E`*B(7(BqR}JFV$7Qet9t$F(xj4ysDWAd}H>;1l4%5fWcv%bszY zs(wO^Z`PQ2@zbsW<5EiC!9!dx&3A=6ii!pZuug-@>jIBQeCqVz{Vhy&fRMeb?Wku1%pCHbba~5WmQ`-kLAUtI#y=6 zU!Lnkqr7DpJDYd2vC)^Vim=@5C9LG*54{=19$_NhF*OHIjD}*T@g6nj`#$GU<5nDE zCclN6cMb$k)+o~e!tEAA^4L9B3kL4lNC8`v#q{I*uHl`Xop6F11KmE?XwlRagE`QC zQaNd+2zLVH;So6bO3{%G71FGk5nxN?=I=G(GR}*d%)%SV*El~4KMSSNShmH5EA*UT1&=XN)I|txS9UGGK-x($HRk;mh0j*?2 zQ;>O<>8a6CB1kgw57@h*#5^sr)KXZBm6B`!izk<1XuiMG5IX&HctH=gF`EN6Ccp^4 zvAZNfnMrozKEB79 z)j073t0yj2xBn5T`;MI@Op-0PGk{AB^_t3$hfRcQmE?30mIst*rbc0{0p@-&2GKEu z;Fz=GOovV=J1{F08~qo3Rbw{zRl^_^XP;tNiBK>O3VEbQ?bnaG8>}k0wfL)pyzS zn~HIhoAyWedC$4Q*`TFoxJ4=K>y14vi+R~t7W*UOKH;{9RGd9x(-)h}PK+?kZ4>Fv zvCH#)rtg(!4KN?h?SkmK9Wg$~J{u@O2&BV9E(fB2<^5HEDtPXNGN+tuqi~W1u(QFlS9=_xtARt zJY+S0;v+WnPE?L>zpeBs4!2;-GF8Yl!|td3V%(AYSTZ6gn3l>JQt@HtRqn}`T=3@! zedo$*-^1J`rqDJyKk%a*{wxl{$HtP6T$&IIG!N*ad|eJoN{)&erdDXf!2+xq0NO$1 zj*H?{p`Y+L`AE=8wIuhScGcJ8u!NYNN~u2zp8Ri_y~p%0vq$sl%i@MI22Ybe1f4edy_oXYvH z=#9X_e-E)~Uv`3C2MHYZ?fUTjK^lzl++^FaaxrJ>GXXl4t2x*8ZOjIJR0j?p2p4Pz z^JfBivTs2aHO8mqmf@P@zhsa`w~-`?{yQ>$tB4w|_A44_z(kK5%k36D-nxA;S}jCI z_t&GL+vCJ*pG$?F58N#Wl^_3c&sDYZPmzA(~xZRX9fgABos zAPqei5xFby_hliA)3+(O&x|%CG-kN+lnvkFQsNXPv^roM;!qH7E}m{reB!p70YIu- z+_j}xgGO`I`$>ts3Yl7xM=i!BVLj_Qk*(bl_@mx& zC_x=04#{itZR zsu@`30r0K`k>i!lBxUB?N&P5XZimIPu~Os6`3BYH^av8kD9aiRgGs2Muk(&k6foxD zkCzOx^wfWO0;d{@)$e({YKL}b-<0$lZWH_LZOPlzqg;j?z>8WNGn+&6kOFhyAR#dp zU=Kc-C~P#Og1SwBMy8!V>U{_E^Ja5$Ygm*Xj7J&R?v?~HWYZb%oO8r`mn4hK0|`wK z@bwMyd0#n2Kn@mK(`Dcae8yA|l-)a9y(#$~ny@lkudv(O-4Fccz4mQo5_anMIi|jbyQgcdMM87n?O3&!%)%s2_M9jASLwAY zSWtd{v^?%iTtYMVIQq;=6r&O;P)#+PRW=wYv)%br^xo-t;yb)k&r93Hd4G6K8S$m{ zp6oi{h90w2k7`*aD3`VD+SHhCvS&OPSs+qeO*F2my z2KBl`sk7M`q|6r1e9Y_Uor2%aq<+6E@P0Vko4@rr@E@l5!+Fm5@*jZ=+HS_e8OQ#i!zcTjFuW zHG!mdKhT?e6eCp)hg=EWP%%E49QTCD6Gh%cKd)Z!I0`QcwjMSktY|tHAuJwY1W&dY z0|6>CLtGR>D?IAZBNdli;y?$tr#1?nK!Mv5L}9G(sp8J7=mPom@9es1vH59|iF8V_ z{(U7xbiH@=b(l?@3r{$5Usf1WnuIO)f<}zk%y!4@KM^rRVAf|(oYXJTZ78>=j?C_X zXC*yOfUEixPbKL4X(2>?Eg(k1_+K1ggi5eSNrhbQY(habUPNbCB$@a*&y=5A9k{%( z^&Ygz<8ayN>Sy%rPBL~y*+r{Z34|=0fszN{2#JJl^#zIJ5oYbfijG11bR@L<6gJdTNGF zF+8;ZV1&lw=1#4%)tak?!*0p**XT${>2~pBkwC%v!rXl>2XDT|>Y>}hAxB#uPT~f( z!ECo!_#3g6$-Cj^2ni&UH7Xf7 zE0$S%3_^>di+!)rR@Y60z3I90H;ywi>61FK)A>{*j`OL+f{b?fpF-NV9utkS>)#fF z(m6I6Cpc&CuHp^IR(^{vwgth^4KFshFEka5Oev$l42AwdzM$uv$1;rM!x~2`xwd3N z)Kr(3m(M%W=Bzt9oC1`DefdG0I!zFnlw8%)p6>J3f{Oy>0TA3db zym3Zag*wEzy>hRT>1I%fTU=7=Cl~Uc49>k_gZDVtg+(clKCwxBdU0RCa+|@gPY~Ms z`fKaed@EM`ZMw){rWw+#q3xiZB`K6F`itgsw*!n}4oJTr)4dF+5Mu@Et$c=-|CLE12Q zM(q6<{p7IcImUGj44E+G)OhwzP|iT8ITKCn8w^FDsQXUP?D(Eg@m6=PeeW>Jep83h zOBAzEJ`c&~)#ZT{`Hk4HNCIX1s{2@4RFZh}75H!T)BI(%34&{k#(KT7)fDV@f)!bZ=3KISI9ilOUC#^p8p$`mB z(>U2Qret}sJzx9m174yfb&1*=RC}Hl78a;mdIJlcGNS+(0BvgHdPy=kM73smjSf`o zL^aYxv=A8waugr4G0c;i31q4h!mO33&(>f}YW^Ry`)g}!S#gWAq{-t|h|3O|tQ`mt zGsQT*8UkPcs1~@6%WyH=#}HcTMz}CBmuU<`vta-ZsXFH5Qvsa-C97*x8z(7!_{27?{7s#dxkjiE z>wI^*3ac?X-dS|FLNPRr`@ryw9UED$%6uV zMuEDx)gMH|*F_#BMA&)4&U=y7>+i_?7|rKL_=B$PY6XBN@krejs+^S!%(X75TBjsB z4w7IPK1LuBxm{KM&|Y6k`e456_`e{_h?Wa$5+OPu=Ox7m`zjuOhZ|{jl2X2|sj~C1 z5I7Z3ZXMr29)$y?*Wy00!$cPcnZFrPj!$KI$@Q8XGc#;C$B;-%P{>633Si&%wsf42 zL>6LndfrXkpD=&=&-CYa!W9Cu?d0@I<1tJuvRc8lU_P{^s(7@|hCqj6zttTY6Y7yK zy4)m|+1(PY?_D(R{O+FJr>yzz;!@V}P41sfe=zKGN@C;};*YUc%%P$}NA}Xgt#+Wa z1fwYgQZ%`<>W)te3yggylbXkt1;2g-YAYVt?2$Hb>Rs-8?Kp$a7sjfzSn}FV1nX>S{|#=@HSno+u2iBA#r?_B z6Dw)#xorOv{VIie+wCkW)ZZ&>Igdy_)|RS)oGcUl;In^sIIQKGeyipvrRM#?tM)OYyS>ujPoaxJ*5NKRrFU(lA~GX5iAWs7Vh9flWt0v9%vaEx#Dh z2#X|R0)$(6t4CAg^M=T(3u{+s^bFIQj@nH|n0dJOQa8as`dMAWF3z#WS%@dpd0Br=rXmiW#FoESYS1W>z8PHQq zlsiDdwGwIC=8M#_iw7AEK+4#N*VQ~?cf^oa1mxA-S{f?H9oIdhXl?d5)ZeP=I%qQ8~! zjp{D;X!wI%@lP|RIkS(^>B<@>uw59I%iE(;M51x?o+j|~4IWprrG`2M^pRI>o9)=k zeRia;rAR%3HJs0?OW^*3SHiDlt< zMz9`MhS>VmF!+S(SE{}Ulncnm9$w< zESePq!b}$xX=S0O-N8rhBFOP5G>S&T|1_;)w6e<(K&**QV5_8@+FG2PK5$E8O?p}k zn2eFF)*>L@S`VyCTbB5nb`a!|oe&LqvtyEyr{o_;%#%4H6C!7RkXByi0mRr^Jb^D%#?; zDFyme9rb7|Cy8ao*r8psToz{3*|H#CNTyRMe$r|Uu6<47@F`%1VWSH{OEa>uks7JEr&lRJ{*Q6z2r;% z@}9#Q)hzD~Io0T^P!m>>@EU&&(svL$W;8FDc;$$z3yvr;b1QHzq8&#+7r8sX_K z$Tp>T@{uGR(1oSW9*2k9!wQITm$#(XeT zi-DU5V!?ihJAvU_-;+=iP;g03i)n)A_7dcdf5EC$4$^(?%Pu9(#8}Xs_&t1@r^ACV zLQ2#^<;q&XYk*X67bD*1yW1rngK7gwZTP+lqa18pn^2G}FFsl-Q41|C#qHoEl^^vp zE`uuX!Bs(xQq~y$!iJa?2yd=9&@;CdnH9A#qYWaIkPR`JsDG<3m4*wUgL@Bijm2tZ z?4u@q>sUPz6)~*Lv2FH(JKHFV71Oe)seizYQ^T*x`C{V|p;+Cj#xxWzUI|m;0b8;{ zWg@YDq&Z4XtIjEX&TL$WqSJpW225}O&|VRa7hgX%WAXkCK9`K96*Pw(G^S$j)s#<= z(Wca;1z2jAjJyjkdy-^ZDJSyHspAuZO-4i%1KaKbmq}99I1%D&>c4A^wjNu6txjk~ z;!Lsnr5O8CLDA~dT;oxU(iz;d^k@}Sj`=4q41B4{`ZS4@K&?SlKQ@PF{^JIa z&ywQ^FjUN@eUB|(S8gmRd+GB}s8U}H3j+db+PUDDF%U+Fd7h#odSz4%Vi+UwjP@tq z)BvHsxQ`uhM+Zqu!(NhhNCKw_PW*~|sFA^hv(IW3KN0yKSZU{BwjhYENQ9aF?~$^b z-fQ1=Q_xC^4%iwnyClXV$P*UKGT@`+yBSA#E>Fxw1@VG;>w0QuD`A7l5CUG^*FQCK zMH4%I%@7ePg;8L$YiI4CbW()$B(^v%q|i#aMwi#QO!|t7t^7E+;+%f|IM&Ue< z!L+2FBHqJmhG{oazj4R#4}}5L`eyOuJ2%jlnTDOlOX~k)@19!E10}YA6xyl%67EMo PB8-BpicFo9amfDwZIU43 literal 0 HcmV?d00001 From e6976ac84a66c5313b3bf6cbea2b7bb5d9d31b43 Mon Sep 17 00:00:00 2001 From: ray Date: Fri, 24 Nov 2023 17:59:05 -0800 Subject: [PATCH 4/5] DBC22-1261: ferries layer toggling --- src/frontend/src/Components/Layers.js | 13 +++++++++++++ src/frontend/src/Components/Map.js | 2 +- src/frontend/src/assets/ferry-solid.png | Bin 0 -> 5195 bytes 3 files changed, 14 insertions(+), 1 deletion(-) create mode 100644 src/frontend/src/assets/ferry-solid.png diff --git a/src/frontend/src/Components/Layers.js b/src/frontend/src/Components/Layers.js index 0d0f70d15..1bcf11cd6 100644 --- a/src/frontend/src/Components/Layers.js +++ b/src/frontend/src/Components/Layers.js @@ -12,6 +12,7 @@ import { MapContext } from '../App.js'; // Static files import videoIcon from '../assets/video-solid.png'; import eventIcon from '../assets/exclamation-triangle-solid.png'; +import ferryIcon from '../assets/ferry-solid.png'; // Styling import './Layers.scss'; @@ -62,6 +63,18 @@ export default function Layers({ open, setLayersOpen, toggleLayer }) { /> + +
+ + toggleLayer('ferriesLayer', e.target.checked)} + defaultChecked={mapContext.visible_layers.ferriesLayer} + /> + +
); } diff --git a/src/frontend/src/Components/Map.js b/src/frontend/src/Components/Map.js index 003d7ac86..a6b52da97 100644 --- a/src/frontend/src/Components/Map.js +++ b/src/frontend/src/Components/Map.js @@ -573,7 +573,7 @@ export default function MapWrapper({ const ferriesData = await getFerries(); if (layers.current['ferriesLayer']) { - mapRef.current.removeLayer(layers.current['ferries']); + mapRef.current.removeLayer(layers.current['ferriesLayer']); } layers.current['ferriesLayer'] = getFerriesLayer( diff --git a/src/frontend/src/assets/ferry-solid.png b/src/frontend/src/assets/ferry-solid.png new file mode 100644 index 0000000000000000000000000000000000000000..447afb634b96f0d2e60a2960848771137899a5c3 GIT binary patch literal 5195 zcmeHKc~lek77i-1X{CUVMZ_3r3rdoi5Xd3{*$E{OmLkaJB$+@Yo5?^DT#7}sP(_xC zKotQuXbZ@uK9{0`B4Ayp`atlB@DvdxmSSCs(n&zX^LoxZp7Z*j$(hW|{eAP@d%t^s z_hd5JL4NZMtqd_3%zUQ5cPM(c(H#17(ccw7bvb%!O%0DkLLmiKDwFU9LKusvq%am% z3iud|@|K9*Grt+oAAe@H_^hl_d_6E%M zZCk{1EJ-bf1~)bo6^3w4R$OdavAE!;jm=TtzO;?^$6$0a1YTZjrkB^7l+Z->Y|Lf& zH?z$$f};?Lrxl-+R=1v80<5AY^@A;NPJkN{wBQpAyzXawj zDYwWkth|!RF)VqS9K1JN0hZjm{Vd_>a#g;L+h*!zq#5^bY)N+ma`}%IC?e0SU*2Eg zVQ#nSKPMiIE*dOv-c(}gLK;%_1GVf&YyO!Y)yB>|oN1f&eaKJl!*%CW-_)1Xd>J}&WymmYK_`ZR$a&C3Q(WYA%zB{w#SmVWyfg|%{Vvaeh z798)@*;<0MYz9+-D^#`fA_GBdSqx% zPZOi+#VgE_GFg*)^k4R9@1+XR9*blJ(zy~50pdwGFhMDjqRofFxVkH)5H|@%upBr+ zAZFmyC!26s0gr)O<;((DQZG1B;GZgk!%~C7xv5E98V~30X6UMf3MYa@mOun)G#U{g5lJLGionZNVgyp+#d2E>#UzI}Ea%DuQbZsTV>O%* zN0N*%a5!`w`zAh-l*O8+7t5zqK=mLhAt@0g07Q|9IP(cP;*)}sOa=6pPsqd3tx61q z<&tC>7xqbk#fa@p3O^=`J-8BC@=r_4*!6=HwqI*lY$r`6jZw5{iFP$gh3V3wwC6(vw zM4|Dxcn$!P@MIDX!c(av8lDHaIP*Y=!{buPGpLwiIRc5fu!agHCkRj;4oGqacvNRR znM0%E$uuejPX#nQe6lkaq*6#O?@$EG1ZY=6!kJNNsCXz9#34J;T(}^f=FH{c$y}Nf zox^RTMJtVA$*BU#K5s2E;cyq?Mk>n1cxDz zMm3NE091+#nk5BvBGV{uK@qS_j&_`e6a)xPF4`3ym+p&FLTKj*L{I`ul!_Cy3!1vn z(QHsSlT*5~bkPP-nKy^g5Li4ae5q1iG~LJcYdad@prnwVm~-}FMU7A z^+B%pQsBM7AG+&Ww61*-VWA7!zvM zzKP#9)!1z@Zb{3IR;UIq&37I3>>B(ucGkY8FIr!>k4JVc%`iP0d_tPKVQKB5oi%+= zb#ribCbK$!X2wvvElh&zEiMK)`$R>2YEyFSdAZ`?l7S zW=>~Di*r)^SeuUcBbL)hLHE3yn@Zj73wO`HmJxPoZd^^HiA)d*|wmEd0B#ek&xwbBw5@&y*@W2k1^1S1(D-W=Yy4S|9$sH+K@A&*xi>X0o zrfcjTR$hc@-1BYKZb7l*dj%7?&^_L|ImH!ar+w}}aEn&n{rC^NQBULafUKnRqn z&sx*;&;8Kz@QXRsX?34?)JHt6(60^IxoY6m+U2eBKX`N>=m^hiF^`{U-%WXZ?dL`- z-_r$S;lC9ub9va9>%M(Lf5QE)@_FihYb#vWt-E8_R**NZ6o!VMyE~S#q^R*6eV}RE zi(Nk%jC6h38+x8gpb1Bk&wY{+(Xi?d1IJ~@XL-bcuhaLRExmE%7|va`^I6xA8>Cma zI`$`&``889EIUM{aJlluVtbC7An)BWT#r8$9&d5`MwFnfVevAbsw`JQ7N!aJWci}# z;DdS8O$}L>%)*2N*PlPiZ@766G>zGp61TZ{9J%#fhd@F|Q23JSzZt$V)>hhg{TrX_ zyW!T9j+iJqgEUz4s;xWe^u_kjz?y=@>f5ZIj)jH0V;>^xUmU8JELKPE-*Gb?E*#73 zFrVd6ccgXr@c#AsX@kq_z@g;cCHwBxlo?=39C|%2xY&wo0{xx=7`fSL(sq7icD14A>pgn~4@|E6pKc!L|3Q6n$ZEsIoL{!3s{Fp1 zMVhx-Smc`$X%fOTS#W4$uk84*FVlBknH_L+QEe3dlDxadUOLeDsrli6jL+=ZEe}DHOS-z**oN@ItD$>MP1XKG zLDoj?wwL>^TX!DY%jmx?Zwe8!?w+=VU3X2)vD#5wX}Msxc;3}+r}n;z{5iX_PB|LQ zKk67)g~y6|I)!eC@1TQc*~u@r1QYJ?ky{-JqQ>#rgICTktR3wLLx%x|=@aB#?YZWw FKLH Date: Mon, 27 Nov 2023 11:39:26 -0800 Subject: [PATCH 5/5] DBC22-1261: cleanups --- src/backend/apps/cms/models.py | 2 -- src/backend/apps/cms/tests/test_advisory_api.py | 2 +- 2 files changed, 1 insertion(+), 3 deletions(-) diff --git a/src/backend/apps/cms/models.py b/src/backend/apps/cms/models.py index 493df781c..3e7eba5e2 100644 --- a/src/backend/apps/cms/models.py +++ b/src/backend/apps/cms/models.py @@ -117,9 +117,7 @@ def save(self, *args, **kwargs): # Editor panels configuration content_panels = [ FieldPanel("title"), - # FieldPanel("url"), FieldPanel("image"), - # FieldPanel("location", widget=DriveBCMapWidget), FieldPanel("description"), FieldPanel("seasonal_description"), FieldPanel("service_hours"), diff --git a/src/backend/apps/cms/tests/test_advisory_api.py b/src/backend/apps/cms/tests/test_advisory_api.py index 715766070..3d67b2ec9 100644 --- a/src/backend/apps/cms/tests/test_advisory_api.py +++ b/src/backend/apps/cms/tests/test_advisory_api.py @@ -35,7 +35,7 @@ def setUp(self): def test_advisory_list_caching(self): # Empty cache - assert cache.get(CacheKey.EVENT_LIST) is None + assert cache.get(CacheKey.ADVISORY_LIST) is None # Cache miss url = "/api/cms/advisories/"