diff --git a/critiquebrainz/db/rating_stats.py b/critiquebrainz/db/rating_stats.py new file mode 100644 index 00000000..dca3fd77 --- /dev/null +++ b/critiquebrainz/db/rating_stats.py @@ -0,0 +1,54 @@ +import sqlalchemy + +from critiquebrainz import db + +def get_stats(entity_id, entity_type): + """Gets the average rating and the rating statistics of the entity + + It is done by selecting ratings from the latest revisions of all reviews + for a given entity. + + Args: + entity_id (uuid): ID of the entity + entity_type (str): Type of the entity + """ + with db.engine.connect() as connection: + result = connection.execute(sqlalchemy.text(""" + WITH LatestRevisions AS ( + SELECT review_id, + MAX("timestamp") created_at + FROM revision + WHERE review_id in ( + SELECT id + FROM review + WHERE entity_id = :entity_id + AND entity_type = :entity_type + AND is_hidden = 'f') + GROUP BY review_id + ) + SELECT rating + FROM revision + INNER JOIN LatestRevisions + ON revision.review_id = LatestRevisions.review_id + AND revision.timestamp = LatestRevisions.created_at + """), { + "entity_id": entity_id, + "entity_type": entity_type, + }) + row = result.fetchall() + + ratings_stats = {1: 0, 2: 0, 3: 0, 4:0, 5:0} + if row == []: + return ratings_stats, 0 + + ratings = [r[0]/20 for r in row if r[0] is not None] + + for rating in ratings: + ratings_stats[rating] += 1 + + if ratings: + average_rating = sum(ratings)/len(ratings) + else: + average_rating = 0 + + return ratings_stats, average_rating \ No newline at end of file diff --git a/critiquebrainz/db/review.py b/critiquebrainz/db/review.py index 26bca0a8..34d59e0a 100644 --- a/critiquebrainz/db/review.py +++ b/critiquebrainz/db/review.py @@ -300,14 +300,16 @@ def update(review_id, *, drafted, text=None, rating=None, license_id=None, langu with db.engine.begin() as connection: result = connection.execute(sqlalchemy.text(""" - SELECT review.entity_id + SELECT review.entity_id, + review.entity_type, + review.user_id FROM review WHERE review.id = :review_id """), { "review_id": review_id, }) review = dict(result.mappings().first()) - invalidate_ws_entity_cache(review["entity_id"]) + invalidate_ws_entity_cache(review["entity_id"], review["entity_type"], review["user_id"]) def create(*, entity_id, entity_type, user_id, is_draft, text=None, rating=None, @@ -389,11 +391,11 @@ def create(*, entity_id, entity_type, user_id, is_draft, text=None, rating=None, if rating: db_revision.update_rating(review_id) - invalidate_ws_entity_cache(entity_id) + invalidate_ws_entity_cache(entity_id, entity_type, user_id) return get_by_id(review_id) -def invalidate_ws_entity_cache(entity_id): +def invalidate_ws_entity_cache(entity_id, entity_type, user_id): cache_keys_for_entity_id_key = cache.gen_key('ws_cache', entity_id) cache_keys_to_delete = cache.smembers(cache_keys_for_entity_id_key, namespace=REVIEW_CACHE_NAMESPACE) if cache_keys_to_delete: @@ -406,6 +408,20 @@ def invalidate_ws_entity_cache(entity_id): cache.delete_many(cache_keys_to_delete, namespace=REVIEW_CACHE_NAMESPACE) cache.delete(cache_keys_for_no_entity_id_key, namespace=REVIEW_CACHE_NAMESPACE) + # Invalidate top and latest reviews caches + cache_keys_to_delete = [ + cache.gen_key(f'entity_api_{entity_type}', entity_id, review_type, f"{sort_type}_reviews") + for sort_type in ('popularity', 'published_on') + for review_type in ('review', 'rating', None) + ] + cache.delete_many(cache_keys_to_delete, namespace=REVIEW_CACHE_NAMESPACE) + + user = db_users.get_by_id(user_id) + if user and 'musicbrainz_username' in user.keys() and user['musicbrainz_username']: + username = user["musicbrainz_username"] + cache_key = cache.gen_key('entity_api', entity_id, entity_type, username, "user_review") + cache.delete(cache_key, namespace=REVIEW_CACHE_NAMESPACE) + # pylint: disable=too-many-branches def get_reviews_list(connection, *, inc_drafts=False, inc_hidden=False, entity_id=None, diff --git a/critiquebrainz/ws/__init__.py b/critiquebrainz/ws/__init__.py index 1d184354..11b07a8c 100644 --- a/critiquebrainz/ws/__init__.py +++ b/critiquebrainz/ws/__init__.py @@ -7,6 +7,9 @@ deploy_env = os.environ.get('DEPLOY_ENV', '') CONSUL_CONFIG_FILE_RETRY_COUNT = 10 +REVIEWS_LIMIT = 5 +REVIEW_CACHE_NAMESPACE = "Review" +REVIEW_CACHE_TIMEOUT = 30 * 60 # 30 minutes def create_app(debug=None, config_path=None): @@ -95,6 +98,7 @@ def create_app(debug=None, config_path=None): app.config["JSONIFY_PRETTYPRINT_REGULAR"] = False + _register_converters(app) _register_blueprints(app) return app @@ -111,7 +115,14 @@ def _register_blueprints(app): from critiquebrainz.ws.review.views import review_bp from critiquebrainz.ws.user.views import user_bp from critiquebrainz.ws.review.bulk import bulk_review_bp + from critiquebrainz.ws.entity.views import entity_bp app.register_blueprint(oauth_bp, url_prefix="/oauth") app.register_blueprint(review_bp, url_prefix="/review") app.register_blueprint(user_bp, url_prefix="/user") app.register_blueprint(bulk_review_bp, url_prefix="/reviews") + app.register_blueprint(entity_bp) + + +def _register_converters(app): + from critiquebrainz.ws.entity.views import EntityNameConverter + app.url_map.converters['entity_name'] = EntityNameConverter diff --git a/critiquebrainz/ws/constants.py b/critiquebrainz/ws/constants.py index c7645f6f..cd6f7629 100644 --- a/critiquebrainz/ws/constants.py +++ b/critiquebrainz/ws/constants.py @@ -2,4 +2,6 @@ 'user', 'review', 'vote', + 'artist', + 'release_group', ) diff --git a/critiquebrainz/ws/entity/__init__.py b/critiquebrainz/ws/entity/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/critiquebrainz/ws/entity/test/__init__.py b/critiquebrainz/ws/entity/test/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/critiquebrainz/ws/entity/test/views_test.py b/critiquebrainz/ws/entity/test/views_test.py new file mode 100644 index 00000000..ab198e94 --- /dev/null +++ b/critiquebrainz/ws/entity/test/views_test.py @@ -0,0 +1,89 @@ +from brainzutils import cache + +import critiquebrainz.db.license as db_license +import critiquebrainz.db.review as db_review +import critiquebrainz.db.users as db_users +from critiquebrainz.db.user import User +from critiquebrainz.ws.testing import WebServiceTestCase + + +class EntityViewsTestCase(WebServiceTestCase): + + def setUp(self): + super(EntityViewsTestCase, self).setUp() + + self.artist_id1 = "f59c5520-5f46-4d2c-b2c4-822eabf53419" + self.artist_id2 = "83d91898-7763-47d7-b03b-b92132375c47" + + self.user = User(db_users.get_or_create(1, "Tester", new_user_data={ + "display_name": "test user", + })) + self.another_user = User(db_users.get_or_create(2, "Hacker!", new_user_data={ + "display_name": "test hacker", + })) + self.license = db_license.create( + id="CC BY-SA 3.0", + full_name="Created so we can fill the form correctly.", + ) + self.review = dict( + entity_id=self.artist_id1, + entity_type='artist', + user_id=self.user.id, + text="Testing! This text should be on the page.", + rating=5, + is_draft=False, + license_id=self.license["id"], + ) + self.review2 = dict( + entity_id=self.artist_id2, + entity_type='artist', + user_id=self.user.id, + text="Testing! This text should be on the page.", + rating=5, + is_draft=False, + license_id=self.license["id"], + ) + + def create_dummy_review(self): + return db_review.create(**self.review) + + def create_dummy_review2(self): + return db_review.create(**self.review2) + + def test_artist_endpoint(self): + review = self.create_dummy_review() + response = self.client.get('/artist/f59c5520-5f46-4d2c-b2c4-822eabf53419') + + self.assert200(response) + self.assertIn(review['text'], response.json['top_reviews'][0]['text']) + + self.assertEqual(5, response.json['average_rating']) + self.assertEqual(1, response.json['reviews_count']) + + # Test for an artist which does not exist + response = self.client.get('/artist/f59c5520-5f46-4d2c-b2c4-822eabf53417') + self.assert404(response) + + def test_artist_user_reviews(self): + review = self.create_dummy_review() + response = self.client.get('/artist/f59c5520-5f46-4d2c-b2c4-822eabf53419?username=%s' % self.user.musicbrainz_username) + + self.assert200(response) + self.assertIn(review['text'], response.json['user_review']['text']) + + def test_user_cache_tracking(self): + track_key = cache.gen_key("entity_api", self.artist_id2, "artist",self.user.musicbrainz_username, "user_review") + + # Make sure the cache is empty + self.client.get('/artist/83d91898-7763-47d7-b03b-b92132375c47?username=%s' % self.user.musicbrainz_username) + cache_value = cache.get(track_key, namespace="Review") + self.assertEqual([], cache_value) + + review = self.create_dummy_review2() + + # Check if the cache is populated after the request + self.client.get('/artist/83d91898-7763-47d7-b03b-b92132375c47?username=%s' % self.user.musicbrainz_username) + cache_value = cache.get(track_key, namespace="Review") + self.assertTrue(cache_value is not None) + + self.assertIn(review['text'], cache_value['text']) diff --git a/critiquebrainz/ws/entity/views.py b/critiquebrainz/ws/entity/views.py new file mode 100644 index 00000000..9f6101a0 --- /dev/null +++ b/critiquebrainz/ws/entity/views.py @@ -0,0 +1,255 @@ +from flask import Blueprint, jsonify +import critiquebrainz.db.review as db_review +import critiquebrainz.db.users as db_users +import critiquebrainz.db.rating_stats as db_rating_stats +from critiquebrainz.frontend.external.entities import get_entity_by_id +from critiquebrainz.decorators import crossdomain +from critiquebrainz.ws.exceptions import NotFound +from critiquebrainz.ws.parser import Parser +from critiquebrainz.ws import REVIEWS_LIMIT, REVIEW_CACHE_NAMESPACE, REVIEW_CACHE_TIMEOUT +from brainzutils import cache +from werkzeug.routing import BaseConverter + +entity_bp = Blueprint('ws_entity', __name__) + +ENTITY_URL_TYPE_MAPPING = { + "release-group": "release_group", + "artist": "artist", + "label": "label", + "place": "place", + "event": "event", + "work": "work", + "recording": "recording", + "series": "bb_series", + "edition-proup": "bb_edition_group", + "literary-work": "bb_literary_work", + "author": "bb_author", +} + +class EntityNameConverter(BaseConverter): + """This converter only accepts valid entity names from ENTITY_URL_TYPE_MAPPING + + Rule('/') + """ + + def __init__(self, url_map): + super().__init__(url_map) + # Create regex pattern from valid entity names + entity_names = '|'.join(ENTITY_URL_TYPE_MAPPING.keys()) + self.regex = f"({entity_names})" + + def to_python(self, value): + return value + + def to_url(self, value): + return str(value) + + +def _get_cached_reviews(entity_id: str, entity_type: str, review_type: str, sort_type: str, + cache_namespace: str = REVIEW_CACHE_NAMESPACE) -> tuple[list, int]: + """Helper function to fetch and cache reviews.""" + cache_key = cache.gen_key(f"entity_api_{entity_type}", entity_id, review_type, f"{sort_type}_reviews") + cached_result = cache.get(cache_key, cache_namespace) + + if cached_result: + return cached_result + + reviews, count = db_review.list_reviews( + entity_id=entity_id, + entity_type=entity_type, + sort=sort_type, + review_type=review_type, + limit=REVIEWS_LIMIT, + offset=0, + ) + reviews = [db_review.to_dict(review) for review in reviews] + + cache.set(cache_key, (reviews, count), + expirein=REVIEW_CACHE_TIMEOUT, namespace=cache_namespace) + return reviews, count + + +def _get_cached_rating_stats(entity_id: str, entity_type: str, + cache_namespace: str = REVIEW_CACHE_NAMESPACE) -> tuple[dict, float]: + """Helper function to fetch and cache rating statistics.""" + cache_key = cache.gen_key(f"entity_api_{entity_type}", entity_id, "rating_stats") + cached_result = cache.get(cache_key, cache_namespace) + + if cached_result: + return cached_result + + ratings_stats, average_rating = db_rating_stats.get_stats(entity_id, entity_type) + + cache.set(cache_key, (ratings_stats, average_rating), + expirein=REVIEW_CACHE_TIMEOUT, namespace=cache_namespace) + return ratings_stats, average_rating + + +@entity_bp.route('//', methods=['GET', 'OPTIONS']) +@crossdomain(headers="Authorization, Content-Type") +def entity_handler(entity_name: str, entity_id: str): + """Get list of reviews. + + **Request Example:** + + .. code-block:: bash + + $ curl "https://critiquebrainz.org/ws/1//e6f48cbd-26de-4c2e-a24a-29892f9eb3be" \\ + -X GET + + **Response Example:** + + .. code-block:: json + + { + "average_rating": 4.0, + "latest_reviews": [ + { + "created": "Tue, 16 Aug 2022 11:26:58 GMT", + "edits": 0, + "entity_id": "e6f48cbd-26de-4c2e-a24a-29892f9eb3be", + "entity_type": "bb_series", + "full_name": "Creative Commons Attribution-ShareAlike 3.0 Unported", + "id": "998512d8-0d6b-4c76-bfb7-0ec666e3fa0a", + "info_url": "https://creativecommons.org/licenses/by-sa/3.0/", + "is_draft": false, + "is_hidden": false, + "language": "en", + "last_revision": { + "id": 11773, + "rating": 4, + "review_id": "998512d8-0d6b-4c76-bfb7-0ec666e3fa0a", + "text": null, + "timestamp": "Tue, 16 Aug 2022 11:26:58 GMT" + }, + "last_updated": "Tue, 16 Aug 2022 11:26:58 GMT", + "license_id": "CC BY-SA 3.0", + "popularity": 0, + "published_on": "Tue, 16 Aug 2022 11:26:58 GMT", + "rating": 4, + "source": null, + "source_url": null, + "text": null, + "user": { + "created": "Tue, 18 Jan 2022 09:53:49 GMT", + "display_name": "Ansh Goyal", + "id": "11a1160e-d607-4882-8a82-e2e800f664fe", + "karma": 0, + "user_type": "Noob" + }, + "votes_negative_count": 0, + "votes_positive_count": 0 + } + ], + "ratings_stats": { + "1": 0, + "2": 0, + "3": 0, + "4": 1, + "5": 0 + }, + "reviews_count": 1, + "entity": { + "bbid": "e6f48cbd-26de-4c2e-a24a-29892f9eb3be", + "disambiguation": "English", + "identifier_set_id": null, + "identifiers": null, + "name": "Harry Potter", + "relationship_set_id": 151767, + "series_ordering_type": "Automatic", + "series_type": "Work", + "sort_name": "Harry Potter" + }, + "top_reviews": [ + { + "created": "Tue, 16 Aug 2022 11:26:58 GMT", + "edits": 0, + "entity_id": "e6f48cbd-26de-4c2e-a24a-29892f9eb3be", + "entity_type": "bb_series", + "full_name": "Creative Commons Attribution-ShareAlike 3.0 Unported", + "id": "998512d8-0d6b-4c76-bfb7-0ec666e3fa0a", + "info_url": "https://creativecommons.org/licenses/by-sa/3.0/", + "is_draft": false, + "is_hidden": false, + "language": "en", + "last_revision": { + "id": 11773, + "rating": 4, + "review_id": "998512d8-0d6b-4c76-bfb7-0ec666e3fa0a", + "text": null, + "timestamp": "Tue, 16 Aug 2022 11:26:58 GMT" + }, + "last_updated": "Tue, 16 Aug 2022 11:26:58 GMT", + "license_id": "CC BY-SA 3.0", + "popularity": 0, + "published_on": "Tue, 16 Aug 2022 11:26:58 GMT", + "rating": 4, + "source": null, + "source_url": null, + "text": null, + "user": { + "created": "Tue, 18 Jan 2022 09:53:49 GMT", + "display_name": "Ansh Goyal", + "id": "11a1160e-d607-4882-8a82-e2e800f664fe", + "karma": 0, + "user_type": "Noob" + }, + "votes_negative_count": 0, + "votes_positive_count": 0 + } + ] + } + + :statuscode 200: no error + :statuscode 404: series not found + + :query username: User's username **(optional)** + :query review_type: ``review`` or ``rating``. If set, only return reviews which have a text review, or a rating **(optional)** + + :resheader Content-Type: *application/json* + """ + + entity_type = ENTITY_URL_TYPE_MAPPING.get(entity_name) + entity = get_entity_by_id(entity_id, entity_type) + if not entity: + raise NotFound(f"Can't find a {entity_name} with ID: {entity_id}") + + review_type = Parser.string('uri', 'review_type', valid_values=['rating', 'review'], optional=True) + + # Get user review if username provided + user_review = [] + username = Parser.string('uri', 'username', optional=True) + if username: + cache_key = cache.gen_key('entity_api', entity_id, entity_type, username, "user_review") + user_review = cache.get(cache_key, REVIEW_CACHE_NAMESPACE) + + if not user_review: + user = db_users.get_by_mbid(username) + if user: + reviews, _ = db_review.list_reviews( + entity_id=entity_id, + entity_type=entity_type, + user_id=user['id'] + ) + user_review = db_review.to_dict(reviews[0]) if reviews else [] + cache.set(cache_key, user_review, + expirein=REVIEW_CACHE_TIMEOUT, namespace=REVIEW_CACHE_NAMESPACE) + + # Get ratings and reviews + ratings_stats, average_rating = _get_cached_rating_stats(entity_id, entity_type) + top_reviews, reviews_count = _get_cached_reviews(entity_id, entity_type, review_type, 'popularity') + latest_reviews, _ = _get_cached_reviews(entity_id, entity_type, review_type, 'published_on') + + result = { + "entity": entity, + "average_rating": average_rating, + "ratings_stats": ratings_stats, + "reviews_count": reviews_count, + "top_reviews": top_reviews, + "latest_reviews": latest_reviews + } + + if username: + result['user_review'] = user_review + + return jsonify(result)