From f54b30a954969a0ef8250515236779f16a9ed6a8 Mon Sep 17 00:00:00 2001 From: Sam <78538841+spwoodcock@users.noreply.github.com> Date: Thu, 22 Feb 2024 15:19:17 +0100 Subject: [PATCH] feat: flatgeobuf data extracts on frontend & backend (#1241) * refactor: add data_extract_url to ProjectOut schema * feat: load remote flatgeobuf data extracts from S3 * fix(frontend): correctly load nested fgb GeometryCollection type * feat: read/write flatgeobuf, split geoms by task in database * feat: split fgb extract by t ask, generate geojson form media * refactor: deletion comment for frontend files not required * build: remove features table from db * feat: allow uploading of custom data extracts in fgb format * test: update tests to use flatgeobuf data extracts * refactor: rename var for clarity * ci: minify backend test data + ignore from prettier --- .pre-commit-config.yaml | 1 + src/backend/app/central/central_crud.py | 26 +- src/backend/app/db/db_models.py | 28 +- src/backend/app/db/postgis_utils.py | 195 ++++++--- src/backend/app/projects/project_crud.py | 411 +++++++----------- src/backend/app/projects/project_routes.py | 140 +++--- src/backend/app/projects/project_schemas.py | 1 + src/backend/app/tasks/tasks_crud.py | 100 ----- src/backend/app/tasks/tasks_routes.py | 23 +- src/backend/migrations/005-remove-qrcode.sql | 2 + .../migrations/010-drop-features-table.sql | 13 + .../migrations/init/fmtm_base_schema.sql | 35 -- .../revert/010-drop-features-table.sql | 40 ++ src/backend/tests/conftest.py | 29 +- .../test_data/data_extract_kathmandu.fgb | Bin 0 -> 223728 bytes .../test_data/data_extract_kathmandu.geojson | 1 + src/backend/tests/test_projects_routes.py | 105 ++++- src/frontend/src/api/Project.js | 1 + src/frontend/src/api/SubmissionService.ts | 19 +- .../OrganizationDetailsValidation.ts | 11 +- .../OpenLayersComponent/Layers/VectorLayer.js | 155 ++++++- .../components/ProjectInfo/ProjectInfomap.jsx | 15 +- .../src/components/ProjectMap/ProjectMap.jsx | 102 ----- .../ProjectSubmissions/SubmissionsTable.tsx | 2 +- .../ProjectSubmissions/TaskSubmissionsMap.tsx | 54 +-- .../SubmissionMap/SubmissionMap.jsx | 64 --- .../src/components/TasksMap/TasksMap.jsx | 67 --- .../validation/CreateProjectValidation.tsx | 11 +- .../Validation/OrganisationAddValidation.tsx | 11 +- src/frontend/src/store/slices/ProjectSlice.ts | 9 +- src/frontend/src/utilfunctions/urlChecker.ts | 8 + src/frontend/src/views/NewProjectDetails.jsx | 87 ++-- src/frontend/src/views/ProjectDetails.jsx | 23 +- src/frontend/src/views/ProjectDetailsV2.tsx | 51 +-- src/frontend/src/views/ProjectInfo.tsx | 19 +- src/frontend/src/views/Submissions.tsx | 5 +- src/frontend/src/views/Tasks.tsx | 25 +- 37 files changed, 812 insertions(+), 1077 deletions(-) create mode 100644 src/backend/migrations/010-drop-features-table.sql create mode 100644 src/backend/migrations/revert/010-drop-features-table.sql create mode 100644 src/backend/tests/test_data/data_extract_kathmandu.fgb create mode 100644 src/backend/tests/test_data/data_extract_kathmandu.geojson delete mode 100644 src/frontend/src/components/ProjectMap/ProjectMap.jsx delete mode 100644 src/frontend/src/components/SubmissionMap/SubmissionMap.jsx delete mode 100644 src/frontend/src/components/TasksMap/TasksMap.jsx create mode 100644 src/frontend/src/utilfunctions/urlChecker.ts diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index 04dd7cd7d0..f5fb28b24b 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -33,6 +33,7 @@ repos: "!CONTRIBUTING.md", "!LICENSE.md", "!src/frontend/pnpm-lock.yaml", + "!src/backend/tests/test_data/**", ] # # Lint: Dockerfile (disabled until binary is bundled) diff --git a/src/backend/app/central/central_crud.py b/src/backend/app/central/central_crud.py index e1f3b693e2..95de3a81dd 100644 --- a/src/backend/app/central/central_crud.py +++ b/src/backend/app/central/central_crud.py @@ -18,11 +18,11 @@ """Logic for interaction with ODK Central & data.""" import os +from io import BytesIO +from pathlib import Path from typing import Optional from xml.etree import ElementTree -# import osm_fieldwork -# Qr code imports from fastapi import HTTPException from fastapi.responses import JSONResponse from loguru import logger as log @@ -210,11 +210,11 @@ def upload_xform_media( def create_odk_xform( project_id: int, - xform_id: str, + xform_name: str, filespec: str, + feature_geojson: BytesIO, odk_credentials: Optional[project_schemas.ODKCentralDecrypted] = None, create_draft: bool = False, - upload_media=True, convert_to_draft_when_publishing=True, ): """Create an XForm on a remote ODK Central server.""" @@ -236,16 +236,26 @@ def create_odk_xform( status_code=500, detail={"message": "Connection failed to odk central"} ) from e - result = xform.createForm(project_id, xform_id, filespec, create_draft) + result = xform.createForm(project_id, xform_name, filespec, create_draft) if result != 200 and result != 409: return result - data = f"/tmp/{title}.geojson" + + # TODO refactor osm_fieldwork.OdkCentral.OdkForm.uploadMedia + # to accept passing a bytesio object and update + geojson_file = Path(f"/tmp/{title}.geojson") + with open(geojson_file, "w") as f: + f.write(feature_geojson.getvalue().decode("utf-8")) # This modifies an existing published XForm to be in draft mode. # An XForm must be in draft mode to upload an attachment. - if upload_media: - xform.uploadMedia(project_id, title, data, convert_to_draft_when_publishing) + # Upload the geojson of features to be modified + xform.uploadMedia( + project_id, title, str(geojson_file), convert_to_draft_when_publishing + ) + + # Delete temp geojson file + geojson_file.unlink(missing_ok=True) result = xform.publishForm(project_id, title) return result diff --git a/src/backend/app/db/db_models.py b/src/backend/app/db/db_models.py index ba20b9b8eb..9f45c264ab 100644 --- a/src/backend/app/db/db_models.py +++ b/src/backend/app/db/db_models.py @@ -40,7 +40,7 @@ desc, ) from sqlalchemy.dialects.postgresql import ARRAY as PostgreSQLArray # noqa: N811 -from sqlalchemy.dialects.postgresql import JSONB, TSVECTOR +from sqlalchemy.dialects.postgresql import TSVECTOR from sqlalchemy.orm import ( # declarative_base, backref, @@ -691,32 +691,6 @@ class DbLicense(Base): ) # Many to Many relationship -class DbFeatures(Base): - """Features extracted from osm data.""" - - __tablename__ = "features" - - id = cast(int, Column(Integer, primary_key=True)) - project_id = cast(int, Column(Integer, ForeignKey("projects.id"))) - project = cast(DbProject, relationship(DbProject, backref="features")) - - category_title = cast( - str, Column(String, ForeignKey("xlsforms.title", name="fk_xform")) - ) - category = cast(DbXForm, relationship(DbXForm)) - task_id = cast(int, Column(Integer, nullable=True)) - properties = cast(dict, Column(JSONB)) - geometry = cast(WKBElement, Column(Geometry(geometry_type="GEOMETRY", srid=4326))) - - __table_args__ = ( - ForeignKeyConstraint( - [task_id, project_id], ["tasks.id", "tasks.project_id"], name="fk_tasks" - ), - Index("idx_features_composite", "task_id", "project_id"), - {}, - ) - - class BackgroundTasks(Base): """Table managing long running background tasks.""" diff --git a/src/backend/app/db/postgis_utils.py b/src/backend/app/db/postgis_utils.py index dbd9322ddd..a9084c7c30 100644 --- a/src/backend/app/db/postgis_utils.py +++ b/src/backend/app/db/postgis_utils.py @@ -116,6 +116,9 @@ async def geojson_to_flatgeobuf( ) -> Optional[bytes]: """From a given FeatureCollection, return a memory flatgeobuf obj. + NOTE this generate an fgb with string timestamps, not datetime. + NOTE ogr2ogr would generate datetime, but parsing does not seem to work. + Args: db (Session): SQLAlchemy db session. geojson (geojson.FeatureCollection): a FeatureCollection object. @@ -123,67 +126,41 @@ async def geojson_to_flatgeobuf( Returns: flatgeobuf (bytes): a Python bytes representation of a flatgeobuf file. """ - # FIXME make this with with properties / tags - # FIXME this is important - # FIXME but difficult to guarantee users upload geojson - # FIXME With required properties included - # sql = """ - # DROP TABLE IF EXISTS public.temp_features CASCADE; - - # CREATE TABLE IF NOT EXISTS public.temp_features( - # geom geometry, - # osm_id integer, - # changeset integer, - # timestamp timestamp - # ); - - # WITH data AS (SELECT CAST(:geojson AS json) AS fc) - # INSERT INTO public.temp_features (geom, osm_id, changeset, timestamp) - # SELECT - # ST_SetSRID(ST_GeomFromGeoJSON(feat->>'geometry'), 4326) AS geom, - # COALESCE((feat->'properties'->>'osm_id')::integer, -1) AS osm_id, - # COALESCE((feat->'properties'->>'changeset')::integer, -1) AS changeset, - # CASE - # WHEN feat->'properties'->>'timestamp' IS NOT NULL - # THEN (feat->'properties'->>'timestamp')::timestamp - # ELSE NULL - # END AS timestamp - # FROM ( - # SELECT json_array_elements(fc->'features') AS feat - # FROM data - # ) AS f; - - # -- Second param = generate with spatial index - # SELECT ST_AsFlatGeobuf(geoms, true) - # FROM (SELECT * FROM public.temp_features) AS geoms; - # """ sql = """ - DROP TABLE IF EXISTS public.temp_features CASCADE; - - CREATE TABLE IF NOT EXISTS public.temp_features( - geom geometry + DROP TABLE IF EXISTS temp_features CASCADE; + + -- Wrap geometries in GeometryCollection + CREATE TEMP TABLE IF NOT EXISTS temp_features( + geom geometry(GeometryCollection, 4326), + osm_id integer, + tags text, + version integer, + changeset integer, + timestamp text ); WITH data AS (SELECT CAST(:geojson AS json) AS fc) - INSERT INTO public.temp_features (geom) + INSERT INTO temp_features + (geom, osm_id, tags, version, changeset, timestamp) SELECT - ST_SetSRID(ST_GeomFromGeoJSON(feat->>'geometry'), 4326) AS geom - FROM ( - SELECT json_array_elements(fc->'features') AS feat - FROM data - ) AS f; - - SELECT ST_AsFlatGeobuf(fgb_data, true) - FROM (SELECT * FROM public.temp_features as geoms) AS fgb_data; + ST_ForceCollection(ST_GeomFromGeoJSON(feat->>'geometry')) AS geom, + (feat->'properties'->>'osm_id')::integer as osm_id, + (feat->'properties'->>'tags')::text as tags, + (feat->'properties'->>'version')::integer as version, + (feat->'properties'->>'changeset')::integer as changeset, + (feat->'properties'->>'timestamp')::text as timestamp + FROM json_array_elements((SELECT fc->'features' FROM data)) AS f(feat); + + -- Second param = generate with spatial index + SELECT ST_AsFlatGeobuf(geoms, true) + FROM (SELECT * FROM temp_features) AS geoms; """ + # Run the SQL result = db.execute(text(sql), {"geojson": json.dumps(geojson)}) # Get a memoryview object, then extract to Bytes flatgeobuf = result.first() - # Cleanup table - # db.execute(text("DROP TABLE IF EXISTS public.temp_features CASCADE;")) - if flatgeobuf: return flatgeobuf[0].tobytes() @@ -196,6 +173,8 @@ async def flatgeobuf_to_geojson( ) -> Optional[geojson.FeatureCollection]: """Converts FlatGeobuf data to GeoJSON. + Extracts single geometries from wrapped GeometryCollection if used. + Args: db (Session): SQLAlchemy db session. flatgeobuf (bytes): FlatGeobuf data in bytes format. @@ -203,7 +182,6 @@ async def flatgeobuf_to_geojson( Returns: geojson.FeatureCollection: A FeatureCollection object. """ - # FIXME can we use SELECT * to extract all fields into geojson properties? sql = text( """ DROP TABLE IF EXISTS public.temp_fgb CASCADE; @@ -217,24 +195,23 @@ async def flatgeobuf_to_geojson( FROM ( SELECT jsonb_build_object( 'type', 'Feature', - 'geometry', ST_AsGeoJSON(fgb_data.geom)::jsonb, + 'geometry', ST_AsGeoJSON(ST_GeometryN(fgb_data.geom, 1))::jsonb, 'properties', jsonb_build_object( 'osm_id', fgb_data.osm_id, 'tags', fgb_data.tags, 'version', fgb_data.version, 'changeset', fgb_data.changeset, 'timestamp', fgb_data.timestamp - )::jsonb ) AS feature FROM ( SELECT geom, - NULL as osm_id, - NULL as tags, - NULL as version, - NULL as changeset, - NULL as timestamp + osm_id, + tags, + version, + changeset, + timestamp FROM ST_FromFlatGeobuf(null::temp_fgb, :fgb_bytes) ) AS fgb_data ) AS features; @@ -247,7 +224,8 @@ async def flatgeobuf_to_geojson( except ProgrammingError as e: log.error(e) log.error( - "Attempted flatgeobuf --> geojson conversion, but duplicate column found" + "Attempted flatgeobuf --> geojson conversion failed. " + "Perhaps there is a duplicate 'id' column?" ) return None @@ -257,6 +235,105 @@ async def flatgeobuf_to_geojson( return None +async def split_geojson_by_task_areas( + db: Session, + featcol: geojson.FeatureCollection, + project_id: int, +) -> Optional[dict[int, geojson.FeatureCollection]]: + """Split GeoJSON into tagged task area GeoJSONs. + + Args: + db (Session): SQLAlchemy db session. + featcol (bytes): Data extract feature collection. + project_id (int): The project ID for associated tasks. + + Returns: + dict[int, geojson.FeatureCollection]: {task_id: FeatureCollection} mapping. + """ + sql = text( + """ + -- Drop table if already exists + DROP TABLE IF EXISTS temp_features CASCADE; + + -- Create a temporary table to store the parsed GeoJSON features + CREATE TEMP TABLE temp_features ( + id SERIAL PRIMARY KEY, + geometry GEOMETRY, + properties JSONB + ); + + -- Insert parsed geometries and properties into the temporary table + INSERT INTO temp_features (geometry, properties) + SELECT + ST_SetSRID(ST_GeomFromGeoJSON(feature->>'geometry'), 4326) AS geometry, + jsonb_set( + jsonb_set(feature->'properties', '{task_id}', to_jsonb(tasks.id), true), + '{project_id}', to_jsonb(tasks.project_id), true + ) AS properties + FROM ( + SELECT jsonb_array_elements(CAST(:geojson_featcol AS jsonb)->'features') + AS feature + ) AS features + CROSS JOIN tasks + WHERE tasks.project_id = :project_id; + + -- Retrieve task outlines based on the provided project_id + WITH task_outlines AS ( + SELECT id, outline + FROM tasks + WHERE project_id = :project_id + ) + SELECT + task_outlines.id AS task_id, + jsonb_build_object( + 'type', 'FeatureCollection', + 'features', jsonb_agg(features.feature) + ) AS task_features + FROM + task_outlines + LEFT JOIN LATERAL ( + -- Construct a feature collection with geometries per task area + SELECT + jsonb_build_object( + 'type', 'Feature', + 'geometry', ST_AsGeoJSON(temp_features.geometry)::jsonb, + 'properties', temp_features.properties + ) AS feature + FROM + temp_features + WHERE + ST_Within(temp_features.geometry, task_outlines.outline) + ) AS features ON true + GROUP BY + task_outlines.id; + """ + ) + + try: + result = db.execute( + sql, + { + "geojson_featcol": json.dumps(featcol), + "project_id": project_id, + }, + ) + feature_collections = result.all() + + except ProgrammingError as e: + log.error(e) + log.error("Attempted geojson task splitting failed") + return None + + if feature_collections: + task_geojson_dict = { + record[0]: geojson.loads(json.dumps(record[1])) + for record in feature_collections + } + return task_geojson_dict + + return None + + def parse_and_filter_geojson( geojson_str: str, filter: bool = True ) -> Optional[geojson.FeatureCollection]: diff --git a/src/backend/app/projects/project_crud.py b/src/backend/app/projects/project_crud.py index 043adbcbf7..45d43d7e09 100644 --- a/src/backend/app/projects/project_crud.py +++ b/src/backend/app/projects/project_crud.py @@ -35,7 +35,7 @@ from fastapi import File, HTTPException, Response, UploadFile from fastapi.concurrency import run_in_threadpool from fmtm_splitter.splitter import split_by_sql, split_by_square -from geoalchemy2.shape import from_shape, to_shape +from geoalchemy2.shape import to_shape from geojson.feature import Feature, FeatureCollection from loguru import logger as log from osm_fieldwork.basemapper import create_basemap_file @@ -64,6 +64,7 @@ get_address_from_lat_lon_async, get_featcol_main_geom_type, parse_and_filter_geojson, + split_geojson_by_task_areas, ) from app.models.enums import HTTPStatus, ProjectRole from app.projects import project_schemas @@ -483,6 +484,7 @@ async def generate_data_extract( "outputType": "fgb", "bind_zip": False, "useStWithin": False, + "fgb_wrap_geoms": True, }, ) @@ -938,17 +940,19 @@ async def update_data_extract_url_in_db( db.commit() -async def upload_custom_data_extract( +async def upload_custom_extract_to_s3( db: Session, project_id: int, - geojson_str: str, + fgb_content: bytes, + data_extract_type: str, ) -> str: """Uploads custom data extracts to S3. Args: db (Session): SQLAlchemy database session. project_id (int): The ID of the project. - geojson_str (str): The custom data extracts contents. + fgb_content (bytes): Content of read flatgeobuf file. + data_extract_type (str): centroid/polygon/line for database. Returns: str: URL to fgb file in S3. @@ -959,16 +963,62 @@ async def upload_custom_data_extract( if not project: raise HTTPException(status_code=404, detail="Project not found") - featcol_filtered = parse_and_filter_geojson(geojson_str) - if not featcol_filtered: - raise HTTPException( - status_code=HTTPStatus.UNPROCESSABLE_ENTITY, - detail="Could not process geojson input", - ) - await check_crs(featcol_filtered) + fgb_obj = BytesIO(fgb_content) + s3_fgb_path = f"/{project.organisation_id}/{project_id}/custom_extract.fgb" + + log.debug(f"Uploading fgb to S3 path: {s3_fgb_path}") + add_obj_to_bucket( + settings.S3_BUCKET_NAME, + fgb_obj, + s3_fgb_path, + content_type="application/octet-stream", + ) + + # Add url and type to database + s3_fgb_full_url = ( + f"{settings.S3_DOWNLOAD_ROOT}/{settings.S3_BUCKET_NAME}{s3_fgb_path}" + ) - # Get geom type from data extract - geom_type = get_featcol_main_geom_type(featcol_filtered) + await update_data_extract_url_in_db(db, project, s3_fgb_full_url, data_extract_type) + + return s3_fgb_full_url + + +async def upload_custom_fgb_extract( + db: Session, + project_id: int, + fgb_content: bytes, +) -> str: + """Upload a flatgeobuf data extract. + + Args: + db (Session): SQLAlchemy database session. + project_id (int): The ID of the project. + fgb_content (bytes): Content of read flatgeobuf file. + + Returns: + str: URL to fgb file in S3. + """ + featcol = await flatgeobuf_to_geojson(db, fgb_content) + + if not featcol: + msg = f"Failed extracting geojson from flatgeobuf for project ({project_id})" + log.error(msg) + raise HTTPException(status_code=HTTPStatus.UNPROCESSABLE_ENTITY, detail=msg) + + data_extract_type = await get_data_extract_type(featcol) + + return await upload_custom_extract_to_s3( + db, + project_id, + fgb_content, + data_extract_type, + ) + + +async def get_data_extract_type(featcol: FeatureCollection) -> str: + """Determine predominant geometry type for extract.""" + geom_type = get_featcol_main_geom_type(featcol) if geom_type not in ["Polygon", "Polyline", "Point"]: msg = ( "Extract does not contain valid geometry types, from 'Polygon' " @@ -983,30 +1033,56 @@ async def upload_custom_data_extract( } data_extract_type = geom_name_map.get(geom_type, "polygon") + return data_extract_type + + +async def upload_custom_geojson_extract( + db: Session, + project_id: int, + geojson_str: str, +) -> str: + """Upload a geojson data extract. + + Args: + db (Session): SQLAlchemy database session. + project_id (int): The ID of the project. + geojson_str (str): The custom data extracts contents. + + Returns: + str: URL to fgb file in S3. + """ + project = await get_project(db, project_id) + log.debug(f"Uploading custom data extract for project: {project}") + + if not project: + raise HTTPException(status_code=404, detail="Project not found") + + featcol_filtered = parse_and_filter_geojson(geojson_str) + if not featcol_filtered: + raise HTTPException( + status_code=HTTPStatus.UNPROCESSABLE_ENTITY, + detail="Could not process geojson input", + ) + + await check_crs(featcol_filtered) + + data_extract_type = await get_data_extract_type(featcol_filtered) + log.debug( "Generating fgb object from geojson with " f"{len(featcol_filtered.get('features', []))} features" ) - fgb_obj = BytesIO(await geojson_to_flatgeobuf(db, featcol_filtered)) - s3_fgb_path = f"/{project.organisation_id}/{project_id}/custom_extract.fgb" + fgb_data = await geojson_to_flatgeobuf(db, featcol_filtered) - log.debug(f"Uploading fgb to S3 path: {s3_fgb_path}") - add_obj_to_bucket( - settings.S3_BUCKET_NAME, - fgb_obj, - s3_fgb_path, - content_type="application/octet-stream", - ) + if not fgb_data: + msg = f"Failed converting geojson to flatgeobuf for project ({project_id})" + log.error(msg) + raise HTTPException(status_code=HTTPStatus.UNPROCESSABLE_ENTITY, detail=msg) - # Add url and type to database - s3_fgb_full_url = ( - f"{settings.S3_DOWNLOAD_ROOT}/{settings.S3_BUCKET_NAME}{s3_fgb_path}" + return await upload_custom_extract_to_s3( + db, project_id, fgb_data, data_extract_type ) - await update_data_extract_url_in_db(db, project, s3_fgb_full_url, data_extract_type) - - return s3_fgb_full_url - def flatten_dict(d, parent_key="", sep="_"): """Recursively flattens a nested dictionary into a single-level dictionary. @@ -1034,6 +1110,7 @@ def generate_task_files( db: Session, project_id: int, task_id: int, + data_extract: FeatureCollection, xlsform: str, form_type: str, odk_credentials: project_schemas.ODKCentralDecrypted, @@ -1081,71 +1158,31 @@ def generate_task_files( # This file will store xml contents of an xls form. xform = f"/tmp/{appuser_name}.xml" - extracts = f"/tmp/{appuser_name}.geojson" # This file will store osm extracts # xform_id_format xform_id = f"{appuser_name}".split("_")[2] - # Get the features for this task. - # Postgis query to filter task inside this task outline and of this project - # Update those features and set task_id - query = text( - f"""UPDATE features - SET task_id={task_id} - WHERE id IN ( - SELECT id - FROM features - WHERE project_id={project_id} - AND ST_IsValid(geometry) - AND ST_IsValid('{task.outline}'::Geometry) - AND ST_Contains('{task.outline}'::Geometry, ST_Centroid(geometry)) - )""" - ) - - result = db.execute(query) - - # Get the geojson of those features for this task. - query = text( - f"""SELECT jsonb_build_object( - 'type', 'FeatureCollection', - 'features', jsonb_agg(feature) - ) - FROM ( - SELECT jsonb_build_object( - 'type', 'Feature', - 'id', id, - 'geometry', ST_AsGeoJSON(geometry)::jsonb, - 'properties', properties - ) AS feature - FROM features - WHERE project_id={project_id} and task_id={task_id} - ) features;""" - ) - - result = db.execute(query) - - features = result.fetchone()[0] - - upload_media = False if features["features"] is None else True - - # Update outfile containing osm extracts with the new geojson contents - # containing title in the properties. - with open(extracts, "w") as jsonfile: - jsonfile.truncate(0) # clear the contents of the file - geojson.dump(features, jsonfile) + # Create memory object from split data extract + geojson_string = geojson.dumps(data_extract) + geojson_bytes = geojson_string.encode("utf-8") + geojson_bytesio = BytesIO(geojson_bytes) project_log.info( - f"Generating xform for task: {task_id} " + f"Generating xform for task: {task_id} | " f"using xform: {xform} | form_type: {form_type}" ) - outfile = central_crud.generate_updated_xform(xlsform, xform, form_type) + xform_path = central_crud.generate_updated_xform(xlsform, xform, form_type) # Create an odk xform - project_log.info(f"Uploading media in {task_id}") - result = central_crud.create_odk_xform( - odk_id, task_id, outfile, odk_credentials, False, upload_media + project_log.info(f"Uploading data extract media to task ({task_id})") + central_crud.create_odk_xform( + odk_id, + str(task_id), + xform_path, + geojson_bytesio, + odk_credentials, + False, ) - # result = central_crud.create_odk_xform(odk_id, task_id, outfile, odk_credentials) project_log.info(f"Updating role for app user in task {task_id}") # Update the user role for the created xform. @@ -1164,7 +1201,7 @@ def generate_task_files( # NOTE defined as non-async to run in separate thread -def generate_appuser_files( +def generate_project_files( db: Session, project_id: int, custom_form: Optional[BytesIO], @@ -1187,7 +1224,7 @@ def generate_appuser_files( try: project_log = log.bind(task="create_project", project_id=project_id) - project_log.info(f"Starting generate_appuser_files for project {project_id}") + project_log.info(f"Starting generate_project_files for project {project_id}") get_project_sync = async_to_sync(get_project) project = get_project_sync(db, project_id) @@ -1232,39 +1269,26 @@ def generate_appuser_files( # else updated_data_extract # ) - # FIXME do we need these geoms in the db? - # FIXME can we remove this section? - log.debug("Adding data extract geometries to database") - get_extract_geojson_sync = async_to_sync(get_project_features_geojson) - data_extract_geojson = get_extract_geojson_sync(db, project_id) - # Collect feature mappings for bulk insert - feature_mappings = [] - for feature in data_extract_geojson["features"]: - # If the osm extracts contents do not have a title, - # provide an empty text for that. - properties = feature.get("properties", {}) - properties["title"] = "" - - feature_shape = shape(feature["geometry"]) - - wkb_element = from_shape(feature_shape, srid=4326) - feature_mapping = { - "project_id": project_id, - "category_title": form_category, - "geometry": wkb_element, - "properties": properties, - } - feature_mappings.append(feature_mapping) - # Bulk insert the osm extracts into the db. - db.bulk_insert_mappings(db_models.DbFeatures, feature_mappings) - # Generating QR Code, XForm and uploading OSM Extracts to the form. # Creating app users and updating the role of that user. - get_task_id_list_sync = async_to_sync(tasks_crud.get_task_id_list) - task_list = get_task_id_list_sync(db, project_id) + + # Extract data extract from flatgeobuf + feat_project_features_sync = async_to_sync(get_project_features_geojson) + feature_collection = feat_project_features_sync(db, project) + + # Split extract by task area + split_geojson_sync = async_to_sync(split_geojson_by_task_areas) + split_extract_dict = split_geojson_sync(db, feature_collection, project_id) + + if not split_extract_dict: + log.warning("Project ({project_id}) failed splitting tasks") + raise HTTPException( + status_code=HTTPStatus.UNPROCESSABLE_ENTITY, + detail="Failed splitting extract by tasks.", + ) # Run with expensive task via threadpool - def wrap_generate_task_files(task): + def wrap_generate_task_files(task_id): """Func to wrap and return errors from thread. Also passes it's own database session for thread safety. @@ -1275,7 +1299,8 @@ def wrap_generate_task_files(task): generate_task_files( next(get_db()), project_id, - task, + task_id, + split_extract_dict[task_id], xlsform, form_format, odk_credentials, @@ -1287,7 +1312,8 @@ def wrap_generate_task_files(task): with ThreadPoolExecutor() as executor: # Submit tasks to the thread pool futures = [ - executor.submit(wrap_generate_task_files, task) for task in task_list + executor.submit(wrap_generate_task_files, task_id) + for task_id in split_extract_dict.keys() ] # Wait for all tasks to complete wait(futures) @@ -1562,64 +1588,6 @@ async def convert_summary(project): return [] -async def convert_to_project_feature(db_project_feature: db_models.DbFeatures): - """Legacy function to convert db models --> Pydantic. - - TODO refactor to use Pydantic model methods instead. - """ - if db_project_feature: - app_project_feature: project_schemas.GeojsonFeature = db_project_feature - - if db_project_feature.geometry: - app_project_feature.geometry = geometry_to_geojson( - db_project_feature.geometry, - db_project_feature.properties, - db_project_feature.id, - ) - - return app_project_feature - else: - return None - - -async def convert_to_project_features( - db_project_features: List[db_models.DbFeatures], -) -> List[project_schemas.GeojsonFeature]: - """Legacy function to convert db models --> Pydantic. - - TODO refactor to use Pydantic model methods instead. - """ - if db_project_features and len(db_project_features) > 0: - - async def convert_feature(project_feature): - return await convert_to_project_feature(project_feature) - - app_project_features = await gather( - *[convert_feature(feature) for feature in db_project_features] - ) - return [feature for feature in app_project_features if feature is not None] - else: - return [] - - -async def get_project_features(db: Session, project_id: int, task_id: int = None): - """Get features from database for a project.""" - if task_id: - features = ( - db.query(db_models.DbFeatures) - .filter(db_models.DbFeatures.project_id == project_id) - .filter(db_models.DbFeatures.task_id == task_id) - .all() - ) - else: - features = ( - db.query(db_models.DbFeatures) - .filter(db_models.DbFeatures.project_id == project_id) - .all() - ) - return await convert_to_project_features(features) - - async def get_background_task_status(task_id: uuid.UUID, db: Session): """Get the status of a background task.""" task = ( @@ -1710,12 +1678,7 @@ async def update_project_form( else: xlsform = f"{xlsforms_path}/{category}.xls" - db.query(db_models.DbFeatures).filter( - db_models.DbFeatures.project_id == project_id - ).delete() - db.commit() - - # OSM Extracts for whole project + # TODO fix this to use correct data extract generation pg = PostgresClient("underpass") outfile = ( f"/tmp/{project_title}_{category}.geojson" # This file will store osm extracts @@ -1737,112 +1700,34 @@ async def update_project_form( final_outline = json.loads(project_outline.outline) - outline_geojson = pg.getFeatures( + feature_geojson = pg.getFeatures( boundary=final_outline, filespec=outfile, polygon=extract_polygon, xlsfile=f"{category}.xls", category=category, ) - - updated_outline_geojson = {"type": "FeatureCollection", "features": []} - - # Collect feature mappings for bulk insert - feature_mappings = [] - - for feature in outline_geojson["features"]: - # If the osm extracts contents do not have a title, - # provide an empty text for that. - feature["properties"]["title"] = "" - - feature_shape = shape(feature["geometry"]) - - # # If the centroid of the Polygon is not inside the outline, - # skip the feature. - # if extract_polygon and ( - # not shape(outline_geojson).contains( - # shape(feature_shape.centroid - # )) - # ): - # continue - - wkb_element = from_shape(feature_shape, srid=4326) - feature_mapping = { - "project_id": project_id, - "category_title": category, - "geometry": wkb_element, - "properties": feature["properties"], - } - updated_outline_geojson["features"].append(feature) - feature_mappings.append(feature_mapping) - - # Insert features into db - db_feature = db_models.DbFeatures( - project_id=project_id, - category_title=category, - geometry=wkb_element, - properties=feature["properties"], - ) - db.add(db_feature) - db.commit() + # TODO upload data extract to S3 bucket tasks_list = await tasks_crud.get_task_id_list(db, project_id) for task in tasks_list: - task_obj = await tasks_crud.get_task(db, task) - - # Get the features for this task. - # Postgis query to filter task inside this task outline and of this project - # Update those features and set task_id - query = text( - f"""UPDATE features - SET task_id={task} - WHERE id in ( - SELECT id - FROM features - WHERE project_id={project_id} and - ST_Intersects(geometry, '{task_obj.outline}'::Geometry) - )""" - ) - - result = db.execute(query) - - # Get the geojson of those features for this task. - query = text( - f"""SELECT jsonb_build_object( - 'type', 'FeatureCollection', - 'features', jsonb_agg(feature) - ) - FROM ( - SELECT jsonb_build_object( - 'type', 'Feature', - 'id', id, - 'geometry', ST_AsGeoJSON(geometry)::jsonb, - 'properties', properties - ) AS feature - FROM features - WHERE project_id={project_id} and task_id={task} - ) features;""" - ) - - result = db.execute(query) - features = result.fetchone()[0] # This file will store xml contents of an xls form. xform = f"/tmp/{project_title}_{category}_{task}.xml" - # This file will store osm extracts extracts = f"/tmp/{project_title}_{category}_{task}.geojson" # Update outfile containing osm extracts with the new geojson contents # containing title in the properties. with open(extracts, "w") as jsonfile: jsonfile.truncate(0) # clear the contents of the file - geojson.dump(features, jsonfile) + geojson.dump(feature_geojson, jsonfile) outfile = central_crud.generate_updated_xform(xlsform, xform, form_type) # Create an odk xform + # TODO include data extract geojson correctly result = central_crud.create_odk_xform( - odk_id, task, xform, odk_credentials, True, True, False + odk_id, str(task), xform, feature_geojson, odk_credentials, True, False ) return True diff --git a/src/backend/app/projects/project_routes.py b/src/backend/app/projects/project_routes.py index 69e4808ce2..fffae2d850 100644 --- a/src/backend/app/projects/project_routes.py +++ b/src/backend/app/projects/project_routes.py @@ -76,53 +76,54 @@ async def read_projects( return projects -@router.get("/details/{project_id}/") -async def get_projet_details( - project_id: int, - db: Session = Depends(database.get_db), - current_user: AuthUser = Depends(mapper), -): - """Returns the project details. - - Also includes ODK project details, so takes extra time to return. - - Parameters: - project_id: int - - Returns: - Response: Project details. - """ - project = await project_crud.get_project(db, project_id) - if not project: - raise HTTPException(status_code=404, detail={"Project not found"}) - - # ODK Credentials - odk_credentials = project_schemas.ODKCentralDecrypted( - odk_central_url=project.odk_central_url, - odk_central_user=project.odk_central_user, - odk_central_password=project.odk_central_password, - ) - - odk_details = central_crud.get_odk_project_full_details( - project.odkid, odk_credentials - ) - - # Features count - query = text( - "select count(*) from features where " - f"project_id={project_id} and task_id is not null" - ) - result = db.execute(query) - features = result.fetchone()[0] - - return { - "id": project_id, - "odkName": odk_details["name"], - "createdAt": odk_details["createdAt"], - "tasks": odk_details["forms"], - "lastSubmission": odk_details["lastSubmission"], - "total_features": features, - } +# TODO delete me +# @router.get("/details/{project_id}/") +# async def get_projet_details( +# project_id: int, +# db: Session = Depends(database.get_db), +# current_user: AuthUser = Depends(mapper), +# ): +# """Returns the project details. + +# Also includes ODK project details, so takes extra time to return. + +# Parameters: +# project_id: int + +# Returns: +# Response: Project details. +# """ +# project = await project_crud.get_project(db, project_id) +# if not project: +# raise HTTPException(status_code=404, detail={"Project not found"}) + +# # ODK Credentials +# odk_credentials = project_schemas.ODKCentralDecrypted( +# odk_central_url=project.odk_central_url, +# odk_central_user=project.odk_central_user, +# odk_central_password=project.odk_central_password, +# ) + +# odk_details = central_crud.get_odk_project_full_details( +# project.odkid, odk_credentials +# ) + +# # Features count +# query = text( +# "select count(*) from features where " +# f"project_id={project_id} and task_id is not null" +# ) +# result = db.execute(query) +# features = result.fetchone()[0] + +# return { +# "id": project_id, +# "odkName": odk_details["name"], +# "createdAt": odk_details["createdAt"], +# "tasks": odk_details["forms"], +# "lastSubmission": odk_details["lastSubmission"], +# "total_features": features, +# } @router.post("/near_me", response_model=list[project_schemas.ProjectSummary]) @@ -645,7 +646,7 @@ async def generate_files( log.debug(f"Submitting {background_task_id} to background tasks stack") background_tasks.add_task( - project_crud.generate_appuser_files, + project_crud.generate_project_files, db, project_id, BytesIO(custom_xls_form) if custom_xls_form else None, @@ -685,30 +686,6 @@ async def update_project_form( return form_updated -@router.get( - "/{project_id}/features", response_model=list[project_schemas.GeojsonFeature] -) -async def get_project_features( - project_id: int, - task_id: int = None, - db: Session = Depends(database.get_db), -): - """Fetch all the features for a project. - - The features are generated from raw-data-api. - - Args: - project_id (int): The project id. - task_id (int): The task id. - db (Session): the DB session, provided automatically. - - Returns: - feature(json): JSON object containing a list of features - """ - features = await project_crud.get_project_features(db, project_id, task_id) - return features - - @router.get("/generate-log/") async def generate_log( project_id: int, @@ -866,7 +843,7 @@ async def upload_custom_extract( """Upload a custom data extract geojson for a project. Request Body - - 'custom_extract_file' (file): Geojson files with the features. Required. + - 'custom_extract_file' (file): File with the data extract features. Query Params: - 'project_id' (int): the project's id. Required. @@ -874,14 +851,23 @@ async def upload_custom_extract( # Validating for .geojson File. file_name = os.path.splitext(custom_extract_file.filename) file_ext = file_name[1] - allowed_extensions = [".geojson", ".json"] + allowed_extensions = [".geojson", ".json", ".fgb"] if file_ext not in allowed_extensions: - raise HTTPException(status_code=400, detail="Provide a valid .geojson file") + raise HTTPException( + status_code=400, detail="Provide a valid .geojson or .fgb file" + ) # read entire file - geojson_str = await custom_extract_file.read() + extract_data = await custom_extract_file.read() - fgb_url = await project_crud.upload_custom_data_extract(db, project_id, geojson_str) + if file_ext == ".fgb": + fgb_url = await project_crud.upload_custom_fgb_extract( + db, project_id, extract_data + ) + else: + fgb_url = await project_crud.upload_custom_geojson_extract( + db, project_id, extract_data + ) return JSONResponse(status_code=200, content={"url": fgb_url}) diff --git a/src/backend/app/projects/project_schemas.py b/src/backend/app/projects/project_schemas.py index 36b51c2766..cd8627a633 100644 --- a/src/backend/app/projects/project_schemas.py +++ b/src/backend/app/projects/project_schemas.py @@ -322,6 +322,7 @@ class ReadProject(ProjectBase): project_uuid: uuid.UUID = uuid.uuid4() location_str: Optional[str] = None + data_extract_url: str class BackgroundTaskStatus(BaseModel): diff --git a/src/backend/app/tasks/tasks_crud.py b/src/backend/app/tasks/tasks_crud.py index 6290346271..dafad64a25 100644 --- a/src/backend/app/tasks/tasks_crud.py +++ b/src/backend/app/tasks/tasks_crud.py @@ -21,23 +21,17 @@ from typing import List, Optional from fastapi import Depends, HTTPException -from geoalchemy2.shape import from_shape -from geojson import dump from loguru import logger as log -from osm_rawdata.postgres import PostgresClient -from shapely.geometry import shape from sqlalchemy.orm import Session from sqlalchemy.sql import text from app.auth.osm import AuthUser -from app.central import central_crud from app.db import database, db_models from app.models.enums import ( TaskStatus, get_action_for_status_change, verify_valid_status_update, ) -from app.projects import project_crud from app.tasks import tasks_schemas from app.users import user_crud @@ -222,100 +216,6 @@ async def create_task_history_for_status_change( # TODO: write tests for these -async def update_task_files( - db: Session, - project_id: int, - project_odk_id: int, - project_name: str, - task_id: int, - category: str, - task_boundary: str, -): - """Update associated files for a task.""" - # This file will store osm extracts - task_polygons = f"/tmp/{project_name}_{category}_{task_id}.geojson" - - # Update data extracts in the odk central - pg = PostgresClient("underpass") - - category = "buildings" - - # This file will store osm extracts - outfile = f"/tmp/test_project_{category}.geojson" - - # Delete all tasks of the project if there are some - db.query(db_models.DbFeatures).filter( - db_models.DbFeatures.task_id == task_id - ).delete() - - # OSM Extracts - outline_geojson = pg.getFeatures( - boundary=task_boundary, - filespec=outfile, - polygon=True, - xlsfile=f"{category}.xls", - category=category, - ) - - updated_outline_geojson = {"type": "FeatureCollection", "features": []} - - # Collect feature mappings for bulk insert - for feature in outline_geojson["features"]: - # If the osm extracts contents do not have a title, - # provide an empty text for that - feature["properties"]["title"] = "" - - feature_shape = shape(feature["geometry"]) - - wkb_element = from_shape(feature_shape, srid=4326) - updated_outline_geojson["features"].append(feature) - - db_feature = db_models.DbFeatures( - project_id=project_id, - geometry=wkb_element, - properties=feature["properties"], - ) - db.add(db_feature) - db.commit() - - # Update task_polygons file containing osm extracts with the new - # geojson contents containing title in the properties. - with open(task_polygons, "w") as jsonfile: - jsonfile.truncate(0) # clear the contents of the file - dump(updated_outline_geojson, jsonfile) - - # Update the osm extracts in the form. - central_crud.upload_xform_media(project_odk_id, task_id, task_polygons, None) - - return True - - -async def edit_task_boundary(db: Session, task_id: int, boundary: str): - """Update the boundary polyon on the database.""" - geometry = boundary["features"][0]["geometry"] - outline = shape(geometry) - - task = await get_task(db, task_id) - if not task: - raise HTTPException(status_code=404, detail="Task not found") - - task.outline = outline.wkt - db.commit() - - # Get category, project_name - project_id = task.project_id - project = await project_crud.get_project(db, project_id) - category = project.xform_title - project_name = project.project_name_prefix - odk_id = project.odkid - - await update_task_files( - db, project_id, odk_id, project_name, task_id, category, geometry - ) - - return True - - async def get_task_comments(db: Session, project_id: int, task_id: int): """Get a list of tasks id for a project.""" query = text( diff --git a/src/backend/app/tasks/tasks_routes.py b/src/backend/app/tasks/tasks_routes.py index ff0763015b..44f87afc6f 100644 --- a/src/backend/app/tasks/tasks_routes.py +++ b/src/backend/app/tasks/tasks_routes.py @@ -17,16 +17,15 @@ # """Routes for FMTM tasks.""" -import json from datetime import datetime, timedelta from typing import List -from fastapi import APIRouter, Depends, File, HTTPException, UploadFile +from fastapi import APIRouter, Depends, HTTPException from sqlalchemy.orm import Session from sqlalchemy.sql import text from app.auth.osm import AuthUser, login_required -from app.auth.roles import get_uid, mapper, project_admin +from app.auth.roles import get_uid, mapper from app.central import central_crud from app.db import database from app.models.enums import TaskStatus @@ -136,23 +135,6 @@ async def update_task_status( return updated_task -@router.post("/edit-task-boundary") -async def edit_task_boundary( - task_id: int, - boundary: UploadFile = File(...), - db: Session = Depends(database.get_db), - current_user: AuthUser = Depends(project_admin), -): - """Update the task boundary manually.""" - # read entire file - content = await boundary.read() - boundary_json = json.loads(content) - - edit_boundary = await tasks_crud.edit_task_boundary(db, task_id, boundary_json) - - return edit_boundary - - @router.get("/tasks-features/") async def task_features_count( project_id: int, @@ -174,6 +156,7 @@ async def task_features_count( # Assemble the final data list data = [] for x in odk_details: + # TODO features table will be removed, calc from temp table feature_count_query = text( f""" select count(*) from features diff --git a/src/backend/migrations/005-remove-qrcode.sql b/src/backend/migrations/005-remove-qrcode.sql index ae50373311..46e90eb485 100644 --- a/src/backend/migrations/005-remove-qrcode.sql +++ b/src/backend/migrations/005-remove-qrcode.sql @@ -8,6 +8,8 @@ BEGIN; -- Drop qr_code table DROP TABLE IF EXISTS public.qr_code CASCADE; +DROP SEQUENCE IF EXISTS public.qr_code_id_seq; +DROP INDEX IF EXISTS ix_tasks_qr_code_id; -- Update field in tasks table ALTER TABLE IF EXISTS public.tasks diff --git a/src/backend/migrations/010-drop-features-table.sql b/src/backend/migrations/010-drop-features-table.sql new file mode 100644 index 0000000000..0cdf677e98 --- /dev/null +++ b/src/backend/migrations/010-drop-features-table.sql @@ -0,0 +1,13 @@ +-- ## Migration to: +-- * Drop features table. + +-- Start a transaction +BEGIN; + +DROP TABLE IF EXISTS public.features CASCADE; +DROP SEQUENCE IF EXISTS public.features_id_seq; +DROP INDEX IF EXISTS idx_features_composite; +DROP INDEX IF EXISTS idx_features_geometry; + +-- Commit the transaction +COMMIT; diff --git a/src/backend/migrations/init/fmtm_base_schema.sql b/src/backend/migrations/init/fmtm_base_schema.sql index 5f2904ffe2..998b0b9d7d 100644 --- a/src/backend/migrations/init/fmtm_base_schema.sql +++ b/src/backend/migrations/init/fmtm_base_schema.sql @@ -179,26 +179,6 @@ CREATE TABLE public.background_tasks ( ALTER TABLE public.background_tasks OWNER TO fmtm; -CREATE TABLE public.features ( - id integer NOT NULL, - project_id integer, - category_title character varying, - task_id integer, - properties jsonb, - geometry public.geometry(Geometry,4326) -); -ALTER TABLE public.features OWNER TO fmtm; -CREATE SEQUENCE public.features_id_seq - AS integer - START WITH 1 - INCREMENT BY 1 - NO MINVALUE - NO MAXVALUE - CACHE 1; -ALTER TABLE public.features_id_seq OWNER TO fmtm; -ALTER SEQUENCE public.features_id_seq OWNED BY public.features.id; - - CREATE TABLE public.licenses ( id integer NOT NULL, name character varying, @@ -557,7 +537,6 @@ ALTER SEQUENCE public.xlsforms_id_seq OWNED BY public.xlsforms.id; -- nextval for primary keys (autoincrement) -ALTER TABLE ONLY public.features ALTER COLUMN id SET DEFAULT nextval('public.features_id_seq'::regclass); ALTER TABLE ONLY public.licenses ALTER COLUMN id SET DEFAULT nextval('public.licenses_id_seq'::regclass); ALTER TABLE ONLY public.mapping_issue_categories ALTER COLUMN id SET DEFAULT nextval('public.mapping_issue_categories_id_seq'::regclass); ALTER TABLE ONLY public.mbtiles_path ALTER COLUMN id SET DEFAULT nextval('public.mbtiles_path_id_seq'::regclass); @@ -580,9 +559,6 @@ ALTER TABLE public."_migrations" ALTER TABLE ONLY public.background_tasks ADD CONSTRAINT background_tasks_pkey PRIMARY KEY (id); -ALTER TABLE ONLY public.features - ADD CONSTRAINT features_pkey PRIMARY KEY (id); - ALTER TABLE ONLY public.licenses ADD CONSTRAINT licenses_name_key UNIQUE (name); @@ -658,8 +634,6 @@ ALTER TABLE ONLY public.xlsforms -- Indexing -CREATE INDEX idx_features_composite ON public.features USING btree (task_id, project_id); -CREATE INDEX idx_features_geometry ON public.features USING gist (geometry); CREATE INDEX idx_geometry ON public.projects USING gist (outline); CREATE INDEX idx_projects_centroid ON public.projects USING gist (centroid); CREATE INDEX idx_projects_outline ON public.projects USING gist (outline); @@ -686,9 +660,6 @@ CREATE INDEX idx_org_managers ON public.organisation_managers USING btree (user_ -- Foreign keys -ALTER TABLE ONLY public.features - ADD CONSTRAINT features_project_id_fkey FOREIGN KEY (project_id) REFERENCES public.projects(id); - ALTER TABLE ONLY public.task_invalidation_history ADD CONSTRAINT fk_invalidation_history FOREIGN KEY (invalidation_history_id) REFERENCES public.task_history(id); @@ -713,9 +684,6 @@ ALTER TABLE ONLY public.projects ALTER TABLE ONLY public.task_history ADD CONSTRAINT fk_tasks FOREIGN KEY (task_id, project_id) REFERENCES public.tasks(id, project_id); -ALTER TABLE ONLY public.features - ADD CONSTRAINT fk_tasks FOREIGN KEY (task_id, project_id) REFERENCES public.tasks(id, project_id); - ALTER TABLE ONLY public.task_invalidation_history ADD CONSTRAINT fk_tasks FOREIGN KEY (task_id, project_id) REFERENCES public.tasks(id, project_id); @@ -740,9 +708,6 @@ ALTER TABLE ONLY public.task_invalidation_history ALTER TABLE ONLY public.projects ADD CONSTRAINT fk_xform FOREIGN KEY (xform_title) REFERENCES public.xlsforms(title); -ALTER TABLE ONLY public.features - ADD CONSTRAINT fk_xform FOREIGN KEY (category_title) REFERENCES public.xlsforms(title); - ALTER TABLE ONLY public.organisation_managers ADD CONSTRAINT organisation_managers_organisation_id_fkey FOREIGN KEY (organisation_id) REFERENCES public.organisations(id); diff --git a/src/backend/migrations/revert/010-drop-features-table.sql b/src/backend/migrations/revert/010-drop-features-table.sql new file mode 100644 index 0000000000..5da58ca991 --- /dev/null +++ b/src/backend/migrations/revert/010-drop-features-table.sql @@ -0,0 +1,40 @@ +-- Start a transaction +BEGIN; + +-- Add qr_code table +CREATE TABLE IF NOT EXISTS public.features ( + id integer NOT NULL, + project_id integer, + category_title character varying, + task_id integer, + properties jsonb, + geometry public.geometry(Geometry,4326) +); +ALTER TABLE public.features OWNER TO fmtm; + +CREATE SEQUENCE public.features_id_seq + AS integer + START WITH 1 + INCREMENT BY 1 + NO MINVALUE + NO MAXVALUE + CACHE 1; +ALTER TABLE public.features_id_seq OWNER TO fmtm; +ALTER SEQUENCE public.features_id_seq OWNED BY public.features.id; + +ALTER TABLE ONLY public.features ALTER COLUMN id SET DEFAULT nextval('public.features_id_seq'::regclass); +ALTER TABLE ONLY public.features + ADD CONSTRAINT features_pkey PRIMARY KEY (id); + +CREATE INDEX idx_features_composite ON public.features USING btree (task_id, project_id); +CREATE INDEX idx_features_geometry ON public.features USING gist (geometry); + +ALTER TABLE ONLY public.features + ADD CONSTRAINT features_project_id_fkey FOREIGN KEY (project_id) REFERENCES public.projects(id); +ALTER TABLE ONLY public.features + ADD CONSTRAINT fk_tasks FOREIGN KEY (task_id, project_id) REFERENCES public.tasks(id, project_id); +ALTER TABLE ONLY public.features + ADD CONSTRAINT fk_xform FOREIGN KEY (category_title) REFERENCES public.xlsforms(title); + +-- Commit the transaction +COMMIT; diff --git a/src/backend/tests/conftest.py b/src/backend/tests/conftest.py index 1d9b4139b9..72e008e558 100644 --- a/src/backend/tests/conftest.py +++ b/src/backend/tests/conftest.py @@ -24,6 +24,7 @@ import pytest from fastapi import FastAPI from fastapi.testclient import TestClient +from geojson_pydantic import Polygon from loguru import logger as log from sqlalchemy import create_engine from sqlalchemy.orm import sessionmaker @@ -134,22 +135,18 @@ async def project(db, admin_user, organisation): odk_central_user=os.getenv("ODK_CENTRAL_USER"), odk_central_password=os.getenv("ODK_CENTRAL_PASSWD"), hashtags=["hot-fmtm"], - outline_geojson={ - "type": "Feature", - "properties": {}, - "geometry": { - "coordinates": [ - [ - [85.317028828, 27.7052522097], - [85.317028828, 27.7041424888], - [85.318844411, 27.7041424888], - [85.318844411, 27.7052522097], - [85.317028828, 27.7052522097], - ] - ], - "type": "Polygon", - }, - }, + outline_geojson=Polygon( + type="Polygon", + coordinates=[ + [ + [85.299989110, 27.7140080437], + [85.299989110, 27.7108923499], + [85.304783157, 27.7108923499], + [85.304783157, 27.7140080437], + [85.299989110, 27.7140080437], + ] + ], + ), organisation_id=organisation.id, ) diff --git a/src/backend/tests/test_data/data_extract_kathmandu.fgb b/src/backend/tests/test_data/data_extract_kathmandu.fgb new file mode 100644 index 0000000000000000000000000000000000000000..d7c25b0685264892d64d7a069e99d0bc3f31b1cd GIT binary patch literal 223728 zcmeEvcYGE_*Y?t+7eypUTaXf^n1mKWTdH*FguVd+p(T-o-j-?*3svbXNQodw5eR*y zC@Lt5QWSAf5fFiZG*RK3YxkVFarI^2_j~ww??1l$9v?k(U+2tRXU>$JnVs#^w`Zn4 zeS2njGJ~S13jUsU{+&(9>PYy%T*{N;|MQ7ooWHDL;$KKaFf*TBDx@vf|WyrqVgP01#bGCTkOb3R3xd+**o zk&qL**~LG}{RYJ*CC3a(6#xCc`&$j}73g?v!s~7iSYrZ7Jl}130b^tD%dt22nX;g}gX`v-^>~1};wW0B&6X zXRq*Nk!%>0Z(AXM7QiXwFCF?KS=L)0A)gN5^ueUBlRc_8 zdyJ4T2XF?J&qldLGRKAWnvm}Xa3=Z7guX~NxqNR6IZq&N zOR9^rhq>i-+I7VMPK_lVvkAxiHww9S0B3i0ar#UbC(Ch3%zwU+j9kA$W6CddDSeKMvpc!64!`UU;PmQbkLt~6O!7{F`|Lg( z^+Vb$*PcrDwGV}SI)JmQP(CAFoI>`Dc|yJ%z$sN-oZ8dH*~DKpgnT!EGd=wu^Vd<2 zc>?2!#e8eH_SELCeH(r%7QiXwKeMrmv#EZ}?n173ACB~8e?ax2p+Bu1z*)`Qe2faN zJ-t2gZzklH0i5I);^K^!E>3A8`}fJ=_%QPyDwkrk2SV8($zCz zFFh~-Yt%n651spQ_$eKe82TIK>g#0Brg9h#4wD4;&mre3bdf4~6aX%l#2Hj> z73CJG>NuCbQ4qM?5@%Do@`Q`yamxbkU|xwcsXjGoAFO8=1y0QI=uf454aBcVlW|ZC zxW^f$FitD}cbqfNIrxOc{Y@XK6n`q_LkIIq9H;Yq$R?Zx``HTo1E-a6^JSb3d)c0n zID`3?CLH-me-^lc632cp2&dAxYB{)&f8g}zTzeXi`z=hJJ z9cc2iUDmb7dK&CS7MAwd&nlIVaR&4n7m+xF;~suMD#v{&a76<+YKI!zBR$K(6_YrH z+LwuRk;>(8=E;qVOPoRWRQOAz+H4Q~X`C9s@%)19>7?iMyD23kPR^64zvp)3%!^Y? zN}Nvi%x9>4+`qtnw!nnkp#DXtc;We=8!x4$J(KJyh;xz3$ zS&37~586|%Jy{>n%N8neHuIo7oTrjs4AN84 z4$^A`a6AuYzmT2+Kd0A}IEB&;s$ZLAr$0@vC2`Exi8qm|Qu#C$_G(KUKi4_>B2{Bv z@KbsniQ{ww`XbdNdxnEU3jOhO6tyE6x2T`=x{OjZ(zj?l;`mV!-|6)v&SZWxzA2<< zqkW{;mpJC@JnxEBgX0l;=?x^#p!l_T=C+nNjnb72E>7X{LT_#xiBl=v z&g|lthwb3nN*wE}G~Z=EDbSmXMKbr#=u7P~Bjr25l~u=Hdxj|ntw%5qjc_*nnbskI zqjc#%YNtkS;5teiKcDOQ+qsiux3j=rH;L299={^U}IY zoWVG1zZ&6G^do6KB#zgmOd4nOo^E+H%&*dPiPLqeU+Ra9UW5w;E+&AZbc6b5gYtFO zxzc({oJr}H?_~raSiBoBvOXYas_U7OQ zNt{AB9^ZKVO2IgDK3?KDUjzCgmGjjRKj#zvfwRef+wj zoY29968~Zzh+m`<54#d@&Z;<46|N^#Zu~OE{$#a6=`I{o<5cq+0CH+Q1EyIF-`1NY|dt{aPL1h6ixePpHTTsZI&>(&_^@QsPuvC)D6Cq>_ET25_S!PUn7y>VxY?1OL#`5~onVp@ow@wqFyt zF%rk?-nQoAOzMZ6b??xz636|TlP*#%>Tm3}La*_-0FL{sHm*IDa7riO42d(lx$V_x z?&1{gH`@a@UgCH?R_oy646<)`1#W`GS!7Q~x=6KX-QS1-Zlc5)l&*AhaW?T)Vu70^ zaT@2-(Z%W9@Ad$0asbEt$eu?1oYf1sDFGa%JLMLs2KRG4fqPlvG>T>Q|?@*HDg> zS0zrPbgPAHPv>?Cf2O=9aSHV(a{ZLsWlP{*mpGenHpL6KONW2z8vz{kR~F(;q-vC} z2|uS!mpJYZ%q}jD`vYg)Hg$%?Y1|)B{Tehs*AOqMGbN7GRn(J6mCp+fZkEL9G@my) z{`h&z!OfO9mFM47K9innO&$5pkvNU!?FR8>Jri+|`liGw{G7t^!g?rg>RS?Lae1iT z3@V3-c9}X?;y53pl}JY_&F>A=@4R*tmNU+*3gD=o zbc|CX)uQ^fobfkR;yB)%{REM!Q(P&|xc`pCX%tt^yg{ULTv_l_=xT|R&vi8ZaywF; z@pp~Hsr;Nr^`UV4Xb5}n25?+%q$8EvCH7lG*Ginp`t@9#N;u^O;MPf;&H9KBk!tdK zyJPP?iPL#KlH%T^bxjNHDs;WXX{=w%wP%r@({F}ukT~|2LG`BbIwt(rcw+!Z_T;`Z zuj@fCWm5o0>CSqPNag(iI|8`(CC((Af%-%$t?wGhH)XTLsk{zK<&%2ArEZZp&PT!g zQKV|LUap|NQ@8#DXHeYhgmd_(rAeGzmnIyqdu!E%y|ir-$NCo1MXF}d^9TH%w*4PC zmHG|dA8_W0X*(oNu2Z8uh*X38qM*Lhc1j$VPe&XfmGadwE~f1Y;Ao$~r18=qoYEG! z-4bU}J{phHq-SG%PunALHrG4hOwu#azouCd$8pK$4^-a2fL_`M5@&P$l0D|{j3;S( zC63#_N<1vmQym`rBu?S@qyC=bO2PakZNJ3HILCMZ z=$-iRA2^fdohp@2!Tj&UM-pdKJ2H5Fz~#lh^NE8JXK^~&v$=jSKREHR#Bn;G1JJ47 zbmV*clK_tT35Dl>R38@R6}LZ?IE~X8NAY4{o_PC^#BqJ5AC@?c(rx0) z`*}9zVYfe%IF4rv^BR%LajL;Dw?CISjz10jL@Jk8?I+}HUr3zF`>Fl^jx&>h`%>aG z@|TwE;*9Zb`*qI0WIHNx>}PGLi!(`29WCULuOyD+(u{X;9DgSCLcW$b-Um_>$sX0W zF&wyK5~tI+U?sRXo#ICuCgjlL6363;1wV*XljmWuA9_OKG)lMnx;TU4RC91AC642l z?Qy%*P>#mmNSs3Y7V1f)vfoYkz45ma$K$k$=P;4V?O8#28=sOmo5#yI!rjk5`a6l! zX`EIExj2>d^ua=oJ}q%J&o`)i8n3%TFZ%lcj^~9Ge>%-8obw>jw#2Eto`H5KQf->| zIOT{wBXI`LuLig{o#s*2OUU;JiQ{>bNqkKzuMI!P{U~w#yr4s0q?%M-b(D}(ev&wg z^Wpd-dnV!~^=tq~JT&qjm&0iXsXt4c7X+&FMl;xhZib)tiBIk!q8DW3G@>Z%G{I ztIc$A2Io6Z$i^LsGkL!Qeh{e^k9V+d{2_5V<0xN^+d1+z|CBhD`YY{q*B+1W%6#Dd zk~r>nb@GcG&u0kS`nv%fm50wKa(|`3??>)QoKF3kLG_{Y`2p115hViyIb9zs(visH zFZdtHAaNFttFO5@lg330e2-+5I4+M3e!?7&i^|);Ws*3P<^wkTFK|2_X^VxNo>}79 zUpnGL;B=m^EdVZy#BqPj_L#rAMBucn5~p%~E^_VJHcQof|8t_Cir#F-Q?PP(vXarrh1Tv{%PQz>0VJ_2WO z`G8B$EpZB^Gft;^RDjEuSK@enpssT5SriBMyFw1lCvj5Wba4vJH^j4yz&-vCoVCWq z@jOb|AmqkRNSs0K#@OlNxPF{-w0eGtQ^;Q$;W#dgeL^o02lU-PlkBNmTzjnN*iS4V zaW>B{HoG{T{OQZ@!(g5j1!m z#Ho)XMI}z7{#b?o1&bj6z#UxIr{#pOP#j&3Kp^(#yOPo#PjJ@5(DLhW>1x}SX zjd6%0k*`Vf1#^dxLrY2=_xBe0nd`>}|LA8VPUrE0?AgT2(Mv2PaR#MpXg4BXh35gN zpTyDKgkwLO zC`V{Hi8H8t>N2uNylnJCb_I!JKiDe?M|_oaLY^BYaqMTC`XPhDT)RQ=a_92{kh{%`Qm5zQrrM|@3yl+GKYNThN zyeSPNPNn@kWwuBc_Eg>%UjbZ0iPH$j?=R@Q4}nw3L*N5fi%lmoeK_Q>$7Ql6;eNK!QqI`N+l7YM3UEl^*w?HGyU{a=OWle$|D9rbv-r^M+!+;p4cnf7Z{ z%vVEtOC0aRa=*d$o%d%#V*i1&@EisnWZ%GiKctVu@&2zh!o@K!3-j}kz7l6qxs7pT zkM?zK;3DHBPNj0|qg)*C|C)x7)B8)D!TaE2TpaJa8$?ysEpkwV8woJH;2CLRX$FDBx-u_19L zwSS%Jjr$G9&ne?2PUrn?=0W|7^Zc4JLE=pA-&2SO_b=d^I#J?mKG#Ce@oe7$?%*Ve z)2ZLprnvUxemvsm;ADy8eoaA~i#V{zzT@|UQzXu&@;U1s0>}Ifl=tAv0UYb2JqVma z{;#9&JLtGvlrzX~)Qf3Em6!&(# zi<9vUy;gH2j>j40B^Rerz6RFYTFndKxP1*Kd&I+8|7$g0;!O4n^hLE9RF5Y7-fDrw z=^XdOgU3~89j?_viBl-|bxaDK}3f2u1 zmq?t(`Sx>hvV2$%Of)5q^RaumIF3IZdWlOV&L+PoI^if@ob~PL%OsBLNr`cBD*HKB z$kX2r;3%Ef#})Fk!*}{}iBky2>#;hwKh)#&6%wa&oRVL3(o-DVN{O?%KPA7|r01;j zPhTZ*I^mpsDiH@9XLcO&O_ewv2QA`jlAhidxOXJZrg6rCzOcvRjF|}BYKb#x{4t;} zaNJ(3LBOq%IGe{+;%iX8&OXlccO_1vanPc8mU~Vbi{}^Aj<|eIoK6qC5UX{g zc^Jwq%Ex+kGVHx4?a6rw^WbuzJx^aRadJMy{$oEm{@WmNc59k1?^pdC!# zE^$^17bo{kUnCjjTfal%RLa*xT#9^6;_tj?y?&>}X`C;$H}wmN=7eN^dt`n{kfcEs4{cx%M>jvrT%o6OSJRa83Wt*Cai+U)n2iMiUpu z{hCR7N*_@Ub)UrX`{yS4S!?X-S*Ravzr^u-%-r8=WM2ml?Z7{9PP*`mLO69Ga34w> z>uV^FfZ5E;!F?og7Nv9lqOm{WXYHWG>CBhfxwPkuTOUgtzen!u--&z;(zDPm*MA~$ zD)~V}y1=o04dZJ1rxM5Yrl3E8J*r;=dif6j1Lwri{W$353%qd8`Zo1z{Jyw`{xIL? z0ej>Ji|2n7_v%!E3p*llD&dTgBK^L6Ej(9+eIaqu&-7fTaQr#AF9Wz2-1uZ!^v3=9AcU`i2k;y_7*y>BGW zpm^r>2==q~0dU_+9N$;K>y8|c#ya3m1#ldvE8Tph-`5Da)pr3L)swN@#c5O@_FCXh zOC0wn<_fY${hseqZkWc(9aY`LZ$8)F1*Cd=`0(Va0j9P>v9xCbCOMyEtah#9(wrh{?T`*FG zobMNjV|xbSEVhSw%Xd-Y%$md(>7smW-`pVNuwNz4rgEFeN5J%YuAZ}=7%KCmExN8z;6He}HkbM*TCar#xIQE|fePNH|!r3otbzS0&+EhN; zcQMGHHr64H8xqI&V>sm&_9PDW&6^UZ)Nt)N?O)(jiXUYQaJM9m?{(3+d{I;m^h4(D z04~x^clPguJ)LprC(Yj_&Y*OCn@ATp=C7k4w(dxrPUX{Szs#mMv(dj=e@L8GmC8dn zh05XFCu997aeV)kx!KK^?~SpLul<+ASyjj{_2uH|90Mo<;VYb)Q!GB#!-T(K?bs{&Ut16Cam2joZyM zDj)e%nIYuFCnV0`xJ0_hm)n&Ae~) zXd(U*3rQUJ+q}N3@O%d4NO@Y~6v{`Z`q0Uq0{&)UiDSObJYD3g@puBgu;LP@Q=H3n z2C82b^XV{E;&>d@l0~|(r}8+6b;z(1636NKP#0(N_%Rx|k`iZAx-rbf$>%Bu_l(4` zzCr5(Ci&AjFB(=#;&@!PoaaQ*KU2NgcwTK)TH@HA^E@tius!E_xK$a6Q%T=>zY2V* zygHuK63Yf~6jy3bmj{pgD$0=?GtPl>#`*S8LhymGucnDpI$}cOrA%)NcOn@ za&T28PNVT$Z|&l^AJ-gQHHkB6JW{K=I38!5_Y>12130qB?UTg*=k6fO6^}k zx~LD?kJJXPro?f3Gpo2bZXZT1A&1tIIPNFyFtSJeknve@3S6AX>xofyMi}X|nS5M-2T+qNz*i)!K zu<8prrM|>5Uj_QWG2cc)p4&j;R7y7p$K|tL0Is3LaXxAj*Bk;EBXZo*mA zPb!F?v=;(6?spoy_PD&JW3O=lNBxz~_DIipkL5%YiPNaRGAK?h>aVme!v5{15@%6d zIqzo(w{Tq9=!b4M3*abUevjGWaT@(sXo~=j{I7I(c^LfM(@V&4EhWyTbbc?DpMRZo z|KTr6oJQlV^B#;SpUvxGy0DklO5#jj=jrC=%j+F_jF3;XmN?e8JGeMre{#xuqK(9v z{5(!Lg`eZm&xN#;IDY=q(eH@zF%KK%2x%{IDz^vp69Q+EeFJ)t9VCwJSuI_h#_J-` zi|#0K8rd@_4%kmh3xSJ^mN=F2;d>>eJqOoG;uOk<qbg57su;9I{2pP636SCPJ0!_vuS-uhrP5IiL)qOqkIis&v$S=B~IgY z&}cVbn{j9_X}u(l{ov#y^5u0`%fa=QI9}&c$)3gQVZfb;l{lN%OMAHaDui?1KR?k& z;uPjb?bo96*=TPk`br$9^ZLC;`zy{q!ihME(<$8`dp7O6*iO6bCvh6@A5r@??%%Ju z-Txmrr`-s@aJ~lgZV!++gZGcxxqKPtw9}A55~pxJG(S*j|Hyd{EhJvzROUzX9E*5p zPCHMKIGu3vegU%QjQb&p636l3ydNoe=u}=4{Y~Uc5~q^?o%SVgTn=ac5ji-3qkL7g zYxsrp1+T~?iPOm*zc0z~OqMvVU$voY&m#Le+DqgRiIaY==Hg89i`4+Qp%SN2 zx>3``Y3<$TbFH3`qlZbHN;s3oU#<@g<8<_JiL=>W1&=+HBYK3yad~tqpS%aR7H}ga zPR4zhYmdv}+;b8=O5!wXM=H&8EM7;Cg1ylaXH)yqkq_#F>k;#k;bSC@>zC&xCdGm6 z;KoXvPVJKKA+Wg~Q6Iy{Nt{agI`5l{d`;4G`nkDA0LT3&{4a1i={fg+hfI(-i~C)Q z1C{kKPYIbQaok_2Y>#kGI|!L1aT=wY6bCBvf?nukiQ{|?n*VV>XCW>^r%0T}>-X@# z;KAdBbH7aEmnDwJGmYYk?Q7uGIOU%>j(g&-V7zNQRpM+Ozfn)fm&a-NrSU5P9L;m= zIxddKeH-)1xM>n+(0Fb~x;P#`P3Wb(Dsenc+tpp1LHu3ZYZ7Ntd7Ss*Mfnu2N3?^K z*Cozo|M7DL?`yz*${PV3m5=ZDVZJKLn=<_$II|p;kH?t^;ATi1&r9@DE{@~Jeon}# zGbN7w!1q;|#KXDQ>)t=-CihhHt{u)F6=SRxrZxcvBYsdq(AH86z=cQ4~Hy~ zI4+NK-j>b19UZR&t9QUVu&lv03 zsJFRqOC0-^?@czTe2$;emP?#M{jovy#(bUo+-|RsIFT;`+rVzbJWy9R1EeaL#>`B43FsByhu5OB|1Td|xDghr_Wyca6kx zeJF~XuNoI-K0Ghb>~dN!dqe3Qgk++H)e z_B5(TEr*cjz8}DmzLv|ynOxrdLO!us;xsM~;WXy|q`-x2l{ndcpK$HjR1UqMkR#J1 zP9?r7+v9RTFM6BADU`0Fo`hc%vS$D{e7nT)xT0ovak3nb1Ghus6dr%_xj2jaNyPJs zof4<?t4gL$+BXK;AT2voAjye28EQw=%lkPRuxW7l- zhI|mf@j4B~DUZiaoQCX`IF;5ttPF1X3|=pXz0iFUXY%?8wGWeU&V8t%`z4OoWppZ^ z$?HIly#o@*>wo;c1cmp%!9Vmvi8Enq!;{cA*O^QE@*WVG(>7PiP#{NS;CaRa$$27!q`lk|S@H!Xz34vpO zDu~ncLlUR*dLP;=a1>`Q?r;D{_3Pv#a0bPnbH8``XA-Ayz0vqa~-O(8zMfohkIrmb99F;is1CP@h@zOE=Mt&u6 z8pjdJBkalYIpgZr5@%AqX(e48?`v2nU*s`~(`|7vu+*vjl^->>&Qps%X(Ht*!x!EC|J|8HrA zexlR=fpfmoBK$0IC|~qdiIe+k)Q@w0=!l=_YZ7Nu{VFt%;Pz)Z^Vr`cPUSct9vlZ6 z${T%M;&{K&qWOVNdbY#&hQz7l2mU^eMe*l+mnZI~#AzJQG@fhJPW7xJ-?&>6$NPB- z#%07Q*;i5CxZ5rc*NNm3|G3}dbH4+aO~|_Vr-ArKQOfkXP$h$sU;Njol0Ap?8`!&F zeBY8$YRM6?NhQUi5RL<7QYK&eQGCJ=pop-D@CsoO6~d~v3$GScIWnqpb4;>b@>S==8LC9Cp;`=6PB%OU>Rc4tGAu_FF@&nR;m{5NF}D?d)i zVUrf#!hB@nfXnA0FX?i=X=d^J(#5qiLOvQdIQ%god-eVKmklcZeYYQPGDh&Je6r4q zm`5GY->o40k!9+o%kW2^DX(pWTp(I$3%)nXXFCgggVZ*g5FcKBH1mI}|1gpMq!S{) zR=rv#gMwqAqe}MaH+)bmDxU*EjTs!P#wR4JG0E!S*qDLpz=U2g$>PJ0CCjTNlM;pu z?&ZK+G;G)K#WrfQnB=%YG4Z{JsEzu?4(zSA9Wr=m>;j^yPx9+AjHQoQNsHuMhONYabQ%^h@|A$L9jC@M&vknM2}%H10Blj zji|n{2?G=Q_Ujchut!oqQGS6<8kmqcqQ^kt%6~O_Kkc4_`}G>|ca+09YDj$V*ui}V zCy0N;WPCz=EZQ>~eX6lt@8lW#1D*#))QR5%dBW;1*5LPY%gWq=+~cdu9U=db>FgH7cbPX5rO?z_(!Nv+E%@tCh1JlKMygQ0CsOJ>Wa>`Q|Gze@dEAwL18*yw0ds3+?~MYLn;S_cv=N*M*$@ zkD*Us-q}Tqs|Eb(Yqg$%oM+d8eXz%}*S>#zTn(!D_^isIBf|xsbFYS8!g@zSg;{4H zzke>G3g)k^KD-?UzQZ@4ng{v4&%#y6YdU|~8uNLtzCZsu4~~yVJRkh#V>{Go@6l~L zVclkCrlD0JzgD;GB=F0oyj}}@6GKODfz1ARu}dmnR#HDz(O%sj0LuU;Xnl9<`F z3#$@UC0snoif2|tM&R#1d5$eqbowNWQx(4G)Cc3zh2HPnz;hdqOPx;~&4cIGT<0g( zz&g__)3+}I{*4cpj)m;i_iwl59^7u-aeaG%YucJa?@< zb0*dsOMluV6mph*+ooWD?ZE3I(1qM+;|r;fzdJH}5#&V`)c1k+>ihGL&42N~v5pn; z(?8d-A}U2y35yDUsLwUbxBd^snqi-1ZS&hJz|Sj>ZqR%x)^Qo{l^I{tcV53PXyPAh zdRfP|+Xuek^?#g-{eU+Sm>ZSYTw&z-3g{o|FuEIztmgzrlbA5ccE@w>>K57A3g_i@AD_>VjuH~+xtF8d&puh zX#@GWsF|Na7OP@yfcNVA^WPX${A;e5cmsUSeRzEm_`Oj4w+xscc;%yeO3iWj_S*bA z@X1>*u7K>-_g@#;^kDw({AuNobDr@dJPhlm7(c=v*8BisJWDsw1dq~|sP-g)6_A(me$38L37Yoll1-ah()!p&?0pTv_ zd+qu6AH5&!4_Y^RRP!(9KkC%h&ftHu(b_QhV_*8?;pk5mo_TCE_;UZb>6x#GLEiak z!_mNd_5Jzx2rB-6w*G1!)`7~ z__8wM!K?4jzgtl8KRM^k8Q3>D*l=?S_oF^ldp&7NBxcq@3Il&N5rhuf{<62&#?o1 z-|tvFFWLw92Z!ce$^qF@FB}E_r*C&W2Kmj>QJ=z|*S^1hIt7(~t{(6G8seeY2d~Y> zIV-O3COc~`!g#^)lK0xkFpMX>ufX%G+I4&GK)k%N=9AsPcbFKq6|%m*@D|wf+V}U* zTMr&zssDTw=P(dIz2=Rbign~dnLFeGpVu1Ieh>1L4rTiyUOp>ab~4uCIerTKFk?7m zo}ZMhbh-olt3F$DAL3`ro=vqNvwytyyZX*Q=04azk8pnWl<*I)t2g>Jzw`ddw*`;n z#JW7sFPcxgQws9zT`Lx1eo?te`DY5PxDfa312*tMAYMg;{s~*N+}F z{||du^M5@5pZO~5OvK0TO5dyozj=8UEr$HZ;r+>Y9%g>uyj8i9^RHLm zpa1g@UO#?>{SWwe+q-lt_zaGCX${toD%8$mLGCcVb1tkGasP6%$Iurc$N%2ADdOSH zZ0+s=@74F`-|NBg@AfZ`WM3To=dR3_2tGC1@9vBGZ)GnU4f*L8Grt7Be-?cuBgSK1 zFRlO6^&1$^IbOW_{`@NjRsDaIPbq@&G^X*gV<>ion z**NzE_`j8Sz5@K?)yHce`GjBb=RFHqMEvGnkM@CGLOl2#60!cjvG?~MoNlY~Uv2$G zh|_JA|91NB|Iz8Tpx*tVo3%>J9tX>?mz=7Yi_9>>p}loR6<@6*@rk~J&h zx!RAF8(^Qhe0qWJ(7rQuDIJY-=3agO_G=D&%>P_hg<=1)J`+|oDl9yzQq_maU$ny0 zNrDgWcQs$JBoXHryz<>em%f0181I!C@6UgEQ1Sn?@vc>a1>fOUQo}JmmmXQ@0OY)1 z&+d)>>_9DXl1a#$r_TBV^2CV0;{`}v3@blCop6^{+*k?TWhU*`<$NIw1Ha*^iY%dKxg7vtH(W`S} z{>ST6yM7vX4g7h1!>fN?yio98jrZaRh;lZQ2vQ}rSoz!;l=sDdk1Cna&p9U@j*&DT zCs)Lw>6WpH;>>%Sn55*v{YJ#7%@c;j4p!U7;`DdS;1O!uUU3Nt&SCDrQTP`VW8)jN zX`&`MXWwB=*oqft&Yg4JQh`r@|2;K9oJ)^MP8j?@%3B96OiM-pQd+? z*GDCaQ{_N5uizfH7x1Lyn8C?CdW#e5_#wP{)vyZT)vG(F?0Y2)95N_ADyiQ{=eO#^ z!<|D${gTA#n<(dSJyZt5FFlgjFDODnuU)p0PJ&e z4{64!?mhz(5(Y=zpD!Oa`Wuz_etqMdyer>t%XNeEDuI*kzFH6;J1|hz@M=(S{U0m5 z=DtB8$~riOeP90jEiyK+HnIw{`9vu8Ip%3 zh&K#+$M)#muTP)Y!8olI7wd4k&oxmA!{Wuu5pn$z;moAifqni)RZoooe><+ZYGG=s3L(`nd^D@16{~#pCvMtouw*3Pl0``1eyg zVV&q+*61~mc^#=}iA*CPPf5yMsj~RpTOR*@f53z136Hqn|MbbEeDEKSV*_$Um4K{0 zaq|+!Irb0l`~Fb!Rx

`!rsCfByZ0ioZU*^v8%Ner^xhxczI$tLpqQ1M{X!TXG%- z-?e{6gkT;}Wz0|uGVfz~_5Jz39aQt=>0!B|F)wER!!OtB3i*7+shhxmkI_91{+L>( z)qcpw_m+PjGVfb?_5Jzp$#tuy@B1%F2d{X~Ns4{`u&A)gQ4!T2=5tc{ZLb_i5PUeE z`+iZQK%$VjzWXk^I_4!I&+m4t1KLBm$y!CoyzcJR_varURPmqQ=9ih^v+-D~{^;-W zKYRFR$h_~;=f#W*P~Tm;4DXJ8gy*WYZsyc~DkYW=FyF!d15B_*He~(L$BU>yf3Yqt%y!!t9UkWPzJsYY21x8$b8PxtMAXhZBX%Ne#|G2 z`TZ^MYuI$t2=L8#!pMg9e(~;xd{_rL@!SVLApUa?QmSH~!>jMlzw3kfyX!=cZ2Y_U zm9+D`?~?TSKI{NtMAXh%Y*qp z;`wjJYo*(PU#=_zvtoYt_Jv=2L;f;q^?dNHR;c|H)PFUla6YUHzFFe+A&`%JSzsXW zUVVT5or8-1z|PNR#(ZyLzmVFf&l`D57J|(Ce9IR6H6HS<;U$V!;*ZoVc>(v!<%F?et#(NQst9Cq$`A$N^o)d7+dsXe}J0Wv_ z;MMo%-zKQ|?`WvMi}ud@ByVS$xgK)S;uSu_doxWIU;G2@VfRzw4Wj#G-Y4+t`=7Jv z6EysB&Zfa1(I0?sr`Dnc2!1@DA4KoFw)-jHOQ>&tpLJlF?*~G@{Mk2y@SYTp2W!7? z-u!+U+u^Y1weKGvi-PL;@O=IeGqE0!H*MWqyhnHBi`8#J&ZpM@D36dgF77-A&%-Q# zB3>7U%y_T9e|#){F#kumkAnE<^zz$lz?a{PdoEMf_aWcvQlmBAqvQ2~x7udx33=tQ zKVHN7P-L@@2Rpyhx_aB$&3;6D@cO~Vg_FL7?6vP79}6GsA9wtGWc!S5F(3-QGvEB= zTf|4sBU|P}-cT^d3B*UE&-N~cKW5$So+Gc2U!PrJCS-mO(W~#zUkjT41Ls7}9hmwa z_{3#ep8@JLL%w+C(+0qM_5IFC+(rE= zE1h!^_*Cov#yN@qEf`J5Zy2U7jl-MtXM6{}yK4ZK(1zx~z?s`ktMG2dr#EVy!pzyHBGCuc|O->x`#Wapgz2e)|r z_wP3NpI+ki;M)wSnLgC>OjC1eZ)4u^$E@3@vWvK@(XQ=G#Hm;2cFB0J%y|EHx-6*X z87<46`~}aQ;xz6?jH6Z0=C}g6M&WCp=Mr*{?#3ASXLJtZD&%2i%Mp-^%-?tvc(1-c z|D{32e{jK=SHQPQ_w7x+x<(hv#0P?35L%Kpf)?OUg z6|z^~pMUM3>Zf_%f$ybjyZ*zIsNWiQb7syU_{~|;?hnX(PaQv}{nE8{Q~1NH@6SIn zsQ4Egn^+s|eagM$Oj!gUKA+Su-?=lHh0OEnb4l~qf$Y`y=U+Xj z_&=6^+>_wL_mF)>8eo=5Y&oy>RCAd%So&#UjxKQ^e^NB5IiCW24r zh2e)VKe#oc%Pg$xd*w5qr}xP%erLQ_X1qWD)=;dtNrRo(8G z|K!V-Tn7HAzNy<%$aP-LR}6C367PNpyjS0!f3Kk8Ke6DLENJgfeK)cN_$78^y>Tbe=BI_2Uv%!@XM@I;G3B9&0jFS&z*ASnVv$Pe!X9T zzr_4#?CWnl1-?9g%73ly=Y52ncfr9!m`{53{rOjVaQ)+acP3@Sum4{El_R1mS9zHA z->=9o!@z%7|BBVH?=ocl-OnK(%y}Ul@&92;gQc-Tjye5fbHw}h>(4iVoUhiiv#_t{ z)%WKg@nHV$I{YJR|9e|BnghQ4K1;Xwu&*F@&$Hu-vu`&(OBncmmE-6w@O@7&e6_cb z%S~C63H#h$eSiL=njbCZ+y6bYB2S?It5zW_vO#UC1gZCHqR$AHw?Sb#3u*G&AWPTsqtMAXh;L!*2cl*Bw)PF>! zhZ+AB#p_<+%jbAE{Q3J9$Xvfu&aLR#OUQ-JCDg+?hD_OCDGr(6=kV(L^Z$L=P5*P8 zuWtC4bDZM7%Sz(T&xbnx#p5Co!Ds05JD)u-_?@gPcJhTBaj@GYoZ~yxL(B+;%=aTS zq4uCXe`;Hp_}#1T&;M3X@gKMMSXdpi{F9m@o1D z2VQ-D{)HbL|8D;`Vbj00fAP^0TxI_-ie$`+z6Wb$NE6c0b9FaK6mQ*CGVoY&vowUK9JL^CM?8!H_NHt{zQF$S#Ke&_oyz54$5lMQ|F^N~9~Jd$&MxUX&M zeEGa>$Y)c=T%wRD{8sxq>Q2ASWFonJabe>^`$#=Tb?zh9as5`@g{$7|0& z9)>*FA8vo~$i8#5^62*5;KT2^;B`mH8GoDpEY3adTRikO)>Ec8D>4G}qo?vOcmwk1 z{m0kB`<`BXfBvh3YQ5~)L!%a=zIlGQaeVRy$b7%U{x7XJ;19mvp+?bzBOy=tX;FQg zi}ULH^IsEG{Fxv3mqjxdT?>A?xXuLkS-d>uK&rDoNcUS*Y~ODhivWMGzCZtW zf{OpCij|L}zTZ34WeEDecUQmlJ!HNQ`t#;t3&A&Z-okId_ui31vmjsHap6tiz54$A zQ-g|sVuM@nf)C&4ns17u7mZlv`#ytG5+0MIN{HMLgsUiUVVT5 zD}suD*{1bMf?vfBU+H)r+}po=4CG^j`<4M;ULWQ59#_2FZT!yj1Fyb6|5ZW7KOx`R z0^q~<8)fdieFEg=ZGMfzx&IFHXHUTT@XSr?S0Ub4=h;>WvOcTIFL-|P>ihFw8C3i= ztMg{WyPB)!EsXE8p8iHbeDi$Jo_M?>{K4;iy!LGLkNACE?~fW`JoM^|M?s(aYm*;5 z{y)O`5bj6cnxm%){@3uOL&0a)NAt>n-!E?#{Q=|u>d@}7;LrDcHB(Cuh0OPz@qPl| zXLzSWMJeOFv za2NQDpHqJtioz~|dL;(})(^Lv!}#A`J0$ATYTY7PGLu5S4fzu(+=>HR7~_Uild|2e4m_sHGq zY4DFut#Y`A;KS|vX>r`7s*tah%%2XvAKrPaOf@0%^V5pQ#TNyI?A7<@e>SN2^Z57i zxdJcN5_~Gw*k1?roxRPoLlF-Rn>1dI_E#qwIHb!XIxQ>GlNV zCO5NHfxo=^{`@EZr~6G0?fVBHWibB#(!J~r$iHmLv<~yXclXyU zhxmW4#$P7(F2D zn)=6n!~V_R?CUQ0%qZLBcyqyLXoz^jOUMg~)V|O_$T=GSQ5^nIepuHX{ay2FQ|m%* zRru=*=r5Ar>@*4cuOrr7*;iM{d|qK(O75px3fXJlKR(h!&in62E?9HPbFWNzHS9-= z^9T>`ye9gG>T_Ed*neNOY1Rj=1s^_-P~7;c4ETL;ufs;fL)GL>by^9T_h-eemEW`# zvRB`q|FxjvAN}c~d8ltAcS^pFf={m=8?QnZw<;EBBjnY`D`W)!?n|m)1mDH=3#~>x zJo!wspQDBB)%WLrHK_QX-;?#dZh}vhT*V&;zssZRR6+a5)1<;|w3k^ww!8p;47~6~ zyBCH0TA6~qAkTVt-xuwL?A7<@e?6%9zi?1n4F2T`K6kIP;Ipy!>t`X~eyQ>c9fZv1 z4W_+(s|f0QO`W}A;J>H*rLVdQ*{ko*|M#Hcf9JX1Pk?Xe3mKN_f)9>mq5ki5ub&8h z-FFt>it{R^u61kLO~}o0fxM7ge^YpLjF7$h{`_wQ75^;L_RRyIznX~`3k9Du^K0CJ zTsNm!Q516Ct@Zbzy?^^%rsN(%E*$$;AINVNYjLBukiGi;{C^87{<~Ih>Wp~qQR@A( zsPB(!AKVD}9dp?)=nos*E?5`+U&cv!OQU^M@15xa_@`wpp~4?teSiKpgNpxO;cHuf z-)p(Vn@56=nr-@0$Xx%2#Alu`AKtZJTs$LwPfeJ26!XV-cBO3TC1kI@KmT8YivNf4 zOWK2sooT&fop_Rzk+iuHAV-dWHg$9V8e1+a!^(D|Eo%@PKW$` z0WlL2^6?do&O=`QWB=Z0A7eT<90z}hw>tiSd}q|NH6iD|J2t$FkbBgfzX9@KTwHx0 z{`rB2JHVgb{QcwSVo=2ouHiy^3BiSof=|y|MfXAG`BB#H6^0?6j;H8F@H@9B?oYk? z{`@Zo75}bLKi34`vtQnxjQaoRSfxpj_w`<_w-fR!9WG}9{}NeVEP>yPZ#Q?N|EQm| zs1f>8uf9M3D?!Eo>crpP0pI8!GQ?v3SbpL1-ysiPnf+VL7rFj7R><@j=F?pNJiqel z`}4mPRQ$O=C@}5&C(%D}`{(C}#Nl-tIOkD@6sd!F;PY(d$L+3w`J`9hpZ~?6T3>xU zFkAixhCkL9v~TVy z*gv>9xT%KcrF=Da?}EJlz36>dPuQ|^@Gh)3_pUQ-MPniJek-p>tf`l2gR>t0>W$ov zKJCZEB#0Xu1>S4lKR$j98vkH@VNKW6KjDwRDvAM5@X4Iq<1_F*`qc5;;48jn{{hz9 zo8-TupuaF;e*X>rN-8($Yv*~(qwmlEMo{se9ry8%SYPD%Rh32;Q_((7uPy&YC&8EZ z^Q+F7mlNY7UJrr4y0)8^g87wK-=F{WpyI#e;H>4~gXt9bd>%2a73x3#jxAd;ANuUB zwhi^Y^2mtS@VvFmFe1ycvav$;>ihG*5>))3ZaHrO?yYmyl?9({A7?Csd-_^T9$pIb zU%r=l$(pq%z@P6i{qx6N{lVX>@6Z2WQ1Qn#jku@r@GNoRzu+@C{IO$@opm!I^F8id z|9p?%=}(?m1pZ!qfBxHoivPw*o5OMcfwPVz`0%|%pXRxm7wwNm^$g>1;m(TOPj{ZNb%gjvh&xftIO~vy- zY=-=4xId!E;Tdz#9=!Vg{67jR{8@cI(p!_Vuf{9TMiyBEI)fADu55^<5a zkiGi;^TV{D;g9*@u1dVETTWvLil(i>6fqeVVuzHX?H+^#!{NvU4=YKS)_-EMt?M{q` zKeZS;4bMASN9C`N{^I=P8U-5(`K2G9`VH-8=;;spydY%0FNxO|y!!tA!&gDWAN>R0 z4d3u zguMOnrwSk*rcD3sE@bQcwV&~O>fO4faDL0H@83V12^#+BA0qoU=!g2|^C)~z_mNyb zWU3+f@plhqe={u(GS`2l{=1vj7Bc(msn+?&Kt9o+Peu6W=I2KZoM-Xc_m7Vsg2q42 z`IQB)D`+33)iP%gA0uBWQWA1}Wn(MO?{uwIb{G8j=c_rh;&*q!eB^La@Ajs~u!fPeTq z8K1nkdJOD&?fb{aw?X3{w2ul|N2X#uE%i*7=xTz`-c~OcKz#f@;i)IWgdBI#))5cA zv+T(oF65(2kN$?=`Fylj-=F_CLB*fH`;)(S=5~>S-^uPdM}lv~>E>mAN)4{R<$a|ho5$fjzP$Gk8kUT_R+Z2pCOnp z#TT4^5$AvTzSfeJ3Z~T*vRB`q|FNLrzv;0nYw$e5=iRsMD-!{}<671oTUGGm?;5OI zJnJ;tN83*Zl*aS+!{rS%is_!N9eXD0jH7bev<@-nRc^t%lKPxQn=gYiw9^9+%&woKs@&7HUep@_G;~HwTXI@`e-9&sLO2}(^To?`h{Jzt{;j^1U zuJ?Y2_c0!L_5JyO`QZ3>-yfYE@{{L2*08GWA}Wc?hN8kB+W8jr|NLDOoeZ*XcM)(^b;{``*w75_V9`|kwbdSBEuP@m6~f9?m! z%j)J>jOT%z3kPOGybqrGOkTvtnal5f2mX&2>Tnh7PhNe0{+|aG|00*t&Vb*B)Rd!G zU*i5fa!<@M#QT)?J7pM#D`Tq7dm9T!g zsEK$1O30lntp5o0{o$$2lST`<*dLwBBR=ZiomUrfgWb*E0p6?c|9rG5X!zs#=&Ng) zI-)+yt}wp^pKjtf2IOmRJ#_=|@ahxS3yu^ruNRJdt=lrluWjD5sh0SC(1E)f;jdYP zZ&k;9=+^9o`5|9@e!_=1zu~oC)AxRbg%6$|xc6~}y?WL2{!2t9d{?VEqxs*2yu)~_5ax$FKlx@QWP5j>g_tjQ{G#}L_^;=p=e~!` z?Wfh<%SG}2Y=MKnmWTf$&em@Qd3n3N`62UsV(0oQU7)WY&M*P_dGq&gU!%;dwS4ch zd?fzE{DS$AI&p#v}Uidr2O|KO00h!-F^y>Tb9~o5q zlW;+w;PX|3C2xTLle3r2f&59t{te*&%f`7U(4Vw;+`bMud_<;0kUKuJ<4fSZ`u_ck z`QY~B&M#LEIp>*QhKui#S9Ru>;ScLRL-a43r}bM2zPx_I{j1t2+Z$*r>^ACC-39xIdY8`W@qbe|q)(@AG^6zY`yiq<_GDe$!Tq2Q0zwhc!hHpuemC)AbvW z8%`ScIG(@odG@hFo|2Ti5@h}3_T?e-^ZE1^*PemQ>z7`8{_(Kv!Txx}&m&23GcJKY zufKFDv+pY8dv#jQ#d_R@vEqqC$Y!+y??BFg17bp6{JAj_sP>^*PE(-w(=Q6$hlV?It;$WUOiD3 z@;OCRypVrf^-==xFW-K-0%WhgKmQFuGato#Vbe3>Acx>nkDkYBo;>_2%5Nr}pam^WPX${AVmU^BCGY-!D;q=cf%J^Lk^GrR7JD5i;*@ zt~2V@g8apdu6MB>9xmUn%U7tl)A>aIU zTXURu%T?pmCg6MGXr6J9Cr+MK88W{=oy_PShu# zXN$}BQF+L*`?Hv6-x0G?3!*(dJM+EF@DD#9U+&SVALN+7mh8jtm6pHqLwg}}e~@s@ z9s}8H-#0|s*Wo^fdDm~Xfjp*Gk4>oGoo`Ohi+C8Y_?c^Xf2#GJdI!P3 zM9C_bdJ5UA@6UflQ1R#b6u0z!9xM3#HMv+g_&(mYoVs@m1*ykjpJAa|iAHyM}X@ zAs*UQsrwb?2mF3Xxs>tiAlIp3Z$~_g3rjx*d;C6~*S>#z%nlm=;Q8T3`D|yw&$*XH z@Ze#;?!L2Htos{uKH{ufG3&!mOa- zkNpH*FXZ=$$IO1bAo%6L2OZvEBE9D#EAL`!m5k^wG-bH3=4}2duaQwXb*GJCv``BJLePypJ$rnTZi}`Gpt;* zWFhYt$6e7Myd5Pzj3ne!aY_Q@N!z~rbc~R{$R@t1E9BzW2X=t$wdWrXq0`U#e_we2 zz8?bL*A(9uj;Ih;rCnI{s7jThDnHckOY;0C9|k)EkTcI{I3Iksmdg4o#*55remRTZlVV#9LcDnO{hz1Jpy7|_Xc zL(u-q_6@xZ`T41vN`h~%aVKk`{=crZaU0}Y2Y+4-+4;`8kXzY{Mx(tr-@y^`XNAj7 z#_!_v{B!V+*S>#zEDaj}VE)AC^_=hG2tFUQJvaw^IX?LOl5;>-{LbgKocHL2?A7<@ zzbL5qCm$(Y1bm(Eh6+B+H#aW26*BKP|JI?~%ZQJr**{o)e>`~g{l|;l59a@f#|wVH zgX5jwkExisdMMT(Uw(T{GWrAWdL-X}%J)-&Y~DF=Ljn1h@u!VD`o|A&KdmcU9+2wyQnq)}z

!H_8*XAf5^oaq1@cR&pOA|2)*!M)L*N#7T8s@P%=Z`Pe_E^f z5AzSb2K#ZQJ>8f?e2Dcp)F0^nC0~1thleS{CSZIqc`PyAc^B<0KEwAH=f9=UOLh4! zs_(C3o6Y#3c$_)?&stOGL-75lmD~{xdw4LTW;1-xHjP#}LmrY64J#x5y1&h!&EQ*l z`fC%!pIH4L$5a#=f^S`Q|zv_Hlp4dlxf94Mfae3ksI$?tu{3T`&3*NID34coP zqo`e{$w~*b?VLs65-EIcrlNT;- zoDct!>BG#Q2}jU=X^OHgzCYhzod15U;=kNd(G%a3?YE3*^u|A6@eop6yH@%eGK?=t8^YTt3w#sF`R?}_i7(r(>W zeD8V7oPIzbzVG~W8T$YFc`py(0w*tQ8&8xdBd2U&|6+`&KpF8dA5d8b^FQ0aVe4G^gQ3+l>UDB z@p|gg1q|L|`eok8?pYx++|CsjTF0`!Qk&az+6`16x={+P~B(w3D(Q~XQOuVVF; z!khaY#`kCI4IvIon6kf}F&{8{(DbdjGvvechsmp)SFylx^gOE%D4ZiB%<6&JJnf`} zKJmX#TwkhcReYw7WXU!76HNc>l>Isd{>0d4@qQQ|to~PRo2u6*0OJ4^wL4Y0qc@c> z-~S5PgY5LBJpKG9#)8f#L0`_$zlT>3R0apQ%d1OGs&{GQ)W)%gNhev5x3A34BiN*^ zLhj>X(la<9L>_KpAu+eFZfa89Em+~>q44uU4_`%?+`~0EP>zNGrP5Ox=q>XXUgoRx zQ~E1{+`Y5*k;=S;Mt@~skgJ76BFXIMD+`hbC}ci(tGk!tw@yCt5V=nvTCDJMcbUJ; zT@e&6^_K?(D*c283rUdD>bE~nD)SgDbC>(Mhohq`C`jh+E%*5Cz3xgs4@Hnd=_fo9 zA`ie{mxZC!2Y-~zM;aI&7$o<_Q@%=HxnGcLfILtU7(_yF^$d{t{@%$;uJloQDcof~ zu7QdmfvzXL$km5Ln(fStNfIH z^6De=>l7Bod7k)z!aA{)PaN#rAe{%TCX*hry6#55LR&`?Mp*&O3y!Dkx52JEW$Ziv zY!d#Y8}#^ipT<)#PxP8F)OHf#m5bBIqTT=ehjr5lf9BpFPx=e{g8$=fp&_+>7IgcI zpxej)H+37|^S4i#JGK7n+y2%w7rJduy8Zjm`=9Ezmr}{8(SL|yn`hDLrFsQ2vrj1X z#L`S+?j*66TAS1Ct)ru<`3wF%ThAL+aw#>N^w72fX(;LC@`*QXrV+-qV9?W$X$>0A zAk6gsxk+r{xrF)t;{IK;am8&!^KbHG{>Hdw^;2CWdme)yThDIJ*Rd7GrD5oSQ<#5d zJY5Gvw*U;;3$9BiTuUdC%+U|LzheoqyM$qFDC>RU9gu_L+ zcF%&V<`8aelu-=rC0%Pzf!?yXMz2+UbfXBzMz7U_Udsapdo3W$zhB%By|pF2ndrE0 zSHDJKX89~A$;`^k+!}R*Ea-n4i~GXvu{yG$rPtPtCO)zghMQsca7+R6$hX>NDD2*k zKD`%#zi&{oBic7tk(68rJVRB7mh~66du7z;9TuWLN>|6P?Q5=XlDVCegz5@v@?Lr9 z2V2L_-jj3*{z}=OHi^h9Ve63l6UxUTPvpavk}qKQmOTph$3A+pa)CGWBjeR}SKx`q zE*+UkSjaykJhsqcDKP(jasBA345c2JpD4M%Li_ICRljJRh>5^mmL8R(FeT)4PE2#lKH{yfjy@ z4=Q=249q4DZf+&DvXDybHKh+2FRd*o(og)ZMtM{Qf8R}gy@Wh$zeWno`>ZZhP|fIH zkk_h0mnCT5e}NQ)urV&&COoI9!#T9GbED~ppPxiK|9Iym&V$od|nz zvHGoVqlgd7J4#C^yB~3(6j|r*$kTkhwbnzd^IsTKB!lq#x|Fd@nC~ynzg4MM;`=0d zq95QthSG7tM|V(@@mTML_bsE3@4ev8+=*Bp{6?kShI{8aj7X<!n>A7{Gu4EEu5;U*H~@ozsg_7vt% zA>K#0Wz57fu$O7m&(=U5JDV@~{^Itrg@fAp0&59fow_ZuvyU3C!wCD>zlC>GeE+z> z%bqdB&uicH4mhXL!s^4rMTFV8&PQVgc3Dc8)d|z<(_#fMs|!@M6P@%STxO7S2Htng z=6Kpl!uv85nPL+OjL!*?8ms9&vr@tKL6Tjkk}@a~N;7QfpgH>J!WJN4XL3W%DVM z2dVeK3ns7!DWQ`{^I;wsn;hJ|2#VyTI9ms?#}7^+epndmgfq4%ktds z9jldu@5|1~GyBWV4PAOrZ5qCJZAy#8c#G9qQsb=N zLO%Ta#r31DR_RAc{he#^z3UD99=)3QO#9mPEc9bfW#h5}Kl>UT!M76EEB z-(R#|^9_Xg{!TgA+c5eT{pdfs3Gvw>!+bjR=wDg)nf&BoK_LoxXx2bDwlTuxfr$62 zW+C64w1yV`)|!h8pS$5`1O<)O?hlXA>-|+bv z@bO=jyRRU8(&bGCaM#5{jnU4|AJsoT;{@8Ph0smV)Na(f{`&QV=T@#b7SFd{J3?mU6+UUmOOuT-<#hvoPFJ{@drCb2fNpwqAtsWk;2EH$TB@Ebc^4JiXV z&E(dgO?)#m*D{ z7&kFgZWQ0w^-}pQu-EghQ2|QADNFkrV4dVDOGpQ9Xlzks4PjPqvgE?|)gZ4XhwHY4 ze99g=bPer$?-$F!`vwk<7`2u#|9Oa%JaeP*sx8FR;OnvTkb`Yv*g5D)p^imOA-BdAD%FBLSr=UK3gmLW;l1y$CwzbL z_-&nnYV%fA63CN%O{@#IbsMq>^E}-)+TbAZ$=K_C67%pY5C5nGgu5Hk%|L|1UR|1S zj4-<=(DeO;**WS>!Knd|Pj#-pIR9%}#lO#s z#7EHohx_V1+DUxaxz~h;ZD#|sx`Q64DxCp;_Py&xpJ)XBe1GwIq?J=H{I50+*~bg4 ztuFl+=9-q-NwefSNw4_p@RCd}%VRyeui3~+S7p%nOE z4*iqY>>>PRYfD#*m*e)&p91sm7ne_+j=7M}>Kx*iR+dr=jpeaIKJ471&47nXw-O(g zx0}zg-yZl8EU%aO>nzWEPSi&U% z@>w)s+E_e~^jFA-f4{hX)Ko8@JoGo3e-3;~qCgArS#zMb1NhcyWRVY;ox{Jg zodURo*}2=}y)VSW-{AX;^RK0zziOqXE)QN~`@`U`!wqzgXVm=udl;@Oznjedt=6y@cJ}J`6?w)aN>rFpu-UPdpy-=uh-a zHTrJl6i3u-d>ZyS@7#&a)Gys!$yvk1DI0e8Bf->)`ie^Ebql(i?#6n}>tbUg=)=*@w+@59UhKe|!1JF)Pr~Xq~uSY;$e1Duz$}ax|=k8SJ z7>ot}|2@Z$<=owW{5b~I;aVLv$CGTy)@o}0huvo9UEh}5Q44Wf9dI%*r|y1-(5@|IWIPuhH|OIi9HtkgGW< z`iDpA{(D>$i59uhTutiji?kJQpojPF>(50#$tvABanRG`4IVnsTjqZ-J?&p(+)&Iv zV<|ln_LuLkPH$EAwui89`Cohc|CYU_C4x?RIG?3Z6ZY8nsx zV|H~5>G{t2C;UIfhI4~^C? ze2wQbMw$1AU3aN(^B8#dr6D`upA3yX+aLJmdP-y^%>RDzIMrP~_a0A_l`>YTIrk>x2azdLOaz6+~6)ENg+3G?qC zAgXuWa+BwyHZxOG{z6%j&)#En(F5@>oo`|{6lNzT}K??a>(re&<^gKPJ7WVIEmmFpTJ!W;f zDwd&2c7&@;j$Vhqhxsr3-xrmGeE-g`i}~|BA+^?Cb9!k^a%bajSVnyJCB(bkQ?KWc z^E)@$q!_|ck+HGBF65TsoCedo!HrhF!np!=E|i_?X&Ya;C-%Me>R5G*ruH7~Ej9sv zqwBxXpXJ$H-PZ9L&PlTS0mtlrc^$uhMwHnce5V*6%0MCfi_8qL=a+{vSQ_gi*yGN>^uv zkW2y8uC>I0bsdCUAvd}aQ~aQ|Pbuax6@S$%0G^$TX(|JJJr%&EAN z_-dJsC1`Zr!Rjxt^GdeAVx`b~_oCPAp!YQg1%<;dm_P3~d>LV%77=^U9@=o)N1Q|8 z`yU`(&Z&MyCo1pBHrfe@S=UmA?Ky@-QlLcI>xa zf0t3R%t?j+A17Z%lKUnoy-i^P|{0nVnx{>tr?$#n3SY=Gljqi_mPfq%4NNuY6UqAl;R{!hY2P^+$W9&lxFLA@G;rd^~y%^9#x|S0D zsr}*^I*40FeAwU)|I1>QS2W^51qYjl!QW-^_4L~pqTp}v{d3`82|8PbhUf51w<61l zII_hwZq?;&X*4ey*CY85zt6i1+{e6fbMyBB$OmBa&OOKJ(U8Zg+2$=9;AhO3{j&66!nJ4_0sX)A{aJI!XZh+Q z`Jw-=#k(1!J;`hPXvmB2e?`P@e}aDNe0_x6q{M&iclZap`6%4s1^ix+G)IS7ou@%1 zLWO#q|8+M0v>0g)&W4p8C0o*0*HpX+>t@-p%MIkDhwPkgd&{z2ya==U3fI@PeF}VL znNoi~>8Zh?FSgM0&x@)UMiCb7k0s3N!ws4_Im(kTJ4eR9Uwm8_YnF?;j{QDnSC^R% zJ5YTyjpbt^j(y374(<>i7FXD6da*C~wWczzbBO<}Z(~|OJ}F}Z+d>}kx?3Bd{eFXe zpJ4a+{^I=CT{)@l-U8LUR=D{6-|kU0H?yF6G<5up&iiQ8AN9ol5g)G+t^*KnolxY;~r$!Jm}y5Ztwix z*fkHCFO{g3Qt@EFAfaHe+@H!5%VhyU-a~_BL$JecF4U@2c=$`*WxoEIrHcdom3~1o zKe;kE&=n1tb~U@I#s7C!5`VX9u_{oJSXtPDXEz_YLgpb4G&O0}wM|b`la`7Ax8OjN z);;l;3VniPLBtI7Ss%aO3lsdu6@e~JI+ybzkCNQ2tmp|%*UH9Wu`KlOqj|);o8^W=JD!xN|nsw}R>=*L=#m|+lS8rD^?sRqX#I9l= z@A;ge)4+%AUkUfD5#F~lsucK5*85c*^GsFmf)jwxe3{l77}F2-ZH8RkT0}vZFMsRRQd>pLLL;L^i-%!jxy79WY&mhE}8HH zsXtXn$NK~1?qqQ?BSV2fWTsp_sA4r5&FyU@X6E+xLgjcCfz1pWWsQ9*-Q9!zWqv_Y z51CK`-bUym_=KXi%S{$84a2Xq_|2r@$HKdWAA9;Jl>t)Gcl{lj5-hBWtDJsH!c&!= z&k_d9WvyT(6|1KZvrvQIPY~?iqWGVcz5hpOKpE;sW%a!k{;E$w^97m8%(n*y1q8bX z1qTSU=A|x zBXRVX`?c=T!6Z<)fF!FO!aD*pr>?9)rBAT0pA_4H_yg_3 z%mvaKnTUkxJkt#S%ZW(k8{noE>2DKJpghP;<~}5=q@}r?YJx(Qv}o{l5PdMvJ3($&eMybJiz z%eGRXM;dqRd^j7I1ryS4VH_UqHqi)iS9U&d_#w}|cphOooO|Q@i`yyhO}WT3>=cyU zd0DC#E1`;Enp{T|2y#sZpIIH+CWDV#`>wUY*K1dUk2vQSw4-@9V0IqTtQy^*MYv~d z{{lD<$@jmKgPpRYpBvMERDDhW&WRSgLq?OAvm4?@OpjaL3GzrR?kk%Ilk>wd(C)x9W> zgSdoQ-GFBwy0-=1H0NY9@a?zw#AiJJYHaU8z!diJ zS+Z~t@#%{KY{X|x*A``Dgjqb5#YfExw`_;?YC?sd>2B2Cqxg@e$d~8)i`&QITC*<& z`xs^3?5qp%sqO#55BtaEO7$!0N|@a%wEOb4Nste#<2}80Id|ZomLty~A9?uxTK2$} zJ>F~pmY$6+2kfX@r~~pB`1gzJM^UYj5A379+wvih2dmRwaO$yzz*FKLJi~tceC?56U7$~Reqffb!1ovDUqY++w|wV*b|CTjQk5!55x?*Czoh{8{rbHh_@dkv z^ymAVgdxC<=@<_1f^vhZ;(5NmIR7o`_ZRYb-;(iV_YE$pe&iYWzdfRJbRzNV+`Zv+ z=tJPLN(CnoKDO984EWpNc0*?n=KG7=hs|2UANJvpLHo(z%kIUgk+LTP*nUsY*>K|L zvU1oEjCbLF1HxnHrVl0;lD_M6r{;{&l8XU=775%;X5<)-O?K1o~ zJ@&8bbJcDeYINVU`^O_m&{MKrm!X$+%?9*_-44BeW7cTGW*1)1!aU#QcKu0X3HOQa znIE{~f!Nc~TfV=zUCXCcc0FTsj0Aky{YOW;w4De1;&G~SIPpt*n{pm}**)o476P+- z!ukGDImCOu(zv~iHJ{GF8i-48qJL-ZlL{f)zxPT1B<4&PWXt_zn%@ZRY^K^N#U2^A z37RG9oEa1OYoF8Ve;n}=$z2r(CrI(Pp5U5Zkpde?(l`>Vbzfe7EHi(cB@!RWEZdgtS%+%)v&^_mt8b0SR z(RyAR=(Dun)j3!v44-&w1+Y_bqhc@UG491G9+ltM;Fk0P7bWZ-Md3*3s<#!m(ik55r$;78~|_HnsEp z#rf-NmH#yyg?6*>N&fN64&Ohc&gd`DhkF+5&cME1F(pTo@U)aVV}K3gPd&hWbbNpD zb!ADd;g5A?Y5S2)@xAjI%`ib6Mf$jjJ22f^Tps+M3@v{a*zs}Ik-)-5*o4{r#~a^W z>H>LG7@E%;`O?jMMHT|q+4Aij;t>4%#q}dj{rJe^xI?$K7axL;?Uc>d;K$}OcHeJD z6!4{X_g8OxW>ULdN0%kA564q&BOy<|zc~L=TB9GZ56u3t`>gvHidh4`)iY$Bz<1?# zSr_<0alKzH!ua^yqJaeM?0#pyKj!W=5iWW3j3@e9vFn2M9%=^8_{2tIrsmo37v&jvgD%t_Hy;pdlCA!B9j|E zp(%eAex(U6Y#@DH{AAJy*dvR+w?_j@`QGey)3}G2-2hH5Uwqp0agVt6Ad>fXDxJ@BEvJuET)`8yu*H{!@Ct zE(omjat()`^Y0g*m-Mu1UW!S$*9d$pt&7|RpF8tU7DT=PlSj=~GqwOTKR(TO&2>D# zboaI!@LT!*;(l8Zt>F*-@Tsx%CHOEqbzIu(5cn4C(rNQF;@5NT{o=?sSyr{J9_H!S z=adVOKf#{&w5W6*&(CScva9dSJ?U8H4DE4 zR&1>E0rDz)uiIDT)vqJSI61rbfWA~O|e0{ywF$8=i?Z3xQA$}8k3@?rE zt5-Rw2<-2H@Qtm&Kke?%35bJL>wBaC@Q&Jaa}Qy^+is(Pt86&*0rHx2_IYJswtvaL zUtB)V)yqdUzVhteqwnqRt;YB&owRQc=9hxEKl}v#e(BaS@NIWyY!!?r#{X*5?R$ZH zO&Ds6{n=$F=dOl+JzH#59P)ZKddyC=pWHqu72}V8zxeohrCvVdi{+L5!Mf?Z=hY|R zv*z~58Q{al6XVPBlx+%HG={ty_EJoRJXjot?=Q|@S8L`c@IOvvZG?QizsAGJ7RxQ6 zA7jRvv_M?A<;T^XAs<`c6AI|V=$&Nw0AZ5vwh9Pm-1WSzrnDvG3*Q9U!4D&+~|Lv?VrdWn!W@dW-nbnY)l5PImySL!O>|F?5BMZJo zBlYaCKhy>RBf^j9nn{d@i9RL+v(7hse{udMcb}4zCQ?5q&-k3<{yIZ-LC3~e9-r~#y55F zeb>)CQgswz<}Z%#-eC#s!O8wxN1?s?gW;ng55E6L(%+ooW>(Zz9{R1*U)bA!LUUc_ z-oEOn!M{3B_P@2aFLKB^%l`g9PLq@K(PWUbBg>`qMW25(BT(h`BtJ97fkr%+~?Mw+V>Va|YFu;B|*44s2x`cy&h-}(mm%sg(h0qx8$IMj$dVZv8KM|Fo^kXm)uN3=6Pihuv=T*Y6H{inuX|L^w` zv?BhhI)xJU(fNKEx>p0w8h4l$1fKAWCs0+=#&(L_6JuA&j}^W_ZR11 zSgYpoV)>421D_3FoVwyXfrCR-Bk=tu8GZxbw+6Z4h*KSyKda<0!mk$A$_MO&0+59H z{^IMAN9yBnsyMukZ8r5ZB=%Bs`k%(?U1B}*io&Xh!?^FLaTVXQ?x<;df!X*zn6$7q z;x5ki@h2e<7Kb~1ziv3j3){Det3($S5uR3ZM>OJCf#YoKVITSTi|dDf^<3D;XPdLP zkJgq_bB)#;@tEJV2>1*4p%5QM&)}KBwSV?#t|VM>T$%;qbcKi0&4h&6J|B}G-(Q^n zIrnGQqH)T|S7`+c2*FGXiG{hD#KKHs<77^C8||ocqbB`){e=z2L*K3G+R(woZ(Eo8 z#?TMhhKBd$gf}_pZi76U4B0)W38b zU%lGhn`@O|YO7i|@Ui2mN29KcLDG!j^Y?55xFp*` zKzTRtGmTmO&7CmY$G&^oU`6XyGi^RK2=-=D>6 zg?c>1$F=36ZTP-IU2MW#R=T{!_g^8Ydl}=Q-J{xrf!X}P_ZR2?Oso9o_DJj}KDL(M zFJgYcatWB(%hx+jcLv`v?VQ$QeyIQ0*y1^G@C`?hnFx$RoT@Q=AuL_WowD zvJK=T+z&(TO9vkMg!5y<{nv!~{^I=8wTl1JRuwja--q4W<{|4PZ&&PUr zOzSdrp&zey>7PUVi0?1XU#CJY;zM~BKkpgxy(Q`l(WxmZ_j>V~oToYmbb^Py;2h9dw<_~IDZ4J zil6VP68WM#@i`e5)e-w+rzcZl4`Gxe#rSSV>6^ftlSXa=uKe)RAYiti#`hQJ|Ez-( zUH+0|9;YgPo@eoo6`%Vz27eY0|6XU0Gq7;aZWeh+N;IsD`EIDQ@mI97_%PpJoPS@f zTK{jE;y)LBjwE+kjP+ch183uav8{^r&}rqxQ~2I)4L95b_HP!v8JMkq`TpYkowbU8 zn0rfEH{!$M8PBc_83s(JphDmeZthKyP{O}1oSh4QsBzklRNw))h@LRtU!4D2tQUvR}Je{ueOv`YWSAJeZ6{vEpPUx0Y-$d?Vf0b3^cZ^irO&qvT|&a2B5G9uX7P~YkJD}fciicig!o9mj`QaNKfV2G805*nUtB+mchn~T z;GtKI>s^QsvxgnbqB=ufLfnJ+IpG2&!gbr_TRDhu%Cf{KXlL;;zP~vCep)p?y6zg= z7~hlS53PH0#~Ik@_@>QRFU)JdG8*GS*heSaq+Hu!z;~Z~^@cye_irM4&boGWwfkdv z=pW*obp<4r5TBcKdX>g}-@x$MC}5`FGuMifd`cR=8u+6oPyJ^6FQ!OeYzD~CQoB-d;-B!&4Ha-@!9C&fy+s^RE`2OPjduSE^qg2+S z6Y&x9tBKD6y`E9P!+dRe!@mfBH0&1SA=INJ9Gy{X12Efv=lhG#x5-*F9x&e`&7>po z5%Ls?-^SgAb^){bWLm_o7|3Id_ahg~CmWZ@1_KLus)W-^Psj&sTh_1;_A3S&AAXDe z!hIuz`S**O32jtVXSmS9Oh!688c8`{?0iIqgpd9=W1p*a>-(O6f2Hc0j_Q3g8 ze0mFc4DV91C@^IOo(Hbf=9)3EP!EPM|9iLW7 zzpW?mu*B#$;P2`#S;4`pmkx9V9zC2+|-(ROO-r4$;*^5rn(+PNU*zMwlhuhQv7V3Er=6|2K|K33T`b|_{EH#$j0sq}4XDakhgz*nUgrv~n)MEZ_b?+tX!%7uOBLVo@ev9WzT-2!T0-ul6v5``b5M6VAH)*jw2qx*2646 z&#_9`!Qk)eGG-*6=lhF~k2>n(D=PaepOszyv8B|~UTUqe^9dLqvx~2u3O*N>4Elxr zMT>$%Vt^xJzWjpyFn@8p8{#4CJd;dGH^veU-S;68_7>@>z~#5^y9)fgX8(b}+aq?G z4MBSY76E%6SB>cT|eU}AbJ~xa`GnzzvlExif4lK7@dLDIE*5@0) z8u27nuVHJ7YY6b&YR7dZQ#;>ZoPQauvJWg?#O}-4|9VS%@K5MTC)J7HXcTBBtlO&h zZr~*E%DsU1p77d^`*ryK;`|?~pMORD7tOBs-09k9_$%elFJA#ZFV{w02WID8nSb}? z^S`p#$M&PyDXqdv7qW_BbnURiKgsp6C0E^Z%w*_lCM1nX}26`1N(9ohag? z7d+dcFX2#c=V`#R>rd$eEKT-3dXVt*;HgLOe5IX7KLYdp#ra#Q*MF6NkSG2(^gleg zY$@R$To3=K1H^~ji`&_E!+79UxFC`EvU`R*bR1S5_)AEqfFsn-_ZR2?Rja=Ljhf9< z`w*YTHu>M-dyl@+G#uaed)uJ%z;W;yr};W;i>UgP3C9SpiZwZh=PNu~7!Az# z7w2!Io_`+ezp1=JD)>}gd9iXL@#*!|@&Ndb+E}zWaMNA#Bf#w5Xu}$v9^?7WQ)W7z zq;|f)IRCcl`K#8CW7|g-X7OGNI=pLdW^RQ#z1DUbKEHg$k;vUng85xcgt6Jp6@TtzrA|?s_)<9MKz-?>azYZMuIoTV0+5X7qE6+Cp^Zmu|v0L@$_t>q^=^i^9skMb>>wDEL zZe0|9uzqXD)2oSR^7Lm#FfQs1{2mS5JRey~!u3;w=4>I%_DQxJnROMI?=P+wVSU0$ zcsb_vs^-ngX4msEyJ>E1CZYdPeREr>L_(njO~oyt7wKQ_?*u<~pZcSn2K#`mt}9lr zA$}~+qU@nV*MJ?LpSgs%0UIZLe{udh>K?8v%70MXclk5<)466FI@;`HZb3yKq*fZM z?+*SfZnUSeaoJVGuY^(BPs|ggA1;qrNchZk`B%gVmNhZGvWW2f`Ll-sw+mS#hko$= z#qDM@_47s^$6bR%EbK5(q|WVHa{=*TaevR78S}6%s*Q((q1AlB2 z%qPt9y7cQPGSI(6JNpHY-?*tp*1-Jx#pP33{kk#_{VL2WCogOp5BbdLS9f6y@oBw! z?Lfp;-nq#p0h_v|$bcQH?IEwU@~5g~#Xj+so&uw+G2T~fbJ|y6{{7;1 z^~j&w)%cw3svRXvYSyl@^>bEr@ig$~`-|(v^gq{&lrm&}d%jk~)?;e*Mu7$l# zJ*hhzIAPt@6W8?l`l%8S$xdV)fx@;!|XD{PgC8 z4LV!zfIT&HjeY>kZoWGCy`9^%5uF-YMw6VdJz-Qw9J=4Ia zcdXCz#sF{VT6jCY_tB#r7UB8%v1i_59P$0F=bYh4eTY|=F}EAQ!ck(KLU;ntLXwc zF+E}P&iU0{uHt=X4)nT?d4_+#xZWtVN^h8+G&HuT0zWi5Y0>PK#ILW>gWB-hR`rj4 z27Hi?n`1rWwtR>y^k`0K3Hf@$e1CC2Bviehp2vRL@Q%ro@jcl(WnyB}`S|{aZg!6W z|F5>*vA}HIGB7=*CdN&T4!A< z(T*m>Z;wW^beC`TpYiQC(~N4cL!& z1~K2E?`$2%#z*bx7p3^#wVnLKFh0hlTw1k(@WYNx^&JQs^{&4j<3q2Si7T+LeRL|G zpK3Y59?wUZ(MdMK{QJfA!%Mw>fAlRH_bZsIr#M-zHB3K zUyG#oXg4jrJ_FC=u{gqfe{ud*wMIX%&h`9MVmJJ#f)mTNhCO&Nh!O+{&ueFU6#gd5 zm-+DI);QSLHyaC|#q;iU4irEN=`!anl-#rd14|NeQnj>Y$nu}QOPO?=pVxW#u$669gFeaRc} zFJCmJ1o*bR)^k%V;pE-V!D&?=pl7F!XD0l7Cj03BYc1HeBjXB@XwR|VdMj|`P~=c ze&Vxj>dM0Kmu|G!mjZd*v~x29?l5LiKeQkHJl7m}!=%d{fFGX?i3Dc*Da>E07k#49 z0c!s`WP4lqXU|HWYqFOx|NF%KiE3IiUf@qK|FWh-jg&pahpM^shyRq;gH9L`ZvN1$ z4{*%d^Cf_F7g+ei|M;<0F9Z5=xy<}9;Bl!*3$Xvxr^Sk;ke|n?N@sxi_lu7wrFwnJ z<8`e2>wXU4bH3rd@30?5lqDU&x5mNscVI7>Jf>Yguo3%@1Ivx_$Ma0z`2OPjM{GDpglqrg%@uqQRg z7FsMN%;t|F?a1>a9MN$=5}xP#i`$3UTEidq!Kvq*U_0Vd00~FL=TpbN6M)y5(8)T& zD^@=51HSct^>YEfPmzc%gfB;$Sj`^EKRrq;*@{?_=( z&8L9x+C3Xz!rt{PxM~jYna|afkjJ*eFPCDxygpmt81Sil`xgP9nlZfvELS20U-+vsxzz^Y3?}`7ft?A*1N;38nvZbev6O#q;U3 zrGE{t?iQ@@5pK|-`*s4|y_HHI;Vwmgd4McP8Bm>m+D;K9^OXh3Oxi2FyyXEVJ>_(T zqK7OX+@z=K`H}hQNFR>%jcuIWv_p3kP#GF~jors3#5qd#A7({zD439T3*+jA-Kh1z z$L*g#grN9B~dd4ilp%S4SL#=|czi_!)?M@%_d5PuD8` zhTA{bz|I)Z@pt%Pm)0LS1>Akom{yQSg~ktR;d|%b{rWNDfc;w=&V^k`j90#f-^%xo zp#h$gUHM5dj5~;9=sedmB)hVi{;kRTW*roR<$&>;9x4>rJHY?l> znfWdsx;NG*5WRA7*=%O7tQW_sgqhi-z4yj?DU}|=7nUmmeHHGm0rEfv?uB!ggFZ(i zQ0W;&x5g<0wx#Ij4FjVbm*ZOT>o_?#Sa?*x`yk z;!zBbEnKJ+@Zu+vMj$WcL95C0@qFWZj?;np{^EA0#PM9jtJE$WG@0kxctfL3_|At8 z4!Hn6NMi&(QcLGo6k!&xWw-|pj8nVoPKyV4p6@Ttf3nt$8_YZBs$RYczc|K6Hxzaw zsr_d?_{A*G_4BAzmEi}nJVy*KVf{+wTVY4|{^I;6X%+voFDA7T{Nlgztn>;<=K`1b zGQ0m=;+J;MYXIzStrz}57#~N9MCt+8YhieH0b#zsIRA-S#owzKRZbv2W0n>whVzf{ zGmU-%f7nv;#dN}Z%j>=X-|k)GH_amKGjGsP;88XKcQ8Kq{^I;6XchmaZAz4eezzRG zqTMv&bEHOnCt%NmyMEHF<@|xSE}eT9IDCN9)JcRl7_EH+`@k^YU)*mPuQmMPH_RV!JQe(u z9mBUo5I;8F>n`0<5SaNz>|S#RT;xX2U%zKCW;C_;`&=^)`tjuvZ3Gf#aTIpXIsblf z{rHurP4i=-OWi@34_W<$mj}PDgrC&iJoz5vbH8NsU%;$=Pn_QYJb&%y*G^NZo$oKs zf2`K%2j)lSH@hzAY=rS&`lqrM_*L0(=mYp6tO7izslz$gM_m7gbxcsr=wHBme{ud} zw2D95zZsc)XDj@j>x5N(bTL2j{l)q3 zY15Au7df5-(<7zS)tOhdPJzsGQ1)gLD>Dl^2SsrSItr%I`_WxGoh*#;eP$eOToE6( zU(MsvN&EJ|UtrJkm_0Af-&w2n^GzFc>4F#YeF#fN5+g|`? z=fFDD?|Bx_Q}*b0_{)5MasGqU^H=4^rN7PY{4^aNr;?!>t)J5Vw)->iTie>;AjW(7 znp7#0a1GDT;lM+C-3SHVGJz7W39l@Eq!9Ay*f}`9zj)kW&C^5lP(2#v!>b1>g9HAG zTFS$MLKN~)#5Gw67SYUbc_0Fosw|f)Ih@zArmFWEJePoZk>x?MbyAJNE6?Lx^x49+ zfFe1tx-qw}m0gN?<7s5$a>yfO=Ue#x;&NI0=W@B0vs}z=H5*Uq`%NwZ&vs32oP&Oh zSe)?^a$)-8(O2>g*r<|ADU6?%yQ8h}JeyDX{?pX#v%I9Sa8B4~Ne@|1`z$e8_gRE3 zgTM91(O>S@x=+|BrFH^ox;sj!igbpym# z9z;7KS;z=mCSk(s{_$fWzHk29+cWn{gx9liCH;G^M0i}aQzWOK3ev#a1LR)8KC*z{ zUPc=%ehOkn&A)T-RR;XlkokLZySw5UAeYk~h0IUbm(NO98R{nw2=rF?tNtj}$H{zu zU{FA?dr)wIAYUJ)%)`}9E(@T)(bX+L;XcGQROaL3s_=7lm*cy-(%(s61f2Z6!vhuW zG9OoL>?@!Gc)vgGKcO}7U+$}@J3b{L)1t}v9-SU51pB;n5p9^zcyG-u3)b}obdjONh6T2+Q0U(cOwY%{l)n^{M+xZ zQUA?X$HoNwTt2_(8cKZV6m>u7f7Fno9{B#(yGBI-pF3x}68u?yd$eEO@z7w2Do zcrNN+=9&Mx!=>0G@OwPFdHFEn(~io1!QW_cr^Q&vWBHl}DZqxq(mQ}}%}X{Jc)o(I z^#x$Qzj%DlO{?mo=KI-mBlr}z>vSrB_&i;9-wpO|O5a8kfLGk03z`YD`lln$w~v54 zSpDfjku4Vjm!tAQ(3gH@aY=aJ!Hk;CAV2>7;`-sCHS&Rdz%|VnAM8GWAI=F%tasRb zAx+ym&IV@p?I7%j=g-A;zosPqe1CEMXqX%Om}m8KS9~zukMFtFz_f%H@fkAe=Njk( zkJigYxBfQsfMt$Flp3*Oz~y1mD=`wR-*}kNvx=o8x&_Z=3Hg&cB3K@xOoTqZj-qcAqIb z&og88%hH31Z~Bp5OW`kVX=HE{^9eg&Zt!WGV<2I^zc_!RP__5>slLB%dUp5aQ1I76 zYH6X#^;75j*Y3a{9)0x27CG@b*fZZ3U~C6M|NHc7`yBCMcE2avA7b}A^Zmu=hli7I zh@WTLHXysiavQ||%r$oZ1@;HG45rFe#P7xCvMKJww~8@@$VPck4IV~AK1r2F>5@)XUBQ> zD9oR0+l-t8zRdoyJgUtbqwguGot^Jt_vP~a#rey%YJOzrQJDREA9Xbd;{oYWe#Cc~ z$+kPd{`2aTg8#_$gZbZle{ue)TJ`-q7N->w@ymDU)`6kKukfypao8V!JHe_x=7VQ3 z%Tj^aeG8@^6D;sNlLy~loPUH??GIA6*iYm`u=OO%2Vv`NmOu4gKelE#$-{W^1wHV; z@?N(OFyFrk+4t<~77e1YrpjA=tTo>sd8>!t+&h5pd!_`PkRd)12Pap9J!nvlPWTb_ z8NR<3u>8S`Ap;4whXa)bOFGD>0Pk8h?VXI;S-uUA+5K~2^@2~~_p$pP`QI;40R&6M-k*O??2&{7t^U`21Q=z5b~7 z*IF7r;?E1)A@$wbR%)d&{{r*twX{Q5F@IKUUv6=4;`g*`+%MoxIvMqP5iYkZ{~*X` zOz}yj`Vfvf^0*Rk|MR70^d)@Sx}XK-8$Z9!j?RQx{-)c<+YWf1f4{hX+*dCjmH(9| z{s-pQSwmVKhrZ9~LnqjXPnE=EL&&F#_Z(;7Yw7OQfQ7ogciGG0p_Tsn4g5QIYy&5_ZpUpPEVu6`FLK5yT!uKDs=i#27)E;$nsyA@* z-3q?YAHKgh|9e{X{XhOxtODQ7{%6|Zd%G^&{Tx`hr-pFdw_TgT9 zVXcgffQ9>*32*qJvl_pD-#+(kT?n(h@;az1O!)JH_6oE!T)Xn4LBMCP)C_Bd{(t46 zGQ4E$>}c?1c;_+Kxxhkw9(sO%{DU~iOW5Bc%*#)FTs+aLaq)Tkt9bAWD>gITl=$3j za}7 zmwJRlzb(5}_yv43-*qGKtLwgE|6$fUn0zX#AY~rmI zGIPR*i=CK+IQFU2jm877JN;!EaE-F&FC~Q8zWJJuog1OuXTgehXg@xO6o@eYesTMf z`fu|FG+K|B@_J8{d-cwN_*p!SxPW~hAzz5_L%MDY^UbLz zMvj;-SRKJqQMWz=7k+g$3HG<^ju!jj&++dQA1~*$%3iQM-^5twV{M2Jv;VV;y6a;+ zF#Qnr0|~SFqR_I(5Ai(TUwpjWQ_nw-$4iwTeNCa?uN?xHv?V^TLk?O2Gyl7CQptI+ z7j}nbD(C8-Nd`41WjQ$RgiiypMmsxPIJGFCSGL zN8LWEM)Q!^K6%5_KC?OyAC@;>^yB0vz-J~{&jVlDJwELSyl=EA?9Hc$T2;Cc=KG8D zU)s5Vjhb^LD*kzPPjmg5ei7ieZ9NKS()|v9z z1?KyUj}J4g^2aY7Upo)>(4+a(HSLK{TG(_DqTwwnF;_@-oD)}UJvYm|i!=h@br=5vULJ-}+N0@GJ zzYg3jpJXZU`{=Ttpg$}RY5Ju#qk-9cvtwTLPmCW{mz(Jm|9v7IjVBb6Yk`gFR-i*)dLe=&8MjQFs7FDLI#J`DXRci?FQ+#A63t6|2jI>6sPZ*2+v zSlj-PK~~J}ja1moJT{2h{a>~2fcByddtb?luk5isjNixqe)0LOdT!Q}c_Kf=E7`qX zx9;c$K_2X0g{n*Tz3)r-tKWwcxNq{|V2?hq@5u?4D}d{^%eNBp2-!H|Jmk}M&}JpD z3oeu+d@#{@E#Ak!UwnS+t~L6F`EBBllUu-dQ!NVg6QBF0wax$+y0Lu(^lRhpLc4HJ z9Mdnw$*?HkQZJ_WgMRS+#mASadi_vUV#*W$7~@NkTKRrfzSS%3cnO?8qGT=TgK}9f zPw+(<4_$DB&0O&8J1|ioyMYM}HbwVBdT7iHHTjJB)SQC2kC5#P z$M#$J7VVkxK3lu;4 z0FSNt@)qQKBxOqVs)ctr)h-GdHg(a zeJL=rxAkJ@UBLLNhXOd%{`7F+Sm4sX;?@9PpERfv{3HJT;`X>#o%TO!u*|3}n zkP{z|z^hGwS)Ks<{&C0smSG?HSi8h1$cxF3ofAxX`>`YB_o{Ak_pI;Z-!Ja(nB->t zl_&NG{tk;@v%DMjy}e8qC3hvhil0XtV7xplx~nwSSEma6GzMn!^Dq273i4q4?)>}2 z?aAYRYfm(~|6xtn+tFFa-(Q@6 zM~hted!2Id_be@_^oJ(%zS+H`hK1zD*q{HlvZ)LB3ipE(?y>JRSTybHxVc z=YiStO>v~#Q^-+e0uNeO-taB zCgD#IuUt_6%tv6VvRNPed)I4v8Q605lvMEN`-}50tKOd%_ebqDX+M)8ak)$lHuhn{W^J!&i>mbPCw;<>6%DY6Tsu!C&LwC$0}4 z)XPJq4~64O=w{Z@vK2~vn>$JDP~uyn(Q|ZxH+u$jB|dj&wJiwy_pr;Tsle%_sUSb$ z;tv+c9l`ghcQ4==UrJ~uoZqWnUp&wE7w7-@-}2X}Kk;*B=ZWCU^0<2?tX~c6S~$4^ z#(zH#swhmD&p!{x1MK0-`fD$N&+0S^@DQIeOE0_t zE{AO(!ucvlL%I=mPR%C=4s&lQ1D`{&8}3jQQz=lhHEzo%Y5 z#ODW%+DBpCNPMQQzVj6O1*YvS%YuK4|F635fQl*!)&|T9CQKMHqU0e+R1Am-GiIWK zSy4oh#Z^SXgocCy4KMzSiay@cwae=7^8p?_2W{aPje<%mN9E^78!eXchmZ?yalBK8%~C zR}B1`x|X&A=Jq|lD>DW5fcF!c6h1N+nD+}rd3pX_RQG>n`)kBsLjRZiLgM58v3z5+ z|GMR6Z-L)5{gCpAZ(pZ0^p1piKV;yz26ceV7cKvU_eFVm``A=#_`^P4tTJ^l_@v(T z8VJ5peh*=8@7;UL*Xcx<_fMubh_D1sYxQJ4#{aJ5kgLFR6b-Y0B|nnzh}7?=@ICSS z^8Mq*uZ|D3`j03Z*n#*wE3>vB_$;h8wm&fERkFSYF0T0+@mncBjGps;ohUEgKc1=P zuR;F_{o}LA_HN)i{&Tz&_zhfmcNp-Y4(XE-zYnwea1?kIT~`L&d~J`jz#Q-9@pE&X z`5DA-$L}vz1O1Knm&EVO_m4(eGk#z{-nPFM1HSKzBvt^ws^1r+05^)6UIlq`Y%iil zRCWVyeExSG@aK58C@;@H=fCt1jrk9s4Gax(^WpJ6wBd*poQD}&)U2!*@%3Helnwtn z#dTaN>;<2v5#{CiH_@v0m(KkVpY-4@ZLwd&<3Av_MH!5TH+r;?qUW5Kz2MA@7+@Yx zqP)C)up6K)@dnrjlpjF*bNoHcBHu}1K96gu`!N~qU&^RU4$hNSjxRU{`1}~U(3~*O z7aY%*;u7?nr`NS{c5E!9@zeUx^iR$ zaJr+Z1MCOKuS9uy{(31Y`-i-Lt=9QdIyXstq;)0nt7`M0DfnlGEp$M9h3(<0N|aGc zIPv<{m*9W*!4q%9Cq#L9{`RWJhw^-Z#@e6lf5D8bRPYH}vE`eY8TKQjd4X`X z$n$l;f0E~?k{BOsKSgylFVEjrb^9y(f99%xi$B&!ods*Pog<2)@ZD91YOF_q;Z4fcyig zoeAT3I_x9QpR5m2UVeUX)tdIl{Gg8w72+4S^yKxX#LpfFrU)M%JI=TfVV?ij^tsp( znE6K+qXV^sc|I_M!64kG?Q?z1pA{O`Sc&{+@%!@qqoV5l3Ju$bQ|3k5gAeELq#4qV zHet>mjhL{YdS}9%|GDv&VK(f8bZ(URi}LdPFaGL&rdr1buP=j=tDVPq*#6Yn3;Zu{ zt-Gl);ciE~jlefy!rAi3ck=KV_!7@ijt1jJl$YnPP;DQS?Z3w7_TT#xjur}qg{?LA zCv0fHU#-7uz!v$;PaEp2M!a=ROR8)}IQ;m98Ni(HJvO83I=p{(ZIy#g^xX0NvvgpCjw?{&zF}Rc!YSnUaO|lVE?3ZV1#)-=<~6i1N@6q%iCPQc<`F$YXIE0 z#AZFrx1zi}{{~uRA8s`1S_6EhJc#cCK4YA#+5&SvFVFv+-^lSP_BTX%`SI~pb^B|0 zeBAKN1XgCj8$}6YmgXua};FoI(Wud=G>kz_<1%EsT zj=R$REuOce>so;e70sB9_Tc(AUdj2o=Weg8VyQFmzLPuMY(Vehx-#J4^v@e{eog%M z%a6}*s`am7`;@TL#|rj%Yfxr)@ZmPoQSHAg z`K!t=R%?921a*^$59gPDFwTD;nB!mWQ*=v1Kgsdy-od{eUYt%?5%#Ea`x5d?MS1!2 zM(^6s?@ z@c7JQNst%6C*L0mYgK=^xhA_k_-ofo6xPIfQ}br8|3JKh z^N9vFA2!{YaKlGK=;~X#$F7RkU0|t>8{rK#i(kR}qP#r+5322jhUW{(FCjisUMKM> zM``xZ2gg5{Z}|F``_X@-x_0zjszX6ol$YmU^jF(ot@FjROsAUQ%jfwsTocTI{hvN+ z1N+DPrTb9`mv!E-5&hv&mE^~mFGP8H`{1NC{ygG=!RMBILq3C)he~|rNBY&o`E9B0 z0%6W4WdG}Xv(xkNT&hz+&pCgL{ktPiyPU%FJO07Fpda!3^7?tJdVDDDr$+o)=;zbL ziSLl_E9Es2pXn;;*C$G>O!Uhjapo=uh62fqQw#J&0MM(^A0e6bGtmF`O*%=xY^ zC{#*V{J#A7DWO&4Cvj>_EcjL`-Z~%nN_A`qr&q|AjrdRvDu;>wu;+sFFvJ5mKSrus zKvw3u z&p+!|`KvWv9J{#ZWAKsg_a=TPr#&fx`GM<==G#)i75>!n*zG!qm+d_`r6Mqo4^dv8 z|F!>OA2qVxK>wd}BkM87Khhr2{+$2E^%13W1oZsW#J)w)A3XG3V$ok-POGj1`yk58 z&kxU3$BQ-WU$gz6p);hM74aE)pn4_P`zdRW?KUI)SG?op7Y96-@)+nj$B*^Kom>w8 zdceN-vk_00&RG!NI3&Xb`Vzk{ub-D%r5~<`F?ZU@PxXk;`~~@gp`UA>(W7b+zID_7 zF~-YAx<;)IVfMEiqyH#sN|^gktfAlYx`f#u$uy?Qa)ib2|4cuT)K>%PM@Q$2WIt@_ zw}^k-ARQ7kyni5F|Jq-;N%!wdaO*@#(j99BZAqy!e=4Hy z=FS)|R}U8*Q;GPp|8+5>Z=DK+x!%Fru{v=n zC4Sw9^tT5ewlAxaXJn$k^!tr&Dx~MQz6bh_B!xr;X?E6vGVNblXP8k3{jjuvi;ZeQt zKl7VeUS2=%@@#)4{p2UZoli$&{tf$U^DmF~hW$O!eMt!VM^X?~`XbEx7xoc}Wnqsw zo^J8N%?A1sxILR!0nZ)!*aG9Z$C%ruz?Q>zn8Uvkzb`*tQ}V38T=SjA>>1nh zx?7`GLVwF{QR+KOUznLNR_gAAmCtkUr0aIj{wrGln1%CuzQuRG#Qfc1SzKTj!Yg+~&c%FWabjIb;K0Y1 z8$%z#ucD9Q{U0^@WkFxQ(Q}pnC$uUUiSLQum!F>|&sQ7z5p|Sai$II7_Zo%No z=R+I6D^UabN?bLl8_vhkF4Aw%=jg?5gQ1_@<=)-|o_*V(KX8Hl=`nbJdd-V>@jkET z#qa;De??MTD*dI6c^-d~{*?Af)p|^=>(zC;cbLMTIeoV5Agsss)JyRKW`DZzj?0@d z{&;?GK66V9aAHNP8|ZJMynO#k&9i=T*(Z(lFFp^Y_a?3$))%EGm?c6#8|_{#gMUfY z1HR(CI>$pMFL?Y0nAa;-xR{%8j8o%do4aAGk+)`FVJs^J5+E*|`JrdCwbjZLa}~-~X9@V846QkB&}QFXYG3wSYoAws-L0 z!9gLte;$sNhL>`=t1>-6N1gFl&JWwawb^{=s~ikC>F3A`z1jr`OLeOVcWFL7v>;*j zCpwlm^BDRR_4jXqqh7Utlv0Z*rd_!2}OzT%XKY0F`unB{j$C= z;qf0jrvt~8YJUf~?V{^1fvNXQNB^N+#FNmc$p@D*&?nDdruSPlfWF1=%lEH=dG245 z|D%letFS*d*6N(Uqi% zbp`K>^78uWpJ)4=OFtU(5APY-y+wQ^YR#Fou)nUYsV5Px*5+Lx^y6CI<2Ur5gSv0O z03TacDFB%DDay<1XLz3bj}%{3-ngtW{c!zCGtXNCF}^szpq}69Cg>l0p1K?g$`j^z z{f^}Wb`~Kl%FFBLN}ksjl6_J-h#J!m*R!Q8(#o*U8HH&jM*YJcg%zL=y0#nf)&ukX zPN08Db+rjk^`aY^2#fNrvi5sfp6z!o|4<|Pf&G^10u$fPw>~vPe~{_|6SlkJ)(-t8 zWMz?S7=O<3VHbc${r)fonCCl=e;A()_Je+Te`pMCXJbAPzb~)9QNK3+G`Qae{c(NR zD^23Ymj+)Cw~C182d>v&gY^jKH%*%G@*6OZzl)v6^?-kHDZ1Js=yP<@$QZ@K{ z<6}rz{J#A7{4LM=%VpnH`44LKKRF+)@a((yz?bW*x>S073i_}fuc#r}^FIQvLx1c~ zgp{3ns|;aLUS2;DdDc%Z`=yci8}^S5N9{|*e$k#WRG^&r?p^!fByhMhtyBodr`A7& z{>J*_{JA!l41Ew!66NLfll_0nzPE}PZv}l&_Rx+}#J5X{c@u%(4l%Eb{v*|qA>7?A zApv-G|MKVXe9NG9R?wd)FTZ|Usx|Q=^bg)owV6Ki8Tj-3@0)YJretrbRDLGyU;h~& zY1!Oq9`M{wdcDA3l=uG0{FaHoML#+^U#0#LLcc}S7!oizsD`s~jZXbSf`W`&1q?E7 z9xx!63tj&*X>@i<9JaKd?`ct2BNtZF-@%=>>XS(-6dx-M# z<8MWt{Rd6&51{|qPF!9N`eFNHk$Tu2xEVI;h(FgGaBO`v1pST2C&zn5`Jd^>pZ=CI zep^a)zJ+rA78Lg{=N+wWoo$_*?bTMF1@k}3KqDVT|8cYn>@D96P;1@Tk-b~!+0wZ}_@0wIl8a?)UJhJ@g~W%k!V5Rs3g%d~m{j(Y&AH zWY8fR_eHz*Nwh)yl5XvN4NSL<)`xyfGV&+m{cYQN&VoKf`3W?iR)6Km37ztTf` z5|iUwMpvgS{r=A!n|{F~h6iC+MHw}TZ$+h_VJN17&ft|*+$-vQbZ=c7w6#xCMKUp{V6IZ3*+DU8d5bkwtq1dpp1-cI%6spXBUmHr9`Iii8L=4SVMykqiQv;KaiAZ@ z&u24=d=fVPefCO>uM5wgnPJ`B&u7^KJZ~8_v;fvQqP#qRFRg0-o&leIF)we%Z2BYWK z#XPQmd`U3u>!S9PBB39rO+HD`hr!LR5Agh4bhmHlFQU9Wf9qf5uTbm0?eQIB&C%Z5 z&Nuc2Klbxq8BIP7dud;UZdA*qkA-^k6C}U?PyV93JpX4>+|7{2-aqH?@Sx$C!R6QW zYQ2Z{#mHTD;4`+&h=<5)DY)d}ePE6|b6pLTTf{lh0oDflv98&(aLASFghlyU|H+Su zmh)qjT5vynPM7@{g}sHf9jenRC>7Y&Sz)g}KL+uiR|itcqo03Iczy=?P=m6vCjpmU zv1>2lK>?1Jtby0v^7I65TFtySaQfc%6Ja;lF1$E-bugaudg#Q1f=!V>Bz|AMpZI8% zom|#)!xPwvwDbXsYZ9NV!oekA2l#xoR40&dTx-f~rRUXJwk!etaQ=fRFVFw|uZ|P7 zosYphc_wUI8tjB=)$$i=5I?DoBH@59M=Rhwb>s1E5-={3qUhom!hApTfE6W=)Fv#- z%g09#sOGQ1_y@*E+p+I!!Y=r>m^cyjdZju}gt`8}%3i&F!GElxXPPNJ=ljfWocR%A zL74A<=X%fEPk2qj`&`dL{JwnuFx9H@v3_Ux8ua(LetON!i7)#RcGj=c>JYwUM;Ct6 z^TlZk)|(JMo_O^(`qP)EYZ^g6qP#r+9$LkJE|q*Am6BBwdxX|Z<(DJ1HZ_oLU4rNpE?(b`NMu* z%pKrmBfRW^Th%&q9PiH($HGhS8V9qyg<(tqO`r!FM zs#`{w^(D&7^Y5-z{JqINKznjMnGHqyPXm6t>f;`?w^3}V$r$fkpGK;iN;q^|_6pbw zQC^<^4~X0F!%@g=GhEI`|8)| zc+Qgew6ApG2{61Fm5{73JmQP|Z}YqcrFrAP#k8 z)A4PH6E#7CJn^|&b=Mu>ii4iI!oHszny(?o1Lrq(twX;NHfj4J4CB9q$1_9dE4$LX z>%dZ-Kf;{9m}B191J9QgqQrfAPPaE#20mtUU?cP`{(X7<>1a(H2KpNvIQ#_ zt&!8>^C8Za{-ts?w2$Rsvy#BL-WKnRe_ww5b=E5T!*RN<1uqnYzDBqu8>4@PO)QfS z{)bc-kucY*IWoV_AUt<^Ue5{di}LdP{j`ez#ud#wp*=H>HyaH;(!B|Ulij||gugNI zK!bSn2lhWKX5SeFd^@S`X}mAW%k%H4Rs73!F#H{Sc>Nh16}$jAbU}fuSbuXIy9*uH zM4V&a^%oED+|bl91M5{$UY@_VR`H+Jt>X*uaoyE19&wrF`H*66W<>lAs9;*3kaQ?;moclA^t9v$ieKGKVTx!`PoHva;w{sNm@sA&j z5TDBNcsLU{K6|h&;#WQYaDNZ|a6a>Do7kPeTrWuczI^`(n|#GyS8}~n#5p&5-zmWU znT-PenKk)m^(nmPtWc=aKlA(?Q4jN}sji|3`1`g#QV8}kxklMT(9gEfwQ2)L>^UG*rggU{N*Ul;$reE+$wHTuK) zmibWjXGg4OvHSqvA)hx@14eqIq`yDcf6W`XF9UxZr|6oL{@#aVh-Q z{6Xt`y28%hOJVJ-pdBK$)uYDznml{Z2-pYaTe@7?VHh8kaN!j3OR3x=5PYEq@O8Zr zei!fa`riM9ZWJ)<1INH1-{Z|aE3C)F@5}d((W>>M^uIJVzlMherqm}sYv(L}f_SD> zx0*25*U8$}Fa`FM*Ru_p6)y$6#=PiGj1N&>o_~Z^@gIwWZN!J`FG}}T66X2~aZ9Fo zgD?BvHpds|hrTMd%RdbMs3cE zfZ6}}@X)~%nCBx=Ufw>0XbpeZhn#m?EYbdaj;Q_pmS=$n40N{!f2t~d5}40Xaev@^ zCSAkrBhWwGhW=rK^Yt^Ik9NcReC|m6zI^`}rdmHr`>C<~H|d^J;!}FkrFS@2a_~^b zbVtH`&cy%G$`im{0z(@izk>ZQ@9HUsfJJ$E{=-%C*RXw@S}t)g+LO;&y-GgR8tu*P z8&D}H5V)P&s;M|v^>y;G7`)HxGf`fif2daRH!t3I4EV6Womtb=3w+}&>#qY}qs}qI zfCs+XZ3=%h+HLm8dhN|YT!S+Y~Otp^{QefX_tlpUh{`%c-JcU1euqss?gg!QT z*9Tu74@pa(b_M2hwwoH)F~a*iABf+R?+^V|>qEo+;mKBaOX!3117>ts91ebA*1eYC zT)b(>r0u|5?_$Cw%fWcxeO1Rzh3L5`FVBCFR`IXdH=rH*yJh9VNA-x$#|{Qxu)g*? z|Dr4MC9XR5vOqk${gf^*3J~V?GoM=*<>mPg&?^41*3Hu4|K7TLrvchC-uvKP%zs>u c%wN~j2<^@1P`g|>{u1xE`1Y { tasks_validated: projectResp.tasks_validated, xform_title: projectResp.xform_title, tasks_bad: projectResp.tasks_bad, + data_extract_url: projectResp.data_extract_url, }), ); dispatch(ProjectActions.SetProjectDetialsLoading(false)); diff --git a/src/frontend/src/api/SubmissionService.ts b/src/frontend/src/api/SubmissionService.ts index 40915ce7ab..2ba55763b7 100644 --- a/src/frontend/src/api/SubmissionService.ts +++ b/src/frontend/src/api/SubmissionService.ts @@ -2,6 +2,7 @@ import CoreModules from '@/shared/CoreModules'; import { ProjectActions } from '@/store/slices/ProjectSlice'; // import { HomeProjectCardModel } from '@/models/home/homeModel'; import { SubmissionActions } from '@/store/slices/SubmissionSlice'; +import { basicGeojsonTemplate } from '@/utilities/mapUtils'; export const ProjectSubmissionService: Function = (url: string) => { return async (dispatch) => { @@ -21,24 +22,6 @@ export const ProjectSubmissionService: Function = (url: string) => { await fetchProjectSubmission(url); }; }; -export const ProjectBuildingGeojsonService: Function = (url: string) => { - return async (dispatch) => { - dispatch(ProjectActions.GetProjectBuildingGeojsonLoading(true)); - - const fetchProjectBuildingGeojson = async (url: string) => { - try { - const fetchBuildingGeojsonData = await CoreModules.axios.get(url); - const resp: any = fetchBuildingGeojsonData.data; - dispatch(ProjectActions.SetProjectBuildingGeojson(resp)); - dispatch(ProjectActions.GetProjectBuildingGeojsonLoading(false)); - } catch (error) { - dispatch(ProjectActions.GetProjectBuildingGeojsonLoading(false)); - } - }; - - await fetchProjectBuildingGeojson(url); - }; -}; export const ProjectSubmissionInfographicsService: Function = (url: string) => { return async (dispatch) => { diff --git a/src/frontend/src/components/CreateEditOrganization/validation/OrganizationDetailsValidation.ts b/src/frontend/src/components/CreateEditOrganization/validation/OrganizationDetailsValidation.ts index 9b1ad182e1..b2b2c73a74 100644 --- a/src/frontend/src/components/CreateEditOrganization/validation/OrganizationDetailsValidation.ts +++ b/src/frontend/src/components/CreateEditOrganization/validation/OrganizationDetailsValidation.ts @@ -1,3 +1,5 @@ +import { isValidUrl } from '@/utilfunctions/urlChecker'; + interface OrganisationValues { id: string; logo: string; @@ -26,15 +28,6 @@ interface ValidationErrors { fillODKCredentials?: boolean; } -function isValidUrl(url: string) { - try { - new URL(url); - return true; - } catch (error) { - return false; - } -} - function OrganizationDetailsValidation(values: OrganisationValues) { const errors: ValidationErrors = {}; diff --git a/src/frontend/src/components/MapComponent/OpenLayersComponent/Layers/VectorLayer.js b/src/frontend/src/components/MapComponent/OpenLayersComponent/Layers/VectorLayer.js index f4ef884ede..162c4f186b 100644 --- a/src/frontend/src/components/MapComponent/OpenLayersComponent/Layers/VectorLayer.js +++ b/src/frontend/src/components/MapComponent/OpenLayersComponent/Layers/VectorLayer.js @@ -13,7 +13,13 @@ import { isExtentValid } from '@/components/MapComponent/OpenLayersComponent/hel import { Draw, Modify, Snap, Select, defaults as defaultInteractions } from 'ol/interaction.js'; import { getArea } from 'ol/sphere'; import { valid } from 'geojson-validation'; +import Point from 'ol/geom/Point.js'; import MultiPoint from 'ol/geom/MultiPoint.js'; +import { buffer } from 'ol/extent'; +import { bbox as OLBbox } from 'ol/loadingstrategy'; +import { geojson as FGBGeoJson } from 'flatgeobuf'; + +import { isValidUrl } from '@/utilfunctions/urlChecker'; const selectElement = 'singleselect'; @@ -36,6 +42,8 @@ const layerViewProperties = { const VectorLayer = ({ map, geojson, + fgbUrl, + fgbExtent, style, zIndex, zoomToLayer = false, @@ -141,6 +149,89 @@ const VectorLayer = ({ }; }, [map, vectorLayer, onDraw]); + function fgbBoundingBox(originalExtent) { + // Add a 50m buffer to the bbox search + const bufferMeters = 50; + const bufferedExtent = buffer(originalExtent, bufferMeters); + + const minPoint = new Point([bufferedExtent[0], bufferedExtent[1]]); + minPoint.transform('EPSG:3857', 'EPSG:4326'); + + const maxPoint = new Point([bufferedExtent[2], bufferedExtent[3]]); + maxPoint.transform('EPSG:3857', 'EPSG:4326'); + + return { + minX: minPoint.getCoordinates()[0], + minY: minPoint.getCoordinates()[1], + maxX: maxPoint.getCoordinates()[0], + maxY: maxPoint.getCoordinates()[1], + }; + } + + function geomWithin(geom, area) { + // Only include features that intersect extent + let geomCoord; + + if (geom.getType() === 'Point') { + geomCoord = geom.getCoordinates(); + } else if (geom.getType() === 'Polygon') { + geomCoord = geom.getInteriorPoint().getCoordinates(); + } else if (geom.getType() === 'LineString') { + geomCoord = geom.getExtent(); + } + + if (area.intersectsCoordinate(geomCoord)) { + return true; + } + + return false; + } + + async function loadFgbRemote(filterExtent = true, extractGeomCol = true) { + this.clear(); + const filteredFeatures = []; + + for await (let feature of FGBGeoJson.deserialize(fgbUrl, fgbBoundingBox(fgbExtent.getExtent()))) { + if (extractGeomCol && feature.geometry.type === 'GeometryCollection') { + // Extract first geom from geomcollection + feature = { + ...feature, + geometry: feature.geometry.geometries[0], + }; + } + let extractGeom = new GeoJSON().readFeature(feature, { + dataProjection: 'EPSG:4326', + featureProjection: 'EPSG:3857', + }); + + // Clip geoms to another geometry (i.e. ST_Within) + if (filterExtent) { + if (geomWithin(extractGeom.getGeometry(), fgbExtent)) { + filteredFeatures.push(extractGeom); + } + } else { + filteredFeatures.push(extractGeom); + } + } + this.addFeatures(filteredFeatures); + } + + function triggerMapClick(feature) { + // Perform an action if a feature is found + if (feature) { + // Extract properties + const properties = feature.getProperties(); + // Remove geometry key if properties are present + // If no properties are set, the feature uid is included + if (!('uid' in properties)) { + const { geometry, ...restProperties } = properties; + mapOnClick(restProperties, feature); + return; + } + mapOnClick(properties, feature); + } + } + useEffect(() => { if (!map) return; if (!geojson) return; @@ -154,6 +245,7 @@ const VectorLayer = ({ }), declutter: true, }); + map.on('click', (evt) => { var pixel = evt.pixel; const feature = map.forEachFeatureAtPixel(pixel, function (feature, layer) { @@ -162,13 +254,9 @@ const VectorLayer = ({ } }); - // Perform an action if a feature is found - if (feature) { - // Do something with the feature - // dispatch() - mapOnClick(feature.getProperties(), feature); - } + triggerMapClick(feature); }); + setVectorLayer(vectorLyr); return () => { setVectorLayer(null); @@ -176,6 +264,38 @@ const VectorLayer = ({ }; }, [map, geojson]); + useEffect(() => { + if (!map || !fgbUrl || !isValidUrl(fgbUrl)) return; + + const vectorLyr = new OLVectorLayer({ + source: new VectorSource({ + useSpatialIndex: true, + strategy: OLBbox, + loader: loadFgbRemote, + }), + }); + + map.on('click', (evt) => { + const pixel = evt.pixel; + + const feature = map.forEachFeatureAtPixel(pixel, function (feature, layer) { + if (layer === vectorLyr) { + return feature; + } + }); + + triggerMapClick(feature); + }); + + // map.addLayer(vectorLyr); + setVectorLayer(vectorLyr); + + return () => { + setVectorLayer(null); + map.un('click', () => {}); + }; + }, [fgbUrl, fgbExtent]); + useEffect(() => { if (!map || !vectorLayer) return; if (visibleOnMap) { @@ -226,7 +346,9 @@ const VectorLayer = ({ useEffect(() => { if (!map || !vectorLayer || !zoomToLayer) return; - const extent = vectorLayer.getSource().getExtent(); + const source = vectorLayer.getSource(); + if (source.getFeatures().length === 0) return; + const extent = source.getExtent(); if (!isExtentValid(extent)) return; map.getView().fit(extent, viewProperties); }, [map, vectorLayer, zoomToLayer]); @@ -328,7 +450,24 @@ VectorLayer.defaultProps = { }; VectorLayer.propTypes = { - geojson: PropTypes.object.isRequired, + // Ensure either geojson or fgbUrl is provided + geojson: (props, propName, componentName) => { + if (!props.geojson && !props.fgbUrl) { + return new Error(`One of 'geojson' or 'fgbUrl' is required in '${componentName}'`); + } + if (props.geojson && props.fgbUrl) { + return new Error(`Only one of 'geojson' or 'fgbUrl' should be provided in '${componentName}'`); + } + }, + fgbUrl: (props, propName, componentName) => { + if (!props.geojson && !props.fgbUrl) { + return new Error(`One of 'geojson' or 'fgbUrl' is required in '${componentName}'`); + } + if (props.geojson && props.fgbUrl) { + return new Error(`Only one of 'geojson' or 'fgbUrl' should be provided in '${componentName}'`); + } + }, + fgbExtent: PropTypes.object, style: PropTypes.object, zIndex: PropTypes.number, zoomToLayer: PropTypes.bool, diff --git a/src/frontend/src/components/ProjectInfo/ProjectInfomap.jsx b/src/frontend/src/components/ProjectInfo/ProjectInfomap.jsx index d100b730b5..f2087219ce 100644 --- a/src/frontend/src/components/ProjectInfo/ProjectInfomap.jsx +++ b/src/frontend/src/components/ProjectInfo/ProjectInfomap.jsx @@ -1,3 +1,5 @@ +// TODO should this be deleted?? + import React, { useCallback, useState, useEffect } from 'react'; import CoreModules from '@/shared/CoreModules'; @@ -8,7 +10,6 @@ import { VectorLayer } from '@/components/MapComponent/OpenLayersComponent/Layer import { Vector as VectorSource } from 'ol/source'; import GeoJSON from 'ol/format/GeoJSON'; import { get } from 'ol/proj'; -import { ProjectBuildingGeojsonService } from '@/api/SubmissionService'; import environment from '@/environment'; import { getStyles } from '@/components/MapComponent/OpenLayersComponent/helpers/styleUtils'; import { ProjectActions } from '@/store/slices/ProjectSlice'; @@ -128,7 +129,7 @@ const ProjectInfomap = () => { useEffect(() => { return () => { - dispatch(ProjectActions.SetProjectBuildingGeojson(null)); + dispatch(ProjectActions.SetProjectDataExtract(null)); }; }, []); @@ -198,11 +199,11 @@ const ProjectInfomap = () => { duration: 2000, }); - dispatch( - ProjectBuildingGeojsonService( - `${import.meta.env.VITE_API_URL}/projects/${decodedId}/features?task_id=${selectedTask}`, - ), - ); + // dispatch( + // ProjectDataExtractService( + // `${import.meta.env.VITE_API_URL}/projects/${decodedId}/features?task_id=${selectedTask}`, + // ), + // ); }, [selectedTask]); const taskOnSelect = (properties, feature) => { diff --git a/src/frontend/src/components/ProjectMap/ProjectMap.jsx b/src/frontend/src/components/ProjectMap/ProjectMap.jsx deleted file mode 100644 index 84056cf1ec..0000000000 --- a/src/frontend/src/components/ProjectMap/ProjectMap.jsx +++ /dev/null @@ -1,102 +0,0 @@ -import React, { useState } from 'react'; -import CoreModules from 'fmtm/CoreModules'; -import { useOLMap } from '@/components/MapComponent/OpenLayersComponent'; -import { MapContainer as MapComponent } from '@/components/MapComponent/OpenLayersComponent'; -import LayerSwitcherControl from '@/components/MapComponent/OpenLayersComponent/LayerSwitcher/index.js'; -import { VectorLayer } from '@/components/MapComponent/OpenLayersComponent/Layers'; - -const basicGeojsonTemplate = { - type: 'FeatureCollection', - features: [], -}; -const ProjectMap = ({}) => { - const defaultTheme = CoreModules.useAppSelector((state) => state.theme.hotTheme); - const { mapRef, map } = useOLMap({ - // center: fromLonLat([85.3, 27.7]), - center: [0, 0], - zoom: 4, - maxZoom: 25, - }); - const projectTaskBoundries = CoreModules.useAppSelector((state) => state.project.projectTaskBoundries); - const projectBuildingGeojson = CoreModules.useAppSelector((state) => state.project.projectBuildingGeojson); - - const [projectBoundaries, setProjectBoundaries] = useState(null); - const [buildingBoundaries, setBuildingBoundaries] = useState(null); - - if (projectTaskBoundries?.length > 0 && projectBoundaries === null) { - const taskGeojsonFeatureCollection = { - ...basicGeojsonTemplate, - features: [ - ...projectTaskBoundries?.[0]?.taskBoundries?.map((task) => ({ - ...task.outline_geojson, - id: task.outline_geojson.properties.uid, - })), - ], - }; - setProjectBoundaries(taskGeojsonFeatureCollection); - } - if (projectBuildingGeojson?.length > 0 && buildingBoundaries === null) { - const buildingGeojsonFeatureCollection = { - ...basicGeojsonTemplate, - features: [ - ...projectBuildingGeojson?.map((building) => ({ - ...building.geometry.geometry, - id: building.id, - })), - ], - // features: projectBuildingGeojson.map((feature) => ({ ...feature.geometry, id: feature.id })) - }; - setBuildingBoundaries(buildingGeojsonFeatureCollection); - } - return ( - - -

