From 1c1a6ba3f934f67735aa234f83358ed462689816 Mon Sep 17 00:00:00 2001 From: Levi Szamek Date: Wed, 4 Dec 2024 15:16:10 +0100 Subject: [PATCH 01/76] feat: add integration test for tutorials --- mapswipe_workers/tests/integration/set_up.py | 55 ++++++++++---- .../tests/integration/test_create_tutorial.py | 71 +++++++++++++++++++ 2 files changed, 112 insertions(+), 14 deletions(-) create mode 100644 mapswipe_workers/tests/integration/test_create_tutorial.py diff --git a/mapswipe_workers/tests/integration/set_up.py b/mapswipe_workers/tests/integration/set_up.py index 74adc6fda..1c3c0bdf4 100644 --- a/mapswipe_workers/tests/integration/set_up.py +++ b/mapswipe_workers/tests/integration/set_up.py @@ -16,20 +16,28 @@ def set_firebase_test_data( - project_type: str, data_type: str, fixture_name: str, identifier: str + project_type: str, + data_type: str, + fixture_name: str, + identifier: str, + tutorial_id: str = None, ): test_dir = os.path.dirname(__file__) fixture_name = fixture_name + ".json" file_path = os.path.join( test_dir, "fixtures", project_type, data_type, fixture_name ) - upload_file_to_firebase(file_path, data_type, identifier) + upload_file_to_firebase(file_path, data_type, identifier, tutorial_id=tutorial_id) -def upload_file_to_firebase(file_path: str, data_type: str, identifier: str): +def upload_file_to_firebase( + file_path: str, data_type: str, identifier: str, tutorial_id: str = None +): with open(file_path) as test_file: test_data = json.load(test_file) + if tutorial_id: + test_data["tutorialId"] = tutorial_id fb_db = auth.firebaseDB() ref = fb_db.reference(f"/v2/{data_type}/{identifier}") ref.set(test_data) @@ -85,15 +93,20 @@ def create_test_project( set_postgres_test_data(project_type, "users", "user") set_firebase_test_data(project_type, "user_groups", "user_group", "") set_firebase_test_data(project_type, "results", fixture_name, project_id) - set_postgres_test_data(project_type, "mapping_sessions", fixture_name, columns=[ - "project_id", - "group_id", - "user_id", - "mapping_session_id", - "start_time", - "end_time", - "items_count", - ]) + set_postgres_test_data( + project_type, + "mapping_sessions", + fixture_name, + columns=[ + "project_id", + "group_id", + "user_id", + "mapping_session_id", + "start_time", + "end_time", + "items_count", + ], + ) set_postgres_test_data(project_type, mapping_sessions_results, fixture_name) if create_user_group_session_data: set_postgres_test_data( @@ -108,7 +121,9 @@ def create_test_project( "created_at", ], ) - set_postgres_test_data(project_type, "mapping_sessions_user_groups", fixture_name) + set_postgres_test_data( + project_type, "mapping_sessions_user_groups", fixture_name + ) time.sleep(5) # Wait for Firebase Functions to complete return project_id @@ -131,12 +146,24 @@ def create_test_user(project_type: str, user_id: str = None) -> str: def create_test_project_draft( - project_type: str, fixture_name: str = "user", identifier: str = "" + project_type: str, + fixture_name: str = "user", + identifier: str = "", + tutorial_id: str = None, ) -> str: """ Create test project drafts in Firebase and return project ids. Project drafts in Firebase are created by project manager using the dashboard. """ + if tutorial_id: + set_firebase_test_data( + project_type, + "projectDrafts", + fixture_name, + identifier, + tutorial_id=tutorial_id, + ) + return identifier if not identifier: identifier = f"test_{fixture_name}" set_firebase_test_data(project_type, "projectDrafts", fixture_name, identifier) diff --git a/mapswipe_workers/tests/integration/test_create_tutorial.py b/mapswipe_workers/tests/integration/test_create_tutorial.py new file mode 100644 index 000000000..76327120b --- /dev/null +++ b/mapswipe_workers/tests/integration/test_create_tutorial.py @@ -0,0 +1,71 @@ +import unittest + +from click.testing import CliRunner + +from mapswipe_workers import auth, mapswipe_workers +from mapswipe_workers.utils.create_directories import create_directories +from tests.integration import set_up, tear_down + + +class TestCreateTileClassificationProject(unittest.TestCase): + def setUp(self): + self.tutorial_id = set_up.create_test_tutorial_draft("footprint", "footprint") + + self.project_id = set_up.create_test_project_draft( + "tile_classification", + "tile_classification", + "test_tile_classification_tutorial", + tutorial_id=self.tutorial_id, + ) + create_directories() + + def tearDown(self): + tear_down.delete_test_data(self.project_id) + + def test_create_tile_classification_project(self): + runner = CliRunner() + runner.invoke(mapswipe_workers.run_create_projects, catch_exceptions=False) + + pg_db = auth.postgresDB() + query = "SELECT project_id FROM projects WHERE project_id = %s" + result = pg_db.retr_query(query, [self.project_id])[0][0] + self.assertEqual(result, self.project_id) + + query = """ + SELECT project_id + FROM projects + WHERE project_id = %s + and project_type_specifics::jsonb ? 'customOptions' + """ + result = pg_db.retr_query(query, [self.project_id])[0][0] + self.assertEqual(result, self.project_id) + + query = "SELECT count(*) FROM groups WHERE project_id = %s" + result = pg_db.retr_query(query, [self.project_id])[0][0] + self.assertEqual(result, 20) + + query = "SELECT count(*) FROM tasks WHERE project_id = %s" + result = pg_db.retr_query(query, [self.project_id])[0][0] + self.assertEqual(result, 5040) + + fb_db = auth.firebaseDB() + ref = fb_db.reference(f"/v2/projects/{self.project_id}") + result = ref.get(shallow=True) + self.assertIsNotNone(result) + + ref = fb_db.reference(f"/v2/groups/{self.project_id}") + result = ref.get(shallow=True) + self.assertEqual(len(result), 20) + + # Tile classification projects do not have tasks in Firebase + ref = fb_db.reference(f"/v2/tasks/{self.project_id}") + result = ref.get(shallow=True) + self.assertIsNone(result) + + ref = fb_db.reference(f"/v2/projects/{self.project_id}/tutorialId") + result = ref.get(shallow=True) + self.assertEqual(self.tutorial_id, result) + + +if __name__ == "__main__": + unittest.main() From dadca061437099800b41f9f4af1e3d56ad5ba218 Mon Sep 17 00:00:00 2001 From: Levi Szamek Date: Wed, 4 Dec 2024 15:43:33 +0100 Subject: [PATCH 02/76] fix: delete tutorial draft at end of test --- .../tests/integration/test_create_tutorial.py | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/mapswipe_workers/tests/integration/test_create_tutorial.py b/mapswipe_workers/tests/integration/test_create_tutorial.py index 76327120b..c0904f06b 100644 --- a/mapswipe_workers/tests/integration/test_create_tutorial.py +++ b/mapswipe_workers/tests/integration/test_create_tutorial.py @@ -9,7 +9,11 @@ class TestCreateTileClassificationProject(unittest.TestCase): def setUp(self): - self.tutorial_id = set_up.create_test_tutorial_draft("footprint", "footprint") + self.tutorial_id = set_up.create_test_tutorial_draft( + "tile_classification", + "tile_classification", + "test_tile_classification_tutorial", + ) self.project_id = set_up.create_test_project_draft( "tile_classification", @@ -66,6 +70,8 @@ def test_create_tile_classification_project(self): result = ref.get(shallow=True) self.assertEqual(self.tutorial_id, result) + breakpoint() + if __name__ == "__main__": unittest.main() From 8c9d77402d6f4543af54ecb744680bd65ce97202 Mon Sep 17 00:00:00 2001 From: Levi Szamek Date: Thu, 12 Dec 2024 14:49:57 +0100 Subject: [PATCH 03/76] WIP: add tutorial for street project --- .../mapswipe_workers/definitions.py | 2 + .../project_types/__init__.py | 2 + .../project_types/street/tutorial.py | 80 ++++++++- .../tests/fixtures/projectDrafts/street.json | 1 - .../tests/fixtures/tutorialDrafts/street.json | 170 ++++++++++++++++++ .../integration/test_create_street_project.py | 2 + .../tests/integration/test_create_tutorial.py | 4 +- .../tests/unittests/test_tutorial.py | 11 +- 8 files changed, 259 insertions(+), 13 deletions(-) create mode 100644 mapswipe_workers/tests/fixtures/tutorialDrafts/street.json diff --git a/mapswipe_workers/mapswipe_workers/definitions.py b/mapswipe_workers/mapswipe_workers/definitions.py index c9dec6d79..aa32d3aac 100644 --- a/mapswipe_workers/mapswipe_workers/definitions.py +++ b/mapswipe_workers/mapswipe_workers/definitions.py @@ -170,6 +170,7 @@ def tutorial(self): ClassificationTutorial, CompletenessTutorial, FootprintTutorial, + StreetTutorial, ) project_type_classes = { @@ -177,5 +178,6 @@ def tutorial(self): 2: FootprintTutorial, 3: ChangeDetectionTutorial, 4: CompletenessTutorial, + 7: StreetTutorial, } return project_type_classes[self.value] diff --git a/mapswipe_workers/mapswipe_workers/project_types/__init__.py b/mapswipe_workers/mapswipe_workers/project_types/__init__.py index 43013b0dc..9560c76ef 100644 --- a/mapswipe_workers/mapswipe_workers/project_types/__init__.py +++ b/mapswipe_workers/mapswipe_workers/project_types/__init__.py @@ -3,6 +3,7 @@ from .arbitrary_geometry.footprint.tutorial import FootprintTutorial from .media_classification.project import MediaClassificationProject from .street.project import StreetProject +from .street.tutorial import StreetTutorial from .tile_map_service.change_detection.project import ChangeDetectionProject from .tile_map_service.change_detection.tutorial import ChangeDetectionTutorial from .tile_map_service.classification.project import ClassificationProject @@ -22,4 +23,5 @@ "FootprintTutorial", "DigitizationProject", "StreetProject", + "StreetTutorial", ] diff --git a/mapswipe_workers/mapswipe_workers/project_types/street/tutorial.py b/mapswipe_workers/mapswipe_workers/project_types/street/tutorial.py index cfbfc0ead..ca2c56cbe 100644 --- a/mapswipe_workers/mapswipe_workers/project_types/street/tutorial.py +++ b/mapswipe_workers/mapswipe_workers/project_types/street/tutorial.py @@ -1,14 +1,84 @@ +from dataclasses import asdict, dataclass + +from mapswipe_workers.definitions import logger +from mapswipe_workers.firebase.firebase import Firebase +from mapswipe_workers.project_types.street.project import StreetGroup, StreetTask from mapswipe_workers.project_types.tutorial import BaseTutorial +@dataclass +class StreetTutorialTask(StreetTask): + projectId: int + taskId: str + groupId: int + referenceAnswer: int + screen: int + + class StreetTutorial(BaseTutorial): - """The subclass for an TMS Grid based Tutorial.""" + """The subclass for an arbitrary geometry based Tutorial.""" - def save_tutorial(self): - raise NotImplementedError("Currently Street has no Tutorial") + def __init__(self, tutorial_draft): + # this will create the basis attributes + super().__init__(tutorial_draft) + + # self.projectId = tutorial_draft["projectId"] + self.projectType = tutorial_draft["projectType"] + self.tutorial_tasks = tutorial_draft["tasks"] + self.groups = dict() + self.tasks = dict() def create_tutorial_groups(self): - raise NotImplementedError("Currently Street has no Tutorial") + """Create group for the tutorial based on provided examples in geojson file.""" + # load examples/tasks from file + + group = StreetGroup( + groupId=101, + projectId=self.projectId, + numberOfTasks=len(self.tutorial_tasks), + progress=0, + finishedCount=0, + requiredCount=0, + ) + self.groups[101] = group + + # Add number of tasks for the group here. This needs to be set according to + # the number of features/examples in the geojson file + + logger.info( + f"{self.projectId}" + f" - create_tutorial_groups - " + f"created groups dictionary" + ) def create_tutorial_tasks(self): - raise NotImplementedError("Currently Street has no Tutorial") + """Create the tasks dict based on provided examples in geojson file.""" + task_list = [] + for i, task in enumerate(self.tutorial_tasks): + task = StreetTutorialTask( + projectId=self.projectId, + groupId=101, + taskId=f"{task['taskImageId']}", + geometry="", + referenceAnswer=task["referenceAnswer"], + screen=i, + ) + task_list.append(asdict(task)) + if task_list: + self.tasks[101] = task_list + else: + logger.info(f"group in project {self.projectId} is not valid.") + + logger.info( + f"{self.projectId}" + f" - create_tutorial_tasks - " + f"created tasks dictionary" + ) + + def save_tutorial(self): + firebase = Firebase() + firebase.save_tutorial_to_firebase( + self, self.groups, self.tasks, useCompression=True + ) + logger.info(self.tutorialDraftId) + firebase.drop_tutorial_draft(self.tutorialDraftId) diff --git a/mapswipe_workers/tests/fixtures/projectDrafts/street.json b/mapswipe_workers/tests/fixtures/projectDrafts/street.json index 1dd5b452a..67d1d8b04 100644 --- a/mapswipe_workers/tests/fixtures/projectDrafts/street.json +++ b/mapswipe_workers/tests/fixtures/projectDrafts/street.json @@ -46,6 +46,5 @@ "requestingOrganisation": "test", "verificationNumber": 3, "groupSize": 25, - "startTimestamp": "2019-07-01T00:00:00.000Z", "samplingThreshold": 0.1 } diff --git a/mapswipe_workers/tests/fixtures/tutorialDrafts/street.json b/mapswipe_workers/tests/fixtures/tutorialDrafts/street.json new file mode 100644 index 000000000..22116363b --- /dev/null +++ b/mapswipe_workers/tests/fixtures/tutorialDrafts/street.json @@ -0,0 +1,170 @@ +{ + "createdBy": "LtCUyou6CnSSc1H0Q0nDrN97x892", + "tutorialDraftId": "waste_mapping_dar_es_salaam", + "taskImageIds": [ + 888464808378923, + 1552821322020271, + 2969692853315413, + 1036040467918366, + 837497816845037 + ], + "answers": [ + 1, + 2, + 1, + 2, + 3 + ], + "customOptions": [ + { + "description": "the shape does outline a building in the image", + "icon": "hand-right-outline", + "iconColor": "#00796B", + "subOptions": [ + { + "description": "doppelt", + "value": 2 + }, + { + "description": "dreifach", + "value": 3 + } + ], + "title": "Jetzt rede ich", + "value": 1 + }, + { + "description": "the shape doesn't match a building in the image", + "icon": "close-outline", + "iconColor": "#D32F2F", + "title": "No", + "value": 0 + } + ], + "informationPages": [ + { + "blocks": [ + { + "blockNumber": 1, + "blockType": "text", + "textDescription": "asdf" + }, + { + "blockNumber": 2, + "blockType": "image", + "image": "https://firebasestorage.googleapis.com/v0/b/dev-mapswipe.appspot.com/o/tutorialImages%2F1705402528654-block-image-2-base-query-form.png?alt=media&token=54325ab8-c5e7-45a3-be41-1926a5984a05" + } + ], + "pageNumber": 1, + "title": "asdf" + } + ], + "lookFor": "waste", + "name": "Waste Mapping Dar es Salaam", + "projectType": 7, + "screens": [ + null, + { + "hint": { + "description": "Swipe to learn some more.", + "icon": "swipe-left", + "title": "We've marked the correct square green." + }, + "instructions": { + "description": "Tap once to mark the squares with buildings green.", + "icon": "tap-1", + "title": "There are some buildings in this image" + }, + "success": { + "description": "Swipe to the next screen to look for more.", + "icon": "check", + "title": "You found your first areas with buildings!" + } + }, + { + "hint": { + "description": "Swipe to learn some more.", + "icon": "swipe-left", + "title": "We've marked the correct square green." + }, + "instructions": { + "description": "Tap once to mark the squares with buildings green.", + "icon": "tap-1", + "title": "There are some buildings in this image" + }, + "success": { + "description": "Swipe to the next screen to look for more.", + "icon": "check", + "title": "You found your first" + } + }, + { + "hint": { + "description": "Swipe to learn some more.", + "icon": "swipe-left", + "title": "We've marked the correct square green." + }, + "instructions": { + "description": "Tap once to mark the squares with buildings green.", + "icon": "tap-1", + "title": "There are some buildings in this image" + }, + "success": { + "description": "Swipe to the next screen to look for more.", + "icon": "check", + "title": "You found your first areas with buildings!" + } + }, + { + "hint": { + "description": "Swipe to learn some more.", + "icon": "swipe-left", + "title": "We've marked the correct square green." + }, + "instructions": { + "description": "Tap once to mark the squares with buildings green.", + "icon": "tap-1", + "title": "There are some buildings in this image" + }, + "success": { + "description": "Swipe to the next screen to look for more.", + "icon": "check", + "title": "You found your first areas with buildings!" + } + }, + { + "hint": { + "description": "Swipe to learn some more.", + "icon": "swipe-left", + "title": "We've marked the correct square green." + }, + "instructions": { + "description": "Tap once to mark the squares with buildings green.", + "icon": "tap-1", + "title": "There are some buildings in this image" + }, + "success": { + "description": "Swipe to the next screen to look for more.", + "icon": "check", + "title": "You found your first areas with buildings!" + } + }, + { + "hint": { + "description": "Swipe to learn some more.", + "icon": "swipe-left", + "title": "We've marked the correct square green." + }, + "instructions": { + "description": "Tap once to mark the squares with buildings green.", + "icon": "tap-1", + "title": "There are some buildings in this image" + }, + "success": { + "description": "Swipe to the next screen to look for more.", + "icon": "check", + "title": "You found your first areas with buildings!" + } + } + ] +} \ No newline at end of file diff --git a/mapswipe_workers/tests/integration/test_create_street_project.py b/mapswipe_workers/tests/integration/test_create_street_project.py index fd0608f98..10da2bd0e 100644 --- a/mapswipe_workers/tests/integration/test_create_street_project.py +++ b/mapswipe_workers/tests/integration/test_create_street_project.py @@ -56,6 +56,8 @@ def test_create_street_project(self): result = ref.get(shallow=True) self.assertIsNotNone(result) + breakpoint() + if __name__ == "__main__": unittest.main() diff --git a/mapswipe_workers/tests/integration/test_create_tutorial.py b/mapswipe_workers/tests/integration/test_create_tutorial.py index c0904f06b..5ae13c76d 100644 --- a/mapswipe_workers/tests/integration/test_create_tutorial.py +++ b/mapswipe_workers/tests/integration/test_create_tutorial.py @@ -10,8 +10,8 @@ class TestCreateTileClassificationProject(unittest.TestCase): def setUp(self): self.tutorial_id = set_up.create_test_tutorial_draft( - "tile_classification", - "tile_classification", + "street", + "street", "test_tile_classification_tutorial", ) diff --git a/mapswipe_workers/tests/unittests/test_tutorial.py b/mapswipe_workers/tests/unittests/test_tutorial.py index 5ba1c209a..51a4cbf84 100644 --- a/mapswipe_workers/tests/unittests/test_tutorial.py +++ b/mapswipe_workers/tests/unittests/test_tutorial.py @@ -1,26 +1,27 @@ import os import unittest -from mapswipe_workers.project_types import ClassificationTutorial +from mapswipe_workers.project_types import StreetTutorial from tests.fixtures import FIXTURE_DIR, get_fixture class TestTutorial(unittest.TestCase): def test_init_tile_classification_project(self): tutorial_draft = get_fixture( - os.path.join(FIXTURE_DIR, "tutorialDrafts", "tile_classification.json") + os.path.join(FIXTURE_DIR, "tutorialDrafts", "street.json") ) - self.assertIsNotNone(ClassificationTutorial(tutorial_draft=tutorial_draft)) + self.assertIsNotNone(StreetTutorial(tutorial_draft=tutorial_draft)) def test_create_tile_classification_tasks(self): tutorial_draft = get_fixture( - os.path.join(FIXTURE_DIR, "tutorialDrafts", "tile_classification.json") + os.path.join(FIXTURE_DIR, "tutorialDrafts", "street.json") ) - tutorial = ClassificationTutorial(tutorial_draft=tutorial_draft) + tutorial = StreetTutorial(tutorial_draft=tutorial_draft) tutorial.create_tutorial_groups() tutorial.create_tutorial_tasks() self.assertTrue(tutorial.groups) self.assertTrue(tutorial.tasks) + breakpoint() if __name__ == "__main__": From b8b2c14065ce8e449f5619f9ad2794d19935235d Mon Sep 17 00:00:00 2001 From: Oliver Fritz Date: Wed, 22 Jan 2025 18:10:52 +0100 Subject: [PATCH 04/76] feat(manager-dashboard): Add randomizeOrder input for new Street projects --- manager-dashboard/app/views/NewProject/index.tsx | 8 ++++++++ manager-dashboard/app/views/NewProject/utils.ts | 4 ++++ 2 files changed, 12 insertions(+) diff --git a/manager-dashboard/app/views/NewProject/index.tsx b/manager-dashboard/app/views/NewProject/index.tsx index 9d4acf352..2f88a42ab 100644 --- a/manager-dashboard/app/views/NewProject/index.tsx +++ b/manager-dashboard/app/views/NewProject/index.tsx @@ -109,6 +109,7 @@ const defaultProjectFormValue: PartialProjectFormType = { inputType: PROJECT_INPUT_TYPE_UPLOAD, filter: FILTER_BUILDINGS, isPano: false, + randomizeOrder: false, }; interface Props { @@ -762,6 +763,13 @@ function NewProject(props: Props) { onChange={setFieldValue} disabled={submissionPending || projectTypeEmpty} /> + )} diff --git a/manager-dashboard/app/views/NewProject/utils.ts b/manager-dashboard/app/views/NewProject/utils.ts index 57efed00d..e9ea77e8d 100644 --- a/manager-dashboard/app/views/NewProject/utils.ts +++ b/manager-dashboard/app/views/NewProject/utils.ts @@ -84,6 +84,7 @@ export interface ProjectFormType { organizationId?: number; creatorId?: number; isPano?: boolean; + randomizeOrder?: boolean; samplingThreshold?: number; } @@ -308,6 +309,9 @@ export const projectFormSchema: ProjectFormSchema = { isPano: { required: false, }, + randomizeOrder: { + required: false, + }, }; baseSchema = addCondition( From e2486d9b61d8679000b1fa462706a20e3b1cd5e9 Mon Sep 17 00:00:00 2001 From: Levi Szamek Date: Mon, 27 Jan 2025 11:39:19 +0100 Subject: [PATCH 05/76] fix: remove artifacts from debugging --- mapswipe_workers/tests/unittests/test_tutorial.py | 11 +++++------ 1 file changed, 5 insertions(+), 6 deletions(-) diff --git a/mapswipe_workers/tests/unittests/test_tutorial.py b/mapswipe_workers/tests/unittests/test_tutorial.py index 51a4cbf84..5ba1c209a 100644 --- a/mapswipe_workers/tests/unittests/test_tutorial.py +++ b/mapswipe_workers/tests/unittests/test_tutorial.py @@ -1,27 +1,26 @@ import os import unittest -from mapswipe_workers.project_types import StreetTutorial +from mapswipe_workers.project_types import ClassificationTutorial from tests.fixtures import FIXTURE_DIR, get_fixture class TestTutorial(unittest.TestCase): def test_init_tile_classification_project(self): tutorial_draft = get_fixture( - os.path.join(FIXTURE_DIR, "tutorialDrafts", "street.json") + os.path.join(FIXTURE_DIR, "tutorialDrafts", "tile_classification.json") ) - self.assertIsNotNone(StreetTutorial(tutorial_draft=tutorial_draft)) + self.assertIsNotNone(ClassificationTutorial(tutorial_draft=tutorial_draft)) def test_create_tile_classification_tasks(self): tutorial_draft = get_fixture( - os.path.join(FIXTURE_DIR, "tutorialDrafts", "street.json") + os.path.join(FIXTURE_DIR, "tutorialDrafts", "tile_classification.json") ) - tutorial = StreetTutorial(tutorial_draft=tutorial_draft) + tutorial = ClassificationTutorial(tutorial_draft=tutorial_draft) tutorial.create_tutorial_groups() tutorial.create_tutorial_tasks() self.assertTrue(tutorial.groups) self.assertTrue(tutorial.tasks) - breakpoint() if __name__ == "__main__": From bc2bcd5c3a7b549c23911337a8431e2535e7030b Mon Sep 17 00:00:00 2001 From: Levi Szamek Date: Mon, 27 Jan 2025 15:48:51 +0100 Subject: [PATCH 06/76] feat: use ProcessPoolExecutor for download_and_process_tile --- .../utils/process_mapillary.py | 144 +++++++----------- 1 file changed, 59 insertions(+), 85 deletions(-) diff --git a/mapswipe_workers/mapswipe_workers/utils/process_mapillary.py b/mapswipe_workers/mapswipe_workers/utils/process_mapillary.py index 1faf0b23b..ffec6de8d 100644 --- a/mapswipe_workers/mapswipe_workers/utils/process_mapillary.py +++ b/mapswipe_workers/mapswipe_workers/utils/process_mapillary.py @@ -1,18 +1,11 @@ import os -from concurrent.futures import ThreadPoolExecutor, as_completed +from concurrent.futures import ProcessPoolExecutor +from functools import partial import mercantile import pandas as pd import requests -from shapely import ( - LineString, - MultiLineString, - MultiPolygon, - Point, - Polygon, - box, - unary_union, -) +from shapely import MultiPolygon, Point, Polygon, box, unary_union from shapely.geometry import shape from vt2geojson import tools as vt2geojson_tools @@ -44,7 +37,7 @@ def create_tiles(polygon, level): return tiles -def download_and_process_tile(row, attempt_limit=3): +def download_and_process_tile(row, polygon, kwargs, attempt_limit=3): z = row["z"] x = row["x"] y = row["y"] @@ -59,31 +52,37 @@ def download_and_process_tile(row, attempt_limit=3): "features", [] ) data = [] - for feature in features: - geometry = feature.get("geometry", {}) - properties = feature.get("properties", {}) - geometry_type = geometry.get("type", None) - coordinates = geometry.get("coordinates", []) - - element_geometry = None - if geometry_type == "Point": - element_geometry = Point(coordinates) - elif geometry_type == "LineString": - element_geometry = LineString(coordinates) - elif geometry_type == "MultiLineString": - element_geometry = MultiLineString(coordinates) - elif geometry_type == "Polygon": - element_geometry = Polygon(coordinates[0]) - elif geometry_type == "MultiPolygon": - element_geometry = MultiPolygon(coordinates) - - # Append the dictionary with geometry and properties - row = {"geometry": element_geometry, **properties} - data.append(row) + data.extend( + [ + { + "geometry": Point(feature["geometry"]["coordinates"]), + **feature.get("properties", {}), + } + for feature in features + if feature.get("geometry", {}).get("type") == "Point" + ] + ) data = pd.DataFrame(data) - if not data.empty: + if data.isna().all().all() is False or data.empty is False: + data = data[data["geometry"].apply(lambda point: point.within(polygon))] + target_columns = [ + "id", + "geometry", + "captured_at", + "is_pano", + "compass_angle", + "sequence", + "organization_id", + ] + for col in target_columns: + if col not in data.columns: + data[col] = None + + if data.isna().all().all() is False or data.empty is False: + data = filter_results(data, **kwargs) + return data except Exception as e: print(f"An exception occurred while requesting a tile: {e}") @@ -94,7 +93,7 @@ def download_and_process_tile(row, attempt_limit=3): def coordinate_download( - polygon, level, use_concurrency=True, attempt_limit=3, workers=os.cpu_count() * 4 + polygon, level, kwargs: dict, use_concurrency=True, workers=os.cpu_count() * 4 ): tiles = create_tiles(polygon, level) @@ -104,45 +103,22 @@ def coordinate_download( if not use_concurrency: workers = 1 - futures = [] - with ThreadPoolExecutor(max_workers=workers) as executor: - for index, row in tiles.iterrows(): - futures.append( - executor.submit(download_and_process_tile, row, attempt_limit) - ) - - for future in as_completed(futures): - if future is not None: - df = future.result() + process_tile_with_args = partial( + download_and_process_tile, polygon=polygon, kwargs=kwargs + ) + with ProcessPoolExecutor(max_workers=workers) as executor: + futures = list( + executor.map(process_tile_with_args, tiles.to_dict(orient="records")) + ) - if df is not None and not df.empty: - downloaded_metadata.append(df) + for df in futures: + if df is not None and not df.empty: + downloaded_metadata.append(df) if len(downloaded_metadata): downloaded_metadata = pd.concat(downloaded_metadata, ignore_index=True) else: return pd.DataFrame(downloaded_metadata) - target_columns = [ - "id", - "geometry", - "captured_at", - "is_pano", - "compass_angle", - "sequence", - "organization_id", - ] - for col in target_columns: - if col not in downloaded_metadata.columns: - downloaded_metadata[col] = None - if ( - downloaded_metadata.isna().all().all() is False - or downloaded_metadata.empty is False - ): - downloaded_metadata = downloaded_metadata[ - downloaded_metadata["geometry"].apply( - lambda point: point.within(polygon) - ) - ] return downloaded_metadata @@ -198,13 +174,12 @@ def filter_results( ) return None df = df[df["creator_id"] == creator_id] - if is_pano is not None: if df["is_pano"].isna().all(): + print(df) logger.exception("No Mapillary Feature in the AoI has a 'is_pano' value.") return None df = df[df["is_pano"] == is_pano] - if organization_id is not None: if df["organization_id"].isna().all(): logger.exception( @@ -212,7 +187,6 @@ def filter_results( ) return None df = df[df["organization_id"] == organization_id] - if start_time is not None: if df["captured_at"].isna().all(): logger.exception( @@ -220,14 +194,12 @@ def filter_results( ) return None df = filter_by_timerange(df, start_time, end_time) - return df def get_image_metadata( aoi_geojson, level=14, - attempt_limit=3, is_pano: bool = None, creator_id: int = None, organization_id: str = None, @@ -235,9 +207,15 @@ def get_image_metadata( end_time: str = None, sampling_threshold=None, ): + kwargs = { + "is_pano": is_pano, + "creator_id": creator_id, + "organization_id": organization_id, + "start_time": start_time, + "end_time": end_time, + } aoi_polygon = geojson_to_polygon(aoi_geojson) - downloaded_metadata = coordinate_download(aoi_polygon, level, attempt_limit) - + downloaded_metadata = coordinate_download(aoi_polygon, level, kwargs) if downloaded_metadata.empty or downloaded_metadata.isna().all().all(): raise ValueError("No Mapillary Features in the AoI.") @@ -245,27 +223,23 @@ def get_image_metadata( downloaded_metadata["geometry"].apply(lambda geom: isinstance(geom, Point)) ] - filtered_metadata = filter_results( - downloaded_metadata, creator_id, is_pano, organization_id, start_time, end_time - ) - if ( - filtered_metadata is None - or filtered_metadata.empty - or filtered_metadata.isna().all().all() + downloaded_metadata is None + or downloaded_metadata.empty + or downloaded_metadata.isna().all().all() ): raise ValueError("No Mapillary Features in the AoI match the filter criteria.") if sampling_threshold is not None: - filtered_metadata = spatial_sampling(filtered_metadata, sampling_threshold) + downloaded_metadata = spatial_sampling(downloaded_metadata, sampling_threshold) - total_images = len(filtered_metadata) + total_images = len(downloaded_metadata) if total_images > 100000: raise ValueError( f"Too many Images with selected filter options for the AoI: {total_images}" ) return { - "ids": filtered_metadata["id"].tolist(), - "geometries": filtered_metadata["geometry"].tolist(), + "ids": downloaded_metadata["id"].tolist(), + "geometries": downloaded_metadata["geometry"].tolist(), } From c3dca086d7a76b41c62a8f7893d4b6d5ad0666c3 Mon Sep 17 00:00:00 2001 From: Oliver Fritz Date: Mon, 27 Jan 2025 15:53:35 +0100 Subject: [PATCH 07/76] fix(street): get_image_metadata() error handling --- .../mapswipe_workers/utils/process_mapillary.py | 13 +++++++++---- 1 file changed, 9 insertions(+), 4 deletions(-) diff --git a/mapswipe_workers/mapswipe_workers/utils/process_mapillary.py b/mapswipe_workers/mapswipe_workers/utils/process_mapillary.py index 1faf0b23b..25213faf6 100644 --- a/mapswipe_workers/mapswipe_workers/utils/process_mapillary.py +++ b/mapswipe_workers/mapswipe_workers/utils/process_mapillary.py @@ -16,7 +16,12 @@ from shapely.geometry import shape from vt2geojson import tools as vt2geojson_tools -from mapswipe_workers.definitions import MAPILLARY_API_KEY, MAPILLARY_API_LINK, logger +from mapswipe_workers.definitions import ( + MAPILLARY_API_KEY, + MAPILLARY_API_LINK, + CustomError, + logger, +) from mapswipe_workers.utils.spatial_sampling import spatial_sampling @@ -239,7 +244,7 @@ def get_image_metadata( downloaded_metadata = coordinate_download(aoi_polygon, level, attempt_limit) if downloaded_metadata.empty or downloaded_metadata.isna().all().all(): - raise ValueError("No Mapillary Features in the AoI.") + raise CustomError("No Mapillary Features in the AoI.") downloaded_metadata = downloaded_metadata[ downloaded_metadata["geometry"].apply(lambda geom: isinstance(geom, Point)) @@ -254,14 +259,14 @@ def get_image_metadata( or filtered_metadata.empty or filtered_metadata.isna().all().all() ): - raise ValueError("No Mapillary Features in the AoI match the filter criteria.") + raise CustomError("No Mapillary Features in the AoI match the filter criteria.") if sampling_threshold is not None: filtered_metadata = spatial_sampling(filtered_metadata, sampling_threshold) total_images = len(filtered_metadata) if total_images > 100000: - raise ValueError( + raise CustomError( f"Too many Images with selected filter options for the AoI: {total_images}" ) From 6e785980d71656171d983249dd969a5cd7a43fef Mon Sep 17 00:00:00 2001 From: Levi Szamek Date: Mon, 27 Jan 2025 16:27:16 +0100 Subject: [PATCH 08/76] fix: remove articat from debugging --- mapswipe_workers/tests/integration/test_create_tutorial.py | 6 ++---- 1 file changed, 2 insertions(+), 4 deletions(-) diff --git a/mapswipe_workers/tests/integration/test_create_tutorial.py b/mapswipe_workers/tests/integration/test_create_tutorial.py index 5ae13c76d..b61eb182e 100644 --- a/mapswipe_workers/tests/integration/test_create_tutorial.py +++ b/mapswipe_workers/tests/integration/test_create_tutorial.py @@ -10,8 +10,8 @@ class TestCreateTileClassificationProject(unittest.TestCase): def setUp(self): self.tutorial_id = set_up.create_test_tutorial_draft( - "street", - "street", + "tile_classification", + "tile_classification", "test_tile_classification_tutorial", ) @@ -70,8 +70,6 @@ def test_create_tile_classification_project(self): result = ref.get(shallow=True) self.assertEqual(self.tutorial_id, result) - breakpoint() - if __name__ == "__main__": unittest.main() From 538c763ba882b8cfc14c4efc4660472129823215 Mon Sep 17 00:00:00 2001 From: Oliver Fritz Date: Mon, 27 Jan 2025 16:33:55 +0100 Subject: [PATCH 09/76] fix(street): adjust tests --- mapswipe_workers/tests/unittests/test_process_mapillary.py | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/mapswipe_workers/tests/unittests/test_process_mapillary.py b/mapswipe_workers/tests/unittests/test_process_mapillary.py index 32c1bad46..573a259f9 100644 --- a/mapswipe_workers/tests/unittests/test_process_mapillary.py +++ b/mapswipe_workers/tests/unittests/test_process_mapillary.py @@ -7,6 +7,7 @@ from shapely import wkt from shapely.geometry import GeometryCollection, MultiPolygon, Point, Polygon +from mapswipe_workers.definitions import CustomError from mapswipe_workers.utils.process_mapillary import ( coordinate_download, create_tiles, @@ -326,7 +327,7 @@ def test_get_image_metadata_no_rows(self, mock_coordinate_download): "start_time": "1916-01-20 00:00:00", "end_time": "1922-01-21 23:59:59", } - with self.assertRaises(ValueError): + with self.assertRaises(CustomError): get_image_metadata(self.fixture_data, **params) @patch("mapswipe_workers.utils.process_mapillary.coordinate_download") @@ -335,7 +336,7 @@ def test_get_image_metadata_empty_response(self, mock_coordinate_download): df = df.drop(df.index) mock_coordinate_download.return_value = df - with self.assertRaises(ValueError): + with self.assertRaises(CustomError): get_image_metadata(self.fixture_data) @patch("mapswipe_workers.utils.process_mapillary.filter_results") @@ -346,7 +347,7 @@ def test_get_image_metadata_size_restriction( mock_filter_results.return_value = pd.DataFrame({"ID": range(1, 100002)}) mock_coordinate_download.return_value = self.fixture_df - with self.assertRaises(ValueError): + with self.assertRaises(CustomError): get_image_metadata(self.fixture_data) From d4dd6a0655dec987e98877c39a5099683ba8b8e2 Mon Sep 17 00:00:00 2001 From: Levi Szamek Date: Tue, 28 Jan 2025 13:36:17 +0100 Subject: [PATCH 10/76] refactor: extract functions for improved testing --- .../utils/process_mapillary.py | 74 ++++++++++--------- 1 file changed, 39 insertions(+), 35 deletions(-) diff --git a/mapswipe_workers/mapswipe_workers/utils/process_mapillary.py b/mapswipe_workers/mapswipe_workers/utils/process_mapillary.py index ffec6de8d..a8f7f62f6 100644 --- a/mapswipe_workers/mapswipe_workers/utils/process_mapillary.py +++ b/mapswipe_workers/mapswipe_workers/utils/process_mapillary.py @@ -46,25 +46,7 @@ def download_and_process_tile(row, polygon, kwargs, attempt_limit=3): attempt = 0 while attempt < attempt_limit: try: - r = requests.get(url) - assert r.status_code == 200, r.content - features = vt2geojson_tools.vt_bytes_to_geojson(r.content, x, y, z).get( - "features", [] - ) - data = [] - data.extend( - [ - { - "geometry": Point(feature["geometry"]["coordinates"]), - **feature.get("properties", {}), - } - for feature in features - if feature.get("geometry", {}).get("type") == "Point" - ] - ) - - data = pd.DataFrame(data) - + data = get_mapillary_data(url, x, y, z) if data.isna().all().all() is False or data.empty is False: data = data[data["geometry"].apply(lambda point: point.within(polygon))] target_columns = [ @@ -79,7 +61,6 @@ def download_and_process_tile(row, polygon, kwargs, attempt_limit=3): for col in target_columns: if col not in data.columns: data[col] = None - if data.isna().all().all() is False or data.empty is False: data = filter_results(data, **kwargs) @@ -92,6 +73,26 @@ def download_and_process_tile(row, polygon, kwargs, attempt_limit=3): return None +def get_mapillary_data(url, x, y, z): + r = requests.get(url) + assert r.status_code == 200, r.content + features = vt2geojson_tools.vt_bytes_to_geojson(r.content, x, y, z).get( + "features", [] + ) + data = [] + data.extend( + [ + { + "geometry": Point(feature["geometry"]["coordinates"]), + **feature.get("properties", {}), + } + for feature in features + if feature.get("geometry", {}).get("type") == "Point" + ] + ) + return pd.DataFrame(data) + + def coordinate_download( polygon, level, kwargs: dict, use_concurrency=True, workers=os.cpu_count() * 4 ): @@ -103,18 +104,11 @@ def coordinate_download( if not use_concurrency: workers = 1 - process_tile_with_args = partial( - download_and_process_tile, polygon=polygon, kwargs=kwargs + downloaded_metadata = parallelized_processing( + downloaded_metadata, kwargs, polygon, tiles, workers ) - with ProcessPoolExecutor(max_workers=workers) as executor: - futures = list( - executor.map(process_tile_with_args, tiles.to_dict(orient="records")) - ) - - for df in futures: - if df is not None and not df.empty: - downloaded_metadata.append(df) if len(downloaded_metadata): + breakpoint() downloaded_metadata = pd.concat(downloaded_metadata, ignore_index=True) else: return pd.DataFrame(downloaded_metadata) @@ -122,6 +116,21 @@ def coordinate_download( return downloaded_metadata +def parallelized_processing(data, kwargs, polygon, tiles, workers): + process_tile_with_args = partial( + download_and_process_tile, polygon=polygon, kwargs=kwargs + ) + with ProcessPoolExecutor(max_workers=workers) as executor: + futures = list( + executor.map(process_tile_with_args, tiles.to_dict(orient="records")) + ) + + for df in futures: + if df is not None and not df.empty: + data.append(df) + return data + + def geojson_to_polygon(geojson_data): if geojson_data["type"] == "FeatureCollection": features = geojson_data["features"] @@ -176,7 +185,6 @@ def filter_results( df = df[df["creator_id"] == creator_id] if is_pano is not None: if df["is_pano"].isna().all(): - print(df) logger.exception("No Mapillary Feature in the AoI has a 'is_pano' value.") return None df = df[df["is_pano"] == is_pano] @@ -219,10 +227,6 @@ def get_image_metadata( if downloaded_metadata.empty or downloaded_metadata.isna().all().all(): raise ValueError("No Mapillary Features in the AoI.") - downloaded_metadata = downloaded_metadata[ - downloaded_metadata["geometry"].apply(lambda geom: isinstance(geom, Point)) - ] - if ( downloaded_metadata is None or downloaded_metadata.empty From 87d65cb895a3551dfa7cd66807d8c561f62bfca3 Mon Sep 17 00:00:00 2001 From: Levi Szamek Date: Tue, 28 Jan 2025 13:37:47 +0100 Subject: [PATCH 11/76] fix: adapt tests to new multiprocessing --- .../tests/unittests/test_process_mapillary.py | 62 +++++-------------- 1 file changed, 16 insertions(+), 46 deletions(-) diff --git a/mapswipe_workers/tests/unittests/test_process_mapillary.py b/mapswipe_workers/tests/unittests/test_process_mapillary.py index 32c1bad46..9fd1b4d1c 100644 --- a/mapswipe_workers/tests/unittests/test_process_mapillary.py +++ b/mapswipe_workers/tests/unittests/test_process_mapillary.py @@ -53,6 +53,7 @@ def setUp(self): ) self.empty_polygon = Polygon() self.empty_geometry = GeometryCollection() + self.row = pd.Series({"x": 1, "y": 1, "z": self.level}) def test_create_tiles_with_valid_polygon(self): tiles = create_tiles(self.test_polygon, self.level) @@ -171,26 +172,26 @@ def test_download_and_process_tile_success(self, mock_get, mock_vt2geojson): row = {"x": 1, "y": 1, "z": 14} - result = download_and_process_tile(row) + polygon = wkt.loads("POLYGON ((-1 -1, -1 1, 1 1, 1 -1, -1 -1))") + result = download_and_process_tile(row, polygon, {}) self.assertIsInstance(result, pd.DataFrame) self.assertEqual(len(result), 1) self.assertEqual(result["geometry"][0].wkt, "POINT (0 0)") @patch("mapswipe_workers.utils.process_mapillary.requests.get") def test_download_and_process_tile_failure(self, mock_get): - # Mock a failed response + mock_response = MagicMock() mock_response.status_code = 500 mock_get.return_value = mock_response - row = pd.Series({"x": 1, "y": 1, "z": self.level}) - result = download_and_process_tile(row) + result = download_and_process_tile(self.row, self.test_polygon, {}) self.assertIsNone(result) - @patch("mapswipe_workers.utils.process_mapillary.download_and_process_tile") - def test_coordinate_download(self, mock_download_and_process_tile): + @patch("mapswipe_workers.utils.process_mapillary.get_mapillary_data") + def test_download_and_process_tile_spatial_filtering(self, mock_get_mapillary_data): inside_points = [ (0.2, 0.2), (0.5, 0.5), @@ -208,20 +209,20 @@ def test_coordinate_download(self, mock_download_and_process_tile): for x, y in points ] - mock_download_and_process_tile.return_value = pd.DataFrame(data) + mock_get_mapillary_data.return_value = pd.DataFrame(data) - metadata = coordinate_download(self.test_polygon, self.level) + metadata = download_and_process_tile(self.row, self.test_polygon, {}) metadata = metadata.drop_duplicates() self.assertEqual(len(metadata), len(inside_points)) self.assertIsInstance(metadata, pd.DataFrame) - @patch("mapswipe_workers.utils.process_mapillary.download_and_process_tile") - def test_coordinate_download_with_failures(self, mock_download_and_process_tile): - mock_download_and_process_tile.return_value = pd.DataFrame() + @patch("mapswipe_workers.utils.process_mapillary.parallelized_processing") + def test_coordinate_download_with_failures(self, mock_parallelized_processing): + mock_parallelized_processing.return_value = pd.DataFrame() - metadata = coordinate_download(self.test_polygon, self.level) + metadata = coordinate_download(self.test_polygon, self.level, {}) self.assertTrue(metadata.empty) @@ -284,7 +285,7 @@ def test_filter_missing_columns(self): "is_pano", "organization_id", "captured_at", - ] # Add your column names here + ] for column in columns_to_check: df_copy = self.fixture_df.copy() df_copy[column] = None @@ -302,33 +303,6 @@ def test_get_image_metadata(self, mock_coordinate_download): self.assertIn("ids", result) self.assertIn("geometries", result) - @patch("mapswipe_workers.utils.process_mapillary.coordinate_download") - def test_get_image_metadata_filtering(self, mock_coordinate_download): - mock_coordinate_download.return_value = self.fixture_df - - params = { - "is_pano": True, - "start_time": "2016-01-20 00:00:00", - "end_time": "2022-01-21 23:59:59", - } - - result = get_image_metadata(self.fixture_data, **params) - self.assertIsInstance(result, dict) - self.assertIn("ids", result) - self.assertIn("geometries", result) - - @patch("mapswipe_workers.utils.process_mapillary.coordinate_download") - def test_get_image_metadata_no_rows(self, mock_coordinate_download): - mock_coordinate_download.return_value = self.fixture_df - - params = { - "is_pano": True, - "start_time": "1916-01-20 00:00:00", - "end_time": "1922-01-21 23:59:59", - } - with self.assertRaises(ValueError): - get_image_metadata(self.fixture_data, **params) - @patch("mapswipe_workers.utils.process_mapillary.coordinate_download") def test_get_image_metadata_empty_response(self, mock_coordinate_download): df = self.fixture_df.copy() @@ -338,13 +312,9 @@ def test_get_image_metadata_empty_response(self, mock_coordinate_download): with self.assertRaises(ValueError): get_image_metadata(self.fixture_data) - @patch("mapswipe_workers.utils.process_mapillary.filter_results") @patch("mapswipe_workers.utils.process_mapillary.coordinate_download") - def test_get_image_metadata_size_restriction( - self, mock_coordinate_download, mock_filter_results - ): - mock_filter_results.return_value = pd.DataFrame({"ID": range(1, 100002)}) - mock_coordinate_download.return_value = self.fixture_df + def test_get_image_metadata_size_restriction(self, mock_coordinate_download): + mock_coordinate_download.return_value = pd.DataFrame({"ID": range(1, 100002)}) with self.assertRaises(ValueError): get_image_metadata(self.fixture_data) From 41d641363d7eecf78acc6d8b8e111e7ea5fe2111 Mon Sep 17 00:00:00 2001 From: Levi Szamek Date: Tue, 28 Jan 2025 13:58:59 +0100 Subject: [PATCH 12/76] fix: remove debugging artifact --- mapswipe_workers/mapswipe_workers/utils/process_mapillary.py | 1 - 1 file changed, 1 deletion(-) diff --git a/mapswipe_workers/mapswipe_workers/utils/process_mapillary.py b/mapswipe_workers/mapswipe_workers/utils/process_mapillary.py index a8f7f62f6..b2dd3c76c 100644 --- a/mapswipe_workers/mapswipe_workers/utils/process_mapillary.py +++ b/mapswipe_workers/mapswipe_workers/utils/process_mapillary.py @@ -108,7 +108,6 @@ def coordinate_download( downloaded_metadata, kwargs, polygon, tiles, workers ) if len(downloaded_metadata): - breakpoint() downloaded_metadata = pd.concat(downloaded_metadata, ignore_index=True) else: return pd.DataFrame(downloaded_metadata) From 23b654e569e0a89ea552738cd6d29192841b2d4f Mon Sep 17 00:00:00 2001 From: Levi Szamek Date: Tue, 28 Jan 2025 17:22:42 +0100 Subject: [PATCH 13/76] fix: remove debugging artifact --- .../tests/integration/test_create_street_project.py | 2 -- 1 file changed, 2 deletions(-) diff --git a/mapswipe_workers/tests/integration/test_create_street_project.py b/mapswipe_workers/tests/integration/test_create_street_project.py index 10da2bd0e..fd0608f98 100644 --- a/mapswipe_workers/tests/integration/test_create_street_project.py +++ b/mapswipe_workers/tests/integration/test_create_street_project.py @@ -56,8 +56,6 @@ def test_create_street_project(self): result = ref.get(shallow=True) self.assertIsNotNone(result) - breakpoint() - if __name__ == "__main__": unittest.main() From 7899bfbb4200ebdbea357944511b3a10cd28519e Mon Sep 17 00:00:00 2001 From: Levi Szamek Date: Tue, 28 Jan 2025 18:02:42 +0100 Subject: [PATCH 14/76] refactor: use logger.info instead of logger.exception for missing values --- .../mapswipe_workers/utils/process_mapillary.py | 12 ++++-------- 1 file changed, 4 insertions(+), 8 deletions(-) diff --git a/mapswipe_workers/mapswipe_workers/utils/process_mapillary.py b/mapswipe_workers/mapswipe_workers/utils/process_mapillary.py index 25213faf6..6c866e43d 100644 --- a/mapswipe_workers/mapswipe_workers/utils/process_mapillary.py +++ b/mapswipe_workers/mapswipe_workers/utils/process_mapillary.py @@ -198,21 +198,19 @@ def filter_results( df = results_df.copy() if creator_id is not None: if df["creator_id"].isna().all(): - logger.exception( - "No Mapillary Feature in the AoI has a 'creator_id' value." - ) + logger.info("No Mapillary Feature in the AoI has a 'creator_id' value.") return None df = df[df["creator_id"] == creator_id] if is_pano is not None: if df["is_pano"].isna().all(): - logger.exception("No Mapillary Feature in the AoI has a 'is_pano' value.") + logger.info("No Mapillary Feature in the AoI has a 'is_pano' value.") return None df = df[df["is_pano"] == is_pano] if organization_id is not None: if df["organization_id"].isna().all(): - logger.exception( + logger.info( "No Mapillary Feature in the AoI has an 'organization_id' value." ) return None @@ -220,9 +218,7 @@ def filter_results( if start_time is not None: if df["captured_at"].isna().all(): - logger.exception( - "No Mapillary Feature in the AoI has a 'captured_at' value." - ) + logger.info("No Mapillary Feature in the AoI has a 'captured_at' value.") return None df = filter_by_timerange(df, start_time, end_time) From 4d7790bf02b5ee5a342767e09e4854a03ee8fd8f Mon Sep 17 00:00:00 2001 From: Levi Szamek Date: Thu, 30 Jan 2025 14:09:13 +0100 Subject: [PATCH 15/76] fix: remove unnecessary check for empty dataframe --- .../mapswipe_workers/utils/process_mapillary.py | 11 +++-------- 1 file changed, 3 insertions(+), 8 deletions(-) diff --git a/mapswipe_workers/mapswipe_workers/utils/process_mapillary.py b/mapswipe_workers/mapswipe_workers/utils/process_mapillary.py index b2dd3c76c..a87c1970b 100644 --- a/mapswipe_workers/mapswipe_workers/utils/process_mapillary.py +++ b/mapswipe_workers/mapswipe_workers/utils/process_mapillary.py @@ -224,14 +224,9 @@ def get_image_metadata( aoi_polygon = geojson_to_polygon(aoi_geojson) downloaded_metadata = coordinate_download(aoi_polygon, level, kwargs) if downloaded_metadata.empty or downloaded_metadata.isna().all().all(): - raise ValueError("No Mapillary Features in the AoI.") - - if ( - downloaded_metadata is None - or downloaded_metadata.empty - or downloaded_metadata.isna().all().all() - ): - raise ValueError("No Mapillary Features in the AoI match the filter criteria.") + raise ValueError( + "No Mapillary Features in the AoI or no Features match the filter criteria." + ) if sampling_threshold is not None: downloaded_metadata = spatial_sampling(downloaded_metadata, sampling_threshold) From 916d4f703b5a9c3731b004eb592f904b69d2cff4 Mon Sep 17 00:00:00 2001 From: Levi Szamek Date: Thu, 30 Jan 2025 14:21:49 +0100 Subject: [PATCH 16/76] feat: drop duplicated images at exact same location --- .../utils/process_mapillary.py | 2 +- .../tests/unittests/test_process_mapillary.py | 19 ++++++++++++++++++- 2 files changed, 19 insertions(+), 2 deletions(-) diff --git a/mapswipe_workers/mapswipe_workers/utils/process_mapillary.py b/mapswipe_workers/mapswipe_workers/utils/process_mapillary.py index a87c1970b..ac06f435e 100644 --- a/mapswipe_workers/mapswipe_workers/utils/process_mapillary.py +++ b/mapswipe_workers/mapswipe_workers/utils/process_mapillary.py @@ -227,7 +227,7 @@ def get_image_metadata( raise ValueError( "No Mapillary Features in the AoI or no Features match the filter criteria." ) - + downloaded_metadata = downloaded_metadata.drop_duplicates(subset=["geometry"]) if sampling_threshold is not None: downloaded_metadata = spatial_sampling(downloaded_metadata, sampling_threshold) diff --git a/mapswipe_workers/tests/unittests/test_process_mapillary.py b/mapswipe_workers/tests/unittests/test_process_mapillary.py index 9fd1b4d1c..e6894fae1 100644 --- a/mapswipe_workers/tests/unittests/test_process_mapillary.py +++ b/mapswipe_workers/tests/unittests/test_process_mapillary.py @@ -314,11 +314,28 @@ def test_get_image_metadata_empty_response(self, mock_coordinate_download): @patch("mapswipe_workers.utils.process_mapillary.coordinate_download") def test_get_image_metadata_size_restriction(self, mock_coordinate_download): - mock_coordinate_download.return_value = pd.DataFrame({"ID": range(1, 100002)}) + mock_coordinate_download.return_value = pd.DataFrame( + {"geometry": range(1, 100002)} + ) with self.assertRaises(ValueError): get_image_metadata(self.fixture_data) + @patch("mapswipe_workers.utils.process_mapillary.coordinate_download") + def test_get_image_metadata_drop_duplicates(self, mock_coordinate_download): + test_df = pd.DataFrame( + { + "id": [1, 2, 2, 3, 4, 4, 5], + "geometry": ["a", "b", "b", "c", "d", "d", "e"], + } + ) + mock_coordinate_download.return_value = test_df + return_dict = get_image_metadata(self.fixture_data) + + return_df = pd.DataFrame(return_dict) + + self.assertNotEqual(len(return_df), len(test_df)) + if __name__ == "__main__": unittest.main() From dedd45506f38bfcf96f7c50ee314180778284fe8 Mon Sep 17 00:00:00 2001 From: Oliver Fritz Date: Thu, 6 Feb 2025 16:07:14 +0100 Subject: [PATCH 17/76] feat(manager-dashboard): show custom options input for street tutorials --- manager-dashboard/app/views/NewTutorial/index.tsx | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/manager-dashboard/app/views/NewTutorial/index.tsx b/manager-dashboard/app/views/NewTutorial/index.tsx index 76f238066..a6fc640ce 100644 --- a/manager-dashboard/app/views/NewTutorial/index.tsx +++ b/manager-dashboard/app/views/NewTutorial/index.tsx @@ -69,6 +69,7 @@ import { PROJECT_TYPE_COMPLETENESS, PROJECT_TYPE_CHANGE_DETECTION, PROJECT_TYPE_FOOTPRINT, + PROJECT_TYPE_STREET, ProjectType, projectTypeLabelMap, } from '#utils/common'; @@ -761,7 +762,10 @@ function NewTutorial(props: Props) { autoFocus /> - {value.projectType === PROJECT_TYPE_FOOTPRINT && ( + {( + value.projectType === PROJECT_TYPE_FOOTPRINT + || value.projectType === PROJECT_TYPE_STREET + ) && ( Date: Thu, 6 Feb 2025 16:58:11 +0100 Subject: [PATCH 18/76] feat(manager-dashboard): handle default custom options on project type change --- .../app/views/NewTutorial/index.tsx | 4 +- .../app/views/NewTutorial/utils.ts | 40 +++++++++++++++++++ 2 files changed, 42 insertions(+), 2 deletions(-) diff --git a/manager-dashboard/app/views/NewTutorial/index.tsx b/manager-dashboard/app/views/NewTutorial/index.tsx index a6fc640ce..9ce9d4b67 100644 --- a/manager-dashboard/app/views/NewTutorial/index.tsx +++ b/manager-dashboard/app/views/NewTutorial/index.tsx @@ -77,7 +77,7 @@ import { import { tileServerUrls, tutorialFormSchema, - defaultFootprintCustomOptions, + getDefaultOptions, TutorialFormType, PartialTutorialFormType, PartialInformationPagesType, @@ -342,7 +342,6 @@ const defaultTutorialFormValue: PartialTutorialFormType = { name: TILE_SERVER_ESRI, credits: tileServerDefaultCredits[TILE_SERVER_ESRI], }, - customOptions: defaultFootprintCustomOptions, }; type SubmissionStatus = 'started' | 'imageUpload' | 'tutorialSubmit' | 'success' | 'failed'; @@ -717,6 +716,7 @@ function NewTutorial(props: Props) { setFieldValue(undefined, 'tutorialTasks'); setFieldValue(undefined, 'scenarioPages'); setFieldValue(newValue, 'projectType'); + setFieldValue(getDefaultOptions(newValue), 'customOptions'); }, [setFieldValue], ); diff --git a/manager-dashboard/app/views/NewTutorial/utils.ts b/manager-dashboard/app/views/NewTutorial/utils.ts index 90b805f5d..4fc6deebf 100644 --- a/manager-dashboard/app/views/NewTutorial/utils.ts +++ b/manager-dashboard/app/views/NewTutorial/utils.ts @@ -26,6 +26,7 @@ import { PROJECT_TYPE_CHANGE_DETECTION, PROJECT_TYPE_COMPLETENESS, PROJECT_TYPE_FOOTPRINT, + PROJECT_TYPE_STREET, IconKey, } from '#utils/common'; @@ -257,6 +258,33 @@ export const defaultFootprintCustomOptions: PartialTutorialFormType['customOptio }, ]; +export const defaultStreetCustomOptions: PartialTutorialFormType['customOptions'] = [ + { + optionId: 1, + value: 1, + title: 'Yes', + icon: 'checkmark-outline', + iconColor: colorKeyToColorMap.green, + description: '', + }, + { + optionId: 2, + value: 0, + title: 'No', + icon: 'close-outline', + iconColor: colorKeyToColorMap.red, + description: '', + }, + { + optionId: 3, + value: 2, + title: 'Not Sure', + icon: 'remove-outline', + iconColor: colorKeyToColorMap.gray, + description: 'if you\'re not sure or there is bad imagery', + }, +]; + export function deleteKey( value: T, key: K, @@ -268,6 +296,18 @@ export function deleteKey( return copy; } +export function getDefaultOptions(projectType: ProjectType | undefined) { + if (projectType === PROJECT_TYPE_FOOTPRINT) { + return defaultFootprintCustomOptions; + } + + if (projectType === PROJECT_TYPE_STREET) { + return defaultStreetCustomOptions; + } + + return undefined; +} + export interface BuildAreaProperties { reference: number; screen: number; From 3826747f7ac486fe3aa64dfdd7dceb7042a3bf50 Mon Sep 17 00:00:00 2001 From: Oliver Fritz Date: Thu, 6 Feb 2025 17:06:50 +0100 Subject: [PATCH 19/76] feat(manager-dashboard): hide tileserver input for street tutorials --- .../app/views/NewTutorial/index.tsx | 30 ++++++++++++------- 1 file changed, 19 insertions(+), 11 deletions(-) diff --git a/manager-dashboard/app/views/NewTutorial/index.tsx b/manager-dashboard/app/views/NewTutorial/index.tsx index 9ce9d4b67..dccf7f4d6 100644 --- a/manager-dashboard/app/views/NewTutorial/index.tsx +++ b/manager-dashboard/app/views/NewTutorial/index.tsx @@ -646,6 +646,11 @@ function NewTutorial(props: Props) { || tutorialSubmissionStatus === 'tutorialSubmit' ); + const tileServerVisible = value.projectType === PROJECT_TYPE_BUILD_AREA + || value.projectType === PROJECT_TYPE_FOOTPRINT + || value.projectType === PROJECT_TYPE_COMPLETENESS + || value.projectType === PROJECT_TYPE_CHANGE_DETECTION; + const tileServerBVisible = value.projectType === PROJECT_TYPE_CHANGE_DETECTION || value.projectType === PROJECT_TYPE_COMPLETENESS; @@ -900,17 +905,20 @@ function NewTutorial(props: Props) { )} - - - + {tileServerVisible && ( + + + + )} + {tileServerBVisible && ( Date: Wed, 19 Feb 2025 16:28:15 +0100 Subject: [PATCH 20/76] feat(street-tutorials): add street tutorial sample scenario and instructions README --- mapswipe_workers/sample_data/street/README.md | 58 +++++++++++++++++++ .../street_tutorial_sample_scenario.geojson | 17 ++++++ 2 files changed, 75 insertions(+) create mode 100644 mapswipe_workers/sample_data/street/README.md create mode 100644 mapswipe_workers/sample_data/street/street_tutorial_sample_scenario.geojson diff --git a/mapswipe_workers/sample_data/street/README.md b/mapswipe_workers/sample_data/street/README.md new file mode 100644 index 000000000..dfa52293e --- /dev/null +++ b/mapswipe_workers/sample_data/street/README.md @@ -0,0 +1,58 @@ +# Creating a New 'Street' Tutorial +### Useful Links +- MapSwipe Development Server: [https://dev-managers.mapswipe.org] +- MapSwipe Development App Installation Guide: [https://github.com/mapswipe/mapswipe/wiki/How-to-test-the-development-version-of-MapSwipe](https://github.com/mapswipe/mapswipe/wiki/How-to-test-the-development-version-of-MapSwipe) + +## Select appropriate Mapillary imagery for the tutorial (with JOSM and Mapillary plug-in) + +1. Open JOSM. Make sure the [JOSM Mapillary plug-in](https://wiki.openstreetmap.org/wiki/JOSM/Plugins/Mapillary) is installed +2. **File > Download data**. Select an area in which you expect appropriate example imagery available on Mapillary and **Download** +3. **Imagery > Mapillary** to download sequences and images for the current area +4. If helpful, use the Mapillary filter dialog to filter images (for start and end date, user and/or organization) +5. Click **Mapillary** in Layers controls to select the Mapillary layer +6. Zoom in until you can see images location markers (green dots) +7. Click on the dots to view the images +8. Once you have found an image that you would like to use in your tutorial, **File > Export Mapillary images** and select **Export selected images** +9. Click **Explore** +10. Choose a parent folder for all images in this tutorial +11. **OK** +12. Repeat until you have exported all the images that you would like to use in the tutorial. Use the same parent folder for all images. + +## Add exported Mapillary images as geotagged images in QGIS + +1. Open QGIS +2. **Processing Toolbox > Vector creation > Import geotagged photos** +3. Select the folder containing all exported Mapillary images and check **Scan recursively** +4. **Run** +5. **Properties > Display** and add `` to HTML Map Tip to show images on a pop up +6. **View > Show Map Tips** +7. If you keep the mouse tip on the image markers, a pop up with the image will appear + +## Edit geotagged images in QGIS + +1. Right click on layer. +2. **Properties > Field** +3. **Toggle editing mode** +4. Change the name of the `filename` column to `id` +5. Add `Integer (32 bit)` columns titled `screen` and `reference`. +6. Populate the `reference` and `screen` fields. + * `reference` is the value of the correct answer option for the image. + * `screen` determines the order of the images in the tutorial and should start with `1`. +7. Delete any rows representing images that you do not want to use + +## Export as GeoJSON + +1. **Toggle editing mode** +2. **Save** +3. Right click, **Export > Save Features As...** +4. Choose Format GeoJSON, CRS EPSG:4326 - WGS 84 +5. Select only `id`, `reference` and `screen` as fields to export. Deselect all other fields. +6. Choose a file name and location and click OK to save + +## Create tutorial + +1. Go to https://dev-managers.mapswipe.org/ +2. Select **Projects** and then **Add New Tutorial**. +3. Check that **Project Type** is set to **Street**. +4. Fill in all the fields, following the instructions. Upload your `GeoJSON` you just created with the scenarios where it says **Scenario Pages**. +5. Submit diff --git a/mapswipe_workers/sample_data/street/street_tutorial_sample_scenario.geojson b/mapswipe_workers/sample_data/street/street_tutorial_sample_scenario.geojson new file mode 100644 index 000000000..3a56f6d50 --- /dev/null +++ b/mapswipe_workers/sample_data/street/street_tutorial_sample_scenario.geojson @@ -0,0 +1,17 @@ +{ + "type": "FeatureCollection", + "name": "cobblestone-scenario", + "crs": { "type": "name", "properties": { "name": "urn:ogc:def:crs:OGC:1.3:CRS84" } }, + "features": [ + { + "type": "Feature", + "properties": { "id": "378811598610667", "reference": 0, "screen": 2 }, + "geometry": { "type": "Point", "coordinates": [ 13.45285, 52.508467, 0.0 ] } + }, + { + "type": "Feature", + "properties": { "id": "1171343450849316", "reference": 1, "screen": 1 }, + "geometry": { "type": "Point", "coordinates": [ 13.4514123, 52.5103378, 0.0 ] } + } + ] +} From 32ce566d6c31d493de6b5f13dc5faa445ebe9360 Mon Sep 17 00:00:00 2001 From: Oliver Fritz Date: Wed, 19 Feb 2025 16:42:24 +0100 Subject: [PATCH 21/76] feat(street-tutorials): prepare scenario pages input for street tutorial --- .../views/NewTutorial/ScenarioPageInput/index.tsx | 10 +++++++++- manager-dashboard/app/views/NewTutorial/index.tsx | 5 +++++ manager-dashboard/app/views/NewTutorial/utils.ts | 13 ++++++++++++- 3 files changed, 26 insertions(+), 2 deletions(-) diff --git a/manager-dashboard/app/views/NewTutorial/ScenarioPageInput/index.tsx b/manager-dashboard/app/views/NewTutorial/ScenarioPageInput/index.tsx index 607434590..b309be7ff 100644 --- a/manager-dashboard/app/views/NewTutorial/ScenarioPageInput/index.tsx +++ b/manager-dashboard/app/views/NewTutorial/ScenarioPageInput/index.tsx @@ -17,6 +17,7 @@ import { PROJECT_TYPE_FOOTPRINT, PROJECT_TYPE_CHANGE_DETECTION, PROJECT_TYPE_COMPLETENESS, + PROJECT_TYPE_STREET, } from '#utils/common'; import TextInput from '#components/TextInput'; import Heading from '#components/Heading'; @@ -318,7 +319,14 @@ export default function ScenarioPageInput(props: Props) { lookFor={lookFor} /> )} - {(projectType && projectType !== PROJECT_TYPE_FOOTPRINT) && ( + {projectType === PROJECT_TYPE_STREET && ( +
+ Preview not available. +
+ )} + {(projectType + && projectType !== PROJECT_TYPE_FOOTPRINT + && projectType !== PROJECT_TYPE_STREET) && ( checkSchema( diff --git a/manager-dashboard/app/views/NewTutorial/utils.ts b/manager-dashboard/app/views/NewTutorial/utils.ts index 4fc6deebf..be3a92fa0 100644 --- a/manager-dashboard/app/views/NewTutorial/utils.ts +++ b/manager-dashboard/app/views/NewTutorial/utils.ts @@ -348,6 +348,12 @@ export interface ChangeDetectionProperties { // taskId: string; } +export interface StreetProperties { + id: string; + reference: number; + screen: number; +} + export type BuildAreaGeoJSON = GeoJSON.FeatureCollection< GeoJSON.Geometry, BuildAreaProperties @@ -363,9 +369,14 @@ export type ChangeDetectionGeoJSON = GeoJSON.FeatureCollection< ChangeDetectionProperties >; +export type StreetGeoJSON = GeoJSON.FeatureCollection< + GeoJSON.Geometry, + StreetProperties +>; + export type TutorialTasksGeoJSON = GeoJSON.FeatureCollection< GeoJSON.Geometry, - BuildAreaProperties | FootprintProperties | ChangeDetectionProperties + BuildAreaProperties | FootprintProperties | ChangeDetectionProperties | StreetProperties >; export type CustomOptions = { From dfb374329cc71389ca49698804de96684db6270b Mon Sep 17 00:00:00 2001 From: Levi Szamek Date: Thu, 20 Feb 2025 12:18:38 +0100 Subject: [PATCH 22/76] feat: change structure of street tutorial to match those of other project types --- .../project_types/street/tutorial.py | 10 +- .../tests/fixtures/tutorialDrafts/street.json | 198 ++++++------------ 2 files changed, 74 insertions(+), 134 deletions(-) diff --git a/mapswipe_workers/mapswipe_workers/project_types/street/tutorial.py b/mapswipe_workers/mapswipe_workers/project_types/street/tutorial.py index ca2c56cbe..e59a97f09 100644 --- a/mapswipe_workers/mapswipe_workers/project_types/street/tutorial.py +++ b/mapswipe_workers/mapswipe_workers/project_types/street/tutorial.py @@ -24,7 +24,7 @@ def __init__(self, tutorial_draft): # self.projectId = tutorial_draft["projectId"] self.projectType = tutorial_draft["projectType"] - self.tutorial_tasks = tutorial_draft["tasks"] + self.tutorial_tasks = tutorial_draft["tutorialTasks"] self.groups = dict() self.tasks = dict() @@ -54,14 +54,14 @@ def create_tutorial_groups(self): def create_tutorial_tasks(self): """Create the tasks dict based on provided examples in geojson file.""" task_list = [] - for i, task in enumerate(self.tutorial_tasks): + for i, task in enumerate(self.tutorial_tasks["features"]): task = StreetTutorialTask( projectId=self.projectId, groupId=101, - taskId=f"{task['taskImageId']}", + taskId=f"{task['properties']['id']}", geometry="", - referenceAnswer=task["referenceAnswer"], - screen=i, + referenceAnswer=task["properties"]["reference"], + screen=task["properties"]["screen"], ) task_list.append(asdict(task)) if task_list: diff --git a/mapswipe_workers/tests/fixtures/tutorialDrafts/street.json b/mapswipe_workers/tests/fixtures/tutorialDrafts/street.json index 22116363b..1385ffb52 100644 --- a/mapswipe_workers/tests/fixtures/tutorialDrafts/street.json +++ b/mapswipe_workers/tests/fixtures/tutorialDrafts/street.json @@ -1,170 +1,110 @@ { - "createdBy": "LtCUyou6CnSSc1H0Q0nDrN97x892", - "tutorialDraftId": "waste_mapping_dar_es_salaam", - "taskImageIds": [ - 888464808378923, - 1552821322020271, - 2969692853315413, - 1036040467918366, - 837497816845037 - ], - "answers": [ - 1, - 2, - 1, - 2, - 3 - ], - "customOptions": [ - { - "description": "the shape does outline a building in the image", - "icon": "hand-right-outline", - "iconColor": "#00796B", - "subOptions": [ - { - "description": "doppelt", - "value": 2 - }, - { - "description": "dreifach", - "value": 3 - } - ], - "title": "Jetzt rede ich", - "value": 1 - }, - { - "description": "the shape doesn't match a building in the image", - "icon": "close-outline", - "iconColor": "#D32F2F", - "title": "No", - "value": 0 - } - ], + "createdBy": "atCSosZACaN0qhcVjtMO1tq9d1G3", + "tutorialDraftId": "test_tile_classification", "informationPages": [ { "blocks": [ { "blockNumber": 1, "blockType": "text", - "textDescription": "asdf" + "textDescription": "This is the first information page" }, { "blockNumber": 2, "blockType": "image", - "image": "https://firebasestorage.googleapis.com/v0/b/dev-mapswipe.appspot.com/o/tutorialImages%2F1705402528654-block-image-2-base-query-form.png?alt=media&token=54325ab8-c5e7-45a3-be41-1926a5984a05" + "image": "https://firebasestorage.googleapis.com/v0/b/dev-mapswipe.appspot.com/o/tutorialImages%2F1739963139725-block-image-2-1x1.png?alt=media&token=ae584dcd-d351-4bfe-be5f-1e0d38547f72" } ], "pageNumber": 1, - "title": "asdf" + "title": "Information page 1" } ], - "lookFor": "waste", - "name": "Waste Mapping Dar es Salaam", + "lookFor": "cobblestone", + "name": "cobblestone-tutorial", "projectType": 7, "screens": [ null, { "hint": { - "description": "Swipe to learn some more.", - "icon": "swipe-left", - "title": "We've marked the correct square green." + "description": "This seems to be a tarmac surface.", + "icon": "check", + "title": "Tarmac" }, "instructions": { - "description": "Tap once to mark the squares with buildings green.", - "icon": "tap-1", - "title": "There are some buildings in this image" + "description": "Check out if the road surface material is cobblestone here", + "icon": "check", + "title": "Is this cobblestone?" }, "success": { - "description": "Swipe to the next screen to look for more.", + "description": "Correct, this is not cobblestone", "icon": "check", - "title": "You found your first areas with buildings!" + "title": "Nice!" } }, { "hint": { - "description": "Swipe to learn some more.", - "icon": "swipe-left", - "title": "We've marked the correct square green." + "description": "That surface does look like cobblestone!", + "icon": "heart-outline", + "title": "Cobblestone" }, "instructions": { - "description": "Tap once to mark the squares with buildings green.", - "icon": "tap-1", - "title": "There are some buildings in this image" + "description": "Does this look like cobblestone?", + "icon": "egg-outline", + "title": "How about this one?" }, "success": { - "description": "Swipe to the next screen to look for more.", - "icon": "check", - "title": "You found your first" - } + "description": "Correct", + "icon": "search-outline", + "title": "Correct" + } + } + ], + "tileServer": { + "credits": "© 2019 Microsoft Corporation, Earthstar Geographics SIO", + "name": "bing" + }, + "tutorialTasks": { + "crs": { + "properties": { + "name": "urn:ogc:def:crs:OGC:1.3:CRS84" + }, + "type": "name" }, - { - "hint": { - "description": "Swipe to learn some more.", - "icon": "swipe-left", - "title": "We've marked the correct square green." - }, - "instructions": { - "description": "Tap once to mark the squares with buildings green.", - "icon": "tap-1", - "title": "There are some buildings in this image" - }, - "success": { - "description": "Swipe to the next screen to look for more.", - "icon": "check", - "title": "You found your first areas with buildings!" - } - }, - { - "hint": { - "description": "Swipe to learn some more.", - "icon": "swipe-left", - "title": "We've marked the correct square green." + "features": [ + { + "geometry": { + "coordinates": [ + 13.4514123, + 52.5103378, + 0 + ], + "type": "Point" }, - "instructions": { - "description": "Tap once to mark the squares with buildings green.", - "icon": "tap-1", - "title": "There are some buildings in this image" + "properties": { + "id": "1171343450849316", + "reference": 1, + "screen": 1 }, - "success": { - "description": "Swipe to the next screen to look for more.", - "icon": "check", - "title": "You found your first areas with buildings!" - } - }, - { - "hint": { - "description": "Swipe to learn some more.", - "icon": "swipe-left", - "title": "We've marked the correct square green." + "type": "Feature" + }, + { + "geometry": { + "coordinates": [ + 13.45285, + 52.508467, + 0 + ], + "type": "Point" }, - "instructions": { - "description": "Tap once to mark the squares with buildings green.", - "icon": "tap-1", - "title": "There are some buildings in this image" + "properties": { + "id": "378811598610667", + "reference": 0, + "screen": 2 }, - "success": { - "description": "Swipe to the next screen to look for more.", - "icon": "check", - "title": "You found your first areas with buildings!" - } - }, - { - "hint": { - "description": "Swipe to learn some more.", - "icon": "swipe-left", - "title": "We've marked the correct square green." - }, - "instructions": { - "description": "Tap once to mark the squares with buildings green.", - "icon": "tap-1", - "title": "There are some buildings in this image" - }, - "success": { - "description": "Swipe to the next screen to look for more.", - "icon": "check", - "title": "You found your first areas with buildings!" + "type": "Feature" } - } - ] + ], + "name": "cobblestone-scenario", + "type": "FeatureCollection" + } } \ No newline at end of file From 550df7a237036e9217e58d745f19293375da6453 Mon Sep 17 00:00:00 2001 From: Levi Szamek Date: Thu, 20 Feb 2025 12:36:50 +0100 Subject: [PATCH 23/76] fix: working integration test for tutorial creation --- .../tests/integration/tear_down.py | 8 ++- .../tests/integration/test_create_tutorial.py | 50 ++++--------------- .../tests/unittests/test_tutorial.py | 10 ++-- 3 files changed, 22 insertions(+), 46 deletions(-) diff --git a/mapswipe_workers/tests/integration/tear_down.py b/mapswipe_workers/tests/integration/tear_down.py index 61760781c..33af5d52d 100644 --- a/mapswipe_workers/tests/integration/tear_down.py +++ b/mapswipe_workers/tests/integration/tear_down.py @@ -8,7 +8,7 @@ from mapswipe_workers import auth -def delete_test_data(project_id: str) -> None: +def delete_test_data(project_id: str, tutorial_id: str = None) -> None: """ Delete test project indluding groups, tasks and results from Firebase and Postgres @@ -38,6 +38,12 @@ def delete_test_data(project_id: str) -> None: ref = fb_db.reference(f"v2/users/{project_id}") ref.delete() + if tutorial_id is not None: + ref = fb_db.reference(f"v2/projects/{tutorial_id}") + ref.delete() + ref = fb_db.reference(f"v2/tutorialDrafts/{tutorial_id}") + ref.delete() + # Clear out the user-group used in test. # XXX: Use a firebase simulator for running test. # For CI/CD, use a real firebase with scope using commit hash, diff --git a/mapswipe_workers/tests/integration/test_create_tutorial.py b/mapswipe_workers/tests/integration/test_create_tutorial.py index b61eb182e..31e45b3fa 100644 --- a/mapswipe_workers/tests/integration/test_create_tutorial.py +++ b/mapswipe_workers/tests/integration/test_create_tutorial.py @@ -10,65 +10,35 @@ class TestCreateTileClassificationProject(unittest.TestCase): def setUp(self): self.tutorial_id = set_up.create_test_tutorial_draft( - "tile_classification", - "tile_classification", + "street", + "street", "test_tile_classification_tutorial", ) self.project_id = set_up.create_test_project_draft( - "tile_classification", - "tile_classification", + "street", + "street", "test_tile_classification_tutorial", tutorial_id=self.tutorial_id, ) create_directories() def tearDown(self): - tear_down.delete_test_data(self.project_id) + tear_down.delete_test_data(self.project_id, self.tutorial_id) def test_create_tile_classification_project(self): runner = CliRunner() + runner.invoke(mapswipe_workers.run_create_tutorials, catch_exceptions=False) runner.invoke(mapswipe_workers.run_create_projects, catch_exceptions=False) - pg_db = auth.postgresDB() - query = "SELECT project_id FROM projects WHERE project_id = %s" - result = pg_db.retr_query(query, [self.project_id])[0][0] - self.assertEqual(result, self.project_id) - - query = """ - SELECT project_id - FROM projects - WHERE project_id = %s - and project_type_specifics::jsonb ? 'customOptions' - """ - result = pg_db.retr_query(query, [self.project_id])[0][0] - self.assertEqual(result, self.project_id) - - query = "SELECT count(*) FROM groups WHERE project_id = %s" - result = pg_db.retr_query(query, [self.project_id])[0][0] - self.assertEqual(result, 20) - - query = "SELECT count(*) FROM tasks WHERE project_id = %s" - result = pg_db.retr_query(query, [self.project_id])[0][0] - self.assertEqual(result, 5040) - fb_db = auth.firebaseDB() ref = fb_db.reference(f"/v2/projects/{self.project_id}") - result = ref.get(shallow=True) - self.assertIsNotNone(result) + result = ref.get() + self.assertEqual(result["tutorialId"], self.tutorial_id) - ref = fb_db.reference(f"/v2/groups/{self.project_id}") + ref = fb_db.reference(f"/v2/projects/{self.tutorial_id}") result = ref.get(shallow=True) - self.assertEqual(len(result), 20) - - # Tile classification projects do not have tasks in Firebase - ref = fb_db.reference(f"/v2/tasks/{self.project_id}") - result = ref.get(shallow=True) - self.assertIsNone(result) - - ref = fb_db.reference(f"/v2/projects/{self.project_id}/tutorialId") - result = ref.get(shallow=True) - self.assertEqual(self.tutorial_id, result) + self.assertIsNotNone(result) if __name__ == "__main__": diff --git a/mapswipe_workers/tests/unittests/test_tutorial.py b/mapswipe_workers/tests/unittests/test_tutorial.py index 5ba1c209a..16e9e6aa4 100644 --- a/mapswipe_workers/tests/unittests/test_tutorial.py +++ b/mapswipe_workers/tests/unittests/test_tutorial.py @@ -1,22 +1,22 @@ import os import unittest -from mapswipe_workers.project_types import ClassificationTutorial +from mapswipe_workers.project_types import StreetTutorial from tests.fixtures import FIXTURE_DIR, get_fixture class TestTutorial(unittest.TestCase): def test_init_tile_classification_project(self): tutorial_draft = get_fixture( - os.path.join(FIXTURE_DIR, "tutorialDrafts", "tile_classification.json") + os.path.join(FIXTURE_DIR, "tutorialDrafts", "street.json") ) - self.assertIsNotNone(ClassificationTutorial(tutorial_draft=tutorial_draft)) + self.assertIsNotNone(StreetTutorial(tutorial_draft=tutorial_draft)) def test_create_tile_classification_tasks(self): tutorial_draft = get_fixture( - os.path.join(FIXTURE_DIR, "tutorialDrafts", "tile_classification.json") + os.path.join(FIXTURE_DIR, "tutorialDrafts", "street.json") ) - tutorial = ClassificationTutorial(tutorial_draft=tutorial_draft) + tutorial = StreetTutorial(tutorial_draft=tutorial_draft) tutorial.create_tutorial_groups() tutorial.create_tutorial_tasks() self.assertTrue(tutorial.groups) From 5237ce07d4ac4e4981b8fe5a75d838e1603603f8 Mon Sep 17 00:00:00 2001 From: Levi Szamek Date: Thu, 20 Feb 2025 12:38:00 +0100 Subject: [PATCH 24/76] fix: naming in integration test --- .../tests/integration/test_create_tutorial.py | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/mapswipe_workers/tests/integration/test_create_tutorial.py b/mapswipe_workers/tests/integration/test_create_tutorial.py index 31e45b3fa..e6db39579 100644 --- a/mapswipe_workers/tests/integration/test_create_tutorial.py +++ b/mapswipe_workers/tests/integration/test_create_tutorial.py @@ -10,14 +10,14 @@ class TestCreateTileClassificationProject(unittest.TestCase): def setUp(self): self.tutorial_id = set_up.create_test_tutorial_draft( - "street", - "street", + "tile_classification", + "tile_classification", "test_tile_classification_tutorial", ) self.project_id = set_up.create_test_project_draft( - "street", - "street", + "tile_classification", + "tile_classification", "test_tile_classification_tutorial", tutorial_id=self.tutorial_id, ) @@ -26,7 +26,7 @@ def setUp(self): def tearDown(self): tear_down.delete_test_data(self.project_id, self.tutorial_id) - def test_create_tile_classification_project(self): + def test_create_tile_classification_project_and_tutorial(self): runner = CliRunner() runner.invoke(mapswipe_workers.run_create_tutorials, catch_exceptions=False) runner.invoke(mapswipe_workers.run_create_projects, catch_exceptions=False) From e2efb18a07c60e3aa28936863e30cc768d67fee8 Mon Sep 17 00:00:00 2001 From: Levi Szamek Date: Thu, 20 Feb 2025 12:40:32 +0100 Subject: [PATCH 25/76] refactor: change name of test_tutorial to test_tutorial_street --- .../unittests/{test_tutorial.py => test_tutorial_street.py} | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) rename mapswipe_workers/tests/unittests/{test_tutorial.py => test_tutorial_street.py} (87%) diff --git a/mapswipe_workers/tests/unittests/test_tutorial.py b/mapswipe_workers/tests/unittests/test_tutorial_street.py similarity index 87% rename from mapswipe_workers/tests/unittests/test_tutorial.py rename to mapswipe_workers/tests/unittests/test_tutorial_street.py index 16e9e6aa4..6dd9b0127 100644 --- a/mapswipe_workers/tests/unittests/test_tutorial.py +++ b/mapswipe_workers/tests/unittests/test_tutorial_street.py @@ -6,13 +6,13 @@ class TestTutorial(unittest.TestCase): - def test_init_tile_classification_project(self): + def test_init_street_tutorial(self): tutorial_draft = get_fixture( os.path.join(FIXTURE_DIR, "tutorialDrafts", "street.json") ) self.assertIsNotNone(StreetTutorial(tutorial_draft=tutorial_draft)) - def test_create_tile_classification_tasks(self): + def test_create_street_tasks(self): tutorial_draft = get_fixture( os.path.join(FIXTURE_DIR, "tutorialDrafts", "street.json") ) From f3ae5498d91b79c706227a0646137a836e876d98 Mon Sep 17 00:00:00 2001 From: Oliver Fritz Date: Thu, 20 Feb 2025 14:01:02 +0100 Subject: [PATCH 26/76] feat(street-tutorial): correct reference value in sample data --- .../street/street_tutorial_sample_scenario.geojson | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/mapswipe_workers/sample_data/street/street_tutorial_sample_scenario.geojson b/mapswipe_workers/sample_data/street/street_tutorial_sample_scenario.geojson index 3a56f6d50..8f5b236f0 100644 --- a/mapswipe_workers/sample_data/street/street_tutorial_sample_scenario.geojson +++ b/mapswipe_workers/sample_data/street/street_tutorial_sample_scenario.geojson @@ -5,12 +5,12 @@ "features": [ { "type": "Feature", - "properties": { "id": "378811598610667", "reference": 0, "screen": 2 }, + "properties": { "id": "378811598610667", "reference": 1, "screen": 2 }, "geometry": { "type": "Point", "coordinates": [ 13.45285, 52.508467, 0.0 ] } }, { "type": "Feature", - "properties": { "id": "1171343450849316", "reference": 1, "screen": 1 }, + "properties": { "id": "1171343450849316", "reference": 0, "screen": 1 }, "geometry": { "type": "Point", "coordinates": [ 13.4514123, 52.5103378, 0.0 ] } } ] From c27e228588193c0112e30bdea036375ae12c393b Mon Sep 17 00:00:00 2001 From: Oliver Fritz Date: Thu, 20 Feb 2025 15:08:44 +0100 Subject: [PATCH 27/76] feat(street-tutorial): ensure that custom options are written to tutorial and project drafts --- manager-dashboard/app/views/NewProject/utils.ts | 3 ++- manager-dashboard/app/views/NewTutorial/utils.ts | 7 ++++--- 2 files changed, 6 insertions(+), 4 deletions(-) diff --git a/manager-dashboard/app/views/NewProject/utils.ts b/manager-dashboard/app/views/NewProject/utils.ts index c410084b0..685ffd39d 100644 --- a/manager-dashboard/app/views/NewProject/utils.ts +++ b/manager-dashboard/app/views/NewProject/utils.ts @@ -324,7 +324,8 @@ export const projectFormSchema: ProjectFormSchema = { ['projectType'], ['customOptions'], (formValues) => { - if (formValues?.projectType === PROJECT_TYPE_FOOTPRINT) { + if (formValues?.projectType === PROJECT_TYPE_FOOTPRINT + || formValues?.projectType === PROJECT_TYPE_STREET) { return { customOptions: { keySelector: (key) => key.value, diff --git a/manager-dashboard/app/views/NewTutorial/utils.ts b/manager-dashboard/app/views/NewTutorial/utils.ts index be3a92fa0..67f5e4af5 100644 --- a/manager-dashboard/app/views/NewTutorial/utils.ts +++ b/manager-dashboard/app/views/NewTutorial/utils.ts @@ -265,7 +265,7 @@ export const defaultStreetCustomOptions: PartialTutorialFormType['customOptions' title: 'Yes', icon: 'checkmark-outline', iconColor: colorKeyToColorMap.green, - description: '', + description: 'the object you are looking for is in the image.', }, { optionId: 2, @@ -273,7 +273,7 @@ export const defaultStreetCustomOptions: PartialTutorialFormType['customOptions' title: 'No', icon: 'close-outline', iconColor: colorKeyToColorMap.red, - description: '', + description: 'the object you are looking for is NOT in the image.', }, { optionId: 3, @@ -775,7 +775,8 @@ export const tutorialFormSchema: TutorialFormSchema = { }), }; - if (formValues?.projectType === PROJECT_TYPE_FOOTPRINT) { + if (formValues?.projectType === PROJECT_TYPE_FOOTPRINT + || formValues?.projectType === PROJECT_TYPE_STREET) { return { customOptions: customOptionField, }; From 64553efdad31066d0c7d53fbaa54fdec7027f449 Mon Sep 17 00:00:00 2001 From: Levi Szamek Date: Mon, 24 Feb 2025 17:07:15 +0100 Subject: [PATCH 28/76] fix: remove duplicates after spatial sampling --- mapswipe_workers/mapswipe_workers/utils/process_mapillary.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/mapswipe_workers/mapswipe_workers/utils/process_mapillary.py b/mapswipe_workers/mapswipe_workers/utils/process_mapillary.py index f8db99cc9..54afc2be6 100644 --- a/mapswipe_workers/mapswipe_workers/utils/process_mapillary.py +++ b/mapswipe_workers/mapswipe_workers/utils/process_mapillary.py @@ -229,13 +229,14 @@ def get_image_metadata( raise CustomError( "No Mapillary Features in the AoI or no Features match the filter criteria." ) - downloaded_metadata = downloaded_metadata.drop_duplicates(subset=["geometry"]) if sampling_threshold is not None: downloaded_metadata = spatial_sampling(downloaded_metadata, sampling_threshold) if randomize_order is True: downloaded_metadata = downloaded_metadata.sample(frac=1).reset_index(drop=True) + downloaded_metadata = downloaded_metadata.drop_duplicates(subset=["geometry"]) + total_images = len(downloaded_metadata) if total_images > 100000: raise CustomError( From 2e2152cc3b1b0536b4a3b58fa057da23c7b6b549 Mon Sep 17 00:00:00 2001 From: Levi Szamek Date: Mon, 3 Mar 2025 15:04:51 +0100 Subject: [PATCH 29/76] feat: add unittest for each tutorial --- .../tutorialDrafts/change_detection.json | 1 + .../fixtures/tutorialDrafts/completeness.json | 1 + .../fixtures/tutorialDrafts/footprint.json | 1 + ...t_tutorial_arbitrary_geometry_footprint.py | 27 +++++++++++++++++++ .../test_tutorial_tile_change_detection.py | 27 +++++++++++++++++++ .../test_tutorial_tile_classification.py | 27 +++++++++++++++++++ .../test_tutorial_tile_completeness.py | 27 +++++++++++++++++++ 7 files changed, 111 insertions(+) create mode 100644 mapswipe_workers/tests/unittests/test_tutorial_arbitrary_geometry_footprint.py create mode 100644 mapswipe_workers/tests/unittests/test_tutorial_tile_change_detection.py create mode 100644 mapswipe_workers/tests/unittests/test_tutorial_tile_classification.py create mode 100644 mapswipe_workers/tests/unittests/test_tutorial_tile_completeness.py diff --git a/mapswipe_workers/tests/fixtures/tutorialDrafts/change_detection.json b/mapswipe_workers/tests/fixtures/tutorialDrafts/change_detection.json index 4b857eaa0..8c0f817bd 100644 --- a/mapswipe_workers/tests/fixtures/tutorialDrafts/change_detection.json +++ b/mapswipe_workers/tests/fixtures/tutorialDrafts/change_detection.json @@ -4,6 +4,7 @@ "exampleImage2": "", "lookFor": "damaged buildings", "name": "change_detection_tutorial", + "tutorialDraftId": "test_tile_change_detection", "projectType": 3, "screens": [ null, diff --git a/mapswipe_workers/tests/fixtures/tutorialDrafts/completeness.json b/mapswipe_workers/tests/fixtures/tutorialDrafts/completeness.json index b08c10dd7..0752c71a1 100644 --- a/mapswipe_workers/tests/fixtures/tutorialDrafts/completeness.json +++ b/mapswipe_workers/tests/fixtures/tutorialDrafts/completeness.json @@ -4,6 +4,7 @@ "exampleImage2": "https://firebasestorage.googleapis.com/v0/b/heigit-crowdmap.appspot.com/o/projectImages%2F1686065132355-tutorial-image-2-1x1.png?alt=media&token=bf8e67bc-d34c-4676-ba17-56bffc6b3f2d", "lookFor": "buildings", "name": "completeness_tutorial", + "tutorialDraftId": "test_tile_completeness", "projectType": 4, "screens": { "categories": { diff --git a/mapswipe_workers/tests/fixtures/tutorialDrafts/footprint.json b/mapswipe_workers/tests/fixtures/tutorialDrafts/footprint.json index b8b31a9f9..b4e26e7bd 100644 --- a/mapswipe_workers/tests/fixtures/tutorialDrafts/footprint.json +++ b/mapswipe_workers/tests/fixtures/tutorialDrafts/footprint.json @@ -1,5 +1,6 @@ { "createdBy": "LtCUyou6CnSSc1H0Q0nDrN97x892", + "tutorialDraftId": "test_footprint_tutorial", "customOptions": [ { "description": "the shape does outline a building in the image", diff --git a/mapswipe_workers/tests/unittests/test_tutorial_arbitrary_geometry_footprint.py b/mapswipe_workers/tests/unittests/test_tutorial_arbitrary_geometry_footprint.py new file mode 100644 index 000000000..3d20b6289 --- /dev/null +++ b/mapswipe_workers/tests/unittests/test_tutorial_arbitrary_geometry_footprint.py @@ -0,0 +1,27 @@ +import os +import unittest + +from mapswipe_workers.project_types import FootprintTutorial +from tests.fixtures import FIXTURE_DIR, get_fixture + + +class TestTutorial(unittest.TestCase): + def test_init_arbitrary_geometry_footprint_project(self): + tutorial_draft = get_fixture( + os.path.join(FIXTURE_DIR, "tutorialDrafts", "footprint.json") + ) + self.assertIsNotNone(FootprintTutorial(tutorial_draft=tutorial_draft)) + + def test_create_arbitrary_geometry_footprint_tasks(self): + tutorial_draft = get_fixture( + os.path.join(FIXTURE_DIR, "tutorialDrafts", "footprint.json") + ) + tutorial = FootprintTutorial(tutorial_draft=tutorial_draft) + tutorial.create_tutorial_groups() + tutorial.create_tutorial_tasks() + self.assertTrue(tutorial.groups) + self.assertTrue(tutorial.tasks) + + +if __name__ == "__main__": + unittest.main() diff --git a/mapswipe_workers/tests/unittests/test_tutorial_tile_change_detection.py b/mapswipe_workers/tests/unittests/test_tutorial_tile_change_detection.py new file mode 100644 index 000000000..e394fd607 --- /dev/null +++ b/mapswipe_workers/tests/unittests/test_tutorial_tile_change_detection.py @@ -0,0 +1,27 @@ +import os +import unittest + +from mapswipe_workers.project_types import ChangeDetectionTutorial +from tests.fixtures import FIXTURE_DIR, get_fixture + + +class TestTutorial(unittest.TestCase): + def test_init_tile_change_detection_project(self): + tutorial_draft = get_fixture( + os.path.join(FIXTURE_DIR, "tutorialDrafts", "change_detection.json") + ) + self.assertIsNotNone(ChangeDetectionTutorial(tutorial_draft=tutorial_draft)) + + def test_create_tile_change_detection_tasks(self): + tutorial_draft = get_fixture( + os.path.join(FIXTURE_DIR, "tutorialDrafts", "change_detection.json") + ) + tutorial = ChangeDetectionTutorial(tutorial_draft=tutorial_draft) + tutorial.create_tutorial_groups() + tutorial.create_tutorial_tasks() + self.assertTrue(tutorial.groups) + self.assertTrue(tutorial.tasks) + + +if __name__ == "__main__": + unittest.main() diff --git a/mapswipe_workers/tests/unittests/test_tutorial_tile_classification.py b/mapswipe_workers/tests/unittests/test_tutorial_tile_classification.py new file mode 100644 index 000000000..5ba1c209a --- /dev/null +++ b/mapswipe_workers/tests/unittests/test_tutorial_tile_classification.py @@ -0,0 +1,27 @@ +import os +import unittest + +from mapswipe_workers.project_types import ClassificationTutorial +from tests.fixtures import FIXTURE_DIR, get_fixture + + +class TestTutorial(unittest.TestCase): + def test_init_tile_classification_project(self): + tutorial_draft = get_fixture( + os.path.join(FIXTURE_DIR, "tutorialDrafts", "tile_classification.json") + ) + self.assertIsNotNone(ClassificationTutorial(tutorial_draft=tutorial_draft)) + + def test_create_tile_classification_tasks(self): + tutorial_draft = get_fixture( + os.path.join(FIXTURE_DIR, "tutorialDrafts", "tile_classification.json") + ) + tutorial = ClassificationTutorial(tutorial_draft=tutorial_draft) + tutorial.create_tutorial_groups() + tutorial.create_tutorial_tasks() + self.assertTrue(tutorial.groups) + self.assertTrue(tutorial.tasks) + + +if __name__ == "__main__": + unittest.main() diff --git a/mapswipe_workers/tests/unittests/test_tutorial_tile_completeness.py b/mapswipe_workers/tests/unittests/test_tutorial_tile_completeness.py new file mode 100644 index 000000000..972c412ca --- /dev/null +++ b/mapswipe_workers/tests/unittests/test_tutorial_tile_completeness.py @@ -0,0 +1,27 @@ +import os +import unittest + +from mapswipe_workers.project_types import CompletenessTutorial +from tests.fixtures import FIXTURE_DIR, get_fixture + + +class TestTutorial(unittest.TestCase): + def test_init_tile_completeness_project(self): + tutorial_draft = get_fixture( + os.path.join(FIXTURE_DIR, "tutorialDrafts", "completeness.json") + ) + self.assertIsNotNone(CompletenessTutorial(tutorial_draft=tutorial_draft)) + + def test_create_tile_completeness_tasks(self): + tutorial_draft = get_fixture( + os.path.join(FIXTURE_DIR, "tutorialDrafts", "completeness.json") + ) + tutorial = CompletenessTutorial(tutorial_draft=tutorial_draft) + tutorial.create_tutorial_groups() + tutorial.create_tutorial_tasks() + self.assertTrue(tutorial.groups) + self.assertTrue(tutorial.tasks) + + +if __name__ == "__main__": + unittest.main() From 809f0f8f26b229d8ac9b9ca1784d11effff5e6c0 Mon Sep 17 00:00:00 2001 From: Levi Szamek Date: Mon, 3 Mar 2025 15:26:38 +0100 Subject: [PATCH 30/76] fix: handle FutureWarning when concating dfs --- mapswipe_workers/mapswipe_workers/utils/spatial_sampling.py | 3 +++ 1 file changed, 3 insertions(+) diff --git a/mapswipe_workers/mapswipe_workers/utils/spatial_sampling.py b/mapswipe_workers/mapswipe_workers/utils/spatial_sampling.py index 97302b945..739ae39ae 100644 --- a/mapswipe_workers/mapswipe_workers/utils/spatial_sampling.py +++ b/mapswipe_workers/mapswipe_workers/utils/spatial_sampling.py @@ -143,6 +143,9 @@ def spatial_sampling(df, interval_length): if interval_length: sequence_df = filter_points(sequence_df, interval_length) + # below line prevents FutureWarning + # (https://stackoverflow.com/questions/73800841/add-series-as-a-new-row-into-dataframe-triggers-futurewarning) + sequence_df["is_pano"] = sequence_df["is_pano"].astype(bool) sampled_sequence_df = pd.concat([sampled_sequence_df, sequence_df], axis=0) # reverse order such that sequence are in direction of travel From a7319e490cc97193ebcd64cebcb549b5f0974ad9 Mon Sep 17 00:00:00 2001 From: Levi Szamek Date: Mon, 3 Mar 2025 16:16:31 +0100 Subject: [PATCH 31/76] fix: check if column exists before using astype --- .../mapswipe_workers/utils/spatial_sampling.py | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/mapswipe_workers/mapswipe_workers/utils/spatial_sampling.py b/mapswipe_workers/mapswipe_workers/utils/spatial_sampling.py index 739ae39ae..67f35c7e9 100644 --- a/mapswipe_workers/mapswipe_workers/utils/spatial_sampling.py +++ b/mapswipe_workers/mapswipe_workers/utils/spatial_sampling.py @@ -143,9 +143,10 @@ def spatial_sampling(df, interval_length): if interval_length: sequence_df = filter_points(sequence_df, interval_length) - # below line prevents FutureWarning - # (https://stackoverflow.com/questions/73800841/add-series-as-a-new-row-into-dataframe-triggers-futurewarning) - sequence_df["is_pano"] = sequence_df["is_pano"].astype(bool) + if "is_pano" in sequence_df.columns: + # below line prevents FutureWarning + # (https://stackoverflow.com/questions/73800841/add-series-as-a-new-row-into-dataframe-triggers-futurewarning) + sequence_df["is_pano"] = sequence_df["is_pano"].astype(bool) sampled_sequence_df = pd.concat([sampled_sequence_df, sequence_df], axis=0) # reverse order such that sequence are in direction of travel From 7c2654e0d9527f152d1e7f2e737edc156a3d8c36 Mon Sep 17 00:00:00 2001 From: frozenhelium Date: Tue, 11 Mar 2025 11:48:43 +0545 Subject: [PATCH 32/76] Fix community dashboard for street project stats --- .../app/views/StatsBoard/index.tsx | 16 +++++++++++++--- 1 file changed, 13 insertions(+), 3 deletions(-) diff --git a/community-dashboard/app/views/StatsBoard/index.tsx b/community-dashboard/app/views/StatsBoard/index.tsx index 1b44bc50f..f3b842997 100644 --- a/community-dashboard/app/views/StatsBoard/index.tsx +++ b/community-dashboard/app/views/StatsBoard/index.tsx @@ -70,10 +70,12 @@ const BUILD_AREA = 'BUILD_AREA'; const FOOTPRINT = 'FOOTPRINT'; const CHANGE_DETECTION = 'CHANGE_DETECTION'; const COMPLETENESS = 'COMPLETENESS'; +const STREET = 'STREET'; +// FIXME: the name property is not used properly const projectTypes: Record = { [UNKNOWN]: { - color: '#808080', + color: '#cacaca', name: 'Unknown', }, [BUILD_AREA]: { @@ -92,6 +94,10 @@ const projectTypes: Record = { color: '#fb8072', name: 'Completeness', }, + [STREET]: { + color: '#808080', + name: 'Street', + }, }; type ResolutionType = 'day' | 'month' | 'year'; @@ -372,7 +378,11 @@ function StatsBoard(props: Props) { swipeByProjectType ?.map((item) => ({ ...item, - projectType: item.projectType ?? '-1', + projectType: ( + isDefined(item.projectType) + && isDefined(projectTypes[item.projectType]) + ) ? item.projectType + : UNKNOWN, })) .sort((a, b) => compareNumber(a.totalSwipes, b.totalSwipes, -1)) ?? [] ), @@ -750,7 +760,7 @@ function StatsBoard(props: Props) { {sortedProjectSwipeType.map((item) => ( ))} From cb181926efbc39446ea81859664e290104fcd125 Mon Sep 17 00:00:00 2001 From: Oliver Fritz Date: Thu, 20 Mar 2025 16:01:27 +0100 Subject: [PATCH 33/76] feat: add script to extract population stats for projects Co-authored-by: rabenojha " --- .../extract_project_population_stats.py | 143 ++++++++++++++++++ 1 file changed, 143 insertions(+) create mode 100644 mapswipe_workers/python_scripts/extract_project_population_stats.py diff --git a/mapswipe_workers/python_scripts/extract_project_population_stats.py b/mapswipe_workers/python_scripts/extract_project_population_stats.py new file mode 100644 index 000000000..072f57f35 --- /dev/null +++ b/mapswipe_workers/python_scripts/extract_project_population_stats.py @@ -0,0 +1,143 @@ +import argparse +import os +import warnings + +import geopandas as gpd +import pandas as pd +import rasterio +import requests +from exactextract import exact_extract +from tqdm import tqdm + +warnings.filterwarnings("ignore") + + +def project_list(id_file): + """Reads Mapswipe project IDs from the user input file""" + + with open(id_file, "r") as file: + ids = file.read().strip() + + project_list = ids.split(",") + project_list = [id.strip() for id in project_list] + + return project_list + + +def population_raster_download(): + """Downloads 1km resolution global population raster for 2020 from WorldPop to the current working directory.""" + + url = "https://data.worldpop.org/GIS/Population/Global_2000_2020/2020/0_Mosaicked/ppp_2020_1km_Aggregated.tif" + + output_file = "ppp_2020_1km_Aggregated.tif" + + output_file_path = os.path.join(os.getcwd(), output_file) + + if os.path.exists(output_file_path): + + print("Population raster already exists. Moving to next steps......") + return output_file_path + + else: + + response = requests.get(url, stream=True) + size = int(response.headers.get("content-length", 0)) + block_size = 1024 + try: + with open(output_file, "wb") as file, tqdm( + desc="Downloading population raster", + total=size, + unit="B", + unit_scale=True, + unit_divisor=1024, + ) as bar: + for chunk in response.iter_content(block_size): + if chunk: + file.write(chunk) + bar.update(len(chunk)) + + print("Download complete:", output_file_path) + return output_file_path + + except requests.RequestException as e: + print(f"Error downloading data: {e}") + + +def population_count(list, dir, raster): + """Gets boundary data for projects from Mapswipe API and calculates zonal statistics + with global population raster and individual project boundaries.""" + + dict = {} + worldpop = rasterio.open(raster) + + for id in list: + url = f"https://apps.mapswipe.org/api/project_geometries/project_geom_{id}.geojson" + response = requests.get(url) + + try: + geojson = response.json() + for feature in geojson["features"]: + geometry = feature.get("geometry", {}) + if "coordinates" in geometry: + if geometry["type"] == "Polygon": + geometry["coordinates"] = [ + [[coord[0], coord[1]] for coord in polygon] + for polygon in geometry["coordinates"] + ] + elif geometry["type"] == "MultiPolygon": + geometry["coordinates"] = [ + [ + [[coord[0], coord[1]] for coord in polygon] + for polygon in multipolygon + ] + for multipolygon in geometry["coordinates"] + ] + gdf = gpd.GeoDataFrame.from_features(geojson["features"]) + gdf.set_crs("EPSG:4326", inplace=True) + no_of_people = exact_extract(worldpop, gdf, "sum") + no_of_people = round(no_of_people[0]["properties"]["sum"]) + + dict[id] = no_of_people + + except requests.RequestException as e: + print(f"Error in retrieval of project boundary from Mapswipe: {e}") + + df = pd.DataFrame( + dict.items(), columns=["Project_IDs", "Number of people impacted"] + ) + + df["Project_IDs"] = "https://mapswipe.org/en/projects/" + df["Project_IDs"] + + df.to_csv(f"{dir}/projects_population.csv") + + print(f"CSV file successfully created at {dir}/number_of_people_impacted.csv") + + +if __name__ == "__main__": + """Generates population stats for individual Mapswipe projects""" + parser = argparse.ArgumentParser() + parser.add_argument( + "-t", + "--text_file", + help=( + "Path to the text file containing project IDs from Mapswipe. The file should contain IDs in this manner: " + "-O8kulfxD4zRYQ2T1aXf, -O8kyOCreRGklW15n8RU, -O8kzSy9105axIPOAJjO, -OAwWv9rnJqPXTpWxO8-, " + "-OB-tettI2np7t3Gpu-k" + ), + type=str, + required=True, + ) + parser.add_argument( + "-o", + "--output_directory", + help="Path to the directory to store the output", + type=str, + required=True, + ) + args = parser.parse_args() + + population_count( + project_list(args.text_file), + args.output_directory, + population_raster_download(), + ) From fb2b6d7c5127c3b32eb4bd220e5b07e9eade139d Mon Sep 17 00:00:00 2001 From: tnagorra Date: Fri, 4 Apr 2025 11:52:34 +0545 Subject: [PATCH 34/76] Create logger function to include more information --- firebase/functions/src/index.ts | 57 ++++++++++++++++++++++----------- 1 file changed, 39 insertions(+), 18 deletions(-) diff --git a/firebase/functions/src/index.ts b/firebase/functions/src/index.ts index 02a70dd37..793ed9c71 100644 --- a/firebase/functions/src/index.ts +++ b/firebase/functions/src/index.ts @@ -42,23 +42,46 @@ exports.groupUsersCounter = functions.database.ref('/v2/results/{projectId}/{gro const thisResultRef = admin.database().ref('/v2/results/' + context.params.projectId + '/' + context.params.groupId + '/' + context.params.userId ); const userGroupsRef = admin.database().ref('/v2/userGroups/'); + let appVersionString: string | undefined | null = undefined; + + type Args = Record + // eslint-disable-next-line require-jsdoc + function logger(message: string, extraArgs: Args = {}, logFunction: (typeof console.log) = console.log) { + const ctx: Args = { + message: message, + ...extraArgs, + project: context.params.projectId, + user: context.params.userId, + group: context.params.groupId, + version: appVersionString, + }; + const items = Object.keys(ctx).reduce( + (acc, key) => { + const value = ctx[key]; + if (value === undefined || value === null || value === '') { + return acc; + } + const item = `${key}[${value}]`; + return [...acc, item]; + }, + [] + ); + logFunction(items.join(' ')); + } // Check for specific user ids which have been identified as problematic. // These users have repeatedly uploaded harmful results. // Add new user ids to this list if needed. const userIds: string[] = []; - if ( userIds.includes(context.params.userId) ) { - console.log('suspicious user: ' + context.params.userId); - console.log('will remove this result and not update counters'); + if (userIds.includes(context.params.userId) ) { + console.log('Result removed because of suspicious user activity'); return thisResultRef.remove(); } const result = snapshot.val(); - - // New versions of app will have the appVersion defined (> 2.2.5) // appVersion: 2.2.5 (14)-dev - const appVersionString = result.appVersion as string | undefined | null; + appVersionString = result.appVersion; // Check if the app is of older version // (no need to check for specific version since old app won't sent the version info) @@ -68,11 +91,11 @@ exports.groupUsersCounter = functions.database.ref('/v2/results/{projectId}/{gro if (dataSnapshot.exists()) { const project = dataSnapshot.val(); - // Check if project type is validate and also has + // Check if project type is 'validate' and also has // custom options (i.e. these are new type of projects) if (project.projectType === 2 && project.customOptions) { // We remove the results submitted from older version of app (< v2.2.6) - console.info(`Result submitted for ${context.params.projectId} was discarded: submitted from older version of app`); + logger('Result removed because it was submitted from an older version', undefined, console.error); return thisResultRef.remove(); } } @@ -81,16 +104,13 @@ exports.groupUsersCounter = functions.database.ref('/v2/results/{projectId}/{gro // if result ref does not contain all required attributes we don't updated counters // e.g. due to some error when uploading from client if (!Object.prototype.hasOwnProperty.call(result, 'results')) { - console.log('no results attribute for ' + snapshot.ref); - console.log('will not update counters'); + logger('Not updating counters because results attribute was not found.', { result: String(snapshot.ref) }, console.error); return null; } else if (!Object.prototype.hasOwnProperty.call(result, 'endTime')) { - console.log('no endTime attribute for ' + snapshot.ref); - console.log('will not update counters'); + logger('Not updating counters because endTime attribute was not found.', { result: String(snapshot.ref) }, console.error); return null; } else if (!Object.prototype.hasOwnProperty.call(result, 'startTime')) { - console.log('no startTime attribute for ' + snapshot.ref); - console.log('will not update counters'); + logger('Not updating counters because startTime attribute was not found.', { result: String(snapshot.ref) }, console.error); return null; } @@ -103,8 +123,7 @@ exports.groupUsersCounter = functions.database.ref('/v2/results/{projectId}/{gro const mappingSpeed = (endTime - startTime) / numberOfTasks; if (mappingSpeed < 0.125) { // this about 8-times faster than the average time needed per task - console.log('unlikely high mapping speed: ' + mappingSpeed); - console.log('will remove this result and not update counters'); + logger('Result removed because of unlikely high mapping speed', { mappingSpeed: mappingSpeed }, console.warn); return thisResultRef.remove(); } @@ -117,10 +136,12 @@ exports.groupUsersCounter = functions.database.ref('/v2/results/{projectId}/{gro */ const dataSnapshot = await groupUsersRef.child(context.params.userId).once('value'); if (dataSnapshot.exists()) { - console.log('group contribution exists already. user: '+context.params.userId+' project: '+context.params.projectId+' group: '+context.params.groupId); + logger('Group contribution already exists.'); return null; } + // Update contributions + const latestNumberOfTasks = Object.keys(result['results']).length; await Promise.all([ userContributionRef.child(context.params.groupId).set(true), @@ -136,8 +157,8 @@ exports.groupUsersCounter = functions.database.ref('/v2/results/{projectId}/{gro }), ]); - // Tag userGroups of the user in the result + const userGroupsOfTheUserSnapshot = await userRef.child('userGroups').once('value'); if (!userGroupsOfTheUserSnapshot.exists()) { return null; From 3ce0f094b2a1de49bb14134859cfe04e23b8d2a4 Mon Sep 17 00:00:00 2001 From: tnagorra Date: Fri, 4 Apr 2025 11:52:34 +0545 Subject: [PATCH 35/76] Create logger function to include more information --- firebase/functions/src/index.ts | 57 ++++++++++++++++++++++----------- 1 file changed, 39 insertions(+), 18 deletions(-) diff --git a/firebase/functions/src/index.ts b/firebase/functions/src/index.ts index 02a70dd37..793ed9c71 100644 --- a/firebase/functions/src/index.ts +++ b/firebase/functions/src/index.ts @@ -42,23 +42,46 @@ exports.groupUsersCounter = functions.database.ref('/v2/results/{projectId}/{gro const thisResultRef = admin.database().ref('/v2/results/' + context.params.projectId + '/' + context.params.groupId + '/' + context.params.userId ); const userGroupsRef = admin.database().ref('/v2/userGroups/'); + let appVersionString: string | undefined | null = undefined; + + type Args = Record + // eslint-disable-next-line require-jsdoc + function logger(message: string, extraArgs: Args = {}, logFunction: (typeof console.log) = console.log) { + const ctx: Args = { + message: message, + ...extraArgs, + project: context.params.projectId, + user: context.params.userId, + group: context.params.groupId, + version: appVersionString, + }; + const items = Object.keys(ctx).reduce( + (acc, key) => { + const value = ctx[key]; + if (value === undefined || value === null || value === '') { + return acc; + } + const item = `${key}[${value}]`; + return [...acc, item]; + }, + [] + ); + logFunction(items.join(' ')); + } // Check for specific user ids which have been identified as problematic. // These users have repeatedly uploaded harmful results. // Add new user ids to this list if needed. const userIds: string[] = []; - if ( userIds.includes(context.params.userId) ) { - console.log('suspicious user: ' + context.params.userId); - console.log('will remove this result and not update counters'); + if (userIds.includes(context.params.userId) ) { + console.log('Result removed because of suspicious user activity'); return thisResultRef.remove(); } const result = snapshot.val(); - - // New versions of app will have the appVersion defined (> 2.2.5) // appVersion: 2.2.5 (14)-dev - const appVersionString = result.appVersion as string | undefined | null; + appVersionString = result.appVersion; // Check if the app is of older version // (no need to check for specific version since old app won't sent the version info) @@ -68,11 +91,11 @@ exports.groupUsersCounter = functions.database.ref('/v2/results/{projectId}/{gro if (dataSnapshot.exists()) { const project = dataSnapshot.val(); - // Check if project type is validate and also has + // Check if project type is 'validate' and also has // custom options (i.e. these are new type of projects) if (project.projectType === 2 && project.customOptions) { // We remove the results submitted from older version of app (< v2.2.6) - console.info(`Result submitted for ${context.params.projectId} was discarded: submitted from older version of app`); + logger('Result removed because it was submitted from an older version', undefined, console.error); return thisResultRef.remove(); } } @@ -81,16 +104,13 @@ exports.groupUsersCounter = functions.database.ref('/v2/results/{projectId}/{gro // if result ref does not contain all required attributes we don't updated counters // e.g. due to some error when uploading from client if (!Object.prototype.hasOwnProperty.call(result, 'results')) { - console.log('no results attribute for ' + snapshot.ref); - console.log('will not update counters'); + logger('Not updating counters because results attribute was not found.', { result: String(snapshot.ref) }, console.error); return null; } else if (!Object.prototype.hasOwnProperty.call(result, 'endTime')) { - console.log('no endTime attribute for ' + snapshot.ref); - console.log('will not update counters'); + logger('Not updating counters because endTime attribute was not found.', { result: String(snapshot.ref) }, console.error); return null; } else if (!Object.prototype.hasOwnProperty.call(result, 'startTime')) { - console.log('no startTime attribute for ' + snapshot.ref); - console.log('will not update counters'); + logger('Not updating counters because startTime attribute was not found.', { result: String(snapshot.ref) }, console.error); return null; } @@ -103,8 +123,7 @@ exports.groupUsersCounter = functions.database.ref('/v2/results/{projectId}/{gro const mappingSpeed = (endTime - startTime) / numberOfTasks; if (mappingSpeed < 0.125) { // this about 8-times faster than the average time needed per task - console.log('unlikely high mapping speed: ' + mappingSpeed); - console.log('will remove this result and not update counters'); + logger('Result removed because of unlikely high mapping speed', { mappingSpeed: mappingSpeed }, console.warn); return thisResultRef.remove(); } @@ -117,10 +136,12 @@ exports.groupUsersCounter = functions.database.ref('/v2/results/{projectId}/{gro */ const dataSnapshot = await groupUsersRef.child(context.params.userId).once('value'); if (dataSnapshot.exists()) { - console.log('group contribution exists already. user: '+context.params.userId+' project: '+context.params.projectId+' group: '+context.params.groupId); + logger('Group contribution already exists.'); return null; } + // Update contributions + const latestNumberOfTasks = Object.keys(result['results']).length; await Promise.all([ userContributionRef.child(context.params.groupId).set(true), @@ -136,8 +157,8 @@ exports.groupUsersCounter = functions.database.ref('/v2/results/{projectId}/{gro }), ]); - // Tag userGroups of the user in the result + const userGroupsOfTheUserSnapshot = await userRef.child('userGroups').once('value'); if (!userGroupsOfTheUserSnapshot.exists()) { return null; From 682d0b7d3e711d32dbf644eb6f57508b425b28b8 Mon Sep 17 00:00:00 2001 From: Oliver Fritz Date: Tue, 1 Apr 2025 13:35:31 +0200 Subject: [PATCH 36/76] fix: set factor for time_spent_max_allowed in street projects --- .../aggregated/management/commands/update_aggregated_data.py | 3 +++ 1 file changed, 3 insertions(+) diff --git a/django/apps/aggregated/management/commands/update_aggregated_data.py b/django/apps/aggregated/management/commands/update_aggregated_data.py index be8b82989..daaab161f 100644 --- a/django/apps/aggregated/management/commands/update_aggregated_data.py +++ b/django/apps/aggregated/management/commands/update_aggregated_data.py @@ -7,6 +7,7 @@ AggregatedUserStatData, ) from apps.existing_database.models import MappingSession, Project + from django.core.management.base import BaseCommand from django.db import connection, models, transaction from django.utils import timezone @@ -55,6 +56,7 @@ WHEN P.project_type = {Project.Type.CHANGE_DETECTION.value} THEN 11.2 -- FOOTPRINT: Not calculated right now WHEN P.project_type = {Project.Type.FOOTPRINT.value} THEN 6.1 + WHEN P.project_type = {Project.Type.STREET.value} THEN 65 ELSE 1 END ) * COUNT(*) as time_spent_max_allowed @@ -110,6 +112,7 @@ WHEN P.project_type = {Project.Type.CHANGE_DETECTION.value} THEN 11.2 -- FOOTPRINT: Not calculated right now WHEN P.project_type = {Project.Type.FOOTPRINT.value} THEN 6.1 + WHEN P.project_type = {Project.Type.STREET.value} THEN 65 ELSE 1 END ) * COUNT(*) as time_spent_max_allowed From ef3de7e5756e7826e7dce0218ff0e84f1bef409b Mon Sep 17 00:00:00 2001 From: thenav56 Date: Wed, 9 Apr 2025 13:43:46 +0545 Subject: [PATCH 37/76] amend! fix: set factor for time_spent_max_allowed in street projects fix: set factor for time_spent_max_allowed in street projects --- .../aggregated/management/commands/update_aggregated_data.py | 1 - 1 file changed, 1 deletion(-) diff --git a/django/apps/aggregated/management/commands/update_aggregated_data.py b/django/apps/aggregated/management/commands/update_aggregated_data.py index daaab161f..dca5896a4 100644 --- a/django/apps/aggregated/management/commands/update_aggregated_data.py +++ b/django/apps/aggregated/management/commands/update_aggregated_data.py @@ -7,7 +7,6 @@ AggregatedUserStatData, ) from apps.existing_database.models import MappingSession, Project - from django.core.management.base import BaseCommand from django.db import connection, models, transaction from django.utils import timezone From 1857317013292d6e916a81fc63aa39499d6ca8c7 Mon Sep 17 00:00:00 2001 From: Keyur Khadka Date: Wed, 23 Apr 2025 14:16:11 +0545 Subject: [PATCH 38/76] Add time calculation diagram and render images (#1013) * Add files via upload * Add time calculation diagram * Delete old diagram * Render diagram * Delete docs/source/_static/img/mapswipe_time_calculation.png Delete old image * Add files via upload * Update diagrams.md * Delete previous image * Add files via upload * Delete docs/source/_static/img/mapswipe-time-calculation.png * Add files via upload * Update diagrams.md with descriptions as well * Update diagrams.md * PR fix --- .../_static/img/mapswipe-time-calculation.png | Bin 0 -> 124195 bytes docs/source/diagrams.md | 39 +++++++++++++++--- 2 files changed, 33 insertions(+), 6 deletions(-) create mode 100644 docs/source/_static/img/mapswipe-time-calculation.png diff --git a/docs/source/_static/img/mapswipe-time-calculation.png b/docs/source/_static/img/mapswipe-time-calculation.png new file mode 100644 index 0000000000000000000000000000000000000000..ebef8f83ba341f4dbcbad8389b3e16c11aa0812e GIT binary patch literal 124195 zcmcG#bySpX_XbLbNOy^(gdhS#N_Te+CDKE8mk5YR_t4GIHFT$lbT=bi0wP^!e82bm zO0dCc!R-@n&WlT`1sCGE$dV{K1pvDj`sUdS9dwB%b*<~2>Y z8#^YI=jFB z5E$R(#g7?_@b8aW=_&dDwg2;A228b5M$FIQ3IE6HSorqY|1Sdk|Gm8!P#)0BnPWrY z8H%!>gpgh1(?2{UAA}{^S3GP{2rt3eckJ z$hPnT47lu}k?8*_d*5?0$iQIU0)CE`t%t!VCDT7IAvlpRM@vyaLnZYGe7{Nm@FtOe z(UV*tQX*)tQ`XS*6a20V$(W1j|K+PJ<$^RSaMDHmuAE-#Rf~;q+Rbw+fq5`dQBmDf zjaX0vGdX zM#{h4Ez|G2B}r%17}0a{S?2z|O$oLad>iDR?ZSWCCIY^VqKg?RhvCPbo*qykMgC0u zhsCaN%~AlTlX3LF8P#*Rei-<)!9*`Jgvq6Gqf0K7B6JK+W(3K?;x>uBy!_#&Qs&r` zk^OIrMSd^7p!27xQ~zSpV%%_xPTpjS)Jo?WAg(*JDu?_Eu_}=R$doZ+wmicv)+&>2 z$^ie{`7r+yuBB)+dtyp|0(7=BCMzy39*)>Jx^+1B0gKISfs1Mq=y?+Z7j0=GHR0~}x6 z8iUa)hE8p9_@p4oeH@Wm+$5P$`Zx z71{J0Uv8Ymhz=;KibnOH?U*fXk2vvXI$4>EWdxny78nJmahj5a5!j1llxi31{exc5_i~-18{kEgKT;@{V~W8H!aS^#-j5lYPO#9yOD}@>zr8@ z4&f()FdaT%l*3(4P!XynRVHuGr@c19JFm~v9hKPlBI*$-mY@BZ{GaZioF4S08ue@& z*8C!?OLaIox4Zy_Zaz_J>ilFx_KwNsmTuNDm2Sb@i_7rK!QnvcMk`&Q7dn52AX0#) zJb?UXxSYUsU#_>-Ca}?izS?#**h!EJ%zMS;a>fQgA}GT7yZS$Lnuuw)P>;!N;)Td<^{vZsIHh;XjWw=FhMBCF zz>;o3X-W;>=4Z1Y15tgh5W{!8j-ZzH zM36)8N$&)AqnyHHn}RmnHA1KM$INk(iYHJ*089XXWvRl-F??BW1}b&56ABoy_wb zhO`&-$m|ANv{gpF^#|jK)PhH!lD#X|U7EE^ccbl>svXX==bHj5N;@0u516zPle%NS zaLX2A)CJ%IkeH}Au){MO(b1@8@hhLAZvqrcAvpkxIjL%L3^I{T^TYSTLeD~?D{knA zr;=M!W#EoOu>85jinx%oD8F5_AxSGq<7Zl3X5`(?WJvp??9 z$9#^};bbIGVMtg3I0H`spSZcmhNIr02Hod~i-H<>l+KC#EY$~Io?vn*OaC?$l)!Sb!Hvu#i1XO;1>1=Nl(7eBaqf5JcV_66qdrXm!f<< zkPbQy`lL~}ZCMW-p;UnAaMM2%|3`QT?kXi_C;tYK}`kx3vMQUdjSkzF~)F>F(jl=GHH zc-S@G01>8D4akG95EDVlej(nzf4U*U+j}s84B%D*Qo_410*8rp#TY!^GlbDN^Z4cB zWSG948LF4s0t57^hPGYy^lI1rm%S#M@iV2^)jduVZu2c#Kfld{s_ndYA^|liTJO@8 zt^>!b$Yl_oS#dnf?f~mY7edCwkbmbt1Q%AJz=0M+L?dhV+M*IjhIPEONXD zdEEE3&uWS~)zT|1KgO#A&RLgT_Cl|`7x=~thl8I&>b4OERu^zHFTRD6bM{QW%#$q9 z-XCF_dG9{={?yUlAaP96dRzQByUDk- zi6V=I_u})umkF^63oPsxMG*G)8tlQ;)FOWTPg?ACGWiZ{`yx3Mc@;*0CX!NR`V#i@ zU0-!NusWEPOJofFot4au6`)egr(}N?4%U7sBmwv6Oc~BxNa;Fgq*XStH#YZZKVr~} zJ285lLX8_&3|P0oEtW3JTy<1U=aY&j-5L7h)X^tH-kDsdG2!T_s5upV;F~v8kfKB& zF+}IuUlOz7?niO1>`w2xi_XJC)-tQN+sohn{)UvUVOx|33H4r(!O86iCxX~WC!HB^ zuzyYszulvvv!!>F*Y>@9{$~l4&Ql4LadNU#i*cp5`iUTSt1sk#7R}FBFz$Bp*PPd& zglZ4N;o}V9T!}vg-kt<|{R(}|$$KNCJEnyRWsnB)2sJMC3C|fud0KU#$TAaaflA$1 ziiuTP8BJz%u{;qgiYkGesM8!P?|Vjgo;GDJkhL72TPL(ywb&cw8{rlLkqlmFu*WeW zVk&7L(N0WQK%+G3I5bQqK&KzT?khc5y1DDSWzQiwd;D-j=B(Sq^yf8~P$NQi9`Eux zPYyN5OwVp{QD02(5W{9#k2|g|xnK2cIj9RXGj@UKr$GwX6)Wr|b$1V3%U<2hSKCuD zAvR^-2cHrSDj!CzqC>Gh0SrC>w|3Oy>~sXJ^{PXAmX!#@Y~Iazpug?PvZCeASEkk( z)|@f3EamU{MP^f<$ce|&Wum7y|6F6_nNh>rZlsTGrIFvtVyvOy58$8#Yba{xFKKmk zb)PqS>Sb^@9kz$%{o}4}kOF%9e)XrH{!FJa+ZfjEIZ0;DWuoWi#!?;eL#Gz>jKTxu zI))!S3|3SWl}FgiLy1B1QZ?F%#2e+kVef8d*-dMhI(r{BYQ(?TA4MqKK9w%e2fkos z*J`J7Fn;U*?2rHq1BC|oW=3#Ohlx$G+BP$`BaYm`BLc-Ugg7}YD^;0^5PFJD^_sk4 zsZx3D#+F%UMR<8jC!zeD{i>FTuX8$6qe483%=p8fXaf8KF-}G`u#LJZ@QSU0Soj0= z+K8rvGF7tZ`e4YEaeol7XxJ)-zEE&sD5xF<^25`=^+}3UGU|og!J4YOWwt?_LGLM)8j#F z;T+WAZ`F>SPnul7JCa(1twmajVx^`t<$}W%QkO%YI1)Av2nsEYUTMP<-xGM9LTp^E zKpdhp*ewTjTPCiI_)`>u7teq0ru8qKZ0n}0-Ao~sdvAfPRbqa=3l5?|kKre1yt^H8 zGEW(Xm@XDC)mTg0a3X!(+NqWLM~$MJ>xg;g~2%8lxOLC_YUVx>9UJ6){$o4^BdNl)CSFTQ74rQ@#qCv!4MsW^j|mJk3ke6EUDMI>}~>R!gwzh*uG-`>6+g z?6UP?`Uz5HDyfBXMGKyNy6W$-VuYv;=I#oOlU?+UZX0?ajoZDt&z`)E0LiJbOuUn- z)qhu^&<*XhOq_w7_OXy93){o-Am62V4r#cSq5)bvs`>*lwn0MM2&z#?0gD*R zb!4d`9DtZuYa$^ZnvbO}|NdS*Um`l`JD{pMvCz`Va=xJR17yNluJ|-r$C`(9duvYD{UK-}`u<=DnBQZ9{bmb;}BQf?k64_FgMoX}D+`_YP+-uRM zjBX-e^gJAelzMYf)SeY%0Ip-10k&{RHAjv7YhUY9mVd$;+VTXTZzJdlS5Mh(UEe>l zVGb=O855@kjHK;SlAH<$d)2)&$8JVhMwQMi=#9eK60AR(`UCa?8j4NrZ0Ra17Rn4`t_^h2r8YDk9bhcOq3_8M>h# z{vbgdDqbo$w$_6>;|JUvxF_Cvh{?Kbf`tj#$)ZTggi3@plq+-lz^2cKr%33-mqVYb zmI2o!G)3byD!|kv&C_b|fRR64ZZYzJdF3tSbTk3+CbIpcqZq1|kn~AQaSkUZ5d`s0 z=EwR60w4-Lp7V0PFUNKg;7RRpNvtvs8^J2haogG%2N+4~V$TO+JUH$v#tPabFUN1t z+&Hmy=o{g?Lhe1!-};HOc_An5e84TQq_-|Xl;}d3wL3Fgpm?QYe_>{zFD4J6iitJ? z3QZK(QMPV1ue%^XPi=qR9@wX3MLv?Wqp!f!E?FdwvLIt&DSPLn8{wiRyJYXSK1G>c8&tHtP%@t~l)mH&zb-Gtl8#3G5jZwtq*?rZCb`?z)DcL0!Qc z_;AOnjH1CfT@Ed_MTav-VK=rh|KLl5TL|aX9oFCP%yz%fQJ7j^YJ2iI*bsg zmMo7lXOAl!)rn0L5rqFlXbK_Xd8_*>N%`V_SuI|ggZc5xjA>C>0Jj{I!bq;|m3up> zrs!gHXTuH58B|;Ukyi5ti3#ZCmchqEdf)2xoPZ;Z)|KPl4zp5phlro5n@6>IZsGBs z!rOjPMu}14C~_;|bgt+TP4Y)Up^a4dcu5OL2hT{$2&zWH^7bPJPxYV!J*O89%aw7u z;jH2qJCra%KY5`^p#BdVx|}S{kNU2rL=u z+gtqYuEsQ0Dk^^#)+~&0)8=iv^^YJh=fqBYyk`BHJ)WMR9WS-u`NP>+I>U>9Rk=fGW?nT@48QQvjK@dWX8F$;%DcvZ4!#7ufe&=f3U_XeUN zR`#X-izVvBT1sV3=FfzAf3 z5cvRRoSCdaW-bKQc{y$<$Nz8pg9i~wwBr1`NgXdMjPWO{S}pl%6q|a@wyz!d@;G*a zYl5o^@SyoiAV=HdUdurlxzg8`kheb$6iAaABI#>^Y8$1W53V13(iwriy7q~f(tkX` z)l6YoUh)HFv_q6q3W>LJvq#2HTkFevV(6|GMa5x zjF9isAEQy1{6ZOP`!2a<5u{TO6c$GEONy9{B`r~N!Z%nJ$X7$?XE*P?h0+KEp zT0Wm z^IR`6MO;>TZ_Htd-R$dS;3SF+7KO@@>@(JfWAHHS!o0-snA=J8&0SC6^v=K-H|%kf zqiY(r`-sbblYz#+jL#PDWBYi&hhM&WWn(c77N%pLKIfjl;UM!E-dYhbS75B)SKT8{H;VNY~1Z-O8Xb0Z<0YnaIFt3N!J4-Uh7s(+_h_V($0LHHcvRE^ta!(B5U3s-KSz zzQ;j>EHQ-1NO0{T7lpf%C7NZ#h1+eJuf?A8o?C?-fbT=|>eb*>WFvYyW5L^xT9h@O zOe*9Vny#{HgnIY4RZS2w3PK?qP zK$!BT6DM6UDre`3bZOG#Is5u$_fxI6BataA*Yurh0@NyPzq)t6*r(}AmkwQv+Mj&? zf!ok`*uwH)J*i$Xp-(sPT^{;a7`OUxVR_dvl&w}gCPnUMx7S5&wkYwVanTfMsnM>d zIs6EhJ8^a2#rGr|S5$JgYK->eJ`zEq;nng2H_`j;SK%F(Lr#ajB=v_E_$doIKhcx@ zE)g1t9}$(O?A2{w@0YSHF#X^NT3wOl2C_@VYp{bwefCF{K|rNnUE zFFwPe`hr%0zFM_F_yg9Bv8)#6kio@L+12(O>H73aE)yDD{vo&AW-1@FfzPQ=il-7b zsrlM_S>QGRd6P4ccQZs-X`cv&PA^K89X-uuIv|FGWpp#MvMQu!KU09#FFfJ+2M904 zYq}p%zH-r)uHGn~@g_u52z=BSVwf_Ch7v$XxU4x+u&UVaNS;CXNF+gWk&=5cR#n>x zLTzUo^>_$NZqiEP$V?o0P%k9R&LFJ%==BlNMZ)E)-;#@(N(f&8 zh8LtltI6Kk9~7PQ2X2bP3VpfiPA9R^pPLPinIe)9nZEu2_7VO?gILzO{za5H`oRp* z%F;_EFb8Sv?k82iy_vDsD60m$TU7bC3o^jn1k)7?lc!=L=(U%8!u!8_PD2w)>$@%R zxRxTv1GKk-;|?}B2ZoD^`_Fd18cNoK6Wi)l3yR8B(wh+4IDN$PV#m7ahxofOgxL-* z{G!H}^TgAfoYz~86Zuwa3J}W!1lDW^R?PL5=1KPYeY~;h;snYRnn~6ot+Q~t8I5*n zx^f5d6*OuW>Nw58SWw{0NBorNXNZtOCVD>2!N?R(9GRrhDUqbpPj-5E4#s%rK3%x= zhe&g#o)Wez)UjYtZVl*Y@aph(=;XS<477EZ^nOGOdJs+K^XeziLlKZH1E47cem*L# z8qJ2ldHm#;6ChttR2kgQ_ur1}axE)ZR|JE&G%D2FWu0sOaN&`UUHYnbVc4Db7*i*# z!9|mz+I_$i!t-#P@@#MV&)?nm0rC-Td%-VW{~D*KJs+E{mIquT44yv!OWIRbt_NnzJeE=rUG$RjInISB!l0~MCuhEMI{yF&`IU-m`qWTG_|G$HLK z?)24>dWK+WFkcRH;Qs z&bQM_(Dq>OtQ5f*VAJLB%+PTB$%7M)HC{_OY)#sr#tzEoaAR%XP^?J#!r|ErTD&pP zV%|7@&2;U4%+>2|+ZJ|#LO+A%Hr$n2+!(%Q%WZz~5&Chg!c0Q9W= zJ{(Rj)a1=f%9jiYPF4J_z!SWVzyH|~(3)KtkD+1Bwv)ig7i`_!-6-AnQ4|PNjV3r8 z6meFOT6HT4Nk7S}D6RSTzcDQ-Av-m}s08t&vt$vXzY038Re4dbJ^Z9oqSvb7m`3Hn zJK2auc9bagxaG}#@w(X{S1Xc!Mq}lgdqy2JTbrLhGpSsnuTcT#s5xyW3pTSxeFVYq z@q;cG;6He3#^4~+@1G6UdItzzy$PdfUL|l_t$9{4+>jgEh~hvW#HEX-Ae=TImlguG zSgN+0NPG({K{Gcuf4iOMm?Q8$&TwKc`)B-AF06U1!R+Cch4WUb8&bQwRyyliiq?p& zmHm4j4Y__Nfgm9j>&T3!?X^t6UwyPYNojjsTQ8`hh($RxW#uULwFm3FzxXMB>EF9x zK{rtMZ1@6odT#8di45=-plXi^&3}u;qpIR19cX`rL=zC;*|Zk?jjh=8=naY_nqk%x zhA0;t1!#$VS*D*qn*2-fhafyefS4>C>ml zcgl$%n;L9Gls{RA511d%nonv}oxdpJ!oFmac5kqSNY3?6DiLcY>;g_@!4V@G%xfD@ zX7!NYqTG3%-j7YR*blB_sRL%p=Ngj9n{J|_oD0D`?7OoAp_DPBA+PY7#R)1r*4o(d zx^+@$-N#1-_(>%%zef zm?`X|%oKSOaAJ!L)S+xr2|xozdcX|WS(btXum*b*E6Z4kr{S$jvu~!Fuv&Ng3as=2 z4~|0MbaY|MU)+)xNb6pN;&!ElNHL`-TP>kgrSUiAa(?^L#p6$dkOOy>YBN)NkkvLg zAW@B$YJ97;${j#8M#wi3wY8iGUb
8`CkrwT*`q$NTSw+^w)IoPP21!f80;SAZ!2{%NSt?KPvYHr_r z_44kJgeb*9C;Y~bnE(5_?|7wqRY7ALONJN(UQd|Lyi^ht5@ozKSW^epepxa$`ea}t zulgIP^kC^g-dwto!N=vQu?&&_p?wUwXRx&~gw;S%h8_uZ8(MslL^Ik5;&{-{Nl6v# zDR=BVa3A{7Vj*Qw+l2_v3chaXkoRg(MA_PW0bPgP=*xFP4Af9vS-#O*1I0~?Jv?&D zh!*nf!>*oe#SrLPJQ+dKtQ9F=CDj5efA(jJylEclriX8@ueMsNa)(F}aF!ZTEBWbg z5zEes0jgXhn0Zw+(Ry{Bgh@YgjQMd}Fm3{@-pMuxs|M&r@`>EMLUT_Kd1^(D6pVm& zA%NBHOO=iiQRE5_chklS1;Ek*prjO}uvM+@q)78O=~Q%vHX!szR=2)G`3AMkJs(A9 z_^c%JG??T73TO#?tzu(|muRqMQJ5;|`mE)0xe*9qasb_5ATYHDB~6XN7yT!0bjcR zh2YlpC?x(ER2~fDbV9EoMSTWp9hv)+H9f3wewKf~Z_2s9KnQjo_9Sm1WXO&s*TJYa z^Vo4b3x4EZL|E3Qief|4QVP1b-)D2TrX3otjI@3s&t`XQ4t6G zQjn)f645~=1xwQ;JEq~t6ElKu=~Lc*x&N6i>NDde;!%`Ien$5?i=yZ_|60y>$4a{` zXb>lc!kJI)Ob_$ld%+cmc5Hl!jbBf#cjqS~Ru=guxD-(E2!{4_^~| zQ@?>1t*^~3id1|1fWqNW{s;~Bu;24###o7Q#h){gKJTMaE!B-`YNzGYU;>(h-S&Tz zFE>{4+<&lbv0@dNN>?vVe%1F)iP~$}qX!YL2sVTk0H3c9el%0*oADIE=$z@`Zm|hw z_c_|1<9p}p{r$Z2J}lybor>Qt+wSS%cX5ZJ%1tl?HHGGrw&pu!y5+=Zg`!_Txe|d) zLF4orW@Eq2XS=y*sA&$^Q?K@)sxm7!)Y1lPr&oY(iWG#kf3mBTmb4258%^qG%NE9i ziXyfiMw^;9-H6a@UpMueyR8Zb<>RztXD%KY9LVO>WXVA+mzqPdNWX8hsfxq9q5JjO z=V=!UWTD_LrQ+W)+})?mo9oXIR*>9vT8w~2BK7qJi4l)JhkBt+>W}f063r0$ zde(5s!6`=$nWc+NE!}aiOfkkJ*Zp|=D=%qpja5Ys`lzP`+`F;DTuxGJcJ*V}0NH8_ zT^X@b`Euzb{iRw{b`D_&vd?0iQQCbgxwq!9uk;3kHfnhE72L~5Q%uwEBDhNh% zP5y>i_-)+I5r3HPNO;$JNYEDB`%6j^8)v~Foe06HYH8$Xj{{aG#pU#m^$pa5-_x3D zkTm>X-cT*MGob$JzBYh)%$m2qbCj*d432o&7#(aR%zT&>`pn+w1XYP)GUoYgIXOAb z@~@a0y_+FAYah;+%9veeLOI%&a@0C07d8SszKOtZi?*~OBrChmXT3cZERh6}uDaJy zV{U&=%m8SRao;?l4>Owa%UT%E)F_pfhr*}I%Bz^6123mv_Wehih4!)2us^7g$)un) z+{0$RYJcB~LU6`ahh%Rj1CV`l03OIkTQ)%%fBJ20 zRrf39pI9%E;QacrIk5hNe7>w*amFO!OM_I{w0B_O_L@)_|N*5Zqi8DnR6_mk* zzm!b^^onp$l>=5h9PSr}thc_G*<55LVZMwW2 zutjYt7#b9f&u`kAY`>n7IlVPilhNNYxBfIK;?~#j<31igj-GC^kE)*#sxW;c8?ia z3SH=lj3^!1{t!X>%}%R+1$ig)()`mdAj^h%#?DuJ4qgr77tU&Z0KX-HB;))Ae8wn= zbpIIR8SlfqyTju2!gx-=685sJrV?B+yQqUrb~Fh~`lf!~g4Vbc#Zo6Cc7T(~M&D+q zZmU9_dS-;Vst43?%#kj*MlOoCE&h>>+$b5jfax|kd)~A;Z_4S{@%CBSv<7!D%Z|I) zprLH_oS47IZT(B*cx7x}imvZjvfm3fUd&MTx@D$nuq%|#6rL4JL4*OU?glG~AHZtz zDS!3Fd}Ra(|8(%_h^~0mpx$UC!`wwHb$n}p0-(960@6*1gWV_q@Ors`Mp&`bFgPs` zx$a`2Wn=g}{Jy0-vUS@&Sh8n!-}z%%{Gzq&wiPV(?(ienR>YB|Qq=XSPL-i?+J#Lj znGg@@3+&MdB2e<(r~Pk`j&j?^F@|&!=AoT4388(TCiD!om|v5wAO+}nhuqFsPmjd3 zPHCZ_LIng9(4FyD{+T=*`B?U6<_$wxO-0TSBsuW{MQhcefN$zRPfFUDqIbkoqDiSK zmUB0hdc};>J(`Xe(ssw2u^0fFQL&P2rxaw5`)I`Cvv`uAg018I0dGmyxXj%Aa&sYv z=5?OChpEeP>TVenjgTWpVQ%M@65f+rGtUbxGG_7ue4YmDYPwk^ zfSfk?-agHkvK4k{-gUEEs+FHnuWL!UINa>9e*q1d`qWf5nL7nKB?sELB?1%eu!^Gw zK01_rlZMJey?WS-V!3m@UDmTs#Qn__Td!GW>@+GG{&RAzQ1JO?vE=XiuHKjC`};o$ zDi*}Eq2e4Pks2u^Hpk@fX=bVh(^(m zlCqf?*?c)A!xqmBn~2vcE?6<=o=bXmUzuSxbokJ6NihR073sclxj22R@U7o#Nw#Q_{>c60Pbfe=#w(`x44( z|2emt57zg;+4Fsg^>CVit|sRVPUr7ZpDB`hdMe%ZiN^k(;tyuPAWO*pAOhRZ(ZY(X z8%JqiLP=Vy)32hSpiBf&RJ!h6X`vT57pgP|d&6O;upRp*SO~3)as;PAB3M{roj_=f2d7XA>nL9iDsN`^dlny(sjQs{ zbj1T9@lp#0X_PXae>{8pR{#y>fp_h{5-kVZ9+3&UFOwn*3X7&v_9yAU5NJ zCiqH^iRzLlz!NfqdZ2q0HUg0?#lz7w=bX)q-qbYXXEB6|#rl5v9pewZF{1DhCfv#_ zXvaf?yg8UW=rQGT-RLLtx9{gc&&sozdU=pf+=gU_d(IHUxv#+c4-k4;w(hte{mgCw zIHY%K@}<1aB>06o>GfmgE&f2P$Y{S|JIt{p_4w)s>!OiwiJ;ty*yXK79u_%ixJ>w< z_kw@PR5)^rOA3E)Wc(?7P^22M&T^zjmd5|X`N3R%ULF!MI!@p9PIgR|uD|XlV*4*{)0+9&#fWqmvlMtl*H*)A~d)6EM7DGubp5IcnrF*c9>DQx|v? z^@LrKc(tlt$DNiI<}A4WD!h4H(JSQ*&g0^7PO}8k1Q>{XVKarA9+MH|QcT!lqvLV< z6gBQz3Eg9cZ@v7pz`W$6WQ6$CG+nFzEW5&q zykgMOH8-(3XavqQsmckuA=rQGd;|FENzU=tOD+;9t3C(pNkw`Z8UzVL83L}c+1c4H z#_7EFJwnHAAt@#S$sQ7zZNqC z2z|*A?L4jCea+oCk)HF1oqDs~qU&)z6${;yq8WVhdCZTTT`f3>Mcr0Aoup4;;`1&M z1$}5;ZJM}p54()jM4J(;*~>CMhvE^I2qF-6KO52G_vQNZj4JLV`jc8hO!2;2M?o_t zeE#j|@OPHO6y1Ik`^_Vf0BcOpN$rSZo~tJ%Zd zA}M-9!6JRkoA+kX0!$XZT@io#TMiRauw$~sqX8{XF*jF*rivC|s?X)V1a4E zT4=UM?~)p+Oe8q)oR3qHx9h1F;s|~RwtHAblK~r{*P2Ck#)?$QMoU6GPT*|5#emoPpzVkikXZ=eVbzlYsx;ft#o0PykS`$+ti*#K3q z0b!FV55DGO%~Cjssm&BUpeUTBf~SHhdTOz##rX%K`z!U*LJWrD-*_J$HsxA&Y>G3B zMq^7}#{p+k1zedbo4!9eFZ@{D(xF;q+)lx_?1a1;_;`N;klM`jT=nMPO4J;0aX(d@-S|a3pb{db*3a#LZf+rjcD8oUe z?>$}zlytRXZ7xbfdul>n|I;2L(H@P}1L5IQjuadlV=0#vB_8NuD_1m;&E*pd zklbxKOA?PnU)p%f(+{`bhR#nu6kH}If}pV=_b3h9Q3>Vt+@}xf_qHuk42Pf9=S*fj z2Eg9Rzhk)dA<8C==8w+D03uS-jiPjiPfeMxIYn20` ztlQU9Mar6Y{QtF55eza53r^o(Vfp1R`TYr$G2YFp)op*AIT7=Jb&BBxaz|1nsmT)?AZ>9nJaD9KBYQ099C@o} z%Fa+Qx^W=1k6|>aG?j@wi>uE{(7;xE%-8!*yI7Apr9}z?@XTcr#hyfTgZD{UW+rbc zf+K&wISHTcKe;;_+h{qcl=Sf6?Rb2+6*Rf(CpGyXWjGWoQ24DE?z&}B4L2Jxq9^ba z0sBH9Q|_|-iI^>McL|;x%qm9?&P&6v1OxE?eY1@S_H_{kqv z@7ICtMcZzw28-sInv(3@Bm{2>$S;yLd+SuN1NSJnDs};_J6P1Vm;l>PPWHU%S_#*z zJUR41Q+G@_*r_KUxHQ=wNLf5T4F(4t1*l-8k{v{9c8>6<7YV)gzG~K6_UGifBi5!M zCo4jGfplfH?K*GpNFCri4QA{t7u}e7TUYEK7K6Q_)t0EnIO8{t`v$o(PM5dUDnmor zrNo{3jD{i@K3oOzL)a%Ml*uLOW7EJE2vOJ4(xN}U;m@g&DV3KBCjXISNMl|)+RQ!W zM!AjSodm|Pehx}tA z(O~D}{joy*LU=px!a-GM^uzsacqea&q%jbePkKJ*^SM9$vyhf_p zFMv|OvJ84^fvuLwm)#V-Vc5NiESl@5Upn<)@NQoT9yeeHGSjl`#u5^sBq=JNYrL?M|XqJ5C zDPoW=@~mx_OL&;$uk}F+YUZQ>1dO8a#e?c0tnW#ikAigwZfLPI2p6xF-T0K{gcusB z@QB+latsFFgnz958iwhqjWW)btcxXGEswPOv%%=}s@k=>Mf(Lm@Z~V&gx8!BtwKvLC7BrKQyT%Q;p)nD|$|Fkak>rWVr5 zGOm{G)vqa4`m8k9 zF=0N#bo;%@!dRTbIg>zPhqhChB%sd#xz&dhwz%VXRvYZr*o5ZGRbNNHD_Hpc60Yzs zJeeqk@};=8OA{|xXeythF$!2EQ;>yxp-?g9q-_?FfZWO?{k&z`*mpb4Dp8L}{YAV= z-VVGJ&Yg5EB*{FKZ&TON2^;2URl@ApeeM0zoXWWmAF?Pi`kzhto{BaP4U#M!R# zwQklK;|@XD5jcjNa`ebs)?-zLQki7r&C|y~@c#1iRHim?w7_+^sl$U@q5yYJnx#}b zAVR~o*uG!ya*1s74iRyR+@xgam+5QL{88{t0Cp|=didGh4s71y*z(sjnj>^U5enz+ z@~W%xBIKZpMgAHk(hr6aT4jppYWUiU$uEP)UZm|r;KpgVsBmcvOf)MuCDoj)_%*5c zs=r*n&Iw*)ve7b~e_@zZkxKeTdwIIzxdyRra6g!Z%vs3sEF3kiRg%e}th8UQmgV)8 z`&a{3>ZxAs4B^3M>a0}dh``fo2?vqY2`z53zS(kL6I4a?sb9re5o9*Pl~;mM&$s5M z>25%LadCQBH^!>Y{Bu+xGs-xnjYe)d>#j55;~N2$tc?nT#?sMBm&c!%MN3DOd&yV+ ze=|)VpT8A(O}v<-i>&cD&CyJNZ;jtSCNU)wS*v#@RUH2na+(J_T+_^!HF|M#dtsI* z+PHFE#z8&Sa{jg+eDpJ(*C!w}cw&`*FUO@CziZ%dw#l)t|El8Lp>#r0>Y@nqN(mju z^PaT0Wi5mK_A;LWzXWx_&iE~o3huIQ2NpX$7iEsXo9z+fQhD~czegqj zKQ8B4x=M_Pq#^1h{R2QaS@Sb8L<;B2W1nlR7*DVpA#s#{ICB`~El$zk&6WVcsTp=e zG{FMBL`g>|kSF+e;je4r5B0eo6*}&N6lay@RSrZ^7E7f`#ST>$7k*u2>`|I!MZGT! zNCPkk9>EckmbxiT;9X5=<}B1!S1DV>NZMaMMN^)X*xL#_;*BEDdkqlu2s&rHuVvHm z9I$5oF_B6TKKHFsmS6hul{|J5v#{@Unjiad3>U+D%~^9A9oCY&dsY|rS+A`mU9Bl} zd=MS!QiFXNP90yEoD50P;M9=zf8uKaGU8cp|9DtT;=2-U`oU5{+lVjR&D~xEFY#+g zoI8#_=M&%Cmm@9YJtW5kZ=dOU+>SS#v7I-!&_q<{bUVguzH()M_q7>$ssU=8Ibzbe7nW(n^X zrGDRYVAcVA;4bfJ0ZG)|&&lz<*rB@*yvZ2Z1GS@43w1b#c?DjEX0)+C+>Z$4DU)`; z&sG>nP6{*9jj%?)s#TDRBI;G0iKQ?hh;qL>9nAN={PDV&*oGlapRUKD^@vy7Rf!>c z8a1KCI-Z1uCEBs=G)&Nhu3trPkKz0p?if*n&wQ2qIeL_$Nf$2q2vygkg?~rYyV3MwJ1iVr)lAalg-Nq*9ez8|MNagzZ9Yc9Rckpy~!s$+S z;JYuev@q{2FJJan^B+lr|7mgXm?0T7b}yH`;;G^jfG~XlvCZb?PnI{olkVtmiM|Ke z9^~1!NIOFAZ<#o0mx8xibY^;OC>Ugpo_7Y9DVilRd`h;JK{x=`ch}u!e~xKALXTOa z#9>WPHRp)g>zDs=&A-@A7C=9ZZ-mKZAl|?lXB{!E$DMmlj(LgM{TOKNkPnY`QbK`D zl<`J8s}@G^V!V@!Kno;_tiwYTL5+h zUEO9}ftZ4VRineov`su0%U)V7NrY4aj|!xU$w=UGEilmibH*e5HX#&dTvDk+x6LzJ zVxw`)?BsMm z_B&Y4ml*O?R@WGS-v|QerZxl29ps0J^qGDtrgoh4V6kp#6{HrFetqqtZ2A{rMI$Qm zS#y6uUZI3X9S_DVsbN~^{6S7G;;qodyxHjp-x{-nP86R!Vv;YpfQxz4DEQ;ZuG<>; zB5Liw)55ObSjIoSe+*(!M*IZ8wxD<}9r%=;Im?g}_Wv;U*HKY^QQt65ih>}Gpmc+P zNQp>DO81C#NDZO1bV@1R-8C>EF{Gq4(hMn>bSWwQo&mo1b3eb|TJL(-@}J8Yu5->l zyZ2}B3A#~t<9Q`|i6u*xTumYFlzBC8LR?&!LMeBFR$&C|!o*>2`8^w(#k(ZQY#M_^ zl>HVL-rCTzd7}+Eiq9-9rh^p3WIL)|!I@);I94sY;n3(z5y0zj|#{Er(D&8cX z*uCnwXKQNgt+^DTf`@2%vFMWaVb}e~U4dIe&&*O6rM_+y=rUx8$AOMWFbT&0W%9+_ zB#R?sr1Llf5;w9%0qJ7BGODHl&)I%Ib?NLoGyn0uIQ0Ed)1NxYw%;S28TKZQUPEcE zyf3s!xaY`|=z+gYqJCY+EmHAJ7Z?n|lMb|zw# zwK#(}?btu2;Cl+3xn(sqS~BioZQ(+%xH-uL7I^h)ZPlyPgQX9`oa2|U9_6#7=a(sL z3O2Ol8xq`qtaeL*$l!q9QEiedRt5T5M#wyR47bBDGH!{0^F=ub`(M!z*%K?XS(g+4 zB^Q0w`g?mfAOIsFk%P#S;jZw1={ZV_rSV~mB-KuZ5KNmcf9nKqyosmkrb<-Uy*3(A z5A+&smwsp4O_jIpEK0~U1i9j{-jvHPkSHs0ncm`1Z?J%WBk07+>>uHs%fo33gUyws z>x;#CZV*3oxFGP&e;gmUX`QpSKEhNOrc5bZ@ATWeUDqavcf`HALMw1g8YodG9vfMW1Xp0NB_DzmPxF4%4%=~0r|10tplxm+vr z=RHd6E~WN5%1@c?)N^~KKg##0$mhhv>cLl^uL-F*MR{9Fb8f&^JkWx6ubnbfWS5hVYI{Ic|CNLHjsZ*-lXz83$v-Ugnt8rN) z6m4>qYG}ZLgZD1iM43~k$$aJKD8uu+%6O&|I@P~ZrQw4M%WsrKzmvXlA7JcE{r8v^ zqh4%RcyAK&j$tyl(XYP|er!!-ouf`I=`{P<`{>??bLqU~u18@SLQ64!*Yc4%loT3v z@#i_tNSe`_N=f45M^_U_B2xW&`G^GnGR5}N5UFb9DUOjAB)5E+skSs5N*CtXS6lNC z=$!uKUft+ZHUFT%HN~s5G?4X>O<)DO8ztgL-e!pJjnk0$kAmrQ7N=+MJy-9$h5@JQtPWKUx`IUDotC|5?PH?J7u)L@>Xp8B_*8PJM%`3fYPaF+MN_6 zKPLP^E_ovu3iA;Ebh-s+i`s{%*5bFMP(&y(bZ38N%kAOV5#{Zd&+2bIi+}unYNG97 z&c&uuy{uMZywxjJgN&rPvP(5Z5}}c&rnvQzv;^|DokQEUAxF;6C&hY2V8`RCz9SOl zr>&^Ou*P&;CX8QF$xd4qHkt04ad=rGT$TDhr8(5rUAGXtbX!)mD-Oy~8lQyQ$1dFR z%}w#gmx0oAi^+2A1$UfeKAeBYHra1+zB>kMDB2KOmXO=zB-l^+Iq5YJ-kewJG?!G| zv2J4)IlZeMBT=L4tXphi>TSVI!~Xb=k=q)65s|HzF*U^gu|wlBX8L#io6|OB+Sa<+ zL*X9`cRhbJ{zh7)9hcBN(>xdERhE_Fk#V6dE!?%tMez;7({F7Iy~g#$?M%@6N#XG{ z36)7r6+}Pkp(z!d2C)TAWU|ey+hEi6*ed@$I<>|(I9b&jMSOWCOyHr)@scdw-#ku(EG| zSKdD5Ouj)y{x0$1ZR&-7G@6B2;^I?qE=QeJ+RTLQ~{se!_d@Joi z=AnujsonYgCD@B>7hy_WLve}sEBLzmuACfb));&L%-TLI`$^FGoC&ONto_!#?*NhM zoq;eo+IVqNOg;`2vg+&n=sV7j#s9s246U2|B+#<^eN=d?^}#*CFKA+OaN4c=K9$ls z*wvy^u@BIwF{#CW1%Ft*c{k_S>hGA?qRZzyJ>Q1>cRwzH>pOr6%faL`q66&Hcvm-0 zImC6sP+l!^bJNYz1#OY%xyzhTrZNhYtDNG;uN7T*Yjj>TZAWx>;Xmt>aWmfMgt|rK z_nTxNqsO+Tv;1n~(hD(i#!;3<*aqX1uZ_LDIKF>Dduo@xxP@IqjR7~HulW#({08d< z;i%v0xId2=rv8@NUg~De+j()bz8Wlyr2ul}&%||+O^z9FQndpY%f6XZpHD6BjYb$E zH~CGaabVguGQPw9JK~o397zFaz_}+@{k2HJKdNlNxQf_e>I$^B4%;O(wZ%Yi6 z={5;uWELG8Dpn^#HJ-mcmts^KsgNQY5t$g|O}FE0^FEpF-lmwd|L&!tzJZPe&QbF z)q1PknYfC1-`>g{Gu!1UvF?D6#FG7A;pa88J@5Uk$Njo!(M2Q?Q)PUx^>a$a@-EN8 zmVk>sSekCbAGjiwz;usFJg4UPSwO)!n zE$5MSqW&%w!Ts%XLd%9F_C9iTIb{}tcXieV$L-!*6E(hxYo7ifPNaOVQqfUg$R>b` zn0T5fQi>`nLl2^=QQ?$?CL>)2=kD&VcGB1)7}i9|5&LywEAH^W>30$SZT&XoJ&ip@ zXpzRp7WFNqCp&0%mK>gQuYDKa|5Ylk_8)$d#bCVUhHu?>r%dmTeWC;SsA0Ti z_QSTy;oj~)Y{+%0G`N;t{Tq5*JUk*rFhK(={=h2$#i~~pxq3{ zCt;lWGeP6I3+c2T5_enWQJaIjU-|D8PW(=s40K|1N(){J8NAfvTB$pR+^u)0u7eH- zu8m1z`Y6#lV;ByK=V}zEP!VkX;eu>gMI|s(XMZk!fV4=h7d4~uSodwyGI$1O6Ogc( zA=(kKJ=RT{jg%0jgxhJ`U<8Wo*mVjD(CBN3w@IqFu_jH*i5@; zT<$GIT0+h|X&NRNrtehmpWZSuON$TRr}tx2kyDJZ+5SXrYx`$lI6)b|BawMXxjWqS zy~B!!!RL3IU!T$5RV+jQbI$YDc8rDR_yrbhh>(+upRnze2IqT=$*=G66A*22WvQkQ z@V6UnA?h2u-xDi_+IFa^JBVzEn-U`&pQaq4J^F!P;H?myMH?BD^jUcC%PY5Pd75vw zj7Vu1J?sj54AXcMB9tKs_sP7-7)9xFpM%)ytGm`BzRPb#C}=wkW?+T{pp*glry*gy zs6A!6Nunz0^n`Ok*Rr0yI2&^TOw$n2^@zWnC1ch|iU$Js(-%X=53gAiC3X#M6(Pz)9hWH z`TQV`?`l{g)~yfsga|+j^jlDcxch&fdo#X7$9+*er@??|AE{J9;VrK3x8c3ZSuoqp z{P5fp{rg^!hJ@J!&yO#fw33yqB05xqSR5EC zr+gRMq^|n7p9%Tcla*}Cd%LHjSYV(dLs9uoX78zx>y!|hKid5sY#CZBgL1#bdCmj4 z-8X_S-X!$^Kd4vC|5QOXUxxYwT1&#`m>!Km{I}4Wi58v<30YLrZuQu^AkQ zLiC;4!&^^r8|SdeN@|O5&YYcS8$MEtY{Owh&*QIsUfqZ`-4xhAlOucM$2UrN2f*gU z*brDXVEPmIh9r9W1=VUd4@TVcryNFCRk~4Yhu1G~@?5PZ!Md%pmZr=pNZR;z_Sp3I z4|YBuxMMQ2e;j12jcd&Nd*bnQST-$63H^Q!!bY~0e_Y&~yHE5@$E0g!3R0~lr%vl% z&-{J+TG{vU?@aWX@mdpTH|Cr4zc=rD(&!lJ&9N=pC2DXr?y|m{9rAQ_yVP14b>BW+ z0!{fTz+|UE^CI`GYX>?|!uW*XRPYCft%pltEYy3!7e^RJ#&t`uB^k|WzN5Dh+V5{& z%bi_opG}0p#bL^|=J}8_8}am}ZjqB8`3V-|Om0u3OB-AW)9D+A*TVNFrV(#r2eHKZ zoxf*7xUo2M-pLj_lz1_VDb-BXAQr92!lmP;&Ifn-SX9SDM>E8G zRM2JjF(Nl9zjDeJ3vBSsjaV@MpLP0{*3V&X`|b7{a;LMm%G0XRef=$}{-V(=$K|GcT=2pQ ze~V#t0u%yVl9Yk#nO-@T2;Q%Bda8Lg#Iwo9u&Zd<|Fh-D)sv?56k^?s40zY?es}m+({ujD>X{zP#b#zTK^;N* z`30DX{Z>o+_~KhodBE64IJT#hgtU6N=@epMR$Xf&ptKzSqZ_u&Gm6SGGJ^>+12VGq z8ywlW+*RSeBL**Ih#b(_<6dasKQyeL(rD^jDho?xs0TVi1q?ECh~Vno>Mo}jVNd~phXP}Pmf*xAUp=2+ zcu8vMxj<=M3XjbSl;QbcXS#+PCPA>g zusFn8d-_}Nnonkrau5yRnUp>fXf96B=No))g^tceU#3ljjO`SV=}r)=jg8=`jfHro z2|TBl^*%0{!Cp=qOiyW<)b-0sB}=b!)uf0?w2x1UpB}suiGbJ6^YRvWwaYeD@l+p)EZErzZIffD22Ta)ghLAo3cyt0aE+$?w3+eOUq0Go zrJ9I}z%5>X`1<(M0*A)H&OAD)I{f)-rbukm)}^sr7u+q=#C=;Ls047;xukvCNpDJ^ zVGxE=`WJ}5oUy?v)B505G@Q-0bdN%d2#Fyu8L(^6rkj1Qh@v4t-PXrPCgo)w1X6`G zfK<&UYPWNmZtxH@bMxA?bZ_TEI4HD}=aH}&WgU19fRL)^tYb=vzph2ry7rjylzI7_ zlX7PH1i!SdPROw}Rg+gbY36y8)LivHZwPq+Qw6uq2^u2B{!;&vf$5AWY4|$KG{l#v=(59hO!2pO%MG7B?3{+bXyewdb?GN1QIYA!Oh&dUDKXta~6kK2f6P#M=po3Sb( zNl$)`8#24^S{*#0Ub-q6nNjSzk9;v#JuJ3f&!!L&Kb&eGbNV!Em>@e8di0mQ>5?D# z(4`TNJ+rE%F85!2^T7qO-%w<_%+^6dOOYsL3G1WQC=_g!z6Sopgzl_bL)WURe#u|! z0iVrim%`i~L7OUj2^T$qAI*nfj`#Ua{`SNpCdrd$tk(rA$qK}7)jYCZwNOmdo;Iqm zv$K0Kl}^Pl;oMMKw6v_9EI`;wdHNp~z@Ud}&ugO0x>T)!x2zk`JMLqle>QpM9I-mr z)0Q^k%lj`D0o!fldxd@Zz;UKQJ^<(H3`{Yi0#gmvG6{(5_GS6`K!BCoc%|sgPG^{PrQxbkS#+>dWsdE z@2uywH61jKg$eNHZjK*Z20^K<(?TpG$M~tbfoFz~Y`!wQSb6W0F&zf=+(aa;cfZRf zFZa~TO%>QSC1Fu2cKaNvRqEDhd#OYYun3W+zIbRT@}gmNNE`RLK@N5nEDG?yQ1Pn} zPeDpd+AXkEl<=hC*jw~pk^DgiU6VI8bZ@y`SlU~azMNI-6m2&0w))C&8<3YuVqzH0S z%NCu6mrGuq{$Lpxv#(Yq*{=D zYqcZW6@R2xNIDQ~L7b%_Q^p)rLzO#zig>0DmwjeOb{nZ{^L0|8?!GCrjY~-?!EwiX zizGuRP+1nuhT{;N#nx%2dUL{I(_q zO?T>I6X(dL6-Vd9TysiFC@!vtC3UkIEFwE3Jb}y`jpx;zZCS zfq~MGwOED9X%^lm2PllfEC5u(LBYS$yuFi+rl8l0O)p$TOO7d8Vj z*E~~pn0pacmw-&Uxno7he2)6Z3N=&Rp8{O}L`*%Bk1 zRIj$J-%|V)nb8@tEPtY`M*ZNYZdEe55)oWpf0y2ZjI%6%KNHgUd1Q~~XtNb^@Wb8W z{ZeuQ|Hp9RYV!dOeXZ0gX+`EjYb;GM&a?Zr3TpO~*(n0k%kI^MUT8XVMu9r-p(ULH z8j?1ioJg+>ig6YdJzG#DmR(>NkL6<2fLj>xVqWq&R25Afey~wbeECGD_7FqSw1;asGPnXJmyta)Tts)3TPQ_9ugJLfy@{DL!jGb)4kBJx zRQjqgSObhe(FA9ST%YdK6Ls;BQ{#2Sp z-Nx|#?i9O#Q_`qK>1#vEo*kny+cA0zb47^#2E!P{U?Xolpd!0(JZM{~XGb=4M&mjF<*CzLl z|6;4|5yfG7S;OSVRRy3y^y3hH9WTk~xiYE}^9z}M${yO(7YeK#)V%zanDAEX^t3Xe z)lGMibVPy3rnFFmUO876coT>+=fkG5{6&HvCVE(jbTH(1X${Hhrh(573XkHV&r>o~ zQ?52LLsSp2RCd!JO=@5D5jXl;ghb9xZv*Rf#UjH(0V;Io-! z$@j{$AR#*1^s!-{~1Yk$`hT=6iWBgP`9K4V}w&$bce zS%C$1i^($%bgsCX;oponT?p-x3}!pvfJWA(2&my@OQQ*OBHp(_2y#?GhrZoJoJ`nj4$gNJfW=5FsDsbl$(RbzH#K&Bp$E za&7CRZ$3Eg?q687sr7ocsB6wo+z~fNR#L-?A$Z^9b<+5)WA~?jYPz}HPooS%+;DN0!^fY#kn?sK$lqU{2_iwqj1x@x1tQR@wk2Qtv018({nP%5 zaqT?Q1y9KN!73-M=5PIXFIUM~_4>$J(*#QNL%1YgF`9Ga>Qa9to3gl}UYrKW&?7>w zy%~36ztm_G&jG<@rIk>T22jJKIR0es%e=ZHAb+zc1{J8vpK5aO!dd*ez)zV>;)<19 zAq=)Y#_oGwaKp|H@i~2$ZL2NTU-lKsy_9c~Dtomb5guqKt>Ryth$+{_%*?+AG$>}E z@sKCM=`is%JJF#_3vA(B)Az&8c&^HwzGdC6)2J^BilsFA0jZspYpK4+!hJ7r((c6b zAjUR=Iq#@VNf3OqZTmGcyvSKrvz!MRa|}s{O5_pZ zG(aBl3ET99l-xgxw8Mauml}076(V!#-Qe)&-q*PUl`(E&blr!&|5zqtY$BA z5_i1p)1A}nn=I1n^AOFIV1x)Vz>slGdHS!p`ju}!a$wEXWr3Yi#4_kEd!>kmj^DW{!aH z*?gTXD`}b4kslYdP@XN@VCoOO4Mgl|1INbx?m2TpuG;O`%Tx-|_hK>qUzGGKhUhu!<6J3RaJXCKUSCrveg+uE#qFZC79#r)E`TraZ4Ygns z4(LdEMZ&7Wf6G?tykb(^}5}O-3>sHq6z=EO7sV988b`6S+6rh++=}FN&gRP19MIX zuFR3ueEdtB5PiMJM3lu~Q+;m@ji^V^Y0#0-9d)jYFEd0yn@gnM1Yug=Tsqm6YDe>b zOd_H6KJ+l~XQ~opPd6&EbS8iGJqfFX(!iILP-u#SDfyL#iUdB_X1xR1g zpipod5H79PsJee<_+bz=j<}Pz(A=c%zhwYGc`zu|&PHLkiFi#7MDE zi6pcJNUUm6T8B^*EXwQhv?mxmT!td9U`^9yM}iN<-J(}+?!G+WGqKew!T(wD<{hx& zpMMyNJE+8uYWBYnte0Mse_W^?jOC;4|345tCJb89KLa4DS1L za&Ua`d@!Z;^3LVA1Hh{FwAV%iyzA-BAO)@W7&|W4u^N8#a{M1!Zh#eFf7RUG8@&H^ zQ5>}6&`x)Tk=T8$cR2WbM_j7`AWrID8}YPc3fLstHSbT>0%YzqXh1RI@%+g4-^m;3 zvs?I|LGv zkSTKXJ8ly+36zTNPiVUCjmj;59sSi4RR;iQ!y%=#4m zZ_>p+t$F*`RFf;-_iWwB6#&@hjLvr`Y(V>st~f`R+40`$mirm~e5F}W`tj+g94&%G z_33DN%Q5uc)3BaS0*n7HbmzQn)g5|98%0rG;ot?$r9=K2|5sJ{21OsKZd z`PsJb9Do6CFd(^G{+Twd);4D+hr_G0CFGiz!FR+@M|2U; z1SkKG3lWx>SBpvb1CAt{Mz_85A1N1Z6&G&!UYr=s83*F;w778##kY*-$=$j4(AW>v z%0^ zK`aAr;8)cxcnkojWK;**V0(F#=s|MEn~qzxo3(SZmRa73qv4@YWGEDLA~YL-7D@Zy zPR6sG2Qjt)4pr;CszK2;+2pp(!D(gqgcsTwO5{5E(vrEn0kjxx0`@DLAbck9wfh65 z*OjO~gxca>d| z#nL*k0<1e=Wv|b|Ze#Pk&3~)e9;nRqv+0ZNi!<>#+v-8$ljlYw3m#E42!}Cw))~;T zXnOIlM_ga@xR@Xd!PfdEQ&c3+j1E@N_WRiyyG#VYPJHnJT+6}tx?zz#X{V&o^4{xD zl`h*0^Mf&uIDg1b8G_cc+ND&R#(04Lhq4uDwwBe+yQVc6=7E+ed;EeJ=sz}L0omxc z1&vVLf(afEa0g0=uUf5KI-ivl4zVfpjfuBnL)I!+6{n6JK+oTFG`$qDc0<0 zisK7U!=bx851(X7%gBtDD6YlG(OCPQ%zb;*_%|8ria*(80U^3pw1-g7bjMger+?+9 zj8!SMaTorbe7mp*O#ZQyk)|%k79JZ*d`NcRW4Qcq>SUujYT##okD6;*zv${&4K1#t zdUra8t!}Mt${jw9E&3nY5%=c+7CsN(_upq)Ta?BF>UfU3X+z`J=|`2v09E>RTXuJ8 z(17(&=>`x*g=gwq|Mqf1XrREZf7Fbun>NL>nXM`D)HO*xe(C!hRFc0rgB;dJ!)6cW z#D7~&+xn^ICkLAuZx*w5_s2lTwonoSEbT97UaJG$yW@hhR(TvTub!pF(|G)f5NZrT zL5lB*dep2N!g?m)T0F_139#H#tU+p(!pQ3fmITjTj^BF#Zik?Dou*%G2GerIo2pkS zS^)Y*HOk>qvMwy8Qr3XU(J>?JZ&a2#VJp*I&V1~U>#5Xr`bpWv89nLRDlz~&&J}C_+3M2e zQf|(B0mR&&X8^Fh;)R3+-Jobe#c9_udt%!xS#A> z(x1Y^-UfSx0)rQU(3DYiJiP4q!* z&TDsYo~L>vTGR)$?A6z3c+X}#tFmt~%0)VVJ4v-8o_*rHq9(toG9|;GP+!<95avd_ zQWJH-{DK3v2FDg(9nsHV?C&CTI+*K~yfkEldO)@+T|{wEkpSTn430ZBtA%Nehk>}H z>VurvI#{$X(7M4KUnxsZZz?8wR9<1ryEc5x7g)?KZ!brI?gfG)#`m=uaA*b zcNKc$?6VIKU@6@9;zwDi>8GTuGZnm%-Z?BmV#&{mB5@xz9?aTo0xhr91h2Ve{?nXf zK4G2jx2dSi?X}xf#M$58{)+gJD5F&>tED;5hZHZh{`X05F<&tOiI=z*;SGKVl$}Q# zU+|Wuk9UDKtkN%DU8WP8%<7C-{^qzx9ILA91^3IU&WZ05?739O@jw3d(AE`Y`OK<_ zpE+ArJ1!zZ82a+7U=9J&6~B#S!>k9AciI`gJlk3n5Ps?3#u3V-h~6Kr#f8 zH(wmYgbx%^g+I}V6KlSM(lAUp&mYF>PPgEMUC{&0Hh-;ZX$bT727Weh(?+5@kKMSvEFdyyb2TDHuc|KaQ zLJG_<=((j#XftSFP|?x+2i+OL&tGIMICPpEghed7r>c32EW zG1EwiY(@>v{0o5y+ijkCOx%^2L0k-t!MDh`Zx(5GFk5Dm-QRJlu7gZ*A*oUvgz`R< zso`JuYH8iRI&AR@gzO!f6#ire7AZ=6Z$!$qpS@@rOp^M34!2&BQ4lI*=wlLGm5~xk z$?Xi_A@rd>Ylrvp;=5pJpH=ARs`gr&0^@VO=?e*lR9u=F#pI7+)X~$uYAapG0joY?$)VEgl?(bTy4T9mkD*bhBN2x?beHh<~xbm zh2IIW-~B7}YV5^nUJ)(T5GI3D{I0T{sgf3b*xafj;Kk0`{Ce`_;(PQ%R&~WMT1KKI zOYQ5;#f72lxAgm@1SZLP^IG}h8`2;3xLc%(9UEdunE!nZTb)Nq?!H4% zT5eB5Dojh89eKQ{HT}`BW^bT0)#mnf6!Gr(`UE52_(#28Uzw3jgMD7@rFwgg)k7I8`LY;ehk2JNVRhwENd>-r zDf7s^dTBhP+UM%V#M)lQp`F~cXNBAQpQP{mM(`vsIc$RA6pp!rAku;K(eTtCsYDQ) zc;&~iK)I0Oyz+Ox?N8WQ3yjKZHufyPb785n>g!u z%{Q1vn#wOta2w@mh!vP?!%7&An0_9y@rN0Rp~<$WtyR$eyiZz9%F9Bp6cBspzX4%M z$5Q3d2=m7X`E7)a#vZGNk)q*uHj^;Qg5Ulg+9*znC`4nFxsmBSCO=d$Ciq*NC&0qd zx1>C!v)&l)2}Vo$Pcg<~E75YX{ZMjcxZeCFg0jRr1~I z4^f#g;4!J$0>c$Pn3s;KdPq3mdYdDc8&?$>tGO7f%F^CZgw8UBH~uxdIPZ~Uzw1uH zyVxm>S1DMx6KG?5^Wq1bXCUXgNW2%id06*(;^jY$iS=)J0Ee1n?q4yNXm%+3Ox_pH z0;=O5?~hL!43jlya~Y~NkZOk=i9vKmMv6bvl(|TmSsf;mdiwXyW6y|x2B^F1 z8m+>@b#|o=Q%={ro)p~X^iZB!Ur?k$g^32BZZgS=kCCCb@ znG#%?3Xosv^#hrX8_}1^Q#1^BdO9MdpHZs&nw?S5;L@O|)M5F@np&}IrtC=d876 zsUTWTho4!7Mle(`nX-#&OXj`CrOSMhR1sBvn>*}rlD=b@9z&YeoR&}<7&$`il%N@# z%_u_ZmFK9`vd~bj$x*oWLH;|5xJ*ikSNz@H)|RrkRF=0Uy>hFo9!D>Up#4hY(ZEaR zE;u~y6(~qpONW&#g&dan9Mmd|#BF=K&VzRB^~urbP1r8?f@$7LqtZ4{n5CGg&N(DM zqx0crX^zwT_gmVxJa+2F1YB4$o()@X|E8x`9*r{8(yYz&kz+bl$R79R(qmp)HI&!3 z;dWP+Mtu5|UU}<3(mSBCzBoKivM8NCN}nCC5+(*EjJ7YvT!|1`SjlVwkw0;XH?d7T zRLJvSL)xPV3+I*rR4dzx<(&` znjOEu`uO=1o=*Ac*pG}3($+qGQv20Ro$}JLp=0^-BoqbAo_jiKZE@K}p^t8f5^s{e zON6PUsnW)KE6~Q@-3MM!qlc-&J>&|g#}wEnFL2)yL?*Z?bQ#&ecymtk*(%mfnf^Xk zcWPN*1oOW%EyCpqV?)^qc1p+C1em1R&0n0bi>SnShGXopM-xwJl`0M1tO;K0agTW< zY=AOFf*vLsrkP78^tIs!yHfjKIt9m16vf7xSxk7{pX*#EXd<3|bAC1&K5OJN+0wX- zRHJuiQyeLpdCcWFt@|ORw3z7JiB98jO!#_}q{63hC*pN29;N-s0I6J!s)sD?G7K3N ziiO9opS*_#v11Ggy^1!b_^<)%YwwGok6q^UP(}1FvdR=5$=+;thY}V$4P+5_Fx_P* z9>6nCth>>3zd z;#N{9hRyG;D$~!NtJ)Ed)npsed0)Ug+OLoY$u`wOJOcf>b{MO+Xwz2EB+C#h%@-y^ zb$oQZUZF~f&gRQmrDpk}zmx&Zd$8&igm|B{VfirOHKKNlCoFn(>mEF=@2p^-$&uYr z*|mTQe5_ouB66fU>Py@?HaL|E{YYo=b2Ei0OL=z;PL%$>IC0Zp*z3ycLM8Dp>l`<( zL>Q#QnEtV4Al)#P-56vn*$p&eoS{sFh@9>)h06Gc1*xxycS(is+6odP_!j33r4e2v zmg4`tDm)SB-O=sG^;Z*mCDp%!__=f&|nzJTL~T zu7lucBDK#qTqL#OJjgh+&N%S0r)NVY*C-6aVjOTS`)CQw*Z)D!^u0+MmY2UBODW06 zTapQq&`3Vx#Q#KvP7D&bCkE$gHTe8qJM=clt|_)sR(A>wR%6^2QMu zbx=t_{iHsz-Xw2#`dBwX9vDOk!^gEz`@5*q_?9`a$Lo@U z+BYmzG46$dO9C~B?7&=(DXKcn5H<5OfIIEC7)&*1O@9-B%0tzjAFofnN8;%C2~MBJu%Iz4INHXoXS<#bK)E^uV{Kd=6<>jIJs^F!k{ zG|zQ--3AySQVH6}^gwzuqpfz%z6mtmm-9RX_lqGyH@8^;y+bSZ6-*Ra=H8rds>7q6 zcOF-+HV8lZj}?9=gWHbuueyK5hS@i7rh^2+!BX05$df)WZA7U?m*^k4I+v-67jU5r zl1sp(dQeDujmp-_TvMP+C@w4vMCIu1TF(w#3^Ld8 zQ0NA~Kh^WP__$!YQ>MZ@LIA)pJ;pA0`K*>d<+_m3D+>1imCsz}nh7RuYey@Au9K<%L5+^c48il9KS{l}+jd1UQu0GmA959%%Es5I16 z42CYF7|V6>Yey1X9Z&KM)|KN(NO{RvsC??uP=;QYMJ|%zS9eV?15Bnf1iT2$DVw4a zfWAT%Fscn(LqqO6hN7G)XY`0rabA+kmxQAv&fz#g^O|r_#qVHDPVL`cJx%3VKDI4K zOQ;gwbVchqRJs(IX#2QaN((Q97KSiw55PVEQ<|oMOKTmjA%ZI1tQG10Ietbla`Q1U z$tC+_ub`?#&F_Wjt~HGr#;S(+u=s`7xD%MK;QZ{-1Z3c@^$H6E?A;KvHa-|XmxzDO zGz??;&YVH~Y_J#%Ds(y1D=ac8>UMJ7cyWS&5(BZ~D4pk@#lAV=fgiQR*ew*o(?zV|-T^>>V%E|4TX z-7NVZD36LjJRkA%e`$g(hSBoIH4aWrPQ_5D9JNclO)CJuPCaos0druSkEZ`ejqLz6 z_FthLP>E0$Lu|hqU`7vyeb2{RKmsc$g%~+oYi9#|&s*%-CeDWBg7h$R^io32I8AZw zkw2g^s~QSLDV|L*rcMP3*$oIJc+y)k4-RNg|HWMOS~SW~Md{hI`!rYV`28l9rZ`XI z3WF!$aa86J&IEJWoJC^yL}O5Grrl8I>O5CWI4-e5yi(ts&{RcuZZ(9Eju4&@UpqE5 zDCPabQso-=Rvg&|k{lK&?j6h$|E49r34-a5AlF$GTRhq9>E;P$J;C zBsVmbE>T7PKLJi2^7VYt`P#CjqUa&_;jRf7`d7i;^Z+O%MDT4&Q;?1-z_U^|mO6e4 zn25R>Bw8sLJOCHXLiFs+*N#gjhMjX$XaB&kw^3=c&jHcLq}7+&H~Sw-oLZxV>8~~B z6V%n34U0&SJXMkU5e3s&`_w9`3wYCgw+iOrTsb8ZRkDmCH|*C_B^YXBLI*kEk>eO8N0%>qydfLT5d zr(Xk7qr)X&6T_gIqsZ_azW<0yrze}n=nFE?rcZ0pVb~MrOo-FW0tvXF9?J~ zHR{2IYJ^+^K{^9~R6Y3mh5p)1vETYPF$@OGHQvD9oU3!&g(+T4?6zA8cvp<60HF9mkco_z9ea9u!Ne{IPC z>}#?-Cizp61ttpDfRT_3sCsxzaiKg|!39rrrL(Kx z!h*L>;QmZ~FyCA`g`r&jQR3&* z+^wgMpeAdO`^-N?UGumotLUcN8P_O~Hc)KbwFMA@4_w2=_wTE71!W!s-@$fmqGYnh zS`&ruCd0EKMmj%Jwh8(({a@KN^eK?L-lk~GYYPUIk%+MXhOHJ~aJuc54OA@}UR;Pq zX^no*u0}mU0BTN3fkbr}c*yBD;qT!!n0Ddn0jvd-8Md0cHd&mZt&i%iLHRxsE@1!l z;@4gY@bnJ914F2k@9jY>6@0rAfF&Bs9z~$ktV~`3)#_v@5AsbG*!S0Sd20if7%0(G za+d#!cPNFq!~GlW`li!K)Z`0!&AO+7g9If-I<^SoO>&|()uku?o)Lz;^WqB244l~n zz=a0497V6M>>pq(NROiyz z(zSk&tBaKp+;_PFLOfJC?G0bZe>M(a(YQGEV^Tj9le4?g!(>Jbl5by9ftLZ;2}Vn& zE3d;(A{Z?42#c)UIuuIeOa;#9Om%fei3cd@>lnQMpY)M|~q)WOx#h^hEP(n&+ zB&0+@Qo1`Nq%A;NQc9#tKtY6mF2sG#`QPsy<9y#34)&1k^Q^V*b>B0td0lgYb-IJW z_ILn!dOkYjhsp`}$+vFM4hfv zA-s$`0qj~aaNV!Pah(va{dc!Y3cF-7;B=SJ!y5A37r1^xtA|JkqFYWG4gBOlSfU*K zJoxchl;ZJ%SU<5?Y?e5wJ>ET z{-u*K2JWUs48K;r?ZFq)*(0_wQrU~=PPREK;`KJd{oujA{Iga>jD8Q+#u$6}EkBYm zJA$$0+xiUXO+m7;GX#6uOZ7U^Bc&E?wkFD94C8o#RrlRHON-+oyvlQjn|*u`X4Xwt z^~Hy@`{Ez7sAEQPFAIHw?xh-EA9BWgNjDjbYtR#U-9T2bpSQ~Oo>ckbiKq`TnV~<< z!Cp?g`_G=CyZ#3I_UpB3yIdgjsBZg8@< z;R%<-;LJ#M!5;auH)TV8eFlKoZu-jT)kXg@clszL9Xs%>Q+rmKi+FEk9~CH~MfTW~ z5h1p@0TVAzui*%C?SES+NM29vz{>Ba7VFVv+lryrv?n61GpNF}n}E}eL9nt0fkch* z25@-FfROm@wgU+(2b{Fl?UJE&AaS;K$|1*y4S%!GdXRUv^DQI%Pm}(q8%8gNBabLJJaGt3tggC>f@=*U}71)>^sCWJ`vy!;hHpS9 zQ-87T)ZOgL!}X^=kvy`Ht$O^L#`7%r6su0+Qyc#XJl==%clMAwfoM)P43@w0FfJUn z;piu0j^^kUXN{2ikuearlnYkoR)E%mG)I>FWplUOXos zp)rAvw|Pu@QO5FHHEj9o$8v!GmMCm zbr}TMv{XwxT|~GDg6=~Gnzjh1f%v5|;dvxb_?MB~mY1ZIfCR-B`qN7pe4ua@zyeHr z&ikz(3Z|{cWK6F7fmPE|#Yt^H5$Q?+2F-#_>zb6#z<{9rU%e z@9B{`-931@O0a$0Hn89W2Y_+?ahLym02~C8nexMbkrX6=Uh=?m=JOxf{4%n{8^8b4 z9m2KBVE2+x=m#yQX=kgHiF+LgB>^l%!8U&7ghD=X;zcR{r;YjNV{jrqCh#B-qQ+boqo}$hzWI22#-7r2T3lDJB%@ZW?+yX|V+e9>>GTzYr$2`qpu>YP5 zzJ`3UkM>is={2j>H-2Xwz|QpXTviSXl?0sBVXDD>U1d;*oo(bk*`F;SSp@4bgk{XY z1zXNPiH#!UuUm{Fg*$L>`N+hNCp7nz*S^&sv-;8geNY_5HxO)=_zmiOV1FQRq8*_J zGewRs_W%_RooUej_yTy4v$PD7s#SO*=rPElD}=~fub~F{=l^oUaJ}V6IfC)8d=}6J zlU~tEz9O%Yr^Wd6y8>@;6oK0$En%}>og?ZnM4ZuyNkn3P$tjG*a7IfqXV|j{6fM@1;A`!vt0O}n+5Lsb8X_2zcDD>Ry=;Tf*NxA#!tfV(+K?k zV#EHwf8~dGPTs~y^Lm#&ASkvEn|V6?;HE!-XQo?}`Fq|F?<@enx;bZi@gExgN5lhe zo&;7WK{GhfFZ*hf5=&XkxNHbQe%D;_zA(Ne%ssk*;!eg%r9WfBe_(u z!h#beNQqi4+2I0OC#HXulGpMABsik_CJ7(lS^(0W9_s~`{@ zBEJ&&*NMf@z{}M~Jw1b)Ez*rzmQHX_N@#k81{UDFsv!8hX0f+iP~s>!<(5h5v6wC_ z7CM#b0}Y{v8{C~&037DG{zQ|rQeof*w-GQRXv6uB$A+cJ5?_jBrW@LRoTS>1hbT2w zf6PWR!Xc_v-d2*N=3v=1Edgr>=go=f%H1i^Yk&cmx+HcAjN9KDF>wxeU7r{Q+O%`1 zJhg2ywBN4=prd9Tnt_e#rpg_QE57mCV9XIUFVdn3(9L%$D!Fl-Dh0G9(Ima9w=-GSu5XFy=q0!YnD`A19!ei!l`Ds$Dfdr81- zuyH79taMx?;Y4tb)?YMs*boKCccPz00S!p1MPV!O_)iqTfbkL7&GipA*1hAWT|pKw zMDfzON4P7*nj9fIATs3&xdC2GIGfO~!I%pz&bBP@I4}X*Fmj9D^8ak zkPNT*Ev3Kv$*a(CLDTud5ksVmX~_K2MeAjc53C|GjOixA01eZ^zUX?!3s^G_Fh7WA zv=X@N^*N0id!Qj22eHKx)h|d+3gyB`)_=j=ZrRw+Key%_3W}=lN7@}(1W{T_{N9Oq zz5YoC*VPj_#SZ|?t^dZi^Ber6#kIA{7|&wpZtwt#SSPizgxqwW-CB{1b4da_P@6FG zT;mtC0!2s&(XfbAXkQ0nbOips6-jWa>OXb5P%SvQ-`;v1a-^Y`D4YK*FIR7pybS$A z%Zh9o#DeP{S&}EwV0(yfKfM=2d=KZAHK;qdC|!;Y_xEPQxa$?T!V#=?NU~rpDk_Ul zsi+2WU_B#C=rpkhcRnh0o^rnZ+HZ$?gtr_l91e=Gael5SFCa~*o3PWa17hzgaXzzS zuu8`!HPkJ}Hz!t|UqVB645sF+)`!`Of?(zWyvf%aRn^0GqvdyQdSTiSpg@QFm)03+ z%wK?pPWmo89YX}u>FFvGigmFQRp1Xv&zyS-k%qLwo96UEYR$9fE?}ewBhTMauu+Ep1S3mrFLH?LvwuvR2wz4j#0c ztx0G?;ZQ-XueWKLNgWY~Q~2CdNz>JHqY-om&Bw0N$vv;U$VL%M5TVav3HWd;4jFXQ z@sIL7zJ&=uY7Z-2%+6A!xR>LXrbe-!(@&~F{G%)K(l&SJPrja@r}Mqf9^_e>lW?R% zb|UFo+cmdU0650ai?{k|4^>A0$G*#t6|I#bI=4G1-RGYn5hrQiP_xgm(Rg8S*-Tr| zH54VA7=ky481mlxT?l$Sey>51wEKE=MfAOtB#TuWyJEwz!kTanKU1JIj3*GPkoWLU!uum`!R+yaFyiI(Pgs)-S9N98Nw=*Us@p=mwOWnT%se@_!R8W#fL^5jS@=ATG-0EByxgQLZr0z%{wM4qv(#SHdlyyg_bI|&s0{okyPR+t8< z4oT|b49tt%x~jd~MMPXZ`CzNEEKa+3dw5(A4U8z)i-bhu(Gq*Ttb$>B4ve0R+li#%UyA3S2 z+~zQn+Wv)-Ln9=wp!0n$O%u`V7G%-L;ntD}TaTZXJdHP$&~!eiETt@$<^feVV8}AJ z%y&7B)l{2*sCwf52NAjYNGNI-*Da8bG)Nv_J>{ujJ%9|ZUE-(X0RHEo%14}@X* z%iD%8bLE$r1^+z1B#jx`;Ae&7V{Xn z&wboKIie~Wh$O4VbXHXPzbGjw+1C4iD?qZ;mhrQHGi4g?lMsJTbKC{sHk!!aOyX>+ zyGD;$(+&ppcaEotbA*#6!gi7D3Jr=+LOAgH2~l+05{ za)3JLZ}M-9R5(ab#nE+MHWC#LXUJjer!tV`Wmh~{>hBPfAL+fh_2_5MjD*27KTL9N zp+iDr3z{@}#by6`h}cIs`JPXzwcTry{tRo5vFthlka5JBy32nMt|%GQra&XuEQUJT zY>{Ykf>2^xQbOC!l zIQb#R_aK)K*w_5ZJJ;=@KaI-LJ5a9{xNBOTys$sft$!j!T)C4mWB*_)$eQG0{v`SY zTEiA9@1GVU`BOPQ2=}HMYT_{mhJz2!1f90zkfez?NfcfGEyI8hqfwBkyc8MY=h(~` zgEQqY|MuA{Z<1ZgH#2|XvS1{4$MB&1yEBkO?13t7v<3-HC4yX#a2VWBI(`JrZdak6 z&a_*xxN+tp2HYF`OAJy!;CFo7dH4=TaN>r;SS99jC%KzA;HD`51tSO|$z1xT?UZ$e zX4IQXn}I?Qkz@y=11q0Lu5bx=6iIdVfUMQO8#8_iFrM*iAaDap$0}&lDzUj6K5^Af zKWhBY+zLY7d+)sjrrd7n)7^nWkMo{=`8y`v1yH4Wp7c5ZRLl~( zSI(YoAr}pVR)jS!>w+{|oIg|yPZ|DE?5OWp(`56$hH|W&9vD4>D1)q?vCvkdvYP=# z@RY@dpFrbnjIIe?6dHl~fV3*8U11PH-~m=m%d7f!dGez$Jas{ql^=CeCSwR<+OOV^ zAdNBE^Y7>U9Tg5@EZ4Y3{%?>gJ}|c{EDH?tzzub7!H(xLU6w5YsG$w=%kxmPsiM69 zmUcK#n_18SGWBW@rt6R00#RKJ$`dTZjcO}<)Upg;eRDj1}V)kvSi z%-!|LarsE9mn4<`^@M6dEpo#j1iT~>CH5EuN)M3{N{FQ8eUBnfVmL%~R{xa9;PK>S zBxgsxO1#&5<^n3`+kPMPoUL9R_c0pa^!#i)zGlUULy;reOgfA=5xTH9P4g!=jYNCA zORp}zf1CNIeQC=%U?V-RQ>dhs31nz51ai-)=5@+Dh_16j!TARaas@P}*JNw~|N7;b zjD~FWRL9YezX7_q1HV_FjfL@5CRw@VjLPu_5Z)#GGVv(=VoOx}^)9~o_u5^AM^d}; z?c^iT?~_=uvm%}-OeBTW*RcYw*0%?=-&d_`(z3rG#)#)mCxlNY&rgS2vUJ? zQ`$%bpL747vZZPzvBV=A)tb+3mMdBM`A3PrB#xf)nk43S^7e|xD9jA@>YF$PD39() zr)w$lTLENMfln`Q0CPkqUC&dxXwcnM+Z}vNDK=Cn`JFMIF~`*-lQ=!cqf}f~_j^?} z=?uf7;^$khpm+lz_?Tb@Kl=_o9|gHI^vvF!+DIks*d(8x>kYc+ug{XV8Zq3Z ze)`9VUnbB9a>%khms!ll#ic}Vs z4-hbh^9StOHsas75!!0P9ZV_-({zJ6J702al{U4ptV@M`R|hp@6J^CEc2dlj3dsrj zq1tub4pPlu?%;Ax+A?hDJ}L4!J_=b`ubsPi4cJVC5(N~MiOgtrjqkj(UfGI;_)vT8 zJS*U-8Bn`shy?kwNm?4jILzYUnuK?5J8>es44;2$^Ya0|M;d+1eg_QdK14h;&=qSa z#HPi_TFU8Tn7P%&k7~khEldj^_ciAvkzdz;DIbd^+l*cRZbyz`$B#ejj-U36`3DMA z@IImq#!5c30IqS0*wN3qYG|fbXU!#dLy=F*Ki)Rk-S2%4U+6mk<`!k?kIlwLxV2+H zL$2>I`f_SZL*Oiy|!TB8l{ApE7H71DkJHw5Y27s2hadCO2Fz-Pk znxtICl4vN#A*7t9UP9D3`X7Jjn)ZSl(0`0xOQl(K=F=#UZi3NJj?YJkBXv~&57IWT z{JE*6;wPXrl*QCN?oh@sGb0&#mO@5sTkr5`Z}G3Zx@Z)wMl5%qYh3EEyOBHL2h|wK z2uy|BT5NYpld`L$HeY$z5`+ylYR$%Bt+N+tcKq;Ct-9^Z(32;h)Y%$<&6`rNf3&OD zam=WO9+dG#=ZVS#MGO;o5<+!$C6(FuPM{ioCC5_mR~C-a^B1?b zapa=Q@f{~q#e61tdNt|6_xC*(Ei)@6HpR^XL!zdH6Z#UrcKTqeP3~@O;W4XTr!iFyhicqS#x#s8Sqy9k$}&}NVvq8yc~XaS-?CFnWn*w>1+t-Qx5o8TFg7V#YtQ)t zubxTc4zK<*(=q0ul{LUMZvfx5xnP&cLX}dU(NgiPmPqdECJsSmooo?ITm0y{HyHJq zSyS!1W8uEzjo>X<`O?rz{sy-g|?xZk6Q?h{{VfJ|NypXoWbm!%9Xt>Yi6nwaFNgbTrS2T$9^hZV7z~um zktpGCOy**P)8a9OZDw}6z_B^)O}wMoeYRY4LE-4|1KX-Cd(q-8YK~2EGF%^LC5-ks z-;5F%M1Nd+F|IpFu;j56zO53Ds^H)=ZB65Mv)cLlOAAW(qHD7++0R%xI(n&~pf4(ZKDTgNA> zk^9e%>}uZgxM;z-Q-gEv1mc%$$H-ksM0+*wn3Nastb0OdgxkDnV(^9(U9yNDTN_7= z$em@&d<8-C^F_tML!YEriVwt$MF8yCps4f`1wDXV0z>wQle?A?0RL1I24EkclMbl z|6$b8L}8)~ksxg$^Y%W&MAqeuW+L=#a(kts3la7UmgNtboEft7T6|YMAc?XElo=uF zk0bF7^lPetWJfgSPJ*^1fcWVC=Ho^9|K2__5A&5RQ4~ik`ZqL+b3ZZ4RWiHyn!hhN z5a8S8i3FT0Qi&tU|7K(K1}0UX1ZoQElB|)4QuXHJDlL&FN@r4#~2BoL3r5^4PSdcUZy7ocvr#s+}%cKu=lrjFYn}#>4yn}Dbw!AeH(1MI|zT%(-OTI6(NMYIRnsOJ6g8Ni<{s$amoS` zNaRtMV0L36Yom5XynKCenunU5%FExk7Io71b7Swlm>q!&H(2 z0fD{)bj6X<_z-1C23=(XTCferpcu?}B^y$uvXgrH!(qR(=f{BQaMRiGqR~UtJXjQ? zq1e5phZjT5Jycje_ZdD97|;Thr2%{JVZx!GHgG8tvchld`v zUoK&StMcPx&3uOri!NA9e;!&BPl2&;sH>%4E^$9ro%v&ywJfX<_0$d3KS>Q#_5t>` zgRTkbNry-y~1=*X_{R{duLym@ccI z`@9d9n30rNHC0^9jwsae7c|y#IQ+RK;56GFJCX`T+6PNn&n+7TNQsdA*P?dBMj!e` z&dl9}^fwqY_xnrnH40wKr><*Cuhj5gi_B%ZZ+#rOt{nH?aNy=Bmg6$X^7T^XT-hts zMGJNX=*Oz-JC`ci>-?mzFzRF&skVjLi6<6i?~78aqPiSE&HhqK4#DC@KOoo*%f$j* z<-Pa3`H+#lZCCsVO2TUH-O!eC_2|JQ42xFjvqQ|Yo*i%9J%G-zx|)Ws0XvwVY>x&m6n734aPvu+<+b@J8KijaIE4UsK(;7x>4s?eF4(f%Drlc#L<6AHs99Y4*r%)0Ke#14LlT84M-*F2}mB@odfv`B2tiX~4U6^Ads{ zBFMl=y$47^2ZI_u^NW$B@`o5`cJccI$_>$YUk9$3a#ldOur)T2uH81&5?j7u$ph19 zDUh@wUs(`GK5xG&I^b%vw+2mYQ43dwxG4hU=I6+l#*c-T1nKeAZCzA{F5+*!RS(Z& zJAdopERS(IX$^te zJg(p4IJL8D7z!FO*25*{60Z?861a+L(1M~ghaanM0WX5|-OKPDm5XV#qLStgx{f*b(*NNXVgN8J^LK` z-NxN`Pm|V3K!F#BW2|%JF%a>q@i+L)LuaHLHDc5Arjj~6R*I~U#+z~R@e9;M*F@mm z;Wyy_J5mD>^HOgwon_rvqMuEUN^`DPMgvP2-&-(dkd7qs8O zf6pYuQUE>Av+$eKtFp$lV3dIC5M&d6tb%XfvZ8&7y71uweP2K2)j9#AdiQs(GtqL5 zI?rtRLu~WwvJY&bgCk3!rg4#(qbkWCo=h+RH)^iSgBSwLLI9xvlHgwjjY0*UdF1J+ zU|PMq1yu_*A<%avc`n1a#VXF4iGDA0GqaT3ciHZt_q1IS_u>+=na&{~3xfCR4o?Eo zYx4UBrWc5^Y76$WEzdn|g}|Eq!Vi%(n4rY`$mCCyE+7cX5sX@dKsf^K}h z;kz1h;=YKzhbZ)ZI)_H^@sX)Oh$Sd04O@>gBShX0xg(>WD`XyzLBP6TTk{MxF)qnj z08~#_EZ=MJGK`K4DhR<2i=FW0icMe3_uk{^NtC>DpiG)W&~h~#?|V{C!Hf9!La9{w z-}+rQ2A$1VzGtNF+pco(r+xpk?>B zYj?{?0*mRU;>N--Z!_h;U^Ze*=h)G^r9#YUy|k44cAk=wGU7Zo!y7$96ns1s{I}vL zvOH*_?DwL66Wax7q7n?G`>SCdA>MvC!UpLGf$-JeLzjF!8uCTYgT&Zr9&3!T;gPh9N--}@Q(t=hPd5(^p z2Bw|rwE)6Y3?3ZK@a*&0A#+2%bTp{QAB|{VC{L!4!L_aVI1SNc(^%0yD$8UTY2T}H ziPpFg>X8=r!IcEdL30bMq()14J!|ELyfXp1v9DaCB|Lctx!#c(>Xp?bp9M_FS!?C+i_8;C}%L9 zJtP>z!h*k7s{>0qJ$y@5BeR6kt~Dt<=d#`YFH<_S6ejh_!%;PMl0Qq$3>OuyOoC@6 zS5^M=JB3(DmVDa#v?2i(v2mj9g&Jp2U+5kbGR3~gC9Zj#>rg!=(u#UM3yZ8&{$Nv# z2v;T{z00s}QgD=+N0-XZ*T$-6SS56-do(@DDa3Q`$Kya2%FeAt+z98Zt z&ZM}W+da9f+?SGMS8eF@^G4XhFM9-c5WiRPEntX!@iS22I&T?nWNm5O z`*D#jp)url%)K%@eX%sCmuNNE zZkLlh``FITe(}vQJEomQRxv3j(DP9K@ZW}E=Bat)hiQys|Vo}jx zB{kwx8Eh5fWHzpv#qxoGyO7ejfqyjiKlDjS=jNaXVSE z?tKc!H8sX=4Kf)OnVoVH;*yWfAK8KfXGu$(M7vx0+D4?GE|hiJY2sEY)v||pX+A5A zW+BAAg2u~(&HTWVV0@S?_Eb|W1!@L70!HeIHH_T``A*L zLSNA66(8h;nD0b}>RQmAp#Ii5UzL*5fA}sP z1U)tyycUk4M9glzXU3m;a%a96L@(j6-88Co)Les*@E+0z0O|HIap$?|f@Pvjb~`3* zn&{@6vyQ-}S_jAM3RpzrI7HBdsmwjTx$ow$j6?P^0S=7h8{V#>-x!72gW8-bd(>(K zI71iMpBVL2X#K{Ycxyuv7^G+1;9eN;yY-Rv*N8|eD=Mc3M~fE=swBkyWQ5!mHXG{K zzU1s)8re0f6Sb{Qxlfxuv{&$>LQ!%^qQZu>ND0|2G≫3E!klGY}%^ETWJR^geV` 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 + ); +} diff --git a/manager-dashboard/app/views/NewProject/ImageInput/styles.css b/manager-dashboard/app/views/NewProject/ImageInput/styles.css new file mode 100644 index 000000000..a6e6f1707 --- /dev/null +++ b/manager-dashboard/app/views/NewProject/ImageInput/styles.css @@ -0,0 +1,5 @@ +.image-input { + display: flex; + flex-direction: column; + gap: var(--spacing-medium); +} diff --git a/manager-dashboard/app/views/NewProject/index.tsx b/manager-dashboard/app/views/NewProject/index.tsx index 811279723..23666c80b 100644 --- a/manager-dashboard/app/views/NewProject/index.tsx +++ b/manager-dashboard/app/views/NewProject/index.tsx @@ -3,6 +3,7 @@ import { _cs, isDefined, isNotDefined, + randomString, } from '@togglecorp/fujs'; import { useForm, @@ -10,6 +11,7 @@ import { createSubmitHandler, analyzeErrors, nonFieldError, + useFormArray, } from '@togglecorp/toggle-form'; import { getStorage, @@ -29,8 +31,14 @@ import { import { MdOutlinePublishedWithChanges, MdOutlineUnpublished, + MdAdd, } from 'react-icons/md'; +import { + IoIosTrash, +} from 'react-icons/io'; import { Link } from 'react-router-dom'; +import * as t from 'io-ts'; +import { isRight } from 'fp-ts/Either'; import UserContext from '#base/context/UserContext'; import projectTypeOptions from '#base/configs/projectTypes'; @@ -40,6 +48,7 @@ import TextInput from '#components/TextInput'; import NumberInput from '#components/NumberInput'; import SegmentInput from '#components/SegmentInput'; import GeoJsonFileInput from '#components/GeoJsonFileInput'; +import JsonFileInput from '#components/JsonFileInput'; import TileServerInput, { TILE_SERVER_BING, TILE_SERVER_ESRI, @@ -60,6 +69,7 @@ import { ProjectInputType, PROJECT_TYPE_BUILD_AREA, PROJECT_TYPE_FOOTPRINT, + PROJECT_TYPE_VALIDATE_IMAGE, PROJECT_TYPE_COMPLETENESS, PROJECT_TYPE_CHANGE_DETECTION, PROJECT_TYPE_STREET, @@ -73,6 +83,7 @@ import CustomOptionPreview from '#views/NewTutorial/CustomOptionInput/CustomOpti import { projectFormSchema, ProjectFormType, + ImageType, PartialProjectFormType, projectInputTypeOptions, filterOptions, @@ -84,12 +95,33 @@ import { getGroupSize, validateAoiOnOhsome, validateProjectIdOnHotTaskingManager, + MAX_IMAGES, } from './utils'; import BasicProjectInfoForm from './BasicProjectInfoForm'; +import ImageInput from './ImageInput'; // eslint-disable-next-line postcss-modules/no-unused-class import styles from './styles.css'; +const Image = t.type({ + id: t.number, + // width: t.number, + // height: t.number, + file_name: t.string, + // license: t.union([t.number, t.undefined]), + flickr_url: t.union([t.string, t.undefined]), + coco_url: t.union([t.string, t.undefined]), + // date_captured: DateFromISOString, +}); +const CocoDataset = t.type({ + // info: Info, + // licenses: t.array(License), + images: t.array(Image), + // annotations: t.array(Annotation), + // categories: t.array(Category) +}); +// type CocoDatasetType = t.TypeOf + const defaultProjectFormValue: PartialProjectFormType = { // projectType: PROJECT_TYPE_BUILD_AREA, projectNumber: 1, @@ -448,11 +480,77 @@ function NewProject(props: Props) { })), }))), [customOptionsFromValue]); - const optionsError = React.useMemo( + const customOptionsError = React.useMemo( () => getErrorObject(error?.customOptions), [error?.customOptions], ); + const { images } = value; + + const imagesError = React.useMemo( + () => getErrorObject(error?.images), + [error?.images], + ); + + const { + setValue: setImageValue, + removeValue: onImageRemove, + } = useFormArray< + 'images', + ImageType + >('images', setFieldValue); + + const handleCocoImport = React.useCallback( + (val) => { + const result = CocoDataset.decode(val); + if (!isRight(result)) { + // eslint-disable-next-line no-console + console.error('Invalid COCO format', result.left); + setError((err) => ({ + ...getErrorObject(err), + [nonFieldError]: 'Invalid COCO format', + })); + return; + } + if (result.right.images.length > MAX_IMAGES) { + setError((err) => ({ + ...getErrorObject(err), + [nonFieldError]: `Too many images ${result.right.images.length} uploaded. Please do not exceed ${MAX_IMAGES} images.`, + })); + return; + } + setFieldValue( + () => result.right.images.map((image) => ({ + sourceIdentifier: String(image.id), + fileName: image.file_name, + url: image.flickr_url || image.coco_url, + })), + 'images', + ); + }, + [setFieldValue, setError], + ); + + const handleAddImage = React.useCallback( + () => { + setFieldValue( + (oldValue: PartialProjectFormType['images']) => { + const safeOldValues = oldValue ?? []; + + const newDefineOption: ImageType = { + sourceIdentifier: randomString(), + }; + + return [...safeOldValues, newDefineOption]; + }, + 'images', + ); + }, + [ + setFieldValue, + ], + ); + // eslint-disable-next-line @typescript-eslint/no-empty-function const noOp = () => {}; @@ -492,8 +590,79 @@ function NewProject(props: Props) { disabled={submissionPending || projectTypeEmpty} /> + {(value.projectType === PROJECT_TYPE_VALIDATE_IMAGE) && ( + + + + )} + > + + {(images && images.length > 0) ? ( +
+ {images.map((image, index) => ( + + + + )} + > + + + ))} +
+ ) : ( + + name={undefined} + onChange={handleCocoImport} + disabled={ + submissionPending + || projectTypeEmpty + } + label="Import COCO file" + value={undefined} + /> + )} +
+ )} {( (value.projectType === PROJECT_TYPE_FOOTPRINT + || value.projectType === PROJECT_TYPE_VALIDATE_IMAGE || value.projectType === PROJECT_TYPE_STREET) && customOptions && customOptions.length > 0 @@ -502,7 +671,7 @@ function NewProject(props: Props) { heading="Custom Options" > {(customOptions && customOptions.length > 0) ? (
@@ -517,7 +686,7 @@ function NewProject(props: Props) { value={option} index={index} onChange={noOp} - error={optionsError?.[option.value]} + error={customOptionsError?.[option.value]} readOnly /> @@ -743,7 +912,7 @@ function NewProject(props: Props) { value={value?.organizationId} onChange={setFieldValue} error={error?.organizationId} - label="Mapillary Organization ID" + label="Mapillary Organization IidD" hint="Provide a valid Mapillary organization ID to filter for images belonging to a specific organization. Empty indicates that no filter is set on organization." disabled={submissionPending || projectTypeEmpty} /> diff --git a/manager-dashboard/app/views/NewProject/styles.css b/manager-dashboard/app/views/NewProject/styles.css index cbfa76230..45aedf1bc 100644 --- a/manager-dashboard/app/views/NewProject/styles.css +++ b/manager-dashboard/app/views/NewProject/styles.css @@ -13,6 +13,14 @@ max-width: 70rem; gap: var(--spacing-large); + + .image-list { + display: flex; + flex-direction: column; + flex-grow: 1; + gap: var(--spacing-medium); + } + .custom-option-container { display: flex; gap: var(--spacing-large); diff --git a/manager-dashboard/app/views/NewProject/utils.ts b/manager-dashboard/app/views/NewProject/utils.ts index ce419e42d..be883e85d 100644 --- a/manager-dashboard/app/views/NewProject/utils.ts +++ b/manager-dashboard/app/views/NewProject/utils.ts @@ -34,6 +34,7 @@ import { ProjectInputType, PROJECT_TYPE_BUILD_AREA, PROJECT_TYPE_FOOTPRINT, + PROJECT_TYPE_VALIDATE_IMAGE, PROJECT_TYPE_CHANGE_DETECTION, PROJECT_TYPE_COMPLETENESS, PROJECT_TYPE_STREET, @@ -68,13 +69,14 @@ export interface ProjectFormType { projectImage: File; // image verificationNumber: number; groupSize: number; + maxTasksPerUser: number; + zoomLevel: number; geometry?: GeoJSON.GeoJSON | string; inputType?: ProjectInputType; TMId?: string; filter?: string; filterText?: string; - maxTasksPerUser: number; tileServer: TileServer; tileServerB?: TileServer; customOptions?: CustomOptionsForProject; @@ -87,6 +89,11 @@ export interface ProjectFormType { panoOnly?: boolean; isPano?: boolean | null; samplingThreshold?: number; + images?: { + sourceIdentifier: string; + fileName: string; + url: string; + }[]; } export const PROJECT_INPUT_TYPE_UPLOAD = 'aoi_file'; @@ -115,9 +122,11 @@ export const filterOptions = [ export type PartialProjectFormType = PartialForm< Omit & { projectImage?: File }, // NOTE: we do not want to change File and FeatureCollection to partials - 'geometry' | 'projectImage' | 'value' + 'geometry' | 'projectImage' | 'value' | 'sourceIdentifier' >; +export type ImageType = NonNullable[number]; + type ProjectFormSchema = ObjectSchema; type ProjectFormSchemaFields = ReturnType; @@ -127,6 +136,12 @@ type CustomOptionSchemaFields = ReturnType type CustomOptionFormSchema = ArraySchema; type CustomOptionFormSchemaMember = ReturnType; +type PartialImages = NonNullable[number]; +type ImageSchema = ObjectSchema; +type ImageSchemaFields = ReturnType +type ImageFormSchema = ArraySchema; +type ImageFormSchemaMember = ReturnType; + // FIXME: break this into multiple geometry conditions const DEFAULT_MAX_FEATURES = 20; // const DEFAULT_MAX_FEATURES = 10; @@ -194,6 +209,8 @@ function validGeometryCondition(zoomLevel: number | undefined | null) { return validGeometryConditionForZoom; } +export const MAX_IMAGES = 2000; + export const MAX_OPTIONS = 6; export const MIN_OPTIONS = 2; export const MAX_SUB_OPTIONS = 6; @@ -275,49 +292,16 @@ export const projectFormSchema: ProjectFormSchema = { lessThanOrEqualToCondition(250), ], }, - tileServer: { - fields: tileServerFieldsSchema, - }, maxTasksPerUser: { validations: [ integerCondition, greaterThanCondition(0), ], }, - dateRange: { - required: false, - }, - creatorId: { - required: false, - validations: [ - integerCondition, - greaterThanCondition(0), - ], - }, - organizationId: { - required: false, - validations: [ - integerCondition, - greaterThanCondition(0), - ], - }, - samplingThreshold: { - required: false, - validation: [ - greaterThanCondition(0), - ], - }, - panoOnly: { - required: false, - }, - isPano: { - required: false, - }, - randomizeOrder: { - required: false, - }, }; + // Common + baseSchema = addCondition( baseSchema, value, @@ -325,6 +309,7 @@ export const projectFormSchema: ProjectFormSchema = { ['customOptions'], (formValues) => { if (formValues?.projectType === PROJECT_TYPE_FOOTPRINT + || formValues?.projectType === PROJECT_TYPE_VALIDATE_IMAGE || formValues?.projectType === PROJECT_TYPE_STREET) { return { customOptions: { @@ -388,8 +373,8 @@ export const projectFormSchema: ProjectFormSchema = { const projectType = v?.projectType; if ( projectType === PROJECT_TYPE_BUILD_AREA - || projectType === PROJECT_TYPE_COMPLETENESS || projectType === PROJECT_TYPE_CHANGE_DETECTION + || projectType === PROJECT_TYPE_COMPLETENESS ) { return { zoomLevel: { @@ -408,24 +393,6 @@ export const projectFormSchema: ProjectFormSchema = { }, ); - baseSchema = addCondition( - baseSchema, - value, - ['projectType'], - ['inputType'], - (v) => { - const projectType = v?.projectType; - if (projectType === PROJECT_TYPE_FOOTPRINT) { - return { - inputType: { required: true }, - }; - } - return { - inputType: { forceValue: nullValue }, - }; - }, - ); - baseSchema = addCondition( baseSchema, value, @@ -437,8 +404,8 @@ export const projectFormSchema: ProjectFormSchema = { const zoomLevel = v?.zoomLevel; if ( projectType === PROJECT_TYPE_BUILD_AREA - || projectType === PROJECT_TYPE_COMPLETENESS || projectType === PROJECT_TYPE_CHANGE_DETECTION + || projectType === PROJECT_TYPE_COMPLETENESS || projectType === PROJECT_TYPE_STREET || (projectType === PROJECT_TYPE_FOOTPRINT && ( inputType === PROJECT_INPUT_TYPE_UPLOAD @@ -483,6 +450,51 @@ export const projectFormSchema: ProjectFormSchema = { }, ); + baseSchema = addCondition( + baseSchema, + value, + ['projectType'], + ['tileServer'], + (v) => { + const projectType = v?.projectType; + if ( + projectType === PROJECT_TYPE_BUILD_AREA + || projectType === PROJECT_TYPE_COMPLETENESS + || projectType === PROJECT_TYPE_CHANGE_DETECTION + || projectType === PROJECT_TYPE_FOOTPRINT + ) { + return { + tileServer: { + fields: tileServerFieldsSchema, + }, + }; + } + return { + tileServer: { forceValue: nullValue }, + }; + }, + ); + + // Validate + + baseSchema = addCondition( + baseSchema, + value, + ['projectType'], + ['inputType'], + (v) => { + const projectType = v?.projectType; + if (projectType === PROJECT_TYPE_FOOTPRINT) { + return { + inputType: { required: true }, + }; + } + return { + inputType: { forceValue: nullValue }, + }; + }, + ); + baseSchema = addCondition( baseSchema, value, @@ -560,6 +572,103 @@ export const projectFormSchema: ProjectFormSchema = { }, ); + // Street + + baseSchema = addCondition( + baseSchema, + value, + ['projectType'], + ['dateRange', 'creatorId', 'organizationId', 'samplingThreshold', 'panoOnly', 'isPano', 'randomizeOrder'], + (formValues) => { + if (formValues?.projectType === PROJECT_TYPE_STREET) { + return { + dateRange: { + required: false, + }, + creatorId: { + required: false, + validations: [ + integerCondition, + greaterThanCondition(0), + ], + }, + organizationId: { + required: false, + validations: [ + integerCondition, + greaterThanCondition(0), + ], + }, + samplingThreshold: { + required: false, + validations: [ + greaterThanCondition(0), + ], + }, + panoOnly: { + required: false, + }, + // FIXME: This is not used. + isPano: { + required: false, + }, + randomizeOrder: { + required: false, + }, + }; + } + return { + dateRange: { forceValue: nullValue }, + creatorId: { forceValue: nullValue }, + organizationId: { forceValue: nullValue }, + samplingThreshold: { forceValue: nullValue }, + panoOnly: { forceValue: nullValue }, + isPano: { forceValude: nullValue }, + randomizeOrder: { forceValue: nullValue }, + }; + }, + ); + + // Validate Image + + baseSchema = addCondition( + baseSchema, + value, + ['projectType'], + ['images'], + (formValues) => { + // FIXME: Add "unique" constraint for sourceIdentifier and fileName + // FIXME: Add max length constraint + if (formValues?.projectType === PROJECT_TYPE_VALIDATE_IMAGE) { + return { + images: { + keySelector: (key) => key.sourceIdentifier, + member: (): ImageFormSchemaMember => ({ + fields: (): ImageSchemaFields => ({ + sourceIdentifier: { + required: true, + requiredValidation: requiredStringCondition, + }, + fileName: { + required: true, + requiredValidation: requiredStringCondition, + }, + url: { + required: true, + requiredValidation: requiredStringCondition, + validations: [urlCondition], + }, + }), + }), + }, + }; + } + return { + images: { forceValue: nullValue }, + }; + }, + ); + return baseSchema; }, }; @@ -588,6 +697,7 @@ export function getGroupSize(projectType: ProjectType | undefined) { } if (projectType === PROJECT_TYPE_FOOTPRINT + || projectType === PROJECT_TYPE_VALIDATE_IMAGE || projectType === PROJECT_TYPE_CHANGE_DETECTION || projectType === PROJECT_TYPE_STREET) { return 25; diff --git a/manager-dashboard/app/views/NewTutorial/ImageInput/index.tsx b/manager-dashboard/app/views/NewTutorial/ImageInput/index.tsx new file mode 100644 index 000000000..f985d2ed8 --- /dev/null +++ b/manager-dashboard/app/views/NewTutorial/ImageInput/index.tsx @@ -0,0 +1,92 @@ +import React from 'react'; + +import { + SetValueArg, + Error, + useFormObject, + getErrorObject, +} from '@togglecorp/toggle-form'; +import TextInput from '#components/TextInput'; +import NumberInput from '#components/NumberInput'; + +import { + ImageType, +} from '../utils'; + +import styles from './styles.css'; + +const defaultImageValue: ImageType = { + sourceIdentifier: '', +}; + +interface Props { + value: ImageType; + onChange: (value: SetValueArg, index: number) => void | undefined; + index: number; + error: Error | undefined; + disabled?: boolean; + readOnly?: boolean; +} + +export default function ImageInput(props: Props) { + const { + value, + onChange, + index, + error: riskyError, + disabled, + readOnly, + } = props; + + const onImageChange = useFormObject(index, onChange, defaultImageValue); + + const error = getErrorObject(riskyError); + + return ( +
+ + + + + {/* FIXME: Use select input */} + +
+ ); +} diff --git a/manager-dashboard/app/views/NewTutorial/ImageInput/styles.css b/manager-dashboard/app/views/NewTutorial/ImageInput/styles.css new file mode 100644 index 000000000..a6e6f1707 --- /dev/null +++ b/manager-dashboard/app/views/NewTutorial/ImageInput/styles.css @@ -0,0 +1,5 @@ +.image-input { + display: flex; + flex-direction: column; + gap: var(--spacing-medium); +} diff --git a/manager-dashboard/app/views/NewTutorial/ScenarioPageInput/FootprintGeoJsonPreview/index.tsx b/manager-dashboard/app/views/NewTutorial/ScenarioPageInput/FootprintGeoJsonPreview/index.tsx index f381ff4f9..2ab9cbe36 100644 --- a/manager-dashboard/app/views/NewTutorial/ScenarioPageInput/FootprintGeoJsonPreview/index.tsx +++ b/manager-dashboard/app/views/NewTutorial/ScenarioPageInput/FootprintGeoJsonPreview/index.tsx @@ -15,7 +15,7 @@ import { import styles from './styles.css'; // NOTE: the padding is selected wrt the size of the preview -const footprintGeojsonPadding = [140, 140]; +const footprintGeojsonPadding: [number, number] = [140, 140]; interface Props { className?: string; diff --git a/manager-dashboard/app/views/NewTutorial/ScenarioPageInput/ValidateImagePreview/index.tsx b/manager-dashboard/app/views/NewTutorial/ScenarioPageInput/ValidateImagePreview/index.tsx new file mode 100644 index 000000000..3dfa8fb98 --- /dev/null +++ b/manager-dashboard/app/views/NewTutorial/ScenarioPageInput/ValidateImagePreview/index.tsx @@ -0,0 +1,81 @@ +import React from 'react'; +import { _cs } from '@togglecorp/fujs'; + +import MobilePreview from '#components/MobilePreview'; +import { IconKey, iconMap } from '#utils/common'; + +import { + ImageType, + colorKeyToColorMap, + PartialCustomOptionsType, +} from '../../utils'; +import styles from './styles.css'; + +interface Props { + className?: string; + image?: ImageType; + previewPopUp?: { + title?: string; + description?: string; + icon?: IconKey; + } + customOptions: PartialCustomOptionsType | undefined; + lookFor: string | undefined; +} + +export default function ValidateImagePreview(props: Props) { + const { + className, + previewPopUp, + customOptions, + lookFor, + image, + } = props; + + const Comp = previewPopUp?.icon ? iconMap[previewPopUp.icon] : undefined; + + return ( + } + popupTitle={previewPopUp?.title || '{title}'} + popupDescription={previewPopUp?.description || '{description}'} + > + Preview +
+ {customOptions?.map((option) => { + const Icon = option.icon + ? iconMap[option.icon] + : iconMap['flag-outline']; + return ( +
+
+ {Icon && ( + + )} +
+
+ ); + })} +
+
+ ); +} diff --git a/manager-dashboard/app/views/NewTutorial/ScenarioPageInput/ValidateImagePreview/styles.css b/manager-dashboard/app/views/NewTutorial/ScenarioPageInput/ValidateImagePreview/styles.css new file mode 100644 index 000000000..d3642b14d --- /dev/null +++ b/manager-dashboard/app/views/NewTutorial/ScenarioPageInput/ValidateImagePreview/styles.css @@ -0,0 +1,37 @@ +.validate-image-preview { + .content { + display: flex; + flex-direction: column; + gap: var(--spacing-large); + + .image-preview { + position: relative; + border: 1px solid red; + width: 100%; + height: var(--height-mobile-preview-validate-image-content); + } + + .options { + display: grid; + flex-grow: 1; + grid-template-columns: 1fr 1fr 1fr; + grid-gap: var(--spacing-large); + + .option-container { + display: flex; + align-items: center; + justify-content: center; + + .option { + display: flex; + align-items: center; + justify-content: center; + border-radius: 50%; + width: 2.5rem; + height: 2.5rem; + font-size: var(--font-size-extra-large); + } + } + } + } +} diff --git a/manager-dashboard/app/views/NewTutorial/ScenarioPageInput/index.tsx b/manager-dashboard/app/views/NewTutorial/ScenarioPageInput/index.tsx index b309be7ff..2ba9d05fe 100644 --- a/manager-dashboard/app/views/NewTutorial/ScenarioPageInput/index.tsx +++ b/manager-dashboard/app/views/NewTutorial/ScenarioPageInput/index.tsx @@ -18,6 +18,7 @@ import { PROJECT_TYPE_CHANGE_DETECTION, PROJECT_TYPE_COMPLETENESS, PROJECT_TYPE_STREET, + PROJECT_TYPE_VALIDATE_IMAGE, } from '#utils/common'; import TextInput from '#components/TextInput'; import Heading from '#components/Heading'; @@ -25,6 +26,7 @@ import SelectInput from '#components/SelectInput'; import SegmentInput from '#components/SegmentInput'; import { + ImageType, TutorialTasksGeoJSON, FootprintGeoJSON, BuildAreaGeoJSON, @@ -34,6 +36,7 @@ import { import BuildAreaGeoJsonPreview from './BuildAreaGeoJsonPreview'; import FootprintGeoJsonPreview from './FootprintGeoJsonPreview'; import ChangeDetectionGeoJsonPreview from './ChangeDetectionGeoJsonPreview'; +import ValidateImagePreview from './ValidateImagePreview'; import styles from './styles.css'; type ScenarioType = { @@ -78,6 +81,7 @@ interface Props { index: number, error: Error | undefined; geoJson: TutorialTasksGeoJSON | undefined; + images: ImageType[] | undefined; projectType: ProjectType | undefined; urlA: string | undefined; urlB: string | undefined; @@ -94,6 +98,7 @@ export default function ScenarioPageInput(props: Props) { index, error: riskyError, geoJson: geoJsonFromProps, + images, urlA, projectType, urlB, @@ -171,7 +176,21 @@ export default function ScenarioPageInput(props: Props) { [geoJsonFromProps, scenarioId], ); - const activeSegmentInput: ScenarioSegmentType['value'] = projectType && projectType !== PROJECT_TYPE_FOOTPRINT + const image = React.useMemo( + () => { + if (!images) { + return undefined; + } + return images.find((img) => img.screen === scenarioId); + }, + [images, scenarioId], + ); + + const activeSegmentInput: ScenarioSegmentType['value'] = ( + projectType + && projectType !== PROJECT_TYPE_FOOTPRINT + && projectType !== PROJECT_TYPE_VALIDATE_IMAGE + ) ? activeSegmentInputFromState : 'instructions'; @@ -214,7 +233,11 @@ export default function ScenarioPageInput(props: Props) { disabled={disabled} />
- {projectType && projectType !== PROJECT_TYPE_FOOTPRINT && ( + {( + projectType + && projectType !== PROJECT_TYPE_FOOTPRINT + && projectType !== PROJECT_TYPE_VALIDATE_IMAGE + ) && ( <> Hint @@ -252,7 +275,11 @@ export default function ScenarioPageInput(props: Props) { )} - {projectType && projectType !== PROJECT_TYPE_FOOTPRINT && ( + {( + projectType + && projectType !== PROJECT_TYPE_FOOTPRINT + && projectType !== PROJECT_TYPE_VALIDATE_IMAGE + ) && ( <> Success @@ -319,6 +346,14 @@ export default function ScenarioPageInput(props: Props) { lookFor={lookFor} /> )} + {projectType === PROJECT_TYPE_VALIDATE_IMAGE && ( + + )} {projectType === PROJECT_TYPE_STREET && (
Preview not available. @@ -326,6 +361,7 @@ export default function ScenarioPageInput(props: Props) { )} {(projectType && projectType !== PROJECT_TYPE_FOOTPRINT + && projectType !== PROJECT_TYPE_VALIDATE_IMAGE && projectType !== PROJECT_TYPE_STREET) && ( + export function getDuplicates( list: T[], keySelector: (item: T) => K, @@ -157,6 +185,7 @@ function getGeoJSONError( return 'GeoJSON does not contain iterable features'; } + // FIXME: Use io-ts // Check properties schema const projectSchemas: { [key in ProjectType]: Record; @@ -195,6 +224,9 @@ function getGeoJSONError( reference: 'number', screen: 'number', }, + [PROJECT_TYPE_VALIDATE_IMAGE]: { + // NOTE: We do not use geojson import for validate image project + }, }; const schemaErrors = tutorialTasks.features.map( (feature) => checkSchema( @@ -406,6 +438,14 @@ function NewTutorial(props: Props) { InformationPagesType >('informationPages', setFieldValue); + const { + setValue: setImageValue, + // removeValue: onImageRemove, + } = useFormArray< + 'images', + ImageType + >('images', setFieldValue); + const handleSubmission = React.useCallback(( finalValuesFromProps: PartialTutorialFormType, ) => { @@ -600,7 +640,6 @@ function NewTutorial(props: Props) { })); return; } - setFieldValue(tutorialTasks, 'tutorialTasks'); const uniqueArray = unique( @@ -616,7 +655,6 @@ function NewTutorial(props: Props) { success: {}, } )); - setFieldValue(tutorialTaskArray, 'scenarioPages'); }, [setFieldValue, setError, value?.projectType]); @@ -645,6 +683,56 @@ function NewTutorial(props: Props) { [setFieldValue], ); + const handleCocoImport = React.useCallback( + (val) => { + const result = CocoDataset.decode(val); + if (!isRight(result)) { + // eslint-disable-next-line no-console + console.error('Invalid COCO format', result.left); + setError((err) => ({ + ...getErrorObject(err), + [nonFieldError]: 'Invalid COCO format', + })); + return; + } + if (result.right.images.length > MAX_IMAGES) { + setError((err) => ({ + ...getErrorObject(err), + [nonFieldError]: `Too many images ${result.right.images.length} uploaded. Please do not exceed ${MAX_IMAGES} images.`, + })); + return; + } + + const newImages = result.right.images.map((image, index) => ({ + sourceIdentifier: String(image.id), + fileName: image.file_name, + url: image.flickr_url || image.coco_url, + screen: index + 1, + referenceAnswer: 1, + })); + setFieldValue( + () => newImages, + 'images', + ); + + const uniqueArray = unique( + newImages, + ((img) => img.screen), + ); + const sorted = uniqueArray.sort((a, b) => a.screen - b.screen); + const tutorialTaskArray = sorted?.map((img) => ( + { + scenarioId: img.screen, + hint: {}, + instructions: {}, + success: {}, + } + )); + setFieldValue(tutorialTaskArray, 'scenarioPages'); + }, + [setFieldValue, setError], + ); + const submissionPending = ( tutorialSubmissionStatus === 'started' || tutorialSubmissionStatus === 'imageUpload' @@ -678,6 +766,11 @@ function NewTutorial(props: Props) { [error?.informationPages], ); + const imagesError = React.useMemo( + () => getErrorObject(error?.images), + [error?.images], + ); + const hasErrors = React.useMemo( () => analyzeErrors(error), [error], @@ -693,6 +786,8 @@ function NewTutorial(props: Props) { ...options, ...subOptions, ].filter(isDefined); + + // FIXME: Add warning here for validate image return getGeoJSONWarning( value?.tutorialTasks, value?.projectType, @@ -719,6 +814,7 @@ function NewTutorial(props: Props) { const { customOptions, informationPages, + images, } = value; const handleProjectTypeChange = React.useCallback( @@ -774,6 +870,7 @@ function NewTutorial(props: Props) { {( value.projectType === PROJECT_TYPE_FOOTPRINT + || value.projectType === PROJECT_TYPE_VALIDATE_IMAGE || value.projectType === PROJECT_TYPE_STREET ) && ( ) } + {value.projectType === PROJECT_TYPE_VALIDATE_IMAGE && ( + + + + name={undefined} + onChange={handleCocoImport} + disabled={ + submissionPending + || projectTypeEmpty + } + label="Import COCO file" + value={undefined} + /> + {(images && images.length > 0) ? ( +
+ {images.map((image, index) => ( + + + + ))} +
+ ) : ( + + )} +
+ )} - + {value?.projectType !== PROJECT_TYPE_VALIDATE_IMAGE && ( + + )}
{value.scenarioPages?.map((task, index) => ( ))} {(value.scenarioPages?.length ?? 0) === 0 && ( - + <> + {value.projectType !== PROJECT_TYPE_VALIDATE_IMAGE ? ( + + ) : ( + + )} + )}
diff --git a/manager-dashboard/app/views/NewTutorial/styles.css b/manager-dashboard/app/views/NewTutorial/styles.css index 7242f2344..40b8cbdf6 100644 --- a/manager-dashboard/app/views/NewTutorial/styles.css +++ b/manager-dashboard/app/views/NewTutorial/styles.css @@ -20,6 +20,13 @@ gap: var(--spacing-medium); } + .image-list { + display: flex; + flex-direction: column; + flex-grow: 1; + gap: var(--spacing-medium); + } + .custom-option-container { display: flex; gap: var(--spacing-large); diff --git a/manager-dashboard/app/views/NewTutorial/utils.ts b/manager-dashboard/app/views/NewTutorial/utils.ts index 67f5e4af5..74fc7711b 100644 --- a/manager-dashboard/app/views/NewTutorial/utils.ts +++ b/manager-dashboard/app/views/NewTutorial/utils.ts @@ -8,6 +8,7 @@ import { nullValue, ArraySchema, addCondition, + urlCondition, } from '@togglecorp/toggle-form'; import { isDefined, @@ -27,6 +28,7 @@ import { PROJECT_TYPE_COMPLETENESS, PROJECT_TYPE_FOOTPRINT, PROJECT_TYPE_STREET, + PROJECT_TYPE_VALIDATE_IMAGE, IconKey, } from '#utils/common'; @@ -285,6 +287,36 @@ export const defaultStreetCustomOptions: PartialTutorialFormType['customOptions' }, ]; +export const defaultValidateImageCustomOptions: PartialTutorialFormType['customOptions'] = [ + { + optionId: 1, + value: 1, + title: 'Yes', + icon: 'checkmark-outline', + iconColor: colorKeyToColorMap.green, + // FIXME: Add description + description: 'Yes', + }, + { + optionId: 2, + value: 0, + title: 'No', + icon: 'close-outline', + iconColor: colorKeyToColorMap.red, + // FIXME: Add description + description: 'No', + }, + { + optionId: 3, + value: 2, + title: 'Not Sure', + icon: 'remove-outline', + iconColor: colorKeyToColorMap.gray, + // FIXME: Add description + description: 'Not Sure', + }, +]; + export function deleteKey( value: T, key: K, @@ -305,6 +337,10 @@ export function getDefaultOptions(projectType: ProjectType | undefined) { return defaultStreetCustomOptions; } + if (projectType === PROJECT_TYPE_VALIDATE_IMAGE) { + return defaultValidateImageCustomOptions; + } + return undefined; } @@ -426,7 +462,6 @@ export interface TutorialFormType { title: string; }; }[]; - tutorialTasks?: TutorialTasksGeoJSON, exampleImage1: File; exampleImage2: File; projectType: ProjectType; @@ -434,6 +469,15 @@ export interface TutorialFormType { zoomLevel?: number; customOptions?: CustomOptions; informationPages: InformationPages; + + tutorialTasks?: TutorialTasksGeoJSON, + images?: { + sourceIdentifier: string; + fileName: string; + url: string; + referenceAnswer: number; + screen: number; + }[]; } export type PartialTutorialFormType = PartialForm< @@ -442,9 +486,11 @@ export type PartialTutorialFormType = PartialForm< exampleImage2?: File; }, // NOTE: we do not want to change File and FeatureCollection to partials - 'image' | 'tutorialTasks' | 'exampleImage1' | 'exampleImage2' | 'scenarioId' | 'optionId' | 'subOptionsId' | 'pageNumber' | 'blockNumber' | 'blockType' | 'imageFile' + 'image' | 'tutorialTasks' | 'exampleImage1' | 'exampleImage2' | 'scenarioId' | 'optionId' | 'subOptionsId' | 'pageNumber' | 'blockNumber' | 'blockType' | 'imageFile' | 'sourceIdentifier' >; +export type ImageType = NonNullable[number]; + type TutorialFormSchema = ObjectSchema; type TutorialFormSchemaFields = ReturnType; @@ -462,6 +508,12 @@ export type CustomOptionSchemaFields = ReturnType export type CustomOptionFormSchema = ArraySchema; export type CustomOptionFormSchemaMember = ReturnType; +type PartialImages = NonNullable[number]; +type ImageSchema = ObjectSchema; +type ImageSchemaFields = ReturnType +type ImageFormSchema = ArraySchema; +type ImageFormSchemaMember = ReturnType; + export type InformationPagesType = NonNullable[number] type InformationPagesSchema = ObjectSchema; type InformationPagesSchemaFields = ReturnType @@ -473,6 +525,8 @@ export type PartialInformationPagesType = PartialTutorialFormType['informationPa export type PartialCustomOptionsType = PartialTutorialFormType['customOptions']; export type PartialBlocksType = NonNullable[number]>['blocks']; +export const MAX_IMAGES = 20; + export const MAX_OPTIONS = 6; export const MIN_OPTIONS = 2; export const MAX_SUB_OPTIONS = 6; @@ -500,12 +554,6 @@ export const tutorialFormSchema: TutorialFormSchema = { requiredValidation: requiredStringCondition, validations: [getNoMoreThanNCharacterCondition(MD_TEXT_MAX_LENGTH)], }, - tileServer: { - fields: tileServerFieldsSchema, - }, - tutorialTasks: { - required: true, - }, informationPages: { validation: (info) => { if (info && info.length > MAX_INFO_PAGES) { @@ -564,6 +612,8 @@ export const tutorialFormSchema: TutorialFormSchema = { }, }; + // common + baseSchema = addCondition( baseSchema, value, @@ -601,7 +651,11 @@ export const tutorialFormSchema: TutorialFormSchema = { }), }, }; - if (projectType && projectType !== PROJECT_TYPE_FOOTPRINT) { + if ( + projectType + && projectType !== PROJECT_TYPE_FOOTPRINT + && projectType !== PROJECT_TYPE_VALIDATE_IMAGE + ) { fields = { ...fields, hint: { @@ -776,6 +830,7 @@ export const tutorialFormSchema: TutorialFormSchema = { }; if (formValues?.projectType === PROJECT_TYPE_FOOTPRINT + || formValues?.projectType === PROJECT_TYPE_VALIDATE_IMAGE || formValues?.projectType === PROJECT_TYPE_STREET) { return { customOptions: customOptionField, @@ -809,6 +864,23 @@ export const tutorialFormSchema: TutorialFormSchema = { }), ); + baseSchema = addCondition( + baseSchema, + value, + ['projectType'], + ['tileServer'], + (v) => ( + v?.projectType !== PROJECT_TYPE_VALIDATE_IMAGE + ? { + tileServer: { + fields: tileServerFieldsSchema, + }, + } : { + tileServer: { forceValue: nullValue }, + } + ), + ); + baseSchema = addCondition( baseSchema, value, @@ -824,6 +896,72 @@ export const tutorialFormSchema: TutorialFormSchema = { tileServerB: { forceValue: nullValue }, }), ); + + baseSchema = addCondition( + baseSchema, + value, + ['projectType'], + ['tutorialTasks'], + (formValues) => { + if (formValues?.projectType === PROJECT_TYPE_VALIDATE_IMAGE) { + return { + tutorialTasks: { forceValue: nullValue }, + }; + } + return { + tutorialTasks: { + required: true, + }, + }; + }, + ); + + // validate image + + baseSchema = addCondition( + baseSchema, + value, + ['projectType'], + ['images'], + (formValues) => { + // FIXME: Add "unique" constraint for sourceIdentifier and fileName + // FIXME: Add max length constraint + if (formValues?.projectType === PROJECT_TYPE_VALIDATE_IMAGE) { + return { + images: { + keySelector: (key) => key.sourceIdentifier, + member: (): ImageFormSchemaMember => ({ + fields: (): ImageSchemaFields => ({ + sourceIdentifier: { + required: true, + requiredValidation: requiredStringCondition, + }, + fileName: { + required: true, + requiredValidation: requiredStringCondition, + }, + url: { + required: true, + requiredValidation: requiredStringCondition, + validations: [urlCondition], + }, + referenceAnswer: { + required: true, + }, + screen: { + required: true, + }, + }), + }), + }, + }; + } + return { + images: { forceValue: nullValue }, + }; + }, + ); + return baseSchema; }, }; diff --git a/manager-dashboard/package.json b/manager-dashboard/package.json index de3c020bd..4b0ee3c1a 100644 --- a/manager-dashboard/package.json +++ b/manager-dashboard/package.json @@ -44,8 +44,11 @@ "apollo-upload-client": "^16.0.0", "core-js": "3", "firebase": "^9.9.0", + "fp-ts": "^2.16.10", "graphql": "^15.5.1", "graphql-anywhere": "^4.2.7", + "io-ts": "^2.2.22", + "io-ts-types": "^0.5.19", "leaflet": "^1.8.0", "react": "^17.0.2", "react-dom": "^17.0.2", diff --git a/manager-dashboard/yarn.lock b/manager-dashboard/yarn.lock index a3597eea5..66718bef1 100644 --- a/manager-dashboard/yarn.lock +++ b/manager-dashboard/yarn.lock @@ -6668,6 +6668,11 @@ forwarded@0.2.0: resolved "https://registry.yarnpkg.com/forwarded/-/forwarded-0.2.0.tgz#2269936428aad4c15c7ebe9779a84bf0b2a81811" integrity sha512-buRG0fpBtRHSTCOASe6hD258tEubFoRLb4ZNA6NxMVHNw2gOcwHo9wyablzMzOA5z9xA9L1KNjk/Nt6MT9aYow== +fp-ts@^2.16.10: + version "2.16.10" + resolved "https://registry.yarnpkg.com/fp-ts/-/fp-ts-2.16.10.tgz#829b82a46571c2dc202bed38a9c2eeec603e38c4" + integrity sha512-vuROzbNVfCmUkZSUbnWSltR1sbheyQbTzug7LB/46fEa1c0EucLeBaCEUE0gF3ZGUGBt9lVUiziGOhhj6K1ORA== + fraction.js@^4.1.1: version "4.1.2" resolved "https://registry.yarnpkg.com/fraction.js/-/fraction.js-4.1.2.tgz#13e420a92422b6cf244dff8690ed89401029fbe8" @@ -7480,6 +7485,16 @@ invariant@^2.2.4: dependencies: loose-envify "^1.0.0" +io-ts-types@^0.5.19: + version "0.5.19" + resolved "https://registry.yarnpkg.com/io-ts-types/-/io-ts-types-0.5.19.tgz#9c04fa73f15992436605218a5686b610efa7a5d3" + integrity sha512-kQOYYDZG5vKre+INIDZbLeDJe+oM+4zLpUkjXyTMyUfoCpjJNyi29ZLkuEAwcPufaYo3yu/BsemZtbdD+NtRfQ== + +io-ts@^2.2.22: + version "2.2.22" + resolved "https://registry.yarnpkg.com/io-ts/-/io-ts-2.2.22.tgz#5ab0d3636fe8494a275f0266461ab019da4b8d0b" + integrity sha512-FHCCztTkHoV9mdBsHpocLpdTAfh956ZQcIkWQxxS0U5HT53vtrcuYdQneEJKH6xILaLNzXVl2Cvwtoy8XNN0AA== + ip-regex@^2.1.0: version "2.1.0" resolved "https://registry.yarnpkg.com/ip-regex/-/ip-regex-2.1.0.tgz#fa78bf5d2e6913c911ce9f819ee5146bb6d844e9" From fa13b4fb56cd19dc6cf92ce65c5d5355c93dfe20 Mon Sep 17 00:00:00 2001 From: tnagorra Date: Fri, 27 Jun 2025 17:15:34 +0545 Subject: [PATCH 70/76] feat(validate_image): validations for coco file - create a input for coco file - add validations while importing coco file - add max validation for images - always show coco import on create project - add validation on forms for no. of images - add a select input for option selection in image reference - add warning if undefined option used in image reference --- .../app/components/CocoFileInput/index.tsx | 80 ++++++++++++++ .../app/components/JsonFileInput/index.tsx | 2 +- .../app/views/NewProject/index.tsx | 73 +++++-------- .../app/views/NewProject/utils.ts | 7 +- .../views/NewTutorial/ImageInput/index.tsx | 49 ++++++++- .../ValidateImagePreview/styles.css | 1 - .../app/views/NewTutorial/index.tsx | 100 +++++++++--------- .../app/views/NewTutorial/utils.ts | 7 +- 8 files changed, 214 insertions(+), 105 deletions(-) create mode 100644 manager-dashboard/app/components/CocoFileInput/index.tsx diff --git a/manager-dashboard/app/components/CocoFileInput/index.tsx b/manager-dashboard/app/components/CocoFileInput/index.tsx new file mode 100644 index 000000000..125e0c592 --- /dev/null +++ b/manager-dashboard/app/components/CocoFileInput/index.tsx @@ -0,0 +1,80 @@ +import React from 'react'; +import * as t from 'io-ts'; +import { isRight } from 'fp-ts/Either'; + +import JsonFileInput, { Props as JsonFileInputProps } from '#components/JsonFileInput'; + +const Image = t.type({ + id: t.number, + // width: t.number, + // height: t.number, + file_name: t.string, + // license: t.union([t.number, t.undefined]), + flickr_url: t.union([t.string, t.undefined]), + coco_url: t.union([t.string, t.undefined]), + // date_captured: DateFromISOString, +}); + +const CocoDataset = t.type({ + // info: Info, + // licenses: t.array(License), + images: t.array(Image), + // annotations: t.array(Annotation), + // categories: t.array(Category) +}); +export type CocoDatasetType = t.TypeOf + +interface Props extends Omit, 'onChange' | 'value'> { + value: CocoDatasetType | undefined; + maxLength: number; + onChange: (newValue: CocoDatasetType | undefined, name: N) => void; +} +function CocoFileInput(props: Props) { + const { + name, + onChange, + error, + maxLength, + ...otherProps + } = props; + + const [ + internalErrorMessage, + setInternalErrorMessage, + ] = React.useState(); + + const handleChange = React.useCallback( + (val) => { + const result = CocoDataset.decode(val); + if (!isRight(result)) { + // eslint-disable-next-line no-console + console.error('Invalid COCO format', result.left); + setInternalErrorMessage('Invalid COCO format'); + return; + } + if (result.right.images.length > maxLength) { + setInternalErrorMessage(`Too many images ${result.right.images.length} uploaded. Please do not exceed ${maxLength} images.`); + return; + } + const uniqueIdentifiers = new Set(result.right.images.map((item) => item.id)); + if (uniqueIdentifiers.size < result.right.images.length) { + setInternalErrorMessage('Each image should have a unique id.'); + return; + } + setInternalErrorMessage(undefined); + onChange(result.right, name); + }, + [onChange, maxLength, name], + ); + + return ( + + ); +} + +export default CocoFileInput; diff --git a/manager-dashboard/app/components/JsonFileInput/index.tsx b/manager-dashboard/app/components/JsonFileInput/index.tsx index bda27a599..023abde95 100644 --- a/manager-dashboard/app/components/JsonFileInput/index.tsx +++ b/manager-dashboard/app/components/JsonFileInput/index.tsx @@ -23,7 +23,7 @@ function readUploadedFileAsText(inputFile: File) { const ONE_MB = 1024 * 1024; const DEFAULT_MAX_FILE_SIZE = ONE_MB; -interface Props extends Omit, 'value' | 'onChange' | 'accept'> { +export interface Props extends Omit, 'value' | 'onChange' | 'accept'> { maxFileSize?: number; value: T | undefined | null; onChange: (newValue: T | undefined, name: N) => void; diff --git a/manager-dashboard/app/views/NewProject/index.tsx b/manager-dashboard/app/views/NewProject/index.tsx index 23666c80b..cbc94b5ad 100644 --- a/manager-dashboard/app/views/NewProject/index.tsx +++ b/manager-dashboard/app/views/NewProject/index.tsx @@ -37,8 +37,6 @@ import { IoIosTrash, } from 'react-icons/io'; import { Link } from 'react-router-dom'; -import * as t from 'io-ts'; -import { isRight } from 'fp-ts/Either'; import UserContext from '#base/context/UserContext'; import projectTypeOptions from '#base/configs/projectTypes'; @@ -48,7 +46,7 @@ import TextInput from '#components/TextInput'; import NumberInput from '#components/NumberInput'; import SegmentInput from '#components/SegmentInput'; import GeoJsonFileInput from '#components/GeoJsonFileInput'; -import JsonFileInput from '#components/JsonFileInput'; +import CocoFileInput, { CocoDatasetType } from '#components/CocoFileInput'; import TileServerInput, { TILE_SERVER_BING, TILE_SERVER_ESRI, @@ -57,6 +55,7 @@ import TileServerInput, { import InputSection from '#components/InputSection'; import Button from '#components/Button'; import NonFieldError from '#components/NonFieldError'; +import EmptyMessage from '#components/EmptyMessage'; import AnimatedSwipeIcon from '#components/AnimatedSwipeIcon'; import ExpandableContainer from '#components/ExpandableContainer'; import AlertBanner from '#components/AlertBanner'; @@ -103,25 +102,6 @@ import ImageInput from './ImageInput'; // eslint-disable-next-line postcss-modules/no-unused-class import styles from './styles.css'; -const Image = t.type({ - id: t.number, - // width: t.number, - // height: t.number, - file_name: t.string, - // license: t.union([t.number, t.undefined]), - flickr_url: t.union([t.string, t.undefined]), - coco_url: t.union([t.string, t.undefined]), - // date_captured: DateFromISOString, -}); -const CocoDataset = t.type({ - // info: Info, - // licenses: t.array(License), - images: t.array(Image), - // annotations: t.array(Annotation), - // categories: t.array(Category) -}); -// type CocoDatasetType = t.TypeOf - const defaultProjectFormValue: PartialProjectFormType = { // projectType: PROJECT_TYPE_BUILD_AREA, projectNumber: 1, @@ -501,26 +481,16 @@ function NewProject(props: Props) { >('images', setFieldValue); const handleCocoImport = React.useCallback( - (val) => { - const result = CocoDataset.decode(val); - if (!isRight(result)) { - // eslint-disable-next-line no-console - console.error('Invalid COCO format', result.left); - setError((err) => ({ - ...getErrorObject(err), - [nonFieldError]: 'Invalid COCO format', - })); - return; - } - if (result.right.images.length > MAX_IMAGES) { - setError((err) => ({ - ...getErrorObject(err), - [nonFieldError]: `Too many images ${result.right.images.length} uploaded. Please do not exceed ${MAX_IMAGES} images.`, - })); + (val: CocoDatasetType | undefined) => { + if (isNotDefined(val)) { + setFieldValue( + [], + 'images', + ); return; } setFieldValue( - () => result.right.images.map((image) => ({ + () => val.images.map((image) => ({ sourceIdentifier: String(image.id), fileName: image.file_name, url: image.flickr_url || image.coco_url, @@ -528,7 +498,7 @@ function NewProject(props: Props) { 'images', ); }, - [setFieldValue, setError], + [setFieldValue], ); const handleAddImage = React.useCallback( @@ -613,6 +583,17 @@ function NewProject(props: Props) { + {(images && images.length > 0) ? (
{images.map((image, index) => ( @@ -647,15 +628,9 @@ function NewProject(props: Props) { ))}
) : ( - - name={undefined} - onChange={handleCocoImport} - disabled={ - submissionPending - || projectTypeEmpty - } - label="Import COCO file" - value={undefined} + )}
diff --git a/manager-dashboard/app/views/NewProject/utils.ts b/manager-dashboard/app/views/NewProject/utils.ts index be883e85d..e2ac2731f 100644 --- a/manager-dashboard/app/views/NewProject/utils.ts +++ b/manager-dashboard/app/views/NewProject/utils.ts @@ -638,11 +638,16 @@ export const projectFormSchema: ProjectFormSchema = { ['images'], (formValues) => { // FIXME: Add "unique" constraint for sourceIdentifier and fileName - // FIXME: Add max length constraint if (formValues?.projectType === PROJECT_TYPE_VALIDATE_IMAGE) { return { images: { keySelector: (key) => key.sourceIdentifier, + validation: (values) => { + if (values && values.length > MAX_IMAGES) { + return `Too many images ${values.length}. Please do not exceed ${MAX_IMAGES} images.`; + } + return undefined; + }, member: (): ImageFormSchemaMember => ({ fields: (): ImageSchemaFields => ({ sourceIdentifier: { diff --git a/manager-dashboard/app/views/NewTutorial/ImageInput/index.tsx b/manager-dashboard/app/views/NewTutorial/ImageInput/index.tsx index f985d2ed8..ca10b5806 100644 --- a/manager-dashboard/app/views/NewTutorial/ImageInput/index.tsx +++ b/manager-dashboard/app/views/NewTutorial/ImageInput/index.tsx @@ -1,4 +1,4 @@ -import React from 'react'; +import React, { useMemo } from 'react'; import { SetValueArg, @@ -6,11 +6,14 @@ import { useFormObject, getErrorObject, } from '@togglecorp/toggle-form'; +import { isNotDefined, isDefined, unique } from '@togglecorp/fujs'; import TextInput from '#components/TextInput'; +import SelectInput from '#components/SelectInput'; import NumberInput from '#components/NumberInput'; import { ImageType, + PartialCustomOptionsType, } from '../utils'; import styles from './styles.css'; @@ -26,6 +29,7 @@ interface Props { error: Error | undefined; disabled?: boolean; readOnly?: boolean; + customOptions: PartialCustomOptionsType | undefined; } export default function ImageInput(props: Props) { @@ -36,8 +40,45 @@ export default function ImageInput(props: Props) { error: riskyError, disabled, readOnly, + customOptions, } = props; + const flattenedOptions = useMemo( + () => { + const opts = customOptions?.flatMap( + (option) => ([ + { + key: option.value, + label: option.title, + }, + ...(option.subOptions ?? []).map( + (subOption) => ({ + key: subOption.value, + label: subOption.description, + }), + ), + ]), + ) ?? []; + + const validOpts = opts.map( + (option) => { + if (isNotDefined(option.key)) { + return undefined; + } + return { + ...option, + key: option.key, + }; + }, + ).filter(isDefined); + return unique( + validOpts, + (option) => option.key, + ); + }, + [customOptions], + ); + const onImageChange = useFormObject(index, onChange, defaultImageValue); const error = getErrorObject(riskyError); @@ -78,12 +119,14 @@ export default function ImageInput(props: Props) { disabled={disabled} readOnly /> - {/* FIXME: Use select input */} - option.key} + labelSelector={(option) => option.label ?? `Option ${option.key}`} + options={flattenedOptions} error={error?.referenceAnswer} disabled={disabled || readOnly} /> diff --git a/manager-dashboard/app/views/NewTutorial/ScenarioPageInput/ValidateImagePreview/styles.css b/manager-dashboard/app/views/NewTutorial/ScenarioPageInput/ValidateImagePreview/styles.css index d3642b14d..5f708d4a5 100644 --- a/manager-dashboard/app/views/NewTutorial/ScenarioPageInput/ValidateImagePreview/styles.css +++ b/manager-dashboard/app/views/NewTutorial/ScenarioPageInput/ValidateImagePreview/styles.css @@ -6,7 +6,6 @@ .image-preview { position: relative; - border: 1px solid red; width: 100%; height: var(--height-mobile-preview-validate-image-content); } diff --git a/manager-dashboard/app/views/NewTutorial/index.tsx b/manager-dashboard/app/views/NewTutorial/index.tsx index 1830402a0..162361ddd 100644 --- a/manager-dashboard/app/views/NewTutorial/index.tsx +++ b/manager-dashboard/app/views/NewTutorial/index.tsx @@ -14,7 +14,6 @@ import { createSubmitHandler, analyzeErrors, useFormArray, - nonFieldError, } from '@togglecorp/toggle-form'; import { getStorage, @@ -41,8 +40,6 @@ import { IoInformationCircleOutline, } from 'react-icons/io5'; import { Link } from 'react-router-dom'; -import * as t from 'io-ts'; -import { isRight } from 'fp-ts/Either'; import UserContext from '#base/context/UserContext'; import projectTypeOptions from '#base/configs/projectTypes'; @@ -53,7 +50,7 @@ import NumberInput from '#components/NumberInput'; import Heading from '#components/Heading'; import SegmentInput from '#components/SegmentInput'; import GeoJsonFileInput from '#components/GeoJsonFileInput'; -import JsonFileInput from '#components/JsonFileInput'; +import CocoFileInput, { CocoDatasetType } from '#components/CocoFileInput'; import ExpandableContainer from '#components/ExpandableContainer'; import PopupButton from '#components/PopupButton'; import TileServerInput, { @@ -109,26 +106,6 @@ import InformationPageInput from './InformationPageInput'; import ImageInput from './ImageInput'; import styles from './styles.css'; -// FIXME: let's not duplicate this logic -const Image = t.type({ - id: t.number, - // width: t.number, - // height: t.number, - file_name: t.string, - // license: t.union([t.number, t.undefined]), - flickr_url: t.union([t.string, t.undefined]), - coco_url: t.union([t.string, t.undefined]), - // date_captured: DateFromISOString, -}); -const CocoDataset = t.type({ - // info: Info, - // licenses: t.array(License), - images: t.array(Image), - // annotations: t.array(Annotation), - // categories: t.array(Category) -}); -// type CocoDatasetType = t.TypeOf - export function getDuplicates( list: T[], keySelector: (item: T) => K, @@ -351,6 +328,27 @@ function getGeoJSONWarning( return errors; } +function getImagesWarning( + images: ImageType[], + customOptions: number[], +) { + const errors = []; + + const usedValues = images.map((item) => item.referenceAnswer).filter(isDefined); + + const usedValuesSet = new Set(usedValues); + const customOptionsSet = new Set(customOptions); + + const invalidUsedValuesSet = difference(usedValuesSet, customOptionsSet); + + if (invalidUsedValuesSet.size === 1) { + errors.push(`Reference in images should be either ${customOptions.join(', ')}. The invalid reference is ${[...invalidUsedValuesSet].join(', ')}`); + } else if (invalidUsedValuesSet.size > 1) { + errors.push(`Reference in images should be either ${customOptions.join(', ')}. The invalid references are ${[...invalidUsedValuesSet].sort().join(', ')}`); + } + return errors; +} + type CustomScreen = Omit; function sanitizeScreens(scenarioPages: TutorialFormType['scenarioPages']) { const screens = scenarioPages.reduce>( @@ -684,34 +682,23 @@ function NewTutorial(props: Props) { ); const handleCocoImport = React.useCallback( - (val) => { - const result = CocoDataset.decode(val); - if (!isRight(result)) { - // eslint-disable-next-line no-console - console.error('Invalid COCO format', result.left); - setError((err) => ({ - ...getErrorObject(err), - [nonFieldError]: 'Invalid COCO format', - })); - return; - } - if (result.right.images.length > MAX_IMAGES) { - setError((err) => ({ - ...getErrorObject(err), - [nonFieldError]: `Too many images ${result.right.images.length} uploaded. Please do not exceed ${MAX_IMAGES} images.`, - })); + (val: CocoDatasetType | undefined) => { + if (isNotDefined(val)) { + setFieldValue( + [], + 'images', + ); return; } - - const newImages = result.right.images.map((image, index) => ({ + const newImages = val.images.map((image, index) => ({ sourceIdentifier: String(image.id), fileName: image.file_name, url: image.flickr_url || image.coco_url, screen: index + 1, - referenceAnswer: 1, + // referenceAnswer: 1, })); setFieldValue( - () => newImages, + newImages, 'images', ); @@ -730,7 +717,7 @@ function NewTutorial(props: Props) { )); setFieldValue(tutorialTaskArray, 'scenarioPages'); }, - [setFieldValue, setError], + [setFieldValue], ); const submissionPending = ( @@ -787,7 +774,13 @@ function NewTutorial(props: Props) { ...subOptions, ].filter(isDefined); - // FIXME: Add warning here for validate image + if (value?.projectType === PROJECT_TYPE_VALIDATE_IMAGE) { + return getImagesWarning( + value?.images ?? [], + selectedValues, + ); + } + return getGeoJSONWarning( value?.tutorialTasks, value?.projectType, @@ -795,7 +788,13 @@ function NewTutorial(props: Props) { value?.zoomLevel, ); }, - [value?.tutorialTasks, value?.projectType, value?.customOptions, value?.zoomLevel], + [ + value?.tutorialTasks, + value?.images, + value?.projectType, + value?.customOptions, + value?.zoomLevel, + ], ); const getTileServerUrl = (val: PartialTutorialFormType['tileServer']) => { @@ -821,6 +820,7 @@ function NewTutorial(props: Props) { (newValue: ProjectType | undefined) => { setFieldValue(undefined, 'tutorialTasks'); setFieldValue(undefined, 'scenarioPages'); + setFieldValue(undefined, 'images'); setFieldValue(newValue, 'projectType'); setFieldValue(getDefaultOptions(newValue), 'customOptions'); }, @@ -1061,15 +1061,16 @@ function NewTutorial(props: Props) { - + {(images && images.length > 0) ? (
@@ -1084,6 +1085,7 @@ function NewTutorial(props: Props) { value={image} index={index} onChange={setImageValue} + customOptions={customOptions} error={imagesError?.[image.sourceIdentifier]} disabled={submissionPending || projectTypeEmpty} /> diff --git a/manager-dashboard/app/views/NewTutorial/utils.ts b/manager-dashboard/app/views/NewTutorial/utils.ts index 74fc7711b..e0533080a 100644 --- a/manager-dashboard/app/views/NewTutorial/utils.ts +++ b/manager-dashboard/app/views/NewTutorial/utils.ts @@ -925,11 +925,16 @@ export const tutorialFormSchema: TutorialFormSchema = { ['images'], (formValues) => { // FIXME: Add "unique" constraint for sourceIdentifier and fileName - // FIXME: Add max length constraint if (formValues?.projectType === PROJECT_TYPE_VALIDATE_IMAGE) { return { images: { keySelector: (key) => key.sourceIdentifier, + validation: (values) => { + if (values && values.length > MAX_IMAGES) { + return `Too many images ${values.length}. Please do not exceed ${MAX_IMAGES} images.`; + } + return undefined; + }, member: (): ImageFormSchemaMember => ({ fields: (): ImageSchemaFields => ({ sourceIdentifier: { From d769ac81cae3d1c685c535e0d362dc152305f1c5 Mon Sep 17 00:00:00 2001 From: tnagorra Date: Fri, 27 Jun 2025 17:18:38 +0545 Subject: [PATCH 71/76] feat(validate_image): add scripts to generate coco files - generate coco file from images in drive - generate coco file from images in dropbox --- .../user_scripts/generate_coco_from_drive.js | 33 ++++ .../generate_coco_from_dropbox.py | 157 ++++++++++++++++++ 2 files changed, 190 insertions(+) create mode 100644 manager-dashboard/user_scripts/generate_coco_from_drive.js create mode 100644 manager-dashboard/user_scripts/generate_coco_from_dropbox.py diff --git a/manager-dashboard/user_scripts/generate_coco_from_drive.js b/manager-dashboard/user_scripts/generate_coco_from_drive.js new file mode 100644 index 000000000..957eee989 --- /dev/null +++ b/manager-dashboard/user_scripts/generate_coco_from_drive.js @@ -0,0 +1,33 @@ +function main() { + const exportFileName = 'your_coco_export.json'; + const folderId = 'your_public_folder_id'; + const folder = DriveApp.getFolderById(folderId); + const files = folder.getFiles(); + + const images = []; + + let id = 1; + while (files.hasNext()) { + const file = files.next(); + const name = file.getName(); + const fileId = file.getId(); + // const url = https://drive.google.com/uc?export=view&id=" + fileId; + const url = `https://drive.google.com/thumbnail?id=${fileId}&sz=w1000`; + images.push({ + coco_url: url, + file_name: name, + id, + }); + id += 1; + } + + const exportContent = JSON.stringify({ images }); + const exportFile = DriveApp.createFile( + exportFileName, + exportContent, + MimeType.PLAIN_TEXT, + ); + const exportFileUrl = exportFile.getUrl(); + + Logger.log(`COCO file available at: ${exportFileUrl}`); +} diff --git a/manager-dashboard/user_scripts/generate_coco_from_dropbox.py b/manager-dashboard/user_scripts/generate_coco_from_dropbox.py new file mode 100644 index 000000000..6f3bedfe8 --- /dev/null +++ b/manager-dashboard/user_scripts/generate_coco_from_dropbox.py @@ -0,0 +1,157 @@ +# /// script +# dependencies = [ +# "requests<3", +# ] +# /// +from pathlib import Path +from argparse import ArgumentParser +import requests +import json +import re + +def dropbox_request(endpoint: str, data: object, *, access_token: str): + url = f"https://api.dropboxapi.com/2/{endpoint}" + headers = { + "Authorization": f"Bearer {access_token}", + "Content-Type": "application/json", + } + res = requests.post( + url, + headers=headers, + data=json.dumps(data), + ) + res.raise_for_status() + return res.json() + +def dropbox_content_request(endpoint: str, path: str, data: object, *, access_token: str): + url = f"https://content.dropboxapi.com/2/{endpoint}" + headers = { + "Authorization": f"Bearer {access_token}", + "Content-Type": "application/octet-stream", + "Dropbox-API-Arg": json.dumps({ + "path": path, + "mode": "overwrite", # overwrite if exists + "autorename": False, + "mute": False + }) + } + res = requests.post( + url, + headers=headers, + data=json.dumps(data).encode("utf-8"), + ) + res.raise_for_status() + return res.json() + +def list_all_files(folder_path: str, *, access_token: str): + ALLOWED_EXTENSIONS = {".jpg", ".jpeg", ".png", ".webp"} + files = [] + + data = {"path": folder_path, "recursive": False} + response = dropbox_request("files/list_folder", data, access_token=access_token) + + files.extend(response.get("entries", [])) + + while response.get("has_more", False): + cursor = response["cursor"] + response = dropbox_request( + "files/list_folder/continue", + {"cursor": cursor}, + access_token=access_token, + ) + files.extend(response.get("entries", [])) + + # Sort files by name (just in case) + files = sorted(files, key=lambda file: file["name"].lower()) + # Filter out only files (not folders) that are supported + files = [ + file for file in files + if file[".tag"] == "file" and Path(file["name"]).suffix.lower() in ALLOWED_EXTENSIONS + ] + return files + +def share_file_and_get_links(files, *, access_token: str): + total = len(files) + images = [] + for i, file in enumerate(files): + path = file["path_lower"] + actual_path = file["path_display"] + + # First try to list existing shared links + data = {"path": path, "direct_only": True} + print(f"{i + 1}/{total} Getting public URL") + res = dropbox_request( + "sharing/list_shared_links", + data, + access_token=access_token, + ) + if res.get("links"): + link = res["links"][0]["url"] + else: + data = { + "path": path, + "settings": { + "requested_visibility": "public" + } + } + res_create = dropbox_request( + "sharing/create_shared_link_with_settings", + data, + access_token=access_token, + ) + link = res_create["url"] + + raw_url = re.sub(r'&dl=0\b', '', link) + '&raw=1' + + images.append({ + "id": i + 1, + "file_name": actual_path, + "coco_url": raw_url, + }) + return images + + +def main(): + parser = ArgumentParser(description="Generate COCO file from images folder.") + parser.add_argument("access_token", help="Access token for authentication") + parser.add_argument("images_folder", help="Path to the images folder") + parser.add_argument("export_file_name", help="Name of the export COCO file") + + args = parser.parse_args() + + access_token = args.access_token + images_folder = args.images_folder + export_file_name = args.export_file_name + + # Get all the files on given path + files = list_all_files( + images_folder, + access_token=access_token, + ) + + # Share individual file publically and get public link + public_images = share_file_and_get_links( + files, + access_token=access_token, + ) + + # Upload coco format export to dropbox + print("Uploading COCO file") + absolute_export_file_name = str(Path(images_folder) / Path(export_file_name)) + dropbox_content_request( + "files/upload", + absolute_export_file_name, + { "images": public_images }, + access_token=access_token, + ) + + # Get temporary link + res = dropbox_request( + "files/get_temporary_link", + { "path": absolute_export_file_name }, + access_token=access_token, + ) + print(f"COCO file available at {res["link"]}") + +if __name__ == "__main__": + main() From c1c5bb49206adfcbc33a1fa404f75462cbf17058 Mon Sep 17 00:00:00 2001 From: Aditya Khatri Date: Tue, 8 Jul 2025 08:04:35 +0545 Subject: [PATCH 72/76] feat(validate_image): add validate image updates in community dashboard --- .../app/views/StatsBoard/index.tsx | 36 ++++++++++++++----- community-dashboard/docker-compose.yml | 3 +- django/apps/existing_database/models.py | 1 + django/schema.graphql | 1 + 4 files changed, 30 insertions(+), 11 deletions(-) diff --git a/community-dashboard/app/views/StatsBoard/index.tsx b/community-dashboard/app/views/StatsBoard/index.tsx index f3b842997..466567094 100644 --- a/community-dashboard/app/views/StatsBoard/index.tsx +++ b/community-dashboard/app/views/StatsBoard/index.tsx @@ -49,6 +49,7 @@ import { ProjectTypeSwipeStatsType, ProjectTypeAreaStatsType, ContributorSwipeStatType, + ProjectTypeEnum, } from '#generated/types'; import { mergeItems } from '#utils/common'; import { @@ -67,17 +68,28 @@ const CHART_BREAKPOINT = 700; export type ActualContributorTimeStatType = ContributorTimeStatType & { totalSwipeTime: number }; const UNKNOWN = '-1'; const BUILD_AREA = 'BUILD_AREA'; +const MEDIA = 'MEDIA'; +const DIGITIZATION = 'DIGITIZATION'; const FOOTPRINT = 'FOOTPRINT'; const CHANGE_DETECTION = 'CHANGE_DETECTION'; +const VALIDATE_IMAGE = 'VALIDATE_IMAGE'; const COMPLETENESS = 'COMPLETENESS'; const STREET = 'STREET'; // FIXME: the name property is not used properly -const projectTypes: Record = { +const projectTypes: Record = { [UNKNOWN]: { color: '#cacaca', name: 'Unknown', }, + [MEDIA]: { + color: '#cacaca', + name: 'Media', + }, + [DIGITIZATION]: { + color: '#cacaca', + name: 'Digitization', + }, [BUILD_AREA]: { color: '#f8a769', name: 'Find', @@ -94,6 +106,10 @@ const projectTypes: Record = { color: '#fb8072', name: 'Completeness', }, + [VALIDATE_IMAGE]: { + color: '#a1b963', + name: 'Validate Image', + }, [STREET]: { color: '#808080', name: 'Street', @@ -376,14 +392,16 @@ function StatsBoard(props: Props) { const sortedProjectSwipeType = useMemo( () => ( swipeByProjectType - ?.map((item) => ({ - ...item, - projectType: ( - isDefined(item.projectType) - && isDefined(projectTypes[item.projectType]) - ) ? item.projectType - : UNKNOWN, - })) + ?.map((item) => { + const projectType: ProjectTypeEnum | '-1' = ( + isDefined(item.projectType) && isDefined(projectTypes[item.projectType]) + ) ? item.projectType : UNKNOWN; + + return ({ + ...item, + projectType, + }); + }) .sort((a, b) => compareNumber(a.totalSwipes, b.totalSwipes, -1)) ?? [] ), [swipeByProjectType], diff --git a/community-dashboard/docker-compose.yml b/community-dashboard/docker-compose.yml index 39ac61dcc..2b548f3bb 100644 --- a/community-dashboard/docker-compose.yml +++ b/community-dashboard/docker-compose.yml @@ -2,7 +2,6 @@ version: '3.3' services: react: - build: . command: sh -c 'yarn install --frozen-lockfile && yarn start' build: context: ./ @@ -15,4 +14,4 @@ services: volumes: - .:/code ports: - - '3080:3080' + - '3081:3081' diff --git a/django/apps/existing_database/models.py b/django/apps/existing_database/models.py index 5bc85e113..319c28b7c 100644 --- a/django/apps/existing_database/models.py +++ b/django/apps/existing_database/models.py @@ -69,6 +69,7 @@ class Type(models.IntegerChoices): MEDIA = 5, "Media" DIGITIZATION = 6, "Digitization" STREET = 7, "Street" + VALIDATE_IMAGE = 10, "Validate Image" project_id = models.CharField(primary_key=True, max_length=999) created = models.DateTimeField(blank=True, null=True) diff --git a/django/schema.graphql b/django/schema.graphql index b5596fc46..07b9659c4 100644 --- a/django/schema.graphql +++ b/django/schema.graphql @@ -100,6 +100,7 @@ enum ProjectTypeEnum { MEDIA DIGITIZATION STREET + VALIDATE_IMAGE } type ProjectTypeSwipeStatsType { From 62b3f1a60a1eb58cc1529c56d3ae3860f2fab974 Mon Sep 17 00:00:00 2001 From: tnagorra Date: Fri, 11 Jul 2025 18:11:38 +0545 Subject: [PATCH 73/76] feat(aggregates): update aggregates for validate image project - using time_spent_max_allowed value of 6.1 - exluding area calculation --- .../management/commands/update_aggregated_data.py | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/django/apps/aggregated/management/commands/update_aggregated_data.py b/django/apps/aggregated/management/commands/update_aggregated_data.py index dca5896a4..49f536227 100644 --- a/django/apps/aggregated/management/commands/update_aggregated_data.py +++ b/django/apps/aggregated/management/commands/update_aggregated_data.py @@ -55,6 +55,7 @@ WHEN P.project_type = {Project.Type.CHANGE_DETECTION.value} THEN 11.2 -- FOOTPRINT: Not calculated right now WHEN P.project_type = {Project.Type.FOOTPRINT.value} THEN 6.1 + WHEN P.project_type = {Project.Type.VALIDATE_IMAGE.value} THEN 6.1 WHEN P.project_type = {Project.Type.STREET.value} THEN 65 ELSE 1 END @@ -111,6 +112,7 @@ WHEN P.project_type = {Project.Type.CHANGE_DETECTION.value} THEN 11.2 -- FOOTPRINT: Not calculated right now WHEN P.project_type = {Project.Type.FOOTPRINT.value} THEN 6.1 + WHEN P.project_type = {Project.Type.VALIDATE_IMAGE.value} THEN 6.1 WHEN P.project_type = {Project.Type.STREET.value} THEN 65 ELSE 1 END @@ -136,8 +138,10 @@ G.group_id, ( CASE - -- Hide area for Footprint + -- Hide area for Footprint and Validate Image + -- FIXME: What should we do for Project.Type.STREET.value WHEN P.project_type = {Project.Type.FOOTPRINT.value} THEN 0 + WHEN P.project_type = {Project.Type.VALIDATE_IMAGE.value} THEN 0 ELSE G.total_area END ) as total_task_group_area, From 7385edfbe3ea652d1e165dd4dc0231fa95e5ab07 Mon Sep 17 00:00:00 2001 From: tnagorra Date: Sun, 13 Jul 2025 13:50:23 +0545 Subject: [PATCH 74/76] chore(ci): Build docker images before-hand --- .github/workflows/actions.yml | 28 ++++++++++++++++------------ django/Dockerfile | 2 +- 2 files changed, 17 insertions(+), 13 deletions(-) diff --git a/.github/workflows/actions.yml b/.github/workflows/actions.yml index 4609eb697..e3e24b043 100644 --- a/.github/workflows/actions.yml +++ b/.github/workflows/actions.yml @@ -42,15 +42,28 @@ jobs: exit 1; } + - name: Decrypt Service Account Key File + working-directory: ./ + run: | + openssl enc -aes-256-cbc -d -K "$OPENSSL_KEY" -iv "$OPENSSL_IV" -in ci-mapswipe-firebase-adminsdk-80fzw-ebce84bd5b.json.enc -out mapswipe_workers/serviceAccountKey.json + env: + OPENSSL_PASSPHRASE: ${{ secrets.OPENSSL_PASSPHRASE }} + OPENSSL_KEY: ${{ secrets.OPENSSL_KEY }} + OPENSSL_IV: ${{ secrets.OPENSSL_IV }} + + - name: Build docker images + run: | + # Create a mock file for wal-g setup + touch postgres/serviceAccountKey.json + docker compose build postgres firebase_deploy mapswipe_workers_creation django + - name: Setup Postgres Database Container env: POSTGRES_PASSWORD: postgres POSTGRES_USER: postgres POSTGRES_DB: postgres run: | - # Create a mock file for wal-g setup - touch postgres/serviceAccountKey.json - docker compose up --build --detach postgres + docker compose up --detach postgres for i in {1..5}; do docker compose exec -T postgres pg_isready && s=0 && break || s=$? && sleep 5; done; (docker compose logs postgres && exit $s) - name: Deploy Firebase Rules and Functions @@ -60,15 +73,6 @@ jobs: run: | docker compose run --rm firebase_deploy sh -c "firebase use $FIREBASE_DB && firebase deploy --token $FIREBASE_TOKEN --only database" - - name: Decrypt Service Account Key File - working-directory: ./ - run: | - openssl enc -aes-256-cbc -d -K "$OPENSSL_KEY" -iv "$OPENSSL_IV" -in ci-mapswipe-firebase-adminsdk-80fzw-ebce84bd5b.json.enc -out mapswipe_workers/serviceAccountKey.json - env: - OPENSSL_PASSPHRASE: ${{ secrets.OPENSSL_PASSPHRASE }} - OPENSSL_KEY: ${{ secrets.OPENSSL_KEY }} - OPENSSL_IV: ${{ secrets.OPENSSL_IV }} - - name: Run Tests working-directory: ./mapswipe_workers env: diff --git a/django/Dockerfile b/django/Dockerfile index 4f220f7e6..a6330b600 100644 --- a/django/Dockerfile +++ b/django/Dockerfile @@ -1,4 +1,4 @@ -FROM python:3.10-buster +FROM python:3.10-bullseye LABEL maintainer="Mapswipe info@mapswipe.org" From 1371c5d5b4fd6a1f948d859f251c093c8ebc3121 Mon Sep 17 00:00:00 2001 From: Aditya Khatri Date: Mon, 14 Jul 2025 10:24:19 +0545 Subject: [PATCH 75/76] feat(validate_image): add validate image swipes count in stats group --- .../app/resources/icons/validate-image.svg | 13 +++++++++ .../app/views/StatsBoard/index.tsx | 28 +++++++++++++++++++ .../app/views/StatsBoard/styles.css | 2 +- 3 files changed, 42 insertions(+), 1 deletion(-) create mode 100644 community-dashboard/app/resources/icons/validate-image.svg diff --git a/community-dashboard/app/resources/icons/validate-image.svg b/community-dashboard/app/resources/icons/validate-image.svg new file mode 100644 index 000000000..7066c5c2b --- /dev/null +++ b/community-dashboard/app/resources/icons/validate-image.svg @@ -0,0 +1,13 @@ + + + + + + + + + + + + + diff --git a/community-dashboard/app/views/StatsBoard/index.tsx b/community-dashboard/app/views/StatsBoard/index.tsx index 466567094..d04b93212 100644 --- a/community-dashboard/app/views/StatsBoard/index.tsx +++ b/community-dashboard/app/views/StatsBoard/index.tsx @@ -43,6 +43,7 @@ import InformationCard from '#components/InformationCard'; import areaSvg from '#resources/icons/area.svg'; import sceneSvg from '#resources/icons/scene.svg'; import featureSvg from '#resources/icons/feature.svg'; +import validateImageSvg from '#resources/icons/validate-image.svg'; import { ContributorTimeStatType, OrganizationSwipeStatsType, @@ -467,6 +468,10 @@ function StatsBoard(props: Props) { (project) => project.projectType === FOOTPRINT, )?.totalSwipes; + const validateImageTotalSwipes = swipeByProjectType?.find( + (project) => project.projectType === VALIDATE_IMAGE, + )?.totalSwipes; + const organizationColors = scaleOrdinal() .domain(totalSwipesByOrganizationStats?.map( (organization) => (organization.organizationName), @@ -717,6 +722,29 @@ function StatsBoard(props: Props) { subHeading="Compare" variant="stat" /> + + )} + value={( + + )} + label={( +
+ Images Validated +
+ )} + subHeading="Validate Image" + variant="stat" + />
* { flex-basis: 0; flex-grow: 1; - min-width: 12rem; + min-width: 24rem; @media (max-width: 48rem) { min-width: 100%; From e32dcb32410209dbf3a293218c5686d6b22ad88a Mon Sep 17 00:00:00 2001 From: Keyur Khadka Date: Wed, 30 Jul 2025 20:20:45 +0545 Subject: [PATCH 76/76] docs(coco): update readme.md (#1046) * docs(coco): update readme.md * refactor: improve error readability --- manager-dashboard/user_scripts/README.md | 73 +++++++++++ .../generate_coco_from_dropbox.py | 124 +++++++++++++----- 2 files changed, 163 insertions(+), 34 deletions(-) create mode 100644 manager-dashboard/user_scripts/README.md diff --git a/manager-dashboard/user_scripts/README.md b/manager-dashboard/user_scripts/README.md new file mode 100644 index 000000000..3964cc12c --- /dev/null +++ b/manager-dashboard/user_scripts/README.md @@ -0,0 +1,73 @@ +## Description +This will serve as a guide on how to create a COCO file using the utility script for Google Drive and DropBox + +## Google Drive +You can find the utility script for Google Drive here: [generate_coco_from_drive.js](./generate_coco_from_drive.js) + +### Prerequisites +- You must have a Google account +- Your image files should be stored in a public Google Drive folder +- You have access to Google Apps Script via https://script.google.com + +### Creation Steps +- Create a Google Apps script project + - Go to https://script.google.com + - Click on "New Project" + - Rename the project name to `your-project-name` +- Paste the utility script + - Replace the default code with the utility file's code +- Replace placeholder values + - Replace `your_coco_export.json` with your output filename + - Replace `your_public_folder_id` with the ID of your Google Drive folder +> The folder ID is the alphanumeric string that appears after "/folders/" in the URL.\ +> Eg: drive.google.com/drive/folders/**1prcCevijN5mubTllB2kr5ki1gjh_IO4u**?usp=sharing +- Run the script + - Save the project to Drive using the floppy disk 💾 icon + - Press Run + - Accept the authorization prompts the first time you run the script +- View COCO JSON Output + - Go to **View > Logs** + - Copy the Google Drive URL where the coco file is generated + - Download the json file + +## DropBox +You can find the utility script for DropBox here: [generate_coco_from_dropbox.py](./generate_coco_from_dropbox.py) + +### Prerequisites +- Create account: https://www.dropbox.com/register +- Create new App: https://www.dropbox.com/developers/apps + - Choose an API: Scoped access + - Choose the type of access you need: Full Dropbox + - Name your app: `your-app-name` +- Update `Permission type` + - Go to the app settings + - Click **Scoped App** + - Tick the following permissions + - files.metadata.read + - files.content.write + - files.content.read + - sharing.write + - sharing.read + - Submit +- Generate new access token: + - Go to the app settings + - Click **Generated access token** +- Install uv on your system: https://docs.astral.sh/uv/getting-started/installation/ +- Download the [generate_coco_from_dropbox.py](./generate_coco_from_dropbox.py) script +- Create a DropBox folder and upload images + +### Creation Steps +- Copy the folder pathname in DropBox +- Copy the generated access token from DropBox +- Run the script +```bash + # Help + uv run generate_coco_dropbox.py --help + + # Sample + uv run generate_coco_dropbox.py "DROPBOX_ACCESS_TOKEN" "FOLDER_PATHNAME_IN_DROPBOX" "DESTINATION_EXPORT_FILE_NAME_IN_DROPBOX" + + # Example + uv run generate_coco_dropbox.py sl.yourAccessTokenHere "/COCO TEST" "coco_export.json" +``` +- Download the exported coco json from the link in terminal or your DropBox folder diff --git a/manager-dashboard/user_scripts/generate_coco_from_dropbox.py b/manager-dashboard/user_scripts/generate_coco_from_dropbox.py index 6f3bedfe8..47249d2de 100644 --- a/manager-dashboard/user_scripts/generate_coco_from_dropbox.py +++ b/manager-dashboard/user_scripts/generate_coco_from_dropbox.py @@ -1,48 +1,94 @@ # /// script +# requires-python = ">=3.13" # dependencies = [ -# "requests<3", +# "httpx~=0.28.1", +# "colorama", # ] # /// from pathlib import Path -from argparse import ArgumentParser -import requests +from colorama import init, Fore + +import argparse +import textwrap +import httpx import json import re +# Initialize colorama +init(autoreset=True) + + +DROPBOX_PERMISSION_MESSAGE = f""" +{Fore.YELLOW} +---------------------------------------------------- +Make sure the dropbox App includes these permissions +- files.metadata.read +- files.content.write +- files.content.read +- sharing.write +- sharing.read +""" + + +def dropbox_request_error_handler(res: httpx.Response): + try: + res.raise_for_status() + except httpx.HTTPStatusError as http_err: + print(f"{Fore.RED}HTTP error occurred while requesting {res.url}: {http_err}") + print(f"{Fore.RED}Response content: {res.text}") + raise + except httpx.RequestError as req_err: + print( + f"{Fore.RED}An error occurred while making the request to {res.url}: {req_err}" + ) + raise + except Exception as err: + print(f"{Fore.RED}An unexpected error occurred: {err}") + raise + finally: + print(DROPBOX_PERMISSION_MESSAGE) + + def dropbox_request(endpoint: str, data: object, *, access_token: str): url = f"https://api.dropboxapi.com/2/{endpoint}" headers = { "Authorization": f"Bearer {access_token}", "Content-Type": "application/json", } - res = requests.post( + res = httpx.post( url, headers=headers, data=json.dumps(data), ) - res.raise_for_status() + dropbox_request_error_handler(res) return res.json() -def dropbox_content_request(endpoint: str, path: str, data: object, *, access_token: str): + +def dropbox_content_request( + endpoint: str, path: str, data: object, *, access_token: str +): url = f"https://content.dropboxapi.com/2/{endpoint}" headers = { "Authorization": f"Bearer {access_token}", "Content-Type": "application/octet-stream", - "Dropbox-API-Arg": json.dumps({ - "path": path, - "mode": "overwrite", # overwrite if exists - "autorename": False, - "mute": False - }) + "Dropbox-API-Arg": json.dumps( + { + "path": path, + "mode": "overwrite", # overwrite if exists + "autorename": False, + "mute": False, + } + ), } - res = requests.post( + res = httpx.post( url, headers=headers, data=json.dumps(data).encode("utf-8"), ) - res.raise_for_status() + dropbox_request_error_handler(res) return res.json() + def list_all_files(folder_path: str, *, access_token: str): ALLOWED_EXTENSIONS = {".jpg", ".jpeg", ".png", ".webp"} files = [] @@ -65,11 +111,14 @@ def list_all_files(folder_path: str, *, access_token: str): files = sorted(files, key=lambda file: file["name"].lower()) # Filter out only files (not folders) that are supported files = [ - file for file in files - if file[".tag"] == "file" and Path(file["name"]).suffix.lower() in ALLOWED_EXTENSIONS + file + for file in files + if file[".tag"] == "file" + and Path(file["name"]).suffix.lower() in ALLOWED_EXTENSIONS ] return files + def share_file_and_get_links(files, *, access_token: str): total = len(files) images = [] @@ -88,12 +137,7 @@ def share_file_and_get_links(files, *, access_token: str): if res.get("links"): link = res["links"][0]["url"] else: - data = { - "path": path, - "settings": { - "requested_visibility": "public" - } - } + data = {"path": path, "settings": {"requested_visibility": "public"}} res_create = dropbox_request( "sharing/create_shared_link_with_settings", data, @@ -101,21 +145,32 @@ def share_file_and_get_links(files, *, access_token: str): ) link = res_create["url"] - raw_url = re.sub(r'&dl=0\b', '', link) + '&raw=1' + raw_url = re.sub(r"&dl=0\b", "", link) + "&raw=1" - images.append({ - "id": i + 1, - "file_name": actual_path, - "coco_url": raw_url, - }) + images.append( + { + "id": i + 1, + "file_name": actual_path, + "coco_url": raw_url, + } + ) return images def main(): - parser = ArgumentParser(description="Generate COCO file from images folder.") + parser = argparse.ArgumentParser( + description="Generate COCO file from images folder.", + formatter_class=argparse.RawDescriptionHelpFormatter, + epilog=textwrap.dedent(DROPBOX_PERMISSION_MESSAGE), + ) parser.add_argument("access_token", help="Access token for authentication") - parser.add_argument("images_folder", help="Path to the images folder") - parser.add_argument("export_file_name", help="Name of the export COCO file") + parser.add_argument( + "images_folder", help='Path to the images folder in dropbox. eg: "/COCO TEST"' + ) + parser.add_argument( + "export_file_name", + help="Name of the export COCO file to be created in dropbox under provided images_folder", + ) args = parser.parse_args() @@ -141,17 +196,18 @@ def main(): dropbox_content_request( "files/upload", absolute_export_file_name, - { "images": public_images }, + {"images": public_images}, access_token=access_token, ) # Get temporary link res = dropbox_request( "files/get_temporary_link", - { "path": absolute_export_file_name }, + {"path": absolute_export_file_name}, access_token=access_token, ) - print(f"COCO file available at {res["link"]}") + print(f"COCO file available at {res['link']}") + if __name__ == "__main__": main()

mlSzk{zRgB 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 Date: Thu, 8 May 2025 14:20:50 +0200 Subject: [PATCH 39/76] feat: osm auth for web client --- docker-compose.yaml | 10 ++- example.env | 5 ++ firebase/functions/src/index.ts | 10 ++- firebase/functions/src/osm_auth.ts | 128 ++++++++++++++++++++++++++++- 4 files changed, 150 insertions(+), 3 deletions(-) diff --git a/docker-compose.yaml b/docker-compose.yaml index d465a704c..b1afabb16 100644 --- a/docker-compose.yaml +++ b/docker-compose.yaml @@ -230,15 +230,23 @@ services: OSM_OAUTH_API_URL: '${OSM_OAUTH_API_URL}' OSM_OAUTH_CLIENT_ID: '${OSM_OAUTH_CLIENT_ID}' OSM_OAUTH_CLIENT_SECRET: '${OSM_OAUTH_CLIENT_SECRET}' + OSM_OAUTH_REDIRECT_URI_WEB: '${OSM_OAUTH_REDIRECT_URI_WEB}' + OSM_OAUTH_APP_LOGIN_LINK_WEB: '${OSM_APP_LOGIN_LINK_WEB}' + OSM_OAUTH_CLIENT_ID_WEB: '${OSM_OAUTH_CLIENT_ID_WEB}' + OSM_OAUTH_CLIENT_SECRET_WEB: '${OSM_OAUTH_CLIENT_SECRET_WEB}' command: >- sh -c "firebase use $FIREBASE_DB && firebase target:apply hosting auth \"$FIREBASE_AUTH_SITE\" && firebase functions:config:set osm.redirect_uri=\"$OSM_OAUTH_REDIRECT_URI\" + osm.redirect_uri_web=\"$OSM_OAUTH_REDIRECT_URI_WEB\" osm.app_login_link=\"$OSM_OAUTH_APP_LOGIN_LINK\" + osm.app_login_link_web=\"$OSM_OAUTH_APP_LOGIN_LINK_WEB\" osm.api_url=\"$OSM_OAUTH_API_URL\" osm.client_id=\"$OSM_OAUTH_CLIENT_ID\" - osm.client_secret=\"$OSM_OAUTH_CLIENT_SECRET\" && + osm.client_id_web=\"$OSM_OAUTH_CLIENT_ID_WEB\" + osm.client_secret=\"$OSM_OAUTH_CLIENT_SECRET\" + osm.client_secret_web=\"$OSM_OAUTH_CLIENT_SECRET_WEB\" && firebase deploy --token $FIREBASE_TOKEN --only functions,hosting,database" django: diff --git a/example.env b/example.env index 2fa92ae33..3b5024245 100644 --- a/example.env +++ b/example.env @@ -38,10 +38,15 @@ OSMCHA_API_KEY= # OSM OAuth Configuration OSM_OAUTH_REDIRECT_URI= +OSM_OAUTH_REDIRECT_URI_WEB= OSM_OAUTH_API_URL= OSM_OAUTH_CLIENT_ID= +OSM_OAUTH_CLIENT_ID_WEB= OSM_OAUTH_CLIENT_SECRET= +OSM_OAUTH_CLIENT_SECRET_WEB= OSM_APP_LOGIN_LINK= +OSM_APP_LOGIN_LINK_WEB= + # DJANGO For more info look at django/mapswipe/settings.py::L22 DJANGO_SECRET_KEY= diff --git a/firebase/functions/src/index.ts b/firebase/functions/src/index.ts index 793ed9c71..6bb56e98c 100644 --- a/firebase/functions/src/index.ts +++ b/firebase/functions/src/index.ts @@ -8,7 +8,7 @@ admin.initializeApp(); // all functions are bundled together. It's less than ideal, but it does not // seem possible to split them using the split system for multiple sites from // https://firebase.google.com/docs/hosting/multisites -import {redirect, token} from './osm_auth'; +import {redirect, token, redirect_web, token_web} from './osm_auth'; import { formatProjectTopic, formatUserName } from './utils'; exports.osmAuth = {}; @@ -23,6 +23,14 @@ exports.osmAuth.token = functions.https.onRequest((req, res) => { token(req, res, admin); }); +exports.osmAuth.redirect_web = functions.https.onRequest((req, res) => { + redirect_web(req, res); +}); + +exports.osmAuth.token_web = functions.https.onRequest((req, res) => { + token_web(req, res, admin); +}); + /* Log the userIds of all users who finished a group to /v2/userGroups/{projectId}/{groupId}/. Gets triggered when new results of a group are written to the database. diff --git a/firebase/functions/src/osm_auth.ts b/firebase/functions/src/osm_auth.ts index d187b4e4f..5b3a84605 100644 --- a/firebase/functions/src/osm_auth.ts +++ b/firebase/functions/src/osm_auth.ts @@ -1,4 +1,4 @@ -// Firebase cloud functions to allow authentication with OpenStreet Map +// Firebase cloud functions to allow authentication with OpenStreetMap // // There are really 2 functions, which must be publicly accessible via // an https endpoint. They can be hosted on firebase under a domain like @@ -20,8 +20,10 @@ import axios from 'axios'; // will get a cryptic error about the server not being able to continue // TODO: adjust the prefix based on which deployment is done (prod/dev) const OAUTH_REDIRECT_URI = functions.config().osm?.redirect_uri; +const OAUTH_REDIRECT_URI_WEB = functions.config().osm?.redirect_web; const APP_OSM_LOGIN_DEEPLINK = functions.config().osm?.app_login_link; +const APP_OSM_LOGIN_DEEPLINK_WEB = functions.config().osm?.app_login_link_web; // the scope is taken from https://wiki.openstreetmap.org/wiki/OAuth#OAuth_2.0 // at least one seems to be required for the auth workflow to complete. @@ -51,6 +53,21 @@ function osmOAuth2Client() { return simpleOAuth2.create(credentials); } +function osmOAuth2ClientWeb() { + const credentials = { + client: { + id: functions.config().osm?.client_id_web, + secret: functions.config().osm?.client_secret_web, + }, + auth: { + tokenHost: OSM_API_URL, + tokenPath: '/oauth2/token', + authorizePath: '/oauth2/authorize', + }, + }; + return simpleOAuth2.create(credentials); +} + /** * Redirects the User to the OSM authentication consent screen. * Also the '__session' cookie is set for later state verification. @@ -84,6 +101,32 @@ export const redirect = (req: any, res: any) => { }); }; +export const redirect_web = (req: any, res: any) => { + const oauth2 = osmOAuth2ClientWeb(); + + cookieParser()(req, res, () => { + const state = + req.cookies.state || crypto.randomBytes(20).toString('hex'); + functions.logger.log('Setting verification state:', state); + // the cookie MUST be called __session for hosted functions not to + // strip it from incoming requests + // (https://firebase.google.com/docs/hosting/manage-cache#using_cookies) + res.cookie('__session', state.toString(), { + // cookie is valid for 1 hour + maxAge: 3600000, + secure: true, + httpOnly: true, + }); + const redirectUri = oauth2.authorizationCode.authorizeURL({ + redirect_uri: OAUTH_REDIRECT_URI_WEB, + scope: OAUTH_SCOPES, + state: state, + }); + functions.logger.log('Redirecting to:', redirectUri); + res.redirect(redirectUri); + }); +}; + /** * The OSM OAuth endpoing does not give us any info about the user, * so we need to get the user profile from this endpoint @@ -189,6 +232,89 @@ export const token = async (req: any, res: any, admin: any) => { } }; + +export const token_web = async (req: any, res: any, admin: any) => { + const oauth2 = osmOAuth2ClientWeb(); + + try { + return cookieParser()(req, res, async () => { + functions.logger.log( + 'Received verification state:', + req.cookies.__session, + ); + functions.logger.log('Received state:', req.query.state); + // FIXME: For security, we need to check the cookie that was set + // in the /redirect_web function on the user's browser. + // However, there seems to be a bug in firebase around this. + // https://github.com/firebase/firebase-functions/issues/544 + // and linked SO question + // firebase docs mention the need for a cookie middleware, but there + // is no info about it :( + // cross site cookies don't seem to be the issue + // WE just need to make sure the domain set on the cookies is right + if (!req.cookies.__session) { + throw new Error('State cookie not set or expired. Maybe you took too long to authorize. Please try again.'); + } else if (req.cookies.__session !== req.query.state) { + throw new Error('State validation failed'); + } + functions.logger.log('Received auth code:', req.query.code); + let results; + + try { + // TODO: try adding auth data to request headers if + // this doesn't work + results = await oauth2.authorizationCode.getToken({ + code: req.query.code, + redirect_uri: OAUTH_REDIRECT_URI, + scope: OAUTH_SCOPES, + state: req.query.state, + }); + } catch (error: any) { + functions.logger.log('Auth token error', error, error.data.res.req); + } + // why is token called twice? + functions.logger.log( + 'Auth code exchange result received:', + results, + ); + + // We have an OSM access token and the user identity now. + const accessToken = results && results.access_token; + if (accessToken === undefined) { + throw new Error( + 'Could not get an access token from OpenStreetMap', + ); + } + // get the OSM user id and display_name + const { id, display_name } = await getOSMProfile(accessToken); + functions.logger.log('osmuser:', id, display_name); + if (id === undefined) { + // this should not happen, but help guard against creating + // invalid accounts + throw new Error('Could not obtain an account id from OSM'); + } + + // Create a Firebase account and get the Custom Auth Token. + const firebaseToken = await createFirebaseAccount( + admin, + id, + display_name, + accessToken, + ); + // build a deep link so we can send the token back to the app + // from the browser + const signinUrl = `${APP_OSM_LOGIN_DEEPLINK_WEB}?token=${firebaseToken}`; + functions.logger.log('redirecting user to', signinUrl); + res.redirect(signinUrl); + }); + } catch (error: any) { + // FIXME: this should show up in the user's browser as a bit of text + // We should figure out the various error codes available and feed them + // back into the app to allow the user to take action + return res.json({ error: error.toString() }); + } +}; + /** * Creates a Firebase account with the given user profile and returns a custom * auth token allowing the user to sign in to this account on the app. From 2885ddc3696cfefd7ed61d6ed67e2790f98550cf Mon Sep 17 00:00:00 2001 From: Oliver Fritz Date: Thu, 8 May 2025 14:32:45 +0200 Subject: [PATCH 40/76] fix: use web redirect uri in token_web --- firebase/functions/src/osm_auth.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/firebase/functions/src/osm_auth.ts b/firebase/functions/src/osm_auth.ts index 5b3a84605..80c157774 100644 --- a/firebase/functions/src/osm_auth.ts +++ b/firebase/functions/src/osm_auth.ts @@ -20,7 +20,7 @@ import axios from 'axios'; // will get a cryptic error about the server not being able to continue // TODO: adjust the prefix based on which deployment is done (prod/dev) const OAUTH_REDIRECT_URI = functions.config().osm?.redirect_uri; -const OAUTH_REDIRECT_URI_WEB = functions.config().osm?.redirect_web; +const OAUTH_REDIRECT_URI_WEB = functions.config().osm?.redirect_uri_web; const APP_OSM_LOGIN_DEEPLINK = functions.config().osm?.app_login_link; const APP_OSM_LOGIN_DEEPLINK_WEB = functions.config().osm?.app_login_link_web; @@ -265,7 +265,7 @@ export const token_web = async (req: any, res: any, admin: any) => { // this doesn't work results = await oauth2.authorizationCode.getToken({ code: req.query.code, - redirect_uri: OAUTH_REDIRECT_URI, + redirect_uri: OAUTH_REDIRECT_URI_WEB, scope: OAUTH_SCOPES, state: req.query.state, }); From f5f0c61f5490423130677df3e7280db332eb6ff9 Mon Sep 17 00:00:00 2001 From: Oliver Fritz Date: Thu, 8 May 2025 15:14:05 +0200 Subject: [PATCH 41/76] fix: adapt firebase.json for web app osm login --- firebase/firebase.json | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/firebase/firebase.json b/firebase/firebase.json index 4c56a3044..469e96e11 100644 --- a/firebase/firebase.json +++ b/firebase/firebase.json @@ -20,6 +20,14 @@ { "source": "/token", "function": "osmAuth-token" + }, + { + "source": "/redirect_web", + "function": "osmAuth-redirect_web" + }, + { + "source": "/token_web", + "function": "osmAuth-token_web" } ] }, From c25951918b20335223c2eabe39a4be4d3c4156af Mon Sep 17 00:00:00 2001 From: Oliver Fritz Date: Wed, 14 May 2025 17:51:31 +0200 Subject: [PATCH 42/76] fix: osm oauth app login link env variable --- docker-compose.yaml | 2 +- example.env | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/docker-compose.yaml b/docker-compose.yaml index b1afabb16..f015a71a4 100644 --- a/docker-compose.yaml +++ b/docker-compose.yaml @@ -231,7 +231,7 @@ services: OSM_OAUTH_CLIENT_ID: '${OSM_OAUTH_CLIENT_ID}' OSM_OAUTH_CLIENT_SECRET: '${OSM_OAUTH_CLIENT_SECRET}' OSM_OAUTH_REDIRECT_URI_WEB: '${OSM_OAUTH_REDIRECT_URI_WEB}' - OSM_OAUTH_APP_LOGIN_LINK_WEB: '${OSM_APP_LOGIN_LINK_WEB}' + OSM_OAUTH_APP_LOGIN_LINK_WEB: '${OSM_OAUTH_APP_LOGIN_LINK_WEB}' OSM_OAUTH_CLIENT_ID_WEB: '${OSM_OAUTH_CLIENT_ID_WEB}' OSM_OAUTH_CLIENT_SECRET_WEB: '${OSM_OAUTH_CLIENT_SECRET_WEB}' command: >- diff --git a/example.env b/example.env index 3b5024245..a4ee41436 100644 --- a/example.env +++ b/example.env @@ -44,8 +44,8 @@ OSM_OAUTH_CLIENT_ID= OSM_OAUTH_CLIENT_ID_WEB= OSM_OAUTH_CLIENT_SECRET= OSM_OAUTH_CLIENT_SECRET_WEB= -OSM_APP_LOGIN_LINK= -OSM_APP_LOGIN_LINK_WEB= +OSM_OAUTH_APP_LOGIN_LINK= +OSM_OAUTH_APP_LOGIN_LINK_WEB= # DJANGO For more info look at django/mapswipe/settings.py::L22 From b006d8b51d86d19248060532d6fdea0ad1b9b1e0 Mon Sep 17 00:00:00 2001 From: Oliver Fritz Date: Thu, 15 May 2025 09:20:46 +0200 Subject: [PATCH 43/76] fix: avoid underscore in fb function names --- firebase/firebase.json | 8 ++++---- firebase/functions/src/index.ts | 10 +++++----- firebase/functions/src/osm_auth.ts | 6 +++--- 3 files changed, 12 insertions(+), 12 deletions(-) diff --git a/firebase/firebase.json b/firebase/firebase.json index 469e96e11..b81c02219 100644 --- a/firebase/firebase.json +++ b/firebase/firebase.json @@ -22,12 +22,12 @@ "function": "osmAuth-token" }, { - "source": "/redirect_web", - "function": "osmAuth-redirect_web" + "source": "/redirectweb", + "function": "osmAuth-redirectweb" }, { - "source": "/token_web", - "function": "osmAuth-token_web" + "source": "/tokenweb", + "function": "osmAuth-tokenweb" } ] }, diff --git a/firebase/functions/src/index.ts b/firebase/functions/src/index.ts index 6bb56e98c..c448cadc2 100644 --- a/firebase/functions/src/index.ts +++ b/firebase/functions/src/index.ts @@ -8,7 +8,7 @@ admin.initializeApp(); // all functions are bundled together. It's less than ideal, but it does not // seem possible to split them using the split system for multiple sites from // https://firebase.google.com/docs/hosting/multisites -import {redirect, token, redirect_web, token_web} from './osm_auth'; +import {redirect, token, redirectweb, tokenweb} from './osm_auth'; import { formatProjectTopic, formatUserName } from './utils'; exports.osmAuth = {}; @@ -23,12 +23,12 @@ exports.osmAuth.token = functions.https.onRequest((req, res) => { token(req, res, admin); }); -exports.osmAuth.redirect_web = functions.https.onRequest((req, res) => { - redirect_web(req, res); +exports.osmAuth.redirectweb = functions.https.onRequest((req, res) => { + redirectweb(req, res); }); -exports.osmAuth.token_web = functions.https.onRequest((req, res) => { - token_web(req, res, admin); +exports.osmAuth.tokenweb = functions.https.onRequest((req, res) => { + tokenweb(req, res, admin); }); /* diff --git a/firebase/functions/src/osm_auth.ts b/firebase/functions/src/osm_auth.ts index 80c157774..9cc853a2d 100644 --- a/firebase/functions/src/osm_auth.ts +++ b/firebase/functions/src/osm_auth.ts @@ -101,7 +101,7 @@ export const redirect = (req: any, res: any) => { }); }; -export const redirect_web = (req: any, res: any) => { +export const redirectweb = (req: any, res: any) => { const oauth2 = osmOAuth2ClientWeb(); cookieParser()(req, res, () => { @@ -233,7 +233,7 @@ export const token = async (req: any, res: any, admin: any) => { }; -export const token_web = async (req: any, res: any, admin: any) => { +export const tokenweb = async (req: any, res: any, admin: any) => { const oauth2 = osmOAuth2ClientWeb(); try { @@ -244,7 +244,7 @@ export const token_web = async (req: any, res: any, admin: any) => { ); functions.logger.log('Received state:', req.query.state); // FIXME: For security, we need to check the cookie that was set - // in the /redirect_web function on the user's browser. + // in the /redirectweb function on the user's browser. // However, there seems to be a bug in firebase around this. // https://github.com/firebase/firebase-functions/issues/544 // and linked SO question From e2c22ba9b670e8442a0ebab7ea4487d7c897a153 Mon Sep 17 00:00:00 2001 From: Oliver Fritz Date: Thu, 15 May 2025 10:55:37 +0200 Subject: [PATCH 44/76] fix: add doc --- firebase/functions/src/osm_auth.ts | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/firebase/functions/src/osm_auth.ts b/firebase/functions/src/osm_auth.ts index 9cc853a2d..b55613439 100644 --- a/firebase/functions/src/osm_auth.ts +++ b/firebase/functions/src/osm_auth.ts @@ -53,6 +53,11 @@ function osmOAuth2Client() { return simpleOAuth2.create(credentials); } +/** + * Creates a configured simple-oauth2 client for OSM for the web app. + * Configure the `osm.client_id_web` and `osm.client_secret_web` + * Google Cloud environment variables for the values below to exist + */ function osmOAuth2ClientWeb() { const credentials = { client: { From 261533105ee7a083a478b4ff0c3f7f73491dc4e5 Mon Sep 17 00:00:00 2001 From: Oliver Fritz Date: Mon, 19 May 2025 13:03:44 +0200 Subject: [PATCH 45/76] refactor: redirect and token --- firebase/functions/src/osm_auth.ts | 166 ++++++----------------------- 1 file changed, 34 insertions(+), 132 deletions(-) diff --git a/firebase/functions/src/osm_auth.ts b/firebase/functions/src/osm_auth.ts index b55613439..7e721033d 100644 --- a/firebase/functions/src/osm_auth.ts +++ b/firebase/functions/src/osm_auth.ts @@ -38,7 +38,7 @@ const OSM_API_URL = functions.config().osm?.api_url; * Configure the `osm.client_id` and `osm.client_secret` * Google Cloud environment variables for the values below to exist */ -function osmOAuth2Client() { +function osmOAuth2Client(client_id, client_secret) { const credentials = { client: { id: functions.config().osm?.client_id, @@ -53,26 +53,6 @@ function osmOAuth2Client() { return simpleOAuth2.create(credentials); } -/** - * Creates a configured simple-oauth2 client for OSM for the web app. - * Configure the `osm.client_id_web` and `osm.client_secret_web` - * Google Cloud environment variables for the values below to exist - */ -function osmOAuth2ClientWeb() { - const credentials = { - client: { - id: functions.config().osm?.client_id_web, - secret: functions.config().osm?.client_secret_web, - }, - auth: { - tokenHost: OSM_API_URL, - tokenPath: '/oauth2/token', - authorizePath: '/oauth2/authorize', - }, - }; - return simpleOAuth2.create(credentials); -} - /** * Redirects the User to the OSM authentication consent screen. * Also the '__session' cookie is set for later state verification. @@ -80,8 +60,8 @@ function osmOAuth2ClientWeb() { * NOT a webview inside MapSwipe, as this would break the promise of * OAuth that we do not touch their OSM credentials */ -export const redirect = (req: any, res: any) => { - const oauth2 = osmOAuth2Client(); +function redirect2OsmOauth(redirect_uri, client_id, client_secret) { + const oauth2 = osmOAuth2Client(client_id, client_secret); cookieParser()(req, res, () => { const state = @@ -97,43 +77,31 @@ export const redirect = (req: any, res: any) => { httpOnly: true, }); const redirectUri = oauth2.authorizationCode.authorizeURL({ - redirect_uri: OAUTH_REDIRECT_URI, + redirect_uri: redirect_uri, scope: OAUTH_SCOPES, state: state, }); functions.logger.log('Redirecting to:', redirectUri); res.redirect(redirectUri); }); +} + +export const redirect = (req: any, res: any) => { + const redirect_uri = OAUTH_REDIRECT_URI; + const client_id = functions.config().osm?.client_id; + const client_secret = functions.config().osm?.client_secret; + redirect2OsmOauth(redirect_uri, client_id, client_secret); }; export const redirectweb = (req: any, res: any) => { - const oauth2 = osmOAuth2ClientWeb(); - - cookieParser()(req, res, () => { - const state = - req.cookies.state || crypto.randomBytes(20).toString('hex'); - functions.logger.log('Setting verification state:', state); - // the cookie MUST be called __session for hosted functions not to - // strip it from incoming requests - // (https://firebase.google.com/docs/hosting/manage-cache#using_cookies) - res.cookie('__session', state.toString(), { - // cookie is valid for 1 hour - maxAge: 3600000, - secure: true, - httpOnly: true, - }); - const redirectUri = oauth2.authorizationCode.authorizeURL({ - redirect_uri: OAUTH_REDIRECT_URI_WEB, - scope: OAUTH_SCOPES, - state: state, - }); - functions.logger.log('Redirecting to:', redirectUri); - res.redirect(redirectUri); - }); + const redirect_uri = OAUTH_REDIRECT_URI_WEB; + const client_id = functions.config().osm?.client_id_web; + const client_secret = functions.config().osm?.client_secret_web; + redirect2OsmOauth(redirect_uri, client_id, client_secret); }; /** - * The OSM OAuth endpoing does not give us any info about the user, + * The OSM OAuth endpoint does not give us any info about the user, * so we need to get the user profile from this endpoint */ async function getOSMProfile(accessToken: string) { @@ -155,8 +123,8 @@ async function getOSMProfile(accessToken: string) { * The Firebase custom auth token, display name, photo URL and OSM access * token are sent back to the app via a deeplink redirect. */ -export const token = async (req: any, res: any, admin: any) => { - const oauth2 = osmOAuth2Client(); +function fbToken(redirect_uri, osm_login_link, client_id, client_web) { + const oauth2 = osmOAuth2Client(client_id, client_web); try { return cookieParser()(req, res, async () => { @@ -187,7 +155,7 @@ export const token = async (req: any, res: any, admin: any) => { // this doesn't work results = await oauth2.authorizationCode.getToken({ code: req.query.code, - redirect_uri: OAUTH_REDIRECT_URI, + redirect_uri: redirect_uri, scope: OAUTH_SCOPES, state: req.query.state, }); @@ -225,7 +193,7 @@ export const token = async (req: any, res: any, admin: any) => { ); // build a deep link so we can send the token back to the app // from the browser - const signinUrl = `${APP_OSM_LOGIN_DEEPLINK}?token=${firebaseToken}`; + const signinUrl = `${osm_login_link}?token=${firebaseToken}`; functions.logger.log('redirecting user to', signinUrl); res.redirect(signinUrl); }); @@ -235,89 +203,23 @@ export const token = async (req: any, res: any, admin: any) => { // back into the app to allow the user to take action return res.json({ error: error.toString() }); } -}; - -export const tokenweb = async (req: any, res: any, admin: any) => { - const oauth2 = osmOAuth2ClientWeb(); - - try { - return cookieParser()(req, res, async () => { - functions.logger.log( - 'Received verification state:', - req.cookies.__session, - ); - functions.logger.log('Received state:', req.query.state); - // FIXME: For security, we need to check the cookie that was set - // in the /redirectweb function on the user's browser. - // However, there seems to be a bug in firebase around this. - // https://github.com/firebase/firebase-functions/issues/544 - // and linked SO question - // firebase docs mention the need for a cookie middleware, but there - // is no info about it :( - // cross site cookies don't seem to be the issue - // WE just need to make sure the domain set on the cookies is right - if (!req.cookies.__session) { - throw new Error('State cookie not set or expired. Maybe you took too long to authorize. Please try again.'); - } else if (req.cookies.__session !== req.query.state) { - throw new Error('State validation failed'); - } - functions.logger.log('Received auth code:', req.query.code); - let results; - - try { - // TODO: try adding auth data to request headers if - // this doesn't work - results = await oauth2.authorizationCode.getToken({ - code: req.query.code, - redirect_uri: OAUTH_REDIRECT_URI_WEB, - scope: OAUTH_SCOPES, - state: req.query.state, - }); - } catch (error: any) { - functions.logger.log('Auth token error', error, error.data.res.req); - } - // why is token called twice? - functions.logger.log( - 'Auth code exchange result received:', - results, - ); +} - // We have an OSM access token and the user identity now. - const accessToken = results && results.access_token; - if (accessToken === undefined) { - throw new Error( - 'Could not get an access token from OpenStreetMap', - ); - } - // get the OSM user id and display_name - const { id, display_name } = await getOSMProfile(accessToken); - functions.logger.log('osmuser:', id, display_name); - if (id === undefined) { - // this should not happen, but help guard against creating - // invalid accounts - throw new Error('Could not obtain an account id from OSM'); - } +export const token = async (req: any, res: any, admin: any) => { + const redirect_uri = OAUTH_REDIRECT_URI; + const osm_login_link = APP_OSM_LOGIN_DEEPLINK; + const client_id = functions.config().osm?.client_id; + const client_secret = functions.config().osm?.client_secret; + fbToken(redirect_uri, osm_login_link, client_id, client_secret); +}; - // Create a Firebase account and get the Custom Auth Token. - const firebaseToken = await createFirebaseAccount( - admin, - id, - display_name, - accessToken, - ); - // build a deep link so we can send the token back to the app - // from the browser - const signinUrl = `${APP_OSM_LOGIN_DEEPLINK_WEB}?token=${firebaseToken}`; - functions.logger.log('redirecting user to', signinUrl); - res.redirect(signinUrl); - }); - } catch (error: any) { - // FIXME: this should show up in the user's browser as a bit of text - // We should figure out the various error codes available and feed them - // back into the app to allow the user to take action - return res.json({ error: error.toString() }); - } +export const tokenweb = async (req: any, res: any, admin: any) => { + const redirect_uri = OAUTH_REDIRECT_URI_WEB; + const osm_login_link = APP_OSM_LOGIN_DEEPLINK_WEB; + const client_id = functions.config().osm?.client_id_web; + const client_secret = functions.config().osm?.client_secret_web; + fbToken(redirect_uri, osm_login_link, client_id, client_secret); }; /** From 515190c54694e1d5e0373a35917d94c37f534ebf Mon Sep 17 00:00:00 2001 From: Oliver Fritz Date: Mon, 19 May 2025 13:11:48 +0200 Subject: [PATCH 46/76] fix: pass req res admin --- firebase/functions/src/osm_auth.ts | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/firebase/functions/src/osm_auth.ts b/firebase/functions/src/osm_auth.ts index 7e721033d..584e4df65 100644 --- a/firebase/functions/src/osm_auth.ts +++ b/firebase/functions/src/osm_auth.ts @@ -60,7 +60,7 @@ function osmOAuth2Client(client_id, client_secret) { * NOT a webview inside MapSwipe, as this would break the promise of * OAuth that we do not touch their OSM credentials */ -function redirect2OsmOauth(redirect_uri, client_id, client_secret) { +function redirect2OsmOauth(req, res, redirect_uri, client_id, client_secret) { const oauth2 = osmOAuth2Client(client_id, client_secret); cookieParser()(req, res, () => { @@ -90,14 +90,14 @@ export const redirect = (req: any, res: any) => { const redirect_uri = OAUTH_REDIRECT_URI; const client_id = functions.config().osm?.client_id; const client_secret = functions.config().osm?.client_secret; - redirect2OsmOauth(redirect_uri, client_id, client_secret); + redirect2OsmOauth(req, res, redirect_uri, client_id, client_secret); }; export const redirectweb = (req: any, res: any) => { const redirect_uri = OAUTH_REDIRECT_URI_WEB; const client_id = functions.config().osm?.client_id_web; const client_secret = functions.config().osm?.client_secret_web; - redirect2OsmOauth(redirect_uri, client_id, client_secret); + redirect2OsmOauth(req, res, redirect_uri, client_id, client_secret); }; /** @@ -123,7 +123,7 @@ async function getOSMProfile(accessToken: string) { * The Firebase custom auth token, display name, photo URL and OSM access * token are sent back to the app via a deeplink redirect. */ -function fbToken(redirect_uri, osm_login_link, client_id, client_web) { +function fbToken(req, res, admin, redirect_uri, osm_login_link, client_id, client_web) { const oauth2 = osmOAuth2Client(client_id, client_web); try { @@ -211,7 +211,7 @@ export const token = async (req: any, res: any, admin: any) => { const osm_login_link = APP_OSM_LOGIN_DEEPLINK; const client_id = functions.config().osm?.client_id; const client_secret = functions.config().osm?.client_secret; - fbToken(redirect_uri, osm_login_link, client_id, client_secret); + fbToken(req, res, admin, redirect_uri, osm_login_link, client_id, client_secret); }; export const tokenweb = async (req: any, res: any, admin: any) => { @@ -219,7 +219,7 @@ export const tokenweb = async (req: any, res: any, admin: any) => { const osm_login_link = APP_OSM_LOGIN_DEEPLINK_WEB; const client_id = functions.config().osm?.client_id_web; const client_secret = functions.config().osm?.client_secret_web; - fbToken(redirect_uri, osm_login_link, client_id, client_secret); + fbToken(req, res, admin, redirect_uri, osm_login_link, client_id, client_secret); }; /** From 52a00542a3e58ef8b649ae4d167875c8cc38f51a Mon Sep 17 00:00:00 2001 From: Oliver Fritz Date: Mon, 19 May 2025 13:15:49 +0200 Subject: [PATCH 47/76] style: remove blank line padding --- firebase/functions/src/osm_auth.ts | 1 - 1 file changed, 1 deletion(-) diff --git a/firebase/functions/src/osm_auth.ts b/firebase/functions/src/osm_auth.ts index 584e4df65..6fadc565f 100644 --- a/firebase/functions/src/osm_auth.ts +++ b/firebase/functions/src/osm_auth.ts @@ -203,7 +203,6 @@ function fbToken(req, res, admin, redirect_uri, osm_login_link, client_id, clien // back into the app to allow the user to take action return res.json({ error: error.toString() }); } - } export const token = async (req: any, res: any, admin: any) => { From e04f69459acbc95d35d1a5e3b071ee22fe6771b8 Mon Sep 17 00:00:00 2001 From: Oliver Fritz Date: Mon, 19 May 2025 13:38:24 +0200 Subject: [PATCH 48/76] fix: typing --- firebase/functions/src/osm_auth.ts | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/firebase/functions/src/osm_auth.ts b/firebase/functions/src/osm_auth.ts index 6fadc565f..c344ced86 100644 --- a/firebase/functions/src/osm_auth.ts +++ b/firebase/functions/src/osm_auth.ts @@ -38,11 +38,11 @@ const OSM_API_URL = functions.config().osm?.api_url; * Configure the `osm.client_id` and `osm.client_secret` * Google Cloud environment variables for the values below to exist */ -function osmOAuth2Client(client_id, client_secret) { +function osmOAuth2Client(client_id: any, client_secret: any) { const credentials = { client: { - id: functions.config().osm?.client_id, - secret: functions.config().osm?.client_secret, + id: client_id, + secret: client_secret, }, auth: { tokenHost: OSM_API_URL, @@ -60,7 +60,7 @@ function osmOAuth2Client(client_id, client_secret) { * NOT a webview inside MapSwipe, as this would break the promise of * OAuth that we do not touch their OSM credentials */ -function redirect2OsmOauth(req, res, redirect_uri, client_id, client_secret) { +function redirect2OsmOauth(req: any, res: any, redirect_uri: string, client_id: string, client_secret: string) { const oauth2 = osmOAuth2Client(client_id, client_secret); cookieParser()(req, res, () => { @@ -123,7 +123,7 @@ async function getOSMProfile(accessToken: string) { * The Firebase custom auth token, display name, photo URL and OSM access * token are sent back to the app via a deeplink redirect. */ -function fbToken(req, res, admin, redirect_uri, osm_login_link, client_id, client_web) { +function fbToken(req: any , res: any, admin: any, redirect_uri: string, osm_login_link: string, client_id: string, client_web: string) { const oauth2 = osmOAuth2Client(client_id, client_web); try { From 3944e677682585d1f0fe35b507f7fea85bbaad90 Mon Sep 17 00:00:00 2001 From: Oliver Fritz Date: Mon, 19 May 2025 13:41:24 +0200 Subject: [PATCH 49/76] fix: remove space before , --- firebase/functions/src/osm_auth.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/firebase/functions/src/osm_auth.ts b/firebase/functions/src/osm_auth.ts index c344ced86..27e4f562a 100644 --- a/firebase/functions/src/osm_auth.ts +++ b/firebase/functions/src/osm_auth.ts @@ -123,7 +123,7 @@ async function getOSMProfile(accessToken: string) { * The Firebase custom auth token, display name, photo URL and OSM access * token are sent back to the app via a deeplink redirect. */ -function fbToken(req: any , res: any, admin: any, redirect_uri: string, osm_login_link: string, client_id: string, client_web: string) { +function fbToken(req: any, res: any, admin: any, redirect_uri: string, osm_login_link: string, client_id: string, client_web: string) { const oauth2 = osmOAuth2Client(client_id, client_web); try { From 95d0c56bc6f950ebeabb7ba12c9ccb43e61bb65e Mon Sep 17 00:00:00 2001 From: Oliver Fritz Date: Tue, 20 May 2025 18:47:17 +0200 Subject: [PATCH 50/76] fix: avoid overwriting existing osm profiles on sign in --- firebase/functions/src/osm_auth.ts | 46 ++++++++++++++++++++++-------- mapswipe_workers/requirements.txt | 1 + 2 files changed, 35 insertions(+), 12 deletions(-) diff --git a/firebase/functions/src/osm_auth.ts b/firebase/functions/src/osm_auth.ts index 27e4f562a..18d165d6a 100644 --- a/firebase/functions/src/osm_auth.ts +++ b/firebase/functions/src/osm_auth.ts @@ -236,23 +236,19 @@ async function createFirebaseAccount(admin: any, osmID: any, displayName: any, a // with a variable length. const uid = `osm:${osmID}`; + // check if profile exists on Firebase Realtime Database + const profileExists = await admin + .database() + .ref(`v2/users/osm:${id}/`) + .get() + .then((snapshot: any) => { return snapshot.exists()}); + // Save the access token to the Firebase Realtime Database. const databaseTask = admin .database() .ref(`v2/OSMAccessToken/${uid}`) .set(accessToken); - const profileTask = admin - .database() - .ref(`v2/users/${uid}/`) - .set({ - created: new Date().toISOString(), - groupContributionCount: 0, - projectContributionCount: 0, - taskContributionCount: 0, - displayName, - }); - // Create or update the firebase user account. // This does not login the user on the app, it just ensures that a firebase // user account (linked to the OSM account) exists. @@ -272,8 +268,34 @@ async function createFirebaseAccount(admin: any, osmID: any, displayName: any, a throw error; }); + // Only update display name if profile exists, else create profile + const profileUpdateTask = admin + .database() + .ref(`v2/users/${uid}/`) + .update({ displayName }) + + const profileCreationTask = admin + .database() + .ref(`v2/users/${uid}/`) + .set({ + created: new Date().toISOString(), + groupContributionCount: 0, + projectContributionCount: 0, + taskContributionCount: 0, + displayName, + }); + } + + const tasks = [userCreationTask, databaseTask] + + if (profileExists) { + tasks.push(profileUpdateTask) + } else { + tasks.push(profileCreationTask) + } + // Wait for all async task to complete then generate and return a custom auth token. - await Promise.all([userCreationTask, databaseTask, profileTask]); + await Promise.all(tasks); // Create a Firebase custom auth token. functions.logger.log('In createFirebaseAccount: createCustomToken'); let authToken; diff --git a/mapswipe_workers/requirements.txt b/mapswipe_workers/requirements.txt index 588754060..13462eaa5 100644 --- a/mapswipe_workers/requirements.txt +++ b/mapswipe_workers/requirements.txt @@ -5,6 +5,7 @@ firebase-admin==6.0.0 flake8==3.8.3 geojson==3.0.1 mapswipe-workers==3.0 +numpy==1.26.4 pandas==1.5.2 pre-commit==2.9.2 psycopg2-binary==2.9.3 From 29ce576e95a874c3f8caed9732f42759dcd94bb4 Mon Sep 17 00:00:00 2001 From: Oliver Fritz Date: Tue, 20 May 2025 18:52:21 +0200 Subject: [PATCH 51/76] revert change to requirements --- mapswipe_workers/requirements.txt | 1 - 1 file changed, 1 deletion(-) diff --git a/mapswipe_workers/requirements.txt b/mapswipe_workers/requirements.txt index 13462eaa5..588754060 100644 --- a/mapswipe_workers/requirements.txt +++ b/mapswipe_workers/requirements.txt @@ -5,7 +5,6 @@ firebase-admin==6.0.0 flake8==3.8.3 geojson==3.0.1 mapswipe-workers==3.0 -numpy==1.26.4 pandas==1.5.2 pre-commit==2.9.2 psycopg2-binary==2.9.3 From 57385ef430019cfdcc5ec1e56bdb5f7f8da89b76 Mon Sep 17 00:00:00 2001 From: Oliver Fritz Date: Tue, 20 May 2025 19:03:18 +0200 Subject: [PATCH 52/76] fix: clean parentheses --- firebase/functions/src/osm_auth.ts | 11 +++++------ 1 file changed, 5 insertions(+), 6 deletions(-) diff --git a/firebase/functions/src/osm_auth.ts b/firebase/functions/src/osm_auth.ts index 18d165d6a..0c8f0dd76 100644 --- a/firebase/functions/src/osm_auth.ts +++ b/firebase/functions/src/osm_auth.ts @@ -239,7 +239,7 @@ async function createFirebaseAccount(admin: any, osmID: any, displayName: any, a // check if profile exists on Firebase Realtime Database const profileExists = await admin .database() - .ref(`v2/users/osm:${id}/`) + .ref(`v2/users/${uid}/`) .get() .then((snapshot: any) => { return snapshot.exists()}); @@ -272,7 +272,7 @@ async function createFirebaseAccount(admin: any, osmID: any, displayName: any, a const profileUpdateTask = admin .database() .ref(`v2/users/${uid}/`) - .update({ displayName }) + .update({ displayName }); const profileCreationTask = admin .database() @@ -284,14 +284,13 @@ async function createFirebaseAccount(admin: any, osmID: any, displayName: any, a taskContributionCount: 0, displayName, }); - } - const tasks = [userCreationTask, databaseTask] + const tasks = [userCreationTask, databaseTask]; if (profileExists) { - tasks.push(profileUpdateTask) + tasks.push(profileUpdateTask); } else { - tasks.push(profileCreationTask) + tasks.push(profileCreationTask); } // Wait for all async task to complete then generate and return a custom auth token. From a8d0ae6152e62b82060a71ce4bbb372d9f116ceb Mon Sep 17 00:00:00 2001 From: Oliver Fritz Date: Tue, 20 May 2025 19:09:10 +0200 Subject: [PATCH 53/76] fix: formatting --- firebase/functions/src/osm_auth.ts | 10 ++++++---- 1 file changed, 6 insertions(+), 4 deletions(-) diff --git a/firebase/functions/src/osm_auth.ts b/firebase/functions/src/osm_auth.ts index 0c8f0dd76..76d820b2e 100644 --- a/firebase/functions/src/osm_auth.ts +++ b/firebase/functions/src/osm_auth.ts @@ -241,7 +241,9 @@ async function createFirebaseAccount(admin: any, osmID: any, displayName: any, a .database() .ref(`v2/users/${uid}/`) .get() - .then((snapshot: any) => { return snapshot.exists()}); + .then((snapshot: any) => { + return snapshot.exists(); + }); // Save the access token to the Firebase Realtime Database. const databaseTask = admin @@ -270,9 +272,9 @@ async function createFirebaseAccount(admin: any, osmID: any, displayName: any, a // Only update display name if profile exists, else create profile const profileUpdateTask = admin - .database() - .ref(`v2/users/${uid}/`) - .update({ displayName }); + .database() + .ref(`v2/users/${uid}/`) + .update({ displayName }); const profileCreationTask = admin .database() From a8c662b43a8e7b931a89cb16cd5a737086f8ce9d Mon Sep 17 00:00:00 2001 From: Oliver Fritz Date: Tue, 20 May 2025 19:32:48 +0200 Subject: [PATCH 54/76] fix: use once instead of get --- firebase/functions/src/osm_auth.ts | 9 ++++----- 1 file changed, 4 insertions(+), 5 deletions(-) diff --git a/firebase/functions/src/osm_auth.ts b/firebase/functions/src/osm_auth.ts index 76d820b2e..c19d0bc0d 100644 --- a/firebase/functions/src/osm_auth.ts +++ b/firebase/functions/src/osm_auth.ts @@ -237,13 +237,12 @@ async function createFirebaseAccount(admin: any, osmID: any, displayName: any, a const uid = `osm:${osmID}`; // check if profile exists on Firebase Realtime Database - const profileExists = await admin + const snapshot = await admin .database() .ref(`v2/users/${uid}/`) - .get() - .then((snapshot: any) => { - return snapshot.exists(); - }); + .once() + + const profileExists = snapshot.exists() // Save the access token to the Firebase Realtime Database. const databaseTask = admin From 0aace7f39b2ba9fc04df709193604ffd8a837765 Mon Sep 17 00:00:00 2001 From: Oliver Fritz Date: Tue, 20 May 2025 19:38:01 +0200 Subject: [PATCH 55/76] reuse profileRef and add semicolons --- firebase/functions/src/osm_auth.ts | 20 ++++++-------------- 1 file changed, 6 insertions(+), 14 deletions(-) diff --git a/firebase/functions/src/osm_auth.ts b/firebase/functions/src/osm_auth.ts index c19d0bc0d..b600d0c3a 100644 --- a/firebase/functions/src/osm_auth.ts +++ b/firebase/functions/src/osm_auth.ts @@ -236,13 +236,11 @@ async function createFirebaseAccount(admin: any, osmID: any, displayName: any, a // with a variable length. const uid = `osm:${osmID}`; - // check if profile exists on Firebase Realtime Database - const snapshot = await admin - .database() - .ref(`v2/users/${uid}/`) - .once() + const profileRef = admin.database().ref(`v2/users/${uid}/`); - const profileExists = snapshot.exists() + // check if profile exists on Firebase Realtime Database + const snapshot = await profileRef.once(); + const profileExists = snapshot.exists(); // Save the access token to the Firebase Realtime Database. const databaseTask = admin @@ -270,14 +268,8 @@ async function createFirebaseAccount(admin: any, osmID: any, displayName: any, a }); // Only update display name if profile exists, else create profile - const profileUpdateTask = admin - .database() - .ref(`v2/users/${uid}/`) - .update({ displayName }); - - const profileCreationTask = admin - .database() - .ref(`v2/users/${uid}/`) + const profileUpdateTask = profileRef.update({ displayName }); + const profileCreationTask = profileRef .set({ created: new Date().toISOString(), groupContributionCount: 0, From bbfb5ad2ce84ab2a189acdaef6da26aad12bbfd2 Mon Sep 17 00:00:00 2001 From: Oliver Fritz Date: Tue, 20 May 2025 20:01:06 +0200 Subject: [PATCH 56/76] fix: add arg to once --- firebase/functions/src/osm_auth.ts | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/firebase/functions/src/osm_auth.ts b/firebase/functions/src/osm_auth.ts index b600d0c3a..3328f7079 100644 --- a/firebase/functions/src/osm_auth.ts +++ b/firebase/functions/src/osm_auth.ts @@ -236,10 +236,10 @@ async function createFirebaseAccount(admin: any, osmID: any, displayName: any, a // with a variable length. const uid = `osm:${osmID}`; - const profileRef = admin.database().ref(`v2/users/${uid}/`); + const profileRef = admin.database().ref(`v2/users/${uid}`); // check if profile exists on Firebase Realtime Database - const snapshot = await profileRef.once(); + const snapshot = await profileRef.once('value'); const profileExists = snapshot.exists(); // Save the access token to the Firebase Realtime Database. @@ -268,7 +268,7 @@ async function createFirebaseAccount(admin: any, osmID: any, displayName: any, a }); // Only update display name if profile exists, else create profile - const profileUpdateTask = profileRef.update({ displayName }); + const profileUpdateTask = profileRef.update({ displayName: displayName }); const profileCreationTask = profileRef .set({ created: new Date().toISOString(), From 09a955df77c78a859b4f8f5be2b5a3e344b218c2 Mon Sep 17 00:00:00 2001 From: Oliver Fritz Date: Tue, 20 May 2025 20:13:21 +0200 Subject: [PATCH 57/76] add logging --- firebase/functions/src/osm_auth.ts | 3 +++ 1 file changed, 3 insertions(+) diff --git a/firebase/functions/src/osm_auth.ts b/firebase/functions/src/osm_auth.ts index 3328f7079..620b247f2 100644 --- a/firebase/functions/src/osm_auth.ts +++ b/firebase/functions/src/osm_auth.ts @@ -240,6 +240,7 @@ async function createFirebaseAccount(admin: any, osmID: any, displayName: any, a // check if profile exists on Firebase Realtime Database const snapshot = await profileRef.once('value'); + functions.logger.log("Snapshot value:", snapshot.val()); const profileExists = snapshot.exists(); // Save the access token to the Firebase Realtime Database. @@ -281,8 +282,10 @@ async function createFirebaseAccount(admin: any, osmID: any, displayName: any, a const tasks = [userCreationTask, databaseTask]; if (profileExists) { + functions.logger.log('Sign in to existing OSM profile'); tasks.push(profileUpdateTask); } else { + functions.logger.log('Sign up new OSM profile'); tasks.push(profileCreationTask); } From 61bb66ec9dfe92363c1e24636b8c3eb4c1973d41 Mon Sep 17 00:00:00 2001 From: Oliver Fritz Date: Wed, 21 May 2025 08:51:44 +0200 Subject: [PATCH 58/76] fix: use singlequote --- firebase/functions/src/osm_auth.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/firebase/functions/src/osm_auth.ts b/firebase/functions/src/osm_auth.ts index 620b247f2..bbc54c34e 100644 --- a/firebase/functions/src/osm_auth.ts +++ b/firebase/functions/src/osm_auth.ts @@ -240,7 +240,7 @@ async function createFirebaseAccount(admin: any, osmID: any, displayName: any, a // check if profile exists on Firebase Realtime Database const snapshot = await profileRef.once('value'); - functions.logger.log("Snapshot value:", snapshot.val()); + functions.logger.log('Snapshot value:', snapshot.val()); const profileExists = snapshot.exists(); // Save the access token to the Firebase Realtime Database. From 85710d57e32cf375ea320eb4911f6609ae1d104d Mon Sep 17 00:00:00 2001 From: Oliver Fritz Date: Wed, 21 May 2025 09:01:22 +0200 Subject: [PATCH 59/76] move profile tasks definition --- firebase/functions/src/osm_auth.ts | 19 +++++++++---------- 1 file changed, 9 insertions(+), 10 deletions(-) diff --git a/firebase/functions/src/osm_auth.ts b/firebase/functions/src/osm_auth.ts index bbc54c34e..95d9169fa 100644 --- a/firebase/functions/src/osm_auth.ts +++ b/firebase/functions/src/osm_auth.ts @@ -269,23 +269,22 @@ async function createFirebaseAccount(admin: any, osmID: any, displayName: any, a }); // Only update display name if profile exists, else create profile - const profileUpdateTask = profileRef.update({ displayName: displayName }); - const profileCreationTask = profileRef - .set({ - created: new Date().toISOString(), - groupContributionCount: 0, - projectContributionCount: 0, - taskContributionCount: 0, - displayName, - }); - const tasks = [userCreationTask, databaseTask]; if (profileExists) { functions.logger.log('Sign in to existing OSM profile'); + const profileUpdateTask = profileRef.update({ displayName: displayName }); tasks.push(profileUpdateTask); } else { functions.logger.log('Sign up new OSM profile'); + const profileCreationTask = profileRef + .set({ + created: new Date().toISOString(), + groupContributionCount: 0, + projectContributionCount: 0, + taskContributionCount: 0, + displayName, + }); tasks.push(profileCreationTask); } From 3430784817d3a94d0228928322fd617629eefe10 Mon Sep 17 00:00:00 2001 From: Oliver Fritz Date: Wed, 21 May 2025 09:05:14 +0200 Subject: [PATCH 60/76] fix indentation --- firebase/functions/src/osm_auth.ts | 14 +++++++------- 1 file changed, 7 insertions(+), 7 deletions(-) diff --git a/firebase/functions/src/osm_auth.ts b/firebase/functions/src/osm_auth.ts index 95d9169fa..9f0b286f2 100644 --- a/firebase/functions/src/osm_auth.ts +++ b/firebase/functions/src/osm_auth.ts @@ -278,13 +278,13 @@ async function createFirebaseAccount(admin: any, osmID: any, displayName: any, a } else { functions.logger.log('Sign up new OSM profile'); const profileCreationTask = profileRef - .set({ - created: new Date().toISOString(), - groupContributionCount: 0, - projectContributionCount: 0, - taskContributionCount: 0, - displayName, - }); + .set({ + created: new Date().toISOString(), + groupContributionCount: 0, + projectContributionCount: 0, + taskContributionCount: 0, + displayName, + }); tasks.push(profileCreationTask); } From ed806f6503d6008329f79fd940e09deb468d7e05 Mon Sep 17 00:00:00 2001 From: Oliver Fritz Date: Wed, 21 May 2025 09:33:09 +0200 Subject: [PATCH 61/76] fix: clean up --- firebase/functions/src/osm_auth.ts | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/firebase/functions/src/osm_auth.ts b/firebase/functions/src/osm_auth.ts index 9f0b286f2..9953f2ea9 100644 --- a/firebase/functions/src/osm_auth.ts +++ b/firebase/functions/src/osm_auth.ts @@ -240,7 +240,6 @@ async function createFirebaseAccount(admin: any, osmID: any, displayName: any, a // check if profile exists on Firebase Realtime Database const snapshot = await profileRef.once('value'); - functions.logger.log('Snapshot value:', snapshot.val()); const profileExists = snapshot.exists(); // Save the access token to the Firebase Realtime Database. @@ -268,9 +267,8 @@ async function createFirebaseAccount(admin: any, osmID: any, displayName: any, a throw error; }); - // Only update display name if profile exists, else create profile + // If profile exists, only update displayName -- else create new user profile const tasks = [userCreationTask, databaseTask]; - if (profileExists) { functions.logger.log('Sign in to existing OSM profile'); const profileUpdateTask = profileRef.update({ displayName: displayName }); From a758dc1c42707639dc394c99ee8bec1e66a29d20 Mon Sep 17 00:00:00 2001 From: Oliver Fritz Date: Wed, 21 May 2025 13:28:00 +0200 Subject: [PATCH 62/76] docs(osm-login-web): add web osm login to docs --- firebase/README.md | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/firebase/README.md b/firebase/README.md index fed47268d..c3381abc9 100644 --- a/firebase/README.md +++ b/firebase/README.md @@ -20,6 +20,11 @@ expose the authentication functions publicly. * `firebase deploy --only functions,hosting` * `firebase deploy --only database:rules` +## Deploy with Makefile +You can also deploy the changes to Firebase using make: +* Make sure to remove the firebase_deploy docker image first: `docker rmi python-mapswipe-workers-firebase_deploy` +* `make update_firebase_functions_and_db_rules` + ## Notes on OAuth (OSM login) Refer to [the notes in the app repository](https://github.com/mapswipe/mapswipe/blob/master/docs/osm_login.md). @@ -30,12 +35,16 @@ Some specifics about the related functions: - Before deploying, set the required firebase config values in environment: FIXME: replace env vars with config value names - OSM_OAUTH_REDIRECT_URI `osm.redirect_uri`: `https://dev-auth.mapswipe.org/token` or `https://auth.mapswipe.org/token` + - OSM_OAUTH_REDIRECT_URI_WEB: `https://dev-auth.mapswipe.org/tokenweb` or `https://auth.mapswipe.org/tokenweb` - OSM_OAUTH_APP_LOGIN_LINK `osm.app_login_link`: 'devmapswipe://login/osm' or 'mapswipe://login/osm' + - OSM_OAUTH_APP_LOGIN_LINK_WEB: `https://web.mapswipe.org/dev/#/osm-callback` or `https://web.mapswipe.org/#/osm-callback` - OSM_OAUTH_API_URL `osm.api_url`: 'https://master.apis.dev.openstreetmap.org/' or 'https://www.openstreetmap.org/' (include the trailing slash) - OSM_OAUTH_CLIENT_ID `osm.client_id`: find it on the OSM application page - OSM_OAUTH_CLIENT_SECRET `osm.client_secret`: same as above. Note that this can only be seen once when the application is created. Do not lose it! + - OSM_OAUTH_CLIENT_ID_WEB: This is the ID of a __different__ registered OSM OAuth client for the web version that needs to have `https://dev-auth.mapswipe.org/tokenweb` or `https://auth.mapswipe.org/tokenweb` set as redirect URI. + - OSM_OAUTH_CLIENT_SECRET_WEB: This is the secret of the OSM OAuth client for MapSwipe web version. - Deploy the functions as explained above - Expose the functions publicly through firebase hosting, this is done in `/firebase/firebase.json` under the `hosting` key. From 25a81f40039ed8f2dd410440d7969951d724d35f Mon Sep 17 00:00:00 2001 From: tnagorra Date: Thu, 22 May 2025 09:54:11 +0545 Subject: [PATCH 63/76] Update domain for tasking manager v5 --- manager-dashboard/app/views/NewProject/utils.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/manager-dashboard/app/views/NewProject/utils.ts b/manager-dashboard/app/views/NewProject/utils.ts index 685ffd39d..ce419e42d 100644 --- a/manager-dashboard/app/views/NewProject/utils.ts +++ b/manager-dashboard/app/views/NewProject/utils.ts @@ -681,7 +681,7 @@ async function fetchAoiFromHotTaskingManager(projectId: number | string): ( let response; try { response = await fetch( - `https://tasking-manager-tm4-production-api.hotosm.org/api/v2/projects/${projectId}/queries/aoi/?as_file=false`, + `https://tasking-manager-production-api.hotosm.org/api/v2/projects/${projectId}/queries/aoi/?as_file=false`, ); } catch { return { From d256198504db1b0609ecc83a67a9c66da1c105b8 Mon Sep 17 00:00:00 2001 From: thenav56 Date: Thu, 12 Jun 2025 10:47:54 +0545 Subject: [PATCH 64/76] fix(sql): fix typo in comment in initdb.sql --- mapswipe_workers/tests/integration/set_up_db.sql | 2 +- postgres/initdb.sql | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/mapswipe_workers/tests/integration/set_up_db.sql b/mapswipe_workers/tests/integration/set_up_db.sql index f954d3a8c..6a8d1edd5 100644 --- a/mapswipe_workers/tests/integration/set_up_db.sql +++ b/mapswipe_workers/tests/integration/set_up_db.sql @@ -30,7 +30,7 @@ CREATE TABLE IF NOT EXISTS groups ( required_count int, progress int, project_type_specifics json, - -- total_area & time_spent_max_allowed are maintaned and used by aggregated module + -- total_area & time_spent_max_allowed are maintained and used by aggregated module total_area float DEFAULT NULL, time_spent_max_allowed float DEFAULT NULL, PRIMARY KEY (project_id, group_id), diff --git a/postgres/initdb.sql b/postgres/initdb.sql index f954d3a8c..6a8d1edd5 100644 --- a/postgres/initdb.sql +++ b/postgres/initdb.sql @@ -30,7 +30,7 @@ CREATE TABLE IF NOT EXISTS groups ( required_count int, progress int, project_type_specifics json, - -- total_area & time_spent_max_allowed are maintaned and used by aggregated module + -- total_area & time_spent_max_allowed are maintained and used by aggregated module total_area float DEFAULT NULL, time_spent_max_allowed float DEFAULT NULL, PRIMARY KEY (project_id, group_id), From d4805a0dc84dab9ce199b1b007b7860e8c30950c Mon Sep 17 00:00:00 2001 From: thenav56 Date: Thu, 12 Jun 2025 10:49:24 +0545 Subject: [PATCH 65/76] feat(django): add support for unaccent search for usergroups --- django/apps/existing_database/filters.py | 2 +- mapswipe_workers/tests/integration/set_up_db.sql | 1 + postgres/initdb.sql | 1 + 3 files changed, 3 insertions(+), 1 deletion(-) diff --git a/django/apps/existing_database/filters.py b/django/apps/existing_database/filters.py index 096f2f5a1..98227d94b 100644 --- a/django/apps/existing_database/filters.py +++ b/django/apps/existing_database/filters.py @@ -38,7 +38,7 @@ class UserGroupFilter: def filter_search(self, queryset): if self.search: queryset = queryset.filter( - name__icontains=self.search, + name__unaccent__icontains=self.search, ) return queryset diff --git a/mapswipe_workers/tests/integration/set_up_db.sql b/mapswipe_workers/tests/integration/set_up_db.sql index 6a8d1edd5..b2b23f328 100644 --- a/mapswipe_workers/tests/integration/set_up_db.sql +++ b/mapswipe_workers/tests/integration/set_up_db.sql @@ -1,5 +1,6 @@ -- noinspection SqlNoDataSourceInspectionForFile CREATE EXTENSION IF NOT EXISTS postgis; +CREATE EXTENSION IF NOT EXISTS unaccent; CREATE TABLE IF NOT EXISTS projects ( created timestamp, diff --git a/postgres/initdb.sql b/postgres/initdb.sql index 6a8d1edd5..b2b23f328 100644 --- a/postgres/initdb.sql +++ b/postgres/initdb.sql @@ -1,5 +1,6 @@ -- noinspection SqlNoDataSourceInspectionForFile CREATE EXTENSION IF NOT EXISTS postgis; +CREATE EXTENSION IF NOT EXISTS unaccent; CREATE TABLE IF NOT EXISTS projects ( created timestamp, From 46950aa170797e20ac99b0f31c1c1555cd4e6be4 Mon Sep 17 00:00:00 2001 From: thenav56 Date: Thu, 12 Jun 2025 10:49:24 +0545 Subject: [PATCH 66/76] feat(django): add support for unaccent search for usergroups --- django/apps/existing_database/filters.py | 2 +- mapswipe_workers/tests/integration/set_up_db.sql | 1 + postgres/initdb.sql | 1 + 3 files changed, 3 insertions(+), 1 deletion(-) diff --git a/django/apps/existing_database/filters.py b/django/apps/existing_database/filters.py index 096f2f5a1..98227d94b 100644 --- a/django/apps/existing_database/filters.py +++ b/django/apps/existing_database/filters.py @@ -38,7 +38,7 @@ class UserGroupFilter: def filter_search(self, queryset): if self.search: queryset = queryset.filter( - name__icontains=self.search, + name__unaccent__icontains=self.search, ) return queryset diff --git a/mapswipe_workers/tests/integration/set_up_db.sql b/mapswipe_workers/tests/integration/set_up_db.sql index f954d3a8c..66583bb2d 100644 --- a/mapswipe_workers/tests/integration/set_up_db.sql +++ b/mapswipe_workers/tests/integration/set_up_db.sql @@ -1,5 +1,6 @@ -- noinspection SqlNoDataSourceInspectionForFile CREATE EXTENSION IF NOT EXISTS postgis; +CREATE EXTENSION IF NOT EXISTS unaccent; CREATE TABLE IF NOT EXISTS projects ( created timestamp, diff --git a/postgres/initdb.sql b/postgres/initdb.sql index f954d3a8c..66583bb2d 100644 --- a/postgres/initdb.sql +++ b/postgres/initdb.sql @@ -1,5 +1,6 @@ -- noinspection SqlNoDataSourceInspectionForFile CREATE EXTENSION IF NOT EXISTS postgis; +CREATE EXTENSION IF NOT EXISTS unaccent; CREATE TABLE IF NOT EXISTS projects ( created timestamp, From 4242d7bea4a12882420aaf8f8932e0752d4e5f4f Mon Sep 17 00:00:00 2001 From: tnagorra Date: Thu, 26 Jun 2025 09:48:08 +0545 Subject: [PATCH 67/76] feat(validate_image): add validate image project & tutorial creation --- .../mapswipe_workers/definitions.py | 5 + .../mapswipe_workers/firebase/firebase.py | 2 + .../project_types/__init__.py | 4 + .../project_types/validate_image/__init__.py | 0 .../project_types/validate_image/project.py | 108 ++++++++++++++++++ .../project_types/validate_image/tutorial.py | 80 +++++++++++++ 6 files changed, 199 insertions(+) create mode 100644 mapswipe_workers/mapswipe_workers/project_types/validate_image/__init__.py create mode 100644 mapswipe_workers/mapswipe_workers/project_types/validate_image/project.py create mode 100644 mapswipe_workers/mapswipe_workers/project_types/validate_image/tutorial.py diff --git a/mapswipe_workers/mapswipe_workers/definitions.py b/mapswipe_workers/mapswipe_workers/definitions.py index aa32d3aac..afa7cb6ff 100644 --- a/mapswipe_workers/mapswipe_workers/definitions.py +++ b/mapswipe_workers/mapswipe_workers/definitions.py @@ -137,6 +137,7 @@ class ProjectType(Enum): MEDIA_CLASSIFICATION = 5 DIGITIZATION = 6 STREET = 7 + VALIDATE_IMAGE = 10 @property def constructor(self): @@ -149,6 +150,7 @@ def constructor(self): FootprintProject, MediaClassificationProject, StreetProject, + ValidateImageProject, ) project_type_classes = { @@ -159,6 +161,7 @@ def constructor(self): 5: MediaClassificationProject, 6: DigitizationProject, 7: StreetProject, + 10: ValidateImageProject, } return project_type_classes[self.value] @@ -171,6 +174,7 @@ def tutorial(self): CompletenessTutorial, FootprintTutorial, StreetTutorial, + ValidateImageTutorial, ) project_type_classes = { @@ -179,5 +183,6 @@ def tutorial(self): 3: ChangeDetectionTutorial, 4: CompletenessTutorial, 7: StreetTutorial, + 10: ValidateImageTutorial, } return project_type_classes[self.value] diff --git a/mapswipe_workers/mapswipe_workers/firebase/firebase.py b/mapswipe_workers/mapswipe_workers/firebase/firebase.py index 809b6c801..b91256985 100644 --- a/mapswipe_workers/mapswipe_workers/firebase/firebase.py +++ b/mapswipe_workers/mapswipe_workers/firebase/firebase.py @@ -14,6 +14,7 @@ def save_project_to_firebase(self, project): # if a geometry exists in projects we want to delete it. # This geometry is not used in clients. project.pop("geometry", None) + # FIXME: We might need to pop images # save project self.ref.update({f"v2/projects/{project['projectId']}": project}) logger.info( @@ -82,6 +83,7 @@ def save_tutorial_to_firebase( tutorialDict.pop("raw_tasks", None) tutorialDict.pop("examplesFile", None) tutorialDict.pop("tutorial_tasks", None) + tutorialDict.pop("images", None) if not tutorial.projectId or tutorial.projectId == "": raise CustomError( diff --git a/mapswipe_workers/mapswipe_workers/project_types/__init__.py b/mapswipe_workers/mapswipe_workers/project_types/__init__.py index 9560c76ef..3fb4e722b 100644 --- a/mapswipe_workers/mapswipe_workers/project_types/__init__.py +++ b/mapswipe_workers/mapswipe_workers/project_types/__init__.py @@ -10,6 +10,8 @@ from .tile_map_service.classification.tutorial import ClassificationTutorial from .tile_map_service.completeness.project import CompletenessProject from .tile_map_service.completeness.tutorial import CompletenessTutorial +from .validate_image.project import ValidateImageProject +from .validate_image.tutorial import ValidateImageTutorial __all__ = [ "ClassificationProject", @@ -21,6 +23,8 @@ "MediaClassificationProject", "FootprintProject", "FootprintTutorial", + "ValidateImageProject", + "ValidateImageTutorial", "DigitizationProject", "StreetProject", "StreetTutorial", diff --git a/mapswipe_workers/mapswipe_workers/project_types/validate_image/__init__.py b/mapswipe_workers/mapswipe_workers/project_types/validate_image/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/mapswipe_workers/mapswipe_workers/project_types/validate_image/project.py b/mapswipe_workers/mapswipe_workers/project_types/validate_image/project.py new file mode 100644 index 000000000..e0aee5f4d --- /dev/null +++ b/mapswipe_workers/mapswipe_workers/project_types/validate_image/project.py @@ -0,0 +1,108 @@ +import math +from dataclasses import dataclass +from typing import Dict, List + +from mapswipe_workers.definitions import logger +from mapswipe_workers.firebase.firebase import Firebase +from mapswipe_workers.firebase_to_postgres.transfer_results import ( + results_to_file, + save_results_to_postgres, + truncate_temp_results, +) +from mapswipe_workers.generate_stats.project_stats import ( + get_statistics_for_integer_result_project, +) +from mapswipe_workers.project_types.project import BaseGroup, BaseProject + + +@dataclass +class ValidateImageGroup(BaseGroup): + pass + + +@dataclass +class ValidateImageTask: + # TODO(tnagorra): We need to check if fileName should be saved on project + # NOTE: We do not need to add projectId and groupId so we are not extending BaseTask + + # NOTE: taskId is the sourceIdentifier + taskId: str + + fileName: str + url: str + + # NOTE: This is not required but required by the base class + geometry: str + + +class ValidateImageProject(BaseProject): + def __init__(self, project_draft): + super().__init__(project_draft) + self.groups: Dict[str, ValidateImageGroup] = {} + self.tasks: Dict[str, List[ValidateImageTask]] = {} # dict keys are group ids + + # NOTE: This is a standard structure defined on manager dashboard. + # It's derived from other formats like COCO. + # The transfromation is done in manager dashboard. + self.images = project_draft["images"] + + def save_tasks_to_firebase(self, projectId: str, tasks: dict): + firebase = Firebase() + firebase.save_tasks_to_firebase(projectId, tasks, useCompression=False) + + @staticmethod + def results_to_postgres(results: dict, project_id: str, filter_mode: bool): + """How to move the result data from firebase to postgres.""" + results_file, user_group_results_file = results_to_file(results, project_id) + truncate_temp_results() + save_results_to_postgres(results_file, project_id, filter_mode) + return user_group_results_file + + @staticmethod + def get_per_project_statistics(project_id, project_info): + """How to aggregate the project results.""" + return get_statistics_for_integer_result_project( + project_id, project_info, generate_hot_tm_geometries=False + ) + + def validate_geometries(self): + pass + + def save_to_files(self, project): + """We do not have any geometry so we pass here""" + pass + + def create_groups(self): + self.numberOfGroups = math.ceil(len(self.images) / self.groupSize) + for group_index in range(self.numberOfGroups): + self.groups[f"g{group_index + 100}"] = ValidateImageGroup( + projectId=self.projectId, + groupId=f"g{group_index + 100}", + progress=0, + finishedCount=0, + requiredCount=0, + numberOfTasks=self.groupSize, + ) + logger.info(f"{self.projectId} - create_groups - created groups dictionary") + + def create_tasks(self): + if len(self.groups) == 0: + raise ValueError("Groups needs to be created before tasks can be created.") + for group_id, group in self.groups.items(): + self.tasks[group_id] = [] + for i in range(self.groupSize): + # FIXME: We should try not to mutate values + image_metadata = self.images.pop() + task = ValidateImageTask( + taskId=image_metadata["sourceIdentifier"], + fileName=image_metadata["fileName"], + url=image_metadata["url"], + geometry="", + ) + self.tasks[group_id].append(task) + + # list now empty? if usual group size is not reached + # the actual number of tasks for the group is updated + if not self.images: + group.numberOfTasks = i + 1 + break diff --git a/mapswipe_workers/mapswipe_workers/project_types/validate_image/tutorial.py b/mapswipe_workers/mapswipe_workers/project_types/validate_image/tutorial.py new file mode 100644 index 000000000..b42b0be61 --- /dev/null +++ b/mapswipe_workers/mapswipe_workers/project_types/validate_image/tutorial.py @@ -0,0 +1,80 @@ +from dataclasses import dataclass + +from mapswipe_workers.definitions import logger +from mapswipe_workers.firebase.firebase import Firebase +from mapswipe_workers.project_types.tutorial import BaseTutorial +from mapswipe_workers.project_types.validate_image.project import ( + ValidateImageGroup, + ValidateImageTask, +) + + +@dataclass +class ValidateImageTutorialTask(ValidateImageTask): + # TODO(tnagorra): Check if we need projectId and groupId in tutorial task + projectId: str + groupId: str + referenceAnswer: int + screen: int + + +class ValidateImageTutorial(BaseTutorial): + + def __init__(self, tutorial_draft): + # this will create the basis attributes + super().__init__(tutorial_draft) + + self.groups = dict() + self.tasks = dict() + self.images = tutorial_draft["images"] + + def create_tutorial_groups(self): + """Create group for the tutorial based on provided examples in images.""" + + # NOTE: The groupId must be a numeric 101. It's hardcoded in save_tutorial_to_firebase + group = ValidateImageGroup( + groupId=101, + projectId=self.projectId, + numberOfTasks=len(self.images), + progress=0, + finishedCount=0, + requiredCount=0, + ) + self.groups[101] = group + + logger.info( + f"{self.projectId} - create_tutorial_groups - created groups dictionary" + ) + + def create_tutorial_tasks(self): + """Create the tasks dict based on provided examples in geojson file.""" + task_list = [] + for image_metadata in self.images: + image_metadata = ValidateImageTutorialTask( + projectId=self.projectId, + groupId=101, + taskId=image_metadata["sourceIdentifier"], + fileName=image_metadata["fileName"], + url=image_metadata["url"], + geometry="", + referenceAnswer=image_metadata["referenceAnswer"], + screen=image_metadata["screen"], + ) + task_list.append(image_metadata) + + if task_list: + self.tasks[101] = task_list + else: + logger.info(f"group in project {self.projectId} is not valid.") + + logger.info( + f"{self.projectId} - create_tutorial_tasks - created tasks dictionary" + ) + + def save_tutorial(self): + firebase = Firebase() + firebase.save_tutorial_to_firebase( + self, self.groups, self.tasks, useCompression=False + ) + logger.info(self.tutorialDraftId) + firebase.drop_tutorial_draft(self.tutorialDraftId) From c28bea1e833d4c8356033f47bed5a636dea33fbd Mon Sep 17 00:00:00 2001 From: tnagorra Date: Thu, 26 Jun 2025 09:55:50 +0545 Subject: [PATCH 68/76] fix(docker-compose): add mapillary_api_key in tc docker compose --- docker-compose.tc.yaml | 1 + 1 file changed, 1 insertion(+) diff --git a/docker-compose.tc.yaml b/docker-compose.tc.yaml index 210c09570..62475284e 100644 --- a/docker-compose.tc.yaml +++ b/docker-compose.tc.yaml @@ -33,6 +33,7 @@ x-mapswipe-workers: &base_mapswipe_workers SLACK_CHANNEL: '${SLACK_CHANNEL}' SENTRY_DSN: '${SENTRY_DSN}' OSMCHA_API_KEY: '${OSMCHA_API_KEY}' + MAPILLARY_API_KEY: '${MAPILLARY_API_KEY}' depends_on: - postgres volumes: From 3fb2aa213f94e4838c053bd707aa9f7fbe445bc8 Mon Sep 17 00:00:00 2001 From: tnagorra Date: Thu, 26 Jun 2025 09:57:11 +0545 Subject: [PATCH 69/76] feat(validate_image): update form for validate image project & tutorial creation - add io-ts for validation (with typescript types) - fix conditional issue with street project --- .../app/Base/configs/projectTypes.ts | 2 + manager-dashboard/app/Base/styles.css | 1 + .../Calendar/CalendarDate/index.tsx | 2 +- .../app/components/Calendar/index.tsx | 2 +- .../app/components/DateRangeInput/index.tsx | 2 +- .../app/components/InputSection/styles.css | 2 +- manager-dashboard/app/utils/common.tsx | 4 +- .../app/views/NewProject/ImageInput/index.tsx | 73 ++++++ .../views/NewProject/ImageInput/styles.css | 5 + .../app/views/NewProject/index.tsx | 177 +++++++++++++- .../app/views/NewProject/styles.css | 8 + .../app/views/NewProject/utils.ts | 224 +++++++++++++----- .../views/NewTutorial/ImageInput/index.tsx | 92 +++++++ .../views/NewTutorial/ImageInput/styles.css | 5 + .../FootprintGeoJsonPreview/index.tsx | 2 +- .../ValidateImagePreview/index.tsx | 81 +++++++ .../ValidateImagePreview/styles.css | 37 +++ .../NewTutorial/ScenarioPageInput/index.tsx | 42 +++- .../app/views/NewTutorial/index.tsx | 183 ++++++++++++-- .../app/views/NewTutorial/styles.css | 7 + .../app/views/NewTutorial/utils.ts | 156 +++++++++++- manager-dashboard/package.json | 3 + manager-dashboard/yarn.lock | 15 ++ 23 files changed, 1031 insertions(+), 94 deletions(-) create mode 100644 manager-dashboard/app/views/NewProject/ImageInput/index.tsx create mode 100644 manager-dashboard/app/views/NewProject/ImageInput/styles.css create mode 100644 manager-dashboard/app/views/NewTutorial/ImageInput/index.tsx create mode 100644 manager-dashboard/app/views/NewTutorial/ImageInput/styles.css create mode 100644 manager-dashboard/app/views/NewTutorial/ScenarioPageInput/ValidateImagePreview/index.tsx create mode 100644 manager-dashboard/app/views/NewTutorial/ScenarioPageInput/ValidateImagePreview/styles.css diff --git a/manager-dashboard/app/Base/configs/projectTypes.ts b/manager-dashboard/app/Base/configs/projectTypes.ts index e2f7f74eb..e6344d507 100644 --- a/manager-dashboard/app/Base/configs/projectTypes.ts +++ b/manager-dashboard/app/Base/configs/projectTypes.ts @@ -5,6 +5,7 @@ import { PROJECT_TYPE_CHANGE_DETECTION, PROJECT_TYPE_STREET, PROJECT_TYPE_COMPLETENESS, + PROJECT_TYPE_VALIDATE_IMAGE, } from '#utils/common'; const PROJECT_CONFIG_NAME = process.env.REACT_APP_PROJECT_CONFIG_NAME as string; @@ -15,6 +16,7 @@ const mapswipeProjectTypeOptions: { }[] = [ { value: PROJECT_TYPE_BUILD_AREA, label: 'Find' }, { value: PROJECT_TYPE_FOOTPRINT, label: 'Validate' }, + { value: PROJECT_TYPE_VALIDATE_IMAGE, label: 'Validate Image' }, { value: PROJECT_TYPE_CHANGE_DETECTION, label: 'Compare' }, { value: PROJECT_TYPE_STREET, label: 'Street' }, { value: PROJECT_TYPE_COMPLETENESS, label: 'Completeness' }, diff --git a/manager-dashboard/app/Base/styles.css b/manager-dashboard/app/Base/styles.css index c746dc570..87052b68a 100644 --- a/manager-dashboard/app/Base/styles.css +++ b/manager-dashboard/app/Base/styles.css @@ -105,6 +105,7 @@ p { --height-mobile-preview-builarea-content: 30rem; --height-mobile-preview-footprint-content: 22rem; --height-mobile-preview-change-detection-content: 14rem; + --height-mobile-preview-validate-image-content: 22rem; --radius-popup-border: 0.25rem; --radius-scrollbar-border: 0.25rem; diff --git a/manager-dashboard/app/components/Calendar/CalendarDate/index.tsx b/manager-dashboard/app/components/Calendar/CalendarDate/index.tsx index 2b1fed1a0..2520a21f1 100644 --- a/manager-dashboard/app/components/Calendar/CalendarDate/index.tsx +++ b/manager-dashboard/app/components/Calendar/CalendarDate/index.tsx @@ -2,7 +2,7 @@ import React from 'react'; import { _cs } from '@togglecorp/fujs'; import RawButton, { Props as RawButtonProps } from '../../RawButton'; -import { ymdToDateString, typedMemo } from '../../../utils/common.tsx'; +import { ymdToDateString, typedMemo } from '../../../utils/common'; import styles from './styles.css'; diff --git a/manager-dashboard/app/components/Calendar/index.tsx b/manager-dashboard/app/components/Calendar/index.tsx index d72f9054d..21bd7de4f 100644 --- a/manager-dashboard/app/components/Calendar/index.tsx +++ b/manager-dashboard/app/components/Calendar/index.tsx @@ -15,7 +15,7 @@ import Button from '../Button'; import NumberInput from '../NumberInput'; import SelectInput from '../SelectInput'; import useInputState from '../../hooks/useInputState'; -import { typedMemo } from '../../utils/common.tsx'; +import { typedMemo } from '../../utils/common'; import CalendarDate, { Props as CalendarDateProps } from './CalendarDate'; diff --git a/manager-dashboard/app/components/DateRangeInput/index.tsx b/manager-dashboard/app/components/DateRangeInput/index.tsx index 6442fc835..0b25782ee 100644 --- a/manager-dashboard/app/components/DateRangeInput/index.tsx +++ b/manager-dashboard/app/components/DateRangeInput/index.tsx @@ -19,7 +19,7 @@ import Button from '../Button'; import Popup from '../Popup'; import Calendar, { Props as CalendarProps } from '../Calendar'; import CalendarDate, { Props as CalendarDateProps } from '../Calendar/CalendarDate'; -import { ymdToDateString, dateStringToDate } from '../../utils/common.tsx'; +import { ymdToDateString, dateStringToDate } from '../../utils/common'; import { predefinedDateRangeOptions, diff --git a/manager-dashboard/app/components/InputSection/styles.css b/manager-dashboard/app/components/InputSection/styles.css index 0c0012c77..f729ff036 100644 --- a/manager-dashboard/app/components/InputSection/styles.css +++ b/manager-dashboard/app/components/InputSection/styles.css @@ -24,7 +24,7 @@ display: flex; flex-direction: column; border-radius: var(--radius-card-border); - gap: var(--spacing-extra-large); + gap: var(--spacing-large); background-color: var(--color-foreground); padding: var(--spacing-large); min-height: 14rem; diff --git a/manager-dashboard/app/utils/common.tsx b/manager-dashboard/app/utils/common.tsx index 53338d34f..ea4f777fa 100644 --- a/manager-dashboard/app/utils/common.tsx +++ b/manager-dashboard/app/utils/common.tsx @@ -66,8 +66,9 @@ export const PROJECT_TYPE_FOOTPRINT = 2; export const PROJECT_TYPE_CHANGE_DETECTION = 3; export const PROJECT_TYPE_COMPLETENESS = 4; export const PROJECT_TYPE_STREET = 7; +export const PROJECT_TYPE_VALIDATE_IMAGE = 10; -export type ProjectType = 1 | 2 | 3 | 4 | 7; +export type ProjectType = 1 | 2 | 3 | 4 | 7 | 10; export const projectTypeLabelMap: { [key in ProjectType]: string @@ -77,6 +78,7 @@ export const projectTypeLabelMap: { [PROJECT_TYPE_CHANGE_DETECTION]: 'Compare', [PROJECT_TYPE_COMPLETENESS]: 'Completeness', [PROJECT_TYPE_STREET]: 'Street', + [PROJECT_TYPE_VALIDATE_IMAGE]: 'Validate Image', }; export type IconKey = 'add-outline' diff --git a/manager-dashboard/app/views/NewProject/ImageInput/index.tsx b/manager-dashboard/app/views/NewProject/ImageInput/index.tsx new file mode 100644 index 000000000..93f4dcde2 --- /dev/null +++ b/manager-dashboard/app/views/NewProject/ImageInput/index.tsx @@ -0,0 +1,73 @@ +import React from 'react'; + +import { + SetValueArg, + Error, + useFormObject, + getErrorObject, +} from '@togglecorp/toggle-form'; +import TextInput from '#components/TextInput'; + +import { + ImageType, +} from '../utils'; + +import styles from './styles.css'; + +const defaultImageValue: ImageType = { + sourceIdentifier: '', +}; + +interface Props { + value: ImageType; + onChange: (value: SetValueArg, index: number) => void | undefined; + index: number; + error: Error | undefined; + disabled?: boolean; + readOnly?: boolean; +} + +export default function ImageInput(props: Props) { + const { + value, + onChange, + index, + error: riskyError, + disabled, + readOnly, + } = props; + + const onImageChange = useFormObject(index, onChange, defaultImageValue); + + const error = getErrorObject(riskyError); + + return ( +