diff --git a/.png b/.png deleted file mode 100644 index 71f49edb6..000000000 Binary files a/.png and /dev/null differ diff --git a/openAPI/examples/collections.json b/openAPI/examples/collections.json index dd053582c..5d3c31ab3 100644 --- a/openAPI/examples/collections.json +++ b/openAPI/examples/collections.json @@ -66,7 +66,7 @@ "crs": "http://www.opengis.net/def/crs/OGC/1.3/CRS84" } }, - "itemType": "feature" + "itemType": "indoorfeature" }, { "id": "aist_waterfront_lab", diff --git a/pygeoapi/api/indoorgml.py b/pygeoapi/api/indoorgml.py index db15e7d4f..a127fda13 100644 --- a/pygeoapi/api/indoorgml.py +++ b/pygeoapi/api/indoorgml.py @@ -35,8 +35,7 @@ def manage_collection(api: API, request: APIRequest, action: str, dataset: str = headers = request.get_response_headers(SYSTEM_LOCALE) pidb_provider = PostgresIndoorDB() - # --- Action: CREATE --- - if action == 'create': + if action in ['create', 'update']: if not request.data: msg = 'No data found' LOGGER.error(msg) @@ -59,12 +58,24 @@ def manage_collection(api: API, request: APIRequest, action: str, dataset: str = return api.get_exception( HTTPStatus.BAD_REQUEST, headers, request.format, 'InvalidParameterValue', msg) - + + # --- Action: CREATE --- + if action == 'create': # 2. Call Provider to Create try: pidb_provider.connect() + c_id = data.get('id') + title = data.get('title') + item_type = data.get('itemType', 'indoorfeature') + if not c_id or not title: + return api.get_exception( + HTTPStatus.BAD_REQUEST, + headers, request.format, "Missing required parameter 'id' and 'title'.", msg) + elif item_type != 'indoorfeature': + return api.get_exception( + HTTPStatus.BAD_REQUEST, + headers, request.format, "Invalid 'itemType' value. Expected 'indoorfeature'.", msg) new_id = pidb_provider.post_collection(data) - if not new_id: return api.get_exception( HTTPStatus.CONFLICT, headers, request.format, @@ -97,6 +108,24 @@ def manage_collection(api: API, request: APIRequest, action: str, dataset: str = return api.get_exception(HTTPStatus.INTERNAL_SERVER_ERROR, headers, request.format, 'ServerError', str(e)) finally: pidb_provider.disconnect() + + elif action == 'update': + try: + pidb_provider.connect() + collection_id = str(dataset) + + success = pidb_provider.patch_collection(collection_id, data) + if not success: + return api.get_exception( + HTTPStatus.NOT_FOUND, headers, request.format, + 'NotFound', f'Collection {collection_id} not found') + + return headers, HTTPStatus.NO_CONTENT, 'Updated successfully.' + + except Exception as e: + return api.get_exception(HTTPStatus.INTERNAL_SERVER_ERROR, headers, request.format, 'ServerError', str(e)) + finally: + pidb_provider.disconnect() return headers, HTTPStatus.METHOD_NOT_ALLOWED, '' diff --git a/pygeoapi/flask_app.py b/pygeoapi/flask_app.py index 108773d55..7a9b75065 100644 --- a/pygeoapi/flask_app.py +++ b/pygeoapi/flask_app.py @@ -232,7 +232,7 @@ def get_tilematrix_sets(): @BLUEPRINT.route('/collections', methods=['GET', 'POST']) -@BLUEPRINT.route('/collections/', methods=['GET', 'DELETE']) +@BLUEPRINT.route('/collections/', methods=['GET', 'PATCH','DELETE']) def collections(collection_id: str | None = None): """ OGC API collections endpoint @@ -269,6 +269,8 @@ def collections(collection_id: str | None = None): elif request.method == 'DELETE': # Delete from DB return execute_from_flask(indoorgml.manage_collection, request, 'delete', collection_id) + elif request.method == 'PATCH': + return execute_from_flask(indoorgml.manage_collection, request, 'update', collection_id) # Fallback: Standard OGC API (YAML-based GeoJSON/CSV features) else: diff --git a/pygeoapi/provider/postgresql_indoordb.py b/pygeoapi/provider/postgresql_indoordb.py index 317dc511b..f8c41a69e 100644 --- a/pygeoapi/provider/postgresql_indoordb.py +++ b/pygeoapi/provider/postgresql_indoordb.py @@ -89,7 +89,6 @@ def get_collections_list(self): clean_list.append({ 'id': c_id, - # Default to the ID if title is missing 'title': props.get('title', c_id), 'itemType': props.get('itemType', 'indoorfeature') }) @@ -163,7 +162,6 @@ def post_collection(self, collection): } with self.connection.cursor() as cur: try: - # 2. Insert (Let Postgres handle the 'id' column automatically) insert_query = """ INSERT INTO collection (id_str, collection_property) VALUES (%s, %s) @@ -228,8 +226,47 @@ def delete_collection(self, collection_id:str): self.connection.rollback() LOGGER.error(f"Error creating collection: {e}") raise e + + + def patch_collection(self, collection_id:str, data): + with self.connection.cursor() as cur: + try: + cur.execute("SELECT id FROM collection WHERE id_str = %s", (collection_id,)) + row = cur.fetchone() + + if not row: + return False # Collection not found + + coll_pk = row[0] + + sql = "SELECT collection_property FROM collection WHERE id = %s" + cur.execute(sql, (coll_pk,)) + + collection_json = cur.fetchone() + + fields = [] + values = [] + + if 'title' in data: + collection_json['title'] = data['title'] + + if 'description' in data: + collection_json['description'] = data['description'] + + update_sql = "UPDATE collection SET collection_property = %s WHERE id = %s" + cur.execute(update_sql, (json.dumps(collection_json),coll_pk,)) + + self.connection.commit() + return True + + except Exception as e: + self.connection.rollback() + LOGGER.error(f"Error updating collection: {e}") + raise e + # endregion + # region IndoorFeatures def is_indoor_collection(self, collection_id:str): """ @@ -260,7 +297,6 @@ def get_collection_items( """ Retrieve the indoor feature collection /collections/{collectionId}/items Optimized to fetch data and total count in a single query. - /collections/{collectionId}/items """ try: # 1. Prepare Filter Strings @@ -363,7 +399,6 @@ def get_feature(self, collection_id: str, feature_id:str, level:str=None, bbox:l properties = props or {} thematic_layers = [] interlayer_connections = [] - # Initialize Skeleton result_feature = { "type": "Feature", "id": feature_id_str, @@ -512,7 +547,6 @@ def delete_indoorfeature(self, collection_id:str, feature_id:str): res = cur.fetchone() if not res: - # Item not found, usually returns 404 in API, but here we can just return msg = f"Feature {feature_id} not found." LOGGER.warning(msg) raise ValueError(msg) @@ -543,7 +577,6 @@ def delete_indoorfeature(self, collection_id:str, feature_id:str): # 3. DELETE PARENT (The IndoorFeature itself) cur.execute("DELETE FROM indoorfeature WHERE id = %s", (feature_pk,)) - # Commit is handled by the context manager self.connection.commit() return True except Exception as e: @@ -660,7 +693,7 @@ def get_layers(self, collection_id:str, feature_id:str, theme: str = None, level def _get_layer(self, layer_pk:int, level:str=None, bbox:list=None): """ - Retrieves a single Thematic Layer. + Retrieves a single Thematic Layer with Integer ID. If not filtered, just the meta data is given. - PrimalSpace: Filtered by 'level' if provided. - DualSpace: Returns the ENTIRE network (unfiltered) for connectivity. @@ -703,7 +736,7 @@ def _get_layer(self, layer_pk:int, level:str=None, bbox:list=None): def get_layer(self, collection_id:str, feature_id:str, layer_id:str, level:str=None, bbox:list=None): """ - Retrieves a single Thematic Layer. + Retrieves a single Thematic Layer with String ID. If not filtered, just the meta data is given. - PrimalSpace: Filtered by 'level' if provided. - DualSpace: Returns the ENTIRE network (unfiltered) for connectivity. @@ -817,7 +850,7 @@ def get_layer(self, collection_id:str, feature_id:str, layer_id:str, level:str=N def _get_primal_space(self, layer_pk:int, primalspace_id:str, p_create:str = None, p_termination:str=None, level:str=None, bbox:list=None): """ Helper to build PrimalSpaceLayer. - Supports optional filtering by 'level'. + Supports optional filtering by 'level' and 'bbox'. """ primal_space = { "id": primalspace_id, @@ -1053,7 +1086,7 @@ def _post_thematic_layer(self, collection_pk:int, feature_pk:int, layer_data): layer_new = cur.fetchone() - # Insert Primal Members (Cells/Boundaries) - returns duality dict + # Insert Primal Members (Cells/Boundaries) - returns duality dictionary d_c, d_b = self._post_primal_members(collection_pk, feature_pk, layer_new[0], primal) # Insert Dual Members (Nodes/Edges) @@ -1168,65 +1201,6 @@ def _post_primal_members(self, collection_pk:int, feature_pk:int, layer_pk:int, # If there is no 2D geometry but 3D, project 3D to 2D geometry LOGGER.debug("Project geometry 3D to 2D ") sql_project_shell = """ - WITH faces AS ( - SELECT - c.id, - s.shell_idx, - f.face - FROM cell_space_n_boundary c - CROSS JOIN LATERAL jsonb_array_elements(c."3D_geometry"->'coordinates') - WITH ORDINALITY AS s(shell, shell_idx) - CROSS JOIN LATERAL jsonb_array_elements(s.shell) AS f(face) - WHERE c."3D_geometry" IS NOT NULL - AND c.type = 'space' - AND c."2D_geometry" IS NULL - AND c.thematiclayer_id = %s - AND ( - s.shell_idx = 1 - OR jsonb_array_length(c."3D_geometry"->'coordinates') > 1 - ) - ), - proj AS ( - SELECT - id, - shell_idx, - ST_SetSRID( - ST_Force2D( - ST_GeomFromGeoJSON( - jsonb_build_object( - 'type', 'Polygon', - 'coordinates', - CASE - -- if face is already [ring,...], keep it; else wrap to [ring] - WHEN jsonb_typeof(face->0->0) = 'array' THEN face - ELSE jsonb_build_array(face) - END - )::text - ) - ), - 0 - ) AS g2d - FROM faces - ), - u AS ( - SELECT - id, - ST_UnaryUnion( ST_Collect(g2d) FILTER (WHERE shell_idx = 1) ) AS ext2d, - ST_UnaryUnion( ST_Collect(g2d) FILTER (WHERE shell_idx > 1) ) AS int2d - FROM proj - GROUP BY id - ) - UPDATE cell_space_n_boundary c - SET "2D_geometry" = - CASE - WHEN u.int2d IS NULL THEN u.ext2d - ELSE ST_Difference(u.ext2d, u.int2d) - END - FROM u - WHERE c.id = u.id - AND c.thematiclayer_id = %s; - """ - sql_project_shell = """ WITH targets AS ( SELECT id, "3D_geometry" FROM cell_space_n_boundary diff --git a/requirements-indoor.txt b/requirements-indoor.txt deleted file mode 100644 index d44d0b4fa..000000000 --- a/requirements-indoor.txt +++ /dev/null @@ -1,54 +0,0 @@ -affine==2.4.0 -annotated-types==0.7.0 -attrs==25.4.0 -babel==2.17.0 -blinker==1.9.0 -certifi==2026.1.4 -charset-normalizer==3.4.4 -click==8.3.1 -cligj==0.7.2 -contourpy==1.3.3 -cycler==0.12.1 -dateparser==1.2.2 -filelock==3.20.3 -Flask==3.1.2 -flask-cors==6.0.2 -fonttools==4.61.1 -GeoAlchemy2==0.18.1 -greenlet==3.3.0 -idna==3.11 -itsdangerous==2.2.0 -Jinja2==3.1.6 -jsonschema==4.26.0 -jsonschema-specifications==2025.9.1 -kiwisolver==1.4.9 -lark==1.3.1 -MarkupSafe==3.0.3 -matplotlib==3.10.8 -numpy==2.4.1 -packaging==25.0 -pillow==12.1.0 -psycopg2-binary==2.9.11 -pydantic==2.12.5 -pydantic_core==2.41.5 -pygeofilter==0.3.3 -pygeoif==1.6.0 -pyparsing==3.3.1 -pyproj==3.7.2 -python-dateutil==2.9.0.post0 -pytz==2025.2 -PyYAML==6.0.3 -rasterio==1.5.0 -referencing==0.37.0 -regex==2026.1.15 -requests==2.32.5 -rpds-py==0.30.0 -shapely==2.1.2 -six==1.17.0 -SQLAlchemy==2.0.45 -tinydb==4.8.2 -typing-inspection==0.4.2 -typing_extensions==4.15.0 -tzlocal==5.3.1 -urllib3==2.6.3 -Werkzeug==3.1.5