- - - ); -}; - -export default ProjectMap; diff --git a/src/frontend/src/components/ProjectSubmissions/SubmissionsTable.tsx b/src/frontend/src/components/ProjectSubmissions/SubmissionsTable.tsx index 12755a889b..5d95339915 100644 --- a/src/frontend/src/components/ProjectSubmissions/SubmissionsTable.tsx +++ b/src/frontend/src/components/ProjectSubmissions/SubmissionsTable.tsx @@ -15,7 +15,7 @@ import { format } from 'date-fns'; import Button from '@/components/common/Button'; import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuTrigger } from '@/components/common/Dropdown'; import { ConvertXMLToJOSM, getDownloadProjectSubmission, getDownloadProjectSubmissionJson } from '@/api/task'; -import { Modal } from '../common/Modal'; +import { Modal } from '@/components/common/Modal'; import { useNavigate, useSearchParams } from 'react-router-dom'; import filterParams from '@/utilfunctions/filterParams'; diff --git a/src/frontend/src/components/ProjectSubmissions/TaskSubmissionsMap.tsx b/src/frontend/src/components/ProjectSubmissions/TaskSubmissionsMap.tsx index 100aa7ef74..4ce9771dec 100644 --- a/src/frontend/src/components/ProjectSubmissions/TaskSubmissionsMap.tsx +++ b/src/frontend/src/components/ProjectSubmissions/TaskSubmissionsMap.tsx @@ -8,13 +8,13 @@ import { VectorLayer } from '@/components/MapComponent/OpenLayersComponent/Layer import { Vector as VectorSource } from 'ol/source'; import GeoJSON from 'ol/format/GeoJSON'; import { get } from 'ol/proj'; -import { ProjectBuildingGeojsonService } from '@/api/SubmissionService'; import environment from '@/environment'; import { getStyles } from '@/components/MapComponent/OpenLayersComponent/helpers/styleUtils'; import { ProjectActions } from '@/store/slices/ProjectSlice'; import { basicGeojsonTemplate } from '@/utilities/mapUtils'; import TaskSubmissionsMapLegend from '@/components/ProjectSubmissions/TaskSubmissionsMapLegend'; import Accordion from '@/components/common/Accordion'; +import { isValidUrl } from '@/utilfunctions/urlChecker'; export const defaultStyles = { lineColor: '#000000', @@ -104,7 +104,9 @@ const getChoroplethColor = (value, colorCodesOutput) => { const TaskSubmissionsMap = () => { const dispatch = CoreModules.useAppDispatch(); const [taskBoundaries, setTaskBoundaries] = useState(null); - const [buildingGeojson, setBuildingGeojson] = useState(null); + const [dataExtractUrl, setDataExtractUrl] = useState(null); + const [dataExtractExtent, setDataExtractExtent] = useState(null); + const projectInfo = CoreModules.useAppSelector((state) => state.project.projectInfo); const projectTaskBoundries = CoreModules.useAppSelector((state) => state.project.projectTaskBoundries); const taskInfo = CoreModules.useAppSelector((state) => state.task.taskInfo); @@ -113,7 +115,6 @@ const TaskSubmissionsMap = () => { count: task.submission_count, })); - const projectBuildingGeojson = CoreModules.useAppSelector((state) => state.project.projectBuildingGeojson); const selectedTask = CoreModules.useAppSelector((state) => state.task.selectedTask); const defaultTheme = CoreModules.useAppSelector((state) => state.theme.hotTheme); const params = CoreModules.useParams(); @@ -126,12 +127,6 @@ const TaskSubmissionsMap = () => { maxZoom: 25, }); - useEffect(() => { - return () => { - dispatch(ProjectActions.SetProjectBuildingGeojson(null)); - }; - }, []); - useEffect(() => { if ( !projectTaskBoundries || @@ -150,32 +145,10 @@ const TaskSubmissionsMap = () => { ], }; setTaskBoundaries(taskGeojsonFeatureCollection); - // const taskBuildingGeojsonFeatureCollection = { - // ...basicGeojsonTemplate, - // features: [ - // ...projectBuildingGeojson?.map((feature) => ({ - // ...feature.geometry, - // id: feature.id, - // })), - // ], - // }; - // setBuildingGeojson(taskBuildingGeojsonFeatureCollection); }, [projectTaskBoundries]); - useEffect(() => { - if (!projectBuildingGeojson) return; - const taskBuildingGeojsonFeatureCollection = { - ...basicGeojsonTemplate, - features: [ - ...projectBuildingGeojson?.map((feature) => ({ - ...feature.geometry, - id: feature.id, - })), - ], - }; - setBuildingGeojson(taskBuildingGeojsonFeatureCollection); - }, [projectBuildingGeojson]); useEffect(() => { + console.log(taskBoundaries); if (!taskBoundaries) return; const filteredSelectedTaskGeojson = { ...basicGeojsonTemplate, @@ -186,7 +159,11 @@ const TaskSubmissionsMap = () => { featureProjection: get('EPSG:3857'), }), }); - var extent = vectorSource.getExtent(); + const extent = vectorSource.getExtent(); + + setDataExtractExtent(vectorSource.getFeatures()[0].getGeometry()); + setDataExtractUrl(projectInfo.data_extract_url); + map.getView().fit(extent, { // easing: elastic, animate: true, @@ -197,12 +174,6 @@ const TaskSubmissionsMap = () => { constrainResolution: true, duration: 2000, }); - - dispatch( - ProjectBuildingGeojsonService( - `${import.meta.env.VITE_API_URL}/projects/${decodedId}/features?task_id=${selectedTask}`, - ), - ); }, [selectedTask]); const taskOnSelect = (properties, feature) => { @@ -258,7 +229,6 @@ const TaskSubmissionsMap = () => { {taskBoundaries && ( setChoropleth({ ...municipalStyles, lineThickness: 3 }, feature, resolution) } @@ -287,7 +257,9 @@ const TaskSubmissionsMap = () => { collapsed={true} /> - {buildingGeojson && } + {dataExtractUrl && isValidUrl(dataExtractUrl) && ( + + )} ); diff --git a/src/frontend/src/components/SubmissionMap/SubmissionMap.jsx b/src/frontend/src/components/SubmissionMap/SubmissionMap.jsx deleted file mode 100644 index 881910627c..0000000000 --- a/src/frontend/src/components/SubmissionMap/SubmissionMap.jsx +++ /dev/null @@ -1,64 +0,0 @@ -import React, { useState } from 'react'; -import useOLMap from '@/hooks/useOlMap'; -import { MapContainer as MapComponent } from '@/components/MapComponent/OpenLayersComponent'; -import LayerSwitcherControl from '@/components/MapComponent/OpenLayersComponent/LayerSwitcher/index.js'; -import { VectorLayer } from '@/components/MapComponent/OpenLayersComponent/Layers'; - -function elastic(t) { - return Math.pow(2, -10 * t) * Math.sin(((t - 0.075) * (2 * Math.PI)) / 0.3) + 1; -} - -const SubmissionMap = ({ outlineBoundary, featureGeojson }) => { - const { mapRef, map } = useOLMap({ - // center: fromLonLat([85.3, 27.7]), - center: [0, 0], - zoom: 4, - maxZoom: 25, - }); - - return ( -
- - - {outlineBoundary?.type && ( - - )} - {featureGeojson?.type && } - {/* )} */} - -
- ); -}; - -SubmissionMap.propTypes = {}; - -export default SubmissionMap; diff --git a/src/frontend/src/components/TasksMap/TasksMap.jsx b/src/frontend/src/components/TasksMap/TasksMap.jsx deleted file mode 100644 index c15b806099..0000000000 --- a/src/frontend/src/components/TasksMap/TasksMap.jsx +++ /dev/null @@ -1,67 +0,0 @@ -import React from 'react'; -import useOLMap from '@/hooks/useOlMap'; -import { MapContainer as MapComponent } from '@/components/MapComponent/OpenLayersComponent'; -import LayerSwitcherControl from '@/components/MapComponent/OpenLayersComponent/LayerSwitcher/index.js'; -import { VectorLayer } from '@/components/MapComponent/OpenLayersComponent/Layers'; - -function elastic(t) { - return Math.pow(2, -10 * t) * Math.sin(((t - 0.075) * (2 * Math.PI)) / 0.3) + 1; -} -const basicGeojsonTemplate = { - type: 'FeatureCollection', - features: [], -}; -const TasksMap = ({ projectTaskBoundries, projectBuildingGeojson }) => { - // const projectTaskBoundries = CoreModules.useAppSelector((state) => state.project.projectTaskBoundries); - // const projectBuildingGeojson = CoreModules.useAppSelector((state) => state.project.projectBuildingGeojson); - - const { mapRef, map } = useOLMap({ - // center: fromLonLat([85.3, 27.7]), - center: [0, 0], - zoom: 4, - maxZoom: 25, - }); - console.log(projectTaskBoundries, 'projectTaskBoundries'); - - return ( -
- - - {/* - */} - -
- ); -}; - -TasksMap.propTypes = {}; - -export default TasksMap; diff --git a/src/frontend/src/components/createnewproject/validation/CreateProjectValidation.tsx b/src/frontend/src/components/createnewproject/validation/CreateProjectValidation.tsx index 9f24902769..8aaa8563c4 100755 --- a/src/frontend/src/components/createnewproject/validation/CreateProjectValidation.tsx +++ b/src/frontend/src/components/createnewproject/validation/CreateProjectValidation.tsx @@ -1,3 +1,5 @@ +import { isValidUrl } from '@/utilfunctions/urlChecker'; + interface ProjectValues { organisation_id: string; name: string; @@ -26,15 +28,6 @@ interface ValidationErrors { const regexForSymbol = /_/g; -function isValidUrl(url: string) { - try { - new URL(url); - return true; - } catch (error) { - return false; - } -} - function CreateProjectValidation(values: ProjectValues) { const errors: ValidationErrors = {}; diff --git a/src/frontend/src/components/organisation/Validation/OrganisationAddValidation.tsx b/src/frontend/src/components/organisation/Validation/OrganisationAddValidation.tsx index 0d947418f4..bbd4935658 100644 --- a/src/frontend/src/components/organisation/Validation/OrganisationAddValidation.tsx +++ b/src/frontend/src/components/organisation/Validation/OrganisationAddValidation.tsx @@ -1,3 +1,5 @@ +import { isValidUrl } from '@/utilfunctions/urlChecker'; + interface OrganisationValues { logo: string; name: string; @@ -19,15 +21,6 @@ interface ValidationErrors { odk_central_password?: string; } -function isValidUrl(url: string) { - try { - new URL(url); - return true; - } catch (error) { - return false; - } -} - function OrganisationAddValidation(values: OrganisationValues) { const errors: ValidationErrors = {}; diff --git a/src/frontend/src/store/slices/ProjectSlice.ts b/src/frontend/src/store/slices/ProjectSlice.ts index 20a82bdf03..352cd1a377 100755 --- a/src/frontend/src/store/slices/ProjectSlice.ts +++ b/src/frontend/src/store/slices/ProjectSlice.ts @@ -10,8 +10,7 @@ const ProjectSlice = createSlice({ projectInfo: {}, projectSubmissionLoading: false, projectSubmission: [], - projectBuildingGeojsonLoading: false, - projectBuildingGeojson: [], + projectDataExtractLoading: false, downloadProjectFormLoading: { type: 'form', loading: false }, generateProjectTilesLoading: false, tilesList: [], @@ -48,12 +47,6 @@ const ProjectSlice = createSlice({ SetProjectSubmission(state, action) { state.projectSubmission = action.payload; }, - GetProjectBuildingGeojsonLoading(state, action) { - state.projectSubmissionLoading = action.payload; - }, - SetProjectBuildingGeojson(state, action) { - state.projectBuildingGeojson = action.payload; - }, SetDownloadProjectFormLoading(state, action) { state.downloadProjectFormLoading = action.payload; }, diff --git a/src/frontend/src/utilfunctions/urlChecker.ts b/src/frontend/src/utilfunctions/urlChecker.ts new file mode 100644 index 0000000000..ac2c705c9d --- /dev/null +++ b/src/frontend/src/utilfunctions/urlChecker.ts @@ -0,0 +1,8 @@ +export function isValidUrl(url) { + try { + new URL(url); + return true; + } catch (error) { + return false; + } +} diff --git a/src/frontend/src/views/NewProjectDetails.jsx b/src/frontend/src/views/NewProjectDetails.jsx index cffb47b8b6..e027cfdb68 100644 --- a/src/frontend/src/views/NewProjectDetails.jsx +++ b/src/frontend/src/views/NewProjectDetails.jsx @@ -7,13 +7,13 @@ import environment from '@/environment'; import { ProjectById } from '@/api/Project'; import { ProjectActions } from '@/store/slices/ProjectSlice'; import CustomizedSnackbar from '@/utilities/CustomizedSnackbar'; +import { Modal } from '@/components/common/Modal'; import OnScroll from '@/hooks/OnScroll'; import { HomeActions } from '@/store/slices/HomeSlice'; import CoreModules from '@/shared/CoreModules'; import AssetModules from '@/shared/AssetModules'; import FmtmLogo from '@/assets/images/hotLog.png'; import GenerateBasemap from '@/components/GenerateBasemap'; -import { ProjectBuildingGeojsonService } from '@/api/SubmissionService'; import TaskSectionPopup from '@/components/ProjectDetails/TaskSectionPopup'; import DialogTaskActions from '@/components/DialogTaskActions'; import QrcodeComponent from '@/components/QrcodeComponent'; @@ -28,7 +28,6 @@ import LayerSwitcherControl from '@/components/MapComponent/OpenLayersComponent/ import MapControlComponent from '@/components/ProjectDetails/MapControlComponent'; import { VectorLayer } from '@/components/MapComponent/OpenLayersComponent/Layers'; import { geojsonObjectModel } from '@/constants/geojsonObjectModal'; -import { basicGeojsonTemplate } from '@/utilities/mapUtils'; import getTaskStatusStyle from '@/utilfunctions/getTaskStatusStyle'; import { defaultStyles } from '@/components/MapComponent/OpenLayersComponent/helpers/styleUtils'; import MapLegends from '@/components/MapLegends'; @@ -38,6 +37,7 @@ import { Icon, Style } from 'ol/style'; import { Motion } from '@capacitor/motion'; import locationArc from '@/assets/images/locationArc.png'; import { CommonActions } from '@/store/slices/CommonSlice'; +import { isValidUrl } from '@/utilfunctions/urlChecker'; const Home = () => { const dispatch = CoreModules.useAppDispatch(); @@ -49,11 +49,14 @@ const Home = () => { const [mainView, setView] = useState(); const [featuresLayer, setFeaturesLayer] = useState(); const [toggleGenerateModal, setToggleGenerateModal] = useState(false); - const [taskBuildingGeojson, setTaskBuildingGeojson] = useState(null); - const [initialFeaturesLayer, setInitialFeaturesLayer] = useState(null); + const [taskBoundariesLayer, setTaskBoundariesLayer] = useState(null); + const [dataExtractUrl, setDataExtractUrl] = useState(null); + const [dataExtractExtent, setDataExtractExtent] = useState(null); const [currentCoordinate, setCurrentCoordinate] = useState({ latitude: null, longitude: null }); const [positionGeojson, setPositionGeojson] = useState(null); const [deviceRotation, setDeviceRotation] = useState(0); + const [selectedFeature, setSelectedFeature] = useState(false); + const [dataExtractModalOpen, setDataExtractModalOpen] = useState(false); const encodedId = params.id; const decodedId = environment.decode(encodedId); @@ -61,7 +64,6 @@ const Home = () => { const state = CoreModules.useAppSelector((state) => state.project); const projectInfo = CoreModules.useAppSelector((state) => state.home.selectedProject); const stateSnackBar = CoreModules.useAppSelector((state) => state.home.snackbar); - const projectBuildingGeojson = CoreModules.useAppSelector((state) => state.project.projectBuildingGeojson); const mobileFooterSelection = CoreModules.useAppSelector((state) => state.project.mobileFooterSelection); const mapTheme = CoreModules.useAppSelector((state) => state.theme.hotTheme); const geolocationStatus = CoreModules.useAppSelector((state) => state.project.geolocationStatus); @@ -80,13 +82,13 @@ const Home = () => { }), ); }; + //Fetch project for the first time useEffect(() => { dispatch(ProjectActions.SetNewProjectTrigger()); if (state.projectTaskBoundries.findIndex((project) => project.id == environment.decode(encodedId)) == -1) { dispatch(ProjectActions.SetProjectTaskBoundries([])); dispatch(ProjectById(state.projectTaskBoundries, environment.decode(encodedId))); - // dispatch(ProjectBuildingGeojsonService(`${import.meta.env.VITE_API_URL}/projects/${environment.decode(encodedId)}/features`)) } else { dispatch(ProjectActions.SetProjectTaskBoundries([])); dispatch(ProjectById(state.projectTaskBoundries, environment.decode(encodedId))); @@ -98,9 +100,7 @@ const Home = () => { dispatch(ProjectActions.SetProjectInfo(projectInfo)); } } - return () => { - dispatch(ProjectActions.SetProjectBuildingGeojson(null)); - }; + return () => {}; }, [params.id]); const { mapRef, map } = useOLMap({ @@ -123,39 +123,21 @@ const Home = () => { }, id: `${feature.id}_${feature.task_status}`, })); - const taskBuildingGeojsonFeatureCollection = { + const taskBoundariesFeatcol = { ...geojsonObjectModel, features: features, }; - setInitialFeaturesLayer(taskBuildingGeojsonFeatureCollection); + setTaskBoundariesLayer(taskBoundariesFeatcol); }, [state.projectTaskBoundries[0]?.taskBoundries?.length]); - useEffect(() => { - if (!map) return; - if (!projectBuildingGeojson) return; - - const taskBuildingGeojsonFeatureCollection = { - ...basicGeojsonTemplate, - features: [ - ...projectBuildingGeojson?.map((feature) => ({ - ...feature.geometry, - id: feature.id, - })), - ], - }; - - setTaskBuildingGeojson(taskBuildingGeojsonFeatureCollection); - }, [map, projectBuildingGeojson]); - // TasksLayer(map, mainView, featuresLayer); const projectClickOnMap = (properties, feature) => { setFeaturesLayer(feature, 'feature'); - let extent = properties.geometry.getExtent(); - dispatch( - ProjectBuildingGeojsonService( - `${import.meta.env.VITE_API_URL}/projects/${decodedId}/features?task_id=${properties.uid}`, - ), - ); + const extent = properties.geometry.getExtent(); + + setDataExtractExtent(properties.geometry); + setDataExtractUrl(state.projectInfo.data_extract_url); + mapRef.current?.scrollIntoView({ block: 'center', behavior: 'smooth', @@ -173,6 +155,11 @@ const Home = () => { } }; + const displayDataExtractValues = (properties) => { + setSelectedFeature(properties); + setDataExtractModalOpen(true); + }; + const buildingStyle = { ...defaultStyles, lineColor: '#FF0000', @@ -282,6 +269,23 @@ const Home = () => { /> +
+ + {Object.entries(selectedFeature).map(([key, value]) => ( +

{`${key}: ${value}`}

+ ))} +
+ } + open={dataExtractModalOpen} + onOpenChange={(value) => { + setDataExtractModalOpen(value); + }} + /> + + {/* Top project details heading medium dimension*/} {windowSize.width >= 640 && (
@@ -367,7 +371,6 @@ const Home = () => {
)} - {/* */} {params?.id && (
{ > - {initialFeaturesLayer && initialFeaturesLayer?.features?.length > 0 && ( + {taskBoundariesLayer && taskBoundariesLayer?.features?.length > 0 && ( { getTaskStatusStyle={(feature) => getTaskStatusStyle(feature, mapTheme)} /> )} - {taskBuildingGeojson && taskBuildingGeojson?.features?.length > 0 && ( + {dataExtractUrl && isValidUrl(dataExtractUrl) && ( { constrainResolution: true, duration: 2000, }} + mapOnClick={displayDataExtractValues} zoomToLayer - zIndex={5} + zIndex={10} /> )} {geolocationStatus && currentCoordinate?.latitude && currentCoordinate?.longitude && ( @@ -422,7 +427,7 @@ const Home = () => { constrainResolution: true, duration: 2000, }} - zIndex={5} + zIndex={15} rotation={deviceRotation} /> )} diff --git a/src/frontend/src/views/ProjectDetails.jsx b/src/frontend/src/views/ProjectDetails.jsx index 080e7dd062..518b7b5462 100755 --- a/src/frontend/src/views/ProjectDetails.jsx +++ b/src/frontend/src/views/ProjectDetails.jsx @@ -1,3 +1,5 @@ +// TODO should this be deleted?? + import React, { useEffect, useRef, useState } from 'react'; import '../styles/home.scss'; import WindowDimension from '@/hooks/WindowDimension'; @@ -24,7 +26,6 @@ import GeoJSON from 'ol/format/GeoJSON'; import FmtmLogo from '@/assets/images/hotLog.png'; import GenerateBasemap from '@/components/GenerateBasemap'; -import { ProjectBuildingGeojsonService } from '@/api/SubmissionService'; import { get } from 'ol/proj'; import { buildingStyle, basicGeojsonTemplate } from '@/utilities/mapUtils'; import MapLegends from '@/components/MapLegends'; @@ -82,12 +83,11 @@ const Home = () => { //Fetch project for the first time useEffect(() => { + console.log('HERE'); dispatch(ProjectActions.SetNewProjectTrigger()); if (state.projectTaskBoundries.findIndex((project) => project.id == environment.decode(encodedId)) == -1) { dispatch(ProjectActions.SetProjectTaskBoundries([])); dispatch(ProjectById(state.projectTaskBoundries, environment.decode(encodedId))); - - // dispatch(ProjectBuildingGeojsonService(`${import.meta.env.VITE_API_URL}/projects/${environment.decode(encodedId)}/features`)) } else { dispatch(ProjectActions.SetProjectTaskBoundries([])); dispatch(ProjectById(state.projectTaskBoundries, environment.decode(encodedId))); @@ -100,7 +100,7 @@ const Home = () => { } } return () => { - dispatch(ProjectActions.SetProjectBuildingGeojson(null)); + dispatch(ProjectActions.SetProjectDataExtract(null)); }; }, [params.id]); @@ -180,13 +180,13 @@ const Home = () => { document.querySelector('#project-details-map').scrollIntoView({ behavior: 'smooth', }); - dispatch( - ProjectBuildingGeojsonService( - `${import.meta.env.VITE_API_URL}/projects/${decodedId}/features?task_id=${ - feature?.getId()?.split('_')?.[0] - }`, - ), - ); + // dispatch( + // ProjectDataExtractService( + // `${import.meta.env.VITE_API_URL}/projects/${decodedId}/features?task_id=${ + // feature?.getId()?.split('_')?.[0] + // }`, + // ), + // ); } }); }); @@ -366,7 +366,6 @@ const Home = () => {
)} - {/* */} {params?.id && (
{ const dispatch = CoreModules.useAppDispatch(); @@ -53,8 +53,9 @@ const Home = () => { const [mainView, setView] = useState(); const [featuresLayer, setFeaturesLayer] = useState(); const [toggleGenerateModal, setToggleGenerateModal] = useState(false); - const [taskBuildingGeojson, setTaskBuildingGeojson] = useState(null); - const [initialFeaturesLayer, setInitialFeaturesLayer] = useState(null); + const [dataExtractUrl, setDataExtractUrl] = useState(null); + const [dataExtractExtent, setDataExtractExtent] = useState(null); + const [taskBoundariesLayer, setTaskBoundariesLayer] = useState(null); const [currentCoordinate, setCurrentCoordinate] = useState({ latitude: null, longitude: null }); const [positionGeojson, setPositionGeojson] = useState(null); const [deviceRotation, setDeviceRotation] = useState(0); @@ -65,7 +66,6 @@ const Home = () => { const state = CoreModules.useAppSelector((state) => state.project); const projectInfo = CoreModules.useAppSelector((state) => state.home.selectedProject); const stateSnackBar = CoreModules.useAppSelector((state) => state.home.snackbar); - const projectBuildingGeojson = CoreModules.useAppSelector((state) => state.project.projectBuildingGeojson); const mobileFooterSelection = CoreModules.useAppSelector((state) => state.project.mobileFooterSelection); const mapTheme = CoreModules.useAppSelector((state) => state.theme.hotTheme); const geolocationStatus = CoreModules.useAppSelector((state) => state.project.geolocationStatus); @@ -102,9 +102,7 @@ const Home = () => { dispatch(ProjectActions.SetProjectInfo(projectInfo)); } } - return () => { - dispatch(ProjectActions.SetProjectBuildingGeojson(null)); - }; + return () => {}; }, [params.id]); const { mapRef, map } = useOLMap({ @@ -126,30 +124,13 @@ const Home = () => { }, id: `${feature.id}_${feature.task_status}`, })); - const taskBuildingGeojsonFeatureCollection = { + const taskBoundariesFeatcol = { ...geojsonObjectModel, features: features, }; - setInitialFeaturesLayer(taskBuildingGeojsonFeatureCollection); + setTaskBoundariesLayer(taskBoundariesFeatcol); }, [state.projectTaskBoundries[0]?.taskBoundries?.length]); - useEffect(() => { - if (!map) return; - if (!projectBuildingGeojson) return; - - const taskBuildingGeojsonFeatureCollection = { - ...basicGeojsonTemplate, - features: [ - ...projectBuildingGeojson?.map((feature) => ({ - ...feature.geometry, - id: feature.id, - })), - ], - }; - - setTaskBuildingGeojson(taskBuildingGeojsonFeatureCollection); - }, [map, projectBuildingGeojson]); - useEffect(() => { dispatch(GetProjectDashboard(`${import.meta.env.VITE_API_URL}/projects/project_dashboard/${decodedId}`)); }, []); @@ -158,11 +139,10 @@ const Home = () => { const projectClickOnMap = (properties, feature) => { setFeaturesLayer(feature, 'feature'); let extent = properties.geometry.getExtent(); - dispatch( - ProjectBuildingGeojsonService( - `${import.meta.env.VITE_API_URL}/projects/${decodedId}/features?task_id=${properties.uid}`, - ), - ); + + setDataExtractExtent(properties.geometry); + setDataExtractUrl(state.projectInfo.data_extract_url); + mapRef.current?.scrollIntoView({ block: 'center', behavior: 'smooth', @@ -392,9 +372,9 @@ const Home = () => { > - {initialFeaturesLayer && initialFeaturesLayer?.features?.length > 0 && ( + {taskBoundariesLayer && taskBoundariesLayer?.features?.length > 0 && ( { getTaskStatusStyle={(feature) => getTaskStatusStyle(feature, mapTheme)} /> )} - {taskBuildingGeojson && taskBuildingGeojson?.features?.length > 0 && ( + {dataExtractUrl && isValidUrl(dataExtractUrl) && ( { if (state.projectTaskBoundries.findIndex((project) => project.id == environment.decode(encodedId)) == -1) { dispatch(ProjectActions.SetProjectTaskBoundries([])); dispatch(ProjectById(state.projectTaskBoundries, environment.decode(encodedId))); - - // dispatch(ProjectBuildingGeojsonService(`${import.meta.env.VITE_API_URL}/projects/${environment.decode(encodedId)}/features`)) } else { dispatch(ProjectActions.SetProjectTaskBoundries([])); dispatch(ProjectById(state.projectTaskBoundries, environment.decode(encodedId))); @@ -91,21 +91,6 @@ const ProjectInfo = () => { ); }; - // useEffect(() => { - // const fetchData = () => { - // dispatch(fetchInfoTask(`${environment.baseApiUrl}/tasks/tasks-features/?project_id=${decodedId}`)); - // }; - // fetchData(); - // let interval; - // if (isMonitoring) { - // interval = setInterval(fetchData, 3000); - // } else { - // clearInterval(interval); - // } - - // return () => clearInterval(interval); - // }, [dispatch, isMonitoring]); - useEffect(() => { const fetchData = () => { dispatch(fetchInfoTask(`${import.meta.env.VITE_API_URL}/tasks/tasks-features/?project_id=${decodedId}`)); diff --git a/src/frontend/src/views/Submissions.tsx b/src/frontend/src/views/Submissions.tsx index 6b69859902..a12f4a8301 100755 --- a/src/frontend/src/views/Submissions.tsx +++ b/src/frontend/src/views/Submissions.tsx @@ -1,3 +1,5 @@ +// TODO should this be deleted?? + import React, { useEffect } from 'react'; // import '../styles/home.css' import CoreModules from '@/shared/CoreModules'; @@ -5,7 +7,6 @@ import CoreModules from '@/shared/CoreModules'; import Avatar from '@/assets/images/avatar.png'; import SubmissionMap from '@/components/SubmissionMap/SubmissionMap'; import environment from '@/environment'; -import { ProjectBuildingGeojsonService, ProjectSubmissionService } from '@/api/SubmissionService'; import { ProjectActions } from '@/store/slices/ProjectSlice'; import { ProjectById } from '@/api/Project'; @@ -23,7 +24,7 @@ const Submissions = () => { // const theme = CoreModules.useAppSelector(state => state.theme.hotTheme) useEffect(() => { dispatch(ProjectSubmissionService(`${import.meta.env.VITE_API_URL}/submission/?project_id=${decodedId}`)); - dispatch(ProjectBuildingGeojsonService(`${import.meta.env.VITE_API_URL}/projects/${decodedId}/features`)); + // dispatch(ProjectDataExtractService(`${import.meta.env.VITE_API_URL}/projects/${decodedId}/features`)); //creating a manual thunk that will make an API call then autamatically perform state mutation whenever we navigate to home page }, []); diff --git a/src/frontend/src/views/Tasks.tsx b/src/frontend/src/views/Tasks.tsx index 96dd72ca10..deb90306af 100644 --- a/src/frontend/src/views/Tasks.tsx +++ b/src/frontend/src/views/Tasks.tsx @@ -1,3 +1,5 @@ +// TODO should this be deleted?? + import React, { useEffect, useState } from 'react'; // import '../styles/home.css' import CoreModules from '@/shared/CoreModules'; @@ -6,9 +8,8 @@ import AssetModules from '@/shared/AssetModules'; // import { styled, alpha } from '@mui/material'; import Avatar from '@/assets/images/avatar.png'; -import SubmissionMap from '@/components/SubmissionMap/SubmissionMap'; import environment from '@/environment'; -import { ProjectBuildingGeojsonService, ProjectSubmissionService } from '@/api/SubmissionService'; +import { ProjectSubmissionService } from '@/api/SubmissionService'; import { ProjectActions } from '@/store/slices/ProjectSlice'; import { ProjectById } from '@/api/Project'; import { getDownloadProjectSubmission } from '@/api/task'; @@ -37,22 +38,22 @@ const TasksSubmission = () => { `${import.meta.env.VITE_API_URL}/submission/?project_id=${decodedProjectId}&task_id=${decodedTaskId}`, ), ); - dispatch( - ProjectBuildingGeojsonService( - `${import.meta.env.VITE_API_URL}/projects/${decodedProjectId}/features?task_id=${decodedTaskId}`, - ), - ); + // dispatch( + // ProjectDataExtractService( + // `${import.meta.env.VITE_API_URL}/projects/${decodedProjectId}/features?task_id=${decodedTaskId}`, + // ), + // ); //creating a manual thunk that will make an API call then autamatically perform state mutation whenever we navigate to home page }, []); //Fetch project for the first time useEffect(() => { if (state.projectTaskBoundries.findIndex((project) => project.id == environment.decode(encodedProjectId)) == -1) { dispatch(ProjectById(state.projectTaskBoundries, environment.decode(encodedProjectId))); - dispatch( - ProjectBuildingGeojsonService( - `${import.meta.env.VITE_API_URL}/projects/${environment.decode(encodedProjectId)}/features`, - ), - ); + // dispatch( + // ProjectDataExtractService( + // `${import.meta.env.VITE_API_URL}/projects/${environment.decode(encodedProjectId)}/features`, + // ), + // ); } else { dispatch(ProjectActions.SetProjectTaskBoundries([])); dispatch(ProjectById(state.projectTaskBoundries, environment.decode(encodedProjectId)));