From f8fb90dca16fa2dedd17b11efbaab6d15b2fc422 Mon Sep 17 00:00:00 2001 From: Nayor Date: Mon, 6 May 2024 18:06:51 +0200 Subject: [PATCH 1/2] feat:#1067 expose waypoints public_transportation information --- c2corg_api/models/waypoint.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/c2corg_api/models/waypoint.py b/c2corg_api/models/waypoint.py index e2979036c..8576e62ac 100644 --- a/c2corg_api/models/waypoint.py +++ b/c2corg_api/models/waypoint.py @@ -328,5 +328,5 @@ class ArchiveWaypointLocale(_WaypointLocaleMixin, ArchiveDocumentLocale): schema_create_waypoint = get_create_schema(schema_waypoint) schema_update_waypoint = get_update_schema(schema_waypoint) schema_association_waypoint = restrict_schema(schema_waypoint, [ - 'elevation', 'locales.title', 'locales.access_period', 'geometry.geom' + 'elevation', 'locales.title', 'locales.access_period', 'geometry.geom', 'public_transportation_rating' ]) From af19ddd957f4d5db713a7a6d296f266b5ff11cbb Mon Sep 17 00:00:00 2001 From: Nayor Date: Wed, 8 May 2024 00:18:17 +0200 Subject: [PATCH 2/2] feat:#1067 add public transportation rating to route --- ...dd_column_public_transportation_rating_.py | 92 ++++ c2corg_api/models/common/attributes.py | 1 + c2corg_api/models/common/fields_route.py | 2 + c2corg_api/models/document.py | 46 +- c2corg_api/models/route.py | 4 +- c2corg_api/models/waypoint.py | 12 +- .../tests/views/test_document_delete.py | 4 +- c2corg_api/tests/views/test_route.py | 437 +++++++++++++++++- c2corg_api/tests/views/test_waypoint.py | 74 ++- c2corg_api/views/document_revert.py | 4 +- c2corg_api/views/route.py | 251 +++++++++- c2corg_api/views/waypoint.py | 48 +- 12 files changed, 922 insertions(+), 53 deletions(-) create mode 100644 alembic_migration/versions/b59d3efaf2a1_add_column_public_transportation_rating_.py diff --git a/alembic_migration/versions/b59d3efaf2a1_add_column_public_transportation_rating_.py b/alembic_migration/versions/b59d3efaf2a1_add_column_public_transportation_rating_.py new file mode 100644 index 000000000..296282f05 --- /dev/null +++ b/alembic_migration/versions/b59d3efaf2a1_add_column_public_transportation_rating_.py @@ -0,0 +1,92 @@ +"""Add column public_transportation_rating to route.py + +Revision ID: b59d3efaf2a1 +Revises: 626354ffcda0 +Create Date: 2024-05-07 16:13:17.458223 + +""" +from alembic import op +import sqlalchemy as sa + +# revision identifiers, used by Alembic. +revision = 'b59d3efaf2a1' +down_revision = '626354ffcda0' +branch_labels = None +depends_on = None + + +def upgrade(): + + new_public_transportation_rating_type = sa.Enum( + 'good service', + 'seasonal service', + 'poor service', + 'nearby service', + 'unknown service', + 'no service', + name='public_transportation_ratings', + schema='guidebook' + ) + new_public_transportation_rating_type.create(op.get_bind()) + op.alter_column( + "waypoints", + "public_transportation_rating", + type_=new_public_transportation_rating_type, + postgresql_using="public_transportation_rating::text::guidebook.public_transportation_ratings", + schema="guidebook", + ) + op.alter_column( + 'waypoints_archives', + 'public_transportation_rating', + type_=new_public_transportation_rating_type, + postgresql_using='public_transportation_rating::text::guidebook.public_transportation_ratings', + schema='guidebook' + ) + op.execute("DROP TYPE IF EXISTS guidebook.public_transportation_rating") + op.add_column( + 'routes', + sa.Column( + 'public_transportation_rating', + new_public_transportation_rating_type, + nullable=True), + schema='guidebook') + op.add_column( + 'routes_archives', + sa.Column( + 'public_transportation_rating', + new_public_transportation_rating_type, + nullable=True), + schema='guidebook') + + +def downgrade(): + op.drop_column('routes', 'public_transportation_rating', schema='guidebook') + op.drop_column('routes_archives', 'public_transportation_rating', schema='guidebook') + op.execute("UPDATE guidebook.waypoints SET public_transportation_rating = NULL WHERE public_transportation_rating = 'unknown service'") + op.execute("UPDATE guidebook.waypoints_archives SET public_transportation_rating = NULL WHERE public_transportation_rating = 'unknown service'") + new_public_transportation_rating_type = sa.Enum( + 'good service', + 'seasonal service', + 'poor service', + 'nearby service', + 'unknown service', + 'no service', + name='public_transportation_rating', + schema='guidebook' + ) + new_public_transportation_rating_type.create(op.get_bind()) + op.alter_column( + 'waypoints', + 'public_transportation_rating', + type_=new_public_transportation_rating_type, + postgresql_using='public_transportation_rating::text::guidebook.public_transportation_rating', + schema='guidebook' + ) + op.alter_column( + 'waypoints_archives', + 'public_transportation_rating', + type_=new_public_transportation_rating_type, + postgresql_using='public_transportation_rating::text::guidebook.public_transportation_rating', + schema='guidebook' + ) + op.execute("DROP TYPE IF EXISTS guidebook.public_transportation_ratings") diff --git a/c2corg_api/models/common/attributes.py b/c2corg_api/models/common/attributes.py index 8a4d20b08..ecf4dafca 100644 --- a/c2corg_api/models/common/attributes.py +++ b/c2corg_api/models/common/attributes.py @@ -111,6 +111,7 @@ 'seasonal service', # service saisonnier 'poor service', # service reduit 'nearby service', # service a proximite + 'unknown service', # service inconnu 'no service' # pas de service ] diff --git a/c2corg_api/models/common/fields_route.py b/c2corg_api/models/common/fields_route.py index d9f02a7db..cf33d10d8 100644 --- a/c2corg_api/models/common/fields_route.py +++ b/c2corg_api/models/common/fields_route.py @@ -17,6 +17,7 @@ 'height_diff_down', 'height_diff_access', 'height_diff_difficulties', + 'public_transportation_rating', 'lift_access', 'route_types', 'orientations', @@ -40,6 +41,7 @@ 'height_diff_up', 'height_diff_down', 'height_diff_difficulties', + 'public_transportation_rating', 'activities', 'quality', 'orientations' diff --git a/c2corg_api/models/document.py b/c2corg_api/models/document.py index ad995b2ad..a9754d777 100644 --- a/c2corg_api/models/document.py +++ b/c2corg_api/models/document.py @@ -349,6 +349,18 @@ def almost_equals(self, other): return self._almost_equals(self.geom, other.geom) and \ self._almost_equals(self.geom_detail, other.geom_detail) + def get_shape(self, geom): + if isinstance(geom, WKBElement): + g = wkb_to_shape(geom) + proj = geom.srid + else: + # WKT are used in the tests. + split = str.split(geom, ';') + proj = int(str.split(split[0], '=')[1]) + str1 = split[1] + g = wkt.loads(str1) + return g, proj + def _almost_equals(self, geom, other_geom): if geom is None and other_geom is None: return True @@ -357,29 +369,8 @@ def _almost_equals(self, geom, other_geom): elif geom is None and other_geom is not None: return False - g1 = None - proj1 = None - if isinstance(geom, WKBElement): - g1 = wkb_to_shape(geom) - proj1 = geom.srid - else: - # WKT are used in the tests. - split1 = str.split(geom, ';') - proj1 = int(str.split(split1[0], '=')[1]) - str1 = split1[1] - g1 = wkt.loads(str1) - - g2 = None - proj2 = None - if isinstance(other_geom, WKBElement): - g2 = wkb_to_shape(other_geom) - proj2 = other_geom.srid - else: - # WKT are used in the tests. - split2 = str.split(other_geom, ';') - proj2 = int(str.split(split2[0], '=')[1]) - str2 = split2[1] - g2 = wkt.loads(str2) + g1, proj1 = self.get_shape(geom) + g2, proj2 = self.get_shape(other_geom) # https://github.com/Toblerity/Shapely/blob/ # 8df2b1b718c89e7d644b246ab07ad3670d25aa6a/shapely/geometry/base.py#L673 @@ -401,6 +392,15 @@ def _almost_equals(self, geom, other_geom): return g1.almost_equals(g2, decimals) + def distance(self, geom, other_geom): + if geom is None or other_geom is None: + return None + + g1, _ = self.get_shape(geom) + g2, _ = self.get_shape(other_geom) + + return g1.distance(g2) + DocumentGeometry.lon_lat = column_property( func.ST_AsGeoJSON(func.ST_Transform(DocumentGeometry.geom, 4326)), diff --git a/c2corg_api/models/route.py b/c2corg_api/models/route.py index 11baad6d9..17a281c6c 100644 --- a/c2corg_api/models/route.py +++ b/c2corg_api/models/route.py @@ -153,6 +153,8 @@ class _RouteMixin(object): slackline_height = Column(SmallInteger) + public_transportation_rating = Column(enums.public_transportation_rating) + attributes = [ 'main_waypoint_id', 'activities', 'elevation_min', 'elevation_max', @@ -167,7 +169,7 @@ class _RouteMixin(object): 'hiking_mtb_exposition', 'snowshoe_rating', 'mtb_up_rating', 'mtb_down_rating', 'mtb_length_asphalt', 'mtb_length_trail', 'mtb_height_diff_portages', 'rock_types', 'climbing_outdoor_type', - 'slackline_type', 'slackline_height'] + 'slackline_type', 'slackline_height', 'public_transportation_rating'] class Route(_RouteMixin, Document): diff --git a/c2corg_api/models/waypoint.py b/c2corg_api/models/waypoint.py index 8576e62ac..139a9d117 100644 --- a/c2corg_api/models/waypoint.py +++ b/c2corg_api/models/waypoint.py @@ -214,6 +214,15 @@ def update(self, other): super(Waypoint, self).update(other) copy_attributes(other, self, attributes) + def get_update_type(self, old_versions): + update_types = super(Waypoint, self).get_update_type(old_versions) + + if self.public_transportation_rating != \ + old_versions.get('public_transportation_rating', None): + update_types[0].append('public_transportation_rating') + + return update_types + class ArchiveWaypoint(_WaypointMixin, ArchiveDocument): """ @@ -328,5 +337,6 @@ class ArchiveWaypointLocale(_WaypointLocaleMixin, ArchiveDocumentLocale): schema_create_waypoint = get_create_schema(schema_waypoint) schema_update_waypoint = get_update_schema(schema_waypoint) schema_association_waypoint = restrict_schema(schema_waypoint, [ - 'elevation', 'locales.title', 'locales.access_period', 'geometry.geom', 'public_transportation_rating' + 'elevation', 'locales.title', 'locales.access_period', 'geometry.geom', + 'public_transportation_rating' ]) diff --git a/c2corg_api/tests/views/test_document_delete.py b/c2corg_api/tests/views/test_document_delete.py index 68183b4da..3f39f55ce 100644 --- a/c2corg_api/tests/views/test_document_delete.py +++ b/c2corg_api/tests/views/test_document_delete.py @@ -718,7 +718,7 @@ def image_service_mock(url, request): def test_delete_collaborative_doc(self): # Collaborative documents cannot be deleted, even by their authors: headers = self.add_authorization_header(username='contributor') - return self.app.delete_json( + self.app.delete_json( self._prefix + str(self.waypoint4.document_id), {}, headers=headers, status=400) @@ -731,7 +731,7 @@ def test_delete_collaborative_doc(self): def test_delete_personal_doc_not_author(self): # Personal documents cannot be deleted by anyone: headers = self.add_authorization_header(username='contributor2') - return self.app.delete_json( + self.app.delete_json( self._prefix + str(self.article1.document_id), {}, headers=headers, status=400) diff --git a/c2corg_api/tests/views/test_route.py b/c2corg_api/tests/views/test_route.py index 1c8874aa3..04cf7c949 100644 --- a/c2corg_api/tests/views/test_route.py +++ b/c2corg_api/tests/views/test_route.py @@ -12,7 +12,8 @@ from c2corg_api.models.topo_map_association import TopoMapAssociation from c2corg_api.models.waypoint import Waypoint, WaypointLocale from c2corg_api.tests.search import reset_search_index -from c2corg_api.views.route import check_title_prefix +from c2corg_api.views.route import check_title_prefix, \ + _pt_rating, _find_starting_and_ending_points from c2corg_api.models.common.attributes import quality_types from shapely.geometry import shape, LineString @@ -1363,3 +1364,437 @@ def _add_test_data(self): self.session.add_all([self.area1, self.area2]) self.session.flush() + + def test_public_transportation_rating(self): + # Test case 1: Starting and ending waypoints with different ratings + starting_waypoints = [ + Waypoint( + document_id=1, + public_transportation_rating='good service' + ) + ] + ending_waypoints = [ + Waypoint( + document_id=2, + public_transportation_rating='poor service' + ) + ] + route_types = ['traverse'] + self.assertEqual( + _pt_rating( + starting_waypoints, + ending_waypoints, + route_types + ), + 'poor service' + ) + + # Test case 2: Starting and ending waypoints with the same ratings + starting_waypoints = [ + Waypoint( + document_id=1, + public_transportation_rating='poor service' + ) + ] + ending_waypoints = [ + Waypoint( + document_id=2, + public_transportation_rating='poor service' + ) + ] + route_types = ['raid'] + self.assertEqual( + _pt_rating( + starting_waypoints, + ending_waypoints, + route_types + ), + 'poor service' + ) + + # Test case 3: Starting and ending waypoints with missing ratings + starting_waypoints = [ + Waypoint( + document_id=1, + public_transportation_rating=None + ) + ] + ending_waypoints = [ + Waypoint( + document_id=2, + public_transportation_rating=None + ) + ] + route_types = ['traverse'] + self.assertEqual( + _pt_rating( + starting_waypoints, + ending_waypoints, + route_types + ), + 'unknown service' + ) + + # Test case 4: No waypoints provided + starting_waypoints = [] + ending_waypoints = [] + route_types = [] + self.assertEqual( + _pt_rating( + starting_waypoints, + ending_waypoints, + route_types + ), + 'unknown service' + ) + + # Test case 5: Different route types with no service + starting_waypoints = [ + Waypoint( + document_id=1, + public_transportation_rating='good service' + ) + ] + ending_waypoints = [ + Waypoint( + document_id=2, + public_transportation_rating='no service' + ) + ] + route_types = ['expedition'] + self.assertEqual( + _pt_rating( + starting_waypoints, + ending_waypoints, + route_types + ), + 'no service' + ) + + # Test case 6: Different route types with service + starting_waypoints = [ + Waypoint( + document_id=1, + public_transportation_rating='good service' + ) + ] + ending_waypoints = [ + Waypoint( + document_id=2, + public_transportation_rating='poor service' + ) + ] + route_types = ['traverse'] + self.assertEqual( + _pt_rating( + starting_waypoints, + ending_waypoints, + route_types + ), + 'poor service' + ) + + # Test case 7: Multiple starting and ending waypoints with ratings + starting_waypoints = [ + Waypoint( + document_id=1, + public_transportation_rating='good service' + ), + Waypoint( + document_id=2, + public_transportation_rating='seasonal service' + ), + Waypoint( + document_id=3, + public_transportation_rating='poor service' + ) + ] + ending_waypoints = [ + Waypoint( + document_id=4, + public_transportation_rating='nearby service' + ), + Waypoint( + document_id=5, + public_transportation_rating='no service' + ), + Waypoint( + document_id=6, + public_transportation_rating='good service' + ) + ] + route_types = ['traverse'] + self.assertEqual( + _pt_rating( + starting_waypoints, + ending_waypoints, + route_types + ), + 'good service' + ) + + # Test case 8: Multiple starting waypoints + # with ratings and no ending waypoints + starting_waypoints = [ + Waypoint( + document_id=1, + public_transportation_rating='poor service' + ), + Waypoint( + document_id=2, + public_transportation_rating='nearby service' + ), + Waypoint( + document_id=3, + public_transportation_rating='good service' + ) + ] + ending_waypoints = [] + route_types = ['traverse'] + self.assertEqual( + _pt_rating( + starting_waypoints, + ending_waypoints, + route_types + ), + 'unknown service' + ) + + # Test case 9: No starting waypoints + # and multiple ending waypoints with ratings + starting_waypoints = [] + ending_waypoints = [ + Waypoint( + document_id=4, + public_transportation_rating='good service' + ), + Waypoint( + document_id=5, + public_transportation_rating='seasonal service' + ), + Waypoint( + document_id=6, + public_transportation_rating='poor service' + ) + ] + route_types = ['traverse'] + self.assertEqual( + _pt_rating( + starting_waypoints, + ending_waypoints, + route_types + ), + 'unknown service' + ) + + # Test case 10: Multiple starting and ending waypoints with no ratings + starting_waypoints = [ + Waypoint( + document_id=1, + public_transportation_rating=None + ), + Waypoint( + document_id=2, + public_transportation_rating=None + ) + ] + ending_waypoints = [ + Waypoint( + document_id=4, + public_transportation_rating=None + ), + Waypoint( + document_id=5, + public_transportation_rating=None + ) + ] + route_types = ['traverse'] + self.assertEqual( + _pt_rating( + starting_waypoints, + ending_waypoints, + route_types + ), + 'unknown service' + ) + + # Test case 11: Multiple starting and ending waypoints, + # some with ratings and some without + starting_waypoints = [ + Waypoint( + document_id=1, + public_transportation_rating='poor service' + ), + Waypoint( + document_id=2, + public_transportation_rating=None + ), + Waypoint( + document_id=3, + public_transportation_rating='good service' + ) + ] + ending_waypoints = [ + Waypoint( + document_id=4, + public_transportation_rating=None + ), + Waypoint( + document_id=5, + public_transportation_rating='good service' + ), + Waypoint( + document_id=6, + public_transportation_rating='no service' + ) + ] + route_types = ['traverse'] + self.assertEqual( + _pt_rating( + starting_waypoints, + ending_waypoints, + route_types + ), + 'good service' + ) + + # Test case 12: Multiple starting points + starting_waypoints = [ + Waypoint( + document_id=1, + public_transportation_rating=None + ), + Waypoint( + document_id=2, + public_transportation_rating='seasonal service' + ), + Waypoint( + document_id=3, + public_transportation_rating='poor service' + ) + ] + ending_waypoints = [] + route_types = ['loop'] + self.assertEqual( + _pt_rating( + starting_waypoints, + ending_waypoints, + route_types + ), + 'seasonal service' + ) + + def test_find_starting_and_ending_points(self): + waypoints = [ + Waypoint( + waypoint_type='access', + geometry=DocumentGeometry( + geom='SRID=3857;POINT(659775 5694854)' + ) + ), + Waypoint( + waypoint_type='access', + geometry=DocumentGeometry( + geom='SRID=3857;POINT(659800 5694854)' + )), + Waypoint( + waypoint_type='access', + geometry=DocumentGeometry( + geom='SRID=3857;POINT(659900 5694854)' + ) + ), + Waypoint( + waypoint_type='access', + geometry=DocumentGeometry( + geom='SRID=3857;POINT(659950 5694854)' + ) + ) + ] + expected_start = 'SRID=3857;POINT(659775 5694854)' + + # Test case 1: crossings with multiple waypoints + route_types = ['traverse'] + starts, ends = \ + _find_starting_and_ending_points(waypoints, route_types) + self.assertEqual( + starts[0].geometry.geom, + expected_start + ) + self.assertEqual( + ends[0].geometry.geom, + 'SRID=3857;POINT(659950 5694854)' + ) + + # Test case 2: crossings with two waypoints + route_types = ['raid'] + starts, ends = _find_starting_and_ending_points( + [waypoints[0], waypoints[1]], + route_types + ) + + self.assertEqual( + starts[0].geometry.geom, + expected_start + ) + self.assertEqual( + ends[0].geometry.geom, + 'SRID=3857;POINT(659800 5694854)' + ) + + # Test case 3: loops + route_types = ['loop', 'traverse'] + starts, ends = \ + _find_starting_and_ending_points(waypoints, route_types) + self.assertEqual(starts, waypoints) + self.assertEqual(ends, []) + + # Test case 4: single access other cases + route_types = ['some_other_route_type'] + starts, ends = \ + _find_starting_and_ending_points([waypoints[0]], route_types) + self.assertEqual( + starts[0].geometry.geom, + expected_start + ) + self.assertEqual(ends, []) # No end point expected + + # Test case 5: multi waypoints other cases + route_types = ['some_other_route_type'] + start, end = _find_starting_and_ending_points( + [waypoints[0], waypoints[1]], + route_types + ) + # No start or end points expected for multiple waypoints + self.assertEqual(start, []) + self.assertEqual(end, []) + + def test_update_all_routes(self): + # Test case 1: collaborators cannot update all routes: + headers = self.add_authorization_header(username='contributor') + prefix = '/routes/update_public_transportation_rating' + self.app.get(prefix, headers=headers, status=403) + + # Test case 2: Moderators can, with waypoints_extrapolation + headers = self.add_authorization_header(username='moderator') + self.app.get(prefix, headers=headers, status=200) + self.session.flush() + self.session.refresh(self.route) + self.assertEqual( + self.route.public_transportation_rating, + 'poor service' + ) + + # Test case 3: Moderators can, without waypoints_extrapolation + self.route.route_types = ['traverse'] + self.session.flush() + self.app.get( + prefix+'?waypoint_extrapolation=false', + headers=headers, + status=200 + ) + self.session.flush() + self.session.refresh(self.route) + self.assertEqual( + self.route.public_transportation_rating, + 'unknown service' + ) diff --git a/c2corg_api/tests/views/test_waypoint.py b/c2corg_api/tests/views/test_waypoint.py index 98885e1f4..17803b082 100644 --- a/c2corg_api/tests/views/test_waypoint.py +++ b/c2corg_api/tests/views/test_waypoint.py @@ -65,37 +65,37 @@ def test_get_collection_paginated(self): self.app.get("/waypoints?offset=invalid", status=400) self.assertResultsEqual( - self.get_collection({'offset': 0, 'limit': 0}), [], 4) + self.get_collection({'offset': 0, 'limit': 0}), [], 5) self.assertResultsEqual( self.get_collection({'offset': 0, 'limit': 1}), - [self.waypoint4.document_id], 4) + [self.waypoint6.document_id], 5) self.assertResultsEqual( self.get_collection({'offset': 0, 'limit': 2}), - [self.waypoint4.document_id, self.waypoint3.document_id], 4) + [self.waypoint6.document_id, self.waypoint4.document_id], 5) self.assertResultsEqual( self.get_collection({'offset': 1, 'limit': 2}), - [self.waypoint3.document_id, self.waypoint2.document_id], 4) + [self.waypoint4.document_id, self.waypoint3.document_id], 5) def test_get_collection_caching(self): - cache_key_2 = get_cache_key( - self.waypoint2.document_id, None, WAYPOINT_TYPE) cache_key_3 = get_cache_key( self.waypoint3.document_id, None, WAYPOINT_TYPE) cache_key_4 = get_cache_key( self.waypoint4.document_id, None, WAYPOINT_TYPE) + cache_key_6 = get_cache_key( + self.waypoint6.document_id, None, WAYPOINT_TYPE) - self.assertEqual(cache_document_listing.get(cache_key_2), NO_VALUE) self.assertEqual(cache_document_listing.get(cache_key_3), NO_VALUE) self.assertEqual(cache_document_listing.get(cache_key_4), NO_VALUE) + self.assertEqual(cache_document_listing.get(cache_key_6), NO_VALUE) # check that documents returned in the response are cached self.assertResultsEqual( self.get_collection({'offset': 0, 'limit': 2}), - [self.waypoint4.document_id, self.waypoint3.document_id], 4) + [self.waypoint6.document_id, self.waypoint4.document_id], 5) - self.assertNotEqual(cache_document_listing.get(cache_key_3), NO_VALUE) self.assertNotEqual(cache_document_listing.get(cache_key_4), NO_VALUE) + self.assertNotEqual(cache_document_listing.get(cache_key_6), NO_VALUE) # check that values are returned from the cache fake_cache_value = {'document_id': 'fake_id'} @@ -104,8 +104,8 @@ def test_get_collection_caching(self): body = self.get_collection({'offset': 1, 'limit': 2}) self.assertResultsEqual( body, - ['fake_id', self.waypoint2.document_id], 4) - self.assertNotEqual(cache_document_listing.get(cache_key_2), NO_VALUE) + [self.waypoint4.document_id, 'fake_id'], 5) + self.assertNotEqual(cache_document_listing.get(cache_key_3), NO_VALUE) def test_get_collection_search(self): reset_search_index(self.session) @@ -1501,6 +1501,42 @@ def test_post_success_external_resource(self): locale_en = waypoint.get_locale('en') self.assertEqual(locale_en.external_resources, external_resources) + def test_update_access_waypoints_pt(self): + """Test updating waypoint public transportation rating + """ + body_put = { + 'message': 'Changing figures', + 'document': { + 'document_id': self.waypoint6.document_id, + 'version': self.waypoint6.version, + 'waypoint_type': 'access', + 'elevation': self.waypoint6.elevation, + 'public_transportation_rating': 'good service', + 'geometry': { + 'version': self.waypoint6.geometry.version, + 'geom': '{"type": "Point", "coordinates": [657403, 5691411]}' # noqa + }, + 'associations': { + 'routes': [ + {'document_id': self.route1.document_id} + ] + } + } + } + self.put_success_figures_only(body_put, self.waypoint6) + self.session.flush() + self.session.refresh(self.route1) + self.session.refresh(self.waypoint6) + self.assertEqual(self.waypoint6.waypoint_type, 'access') + self.assertEqual( + self.waypoint6.public_transportation_rating, + 'good service' + ) + self.assertEqual( + self.route1.public_transportation_rating, + 'good service' + ) + def _add_test_data(self): self.waypoint = Waypoint( waypoint_type='summit', elevation=2203) @@ -1562,10 +1598,23 @@ def _add_test_data(self): lang='en', title='Mont Granier', description='...', access='yep')) self.session.add(self.waypoint5) + + self.waypoint6 = Waypoint( + waypoint_type='access', elevation=1096, + geometry=DocumentGeometry( + geom='SRID=3857;POINT(657403 5691411)')) + self.waypoint6.locales.append(WaypointLocale( + lang='fr', title='La Plagne', description='...', + access='ouai')) + self.waypoint6.locales.append(WaypointLocale( + lang='en', title='La Plagne', description='...', + access='yep')) + self.session.add(self.waypoint6) self.session.flush() DocumentRest.create_new_version(self.waypoint4, user_id) DocumentRest.create_new_version(self.waypoint5, user_id) + DocumentRest.create_new_version(self.waypoint6, user_id) # add some associations route1_geometry = DocumentGeometry( @@ -1610,6 +1659,9 @@ def _add_test_data(self): self._add_association(Association.create( parent_document=self.waypoint4, child_document=self.route3), user_id) + self._add_association(Association.create( + parent_document=self.route1, + child_document=self.waypoint6), user_id) # article self.article1 = Article( diff --git a/c2corg_api/views/document_revert.py b/c2corg_api/views/document_revert.py index df4251a38..bea12092a 100644 --- a/c2corg_api/views/document_revert.py +++ b/c2corg_api/views/document_revert.py @@ -21,7 +21,7 @@ from c2corg_api.views.area import update_associations from c2corg_api.views.document import DocumentRest from c2corg_api.views.waypoint import update_linked_route_titles -from c2corg_api.views.route import update_title_prefix +from c2corg_api.views.route import update_linked_attributes from c2corg_api.models.common.attributes import default_langs from colander import ( MappingSchema, SchemaNode, Integer, String, required, OneOf) @@ -202,7 +202,7 @@ def _get_after_update(self, document_type): if document_type == WAYPOINT_TYPE: return update_linked_route_titles if document_type == ROUTE_TYPE: - return update_title_prefix + return update_linked_attributes if document_type == AREA_TYPE: return update_associations return None diff --git a/c2corg_api/views/route.py b/c2corg_api/views/route.py index 351a01a1a..25a9c2641 100644 --- a/c2corg_api/views/route.py +++ b/c2corg_api/views/route.py @@ -1,4 +1,4 @@ -import logging +from itertools import combinations import functools @@ -6,6 +6,7 @@ from c2corg_api.models.association import Association from c2corg_api.models.document import DocumentLocale, DocumentGeometry from c2corg_api.models.outing import Outing +from c2corg_api.models.waypoint import WAYPOINT_TYPE, Waypoint from c2corg_api.views.document_associations import get_first_column from c2corg_api.views.document_info import DocumentInfoRest from c2corg_api.views.document_listings import get_documents_for_ids @@ -22,15 +23,15 @@ from c2corg_api.views.document import DocumentRest, make_validator_create, \ make_validator_update, NUM_RECENT_OUTINGS from c2corg_api.views import cors_policy, restricted_json_view, \ - get_best_locale, set_default_geom_from_associations + get_best_locale, restricted_view, set_default_geom_from_associations from c2corg_api.views.validation import validate_id, validate_pagination, \ validate_lang, validate_version_id, validate_lang_param, \ validate_preferred_lang_param, validate_associations, validate_cook_param from c2corg_api.models.common.fields_route import fields_route -from c2corg_api.models.common.attributes import activities +from c2corg_api.models.common.attributes import activities, \ + public_transportation_ratings from sqlalchemy.orm import load_only - -log = logging.getLogger(__name__) +from sqlalchemy.sql.expression import text, or_, column, union validate_route_create = make_validator_create( fields_route, 'activities', activities) @@ -112,7 +113,7 @@ def collection_post(self): schema_route, before_add=functools.partial( set_default_geometry, linked_waypoints), - after_add=init_title_prefix) + after_add=init_linked_attributes) @restricted_json_view( schema=schema_update_route, @@ -127,7 +128,7 @@ def put(self): return self._put(Route, schema_route, before_update=update_default_geometry, - after_update=update_title_prefix) + after_update=update_linked_attributes) @staticmethod def set_recent_outings(route, lang): @@ -176,6 +177,33 @@ def get(self): return self._get_document_info(route_documents_config) +@resource(path="/routes/update_public_transportation_rating", + cors_policy=cors_policy) +class RoutePublicTransportationRatingRest(object): + """ + Update all route public transportation rating + + Request: + `GET` `/update_public_transportation_rating?waypoint_extrapolation=...` + + + Parameters: + `waypoint_extrapolation=true` (optional) + extrapolate starting and ending points + allows to reset public_transportation_rating values + set to true by default + """ + + def __init__(self, request): + self.request = request + + @restricted_view(permission='moderator') + def get(self): + waypoint_extrapolation = self.request.params.get( + 'waypoint_extrapolation') or True + update_all_pt_rating(waypoint_extrapolation) + + def set_default_geometry(linked_waypoints, route, user_id): """When creating a new route, set the default geometry to the middle point of a given track, if not to the geometry of the associated main waypoint @@ -223,12 +251,14 @@ def main_waypoint_has_changed(route, old_main_waypoint_id): return old_main_waypoint_id != route.main_waypoint_id -def init_title_prefix(route, user_id): +def init_linked_attributes(route, user_id): check_title_prefix(route, create=True) + update_pt_rating(route) -def update_title_prefix(route, update_types, user_id): +def update_linked_attributes(route, update_types, user_id): check_title_prefix(route) + update_pt_rating(route) def check_title_prefix(route, create=False): @@ -282,3 +312,206 @@ def set_title_prefix_for_ids(ids, title): """ DBSession.query(RouteLocale).filter(RouteLocale.id.in_(ids)). \ update({RouteLocale.title_prefix: title}, synchronize_session=False) + + +def update_all_pt_rating(waypoint_extrapolation=True): + """ Update the public transportation rating of every routes + based on linked waypoints + Warning: this is a very heavy request to run, check logs levels before use: + - avoid debug/info level for python + - avoir all level for postgresql (prefer log_statement = 'ddl') + """ + route_type = text('\'' + ROUTE_TYPE + '\'') + waypoint_type = text('\'' + WAYPOINT_TYPE + '\'') + + # Get all routes parent of an access waypoint + parent_routes = DBSession. \ + query( + Association.parent_document_id.label('route_id') + ) \ + .filter(Association.parent_document_type == route_type) \ + .filter(Association.child_document_type == waypoint_type) \ + .join( + Waypoint, + Waypoint.document_id == Association.child_document_id + ) \ + .filter(Waypoint.waypoint_type == 'access') \ + .subquery() + # Get all routes children of an access waypoint + children_routes = DBSession. \ + query( + Association.child_document_id.label('route_id') + ) \ + .filter(Association.child_document_type == route_type) \ + .filter(Association.parent_document_type == waypoint_type) \ + .join( + Waypoint, + Waypoint.document_id == Association.parent_document_id + ) \ + .filter(Waypoint.waypoint_type == 'access') \ + .subquery() + # Merge all routes + routes = DBSession \ + .query(Route) \ + .select_from(union(parent_routes.select(), children_routes.select())) \ + .join(Route, Route.document_id == column('route_id')) \ + .all() + + for route in routes: + update_pt_rating(route, waypoint_extrapolation) + + return True + + +def update_pt_rating(route, waypoint_extrapolation=True): + """Update public transportation rating + based on provided ending and starting waypoints + If none are provided, an extrapolation can be done + """ + + if waypoint_extrapolation: + linked_access_waypoints = _get_linked_waypoints_pt_ratings(route) + starting_waypoints, ending_waypoints = \ + _find_starting_and_ending_points( + linked_access_waypoints, + route.route_types + ) + else: + starting_waypoints, ending_waypoints = [], [] + + public_transportation_rating = _pt_rating( + starting_waypoints, ending_waypoints, route.route_types) + + DBSession.query(Route) \ + .filter(Route.document_id == route.document_id) \ + .update({Route.public_transportation_rating: + public_transportation_rating}, + synchronize_session=False + ) + + +def _get_linked_waypoints_pt_ratings(route): + """ Get all linked waypoints linked to a route + """ + waypoint_type = text('\'' + WAYPOINT_TYPE + '\'') + + linked_access_waypoints = DBSession. \ + query( + Waypoint + ) \ + .select_from(Association) \ + .filter(or_( + Association.parent_document_id == route.document_id, + Association.child_document_id == route.document_id, + )) \ + .filter(or_( + Association.child_document_type == waypoint_type, + Association.parent_document_type == waypoint_type, + )) \ + .join(Waypoint, or_( + Waypoint.document_id == Association.child_document_id, + Waypoint.document_id == Association.parent_document_id + )) \ + .filter(Waypoint.waypoint_type == 'access') \ + .all() + return linked_access_waypoints + + +def _find_starting_and_ending_points(waypoints, route_types): + """Extrapolation of starting and ending waypoints + based on the followed rules: + - for crossings : with the two most distant access waypoints + Calculates the distance between all pairs of points + using shapely's distance() method + and returns the pair with the maximum distance. + - for loops : with all the access waypoints + - for others route type : + only if a single access waypoint is linked to the route + """ + if route_types and bool( + set(["loop", "loop_hut", "return_same_way"]) & set(route_types) + ): + return waypoints, [] + elif ( + route_types + and bool(set(["traverse", "raid", "expedition"]) & set(route_types)) + and len(waypoints) >= 2 + ): + if len(waypoints) == 2: + return [waypoints[0]], [waypoints[1]] + # Initialize variables to store + # the most distant points and their distance + max_dist = 0 + most_distant_points = None + + # Generate combinations of points + point_combinations = combinations(waypoints, 2) + + # Iterate through combinations to find the most distant points + for w1, w2 in point_combinations: + dist = w1.geometry.distance(w1.geometry.geom, w2.geometry.geom) + if dist > max_dist: + max_dist = dist + most_distant_points = (w1, w2) + + return [most_distant_points[0]], [most_distant_points[1]] + elif len(waypoints) == 1: + return [waypoints[0]], [] + else: + return [], [] + + +def _pt_rating(starting_waypoints, ending_waypoints, route_types): + """Take best public transportation rating value of each array + (starting_waypoint and ending_waypoints) + and keep the worst of the two values + If no waypoint is provided, assume default service (unknown) + """ + # If no starting point is provided + if len(starting_waypoints) == 0: + return 'unknown service' + + # Function to convert rating to its index in the enum + def rating_index(rating): + if rating is None: + return public_transportation_ratings.index('unknown service') + return public_transportation_ratings.index(rating) + + # Function to get the best rating between two ratings + def best_rating(rating1, rating2): + return public_transportation_ratings[ + min(rating_index(rating1), rating_index(rating2)) + ] + + # Function to get the worst rating between two ratings + def worst_rating(rating1, rating2): + return public_transportation_ratings[ + max(rating_index(rating1), rating_index(rating2)) + ] + + # Initialize variables to hold best ratings + best_starting_rating = 'no service' + best_ending_rating = 'no service' + + # Iterate through starting waypoints + for waypoint in starting_waypoints: + best_starting_rating = best_rating( + best_starting_rating, waypoint.public_transportation_rating) + + # Return the best starting rating if it's not a crossing + if not (route_types and bool( + set(["traverse", "raid", "expedition"]) & set(route_types) + )): + return best_starting_rating + + # If no ending point is provided + if (len(ending_waypoints) == 0): + return 'unknown service' + + # Iterate through ending waypoints + for waypoint in ending_waypoints: + best_ending_rating = best_rating( + best_ending_rating, waypoint.public_transportation_rating) + + # Return the worst of the two ratings + return worst_rating(best_starting_rating, best_ending_rating) diff --git a/c2corg_api/views/waypoint.py b/c2corg_api/views/waypoint.py index 893ae0b0c..a1d4ce97a 100644 --- a/c2corg_api/views/waypoint.py +++ b/c2corg_api/views/waypoint.py @@ -11,7 +11,8 @@ from c2corg_api.views.document_schemas import waypoint_documents_config, \ waypoint_schema_adaptor, outing_documents_config, route_documents_config from c2corg_api.views.document_version import DocumentVersionRest -from c2corg_api.views.route import set_route_title_prefix +from c2corg_api.views.route import set_route_title_prefix, \ + update_pt_rating from cornice.resource import resource, view from cornice.validators import colander_body_validator @@ -32,7 +33,7 @@ from sqlalchemy.orm import joinedload, load_only from sqlalchemy.orm.util import aliased from sqlalchemy.sql.elements import literal_column -from sqlalchemy.sql.expression import and_, union +from sqlalchemy.sql.expression import and_, text, union, column # the number of routes that are included for waypoints NUM_ROUTES = 400 @@ -234,7 +235,7 @@ def put(self): given, the route associations will not be changed. """ return self._put( - Waypoint, schema_waypoint, after_update=update_linked_route_titles) + Waypoint, schema_waypoint, after_update=update_linked_routes) def set_custom_associations(waypoint, lang): @@ -392,6 +393,11 @@ def get(self): return self._get_document_info(waypoint_documents_config) +def update_linked_routes(waypoint, update_types, user_id): + update_linked_route_titles(waypoint, update_types, user_id) + update_linked_routes_public_transportation_rating(waypoint, update_types) + + def update_linked_route_titles(waypoint, update_types, user_id): """When a waypoint is the main waypoint of a route, the field `title_prefix`, which caches the waypoint name, has to be updated. @@ -417,3 +423,39 @@ def update_linked_route_titles(waypoint, update_types, user_id): for route in linked_routes: set_route_title_prefix( route, waypoint_locales, waypoint_locales_index) + + +def update_linked_routes_public_transportation_rating(waypoint, update_types): + if ( + waypoint.waypoint_type != "access" + or "public_transportation_rating" not in update_types + ): + return + + route_type = text('\'' + ROUTE_TYPE + '\'') + + # Get all parent routes + parent_routes = DBSession. \ + query( + Association.parent_document_id.label('route_id') + ) \ + .filter(Association.parent_document_type == route_type) \ + .filter(Association.child_document_id == waypoint.document_id) \ + .subquery() + # Get all children routes + children_routes = DBSession. \ + query( + Association.child_document_id.label('route_id') + ) \ + .filter(Association.child_document_type == route_type) \ + .filter(Association.parent_document_id == waypoint.document_id) \ + .subquery() + # Merge all routes + routes = DBSession \ + .query(Route) \ + .select_from(union(parent_routes.select(), children_routes.select())) \ + .join(Route, Route.document_id == column('route_id')) \ + .all() + + for route in routes: + update_pt_rating(route)