diff --git a/src/backend/apps/cms/tests/test_advisory_serializer.py b/src/backend/apps/cms/tests/test_advisory_serializer.py index 738a3a82d..f8855e627 100644 --- a/src/backend/apps/cms/tests/test_advisory_serializer.py +++ b/src/backend/apps/cms/tests/test_advisory_serializer.py @@ -2,7 +2,8 @@ from apps.cms.serializers import AdvisorySerializer, AdvisoryTestSerializer from apps.shared.tests import BaseTest from django.contrib.gis.geos import Polygon - +from django.utils.html import format_html +from django.templatetags.static import static class TestAdvisorySerializer(BaseTest): def setUp(self): @@ -21,6 +22,7 @@ def setUp(self): path="000100010001", depth=3, ) + self.advisory.rendered_body() self.advisory.save() self.serializer = AdvisorySerializer(self.advisory) @@ -32,6 +34,8 @@ def test_serializer_valid_data(self): 'Advisory body 1' assert self.serializer.data["geometry"] is not None + + def test_serializer_invalid_data(self): # Create a serializer with invalid data invalid_data = { diff --git a/src/backend/apps/cms/tests/test_bulletin_serialization.py b/src/backend/apps/cms/tests/test_bulletin_serialization.py index a29e2dcf8..1015a8388 100644 --- a/src/backend/apps/cms/tests/test_bulletin_serialization.py +++ b/src/backend/apps/cms/tests/test_bulletin_serialization.py @@ -31,6 +31,8 @@ def setUp(self): image=img_obj, image_alt_text='Some Image Alt text', ) + + self.bulletin.rendered_body() self.bulletin.save() self.serializer = BulletinTestSerializer(self.bulletin) diff --git a/src/backend/apps/cms/tests/test_ferry_api.py b/src/backend/apps/cms/tests/test_ferry_api.py index ae4d74b85..5a6fab4fb 100644 --- a/src/backend/apps/cms/tests/test_ferry_api.py +++ b/src/backend/apps/cms/tests/test_ferry_api.py @@ -34,6 +34,10 @@ def setUp(self): ), live=True, ) + + ferry_2.rendered_description() + ferry_2.rendered_seasonal_description() + ferry_2.rendered_service_hours() ferry_2.save() def test_ferry_list_caching(self): diff --git a/src/backend/apps/cms/tests/test_wagtail_hooks.py b/src/backend/apps/cms/tests/test_wagtail_hooks.py new file mode 100644 index 000000000..5c74a88e1 --- /dev/null +++ b/src/backend/apps/cms/tests/test_wagtail_hooks.py @@ -0,0 +1,25 @@ +from django.test import TestCase +from django.templatetags.static import static +from django.utils.html import format_html +from wagtail import hooks + +class WagtailHookTest(TestCase): + + def test_insert_global_admin_css(self): + # Call the hook function to get the HTML content + hook_name = 'insert_global_admin_css' + callbacks = hooks.get_hooks(hook_name) + + # Verify that there is at least one callback registered + self.assertGreater(len(callbacks), 0) + + # Call each callback and verify the HTML content + for callback in callbacks: + result = callback() + + expected_css_link = format_html( + '', + static("wagtail_admin.css"), + ) + self.assertIn(result, expected_css_link) + diff --git a/src/backend/apps/event/serializers.py b/src/backend/apps/event/serializers.py index 9a8f6769f..6c73c5dc4 100644 --- a/src/backend/apps/event/serializers.py +++ b/src/backend/apps/event/serializers.py @@ -59,12 +59,11 @@ class Meta: ) def to_representation(self, instance): - representation = super().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 + start, end = schedule[0].split('/') + representation['start'] = start + representation['end'] = end return representation def get_direction_display(self, obj): diff --git a/src/backend/apps/event/tests/test_data/event_feed_list_of_one.json b/src/backend/apps/event/tests/test_data/event_feed_list_of_one.json new file mode 100644 index 000000000..e7ecbe1f3 --- /dev/null +++ b/src/backend/apps/event/tests/test_data/event_feed_list_of_one.json @@ -0,0 +1,45 @@ +{ + "events": [ + { + "id": "drivebc.ca/DBC-3175", + "display_category": "minorEvents", + "direction_display": "Not Applicable", + "route_display": "Pritchard Rd", + "schedule": { + "intervals": [ + "2023-09-20T18:13/" + ] + }, + "severity": "MINOR", + "description": "Highway 3. Collision between Pritchard Rd and Abbey Pit Rd (17 km east of Cranbrook). Last updated Wed Sep 20 at 11:13 AM MDT. (DBC-3175)", + "event_type": "INCIDENT", + "event_sub_type": "HAZARD", + "status": "ACTIVE", + "closed": false, + "direction": "NONE", + "location": { + "type": "Point", + "coordinates": [ + -115.583567, + 49.50658 + ] + }, + "route_at": "Highway 3", + "route_from": "Pritchard Rd", + "route_to": "", + "first_created": "2018-11-14T09:06:41-08:00", + "last_updated": "2023-09-20T11:13:56-07:00", + "start": "2023-09-20T18:13", + "end": "2023-10-20T18:13", + "priority": 7 + } + ], + "pagination": { + "offset": "0" + }, + "meta": { + "url": "/events", + "up_url": "", + "version": "v1" + } +} diff --git a/src/backend/apps/event/tests/test_data/event_feed_list_of_one_filtered.json b/src/backend/apps/event/tests/test_data/event_feed_list_of_one_filtered.json new file mode 100644 index 000000000..ca0e74789 --- /dev/null +++ b/src/backend/apps/event/tests/test_data/event_feed_list_of_one_filtered.json @@ -0,0 +1,182 @@ +{ + "events": [ + { + "id": "drivebc.ca/DBC-20066", + "display_category": "roadConditions", + "direction_display": "Not Applicable", + "route_display": "Exit 109: Yale Rd to Exit 120: Young Rd", + "schedule": { + "intervals": [ + "2024-01-30T01:00/2025-01-30T01:00" + ] + }, + "severity": "MAJOR", + "description": "Highway 1 (TransCanada Highway). Watch for slippery sections between Exit 109: Yale Rd and Exit 120: Young Rd for 7.7 km (Chilliwack). Until Wed Jan 29, 2025 at 5:00 PM PST. Next update time Wed Jan 29, 2025 at 4:45 PM PST. Last updated Tue Jan 30 at 8:31 AM PST. (DBC-20066)", + "event_type": "ROAD_CONDITION", + "event_sub_type": "PARTLY_ICY", + "status": "ACTIVE", + "closed": false, + "direction": "NONE", + "location": { + "type": "LineString", + "coordinates": [ + [ + -122.063019, + 49.137886 + ], + [ + -122.060004, + 49.138325 + ], + [ + -122.05868, + 49.138518 + ], + [ + -122.039974, + 49.140932 + ], + [ + -122.02876, + 49.142377 + ], + [ + -122.027027, + 49.142632 + ], + [ + -122.025373, + 49.142998 + ], + [ + -122.022035, + 49.143963 + ], + [ + -122.020949, + 49.144189 + ], + [ + -122.020039, + 49.144322 + ], + [ + -122.0189, + 49.144415 + ], + [ + -122.01753, + 49.144438 + ], + [ + -122.0117, + 49.144303 + ], + [ + -122.006977, + 49.144219 + ], + [ + -122.006907, + 49.144219 + ], + [ + -122.003081, + 49.144157 + ], + [ + -121.999997, + 49.144107 + ], + [ + -121.999873, + 49.144105 + ], + [ + -121.99975, + 49.144103 + ], + [ + -121.999572, + 49.1441 + ], + [ + -121.999088, + 49.144092 + ], + [ + -121.983239, + 49.143787 + ], + [ + -121.977708, + 49.143681 + ], + [ + -121.977602, + 49.14368 + ], + [ + -121.969274, + 49.14352 + ], + [ + -121.967859, + 49.143585 + ], + [ + -121.967236, + 49.143653 + ], + [ + -121.965676, + 49.143902 + ], + [ + -121.964755, + 49.144076 + ], + [ + -121.964486, + 49.144142 + ], + [ + -121.962445, + 49.144643 + ], + [ + -121.962149, + 49.144716 + ], + [ + -121.961942, + 49.144767 + ], + [ + -121.961366, + 49.144906 + ], + [ + -121.961083, + 49.144974 + ] + ] + }, + "route_at": "Highway 1", + "route_from": "Exit 109: Yale Rd", + "route_to": "Exit 120: Young Rd", + "first_created": "2024-01-29T16:55:13-08:00", + "last_updated": "2024-01-30T08:31:32-08:00", + "start": "2024-01-30T01:00", + "end": "2025-01-30T01:00" + } + ], + "pagination": { + "offset": "0" + }, + "meta": { + "url": "/events", + "up_url": "", + "version": "v1" + } +} diff --git a/src/backend/apps/event/tests/test_data/event_feed_list_of_two.json b/src/backend/apps/event/tests/test_data/event_feed_list_of_two.json new file mode 100644 index 000000000..89b4f5715 --- /dev/null +++ b/src/backend/apps/event/tests/test_data/event_feed_list_of_two.json @@ -0,0 +1,320 @@ +{ + "events": [ + { + "id": "drivebc.ca/DBC-20066", + "display_category": "roadConditions", + "direction_display": "Not Applicable", + "route_display": "Exit 109: Yale Rd to Exit 120: Young Rd", + "schedule": { + "intervals": [ + "2024-01-30T01:00/2025-01-30T01:00" + ] + }, + "severity": "MAJOR", + "description": "Highway 1 (TransCanada Highway). Watch for slippery sections between Exit 109: Yale Rd and Exit 120: Young Rd for 7.7 km (Chilliwack). Until Wed Jan 29, 2025 at 5:00 PM PST. Next update time Wed Jan 29, 2025 at 4:45 PM PST. Last updated Tue Jan 30 at 8:31 AM PST. (DBC-20066)", + "event_type": "ROAD_CONDITION", + "event_sub_type": "PARTLY_ICY", + "status": "ACTIVE", + "closed": false, + "direction": "NONE", + "location": { + "type": "LineString", + "coordinates": [ + [ + -122.063019, + 49.137886 + ], + [ + -122.060004, + 49.138325 + ], + [ + -122.05868, + 49.138518 + ], + [ + -122.039974, + 49.140932 + ], + [ + -122.02876, + 49.142377 + ], + [ + -122.027027, + 49.142632 + ], + [ + -122.025373, + 49.142998 + ], + [ + -122.022035, + 49.143963 + ], + [ + -122.020949, + 49.144189 + ], + [ + -122.020039, + 49.144322 + ], + [ + -122.0189, + 49.144415 + ], + [ + -122.01753, + 49.144438 + ], + [ + -122.0117, + 49.144303 + ], + [ + -122.006977, + 49.144219 + ], + [ + -122.006907, + 49.144219 + ], + [ + -122.003081, + 49.144157 + ], + [ + -121.999997, + 49.144107 + ], + [ + -121.999873, + 49.144105 + ], + [ + -121.99975, + 49.144103 + ], + [ + -121.999572, + 49.1441 + ], + [ + -121.999088, + 49.144092 + ], + [ + -121.983239, + 49.143787 + ], + [ + -121.977708, + 49.143681 + ], + [ + -121.977602, + 49.14368 + ], + [ + -121.969274, + 49.14352 + ], + [ + -121.967859, + 49.143585 + ], + [ + -121.967236, + 49.143653 + ], + [ + -121.965676, + 49.143902 + ], + [ + -121.964755, + 49.144076 + ], + [ + -121.964486, + 49.144142 + ], + [ + -121.962445, + 49.144643 + ], + [ + -121.962149, + 49.144716 + ], + [ + -121.961942, + 49.144767 + ], + [ + -121.961366, + 49.144906 + ], + [ + -121.961083, + 49.144974 + ] + ] + }, + "route_at": "Highway 1", + "route_from": "Exit 109: Yale Rd", + "route_to": "Exit 120: Young Rd", + "first_created": "2024-01-29T16:55:13-08:00", + "last_updated": "2024-01-30T08:31:32-08:00", + "start": "2024-01-30T01:00", + "end": "2025-01-30T01:00" + }, + { + "id": "drivebc.ca/DBC-20073", + "display_category": "minorEvents", + "direction_display": "Both Directions", + "route_display": "Highlands Blvd to Sutherland Rd", + "schedule": { + "intervals": [ + "2024-02-26T18:54/" + ] + }, + "severity": "MINOR", + "description": "Highway 7 (Lougheed Highway), in both directions. Bridge maintenance between Highlands Blvd and Sutherland Rd for 2.2 km (Kent). Road closed. Lane Closure. Last updated Mon Feb 26 at 11:59 AM PST. (DBC-20073)", + "event_type": "CONSTRUCTION", + "event_sub_type": "ROAD_MAINTENANCE", + "status": "ACTIVE", + "closed": false, + "direction": "BOTH", + "location": { + "type": "LineString", + "coordinates": [ + [ + -121.857677, + 49.234721 + ], + [ + -121.857661, + 49.234746 + ], + [ + -121.857405, + 49.235408 + ], + [ + -121.857348, + 49.235554 + ], + [ + -121.857061, + 49.236659 + ], + [ + -121.857051, + 49.236695 + ], + [ + -121.857009, + 49.236857 + ], + [ + -121.856937, + 49.237135 + ], + [ + -121.85679, + 49.237697 + ], + [ + -121.855964, + 49.239289 + ], + [ + -121.855881, + 49.239424 + ], + [ + -121.855244, + 49.240469 + ], + [ + -121.855126, + 49.240661 + ], + [ + -121.85497, + 49.240848 + ], + [ + -121.854584, + 49.241307 + ], + [ + -121.854522, + 49.241382 + ], + [ + -121.853879, + 49.242011 + ], + [ + -121.851755, + 49.243658 + ], + [ + -121.850672, + 49.244445 + ], + [ + -121.849925, + 49.244878 + ], + [ + -121.848606, + 49.245493 + ], + [ + -121.84716, + 49.245985 + ], + [ + -121.845412, + 49.246316 + ], + [ + -121.844009, + 49.246446 + ], + [ + -121.843713, + 49.246448 + ], + [ + -121.842488, + 49.246458 + ], + [ + -121.838506, + 49.246077 + ] + ] + }, + "route_at": "Highway 7", + "route_from": "Highlands Blvd", + "route_to": "Sutherland Rd", + "first_created": "2024-02-26T10:54:53-08:00", + "last_updated": "2024-02-26T11:59:57-08:00", + "start": "2024-02-26T18:54", + "end": "" + } + ], + "pagination": { + "offset": "0" + }, + "meta": { + "url": "/events", + "up_url": "", + "version": "v1" + } +} diff --git a/src/backend/apps/event/tests/test_data/event_parsed_feed.py b/src/backend/apps/event/tests/test_data/event_parsed_feed.py index 09883c4b0..69d9da6aa 100644 --- a/src/backend/apps/event/tests/test_data/event_parsed_feed.py +++ b/src/backend/apps/event/tests/test_data/event_parsed_feed.py @@ -2,7 +2,7 @@ import zoneinfo from collections import OrderedDict -from django.contrib.gis.geos import LineString +from django.contrib.gis.geos import LineString, Point parsed_feed = OrderedDict([ ("id", "drivebc.ca/DBC-52446"), @@ -37,3 +37,31 @@ ("priority", 7), ("schedule", {"intervals": ["2023-05-23T14:00/2023-07-22T14:00"]}) ]) + +parsed_feed_2 = OrderedDict([ + ("id", "drivebc.ca/DBC-52446"), + ("description", "Highway 3. Road maintenance work between Bromley Pl " + "and Frontage Rd for 0.6 km (Princeton). Until Sat " + "Jul 22 at 7:00 AM PDT. Single lane alternating traffic. " + "Next update time Fri Jul 21 at 1:00 PM PDT. " + "Last updated Thu Jun 29 at 10:14 AM PDT. (DBC-52446)"), + ("event_type", "CONSTRUCTION"), + ("event_sub_type", "ROAD_MAINTENANCE"), + ("status", "ACTIVE"), + ("severity", "MAJOR"), + ("route_at", "Highway 3"), + ("route_from", "Bromley Pl"), + ("route_to", "Frontage Rd"), + ("direction", "NONE"), + ("location", Point(-120.526427, 49.451752)), + # ("location", {"coordinates": [-120.526427, 49.451752]}) + ("coordinates", [-120.526427, 49.451752]), + ("first_created", datetime.datetime( + 2023, 5, 19, 14, 29, 20, tzinfo=zoneinfo.ZoneInfo(key="America/Vancouver") + )), + ("last_updated", datetime.datetime( + 2023, 6, 29, 10, 14, 55, tzinfo=zoneinfo.ZoneInfo(key="America/Vancouver") + )), + ("priority", 7), + ("schedule", {"intervals": ["2023-05-23T14:00/2023-07-22T14:00"]}) +]) diff --git a/src/backend/apps/event/tests/test_event_api.py b/src/backend/apps/event/tests/test_event_api.py index 075b46c7d..ebfa8ad58 100644 --- a/src/backend/apps/event/tests/test_event_api.py +++ b/src/backend/apps/event/tests/test_event_api.py @@ -2,19 +2,35 @@ import zoneinfo from unittest import skip +from django.test import override_settings + from apps.event import enums as event_enums from apps.event.models import Event from apps.event.views import EventAPI from apps.shared.enums import CacheKey -from apps.shared.tests import BaseTest +from apps.shared.tests import BaseTest, MockResponse from django.contrib.gis.geos import LineString, Point from django.core.cache import cache from rest_framework.test import APITestCase - +import json +from pathlib import Path +from unittest.mock import patch class TestEventAPI(APITestCase, BaseTest): def setUp(self): super().setUp() + # Normal feed + self.event_feed_result = open( + str(Path(__file__).parent) + + "/test_data/event_feed_list_of_two.json" + ) + self.mock_event_feed_result = json.load(self.event_feed_result) + + self.event_feed_result_filtered = open( + str(Path(__file__).parent) + + "/test_data/event_feed_list_of_one_filtered.json" + ) + self.mock_event_feed_result_filtered = json.load(self.event_feed_result_filtered) for i in range(10): Event.objects.create( @@ -73,30 +89,73 @@ def test_delay_list_caching(self): response = self.client.get(url, {}) assert len(response.data) == 5 - @skip('to be mocked') - def test_events_list_filtering(self): + # @skip('to be mocked') + # @patch("httpx.get") + # @override_settings(EXTERNAL_API_URL='/api/events/') + + @patch('rest_framework.test.APIClient.get') + def test_events_list_filtering(self, mock_requests_get): + + + # # No filtering + # url = "/api/events/" + # # mock_requests_get.side_effect = [ + # # MockResponse(self.mock_event_feed_result, status_code=200), + # # ] + + # # Mock the external API response + # mock_requests_get.return_value = MockResponse(self.mock_event_feed_result, status_code=200) + + # response = self.client.get(url, {}) + # # response = self.mock_event_feed_result + + # url = "/api/events/" + + # with requests_mock.mock() as m: + # m.get(url, json=self.mock_event_feed_result, status_code=401) + + # response = self.client.get(url, {}) + # No filtering + mock_requests_get.return_value = MockResponse(self.mock_event_feed_result, status_code=200) + url = "/api/events/" response = self.client.get(url, {}) - assert len(response.data) == 10 + self.assertEqual(response.status_code, 200) + events_list = response.json().get('events', []) + events_list_length = len(events_list) + assert events_list_length == 2 # Manually update location of an event event = Event.objects.get(id=1) # [-123.077387, 49.209919] Beginning of Knight Bridge # [-123.077455, 49.19547] middle of Knight bridge event.location = LineString([ - Point(-123.077387, 49.209919), - Point(-123.077455, 49.19547) + Point(-122.063019, 49.137886), + Point(-121.961083, 49.144974) ]) event.save() + mock_requests_get.side_effect = [ + MockResponse(self.mock_event_feed_result_filtered, status_code=200), + ] + # [-123.0803167, 49.2110127] 1306 SE Marine Dr, Vancouver, BC V5X 4K4 # [-123.0824109, 49.1926452] 2780 Sweden Way, Richmond, BC V6V 2X1 # Filtered cams - hit - point on knight bridge response = self.client.get( - url, {'route': '-123.0803167,49.2110127,-123.0824109,49.1926452'} + url, {'route': '-122.063019, 49.137886,-121.961083, 49.144974'} ) - assert len(response.data) == 1 + assert response.status_code == 200 + events_list = response.json().get('events', []) + events_list_length = len(events_list) + assert events_list_length == 1 + + + + mock_requests_get.side_effect = [ + MockResponse({"events": []}, status_code=200), + ] # [-123.0803167, 49.2110127] 1306 SE Marine Dr, Vancouver, BC V5X 4K4 # [-123.0188764, 49.205069] 3864 Marine Wy, Burnaby, BC V5J 3H4 @@ -104,4 +163,8 @@ def test_events_list_filtering(self): response = self.client.get( url, {'route': '-123.0803167,49.2110127,-123.0188764,49.205069'} ) - assert len(response.data) == 0 + assert response.status_code == 200 + events_list = response.json().get('events', []) + events_list_length = len(events_list) + assert events_list_length == 0 + diff --git a/src/backend/apps/event/tests/test_event_populate.py b/src/backend/apps/event/tests/test_event_populate.py index 62377ad61..779f42b8a 100644 --- a/src/backend/apps/event/tests/test_event_populate.py +++ b/src/backend/apps/event/tests/test_event_populate.py @@ -9,11 +9,13 @@ from apps.event.enums import EVENT_DIRECTION, EVENT_STATUS from apps.event.models import Event from apps.event.tasks import populate_all_event_data, populate_event_from_data -from apps.event.tests.test_data.event_parsed_feed import parsed_feed +from apps.event.tests.test_data.event_parsed_feed import parsed_feed, parsed_feed_2 from apps.shared.tests import BaseTest, MockResponse -from django.contrib.gis.geos import LineString +from django.contrib.gis.geos import LineString, Point from httpx import HTTPStatusError +from apps.event import enums as event_enums + class TestEventModel(BaseTest): def setUp(self): @@ -52,6 +54,7 @@ def setUp(self): # Parsed python dict self.parsed_feed = parsed_feed + self.parsed_feed_2 = parsed_feed_2 def test_populate_event_function(self): populate_event_from_data(self.parsed_feed) @@ -92,6 +95,59 @@ def test_populate_event_function(self): ) assert event_one.priority == 7 + def test_populate_event_function_2(self): + Event.objects.create( + id="drivebc.ca/DBC-3175", + + description="Test description for test construction event", + event_type=event_enums.EVENT_TYPE.CONSTRUCTION, + event_sub_type=event_enums.EVENT_SUB_TYPE.ROAD_CONSTRUCTION, + + # General status + status=event_enums.EVENT_STATUS.ACTIVE, + severity=event_enums.EVENT_SEVERITY.MAJOR, + + # Location + direction=event_enums.EVENT_DIRECTION.NORTH, + location=Point(-120.526427, 49.451752), + # location={"coordinates": Point([-120.526427, 49.451752])}, + route_at="Test Highway", + + # Update status + first_created=datetime.datetime( + 2023, 6, 2, 16, 42, 16, + tzinfo=zoneinfo.ZoneInfo(key="America/Vancouver") + ), + last_updated=datetime.datetime( + 2023, 6, 2, 16, 42, 16, + tzinfo=zoneinfo.ZoneInfo(key="America/Vancouver") + ), + + schedule = { + "intervals": [ + "2023-05-23T14:00/2023-07-22T14:00" + ] + }, + + + + route_from = "at Test Road", + route_to = "Test Avenue", + ) + + # Normal feed + event_feed_data = open( + str(Path(__file__).parent) + + "/test_data/event_feed_list_of_one.json" + ) + self.mock_event_feed_result = json.load(event_feed_data) + populate_event_from_data(self.mock_event_feed_result['events'][0]) + + event_one = Event.objects.get(id="drivebc.ca/DBC-3175") + + + assert event_one.priority == 7 + @patch("httpx.get") def test_populate_and_update_event(self, mock_requests_get): mock_requests_get.side_effect = [ diff --git a/src/backend/apps/event/tests/test_event_serializer.py b/src/backend/apps/event/tests/test_event_serializer.py index 70eacc874..3b621571e 100644 --- a/src/backend/apps/event/tests/test_event_serializer.py +++ b/src/backend/apps/event/tests/test_event_serializer.py @@ -4,11 +4,13 @@ from apps.event import enums as event_enums from apps.event.models import Event -from apps.event.serializers import EventSerializer +from apps.event.serializers import EventInternalSerializer, EventSerializer from apps.shared.tests import BaseTest from django.contrib.gis.geos import LineString + + class TestEventSerializer(BaseTest): def setUp(self): super().setUp() @@ -66,6 +68,36 @@ def setUp(self): self.serializer = EventSerializer(self.event) self.serializer_two = EventSerializer(self.event_two) + self.event_three = copy(self.event) + self.event_three.closed = True + self.event_three.save() + self.serializer_three = EventSerializer(self.event_three) + + self.event_four = copy(self.event_three) + self.event_four.id = 'drivebc.ca/DBCRCON-1234' + self.event_four.closed = False + self.event_four.save() + self.serializer_four = EventSerializer(self.event_four) + + self.event_five = copy(self.event_four) + self.event_five.id = 'drivebc.ca/DBC-1234' + self.event_five.schedule = { + "intervals": [ + "2025-05-23T14:00/2025-07-22T14:00" + ] + } + + self.event_five.start = datetime.datetime( + 2026, 6, 2, 16, 42, 16, + tzinfo=zoneinfo.ZoneInfo(key="America/Vancouver") + ) + self.event_five.severity = event_enums.EVENT_SEVERITY.MINOR + self.event_five.route_to = 'Test Ave' + self.event_five.save() + self.serializer_five = EventSerializer(self.event_five) + + + def test_serializer_data(self): assert len(self.serializer.data) == 21 # route_from beings with 'at ' @@ -84,3 +116,25 @@ def test_serializer_data(self): # Eastern time auto adjusted to Pacific time assert self.serializer_two.data['last_updated'] == \ '2023-06-02T13:42:16-07:00' + + assert self.serializer.data['schedule']['intervals'][0] == \ + "2023-05-23T14:00/2023-07-22T14:00" + + assert self.serializer.data['route_from'] == \ + "at Test Road" + assert self.serializer.data['route_to'] == \ + "Test Avenue" + assert self.event.route_to is not None + + + assert self.serializer_three.data['closed'] == \ + True + assert self.serializer_four.data['display_category'] == \ + 'roadConditions' + assert self.serializer_five.data['display_category'] == \ + 'futureEvents' + assert self.serializer_five.data['route_display'] == \ + 'Test Road to Test Ave' + assert self.event_five.route_to is not None + + diff --git a/src/backend/apps/feed/tests/test_data/event_feed_list_of_two.json b/src/backend/apps/feed/tests/test_data/event_feed_list_of_two.json index 6aeed5673..4047a2659 100644 --- a/src/backend/apps/feed/tests/test_data/event_feed_list_of_two.json +++ b/src/backend/apps/feed/tests/test_data/event_feed_list_of_two.json @@ -56,7 +56,7 @@ "+linear_reference_km": -1, "schedule": { "intervals": [ - "2021-04-26T15:19/" + "2021-04-26T15:19/2021-05-26T15:19" ] }, "event_type": "INCIDENT", diff --git a/src/backend/apps/feed/tests/test_data/ferry_feed_list_of_one.json b/src/backend/apps/feed/tests/test_data/ferry_feed_list_of_one.json new file mode 100644 index 000000000..b9ed5864a --- /dev/null +++ b/src/backend/apps/feed/tests/test_data/ferry_feed_list_of_one.json @@ -0,0 +1,53 @@ +{ + "ferries": [ + { + "id": 3, + "description": "", + "seasonal_description": "", + "service_hours": "", + "image_url": "", + "path": "00010002", + "depth": 2, + "numchild": 0, + "translation_key": "82d4d77f-e599-43bf-acea-9d0b1eed4a8a", + "live": true, + "has_unpublished_changes": true, + "first_published_at": null, + "last_published_at": null, + "go_live_at": null, + "expire_at": null, + "expired": false, + "locked": false, + "locked_at": null, + "title": "Barnston Island Ferry", + "draft_title": "Barnston Island Ferry", + "slug": "barnston-island-ferry", + "url_path": "/barnston-island-ferry/", + "seo_title": "", + "show_in_menus": false, + "search_description": "", + "latest_revision_created_at": "2023-11-28T08:18:59.869526-08:00", + "created_at": "2023-11-28T08:18:59.713774-08:00", + "modified_at": "2024-02-29T16:01:45.589086-08:00", + "feed_id": 15, + "location": { + "type": "Point", + "coordinates": [ + -122.72476444, + 49.19217851 + ] + }, + "url": "https://www2.gov.bc.ca/gov/content?id=3BE3BD9E3A6348E4971F4AE91E92B6C1", + "feed_created_at": "2023-11-06T13:48:00-08:00", + "feed_modified_at": "2024-02-29T16:01:45.589102-08:00", + "locale": 1, + "latest_revision": 1, + "live_revision": null, + "locked_by": null, + "content_type": 56, + "owner": null, + "alias_of": null, + "image": null + } + ] +} diff --git a/src/backend/apps/feed/tests/test_event_feed_parse.py b/src/backend/apps/feed/tests/test_event_feed_parse.py index a9a482f91..46376cd6a 100644 --- a/src/backend/apps/feed/tests/test_event_feed_parse.py +++ b/src/backend/apps/feed/tests/test_event_feed_parse.py @@ -10,7 +10,7 @@ EVENT_SUB_TYPE, EVENT_TYPE, ) -from apps.feed.serializers import EventAPISerializer +from apps.feed.serializers import CarsClosureEventSerializer, EventAPISerializer from apps.shared.tests import BaseTest @@ -20,7 +20,8 @@ def setUp(self): data_path = os.path.join( os.getcwd(), - "src/backend/apps/feed/tests/test_data/event_feed_list_of_two.json" + # "src/backend/apps/feed/tests/test_data/event_feed_list_of_two.json" + "apps/feed/tests/test_data/event_feed_list_of_two.json" ) with open(data_path) as f: self.event_data = json.load(f) @@ -68,3 +69,42 @@ def test_event_to_internal_value(self): second_event_data = events_list["events"][1] assert ("event_sub_type" not in second_event_data) is True + + # assert first_event_data["start"] == datetime.strptime("2021-04-26T15:19", "%Y-%m-%dT%H:%M") + + + + def test_cars_closure_event_to_internal_value(self): + cars_closure_event_1 = { + 'event-id': 111, + 'details': [ + { + 'category': 'test-category', + 'code': 'test-code', + } + ], + 'closed': True + } + cars_closure_event_serializer_1 = CarsClosureEventSerializer(data=cars_closure_event_1) + cars_closure_event_serializer_1.is_valid(raise_exception=True) + + cars_closure_event_2 = { + 'event-id': 222, + 'details': [ + { + 'category': 'traffic_pattern', + 'code': 'closed test-code', + 'descriptions': [{"kind": {'category': 'traffic_pattern', 'code': 'closed'}} ] + }, + { + 'category': 'traffic_pattern', + 'code': 'test-code', + 'descriptions': [{"kind": {'category': 'traffic_pattern', 'code': 'closed'}} ] + } + ], + 'closed': False + } + cars_closure_event_serializer_2 = CarsClosureEventSerializer(data=cars_closure_event_2) + cars_closure_event_serializer_2.is_valid(raise_exception=True) + assert cars_closure_event_1["id"] == 111 + assert cars_closure_event_2["id"] == 222 diff --git a/src/backend/apps/feed/tests/test_webcam_feed_parse.py b/src/backend/apps/feed/tests/test_webcam_feed_parse.py index b0ec75387..935b94ece 100644 --- a/src/backend/apps/feed/tests/test_webcam_feed_parse.py +++ b/src/backend/apps/feed/tests/test_webcam_feed_parse.py @@ -7,6 +7,8 @@ from apps.shared.tests import BaseTest from django.contrib.gis.geos import Point +from src.backend.apps.feed.fields import DriveBCDateField, DriveBCField, EventRoadsField + class TestWebcamFeedSerializer(BaseTest): def setUp(self): @@ -14,7 +16,7 @@ def setUp(self): data_path = os.path.join( os.getcwd(), - "src/backend/apps/feed/tests/test_data/webcam_feed_list_of_one.json" + "apps/feed/tests/test_data/webcam_feed_list_of_one.json" ) with open(data_path) as f: self.webcam_data = json.load(f) diff --git a/src/backend/apps/shared/tests.py b/src/backend/apps/shared/tests.py index 2cbcdd3e7..b3a4d6cf4 100644 --- a/src/backend/apps/shared/tests.py +++ b/src/backend/apps/shared/tests.py @@ -9,6 +9,10 @@ from django.test import TestCase from httpx import HTTPStatusError +from rest_framework.test import APIRequestFactory + +from src.backend.apps.shared.views import FeedbackView + logger = logging.getLogger(__name__) @@ -48,3 +52,4 @@ def tearDown(self): Event.objects.all().delete() Ferry.objects.all().delete() RegionalWeather.objects.all().delete() + diff --git a/src/backend/apps/weather/tasks.py b/src/backend/apps/weather/tasks.py index 8ff9be9d9..5521fdebc 100644 --- a/src/backend/apps/weather/tasks.py +++ b/src/backend/apps/weather/tasks.py @@ -41,4 +41,4 @@ def populate_all_regional_weather_data(): populate_regional_weather_from_data(regional_weather_data) # Rebuild cache - cache.delete(CacheKey.REGIONAL_WEATHER_LIST) + cache.delete(CacheKey.REGIONAL_WEATHER_LIST) \ No newline at end of file diff --git a/src/backend/apps/weather/tests/test_data/regional_weather_feed_list_of_two.json b/src/backend/apps/weather/tests/test_data/regional_weather_feed_list_of_two.json new file mode 100644 index 000000000..4dd479cec --- /dev/null +++ b/src/backend/apps/weather/tests/test_data/regional_weather_feed_list_of_two.json @@ -0,0 +1,358 @@ +[ + { + "code": "s0000341", + "location_latitude": "58.66N", + "location_longitude": "124.64W", + "name": "Tetsa River (Provincial Park)", + "region": "Muncho Lake Park - Stone Mountain Park", + "observation_name": "observation", + "observation_zone": "UTC", + "observation_utc_offset": 0, + "observation_text_summary": "Friday January 19, 2024 at 15:00 UTC", + "conditions": { + "condition": "clear", + "wind_direction": "N", + "wind_gust_units": "km", + "wind_gust_value": "16.5", + "visibility_units": "km", + "visibility_value": "2.3", + "wind_speed_units": "km", + "wind_speed_value": "45", + "temperature_units": "C", + "temperature_value": "27" + }, + "forecast_group": [ + { + "UV": { + "Index": null, + "Category": null, + "TextSummary": null + }, + "Frost": null, + "Winds": { + "WindList": [ + { + "Gust": null, + "Rank": "major", + "Index": "1", + "Speed": { + "Units": "km/h", + "Value": "00" + }, + "Bearing": null, + "Direction": null + } + ], + "TextSummary": null + }, + "Period": { + "Value": "Tuesday", + "TextForecastName": "Today" + }, + "Comfort": null, + "SnowLevel": null, + "WindChill": { + "Units": null, + "Value": null, + "Calculated": { + "Class": null, + "Value": null + }, + "TextSummary": null + }, + "Visibility": { + "WindVisibility": { + "Cause": null, + "TextSummary": null + }, + "OtherVisibility": { + "Cause": null, + "TextSummary": null + } + }, + "CloudPrecip": { + "TextSummary": "Flurries." + }, + "TextSummary": "Flurries. High plus 2.", + "Temperatures": { + "Temperature": { + "Year": null, + "Class": "high", + "Units": "°C", + "Value": "2", + "Period": null + }, + "TextSummary": "High plus 2." + }, + "Precipitation": { + "PrecipType": { + "End": "26", + "Start": "19", + "Value": "snow" + }, + "TextSummary": null, + "Accumulations": null + }, + "RelativeHumidity": { + "Units": "%", + "Value": "80" + }, + "AbbreviatedForecast": { + "Pop": null, + "IconCode": { + "Code": "16", + "Format": "gif" + }, + "TextSummary": "A few flurries" + } + } + ], + "hourly_forecast_group": [ + { + "Lop": { + "Units": "%", + "Value": "80", + "Category": "High" + }, + "Icon": { + "Code": "16", + "Format": "png" + }, + "Wind": { + "Gust": null, + "Rank": null, + "Index": null, + "Speed": { + "Units": "km/h", + "Value": "Calm" + }, + "Bearing": null, + "Direction": null + }, + "Condition": "A few flurries", + "WindChill": null, + "Temperature": { + "Year": null, + "Class": null, + "Units": "°C", + "Value": "1", + "Period": null + }, + "HourlyTimeStampUtc": "2024-01-23T22:00:00.000Z" + }, + { + "Lop": { + "Units": "%", + "Value": "80", + "Category": "High" + }, + "Icon": { + "Code": "16", + "Format": "png" + }, + "Wind": { + "Gust": null, + "Rank": null, + "Index": null, + "Speed": { + "Units": "km/h", + "Value": "Calm" + }, + "Bearing": null, + "Direction": null + }, + "Condition": "A few flurries", + "WindChill": null, + "Temperature": { + "Year": null, + "Class": null, + "Units": "°C", + "Value": "2", + "Period": null + }, + "HourlyTimeStampUtc": "2024-01-23T23:00:00.000Z" + } + ] + }, + { + "code": "s0000342", + "location_latitude": "58.66N", + "location_longitude": "124.64W", + "name": "Tetsa River (Provincial Park)", + "region": "Muncho Lake Park - Stone Mountain Park", + "observation_name": "observation", + "observation_zone": "UTC", + "observation_utc_offset": 0, + "observation_text_summary": "Friday January 19, 2024 at 15:00 UTC", + "conditions": { + "condition": "clear", + "wind_direction": "N", + "wind_gust_units": "km", + "wind_gust_value": "16.5", + "visibility_units": "km", + "visibility_value": "2.3", + "wind_speed_units": "km", + "wind_speed_value": "45", + "temperature_units": "C", + "temperature_value": "27" + }, + "forecast_group": [ + { + "UV": { + "Index": null, + "Category": null, + "TextSummary": null + }, + "Frost": null, + "Winds": { + "WindList": [ + { + "Gust": null, + "Rank": "major", + "Index": "1", + "Speed": { + "Units": "km/h", + "Value": "00" + }, + "Bearing": null, + "Direction": null + } + ], + "TextSummary": null + }, + "Period": { + "Value": "Tuesday", + "TextForecastName": "Today" + }, + "Comfort": null, + "SnowLevel": null, + "WindChill": { + "Units": null, + "Value": null, + "Calculated": { + "Class": null, + "Value": null + }, + "TextSummary": null + }, + "Visibility": { + "WindVisibility": { + "Cause": null, + "TextSummary": null + }, + "OtherVisibility": { + "Cause": null, + "TextSummary": null + } + }, + "CloudPrecip": { + "TextSummary": "Flurries." + }, + "TextSummary": "Flurries. High plus 2.", + "Temperatures": { + "Temperature": { + "Year": null, + "Class": "high", + "Units": "°C", + "Value": "2", + "Period": null + }, + "TextSummary": "High plus 2." + }, + "Precipitation": { + "PrecipType": { + "End": "26", + "Start": "19", + "Value": "snow" + }, + "TextSummary": null, + "Accumulations": null + }, + "RelativeHumidity": { + "Units": "%", + "Value": "80" + }, + "AbbreviatedForecast": { + "Pop": null, + "IconCode": { + "Code": "16", + "Format": "gif" + }, + "TextSummary": "A few flurries" + } + } + ], + "hourly_forecast_group": [ + { + "Lop": { + "Units": "%", + "Value": "80", + "Category": "High" + }, + "Icon": { + "Code": "16", + "Format": "png" + }, + "Wind": { + "Gust": null, + "Rank": null, + "Index": null, + "Speed": { + "Units": "km/h", + "Value": "Calm" + }, + "Bearing": null, + "Direction": null + }, + "Condition": "A few flurries", + "WindChill": null, + "Temperature": { + "Year": null, + "Class": null, + "Units": "°C", + "Value": "1", + "Period": null + }, + "HourlyTimeStampUtc": "2024-01-23T22:00:00.000Z" + }, + { + "Lop": { + "Units": "%", + "Value": "80", + "Category": "High" + }, + "Icon": { + "Code": "16", + "Format": "png" + }, + "Wind": { + "Gust": null, + "Rank": null, + "Index": null, + "Speed": { + "Units": "km/h", + "Value": "Calm" + }, + "Bearing": null, + "Direction": null + }, + "Condition": "A few flurries", + "WindChill": null, + "Temperature": { + "Year": null, + "Class": null, + "Units": "°C", + "Value": "2", + "Period": null + }, + "HourlyTimeStampUtc": "2024-01-23T23:00:00.000Z" + } + ] + } + + + +] + + + diff --git a/src/backend/apps/weather/tests/test_data/regional_weather_feed_weather_one.json b/src/backend/apps/weather/tests/test_data/regional_weather_feed_weather_one.json new file mode 100644 index 000000000..7075a840b --- /dev/null +++ b/src/backend/apps/weather/tests/test_data/regional_weather_feed_weather_one.json @@ -0,0 +1,2118 @@ +{ + "XmlCreationUtc": "Tuesday March 05, 2024 at 05:01 UTC", + "ForecastIssuedUtc": "2024-03-05T00:00:00", + "Location": { + "Continent": "North America", + "Country": { + "Code": "ca", + "Value": "Canada" + }, + "Province": { + "Code": "bc", + "Value": "British Columbia" + }, + "Name": { + "Code": "s0000216", + "Latitude": "50.26N", + "Longitude": "119.27W", + "Value": "Vernon" + }, + "Region": "North Okanagan - including Vernon" + }, + "Warnings": { + "Url": null, + "Events": [] + }, + "CurrentConditions": { + "Station": { + "Code": "wjv", + "Latitude": "50.22N", + "Longitude": "119.19W", + "Name": "Vernon" + }, + "ObservationDateTimeUTC": { + "Name": "observation", + "Zone": "UTC", + "UTCOffset": 0, + "TextSummary": "Tuesday March 05, 2024 at 05:00 UTC" + }, + "Condition": "", + "IconCode": { + "Format": "gif", + "Code": null + }, + "Temperature": { + "Units": "°C", + "Value": "-4.6" + }, + "Dewpoint": { + "Units": "°C", + "Value": "-10.3" + }, + "Pressure": { + "Units": "kPa", + "Value": "101.7" + }, + "Visibility": null, + "RelativeHumidity": { + "Units": "%", + "Value": "64" + }, + "Wind": { + "Speed": { + "Units": "km/h", + "Value": "calm" + }, + "Gust": { + "Units": "km/h", + "Value": null + }, + "Direction": "", + "WindDirFull": null, + "Bearing": { + "Units": "degrees", + "Value": null + }, + "Index": null, + "Rank": null + } + }, + "ForecastGroup": { + "ForecastIssueUtc": null, + "RegionalNormals": null, + "Forecasts": [ + { + "Period": { + "TextForecastName": "Tonight", + "Value": "Monday night" + }, + "TextSummary": "Partly cloudy. 30 percent chance of flurries early this evening. Wind north 20 km/h becoming light this evening. Low minus 7. Wind chill near minus 9.", + "CloudPrecip": { + "TextSummary": "Partly cloudy. 30 percent chance of flurries early this evening." + }, + "AbbreviatedForecast": { + "IconCode": { + "Format": "gif", + "Code": "38" + }, + "Pop": { + "Units": "%", + "Value": "30" + }, + "TextSummary": "Chance of flurries" + }, + "Temperatures": { + "TextSummary": "Low minus 7.", + "Temperature": { + "Class": "low", + "Period": null, + "Units": "°C", + "Year": null, + "Value": "-7" + } + }, + "Winds": { + "TextSummary": "Wind north 20 km/h becoming light this evening.", + "WindList": [ + { + "Speed": { + "Units": "km/h", + "Value": "20" + }, + "Gust": { + "Units": "km/h", + "Value": "00" + }, + "Direction": { + "WindDirFull": null, + "Value": "N" + }, + "Bearing": { + "Units": "degrees", + "Value": "36" + }, + "Index": "1", + "Rank": "major" + }, + { + "Speed": { + "Units": "km/h", + "Value": "10" + }, + "Gust": { + "Units": "km/h", + "Value": "00" + }, + "Direction": { + "WindDirFull": null, + "Value": "N" + }, + "Bearing": { + "Units": "degrees", + "Value": "36" + }, + "Index": "2", + "Rank": "major" + }, + { + "Speed": { + "Units": "km/h", + "Value": "05" + }, + "Gust": { + "Units": "km/h", + "Value": "00" + }, + "Direction": { + "WindDirFull": null, + "Value": "VR" + }, + "Bearing": { + "Units": "degrees", + "Value": "99" + }, + "Index": "3", + "Rank": "minor" + } + ] + }, + "Precipitation": { + "TextSummary": null, + "PrecipType": { + "Start": "0", + "End": "4", + "Value": "snow" + }, + "Accumulations": null + }, + "WindChill": { + "TextSummary": "Wind chill near minus 9.", + "Calculated": { + "Class": "near", + "Value": "-9" + }, + "Value": null, + "Units": null + }, + "RelativeHumidity": { + "Units": "%", + "Value": "60" + }, + "UV": { + "Category": null, + "Index": null, + "TextSummary": null + }, + "Frost": null, + "SnowLevel": null, + "Comfort": null, + "Visibility": { + "OtherVisibility": { + "Cause": null, + "TextSummary": null + }, + "WindVisibility": { + "Cause": null, + "TextSummary": null + } + } + }, + { + "Period": { + "TextForecastName": "Tuesday", + "Value": "Tuesday" + }, + "TextSummary": "Mainly sunny. Wind becoming north 20 km/h in the afternoon. High plus 2. Wind chill minus 10 in the morning. UV index 1 or low.", + "CloudPrecip": { + "TextSummary": "Mainly sunny." + }, + "AbbreviatedForecast": { + "IconCode": { + "Format": "gif", + "Code": "01" + }, + "Pop": null, + "TextSummary": "Mainly sunny" + }, + "Temperatures": { + "TextSummary": "High plus 2.", + "Temperature": { + "Class": "high", + "Period": null, + "Units": "°C", + "Year": null, + "Value": "2" + } + }, + "Winds": { + "TextSummary": "Wind becoming north 20 km/h in the afternoon.", + "WindList": [ + { + "Speed": { + "Units": "km/h", + "Value": "05" + }, + "Gust": { + "Units": "km/h", + "Value": "00" + }, + "Direction": { + "WindDirFull": null, + "Value": "VR" + }, + "Bearing": { + "Units": "degrees", + "Value": "99" + }, + "Index": "1", + "Rank": "major" + }, + { + "Speed": { + "Units": "km/h", + "Value": "20" + }, + "Gust": { + "Units": "km/h", + "Value": "00" + }, + "Direction": { + "WindDirFull": null, + "Value": "N" + }, + "Bearing": { + "Units": "degrees", + "Value": "36" + }, + "Index": "2", + "Rank": "major" + } + ] + }, + "Precipitation": { + "TextSummary": null, + "PrecipType": { + "Start": null, + "End": null, + "Value": null + }, + "Accumulations": null + }, + "WindChill": { + "TextSummary": "Wind chill minus 10 in the morning.", + "Calculated": { + "Class": "morning", + "Value": "-10" + }, + "Value": null, + "Units": null + }, + "RelativeHumidity": { + "Units": "%", + "Value": "45" + }, + "UV": { + "Category": "low", + "Index": "1", + "TextSummary": "UV index 1 or low." + }, + "Frost": null, + "SnowLevel": null, + "Comfort": null, + "Visibility": { + "OtherVisibility": { + "Cause": null, + "TextSummary": null + }, + "WindVisibility": { + "Cause": null, + "TextSummary": null + } + } + }, + { + "Period": { + "TextForecastName": "Tuesday night", + "Value": "Tuesday night" + }, + "TextSummary": "Clear. Wind north 20 km/h becoming light in the evening. Low minus 8. Wind chill near minus 10.", + "CloudPrecip": { + "TextSummary": "Clear." + }, + "AbbreviatedForecast": { + "IconCode": { + "Format": "gif", + "Code": "30" + }, + "Pop": null, + "TextSummary": "Clear" + }, + "Temperatures": { + "TextSummary": "Low minus 8.", + "Temperature": { + "Class": "low", + "Period": null, + "Units": "°C", + "Year": null, + "Value": "-8" + } + }, + "Winds": { + "TextSummary": "Wind north 20 km/h becoming light in the evening.", + "WindList": [ + { + "Speed": { + "Units": "km/h", + "Value": "20" + }, + "Gust": { + "Units": "km/h", + "Value": "00" + }, + "Direction": { + "WindDirFull": null, + "Value": "N" + }, + "Bearing": { + "Units": "degrees", + "Value": "36" + }, + "Index": "1", + "Rank": "major" + }, + { + "Speed": { + "Units": "km/h", + "Value": "05" + }, + "Gust": { + "Units": "km/h", + "Value": "00" + }, + "Direction": { + "WindDirFull": null, + "Value": "VR" + }, + "Bearing": { + "Units": "degrees", + "Value": "99" + }, + "Index": "2", + "Rank": "major" + } + ] + }, + "Precipitation": { + "TextSummary": null, + "PrecipType": { + "Start": null, + "End": null, + "Value": null + }, + "Accumulations": null + }, + "WindChill": { + "TextSummary": "Wind chill near minus 10.", + "Calculated": { + "Class": "near", + "Value": "-10" + }, + "Value": null, + "Units": null + }, + "RelativeHumidity": { + "Units": "%", + "Value": "50" + }, + "UV": { + "Category": null, + "Index": null, + "TextSummary": null + }, + "Frost": null, + "SnowLevel": null, + "Comfort": null, + "Visibility": { + "OtherVisibility": { + "Cause": null, + "TextSummary": null + }, + "WindVisibility": { + "Cause": null, + "TextSummary": null + } + } + }, + { + "Period": { + "TextForecastName": "Wednesday", + "Value": "Wednesday" + }, + "TextSummary": "Sunny. High plus 3.", + "CloudPrecip": { + "TextSummary": "Sunny." + }, + "AbbreviatedForecast": { + "IconCode": { + "Format": "gif", + "Code": "00" + }, + "Pop": null, + "TextSummary": "Sunny" + }, + "Temperatures": { + "TextSummary": "High plus 3.", + "Temperature": { + "Class": "high", + "Period": null, + "Units": "°C", + "Year": null, + "Value": "3" + } + }, + "Winds": { + "TextSummary": null, + "WindList": null + }, + "Precipitation": { + "TextSummary": null, + "PrecipType": { + "Start": null, + "End": null, + "Value": null + }, + "Accumulations": null + }, + "WindChill": { + "TextSummary": null, + "Calculated": { + "Class": null, + "Value": null + }, + "Value": null, + "Units": null + }, + "RelativeHumidity": { + "Units": "%", + "Value": "50" + }, + "UV": { + "Category": null, + "Index": null, + "TextSummary": null + }, + "Frost": null, + "SnowLevel": null, + "Comfort": null, + "Visibility": { + "OtherVisibility": { + "Cause": null, + "TextSummary": null + }, + "WindVisibility": { + "Cause": null, + "TextSummary": null + } + } + }, + { + "Period": { + "TextForecastName": "Wednesday night", + "Value": "Wednesday night" + }, + "TextSummary": "Clear. Low minus 7.", + "CloudPrecip": { + "TextSummary": "Clear." + }, + "AbbreviatedForecast": { + "IconCode": { + "Format": "gif", + "Code": "30" + }, + "Pop": null, + "TextSummary": "Clear" + }, + "Temperatures": { + "TextSummary": "Low minus 7.", + "Temperature": { + "Class": "low", + "Period": null, + "Units": "°C", + "Year": null, + "Value": "-7" + } + }, + "Winds": { + "TextSummary": null, + "WindList": null + }, + "Precipitation": { + "TextSummary": null, + "PrecipType": { + "Start": null, + "End": null, + "Value": null + }, + "Accumulations": null + }, + "WindChill": { + "TextSummary": null, + "Calculated": { + "Class": null, + "Value": null + }, + "Value": null, + "Units": null + }, + "RelativeHumidity": { + "Units": "%", + "Value": "55" + }, + "UV": { + "Category": null, + "Index": null, + "TextSummary": null + }, + "Frost": null, + "SnowLevel": null, + "Comfort": null, + "Visibility": { + "OtherVisibility": { + "Cause": null, + "TextSummary": null + }, + "WindVisibility": { + "Cause": null, + "TextSummary": null + } + } + }, + { + "Period": { + "TextForecastName": "Thursday", + "Value": "Thursday" + }, + "TextSummary": "Sunny. High plus 5.", + "CloudPrecip": { + "TextSummary": "Sunny." + }, + "AbbreviatedForecast": { + "IconCode": { + "Format": "gif", + "Code": "00" + }, + "Pop": null, + "TextSummary": "Sunny" + }, + "Temperatures": { + "TextSummary": "High plus 5.", + "Temperature": { + "Class": "high", + "Period": null, + "Units": "°C", + "Year": null, + "Value": "5" + } + }, + "Winds": { + "TextSummary": null, + "WindList": null + }, + "Precipitation": { + "TextSummary": null, + "PrecipType": { + "Start": null, + "End": null, + "Value": null + }, + "Accumulations": null + }, + "WindChill": { + "TextSummary": null, + "Calculated": { + "Class": null, + "Value": null + }, + "Value": null, + "Units": null + }, + "RelativeHumidity": { + "Units": "%", + "Value": "45" + }, + "UV": { + "Category": null, + "Index": null, + "TextSummary": null + }, + "Frost": null, + "SnowLevel": null, + "Comfort": null, + "Visibility": { + "OtherVisibility": { + "Cause": null, + "TextSummary": null + }, + "WindVisibility": { + "Cause": null, + "TextSummary": null + } + } + }, + { + "Period": { + "TextForecastName": "Thursday night", + "Value": "Thursday night" + }, + "TextSummary": "Clear. Low minus 6.", + "CloudPrecip": { + "TextSummary": "Clear." + }, + "AbbreviatedForecast": { + "IconCode": { + "Format": "gif", + "Code": "30" + }, + "Pop": null, + "TextSummary": "Clear" + }, + "Temperatures": { + "TextSummary": "Low minus 6.", + "Temperature": { + "Class": "low", + "Period": null, + "Units": "°C", + "Year": null, + "Value": "-6" + } + }, + "Winds": { + "TextSummary": null, + "WindList": null + }, + "Precipitation": { + "TextSummary": null, + "PrecipType": { + "Start": null, + "End": null, + "Value": null + }, + "Accumulations": null + }, + "WindChill": { + "TextSummary": null, + "Calculated": { + "Class": null, + "Value": null + }, + "Value": null, + "Units": null + }, + "RelativeHumidity": { + "Units": "%", + "Value": "55" + }, + "UV": { + "Category": null, + "Index": null, + "TextSummary": null + }, + "Frost": null, + "SnowLevel": null, + "Comfort": null, + "Visibility": { + "OtherVisibility": { + "Cause": null, + "TextSummary": null + }, + "WindVisibility": { + "Cause": null, + "TextSummary": null + } + } + }, + { + "Period": { + "TextForecastName": "Friday", + "Value": "Friday" + }, + "TextSummary": "A mix of sun and cloud. High plus 4.", + "CloudPrecip": { + "TextSummary": "A mix of sun and cloud." + }, + "AbbreviatedForecast": { + "IconCode": { + "Format": "gif", + "Code": "02" + }, + "Pop": null, + "TextSummary": "A mix of sun and cloud" + }, + "Temperatures": { + "TextSummary": "High plus 4.", + "Temperature": { + "Class": "high", + "Period": null, + "Units": "°C", + "Year": null, + "Value": "4" + } + }, + "Winds": { + "TextSummary": null, + "WindList": null + }, + "Precipitation": { + "TextSummary": null, + "PrecipType": { + "Start": null, + "End": null, + "Value": null + }, + "Accumulations": null + }, + "WindChill": { + "TextSummary": null, + "Calculated": { + "Class": null, + "Value": null + }, + "Value": null, + "Units": null + }, + "RelativeHumidity": { + "Units": "%", + "Value": "40" + }, + "UV": { + "Category": null, + "Index": null, + "TextSummary": null + }, + "Frost": null, + "SnowLevel": null, + "Comfort": null, + "Visibility": { + "OtherVisibility": { + "Cause": null, + "TextSummary": null + }, + "WindVisibility": { + "Cause": null, + "TextSummary": null + } + } + }, + { + "Period": { + "TextForecastName": "Friday night", + "Value": "Friday night" + }, + "TextSummary": "Cloudy periods. Low zero.", + "CloudPrecip": { + "TextSummary": "Cloudy periods." + }, + "AbbreviatedForecast": { + "IconCode": { + "Format": "gif", + "Code": "32" + }, + "Pop": null, + "TextSummary": "Cloudy periods" + }, + "Temperatures": { + "TextSummary": "Low zero.", + "Temperature": { + "Class": "low", + "Period": null, + "Units": "°C", + "Year": null, + "Value": "0" + } + }, + "Winds": { + "TextSummary": null, + "WindList": null + }, + "Precipitation": { + "TextSummary": null, + "PrecipType": { + "Start": null, + "End": null, + "Value": null + }, + "Accumulations": null + }, + "WindChill": { + "TextSummary": null, + "Calculated": { + "Class": null, + "Value": null + }, + "Value": null, + "Units": null + }, + "RelativeHumidity": { + "Units": "%", + "Value": "60" + }, + "UV": { + "Category": null, + "Index": null, + "TextSummary": null + }, + "Frost": null, + "SnowLevel": null, + "Comfort": null, + "Visibility": { + "OtherVisibility": { + "Cause": null, + "TextSummary": null + }, + "WindVisibility": { + "Cause": null, + "TextSummary": null + } + } + }, + { + "Period": { + "TextForecastName": "Saturday", + "Value": "Saturday" + }, + "TextSummary": "Cloudy with 40 percent chance of showers. High 8.", + "CloudPrecip": { + "TextSummary": "Cloudy with 40 percent chance of showers." + }, + "AbbreviatedForecast": { + "IconCode": { + "Format": "gif", + "Code": "12" + }, + "Pop": { + "Units": "%", + "Value": "40" + }, + "TextSummary": "Chance of showers" + }, + "Temperatures": { + "TextSummary": "High 8.", + "Temperature": { + "Class": "high", + "Period": null, + "Units": "°C", + "Year": null, + "Value": "8" + } + }, + "Winds": { + "TextSummary": null, + "WindList": null + }, + "Precipitation": { + "TextSummary": null, + "PrecipType": { + "Start": "114", + "End": "122", + "Value": "rain" + }, + "Accumulations": null + }, + "WindChill": { + "TextSummary": null, + "Calculated": { + "Class": null, + "Value": null + }, + "Value": null, + "Units": null + }, + "RelativeHumidity": { + "Units": "%", + "Value": "50" + }, + "UV": { + "Category": null, + "Index": null, + "TextSummary": null + }, + "Frost": null, + "SnowLevel": null, + "Comfort": null, + "Visibility": { + "OtherVisibility": { + "Cause": null, + "TextSummary": null + }, + "WindVisibility": { + "Cause": null, + "TextSummary": null + } + } + }, + { + "Period": { + "TextForecastName": "Saturday night", + "Value": "Saturday night" + }, + "TextSummary": "Cloudy with 30 percent chance of showers. Low plus 1.", + "CloudPrecip": { + "TextSummary": "Cloudy with 30 percent chance of showers." + }, + "AbbreviatedForecast": { + "IconCode": { + "Format": "gif", + "Code": "12" + }, + "Pop": { + "Units": "%", + "Value": "30" + }, + "TextSummary": "Chance of showers" + }, + "Temperatures": { + "TextSummary": "Low plus 1.", + "Temperature": { + "Class": "low", + "Period": null, + "Units": "°C", + "Year": null, + "Value": "1" + } + }, + "Winds": { + "TextSummary": null, + "WindList": null + }, + "Precipitation": { + "TextSummary": null, + "PrecipType": { + "Start": "122", + "End": "126", + "Value": "rain" + }, + "Accumulations": null + }, + "WindChill": { + "TextSummary": null, + "Calculated": { + "Class": null, + "Value": null + }, + "Value": null, + "Units": null + }, + "RelativeHumidity": { + "Units": "%", + "Value": "55" + }, + "UV": { + "Category": null, + "Index": null, + "TextSummary": null + }, + "Frost": null, + "SnowLevel": null, + "Comfort": null, + "Visibility": { + "OtherVisibility": { + "Cause": null, + "TextSummary": null + }, + "WindVisibility": { + "Cause": null, + "TextSummary": null + } + } + }, + { + "Period": { + "TextForecastName": "Sunday", + "Value": "Sunday" + }, + "TextSummary": "Cloudy with 30 percent chance of showers. High 8.", + "CloudPrecip": { + "TextSummary": "Cloudy with 30 percent chance of showers." + }, + "AbbreviatedForecast": { + "IconCode": { + "Format": "gif", + "Code": "12" + }, + "Pop": { + "Units": "%", + "Value": "30" + }, + "TextSummary": "Chance of showers" + }, + "Temperatures": { + "TextSummary": "High 8.", + "Temperature": { + "Class": "high", + "Period": null, + "Units": "°C", + "Year": null, + "Value": "8" + } + }, + "Winds": { + "TextSummary": null, + "WindList": null + }, + "Precipitation": { + "TextSummary": null, + "PrecipType": { + "Start": "138", + "End": "146", + "Value": "rain" + }, + "Accumulations": null + }, + "WindChill": { + "TextSummary": null, + "Calculated": { + "Class": null, + "Value": null + }, + "Value": null, + "Units": null + }, + "RelativeHumidity": { + "Units": "%", + "Value": "50" + }, + "UV": { + "Category": null, + "Index": null, + "TextSummary": null + }, + "Frost": null, + "SnowLevel": null, + "Comfort": null, + "Visibility": { + "OtherVisibility": { + "Cause": null, + "TextSummary": null + }, + "WindVisibility": { + "Cause": null, + "TextSummary": null + } + } + } + ] + }, + "HourlyForecastGroup": { + "ForecastIssueUtc": null, + "HourlyForecasts": [ + { + "HourlyTimeStampUtc": "2024-03-05T06:00:00.000Z", + "Condition": "Mainly cloudy", + "Icon": { + "Format": "png", + "Code": "33" + }, + "Temperature": { + "Class": null, + "Period": null, + "Units": "°C", + "Year": null, + "Value": "-4" + }, + "Lop": { + "Value": "0", + "Category": "Nil", + "Units": "%" + }, + "WindChill": { + "TextSummary": null, + "Calculated": null, + "Value": "-8", + "Units": "°C" + }, + "Wind": { + "Speed": { + "Units": "km/h", + "Value": "10" + }, + "Gust": null, + "Direction": { + "WindDirFull": null, + "Value": "N" + }, + "Bearing": null, + "Index": null, + "Rank": null + } + }, + { + "HourlyTimeStampUtc": "2024-03-05T07:00:00.000Z", + "Condition": "Mainly cloudy", + "Icon": { + "Format": "png", + "Code": "33" + }, + "Temperature": { + "Class": null, + "Period": null, + "Units": "°C", + "Year": null, + "Value": "-4" + }, + "Lop": { + "Value": "0", + "Category": "Nil", + "Units": "%" + }, + "WindChill": { + "TextSummary": null, + "Calculated": null, + "Value": "-8", + "Units": "°C" + }, + "Wind": { + "Speed": { + "Units": "km/h", + "Value": "10" + }, + "Gust": null, + "Direction": { + "WindDirFull": null, + "Value": "N" + }, + "Bearing": null, + "Index": null, + "Rank": null + } + }, + { + "HourlyTimeStampUtc": "2024-03-05T08:00:00.000Z", + "Condition": "Partly cloudy", + "Icon": { + "Format": "png", + "Code": "32" + }, + "Temperature": { + "Class": null, + "Period": null, + "Units": "°C", + "Year": null, + "Value": "-5" + }, + "Lop": { + "Value": "0", + "Category": "Nil", + "Units": "%" + }, + "WindChill": { + "TextSummary": null, + "Calculated": null, + "Value": "-9", + "Units": "°C" + }, + "Wind": { + "Speed": { + "Units": "km/h", + "Value": "10" + }, + "Gust": null, + "Direction": { + "WindDirFull": null, + "Value": "N" + }, + "Bearing": null, + "Index": null, + "Rank": null + } + }, + { + "HourlyTimeStampUtc": "2024-03-05T09:00:00.000Z", + "Condition": "Partly cloudy", + "Icon": { + "Format": "png", + "Code": "32" + }, + "Temperature": { + "Class": null, + "Period": null, + "Units": "°C", + "Year": null, + "Value": "-5" + }, + "Lop": { + "Value": "0", + "Category": "Nil", + "Units": "%" + }, + "WindChill": { + "TextSummary": null, + "Calculated": null, + "Value": "-9", + "Units": "°C" + }, + "Wind": { + "Speed": { + "Units": "km/h", + "Value": "10" + }, + "Gust": null, + "Direction": { + "WindDirFull": null, + "Value": "N" + }, + "Bearing": null, + "Index": null, + "Rank": null + } + }, + { + "HourlyTimeStampUtc": "2024-03-05T10:00:00.000Z", + "Condition": "Partly cloudy", + "Icon": { + "Format": "png", + "Code": "32" + }, + "Temperature": { + "Class": null, + "Period": null, + "Units": "°C", + "Year": null, + "Value": "-5" + }, + "Lop": { + "Value": "0", + "Category": "Nil", + "Units": "%" + }, + "WindChill": { + "TextSummary": null, + "Calculated": null, + "Value": "-8", + "Units": "°C" + }, + "Wind": { + "Speed": { + "Units": "km/h", + "Value": "5" + }, + "Gust": null, + "Direction": { + "WindDirFull": null, + "Value": "VR" + }, + "Bearing": null, + "Index": null, + "Rank": null + } + }, + { + "HourlyTimeStampUtc": "2024-03-05T11:00:00.000Z", + "Condition": "Partly cloudy", + "Icon": { + "Format": "png", + "Code": "32" + }, + "Temperature": { + "Class": null, + "Period": null, + "Units": "°C", + "Year": null, + "Value": "-6" + }, + "Lop": { + "Value": "0", + "Category": "Nil", + "Units": "%" + }, + "WindChill": { + "TextSummary": null, + "Calculated": null, + "Value": "-8", + "Units": "°C" + }, + "Wind": { + "Speed": { + "Units": "km/h", + "Value": "5" + }, + "Gust": null, + "Direction": { + "WindDirFull": null, + "Value": "VR" + }, + "Bearing": null, + "Index": null, + "Rank": null + } + }, + { + "HourlyTimeStampUtc": "2024-03-05T12:00:00.000Z", + "Condition": "A few clouds", + "Icon": { + "Format": "png", + "Code": "31" + }, + "Temperature": { + "Class": null, + "Period": null, + "Units": "°C", + "Year": null, + "Value": "-6" + }, + "Lop": { + "Value": "0", + "Category": "Nil", + "Units": "%" + }, + "WindChill": { + "TextSummary": null, + "Calculated": null, + "Value": "-8", + "Units": "°C" + }, + "Wind": { + "Speed": { + "Units": "km/h", + "Value": "5" + }, + "Gust": null, + "Direction": { + "WindDirFull": null, + "Value": "VR" + }, + "Bearing": null, + "Index": null, + "Rank": null + } + }, + { + "HourlyTimeStampUtc": "2024-03-05T13:00:00.000Z", + "Condition": "A few clouds", + "Icon": { + "Format": "png", + "Code": "31" + }, + "Temperature": { + "Class": null, + "Period": null, + "Units": "°C", + "Year": null, + "Value": "-6" + }, + "Lop": { + "Value": "0", + "Category": "Nil", + "Units": "%" + }, + "WindChill": { + "TextSummary": null, + "Calculated": null, + "Value": "-9", + "Units": "°C" + }, + "Wind": { + "Speed": { + "Units": "km/h", + "Value": "5" + }, + "Gust": null, + "Direction": { + "WindDirFull": null, + "Value": "VR" + }, + "Bearing": null, + "Index": null, + "Rank": null + } + }, + { + "HourlyTimeStampUtc": "2024-03-05T14:00:00.000Z", + "Condition": "Mainly sunny", + "Icon": { + "Format": "png", + "Code": "01" + }, + "Temperature": { + "Class": null, + "Period": null, + "Units": "°C", + "Year": null, + "Value": "-7" + }, + "Lop": { + "Value": "0", + "Category": "Nil", + "Units": "%" + }, + "WindChill": { + "TextSummary": null, + "Calculated": null, + "Value": "-9", + "Units": "°C" + }, + "Wind": { + "Speed": { + "Units": "km/h", + "Value": "5" + }, + "Gust": null, + "Direction": { + "WindDirFull": null, + "Value": "VR" + }, + "Bearing": null, + "Index": null, + "Rank": null + } + }, + { + "HourlyTimeStampUtc": "2024-03-05T15:00:00.000Z", + "Condition": "Mainly sunny", + "Icon": { + "Format": "png", + "Code": "01" + }, + "Temperature": { + "Class": null, + "Period": null, + "Units": "°C", + "Year": null, + "Value": "-7" + }, + "Lop": { + "Value": "0", + "Category": "Nil", + "Units": "%" + }, + "WindChill": { + "TextSummary": null, + "Calculated": null, + "Value": "-10", + "Units": "°C" + }, + "Wind": { + "Speed": { + "Units": "km/h", + "Value": "5" + }, + "Gust": null, + "Direction": { + "WindDirFull": null, + "Value": "VR" + }, + "Bearing": null, + "Index": null, + "Rank": null + } + }, + { + "HourlyTimeStampUtc": "2024-03-05T16:00:00.000Z", + "Condition": "Mainly sunny", + "Icon": { + "Format": "png", + "Code": "01" + }, + "Temperature": { + "Class": null, + "Period": null, + "Units": "°C", + "Year": null, + "Value": "-6" + }, + "Lop": { + "Value": "0", + "Category": "Nil", + "Units": "%" + }, + "WindChill": { + "TextSummary": null, + "Calculated": null, + "Value": "-8", + "Units": "°C" + }, + "Wind": { + "Speed": { + "Units": "km/h", + "Value": "5" + }, + "Gust": null, + "Direction": { + "WindDirFull": null, + "Value": "VR" + }, + "Bearing": null, + "Index": null, + "Rank": null + } + }, + { + "HourlyTimeStampUtc": "2024-03-05T17:00:00.000Z", + "Condition": "Mainly sunny", + "Icon": { + "Format": "png", + "Code": "01" + }, + "Temperature": { + "Class": null, + "Period": null, + "Units": "°C", + "Year": null, + "Value": "-4" + }, + "Lop": { + "Value": "0", + "Category": "Nil", + "Units": "%" + }, + "WindChill": { + "TextSummary": null, + "Calculated": null, + "Value": "-7", + "Units": "°C" + }, + "Wind": { + "Speed": { + "Units": "km/h", + "Value": "5" + }, + "Gust": null, + "Direction": { + "WindDirFull": null, + "Value": "VR" + }, + "Bearing": null, + "Index": null, + "Rank": null + } + }, + { + "HourlyTimeStampUtc": "2024-03-05T18:00:00.000Z", + "Condition": "Mainly sunny", + "Icon": { + "Format": "png", + "Code": "01" + }, + "Temperature": { + "Class": null, + "Period": null, + "Units": "°C", + "Year": null, + "Value": "-3" + }, + "Lop": { + "Value": "0", + "Category": "Nil", + "Units": "%" + }, + "WindChill": { + "TextSummary": null, + "Calculated": null, + "Value": "-5", + "Units": "°C" + }, + "Wind": { + "Speed": { + "Units": "km/h", + "Value": "5" + }, + "Gust": null, + "Direction": { + "WindDirFull": null, + "Value": "VR" + }, + "Bearing": null, + "Index": null, + "Rank": null + } + }, + { + "HourlyTimeStampUtc": "2024-03-05T19:00:00.000Z", + "Condition": "Mainly sunny", + "Icon": { + "Format": "png", + "Code": "01" + }, + "Temperature": { + "Class": null, + "Period": null, + "Units": "°C", + "Year": null, + "Value": "-2" + }, + "Lop": { + "Value": "0", + "Category": "Nil", + "Units": "%" + }, + "WindChill": { + "TextSummary": null, + "Calculated": null, + "Value": "-3", + "Units": "°C" + }, + "Wind": { + "Speed": { + "Units": "km/h", + "Value": "5" + }, + "Gust": null, + "Direction": { + "WindDirFull": null, + "Value": "VR" + }, + "Bearing": null, + "Index": null, + "Rank": null + } + }, + { + "HourlyTimeStampUtc": "2024-03-05T20:00:00.000Z", + "Condition": "Mainly sunny", + "Icon": { + "Format": "png", + "Code": "01" + }, + "Temperature": { + "Class": null, + "Period": null, + "Units": "°C", + "Year": null, + "Value": "0" + }, + "Lop": { + "Value": "0", + "Category": "Nil", + "Units": "%" + }, + "WindChill": null, + "Wind": { + "Speed": { + "Units": "km/h", + "Value": "5" + }, + "Gust": null, + "Direction": { + "WindDirFull": null, + "Value": "VR" + }, + "Bearing": null, + "Index": null, + "Rank": null + } + }, + { + "HourlyTimeStampUtc": "2024-03-05T21:00:00.000Z", + "Condition": "Mainly sunny", + "Icon": { + "Format": "png", + "Code": "01" + }, + "Temperature": { + "Class": null, + "Period": null, + "Units": "°C", + "Year": null, + "Value": "1" + }, + "Lop": { + "Value": "0", + "Category": "Nil", + "Units": "%" + }, + "WindChill": null, + "Wind": { + "Speed": { + "Units": "km/h", + "Value": "5" + }, + "Gust": null, + "Direction": { + "WindDirFull": null, + "Value": "VR" + }, + "Bearing": null, + "Index": null, + "Rank": null + } + }, + { + "HourlyTimeStampUtc": "2024-03-05T22:00:00.000Z", + "Condition": "Mainly sunny", + "Icon": { + "Format": "png", + "Code": "01" + }, + "Temperature": { + "Class": null, + "Period": null, + "Units": "°C", + "Year": null, + "Value": "1" + }, + "Lop": { + "Value": "0", + "Category": "Nil", + "Units": "%" + }, + "WindChill": null, + "Wind": { + "Speed": { + "Units": "km/h", + "Value": "20" + }, + "Gust": null, + "Direction": { + "WindDirFull": null, + "Value": "N" + }, + "Bearing": null, + "Index": null, + "Rank": null + } + }, + { + "HourlyTimeStampUtc": "2024-03-05T23:00:00.000Z", + "Condition": "Mainly sunny", + "Icon": { + "Format": "png", + "Code": "01" + }, + "Temperature": { + "Class": null, + "Period": null, + "Units": "°C", + "Year": null, + "Value": "2" + }, + "Lop": { + "Value": "0", + "Category": "Nil", + "Units": "%" + }, + "WindChill": null, + "Wind": { + "Speed": { + "Units": "km/h", + "Value": "20" + }, + "Gust": null, + "Direction": { + "WindDirFull": null, + "Value": "N" + }, + "Bearing": null, + "Index": null, + "Rank": null + } + }, + { + "HourlyTimeStampUtc": "2024-03-06T00:00:00.000Z", + "Condition": "Mainly sunny", + "Icon": { + "Format": "png", + "Code": "01" + }, + "Temperature": { + "Class": null, + "Period": null, + "Units": "°C", + "Year": null, + "Value": "2" + }, + "Lop": { + "Value": "0", + "Category": "Nil", + "Units": "%" + }, + "WindChill": null, + "Wind": { + "Speed": { + "Units": "km/h", + "Value": "20" + }, + "Gust": null, + "Direction": { + "WindDirFull": null, + "Value": "N" + }, + "Bearing": null, + "Index": null, + "Rank": null + } + }, + { + "HourlyTimeStampUtc": "2024-03-06T01:00:00.000Z", + "Condition": "Mainly sunny", + "Icon": { + "Format": "png", + "Code": "01" + }, + "Temperature": { + "Class": null, + "Period": null, + "Units": "°C", + "Year": null, + "Value": "0" + }, + "Lop": { + "Value": "0", + "Category": "Nil", + "Units": "%" + }, + "WindChill": null, + "Wind": { + "Speed": { + "Units": "km/h", + "Value": "20" + }, + "Gust": null, + "Direction": { + "WindDirFull": null, + "Value": "N" + }, + "Bearing": null, + "Index": null, + "Rank": null + } + }, + { + "HourlyTimeStampUtc": "2024-03-06T02:00:00.000Z", + "Condition": "Clear", + "Icon": { + "Format": "png", + "Code": "30" + }, + "Temperature": { + "Class": null, + "Period": null, + "Units": "°C", + "Year": null, + "Value": "-1" + }, + "Lop": { + "Value": "0", + "Category": "Nil", + "Units": "%" + }, + "WindChill": { + "TextSummary": null, + "Calculated": null, + "Value": "-7", + "Units": "°C" + }, + "Wind": { + "Speed": { + "Units": "km/h", + "Value": "20" + }, + "Gust": null, + "Direction": { + "WindDirFull": null, + "Value": "N" + }, + "Bearing": null, + "Index": null, + "Rank": null + } + }, + { + "HourlyTimeStampUtc": "2024-03-06T03:00:00.000Z", + "Condition": "Clear", + "Icon": { + "Format": "png", + "Code": "30" + }, + "Temperature": { + "Class": null, + "Period": null, + "Units": "°C", + "Year": null, + "Value": "-3" + }, + "Lop": { + "Value": "0", + "Category": "Nil", + "Units": "%" + }, + "WindChill": { + "TextSummary": null, + "Calculated": null, + "Value": "-9", + "Units": "°C" + }, + "Wind": { + "Speed": { + "Units": "km/h", + "Value": "20" + }, + "Gust": null, + "Direction": { + "WindDirFull": null, + "Value": "N" + }, + "Bearing": null, + "Index": null, + "Rank": null + } + }, + { + "HourlyTimeStampUtc": "2024-03-06T04:00:00.000Z", + "Condition": "Clear", + "Icon": { + "Format": "png", + "Code": "30" + }, + "Temperature": { + "Class": null, + "Period": null, + "Units": "°C", + "Year": null, + "Value": "-4" + }, + "Lop": { + "Value": "0", + "Category": "Nil", + "Units": "%" + }, + "WindChill": { + "TextSummary": null, + "Calculated": null, + "Value": "-6", + "Units": "°C" + }, + "Wind": { + "Speed": { + "Units": "km/h", + "Value": "5" + }, + "Gust": null, + "Direction": { + "WindDirFull": null, + "Value": "VR" + }, + "Bearing": null, + "Index": null, + "Rank": null + } + }, + { + "HourlyTimeStampUtc": "2024-03-06T05:00:00.000Z", + "Condition": "Clear", + "Icon": { + "Format": "png", + "Code": "30" + }, + "Temperature": { + "Class": null, + "Period": null, + "Units": "°C", + "Year": null, + "Value": "-4" + }, + "Lop": { + "Value": "0", + "Category": "Nil", + "Units": "%" + }, + "WindChill": { + "TextSummary": null, + "Calculated": null, + "Value": "-7", + "Units": "°C" + }, + "Wind": { + "Speed": { + "Units": "km/h", + "Value": "5" + }, + "Gust": null, + "Direction": { + "WindDirFull": null, + "Value": "VR" + }, + "Bearing": null, + "Index": null, + "Rank": null + } + } + ] + }, + "RiseSet": { + "SunriseUtc": "Tuesday March 05, 2024 at 14:31 UTC", + "SunsetUtc": "Wednesday March 06, 2024 at 01:47 UTC" + }, + "Almanac": { + "Temperatures": [ + { + "Class": "extremeMax", + "Period": "1992-2008", + "Units": "°C", + "Year": "2005", + "Value": "13.7" + }, + { + "Class": "extremeMin", + "Period": "1992-2008", + "Units": "°C", + "Year": "1995", + "Value": "-10.4" + }, + { + "Class": "normalMax", + "Period": null, + "Units": "°C", + "Year": null, + "Value": null + }, + { + "Class": "normalMin", + "Period": null, + "Units": "°C", + "Year": null, + "Value": null + }, + { + "Class": "normalMean", + "Period": null, + "Units": "°C", + "Year": null, + "Value": null + } + ], + "Precipitations": [ + { + "Class": "extremeRainfall", + "Period": "-", + "Units": "mm", + "Year": "", + "Value": null + }, + { + "Class": "extremeSnowfall", + "Period": "-", + "Units": "cm", + "Year": "", + "Value": null + }, + { + "Class": "extremePrecipitation", + "Period": "1993-2008", + "Units": "mm", + "Year": "1998", + "Value": "0.4" + }, + { + "Class": "extremeSnowOnGround", + "Period": "-", + "Units": "cm", + "Year": "", + "Value": null + } + ], + "Pop": { + "Units": "%", + "Value": null + } + }, + "ForecastId": 272467435 +} \ No newline at end of file diff --git a/src/backend/apps/weather/tests/test_regional_weather_populate.py b/src/backend/apps/weather/tests/test_regional_weather_populate.py index 914879832..7ee526ac3 100644 --- a/src/backend/apps/weather/tests/test_regional_weather_populate.py +++ b/src/backend/apps/weather/tests/test_regional_weather_populate.py @@ -2,15 +2,16 @@ from pathlib import Path from unittest import skip from unittest.mock import patch - from apps.shared.tests import BaseTest, MockResponse from apps.weather.models import RegionalWeather from apps.weather.tasks import ( populate_all_regional_weather_data, populate_regional_weather_from_data, + populate_all_regional_weather_data, ) from apps.weather.tests.test_data.regional_weather_parsed_feed import json_feed - +from src.backend.apps.feed.client import FeedClient +from unittest import mock class TestRegionalWeatherModel(BaseTest): def setUp(self): @@ -19,28 +20,66 @@ def setUp(self): # Normal feed regional_weather_feed_data = open( str(Path(__file__).parent) + - "/test_data/regional_weather_feed_list_of_one.json" + "/test_data/regional_weather_feed_list_of_two.json" ) self.mock_regional_weather_feed_result = json.load(regional_weather_feed_data) self.json_feed = json_feed + regional_weather_feed_data_weather_one = open( + str(Path(__file__).parent) + + "/test_data/regional_weather_feed_weather_one.json" + ) + self.mock_regional_weather_feed_result_weather_one = json.load(regional_weather_feed_data_weather_one) + def test_populate_regional_weather_function(self): populate_regional_weather_from_data(self.json_feed) regional_weather_one = RegionalWeather.objects.get(code="s0000341") assert regional_weather_one.location_latitude == \ "58.66N" + + def test_populate_regional_weather_function_with_existing_data(self): + RegionalWeather.objects.create( + code="s0000341", + location_latitude="58.66N", + location_longitude="124.64W", + ) + populate_regional_weather_from_data(self.json_feed) + regional_weather_one = RegionalWeather.objects.get(code="s0000341") + assert regional_weather_one.location_latitude == \ + "58.66N" - @patch("httpx.get") - @skip('to be mocked') + @patch('src.backend.apps.feed.client.FeedClient.get_regional_weather_list') def test_populate_and_update_regional_weather(self, mock_requests_get): mock_requests_get.side_effect = [ MockResponse(self.mock_regional_weather_feed_result, status_code=200), ] + response = self.mock_regional_weather_feed_result + client = FeedClient() + feed_data = client.get_regional_weather_list() + feed_data = response + + for regional_weather_data in feed_data: + populate_regional_weather_from_data(regional_weather_data) + weather_list_length = len(response) + assert weather_list_length == 2 + + def test_populate_all_regional_weather_data(self): + with mock.patch('requests.post') as mock_post, mock.patch('requests.get') as mock_get: + # Mock the response for requests.post + mock_post.return_value.json.return_value = {"access_token": "mocked_access_token"} + mock_post.return_value.status_code = 200 - populate_all_regional_weather_data() - assert RegionalWeather.objects.count() == 98 - regional_weather_id_list = sorted(RegionalWeather.objects.all().order_by("id") - .values_list("id", flat=True)) - assert len(regional_weather_id_list) == 98 - regional_weather = RegionalWeather.objects.get(code="s0000341") - assert regional_weather.location_latitude == "58.66N" + # Mock the response for the first requests.get (for area codes) + mock_get.side_effect = [ + mock.Mock(json=lambda: [{ + "AreaCode": "s0000846", + "AreaName": "Coquihalla Highway - Hope to Merritt", + "AreaType": "ECHIGHELEVN" + } + ]), + # Mock the response for the second requests.get (weather data for a specific area codes) + mock.Mock(json=lambda: self.mock_regional_weather_feed_result_weather_one), + ] + + populate_all_regional_weather_data() + assert len(RegionalWeather.objects.all()) == 1 \ No newline at end of file diff --git a/src/backend/apps/weather/tests/test_regionalweather_api.py b/src/backend/apps/weather/tests/test_regionalweather_api.py index 64c84f311..2e6739df7 100644 --- a/src/backend/apps/weather/tests/test_regionalweather_api.py +++ b/src/backend/apps/weather/tests/test_regionalweather_api.py @@ -10,11 +10,17 @@ class TestRegionalWeatherAPI(APITestCase, BaseTest): def setUp(self): super().setUp() - RegionalWeather.objects.create( + self.weather = RegionalWeather.objects.create( code="s0000341", - location_latitude="58.66N", + location_latitude="58.66S", location_longitude="124.64W", + forecast_group={} ) + self.weather.save() + + + + def test_regional_weather_list_caching(self): # Empty cache @@ -56,3 +62,9 @@ def test_regional_weather_list_filtering(self): url ) assert len(response.data) == 1 + + def test_get_forecasts(self): + forecasts = self.weather.get_forecasts() + test_str = self.weather.__str__() + assert len(forecasts) == 0 + assert test_str == 'Regional Forecast for 6' \ No newline at end of file diff --git a/src/backend/apps/webcam/tests/test_data/webcam_feed_list_of_one_filtered.json b/src/backend/apps/webcam/tests/test_data/webcam_feed_list_of_one_filtered.json new file mode 100644 index 000000000..1bfec65b9 --- /dev/null +++ b/src/backend/apps/webcam/tests/test_data/webcam_feed_list_of_one_filtered.json @@ -0,0 +1,160 @@ +{ + "links": { + "self": "https://images.drivebc.ca/webcam/api/v1/webcams" + }, + "webcams": [ + { + "links": { + "self": "https://images.drivebc.ca/webcam/api/v1/webcams/2", + "imageSource": "https://images.drivebc.ca/webcam/api/v1/webcams/2/imageSource", + "imageDisplayAPI": "https://images.drivebc.ca/webcam/api/v1/webcams/2/imageDisplay", + "imageThumbnailAPI": "https://images.drivebc.ca/webcam/api/v1/webcams/2/imageThumbnail", + "imageDisplay": "https://images.drivebc.ca/bchighwaycam/pub/cameras/2.jpg", + "imageThumbnail": "https://images.drivebc.ca/bchighwaycam/pub/cameras/tn/2.jpg", + "currentImage": "https://images.drivebc.ca/webcam/imageUpdate.php?cam=2", + "updateCurrentImage": "https://images.drivebc.ca/webcam/imageUpdate.php?cam=2&update", + "replayTheDay": "https://images.drivebc.ca/ReplayTheDay/player.html?cam=2" + }, + "id": 2, + "isOn": true, + "shouldAppear": true, + "region": { + "links": { + "self": "https://images.drivebc.ca/webcam/api/v1/regions/Southern+Interior" + }, + "name": "Southern Interior", + "group": 1 + }, + "regionGroup": { + "highwayGroup": 6, + "highwayCamOrder": 20 + }, + "highway": { + "links": { + "self": "https://images.drivebc.ca/webcam/api/v1/highways/5" + }, + "number": 5, + "locationDescription": "Coquihalla" + }, + "camName": "Coquihalla Great Bear Snowshed - N", + "caption": "Hwy 5, Great Bear Snowshed looking north.", + "credit": "", + "dbcMark": "DriveBC.ca", + "orientation": "N", + "location": { + "links": { + "googleMap": "http://maps.google.com/maps?q=49.596374,-121.159832+(Coquihalla Great Bear Snowshed - N)", + "googleStaticMap": "http://maps.google.com/maps/api/staticmap?center=49.596374,-121.159832&markers=color:blue%7C49.596374,-121.159832&zoom=11&size=240x240&sensor=false" + }, + "latitude": 49.596374, + "longitude": -121.159832, + "elevation": 980 + }, + "weather": { + "observation": null, + "forecast": { + "links": { + "self": "https://weather.drivebc.ca/EnvCanada/YHE-YKA5full.html" + }, + "id": "YHE-YKA5full" + } + }, + "message": { + "short": "", + "long": "" + }, + "map": { + "image": "https://images.drivebc.ca/bchighwaycam/pub/maps/2.jpg", + "isDisplayedHorizontal": false, + "map": { + "boundingbox": { + "min": { + "latitude": 49.378052, + "longitude": -121.341169 + }, + "max": { + "latitude": 49.596374, + "longitude": -121.159832 + } + }, + "imageMap": { + "2": { + "cam": 2, + "shape": "poly", + "coordinates": "105,6,126,30,143,20,120,0", + "latitude": 49.596374, + "longitude": -121.159832 + }, + "281": { + "cam": 281, + "shape": "poly", + "coordinates": "65,245,76,260,99,250,76,223", + "latitude": 49.378052, + "longitude": -121.341169 + }, + "282": { + "cam": 282, + "shape": "poly", + "coordinates": "40,250,62,277,75,260,64,245", + "latitude": 49.378052, + "longitude": -121.341169 + }, + "666": { + "cam": 666, + "shape": "poly", + "coordinates": "98,115,117,116,131,95,87,95", + "latitude": 49.5084162, + "longitude": -121.1989411 + }, + "667": { + "cam": 667, + "shape": "poly", + "coordinates": "82,137,127,136,116,117,98,116", + "latitude": 49.5084162, + "longitude": -121.1989411 + }, + "674": { + "cam": 674, + "shape": "poly", + "coordinates": "79,60,118,60,124,42,79,42", + "latitude": 49.575593, + "longitude": -121.179854 + }, + "675": { + "cam": 675, + "shape": "poly", + "coordinates": "79,84,125,84,117,60,79,59", + "latitude": 49.575593, + "longitude": -121.179854 + }, + "734": { + "cam": 734, + "shape": "poly", + "coordinates": "87,19,111,41,124,31,104,7", + "latitude": 49.596374, + "longitude": -121.159832 + } + } + } + }, + "isNew": false, + "isOnDemand": false, + "imageStats": { + "md5": "cd1bea24b48ec0f134b1c65a89d542af", + "lastAttempt": { + "time": "2023-06-09 16:58:04", + "seconds": 360 + }, + "lastModified": { + "time": "2023-06-09 16:58:04", + "seconds": 360 + }, + "markedStale": false, + "markedDelayed": false, + "updatePeriodMean": 899, + "updatePeriodStdDev": 12, + "fetchMean": 0 + } + } + ] +} \ No newline at end of file diff --git a/src/backend/apps/webcam/tests/test_webcam_api.py b/src/backend/apps/webcam/tests/test_webcam_api.py index 45f089e71..f0b58395e 100644 --- a/src/backend/apps/webcam/tests/test_webcam_api.py +++ b/src/backend/apps/webcam/tests/test_webcam_api.py @@ -1,21 +1,36 @@ import datetime import zoneinfo from unittest import skip - from apps.shared import enums as shared_enums from apps.shared.enums import CacheKey -from apps.shared.tests import BaseTest +from apps.shared.tests import BaseTest, MockResponse from apps.webcam.models import Webcam from apps.webcam.views import WebcamAPI from django.contrib.gis.geos import Point from django.core.cache import cache from rest_framework.test import APITestCase +import json +from pathlib import Path +from httpx import Response +from unittest.mock import patch class TestCameraAPI(APITestCase, BaseTest): def setUp(self): super().setUp() + self.webcam_feed_result = open( + str(Path(__file__).parent) + + "/test_data/webcam_feed_list_of_five.json" + ) + self.mock_webcam_feed_result = json.load(self.webcam_feed_result) + + self.webcam_feed_result_filtered = open( + str(Path(__file__).parent) + + "/test_data/webcam_feed_list_of_one_filtered.json" + ) + self.mock_webcam_feed_result_filtered = json.load(self.webcam_feed_result_filtered) + for i in range(10): Webcam.objects.create( id=i, @@ -77,31 +92,45 @@ def test_cameras_list_caching(self): response = self.client.get(url, {}) assert len(response.data) == 5 - @skip('to be mocked') - def test_cameras_list_filtering(self): + @patch('rest_framework.test.APIClient.get') + def test_cameras_list_filtering(self, mock_requests_get): # No filtering url = "/api/webcams/" - response = self.client.get(url, {}) - assert len(response.data) == 10 - # Manually update location of a camera - cam = Webcam.objects.get(id=1) - # [-123.077455, 49.19547] middle of Knight bridge - cam.location = Point(-123.077455, 49.19547) - cam.save() + mock_requests_get.side_effect = [ + MockResponse(self.mock_webcam_feed_result, status_code=200), + ] + response = self.client.get(url, {}) + assert response.status_code == 200 + webcams_list = response.json().get('webcams', []) + webcams_list_length = len(webcams_list) + assert webcams_list_length == 5 + + mock_requests_get.side_effect = [ + MockResponse(self.mock_webcam_feed_result_filtered, status_code=200), + ] # [-123.0803167, 49.2110127] 1306 SE Marine Dr, Vancouver, BC V5X 4K4 # [-123.0824109, 49.1926452] 2780 Sweden Way, Richmond, BC V6V 2X1 # Filtered cams - hit - point on knight bridge response = self.client.get( url, {'route': '-123.0803167,49.2110127,-123.0824109,49.1926452'} ) - assert len(response.data) == 1 - + assert response.status_code == 200 + webcams_list = response.json().get('webcams', []) + webcams_list_length = len(webcams_list) + assert webcams_list_length == 1 + + mock_requests_get.side_effect = [ + MockResponse({"webcams": []}, status_code=200), + ] # [-123.0803167, 49.2110127] 1306 SE Marine Dr, Vancouver, BC V5X 4K4 # [-123.0188764, 49.205069] 3864 Marine Wy, Burnaby, BC V5J 3H4 # Filtered cams - miss - does not cross knight bridge response = self.client.get( url, {'route': '-123.0803167,49.2110127,-123.0188764,49.205069'} ) - assert len(response.data) == 0 + assert response.status_code == 200 + webcams_list = response.json().get('webcams', []) + webcams_list_length = len(webcams_list) + assert webcams_list_length == 0