"""Model layer that communicates with the Stream (getstream.io) API.""" from typing import Any from owsresponse import response, status as ows_status from notifications.config import FANOUT_QUEUE_URL from notifications.connectors.sqs import send_messages as sqs_send_messages from notifications.connectors.stream import capture_exceptions, stream_client from notifications.constants.stream import PROFILE_FEED_GROUP from notifications.utils.stream import get_profile_feed_id, get_user_feed_id JOINER = ':' def _add_activities(activities: list[dict[str, Any]]) -> response.Response: """Add activities to feeds in GetStream if not sent already. Args: activities (list): dict with following keys feed_group (str): GetStream feed group to send activity to feed_id (str): GetStream feed id to send activity to event (dict): metadata about event, requires foreign_id and time Returns: response.Response: 200 if no activities to add 201 if some added """ submitted = len(activities) added = 0 if not activities: status = ows_status.OK else: # for json encoding, datetime to string for event in activities: event['event']['time'] = event['event']['time'].isoformat() (added, failed) = sqs_send_messages(FANOUT_QUEUE_URL, activities) if failed: raise Exception('failures when adding activities to sqs fanout queue') status = ows_status.CREATED return response.Response({'submitted': submitted, 'added': added}, status=status) def _primary_activity_key(activity_event: dict[str, Any]) -> str: """Identity activity dict in a globally unique way.""" return f'{activity_event["foreign_id"]}:{activity_event["time"]}' def subscribe_entity( profile_type: str, profile_id: str, entity_type: str, entity_id: str, feed_group: str ) -> None: """Create profile -> entity connection to get notifications. In GetStream, add connection where a profile follows a stream of activity, specific to a profile type, for an entity. Args: profile_type (str): type of profile profile_id (str): identifier of profile entity_type (str): entity type being followed entity_id (str): entity id being followed feed_group (str): feed group name for given subscription Returns: None Raises: https://github.com/GetStream/stream-python/blob/v2.8.1/stream/exceptions.py """ profile_feed = stream_client.feed( PROFILE_FEED_GROUP, get_profile_feed_id(profile_type, profile_id) ) profile_feed.follow(feed_group, get_profile_feed_id(entity_type, entity_id)) def unsubscribe_entity( profile_type: str, profile_id: str, entity_type: str, entity_id: str, feed_group: str ) -> None: """Remove profile -> entity connection to stop notifications. In GetStream, remove the connection where a profile follows a stream of activity, specific to profile type, for an entity. Args: profile_type (str): type of profile profile_id (str): identifier of profile entity_type (str): entity type being followed entity_id (str): entity id being followed feed_group (str): feed group name for given subscription Returns: None Raises: https://github.com/GetStream/stream-python/blob/v2.8.1/stream/exceptions.py """ profile_feed = stream_client.feed( PROFILE_FEED_GROUP, get_profile_feed_id(profile_type, profile_id) ) profile_feed.unfollow(feed_group, get_profile_feed_id(entity_type, entity_id)) def get_subscribed_entities(profile_type: str, profile_id: int) -> dict[str, Any]: """Get subscriptions by combination of parameters. Args: profile_type (str): type of profile profile_id (int): identifier of profile Returns: list of subscriptions """ profile_feed = stream_client.feed( PROFILE_FEED_GROUP, get_profile_feed_id(profile_type, profile_id) ) return profile_feed.following(limit=100) def add_social_spike_activity(events: list[dict[str, Any]]) -> response.Response: """Translate fan out events to GetStream format and send to GetStream. Args: events (list): see notifications.logic.stream._get_fanout_events return Response: response: result sending to fanout-event-queue, see _add_activities """ return _add_activities( [ _getstream_activity( x, f'social_spike_{x["metadata"]["network"]}', x['metadata']['id'], [x['metadata']['id']], ) for x in events ] ) def add_playlist_placement_activity(events: list[dict[str, Any]]) -> response.Response: """Translate fan out events to GetStream format and send to GetStream. Args: events (list): see notifications.logic.stream._get_fanout_events return Response: response: result sending to fanout-event-queue, see _add_activities """ return _add_activities( [ _getstream_activity( x, 'playlist_placements', x['metadata']['sound_recording']['id'], [ x['metadata']['playlist']['id'], x['metadata']['playlist']['dsp'], x['metadata']['sound_recording']['id'], ], ) for x in events ] ) def _getstream_activity( event: dict[str, Any], event_type: str, event_actor: str | int, event_ids: list[str], feed_group: str = PROFILE_FEED_GROUP, ) -> dict[str, Any]: """Format event for inseration into GetStream profile feed. Args: event (dict): singular item from logic.stream._get_fanout_events event_type (str): verb identifier event_actor (str|int): identifier of event source event_ids (list): items used to uniquely, system-wide identify event feed_group (str): GetStream feed group to send activity to Returns: dict: formatted object """ # format foreign_id as event type, attributes, and profile identifiers event_ids.insert(0, event_type) event_ids.append(get_profile_feed_id(event['profile_type'], event['profile_id'])) # add activity sources to object event_obj = event['metadata'].copy() event_obj['activity_sources'] = event['sources'] # join all data for foreign_id and remove commas that break GetStream API foreign_id = JOINER.join([str(x) for x in event_ids]).replace(',', '') return { 'event': { 'actor': event_actor, 'verb': event_type, 'foreign_id': foreign_id, 'object': event_obj, 'time': event['time'], }, 'feed_group': feed_group, 'feed_id': get_profile_feed_id(event['profile_type'], event['profile_id']), } def add_trending_track_activity(events: list[dict[str, Any]]) -> response.Response: """Translate fan out events to GetStream format and send to GetStream. Args: events (list): see notifications.logic.stream._get_fanout_events return Response: response: result sending to fanout-event-queue, see _add_activities """ return _add_activities( [ _getstream_activity( x, 'trending_tracks', x['metadata']['track']['id'], [ x['metadata']['track']['id'], x['metadata']['dsp'], x['metadata']['region'], ], ) for x in events ] ) @capture_exceptions def add_activity(feed_name: str, feed_id: str, payload: dict[str, Any]) -> response.Response: """Add an activity to a feed. Args: feed_name (str): the feed name to get the feed feed_id (str): the feed id to get the feed payload (dict): the data from which to create the activity Returns: Response: A 201 response if the activity has been added. """ feed = stream_client.feed(feed_name, feed_id) feed.add_activity(payload) return response.Response(status=ows_status.CREATED, message=payload) @capture_exceptions def subscribe( user_feed_name: str, user_id: str, feed_name: str, feed_id: str, user_feed_id: str | None = None ) -> response.Response: """Subscribe a user to a feed. Args: user_feed_name (str): the user feed name user_id (str): the orchard user id feed_name (str): the feed name to get the feed feed_id (str): the feed id to get the feed user_feed_id (str): the user feed id to use instead of the user id Returns: Response: A 200 response if the user has been subscribed. """ user_feed = stream_client.feed(user_feed_name, get_user_feed_id(user_id, user_feed_id)) user_feed.follow(feed_name, feed_id) return response.Response({'feed_name': feed_name, 'feed_id': feed_id}) @capture_exceptions def unsubscribe( user_feed_name: str, user_id: str, feed_name: str, feed_id: str, user_feed_id: str | None = None ) -> response.Response: """Unsubscribe a user from a feed. Args: user_feed_name (str): the user feed name user_id (str): the orchard user id feed_name (str): the feed name to get the feed feed_id (str): the feed id to get the feed user_feed_id (str): the user feed id to use instead of the user id Returns: Response: A 200 response if the user has been unsubscribed. """ user_feed = stream_client.feed(user_feed_name, get_user_feed_id(user_id, user_feed_id)) user_feed.unfollow(feed_name, feed_id) return response.Response({'feed_name': feed_name, 'feed_id': feed_id}) @capture_exceptions def get_user_notifications( user_id: str, user_feed_name: str, user_feed_id: str | None = None ) -> response.Response: """Get a user's notifications. Args: user_id (str): the orchard user id user_feed_name (str): the user feed name user_feed_id (str): the user feed id to use instead of the user id Returns: Response: containing the user's notifications """ user_feed = stream_client.feed(user_feed_name, get_user_feed_id(user_id, user_feed_id)) stream_response = user_feed.get(limit=10) notifications = [] for item in stream_response['results']: # "notifications" feed results are structured differently # than "flat" feed results if 'activities' in item: for activity in item['activities']: notifications.append(activity) else: notifications.append(item) return response.Response({'items': notifications}) @capture_exceptions def get_user_subscriptions( user_id: str, user_feed_name: str, user_feed_id: str | None = None ) -> response.Response: """Get a user's subscriptions. Args: user_id (str): the orchard user id user_feed_name (str): the user feed name user_feed_id (str): the user feed id to use instead of the user id Returns: Response: containing the user's subscriptions """ user_feed = stream_client.feed(user_feed_name, get_user_feed_id(user_id, user_feed_id)) stream_response = user_feed.following(limit=100) subscriptions = [item['target_id'] for item in stream_response['results']] return response.Response({'items': subscriptions}) @capture_exceptions def get_feed_subscribers(feed_name: str, feed_id: str) -> response.Response: """Get a feed's subscribers. Args: feed_name (str): the feed name feed_id (str): the feed id Returns: Response: containing the feed's subscribers """ feed = stream_client.feed(feed_name, feed_id) stream_response = feed.followers(limit=100) subscribers = [item['feed_id'] for item in stream_response['results']] return response.Response({'items': subscribers})