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/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/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 f4df5e155..3e7eba5e2 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 = [ @@ -72,3 +76,52 @@ 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." + + feed_id = models.PositiveIntegerField(unique=True) + + 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) + cache.delete(CacheKey.FERRY_LIST) + + # Editor panels configuration + content_panels = [ + FieldPanel("title"), + FieldPanel("image"), + 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..72a06096c 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 @@ -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: @@ -53,3 +57,30 @@ 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_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: + 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 '' + + 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 new file mode 100644 index 000000000..f1273ed60 --- /dev/null +++ b/src/backend/apps/cms/tasks.py @@ -0,0 +1,52 @@ +import logging + +from apps.cms.models import Ferry +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 + +logger = logging.getLogger(__name__) + + +def populate_ferry_from_data(ferry_data): + ferry_id = ferry_data.get('id') + + try: + ferry = Ferry.objects.get(feed_id=ferry_id) + + except ObjectDoesNotExist: + # Generate Page associated with ferry obj + root_page = Page.get_root_nodes()[0] + + # 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), + ) + + root_page.add_child(instance=ferry) + ferry.save_revision().publish() + + 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'] + 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) + + # Reset + cache.delete(CacheKey.FERRY_LIST) 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/cms/tests/test_advisory_api.py b/src/backend/apps/cms/tests/test_advisory_api.py index 4f134419d..3d67b2ec9 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 @@ -35,20 +35,20 @@ def setUp(self): def test_advisory_list_caching(self): # Empty cache - assert cache.get(CacheKey.DELAY_LIST) is None + assert cache.get(CacheKey.ADVISORY_LIST) is None # Cache miss 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..091139043 100644 --- a/src/backend/apps/cms/views.py +++ b/src/backend/apps/cms/views.py @@ -1,20 +1,36 @@ -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(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 AdvisoryAPIViewSet(CMSViewSet): +class AdvisoryAPI(CachedListModelMixin, CMSViewSet): queryset = Advisory.objects.filter(live=True) serializer_class = AdvisorySerializer + cache_key = CacheKey.ADVISORY_LIST + cache_timeout = CacheTimeout.DEFAULT -class BulletinAPIViewSet(CMSViewSet): +class BulletinAPI(CachedListModelMixin, CMSViewSet): queryset = Bulletin.objects.filter(live=True) serializer_class = BulletinSerializer + cache_key = CacheKey.BULLETIN_LIST + cache_timeout = CacheTimeout.DEFAULT + + +class FerryAPI(CachedListModelMixin, CMSViewSet): + 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/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/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 032e04566..5f6d89c79 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,20 @@ 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", + "srsName": "EPSG:4326" + } + ) 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/apps/shared/enums.py b/src/backend/apps/shared/enums.py index 50344dad0..2e7459220 100644 --- a/src/backend/apps/shared/enums.py +++ b/src/backend/apps/shared/enums.py @@ -44,13 +44,17 @@ 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" 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() 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/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") 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 738d99c3b..a6b52da97 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['ferriesLayer']); + } + + 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/assets/ferry-solid.png b/src/frontend/src/assets/ferry-solid.png new file mode 100644 index 000000000..447afb634 Binary files /dev/null and b/src/frontend/src/assets/ferry-solid.png differ 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 000000000..5e8d9b672 Binary files /dev/null and b/src/frontend/src/images/mapIcons/ferry-active.png differ 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 000000000..ebe22a14a Binary files /dev/null and b/src/frontend/src/images/mapIcons/ferry-hover.png differ diff --git a/src/frontend/src/images/mapIcons/ferry-static.png b/src/frontend/src/images/mapIcons/ferry-static.png new file mode 100644 index 000000000..52dd24426 Binary files /dev/null and b/src/frontend/src/images/mapIcons/ferry-static.png differ