diff --git a/discovery-provider/alembic/versions/aab240348d73_add_premium_content_columns.py b/discovery-provider/alembic/versions/aab240348d73_add_premium_content_columns.py new file mode 100644 index 00000000000..e42559ca51c --- /dev/null +++ b/discovery-provider/alembic/versions/aab240348d73_add_premium_content_columns.py @@ -0,0 +1,50 @@ +"""add-premium-content-columns + +Revision ID: aab240348d73 +Revises: 5d3f95470222 +Create Date: 2022-08-22 20:22:22.439424 + +""" +import sqlalchemy as sa +from alembic import op +from sqlalchemy.dialects import postgresql + +# revision identifiers, used by Alembic. +revision = "aab240348d73" +down_revision = "5d3f95470222" +branch_labels = None +depends_on = None + + +def upgrade(): + op.add_column( + "tracks", + sa.Column("is_premium", sa.Boolean(), nullable=False, server_default="false"), + ) + op.add_column( + "tracks", + sa.Column("premium_conditions", postgresql.JSONB(), nullable=True), + ) + + connection = op.get_bind() + connection.execute( + """ + begin; + CREATE INDEX IF NOT EXISTS track_is_premium_idx ON tracks (is_premium); + commit; + """ + ) + + +def downgrade(): + connection = op.get_bind() + connection.execute( + """ + begin; + DROP INDEX IF EXISTS track_is_premium_idx; + commit; + """ + ) + + op.drop_column("tracks", "premium_conditions") + op.drop_column("tracks", "is_premium") diff --git a/discovery-provider/integration_tests/premium_content/test_access.py b/discovery-provider/integration_tests/premium_content/test_access.py new file mode 100644 index 00000000000..85b4f540d6d --- /dev/null +++ b/discovery-provider/integration_tests/premium_content/test_access.py @@ -0,0 +1,50 @@ +from integration_tests.utils import populate_mock_db +from src.premium_content.premium_content_access_checker import ( + PremiumContentAccessChecker, +) +from src.utils.db_session import get_db_read_replica + + +def test_track_access(app): + with app.app_context(): + db = get_db_read_replica() + + user_entities = [{"user_id": 1}, {"user_id": 2}] + non_premium_track_entity = { + "track_id": 1, + "is_premium": False, + "premium_conditions": None, + } + premium_track_entity = { + "track_id": 2, + "is_premium": True, + "premium_conditions": {"nft-collection": "some-nft-collection"}, + } + track_entities = [non_premium_track_entity, premium_track_entity] + entities = {"users": user_entities, "tracks": track_entities} + + populate_mock_db(db, entities) + + premium_content_access_checker = PremiumContentAccessChecker() + + # test non-existent track + non_exisent_track_id = 3 + is_premium, does_user_have_access = premium_content_access_checker.check_access( + user_entities[0]["user_id"], non_exisent_track_id, "track" + ) + assert not is_premium and does_user_have_access + + # test non-premium track + is_premium, does_user_have_access = premium_content_access_checker.check_access( + user_entities[1]["user_id"], non_premium_track_entity["track_id"], "track" + ) + assert not is_premium and does_user_have_access + + # test premium track with user who has access + is_premium, does_user_have_access = premium_content_access_checker.check_access( + user_entities[1]["user_id"], premium_track_entity["track_id"], "track" + ) + assert is_premium and does_user_have_access + + # todo: test premium track with user who has no access + # after we implement nft infexing diff --git a/discovery-provider/integration_tests/utils.py b/discovery-provider/integration_tests/utils.py index 4bfd53c8af5..b662fee6311 100644 --- a/discovery-provider/integration_tests/utils.py +++ b/discovery-provider/integration_tests/utils.py @@ -171,6 +171,8 @@ def populate_mock_db(db, entities, block_offset=None): created_at=track_meta.get("created_at", datetime.now()), release_date=track_meta.get("release_date", None), is_unlisted=track_meta.get("is_unlisted", False), + is_premium=track_meta.get("is_premium", False), + premium_conditions=track_meta.get("premium_conditions", None), ) session.add(track) for i, playlist_meta in enumerate(playlists): diff --git a/discovery-provider/src/models/tracks/track.py b/discovery-provider/src/models/tracks/track.py index 4db0cb2c0d8..c66cfdddc44 100644 --- a/discovery-provider/src/models/tracks/track.py +++ b/discovery-provider/src/models/tracks/track.py @@ -64,6 +64,8 @@ class Track(Base, RepresentableMixin): ) slot = Column(Integer) is_available = Column(Boolean, nullable=False, server_default=text("true")) + is_premium = Column(Boolean, nullable=False, server_default=text("false")) + premium_conditions = Column(JSONB()) block = relationship( # type: ignore "Block", primaryjoin="Track.blockhash == Block.blockhash" diff --git a/discovery-provider/src/premium_content/helpers.py b/discovery-provider/src/premium_content/helpers.py new file mode 100644 index 00000000000..118561d2b74 --- /dev/null +++ b/discovery-provider/src/premium_content/helpers.py @@ -0,0 +1,13 @@ +import logging + +# from src.utils import db_session + +logger = logging.getLogger(__name__) + + +def does_user_have_nft_collection(user_id: int, nft_collection: str): + # todo: check whether user has the nft from some user_wallet_nfts table + # which is populated during nft indexing + # db = db_session.get_db_read_replica() + # with db.scoped_session() as session: + return True diff --git a/discovery-provider/src/premium_content/premium_content_access_checker.py b/discovery-provider/src/premium_content/premium_content_access_checker.py new file mode 100644 index 00000000000..852bc28f5b1 --- /dev/null +++ b/discovery-provider/src/premium_content/premium_content_access_checker.py @@ -0,0 +1,104 @@ +import json +import logging +from typing import Dict, Optional, Tuple + +from src.models.tracks.track import Track +from src.premium_content.helpers import does_user_have_nft_collection +from src.premium_content.types import PremiumContentType +from src.utils import db_session + +logger = logging.getLogger(__name__) + + +class PremiumContentAccessChecker: + # check if content is premium + # - if not, then user has access + # - if so, check whether user fulfills the conditions + # + # Returns a tuple of (bool, bool) -> (track is premium, user has access) + # + # Note: premium content id for type should exist, but just in case it does not, + # we return true for user access so that (non-existent) track does not change + # existing flow of the function caller. + def check_access( + self, + user_id: int, + premium_content_id: int, + premium_content_type: PremiumContentType, + ) -> Tuple[bool, bool]: + # for now, we only allow tracks to be premium + # premium playlists will come later + if premium_content_type != "track": + return False, True + + ( + is_premium, + premium_conditions, + content_owner_id, + ) = self._is_content_premium(premium_content_id) + + if not is_premium: + # premium_conditions should always be null here as it makes + # no sense to have a non-premium track with conditions + if premium_conditions: + logger.warn( + f"premium_content_access_checker.py | _aggregate_conditions | non-premium content with id {premium_content_id} and type {premium_content_type} has premium conditions." + ) + return False, True + + if not premium_conditions: + # is_premium should always be false here as it makes + # no sense to have a premium track with no conditions + if is_premium: + logger.warn( + f"premium_content_access_checker.py | _aggregate_conditions | premium content with id {premium_content_id} and type {premium_content_type} has no premium conditions." + ) + return is_premium, True + + does_user_have_access = self._evaluate_conditions( + user_id, content_owner_id, premium_conditions + ) + return True, does_user_have_access + + # Returns a tuple of (bool, Dict | None, int | None) -> (track is premium, track premium conditions, track owner id) + def _is_content_premium( + self, premium_content_id: int + ) -> Tuple[bool, Optional[Dict], Optional[int]]: + db = db_session.get_db_read_replica() + with db.scoped_session() as session: + track = ( + session.query(Track) + .filter( + Track.track_id == premium_content_id, + Track.is_current == True, + Track.is_delete == False, + ) + .first() + ) + + if not track: + return False, None, None + + return track.is_premium, track.premium_conditions, track.owner_id + + # There will eventually be another step prior to this one where + # we aggregate multiple conditions and evaluate them altogether. + # For now, we only support one condition, which is the ownership + # of an NFT from a given collection. + def _evaluate_conditions( + self, user_id: int, track_owner_id: int, premium_conditions: Dict + ): + if len(premium_conditions) != 1: + logging.info( + f"premium_content_access_checker.py | _aggregate_conditions | invalid conditions: {json.dumps(premium_conditions)}" + ) + return False + + condition, value = list(premium_conditions.items())[0] + if condition != "nft-collection": + return False + + return does_user_have_nft_collection(user_id, value) + + +premium_content_access_checker = PremiumContentAccessChecker() diff --git a/discovery-provider/src/premium_content/signature.py b/discovery-provider/src/premium_content/signature.py index fb37fd4d634..b7a60b49fc4 100644 --- a/discovery-provider/src/premium_content/signature.py +++ b/discovery-provider/src/premium_content/signature.py @@ -1,9 +1,8 @@ from datetime import datetime -from typing import Literal, TypedDict, cast +from typing import TypedDict, cast from src.api_helpers import generate_signature - -PremiumContentType = Literal["track"] +from src.premium_content.types import PremiumContentType class PremiumContentSignatureData(TypedDict): diff --git a/discovery-provider/src/premium_content/types.py b/discovery-provider/src/premium_content/types.py new file mode 100644 index 00000000000..10b4b8aa6f1 --- /dev/null +++ b/discovery-provider/src/premium_content/types.py @@ -0,0 +1,16 @@ +from enum import Enum +from typing import Literal + +# These are the different types of content that can be made premium. +# For now, we only support tracks. +PremiumContentType = Literal["track"] + +# These are the different conditions on which premium content access +# will be gated. +# They should match the PremiumConditions property in the track schema +# in src/schemas/track_schema.json +PremiumContentConditions = Literal["nft-collection"] + +# This is for when we support the combination of different conditions +# for premium content access based on AND'ing / OR'ing them together. +PremiumContentConditionGates = Literal["AND", "OR"] diff --git a/discovery-provider/src/schemas/track_schema.json b/discovery-provider/src/schemas/track_schema.json index c4795f09a56..ccd4e7a6bde 100644 --- a/discovery-provider/src/schemas/track_schema.json +++ b/discovery-provider/src/schemas/track_schema.json @@ -12,63 +12,105 @@ "default": null }, "length": { - "type": ["integer", "null"], + "type": [ + "integer", + "null" + ], "default": null }, "cover_art": { - "type": ["string", "null"], + "type": [ + "string", + "null" + ], "default": null, "$ref": "#/definitions/CID" }, "cover_art_sizes": { - "$comment": "type can be null if attached as a stem", - "type": ["string", "null"], - "default": null, - "$ref": "#/definitions/CID" - }, + "$comment": "type can be null if attached as a stem", + "type": [ + "string", + "null" + ], + "default": null, + "$ref": "#/definitions/CID" + }, "tags": { - "type": ["string", "null"], + "type": [ + "string", + "null" + ], "default": null }, "genre": { "$comment": "genre can be null if attached as a stem", - "type": ["string", "null"], + "type": [ + "string", + "null" + ], "default": null }, "mood": { - "type": ["string", "null"], + "type": [ + "string", + "null" + ], "default": null }, "credits_splits": { - "type": ["string", "null"], + "type": [ + "string", + "null" + ], "default": null }, "create_date": { - "type": ["string", "null"], + "type": [ + "string", + "null" + ], "default": null }, "release_date": { - "type": ["string", "null"], + "type": [ + "string", + "null" + ], "default": null }, "file_type": { - "type": ["string", "null"], + "type": [ + "string", + "null" + ], "default": null }, "description": { - "type": ["string", "null"], + "type": [ + "string", + "null" + ], "default": null }, "license": { - "type": ["string", "null"], + "type": [ + "string", + "null" + ], "default": null }, "isrc": { - "type": ["string", "null"], + "type": [ + "string", + "null" + ], "default": null }, "iswc": { - "type": ["string", "null"], + "type": [ + "string", + "null" + ], "default": null }, "track_segments": { @@ -97,7 +139,10 @@ } }, "download": { - "type": ["object", "null"], + "type": [ + "object", + "null" + ], "$ref": "#/definitions/Download", "default": { "cid": null, @@ -106,14 +151,32 @@ } }, "stem_of": { - "type": ["object", "null"], + "type": [ + "object", + "null" + ], "$ref": "#/definitions/StemOf", "default": null }, "remix_of": { - "type": ["object", "null"], + "type": [ + "object", + "null" + ], "$ref": "#/definitions/RemixOf", "default": null + }, + "is_premium": { + "type": "boolean", + "default": false + }, + "premium_conditions": { + "type": [ + "object", + "null" + ], + "$ref": "#/definitions/PremiumConditions", + "default": null } }, "required": [ @@ -142,7 +205,10 @@ "title": "Track" }, "RemixOf": { - "type": ["object", "null"], + "type": [ + "object", + "null" + ], "additionalProperties": false, "properties": { "tracks": { @@ -172,11 +238,17 @@ "title": "TrackElement" }, "Download": { - "type": ["object", "null"], + "type": [ + "object", + "null" + ], "additionalProperties": false, "properties": { "cid": { - "type": ["string", "null"], + "type": [ + "string", + "null" + ], "$ref": "#/definitions/CID" }, "is_downloadable": { @@ -216,13 +288,15 @@ "type": "boolean" } }, - "required": [ - ], + "required": [], "$comment": "No required fields for FieldVisibility because it causes backwards compatibility issues. If we added a new property, we don't want old records to fail if all properties are not specified(and overwrite the values with the defaults), but we also don't want to set new properties especially on the discovery provider because then we'd be writing properties not present in the metadata written on chain.", "title": "FieldVisibility" }, "StemOf": { - "type": ["object", "null"], + "type": [ + "object", + "null" + ], "additionalProperties": false, "properties": { "category": { @@ -257,11 +331,28 @@ "title": "TrackSegment" }, "CID": { - "type": ["string", "null"], + "type": [ + "string", + "null" + ], "minLength": 46, "maxLength": 46, "pattern": "^Qm[a-zA-Z0-9]{44}$", "title": "CID" + }, + "PremiumConditions": { + "type": "object", + "additionalProperties": false, + "properties": { + "nft-collection": { + "type": "string" + } + }, + "required": [ + "nft-collection" + ], + "$comment": "Once we allow other conditions, we need to update the required property to [] as users will determine what conditions they're gating their content on; the conditions won't necessarily include 'nft-collection'.", + "title": "PremiumConditions" } } } \ No newline at end of file diff --git a/discovery-provider/src/tasks/social_features.py b/discovery-provider/src/tasks/social_features.py index e4bf11270d7..9c6b18850b7 100644 --- a/discovery-provider/src/tasks/social_features.py +++ b/discovery-provider/src/tasks/social_features.py @@ -9,6 +9,9 @@ from src.models.playlists.playlist import Playlist from src.models.social.follow import Follow from src.models.social.repost import Repost, RepostType +from src.premium_content.premium_content_access_checker import ( + premium_content_access_checker, +) from src.utils.indexing_errors import IndexingError logger = logging.getLogger(__name__) @@ -209,6 +212,12 @@ def add_track_repost( repost_user_id = event_args._userId repost_track_id = event_args._trackId + _, can_user_repost_track = premium_content_access_checker.check_access( + repost_user_id, repost_track_id, "track" + ) + if not can_user_repost_track: + continue + if (repost_user_id in track_repost_state_changes) and ( repost_track_id in track_repost_state_changes[repost_user_id] ): @@ -254,6 +263,12 @@ def delete_track_repost( repost_user_id = event_args._userId repost_track_id = event_args._trackId + _, can_user_unrepost_track = premium_content_access_checker.check_access( + repost_user_id, repost_track_id, "track" + ) + if not can_user_unrepost_track: + continue + if (repost_user_id in track_repost_state_changes) and ( repost_track_id in track_repost_state_changes[repost_user_id] ): diff --git a/discovery-provider/src/tasks/tracks.py b/discovery-provider/src/tasks/tracks.py index a6a93b70201..82ec4880e43 100644 --- a/discovery-provider/src/tasks/tracks.py +++ b/discovery-provider/src/tasks/tracks.py @@ -530,6 +530,7 @@ def parse_track_event( track_record.is_delete = True track_record.stem_of = null() track_record.remix_of = null() + track_record.premium_conditions = null() logger.info(f"index.py | tracks.py | Removing track : {track_record.track_id}") track_record.updated_at = block_datetime @@ -576,6 +577,13 @@ def populate_track_record_metadata(track_record, track_metadata, handle): track_record.track_segments = track_metadata["track_segments"] track_record.is_unlisted = track_metadata["is_unlisted"] track_record.field_visibility = track_metadata["field_visibility"] + + track_record.is_premium = track_metadata["is_premium"] + if is_valid_json_field(track_metadata, "premium_conditions"): + track_record.premium_conditions = track_metadata["premium_conditions"] + else: + track_record.premium_conditions = null() + if is_valid_json_field(track_metadata, "stem_of"): track_record.stem_of = track_metadata["stem_of"] else: diff --git a/discovery-provider/src/tasks/user_library.py b/discovery-provider/src/tasks/user_library.py index 3d58b3579a8..24e61424973 100644 --- a/discovery-provider/src/tasks/user_library.py +++ b/discovery-provider/src/tasks/user_library.py @@ -8,6 +8,9 @@ from src.database_task import DatabaseTask from src.models.playlists.playlist import Playlist from src.models.social.save import Save, SaveType +from src.premium_content.premium_content_access_checker import ( + premium_content_access_checker, +) from src.utils.indexing_errors import IndexingError logger = logging.getLogger(__name__) @@ -155,6 +158,12 @@ def add_track_save( save_user_id = event_args._userId save_track_id = event_args._trackId + _, can_user_save_track = premium_content_access_checker.check_access( + save_user_id, save_track_id, "track" + ) + if not can_user_save_track: + continue + if (save_user_id in track_state_changes) and ( save_track_id in track_state_changes[save_user_id] ): @@ -255,6 +264,12 @@ def delete_track_save( save_user_id = event_args._userId save_track_id = event_args._trackId + _, can_user_unsave_track = premium_content_access_checker.check_access( + save_user_id, save_track_id, "track" + ) + if not can_user_unsave_track: + continue + if (save_user_id in track_state_changes) and ( save_track_id in track_state_changes[save_user_id] ):