From 8827b7ec1bc7e073eb2e9f124d75b05ed841b8f6 Mon Sep 17 00:00:00 2001 From: Carina Antunes <carina.oliveira.antunes@cern.ch> Date: Fri, 11 Mar 2022 12:53:30 +0100 Subject: [PATCH 1/4] fix targets intersection --- .../data_source/data_source.py | 10 +-- .../postgres/postgres_data_source.py | 59 +++++++------- notifications_routing/router.py | 77 +++++++++++-------- notifications_routing/utils.py | 4 - .../integration/test_postgres_data_source.py | 2 +- tests/unit/test_postgres_data_source.py | 4 +- tests/unit/test_router.py | 14 ++-- 7 files changed, 89 insertions(+), 81 deletions(-) diff --git a/notifications_routing/data_source/data_source.py b/notifications_routing/data_source/data_source.py index b061775..854d455 100644 --- a/notifications_routing/data_source/data_source.py +++ b/notifications_routing/data_source/data_source.py @@ -17,7 +17,7 @@ class DataSource(ABC): LAST_LOGIN = "last_login" @abstractmethod - def get_channel_users(self, channel_id: str, **kwargs) -> List[Dict[str, str]]: + def get_channel_subscribed_users(self, channel_id: str, **kwargs) -> List[Dict[str, str]]: """Specific implementation of get channel's users. :param channel_id: Channel ID @@ -136,13 +136,13 @@ class DataSource(ABC): pass @abstractmethod - def get_target_users(self, channel_id: str, specified_target_users: List[str], **kwargs) -> List[Dict[str, str]]: - """Return a list of dictionaries with user_ids and emails of channel_users.""" + def get_target_users(self, notification_id: str, **kwargs) -> List[Dict[str, str]]: + """Return a list of dictionaries with target user id and emails of a notification.""" pass @abstractmethod - def get_target_groups(self, channel_id: str, specified_target_groups: List[str], **kwargs) -> List[Dict[str, str]]: - """Return a list of dictionaries with group_ids of a channel.""" + def get_target_groups(self, notification_id: str, **kwargs) -> List[Dict[str, str]]: + """Return a list of dictionaries with target group id of a notification.""" pass diff --git a/notifications_routing/data_source/postgres/postgres_data_source.py b/notifications_routing/data_source/postgres/postgres_data_source.py index 8b840ba..42f4ba9 100644 --- a/notifications_routing/data_source/postgres/postgres_data_source.py +++ b/notifications_routing/data_source/postgres/postgres_data_source.py @@ -74,7 +74,7 @@ class PostgresDataSource(DataSource): except MultipleResultsFound: raise MultipleResultsFoundError(model.__tablename__, **kwargs) - def get_channel_users(self, channel_id: str, **kwargs) -> List[Dict[str, str]]: + def get_channel_subscribed_users(self, channel_id: str, **kwargs) -> List[Dict[str, str]]: """Return a list of dictionaries with user_ids and emails of channel_users.""" def build_user(user): @@ -93,7 +93,7 @@ class PostgresDataSource(DataSource): unsubscribed_ids = [user.id for user in channel.unsubscribed] return [build_user(member) for member in channel.members if member.id not in unsubscribed_ids] - def get_target_users(self, channel_id: str, specified_target_users: List[str], **kwargs) -> List[Dict[str, str]]: + def get_target_users(self, notification_id: str, **kwargs) -> List[Dict[str, str]]: """Return a list of dictionaries with user_ids and emails of channel_users.""" def build_user(user): @@ -104,22 +104,21 @@ class PostgresDataSource(DataSource): DataSource.LAST_LOGIN: user.lastLogin, } - def is_target(member): - return str(member.id) in specified_target_users + with self.session() as session: + notification = self.__get_scalar(session, Notification, id=notification_id, deleteDate=None) + if not notification: + raise NotFoundDataSourceError(Notification, id=notification_id) - def is_not_unsubscribed(member): - return str(member.id) not in unsubscribed_ids + return [build_user(member) for member in notification.target_users] + def get_target_groups(self, notification_id: str, **kwargs) -> List[Dict[str, str]]: + """Return a list of dictionaries with user_ids and emails of channel_users.""" with self.session() as session: - channel = self.__get_scalar(session, Channel, id=channel_id, deleteDate=None) - if not channel: - raise NotFoundDataSourceError(Channel, id=channel_id) - - unsubscribed_ids = [user.id for user in channel.unsubscribed] + notification = self.__get_scalar(session, Notification, id=notification_id, deleteDate=None) + if not notification: + raise NotFoundDataSourceError(Notification, id=notification_id) - return [ - build_user(member) for member in channel.members if is_target(member) and is_not_unsubscribed(member) - ] + return [{DataSource.GROUP_ID: group.id} for group in notification.target_groups] def get_channel_unsubscribed_users(self, channel_id: str, **kwargs) -> List[str]: """Return a list of string with usernames of channel unsubscribed users.""" @@ -149,21 +148,6 @@ class PostgresDataSource(DataSource): return groups - def get_target_groups(self, channel_id: str, specified_target_groups: List[str], **kwargs) -> List[Dict[str, str]]: - """Return a list of dictionaries with group_ids of a channel.""" - groups = [] - - with self.session() as session: - channel = self.__get_scalar(session, Channel, id=channel_id, deleteDate=None) - if not channel: - raise NotFoundDataSourceError(Channel, id=channel_id) - - for group in channel.groups: - if str(group.id) in specified_target_groups: - groups.append({DataSource.GROUP_ID: group.id}) - - return groups - def get_group_users(self, group_id: str, **kwargs) -> List[Dict[str, str]]: """Return a list of dictionaries with user_ids and emails of group_users.""" group_users = [] @@ -364,6 +348,20 @@ channel_unsubscribed = Table( Column("usersId", UUID(as_uuid=True), ForeignKey("Users.id")), ) +notification_target_users = Table( + "notifications_target_users__users", + PostgresDataSource.Base.metadata, + Column("notificationsId", UUID(as_uuid=True), ForeignKey("Notifications.id")), + Column("usersId", UUID(as_uuid=True), ForeignKey("Users.id")), +) + +notification_target_groups = Table( + "notifications_target_groups__groups", + PostgresDataSource.Base.metadata, + Column("notificationsId", UUID(as_uuid=True), ForeignKey("Notifications.id")), + Column("groupsId", UUID(as_uuid=True), ForeignKey("Groups.id")), +) + preferences_devices = Table( "preferences_devices__devices", PostgresDataSource.Base.metadata, @@ -454,6 +452,9 @@ class Notification(PostgresDataSource.Base): __tablename__ = "Notifications" id = Column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4) + target_users = relationship("User", secondary=notification_target_users) + target_groups = relationship("Group", secondary=notification_target_groups) + class UserFeedNotification(PostgresDataSource.Base): """UserDailyNotification Model.""" diff --git a/notifications_routing/router.py b/notifications_routing/router.py index ebc2cb8..7d34fd1 100644 --- a/notifications_routing/router.py +++ b/notifications_routing/router.py @@ -71,24 +71,23 @@ class Router(megabus.Listener): str(OutputMessageKeys.CHANNEL_CATEGORY): category_name, str(OutputMessageKeys.PRIVATE): message_json[str(InputMessageKeys.PRIVATE)], str(OutputMessageKeys.INTERSECTION): message_json[str(InputMessageKeys.INTERSECTION)], - str(OutputMessageKeys.TARGETUSERS): message_json[str(InputMessageKeys.TARGETUSERS)], - str(OutputMessageKeys.TARGETGROUPS): message_json[str(InputMessageKeys.TARGETGROUPS)], } return notification def add_users_from_groups( - self, notification_id: str, channel_id: str, users: List[Dict[str, str]], groups: List[Dict[str, str]] + self, + notification_id: str, + channel_id: str, + users: List[Dict[str, str]], + groups: List[Dict[str, str]], + unsubscribed_users: List[str], ): """Add users from groups to users list.""" unique_usernames = [channel_user[self.data_source.USERNAME] for channel_user in users] logging.debug("channel %s usernames: %s", channel_id, unique_usernames) if groups: - channel_unsubscribed_users = self.data_source.get_channel_unsubscribed_users(channel_id) - logging.debug("channel %s unsubscribed users %s", channel_id, channel_unsubscribed_users) - audit_notification(notification_id, {"event": "Unsubscribed users", "targets": channel_unsubscribed_users}) - for group in groups: for group_id in group.values(): group_users = self.data_source.get_group_users(group_id) @@ -98,7 +97,7 @@ class Router(megabus.Listener): if user[self.data_source.USERNAME] in unique_usernames: continue # Skip unsubscribed users - if user[self.data_source.USERNAME] in channel_unsubscribed_users: + if user[self.data_source.USERNAME] in unsubscribed_users: continue try: @@ -113,31 +112,47 @@ class Router(megabus.Listener): def get_channel_users(self, notification_id, channel_id): """Join users from our data source and the grappa system to return a unique users list.""" - channel_users = self.data_source.get_channel_users(channel_id) - channel_groups = self.data_source.get_channel_groups(channel_id) - logging.debug("channel %s groups %s", channel_id, channel_groups) + users = self.data_source.get_channel_subscribed_users(channel_id) + groups = self.data_source.get_channel_groups(channel_id) + logging.debug("channel %s groups %s", users, groups) - self.add_users_from_groups(notification_id, channel_id, channel_users, channel_groups) + if groups: + unsubscribed_users = self.data_source.get_channel_unsubscribed_users(channel_id) + logging.debug("channel %s unsubscribed users %s", channel_id, unsubscribed_users) + audit_notification(notification_id, {"event": "Unsubscribed users", "targets": unsubscribed_users}) - logging.debug("channel %s final users %s", channel_id, channel_users) + self.add_users_from_groups(notification_id, channel_id, users, groups, unsubscribed_users) - return channel_users + logging.debug("channel %s final users %s", channel_id, users) - def get_target_users(self, notification_id, channel_id, specified_target_users, specified_target_groups): + return users + + def get_target_users(self, notification_id, channel_id): """Join users from our data source and the grappa system to return a unique users list.""" - target_users = [] - if specified_target_users: - target_users = self.data_source.get_target_users(channel_id, specified_target_users) + target_users = self.data_source.get_target_users(channel_id) + logging.debug("channel %s targeted users %s", channel_id, target_users) + + target_groups = self.data_source.get_target_groups(channel_id) + logging.debug("channel %s targeted groups %s", channel_id, target_groups) - if specified_target_groups: - target_groups = self.data_source.get_target_groups(channel_id, specified_target_groups) - logging.debug("channel %s targeted groups %s", channel_id, target_groups) + if not (target_groups or target_users): + return [] - self.add_users_from_groups(notification_id, channel_id, target_users, target_groups) + unsubscribed_users = self.data_source.get_channel_unsubscribed_users(channel_id) + logging.debug("channel %s unsubscribed users %s", channel_id, unsubscribed_users) + audit_notification(notification_id, {"event": "Unsubscribed users", "targets": unsubscribed_users}) - return target_users + subscribed_target_users = [user for user in target_users if user["id"] not in unsubscribed_users] + logging.debug("channel %s subscribed targeted users %s", channel_id, target_users) - def process_target_users(self, message): + if target_groups: + self.add_users_from_groups( + notification_id, channel_id, subscribed_target_users, target_groups, unsubscribed_users + ) + + return subscribed_target_users + + def process_users(self, message): """Process unique users list, if targeted, intersection or standard post.""" if OutputMessageKeys.PRIVATE in message and message[OutputMessageKeys.PRIVATE]: audit_notification(message[OutputMessageKeys.ID], {"event": "Start processing targeted notification"}) @@ -145,23 +160,19 @@ class Router(megabus.Listener): target_users = self.get_target_users( message[OutputMessageKeys.ID], message[OutputMessageKeys.CHANNEL_ID], - message[OutputMessageKeys.TARGETUSERS], - message[OutputMessageKeys.TARGETGROUPS], ) if not target_users: logging.debug("no target_users found for notification %s", message[OutputMessageKeys.ID]) return if OutputMessageKeys.INTERSECTION in message and message[OutputMessageKeys.INTERSECTION]: logging.debug("Processing intersection target users for %s", message[OutputMessageKeys.ID]) - tmp_channel_users = self.get_channel_users( + channel_users = self.get_channel_users( message[OutputMessageKeys.ID], message[OutputMessageKeys.CHANNEL_ID] ) - if not tmp_channel_users: - logging.debug( - "no tmp_channel_users to intersect for channel %s", message[OutputMessageKeys.CHANNEL_ID] - ) + if not channel_users: + logging.debug("no channel_users to intersect for channel %s", message[OutputMessageKeys.CHANNEL_ID]) return - target_users = set(tmp_channel_users).intersection(target_users) + target_users = set(channel_users).intersection(target_users) return target_users else: @@ -176,7 +187,7 @@ class Router(megabus.Listener): def process_message(self, message): """Process a message according to user and default preferences and sends to available delivery channels.""" - target_users = self.process_target_users(message) + target_users = self.process_users(message) if not target_users: return diff --git a/notifications_routing/utils.py b/notifications_routing/utils.py index 100bb94..0bf9cf8 100644 --- a/notifications_routing/utils.py +++ b/notifications_routing/utils.py @@ -30,8 +30,6 @@ class InputMessageKeys(StrEnum): IMGURL = "imgUrl" PRIVATE = "private" INTERSECTION = "intersection" - TARGETUSERS = "targetUsers" - TARGETGROUPS = "targetGroups" class OutputMessageKeys(StrEnum): @@ -50,8 +48,6 @@ class OutputMessageKeys(StrEnum): ID = "id" PRIVATE = "private" INTERSECTION = "intersection" - TARGETUSERS = "target_users" - TARGETGROUPS = "target_groups" class FeedFrequency(StrEnum): diff --git a/tests/integration/test_postgres_data_source.py b/tests/integration/test_postgres_data_source.py index 640f97e..be74659 100644 --- a/tests/integration/test_postgres_data_source.py +++ b/tests/integration/test_postgres_data_source.py @@ -5,7 +5,7 @@ import datetime def test_get_channel_users(appctx, data_source, channel): """Test get channel users.""" - channel_users = data_source.get_channel_users(channel.id) + channel_users = data_source.get_channel_subscribed_users(channel.id) assert channel_users assert len(channel_users) == 1 diff --git a/tests/unit/test_postgres_data_source.py b/tests/unit/test_postgres_data_source.py index 3bbb955..3fc3d2c 100644 --- a/tests/unit/test_postgres_data_source.py +++ b/tests/unit/test_postgres_data_source.py @@ -85,7 +85,7 @@ def test_get_channel_users_not_found_datasource_error(mock_get_scalar, db_mock): """Test that NotFoundDataSourceError is raised when get_scalar returns None.""" with raises(NotFoundDataSourceError): mock_get_scalar.return_value = None - db_mock.get_channel_users("c3ccc15b-298f-4dc7-877f-2c8970331caf") + db_mock.get_channel_subscribed_users("c3ccc15b-298f-4dc7-877f-2c8970331caf") mock_get_scalar.assert_called_once_with( ANY, Channel, id="c3ccc15b-298f-4dc7-877f-2c8970331caf", deleteDate=None ) @@ -95,7 +95,7 @@ def test_get_channel_users_not_found_datasource_error(mock_get_scalar, db_mock): def test_get_channel_users(mock_get_scalar, db_mock, channel): """Test that channel users are returned.""" mock_get_scalar.return_value = channel - assert db_mock.get_channel_users("c3ccc15b-298f-4dc7-877f-2c8970331caf") == [ + assert db_mock.get_channel_subscribed_users("c3ccc15b-298f-4dc7-877f-2c8970331caf") == [ { "user_id": "3eacfc35-42bd-49e8-9f2c-1e552c447ff3", "username": "testuser", diff --git a/tests/unit/test_router.py b/tests/unit/test_router.py index 65e658e..329495a 100644 --- a/tests/unit/test_router.py +++ b/tests/unit/test_router.py @@ -173,7 +173,7 @@ def test_get_channel_users_one_group_without_no_system_users( router_mock, system_user_1, system_user_2, group_user_1, group_user_2 ): """Test get channel users for one group without no system users.""" - router_mock.data_source.get_channel_users.return_value = [system_user_1, system_user_2] + router_mock.data_source.get_channel_subscribed_users.return_value = [system_user_1, system_user_2] router_mock.data_source.get_channel_groups.return_value = [ {"group_id": "186d8dfc-2774-43a8-91b5-a887fcb6ba4a"}, ] @@ -185,7 +185,7 @@ def test_get_channel_users_one_group_without_no_system_users( def test_get_channel_users_one_group_with_no_system_user(router_mock, no_system_user_1): """Test get channel users for one group with a no system user.""" - router_mock.data_source.get_channel_users.return_value = [] + router_mock.data_source.get_channel_subscribed_users.return_value = [] router_mock.data_source.get_channel_groups.return_value = [ {"group_id": "186d8dfc-2774-43a8-91b5-a887fcb6ba4a"}, ] @@ -199,7 +199,7 @@ def test_get_channel_users_multiple_groups_with_overlapping_users( router_mock, system_user_1, system_user_2, system_user_3, group_user_1, group_user_2, group_user_3 ): """Test get channel users for multiple groups with overlapping users.""" - router_mock.data_source.get_channel_users.return_value = [system_user_2, system_user_3] + router_mock.data_source.get_channel_subscribed_users.return_value = [system_user_2, system_user_3] router_mock.data_source.get_channel_groups.return_value = [ {"group_id": "186d8dfc-2774-43a8-91b5-a887fcb6ba4a"}, {"group_id": "ffff6789-2774-43a8-91b5-a887fcb6ba4a"}, @@ -218,7 +218,7 @@ def test_get_channel_no_users_multiple_groups_with_overlapping_users( router_mock, system_user_1, group_user_1, group_user_2, group_user_3 ): """Test get channel users w/out users for multiple groups with overlapping users.""" - router_mock.data_source.get_channel_users.return_value = [] + router_mock.data_source.get_channel_subscribed_users.return_value = [] router_mock.data_source.get_channel_groups.return_value = [ {"group_id": "186d8dfc-2774-43a8-91b5-a887fcb6ba4a"}, {"group_id": "ffff6789-2774-43a8-91b5-a887fcb6ba4a"}, @@ -241,7 +241,7 @@ def test_get_channel_users_group_with_system_and_no_system_users( router_mock, system_user_1, system_user_2, no_system_user_1, no_system_user_2, group_user_1, group_user_2 ): """Test get channel users for groups with system and no system users.""" - router_mock.data_source.get_channel_users.return_value = [system_user_1, system_user_2] + router_mock.data_source.get_channel_subscribed_users.return_value = [system_user_1, system_user_2] router_mock.data_source.get_channel_groups.return_value = [ {"group_id": "186d8dfc-2774-43a8-91b5-a887fcb6ba4a"}, ] @@ -265,7 +265,7 @@ def test_get_channel_users_group_with_system_and_no_system_users( def test_get_channel_users_no_users(router_mock): """Test get channel users for no users.""" - router_mock.data_source.get_channel_users.return_value = [] + router_mock.data_source.get_channel_subscribed_users.return_value = [] router_mock.data_source.get_channel_groups.return_value = [ {"group_id": "186d8dfc-2774-43a8-91b5-a887fcb6ba4a"}, ] @@ -276,7 +276,7 @@ def test_get_channel_users_no_users(router_mock): def test_get_channel_users_no_groups(router_mock, system_user_1, system_user_2): """Test get channel users for no groups.""" - router_mock.data_source.get_channel_users.return_value = [system_user_1, system_user_2] + router_mock.data_source.get_channel_subscribed_users.return_value = [system_user_1, system_user_2] router_mock.data_source.get_channel_groups.return_value = [] router_mock.data_source.get_group_users.assert_not_called() router_mock.data_source.get_system_user.assert_not_called() -- GitLab From 621cd3646d4e9b7ed84671bbe24a40a715461251 Mon Sep 17 00:00:00 2001 From: Carina Antunes <carina.oliveira.antunes@cern.ch> Date: Wed, 16 Mar 2022 19:26:09 +0100 Subject: [PATCH 2/4] add tests --- .../postgres/postgres_data_source.py | 7 +- notifications_routing/router.py | 43 ++- scripts/docker-send.py | 14 - tests/unit/conftest.py | 213 +++++++++++++++ tests/unit/test_router.py | 251 ++++++------------ 5 files changed, 323 insertions(+), 205 deletions(-) diff --git a/notifications_routing/data_source/postgres/postgres_data_source.py b/notifications_routing/data_source/postgres/postgres_data_source.py index 42f4ba9..d2dcdab 100644 --- a/notifications_routing/data_source/postgres/postgres_data_source.py +++ b/notifications_routing/data_source/postgres/postgres_data_source.py @@ -122,17 +122,12 @@ class PostgresDataSource(DataSource): def get_channel_unsubscribed_users(self, channel_id: str, **kwargs) -> List[str]: """Return a list of string with usernames of channel unsubscribed users.""" - channel_unsubscribed_users = [] - with self.session() as session: channel = self.__get_scalar(session, Channel, id=channel_id, deleteDate=None) if not channel: raise NotFoundDataSourceError(Channel, id=channel_id) - for unsubscribed in channel.unsubscribed: - channel_unsubscribed_users.append(unsubscribed.username) - - return channel_unsubscribed_users + return [unsubscribed.username for unsubscribed in channel.unsubscribed] def get_channel_groups(self, channel_id: str, **kwargs) -> List[Dict[str, str]]: """Return a list of dictionaries with group_ids of a channel.""" diff --git a/notifications_routing/router.py b/notifications_routing/router.py index 7d34fd1..857e3da 100644 --- a/notifications_routing/router.py +++ b/notifications_routing/router.py @@ -87,26 +87,25 @@ class Router(megabus.Listener): unique_usernames = [channel_user[self.data_source.USERNAME] for channel_user in users] logging.debug("channel %s usernames: %s", channel_id, unique_usernames) - if groups: - for group in groups: - for group_id in group.values(): - group_users = self.data_source.get_group_users(group_id) - logging.debug("channel %s groups %s", channel_id, group_users) - - for user in group_users: - if user[self.data_source.USERNAME] in unique_usernames: - continue - # Skip unsubscribed users - if user[self.data_source.USERNAME] in unsubscribed_users: - continue - - try: - system_user = self.data_source.get_system_user(user[self.data_source.USERNAME]) - users.append(system_user) - unique_usernames.append(user[self.data_source.USERNAME]) - except NotFoundDataSourceError: - users.append(user) - unique_usernames.append(user[self.data_source.USERNAME]) + for group in groups: + group_id = group[DataSource.GROUP_ID] + group_users = self.data_source.get_group_users(group_id) + logging.debug("channel %s groups %s", channel_id, group_users) + + for user in group_users: + if user[self.data_source.USERNAME] in unique_usernames: + continue + # Skip unsubscribed users + if user[self.data_source.USERNAME] in unsubscribed_users: + continue + + try: + system_user = self.data_source.get_system_user(user[self.data_source.USERNAME]) + users.append(system_user) + unique_usernames.append(user[self.data_source.USERNAME]) + except NotFoundDataSourceError: + users.append(user) + unique_usernames.append(user[self.data_source.USERNAME]) logging.debug("channel %s final users %s", channel_id, users) @@ -142,7 +141,7 @@ class Router(megabus.Listener): logging.debug("channel %s unsubscribed users %s", channel_id, unsubscribed_users) audit_notification(notification_id, {"event": "Unsubscribed users", "targets": unsubscribed_users}) - subscribed_target_users = [user for user in target_users if user["id"] not in unsubscribed_users] + subscribed_target_users = [user for user in target_users if user[DataSource.USERNAME] not in unsubscribed_users] logging.debug("channel %s subscribed targeted users %s", channel_id, target_users) if target_groups: @@ -172,7 +171,7 @@ class Router(megabus.Listener): if not channel_users: logging.debug("no channel_users to intersect for channel %s", message[OutputMessageKeys.CHANNEL_ID]) return - target_users = set(channel_users).intersection(target_users) + target_users = [x for x in target_users if x in channel_users] return target_users else: diff --git a/scripts/docker-send.py b/scripts/docker-send.py index 0e2740a..13c7523 100644 --- a/scripts/docker-send.py +++ b/scripts/docker-send.py @@ -28,28 +28,14 @@ message = { "target": { "id": "bb5f841a-5554-4038-98c7-4eacd19acb61", "slug": "test slug", - "name": "Test name", - "description": "", - "visibility": "RESTRICTED", - "subscriptionPolicy": "DYNAMIC", - "archive": False, - "APIKey": None, - "creationDate": "2021-02-26T12:58:42.131Z", - "lastActivityDate": "2021-02-26T12:58:42.131Z", - "incomingEmail": None, - "deleteDate": None, "category": None, }, "body": "<p>test</p>\n", - "sendAt": None, "sentAt": "2021-08-16T10:01:33.173Z", - "users": [], "link": "https://test-notifications.web.cern.ch/", "summary": "test summary", "imgUrl": None, "priority": "important", - "tags": None, - "contentType": None, "private": False, "targetUsers": False, "targetGroups": False, diff --git a/tests/unit/conftest.py b/tests/unit/conftest.py index 723e32e..c1d6cd4 100644 --- a/tests/unit/conftest.py +++ b/tests/unit/conftest.py @@ -1,9 +1,14 @@ """Package's fixtures.""" +import datetime +from unittest.mock import MagicMock import pytest from notifications_routing.app import configure_logging from notifications_routing.config import load_config +from notifications_routing.data_source.postgres.postgres_data_source import Device, Preference +from notifications_routing.preferences import TargetPreference +from notifications_routing.router import Router @pytest.fixture(scope="module") @@ -17,3 +22,211 @@ def config(): def appctx(config): """Set up app context.""" configure_logging(config) + + +@pytest.fixture(scope="function") +def router_mock(): + """Private access record.""" + publisher = MagicMock() + + data_source = MagicMock() + data_source.USERNAME = "username" + data_source.EMAIL = "email" + data_source.LAST_LOGIN = "last_login" + data_source.USER_ID = "user_id" + + return Router(publisher, data_source) + + +@pytest.fixture(scope="function") +def system_user_1(): + """User dict.""" + return { + "user_id": "3eacfc35-42bd-49e8-9f2c-1e552c447ff3", + "username": "testuser1", + "email": "testuser1@cern.ch", + "last_login": datetime.datetime(2012, 2, 1, 1, 0), + } + + +@pytest.fixture(scope="function") +def system_user_2(): + """User dict.""" + return { + "user_id": "356cfcff-42bd-49e8-9f2c-1e552c447ff3", + "username": "testuser2", + "email": "testuser2@cern.ch", + "last_login": datetime.datetime(2012, 2, 1, 1, 0), + } + + +@pytest.fixture(scope="function") +def system_user_3(): + """User dict.""" + return { + "user_id": "444cfcff-42bd-49e8-9f2c-1e552c447ff3", + "username": "testuser3", + "email": "testuser3@cern.ch", + "last_login": datetime.datetime(2012, 2, 1, 1, 0), + } + + +@pytest.fixture(scope="function") +def no_system_user_1(): + """No system user dict.""" + return {"username": "nosystemuser1", "email": "nosystemuser1@cern.ch"} + + +@pytest.fixture(scope="function") +def no_system_user_2(): + """No system user dict.""" + return {"username": "nosystemuser2", "email": "nosystemuser2@cern.ch"} + + +@pytest.fixture(scope="function") +def no_system_user_unsubscribed(): + """No system user dict.""" + return {"username": "unsubscribeduser", "email": "unsubscribeduser@cern.ch"} + + +@pytest.fixture(scope="function") +def group_1(): + """Channel user dict.""" + return {"group_id": "0557969f-acd8-433c-b9c9-59ea642b751b"} + + +@pytest.fixture(scope="function") +def group_2(): + """Channel user dict.""" + return {"group_id": "378ec6a5-cf3f-459f-bb59-0de749edbba3"} + + +@pytest.fixture(scope="function") +def group_user_1(): + """Channel user dict.""" + return {"username": "testuser1", "email": "testuser1@cern.ch"} + + +@pytest.fixture(scope="function") +def group_user_2(): + """Channel user dict.""" + return {"username": "testuser2", "email": "testuser2@cern.ch"} + + +@pytest.fixture(scope="function") +def group_user_3(): + """Channel user dict.""" + return {"username": "testuser3", "email": "testuser3@cern.ch"} + + +@pytest.fixture(scope="function") +def message(): + """Message dict.""" + return { + "id": "d53a7d79-0c5e-4b72-9d6f-8492b7415340", + "channel_id": "c3ccc15b-298f-4dc7-877f-2c8970331caf", + "channel_name": "Test channel", + "channel_slug": "test-channel", + "priority": "NORMAL", + "created_timestamp": "2021-02-26T13:59:40.754Z", + "message_body": "test", + "summary": "test", + "link": None, + "img_url": None, + "intersection": False, + "private": False, + } + + +@pytest.fixture(scope="function") +def message_private(): + """Message dict.""" + return { + "id": "d53a7d79-0c5e-4b72-9d6f-8492b7415340", + "channel_id": "c3ccc15b-298f-4dc7-877f-2c8970331caf", + "channel_name": "Test channel", + "channel_slug": "test-channel", + "priority": "NORMAL", + "created_timestamp": "2021-02-26T13:59:40.754Z", + "message_body": "test", + "summary": "test", + "link": None, + "img_url": None, + "intersection": False, + "private": True, + } + + +@pytest.fixture(scope="function") +def message_intersection(): + """Message dict.""" + return { + "id": "d53a7d79-0c5e-4b72-9d6f-8492b7415340", + "channel_id": "c3ccc15b-298f-4dc7-877f-2c8970331caf", + "channel_name": "Test channel", + "channel_slug": "test-channel", + "priority": "NORMAL", + "created_timestamp": "2021-02-26T13:59:40.754Z", + "message_body": "test", + "summary": "test", + "link": None, + "img_url": None, + "private": True, + "intersection": True, + } + + +@pytest.fixture(scope="function") +def message_critical(): + """Message dict.""" + return { + "id": "d53a7d79-0c5e-4b72-9d6f-8492b7415340", + "channel_id": "c3ccc15b-298f-4dc7-877f-2c8970331caf", + "channel_name": "Test channel", + "channel_slug": "test-channel", + "priority": "CRITICAL", + "created_timestamp": "2021-02-26T13:59:40.754Z", + "message_body": "test", + "summary": "test", + "link": None, + "img_url": None, + } + + +@pytest.fixture(scope="function") +def preference(device): + """Preference object.""" + return Preference( + id="17fd2706-8baf-433b-82eb-8c7fada847da", + type="DAILY", + name="Daily preference", + userId="3eacfc35-42bd-49e8-9f2c-1e552c447ff3", + targetId="c3ccc15b-298f-4dc7-877f-2c8970331caf", + notificationPriority="NORMAL", + rangeStart=None, + rangeEnd=None, + scheduledTime=datetime.time(13, 0), + devices=[device], + ) + + +@pytest.fixture(scope="function") +def device(): + """Device object.""" + return Device( + id="d4e15d11-a56e-4568-9e9f-497110426a72", + name="testuser1@cern.ch", + userId="3eacfc35-42bd-49e8-9f2c-1e552c447ff3", + info="Default", + type="MAIL", + subType=None, + token=None, + ) + + +@pytest.fixture(scope="function") +def target_preference(preference): + """Target Preference object.""" + return TargetPreference( + preference.type, preference.devices, preference.scheduledTime, preference.scheduledDay, preference.id + ) diff --git a/tests/unit/test_router.py b/tests/unit/test_router.py index 329495a..ef2993d 100644 --- a/tests/unit/test_router.py +++ b/tests/unit/test_router.py @@ -1,174 +1,13 @@ """Unit Tests for Router.""" -import datetime from unittest import mock -from unittest.mock import ANY, MagicMock +from unittest.mock import ANY -import pytest - -from notifications_routing.data_source.postgres.postgres_data_source import Device, Preference, User +from notifications_routing.data_source.postgres.postgres_data_source import User from notifications_routing.exceptions import NotFoundDataSourceError -from notifications_routing.preferences import TargetPreference from notifications_routing.router import Router -@pytest.fixture(scope="function") -def router_mock(): - """Private access record.""" - with mock.patch.object(Router, "__init__", return_value=None): - mock_router = Router() - mock_router.publisher = MagicMock() - mock_router.data_source = MagicMock() - mock_router.data_source.USERNAME = "username" - mock_router.data_source.EMAIL = "email" - mock_router.data_source.LAST_LOGIN = "last_login" - mock_router.data_source.USER_ID = "user_id" - - return mock_router - - -@pytest.fixture(scope="function") -def system_user_1(): - """User dict.""" - return { - "user_id": "3eacfc35-42bd-49e8-9f2c-1e552c447ff3", - "username": "testuser1", - "email": "testuser1@cern.ch", - "last_login": datetime.datetime(2012, 2, 1, 1, 0), - } - - -@pytest.fixture(scope="function") -def system_user_2(): - """User dict.""" - return { - "user_id": "356cfcff-42bd-49e8-9f2c-1e552c447ff3", - "username": "testuser2", - "email": "testuser2@cern.ch", - "last_login": datetime.datetime(2012, 2, 1, 1, 0), - } - - -@pytest.fixture(scope="function") -def system_user_3(): - """User dict.""" - return { - "user_id": "444cfcff-42bd-49e8-9f2c-1e552c447ff3", - "username": "testuser3", - "email": "testuser3@cern.ch", - "last_login": datetime.datetime(2012, 2, 1, 1, 0), - } - - -@pytest.fixture(scope="function") -def no_system_user_1(): - """No system user dict.""" - return {"username": "nosystemuser1", "email": "nosystemuser1@cern.ch"} - - -@pytest.fixture(scope="function") -def no_system_user_2(): - """No system user dict.""" - return {"username": "nosystemuser2", "email": "nosystemuser2@cern.ch"} - - -@pytest.fixture(scope="function") -def no_system_user_unsubscribed(): - """No system user dict.""" - return {"username": "unsubscribeduser", "email": "unsubscribeduser@cern.ch"} - - -@pytest.fixture(scope="function") -def group_user_1(): - """Channel user dict.""" - return {"username": "testuser1", "email": "testuser1@cern.ch"} - - -@pytest.fixture(scope="function") -def group_user_2(): - """Channel user dict.""" - return {"username": "testuser2", "email": "testuser2@cern.ch"} - - -@pytest.fixture(scope="function") -def group_user_3(): - """Channel user dict.""" - return {"username": "testuser3", "email": "testuser3@cern.ch"} - - -@pytest.fixture(scope="function") -def message(): - """Message dict.""" - return { - "id": "d53a7d79-0c5e-4b72-9d6f-8492b7415340", - "channel_id": "c3ccc15b-298f-4dc7-877f-2c8970331caf", - "channel_name": "Test channel", - "channel_slug": "test-channel", - "priority": "NORMAL", - "created_timestamp": "2021-02-26T13:59:40.754Z", - "message_body": "test", - "summary": "test", - "link": None, - "img_url": None, - } - - -@pytest.fixture(scope="function") -def message_critical(): - """Message dict.""" - return { - "id": "d53a7d79-0c5e-4b72-9d6f-8492b7415340", - "channel_id": "c3ccc15b-298f-4dc7-877f-2c8970331caf", - "channel_name": "Test channel", - "channel_slug": "test-channel", - "priority": "CRITICAL", - "created_timestamp": "2021-02-26T13:59:40.754Z", - "message_body": "test", - "summary": "test", - "link": None, - "img_url": None, - } - - -@pytest.fixture(scope="function") -def preference(device): - """Preference object.""" - return Preference( - id="17fd2706-8baf-433b-82eb-8c7fada847da", - type="DAILY", - name="Daily preference", - userId="3eacfc35-42bd-49e8-9f2c-1e552c447ff3", - targetId="c3ccc15b-298f-4dc7-877f-2c8970331caf", - notificationPriority="NORMAL", - rangeStart=None, - rangeEnd=None, - scheduledTime=datetime.time(13, 0), - devices=[device], - ) - - -@pytest.fixture(scope="function") -def device(): - """Device object.""" - return Device( - id="d4e15d11-a56e-4568-9e9f-497110426a72", - name="testuser1@cern.ch", - userId="3eacfc35-42bd-49e8-9f2c-1e552c447ff3", - info="Default", - type="MAIL", - subType=None, - token=None, - ) - - -@pytest.fixture(scope="function") -def target_preference(preference): - """Target Preference object.""" - return TargetPreference( - preference.type, preference.devices, preference.scheduledTime, preference.scheduledDay, preference.id - ) - - def test_get_channel_users_one_group_without_no_system_users( router_mock, system_user_1, system_user_2, group_user_1, group_user_2 ): @@ -290,6 +129,92 @@ def test_process_message_no_channel_users(mock_get_channel_users, appctx, router assert router_mock.process_message(message) is None +def test_process_users_target_users( + appctx, + router_mock, + group_1, + group_2, + system_user_1, + system_user_2, + system_user_3, + group_user_1, + group_user_2, + group_user_3, + no_system_user_1, +): + """Test get_target_users.""" + router_mock.data_source.get_target_users.return_value = [system_user_1, system_user_2] + router_mock.data_source.get_target_groups.return_value = [group_1, group_2] + router_mock.data_source.get_channel_unsubscribed_users.return_value = [system_user_1["username"]] + router_mock.data_source.get_group_users.side_effect = [ + [group_user_1], # ignored because its on unsub + [no_system_user_1, group_user_3], + ] + + router_mock.data_source.get_system_user.side_effect = [ + NotFoundDataSourceError(User, id=no_system_user_1["username"]), + system_user_3, + ] + + assert router_mock.get_target_users(ANY, ANY) == [system_user_2, no_system_user_1, system_user_3] + router_mock.data_source.get_target_users.assert_called_once() + router_mock.data_source.get_target_groups.assert_called_once() + router_mock.data_source.get_target_groups.get_channel_unsubscribed_users() + assert router_mock.data_source.get_group_users.call_count == 2 + assert router_mock.data_source.get_system_user.call_count == 2 + + +@mock.patch.object(Router, "get_target_users") +@mock.patch.object(Router, "get_channel_users") +def test_process_users_intersection( + mock_get_channel_users, + mock_get_target_users, + appctx, + router_mock, + message_intersection, + system_user_1, + system_user_2, + no_system_user_1, + group_user_1, +): + """Test process users for intersection.""" + mock_get_channel_users.return_value = [system_user_2, group_user_1] + mock_get_target_users.return_value = [system_user_1, system_user_2, no_system_user_1] + + result = router_mock.process_users(message_intersection) + assert result == [system_user_2] + mock_get_channel_users.assert_called_once_with(message_intersection["id"], message_intersection["channel_id"]) + mock_get_target_users.assert_called_once_with(message_intersection["id"], message_intersection["channel_id"]) + + +@mock.patch.object(Router, "get_target_users") +@mock.patch.object(Router, "get_channel_users") +def test_process_users_private( + mock_get_channel_users, mock_get_target_users, appctx, router_mock, message_private, group_user_1, system_user_2 +): + """Test process users for private.""" + mock_get_target_users.return_value = [system_user_2, group_user_1] + + result = router_mock.process_users(message_private) + assert result == [system_user_2, group_user_1] + mock_get_target_users.assert_called_once_with(message_private["id"], message_private["channel_id"]) + mock_get_channel_users.assert_not_called() + + +@mock.patch.object(Router, "get_target_users") +@mock.patch.object(Router, "get_channel_users") +def test_process_users( + mock_get_channel_users, mock_get_target_users, appctx, router_mock, message, system_user_1, system_user_2 +): + """Test process users for normal.""" + mock_get_channel_users.return_value = [system_user_1, system_user_2] + + result = router_mock.process_users(message) + assert result == [system_user_1, system_user_2] + mock_get_channel_users.assert_called_once_with(message["id"], message["channel_id"]) + mock_get_target_users.assert_not_called() + + @mock.patch("notifications_routing.router.apply_default_preferences") @mock.patch.object(Router, "get_channel_users") def test_process_message_no_system_users( -- GitLab From 930cd61e7022e29ac79ce73bcc7b0deacd555cea Mon Sep 17 00:00:00 2001 From: Carina Antunes <carina.oliveira.antunes@cern.ch> Date: Thu, 17 Mar 2022 11:39:49 +0100 Subject: [PATCH 3/4] improve docstring in data_source --- .../data_source/data_source.py | 115 +++++++---- .../postgres/postgres_data_source.py | 193 +++++++++++++----- notifications_routing/router.py | 5 +- .../integration/test_postgres_data_source.py | 2 +- tests/unit/test_postgres_data_source.py | 2 +- tests/unit/test_router.py | 18 +- 6 files changed, 233 insertions(+), 102 deletions(-) diff --git a/notifications_routing/data_source/data_source.py b/notifications_routing/data_source/data_source.py index 854d455..5baa525 100644 --- a/notifications_routing/data_source/data_source.py +++ b/notifications_routing/data_source/data_source.py @@ -1,8 +1,10 @@ """Abstract Data Source.""" from abc import ABC, abstractmethod -from datetime import datetime +from datetime import datetime, time from typing import Dict, List +from notifications_routing.utils import FeedFrequency + class DataSource(ABC): """Generic data source interface.""" @@ -18,67 +20,88 @@ class DataSource(ABC): @abstractmethod def get_channel_subscribed_users(self, channel_id: str, **kwargs) -> List[Dict[str, str]]: - """Specific implementation of get channel's users. + """Return the list of users subscribed to a channel. - :param channel_id: Channel ID + :param channel_id: Channel UUID + :return: A list of user data in the format of dicts containing user id, username, email and last login + :raises: NotFoundDataSourceError, MultipleResultsFoundError """ pass @abstractmethod - def get_channel_groups(self, channel_id: str, **kwargs) -> List[Dict[str, str]]: - """Specific implementation of get channel's groups. + def get_channel_groups(self, channel_id: str, **kwargs) -> List[str]: + """Return the list of groups members of to a channel. - :param channel_id: Channel ID + :param channel_id: Channel UUID + :return: A list of group's uuids + :raises: NotFoundDataSourceError, MultipleResultsFoundError """ pass @abstractmethod def get_group_users(self, group_id: str, **kwargs) -> List[Dict[str, str]]: - """Specific implementation of get groups's users. + """Return the list of users that belong to a group. - :param group_id: Group ID + :param group_id: Group UUID or ID + :return: A list of user data in the format of dicts containing username and email + :raises: BadResponseCodeError """ pass @abstractmethod def get_user_preferences(self, user_id: str, channel_id: str, **kwargs) -> Dict[str, List["Preference"]]: - """Specific implementation of get user's preferences. + """Return the list of user preferences. - :param user_id: User ID - :param channel_id: Channel ID + :param user_id: User UUID + :param channel_id: Channel UUID + :return: A dict containing the user email and a list of preferences + :raises: NotFoundDataSourceError, MultipleResultsFoundError """ pass @abstractmethod def get_user_devices(self, user_id: str, **kwargs) -> Dict[str, List["Device"]]: - """Specific implementation of get user's devices. + """Return the list of user devices. - :param user_id: User ID + :param user_id: User UUID + :return: A dict containing the user email and a list of devices + :raises: NotFoundDataSourceError, MultipleResultsFoundError """ pass @abstractmethod def is_user_mute_active(self, user_id: str, channel_id: str, **kwargs) -> bool: - """Specific implementation of is mute active for the specified channel or globally. + """Return true if the user has an active mute for the specified channel or globally. - :param user_id: User ID - :param channel_id: Channel ID + :param user_id: User UUID + :param channel_id: Channel UUID. Optional. + :return: Bool + :raises: NotFoundDataSourceError, MultipleResultsFoundError """ pass @abstractmethod - def create_feed_user_notification(self, user_feed_notification: object) -> None: - """Specific implementation of save feed user notification. + def create_feed_user_notification(self, user_feed_notification: "UserFeedNotification") -> None: + """Save a FeedUserNotification Object to Database. - :param user_feed_notification: UserFeedNotification object + :param user_feed_notification: UserFeedNotification + :raises: DuplicatedError """ pass @abstractmethod def get_unique_users_from_feed_notifications( - self, runtime_datetime: datetime, runtime_time: datetime, runtime_day_of_week: int, frequency_type: str + self, runtime_datetime: datetime, runtime_time: time, runtime_day_of_week: int, frequency_type: FeedFrequency ) -> List[str]: - """Specific implementation of get unique users from feed notifications for specific datetime and frequency.""" + """Return unique users from feed notifications for specific delivery datetime and frequency. + + :param runtime_datetime: Runtime Datetime + :param runtime_time: Runtime Time + :param runtime_day_of_week: Day of week as an integer + :param frequency_type: Frequency type + + :return List of users uuids + """ pass @abstractmethod @@ -86,15 +109,15 @@ class DataSource(ABC): self, user_id: str, runtime_datetime: datetime, - runtime_time: datetime, + runtime_time: time, runtime_day_of_week: int, - frequency_type: str, + frequency_type: FeedFrequency, ) -> List[str]: """Specific implementation of get user feed notifications ids for specific delivery datetime and frequency. :param user_id: User id - :param runtime_datetime: datetime - :param runtime_time: datetime + :param runtime_datetime: Datetime + :param runtime_time: Time :param runtime_day_of_week: day of the week :param frequency_type: frequency type """ @@ -105,15 +128,15 @@ class DataSource(ABC): self, user_id: str, runtime_datetime: datetime, - runtime_time: datetime, + runtime_time: time, runtime_day_of_week: int, - frequency_type: str, + frequency_type: FeedFrequency, ) -> None: """Specific implementation of delete user feed notifications. :param user_id: User id - :param runtime_datetime: datetime - :param runtime_time: datetime + :param runtime_datetime: Datetime + :param runtime_time: Time :param runtime_day_of_week: day of the week :param frequency_type: frequency type """ @@ -121,28 +144,42 @@ class DataSource(ABC): @abstractmethod def get_system_user(self, username: str, **kwargs) -> Dict[str, str]: - """Return a dict of user_id, username and email of user. + """Return user if in system. - :param username: Username + :param username: User UUID + :return Dict of user data containing user_id, username, email and last_login + :raises: NotFoundDataSourceError, MultipleResultsFoundError """ pass @abstractmethod def get_channel_unsubscribed_users(self, channel_id: str, **kwargs) -> List[str]: - """Return a list of string with usernames of channel unsubscribed users. + """Return the list of users unsubscribed to a channel. - :param channel_id: Channel ID + :param channel_id: Channel UUID + :return: A list of user's usernames + :raises: NotFoundDataSourceError, MultipleResultsFoundError """ pass @abstractmethod def get_target_users(self, notification_id: str, **kwargs) -> List[Dict[str, str]]: - """Return a list of dictionaries with target user id and emails of a notification.""" + """Return the list of users targeted to a notification. + + :param notification_id: Notification UUID + :return: A list of user data in the format of dicts containing user_id, username, email and last_login + :raises: NotFoundDataSourceError, MultipleResultsFoundError + """ pass @abstractmethod - def get_target_groups(self, notification_id: str, **kwargs) -> List[Dict[str, str]]: - """Return a list of dictionaries with target group id of a notification.""" + def get_target_groups(self, notification_id: str, **kwargs) -> List[str]: + """Return the list of groups targeted to a notification. + + :param notification_id: Notification UUID + :return: A list of group's uuids + :raises: NotFoundDataSourceError, MultipleResultsFoundError + """ pass @@ -156,3 +193,9 @@ class Device: """Device Model.""" pass + + +class UserFeedNotification: + """UserFeedNotification Model.""" + + pass diff --git a/notifications_routing/data_source/postgres/postgres_data_source.py b/notifications_routing/data_source/postgres/postgres_data_source.py index d2dcdab..82b8d7f 100644 --- a/notifications_routing/data_source/postgres/postgres_data_source.py +++ b/notifications_routing/data_source/postgres/postgres_data_source.py @@ -2,7 +2,7 @@ import logging import uuid from contextlib import contextmanager -from datetime import datetime +from datetime import datetime, time from typing import Dict, List from psycopg2 import errors @@ -52,6 +52,20 @@ class PostgresDataSource(DataSource): self.__session = sessionmaker(self.__engine) self.Base.prepare(self.__engine) + @staticmethod + def __build_user(user: "User") -> Dict[str, str]: + """Convert a user from the database model to a user dict. + + :param user: User + :return: Dict containing user_id, username, email and last_login + """ + return { + DataSource.USER_ID: user.id, + DataSource.USERNAME: user.username, + DataSource.EMAIL: user.email, + DataSource.LAST_LOGIN: user.lastLogin, + } + @contextmanager def session(self): """Open Session With Database.""" @@ -68,60 +82,65 @@ class PostgresDataSource(DataSource): session.close() def __get_scalar(self, session, model, **kwargs): - """Query the model based on kwargs and returns the first matching element.""" + """Query the model based on kwargs and returns the first matching element. + + :raises: MultipleResultsFoundError + """ try: return session.query(model).filter_by(**kwargs).scalar() except MultipleResultsFound: raise MultipleResultsFoundError(model.__tablename__, **kwargs) def get_channel_subscribed_users(self, channel_id: str, **kwargs) -> List[Dict[str, str]]: - """Return a list of dictionaries with user_ids and emails of channel_users.""" - - def build_user(user): - return { - DataSource.USER_ID: user.id, - DataSource.USERNAME: user.username, - DataSource.EMAIL: user.email, - DataSource.LAST_LOGIN: user.lastLogin, - } + """Return the list of users subscribed to a channel. + :param channel_id: Channel UUID + :return: A list of user data in the format of dicts containing user_id, username, email and last_login + :raises: NotFoundDataSourceError, MultipleResultsFoundError + """ with self.session() as session: channel = self.__get_scalar(session, Channel, id=channel_id, deleteDate=None) if not channel: raise NotFoundDataSourceError(Channel, id=channel_id) unsubscribed_ids = [user.id for user in channel.unsubscribed] - return [build_user(member) for member in channel.members if member.id not in unsubscribed_ids] + return [self.__build_user(member) for member in channel.members if member.id not in unsubscribed_ids] def get_target_users(self, notification_id: str, **kwargs) -> List[Dict[str, str]]: - """Return a list of dictionaries with user_ids and emails of channel_users.""" - - def build_user(user): - return { - DataSource.USER_ID: user.id, - DataSource.USERNAME: user.username, - DataSource.EMAIL: user.email, - DataSource.LAST_LOGIN: user.lastLogin, - } + """Return the list of users targeted to a notification. + :param notification_id: Notification UUID + :return: A list of user data in the format of dicts containing user_id, username, email and last_login + :raises: NotFoundDataSourceError, MultipleResultsFoundError + """ with self.session() as session: notification = self.__get_scalar(session, Notification, id=notification_id, deleteDate=None) if not notification: raise NotFoundDataSourceError(Notification, id=notification_id) - return [build_user(member) for member in notification.target_users] + return [self.__build_user(member) for member in notification.target_users] + + def get_target_groups(self, notification_id: str, **kwargs) -> List[str]: + """Return the list of groups targeted to a notification. - def get_target_groups(self, notification_id: str, **kwargs) -> List[Dict[str, str]]: - """Return a list of dictionaries with user_ids and emails of channel_users.""" + :param notification_id: Notification UUID + :return: A list of group's uuids + :raises: NotFoundDataSourceError, MultipleResultsFoundError + """ with self.session() as session: notification = self.__get_scalar(session, Notification, id=notification_id, deleteDate=None) if not notification: raise NotFoundDataSourceError(Notification, id=notification_id) - return [{DataSource.GROUP_ID: group.id} for group in notification.target_groups] + return [group.id for group in notification.target_groups] def get_channel_unsubscribed_users(self, channel_id: str, **kwargs) -> List[str]: - """Return a list of string with usernames of channel unsubscribed users.""" + """Return the list of users unsubscribed to a channel. + + :param channel_id: Channel UUID + :return: A list of user's usernames + :raises: NotFoundDataSourceError, MultipleResultsFoundError + """ with self.session() as session: channel = self.__get_scalar(session, Channel, id=channel_id, deleteDate=None) if not channel: @@ -129,22 +148,27 @@ class PostgresDataSource(DataSource): return [unsubscribed.username for unsubscribed in channel.unsubscribed] - def get_channel_groups(self, channel_id: str, **kwargs) -> List[Dict[str, str]]: - """Return a list of dictionaries with group_ids of a channel.""" - groups = [] + def get_channel_groups(self, channel_id: str, **kwargs) -> List[str]: + """Return the list of groups members of to a channel. + :param channel_id: Channel UUID + :return: A list of group's uuids + :raises: NotFoundDataSourceError, MultipleResultsFoundError + """ with self.session() as session: channel = self.__get_scalar(session, Channel, id=channel_id, deleteDate=None) if not channel: raise NotFoundDataSourceError(Channel, id=channel_id) - for group in channel.groups: - groups.append({DataSource.GROUP_ID: group.id}) - - return groups + return [group.id for group in channel.groups] def get_group_users(self, group_id: str, **kwargs) -> List[Dict[str, str]]: - """Return a list of dictionaries with user_ids and emails of group_users.""" + """Return the list of users that belong to a group. + + :param group_id: Group UUID or ID + :return: A list of user data in the format of dicts containing username and email + :raises: BadResponseCodeError + """ group_users = [] content = get_group_users_api(group_id) @@ -156,7 +180,13 @@ class PostgresDataSource(DataSource): return group_users def get_user_preferences(self, user_id: str, channel_id: str, **kwargs) -> Dict[str, List["Preference"]]: - """Return a dictionary with preferences of user for a specific channel.""" + """Return the list of user preferences. + + :param user_id: User UUID + :param channel_id: Channel UUID + :return: A dict containing the user email and a list of preferences + :raises: NotFoundDataSourceError, MultipleResultsFoundError + """ with self.session() as session: user = self.__get_scalar(session, User, id=user_id) if not user: @@ -175,12 +205,16 @@ class PostgresDataSource(DataSource): .all() ) - user_preferences = {DataSource.EMAIL: user.email, DataSource.PREFERENCES: preferences} - - return user_preferences + # TODO: refactor this to just return a list of preferences object with the fields needed + return {DataSource.EMAIL: user.email, DataSource.PREFERENCES: preferences} def get_user_devices(self, user_id: str, **kwargs) -> Dict[str, List["Device"]]: - """Return a dictionary with devices of user.""" + """Return the list of user devices. + + :param user_id: User UUID + :return: A dict containing the user email and a list of devices + :raises: NotFoundDataSourceError, MultipleResultsFoundError + """ with self.session() as session: user = self.__get_scalar(session, User, id=user_id) @@ -195,12 +229,19 @@ class PostgresDataSource(DataSource): .all() ) + # TODO: refactor this to just return a list of devices object with the fields needed user_devices = {DataSource.EMAIL: user.email, DataSource.DEVICES: devices} return user_devices - def is_user_mute_active(self, user_id: str, channel_id: str, **kwargs) -> bool: - """Return true if a mute is active for the specified channel or globally.""" + def is_user_mute_active(self, user_id: str, channel_id: str = None, **kwargs) -> bool: + """Return true if the user has an active mute for the specified channel or globally. + + :param user_id: User UUID + :param channel_id: Channel UUID. Optional. + :return: Bool + :raises: NotFoundDataSourceError, MultipleResultsFoundError + """ with self.session() as session: user = self.__get_scalar(session, User, id=user_id) if not user: @@ -229,8 +270,12 @@ class PostgresDataSource(DataSource): .exists() ).scalar() - def create_feed_user_notification(self, user_feed_notification): - """Save a FeedUserNotification Object to Database.""" + def create_feed_user_notification(self, user_feed_notification: "UserFeedNotification"): + """Save a FeedUserNotification Object to Database. + + :param user_feed_notification: UserFeedNotification + :raises: DuplicatedError + """ try: with self.session() as session: session.add(user_feed_notification) @@ -241,9 +286,21 @@ class PostgresDataSource(DataSource): raise def get_unique_users_from_feed_notifications( - self, runtime_datetime, runtime_time, runtime_day_of_week, frequency_type + self, + runtime_datetime: datetime, + runtime_time: time, + runtime_day_of_week: int, + frequency_type: FeedFrequency, ) -> List[UUID]: - """Get unique users from weekly notifications for specific delivery datetime and frequency.""" + """Return unique users from feed notifications for specific delivery datetime and frequency. + + :param runtime_datetime: Runtime Datetime + :param runtime_time: Runtime Time + :param runtime_day_of_week: Day of week as an integer + :param frequency_type: Frequency type + + :return List of users uuids + """ with self.session() as session: user_ids = session.query(UserFeedNotification.userId).filter( UserFeedNotification.frequencyType == frequency_type, @@ -254,12 +311,27 @@ class PostgresDataSource(DataSource): if not frequency_type == FeedFrequency.DAILY: user_ids = user_ids.filter(UserFeedNotification.deliveryDayOfTheWeek == runtime_day_of_week) - return user_ids.distinct().yield_per(Config.QUERY_BATCH_SIZE) + # TODO: process in batches + return user_ids.distinct() # .yield_per(Config.QUERY_BATCH_SIZE) def get_user_feed_notifications_ids( - self, user_id: UUID, runtime_datetime, runtime_time, runtime_day_of_week, frequency_type + self, + user_id: str, + runtime_datetime: datetime, + runtime_time: time, + runtime_day_of_week: int, + frequency_type: FeedFrequency, ) -> List[str]: - """Get user feed notifications ids for specific delivery datetime and frequency.""" + """Return user feed notifications ids for specific delivery datetime and frequency. + + :param user_id: User UUID + :param runtime_datetime: Runtime Datetime + :param runtime_time: Runtime Time + :param runtime_day_of_week: Day of week as an integer + :param frequency_type: Frequency type + + :return List of notifications ids as strings + """ with self.session() as session: ids = session.query(UserFeedNotification.notificationId).filter( UserFeedNotification.userId == user_id, @@ -274,9 +346,23 @@ class PostgresDataSource(DataSource): return [str(notificationId) for notificationId, in ids] def delete_user_feed_notifications( - self, user_id: UUID, runtime_datetime, runtime_time, runtime_day_of_week, frequency_type + self, + user_id: UUID, + runtime_datetime: datetime, + runtime_time: time, + runtime_day_of_week: int, + frequency_type: FeedFrequency, ) -> None: - """Delete user feed notifications.""" + """Delete user feed notifications. + + :param user_id: User UUID + :param runtime_datetime: Runtime Datetime + :param runtime_time: Runtime Time + :param runtime_day_of_week: Day of week as an integer + :param frequency_type: Frequency type + + :return None + """ with self.session() as session: user_feed_notifications = session.query(UserFeedNotification).filter( UserFeedNotification.userId == user_id, @@ -293,7 +379,12 @@ class PostgresDataSource(DataSource): return user_feed_notifications.delete() def get_system_user(self, username: str, **kwargs) -> Dict[str, str]: - """Return a dict of user_id and email of user.""" + """Return user if in system. + + :param username: User UUID + :return Dict of user data containing user_id, username, email and last_login + :raises: NotFoundDataSourceError, MultipleResultsFoundError + """ with self.session() as session: user = self.__get_scalar(session, User, username=username) if not user: diff --git a/notifications_routing/router.py b/notifications_routing/router.py index 857e3da..9395c42 100644 --- a/notifications_routing/router.py +++ b/notifications_routing/router.py @@ -80,15 +80,14 @@ class Router(megabus.Listener): notification_id: str, channel_id: str, users: List[Dict[str, str]], - groups: List[Dict[str, str]], + groups: List[str], unsubscribed_users: List[str], ): """Add users from groups to users list.""" unique_usernames = [channel_user[self.data_source.USERNAME] for channel_user in users] logging.debug("channel %s usernames: %s", channel_id, unique_usernames) - for group in groups: - group_id = group[DataSource.GROUP_ID] + for group_id in groups: group_users = self.data_source.get_group_users(group_id) logging.debug("channel %s groups %s", channel_id, group_users) diff --git a/tests/integration/test_postgres_data_source.py b/tests/integration/test_postgres_data_source.py index be74659..ed65db6 100644 --- a/tests/integration/test_postgres_data_source.py +++ b/tests/integration/test_postgres_data_source.py @@ -28,7 +28,7 @@ def test_get_channel_groups(appctx, data_source, channel): assert len(channel_groups) == 1 channel_group = channel_groups[0] - assert str(channel_group["group_id"]) == "186d8dfc-2774-43a8-91b5-a887fcb6ba4a" + assert str(channel_group) == "186d8dfc-2774-43a8-91b5-a887fcb6ba4a" def test_get_user_devices(appctx, data_source, user, device): diff --git a/tests/unit/test_postgres_data_source.py b/tests/unit/test_postgres_data_source.py index 3fc3d2c..cdb2cec 100644 --- a/tests/unit/test_postgres_data_source.py +++ b/tests/unit/test_postgres_data_source.py @@ -119,7 +119,7 @@ def test_get_channel_groups_(mock_get_scalar, db_mock, channel): """Test that channel groups are returned.""" mock_get_scalar.return_value = channel assert db_mock.get_channel_groups("c3ccc15b-298f-4dc7-877f-2c8970331caf") == [ - {"group_id": "186d8dfc-2774-43a8-91b5-a887fcb6ba4a"} + "186d8dfc-2774-43a8-91b5-a887fcb6ba4a" ] mock_get_scalar.assert_called_once_with(ANY, Channel, id="c3ccc15b-298f-4dc7-877f-2c8970331caf", deleteDate=None) diff --git a/tests/unit/test_router.py b/tests/unit/test_router.py index ef2993d..2cec64f 100644 --- a/tests/unit/test_router.py +++ b/tests/unit/test_router.py @@ -25,9 +25,7 @@ def test_get_channel_users_one_group_without_no_system_users( def test_get_channel_users_one_group_with_no_system_user(router_mock, no_system_user_1): """Test get channel users for one group with a no system user.""" router_mock.data_source.get_channel_subscribed_users.return_value = [] - router_mock.data_source.get_channel_groups.return_value = [ - {"group_id": "186d8dfc-2774-43a8-91b5-a887fcb6ba4a"}, - ] + router_mock.data_source.get_channel_groups.return_value = ["186d8dfc-2774-43a8-91b5-a887fcb6ba4a"] router_mock.data_source.get_group_users.return_value = [no_system_user_1] router_mock.data_source.get_system_user.side_effect = NotFoundDataSourceError(User, username="nosystemuser") @@ -40,8 +38,8 @@ def test_get_channel_users_multiple_groups_with_overlapping_users( """Test get channel users for multiple groups with overlapping users.""" router_mock.data_source.get_channel_subscribed_users.return_value = [system_user_2, system_user_3] router_mock.data_source.get_channel_groups.return_value = [ - {"group_id": "186d8dfc-2774-43a8-91b5-a887fcb6ba4a"}, - {"group_id": "ffff6789-2774-43a8-91b5-a887fcb6ba4a"}, + "186d8dfc-2774-43a8-91b5-a887fcb6ba4a", + "ffff6789-2774-43a8-91b5-a887fcb6ba4a", ] router_mock.data_source.get_group_users.side_effect = [[group_user_1, group_user_2], [group_user_1, group_user_3]] router_mock.data_source.get_system_user.side_effect = [system_user_1] @@ -59,8 +57,8 @@ def test_get_channel_no_users_multiple_groups_with_overlapping_users( """Test get channel users w/out users for multiple groups with overlapping users.""" router_mock.data_source.get_channel_subscribed_users.return_value = [] router_mock.data_source.get_channel_groups.return_value = [ - {"group_id": "186d8dfc-2774-43a8-91b5-a887fcb6ba4a"}, - {"group_id": "ffff6789-2774-43a8-91b5-a887fcb6ba4a"}, + "186d8dfc-2774-43a8-91b5-a887fcb6ba4a", + "ffff6789-2774-43a8-91b5-a887fcb6ba4a", ] router_mock.data_source.get_group_users.side_effect = [[group_user_1, group_user_2], [group_user_1, group_user_3]] router_mock.data_source.get_system_user.side_effect = [ @@ -82,7 +80,7 @@ def test_get_channel_users_group_with_system_and_no_system_users( """Test get channel users for groups with system and no system users.""" router_mock.data_source.get_channel_subscribed_users.return_value = [system_user_1, system_user_2] router_mock.data_source.get_channel_groups.return_value = [ - {"group_id": "186d8dfc-2774-43a8-91b5-a887fcb6ba4a"}, + "186d8dfc-2774-43a8-91b5-a887fcb6ba4a", ] router_mock.data_source.get_group_users.return_value = [ group_user_1, @@ -106,7 +104,7 @@ def test_get_channel_users_no_users(router_mock): """Test get channel users for no users.""" router_mock.data_source.get_channel_subscribed_users.return_value = [] router_mock.data_source.get_channel_groups.return_value = [ - {"group_id": "186d8dfc-2774-43a8-91b5-a887fcb6ba4a"}, + "186d8dfc-2774-43a8-91b5-a887fcb6ba4a", ] router_mock.data_source.get_group_users.return_value = [] router_mock.data_source.get_system_user.assert_not_called() @@ -144,7 +142,7 @@ def test_process_users_target_users( ): """Test get_target_users.""" router_mock.data_source.get_target_users.return_value = [system_user_1, system_user_2] - router_mock.data_source.get_target_groups.return_value = [group_1, group_2] + router_mock.data_source.get_target_groups.return_value = [group_1["group_id"], group_2["group_id"]] router_mock.data_source.get_channel_unsubscribed_users.return_value = [system_user_1["username"]] router_mock.data_source.get_group_users.side_effect = [ [group_user_1], # ignored because its on unsub -- GitLab From 792be6e183f97641be8e1c5c329f210d9c188e04 Mon Sep 17 00:00:00 2001 From: Carina Antunes <carina.oliveira.antunes@cern.ch> Date: Thu, 17 Mar 2022 12:22:37 +0100 Subject: [PATCH 4/4] fix typos --- notifications_routing/data_source/data_source.py | 4 ++-- .../data_source/postgres/postgres_data_source.py | 4 ++-- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/notifications_routing/data_source/data_source.py b/notifications_routing/data_source/data_source.py index 5baa525..fc20956 100644 --- a/notifications_routing/data_source/data_source.py +++ b/notifications_routing/data_source/data_source.py @@ -30,7 +30,7 @@ class DataSource(ABC): @abstractmethod def get_channel_groups(self, channel_id: str, **kwargs) -> List[str]: - """Return the list of groups members of to a channel. + """Return the list of groups members of a channel. :param channel_id: Channel UUID :return: A list of group's uuids @@ -164,7 +164,7 @@ class DataSource(ABC): @abstractmethod def get_target_users(self, notification_id: str, **kwargs) -> List[Dict[str, str]]: - """Return the list of users targeted to a notification. + """Return the list of users targeted by a notification. :param notification_id: Notification UUID :return: A list of user data in the format of dicts containing user_id, username, email and last_login diff --git a/notifications_routing/data_source/postgres/postgres_data_source.py b/notifications_routing/data_source/postgres/postgres_data_source.py index 82b8d7f..def3b53 100644 --- a/notifications_routing/data_source/postgres/postgres_data_source.py +++ b/notifications_routing/data_source/postgres/postgres_data_source.py @@ -107,7 +107,7 @@ class PostgresDataSource(DataSource): return [self.__build_user(member) for member in channel.members if member.id not in unsubscribed_ids] def get_target_users(self, notification_id: str, **kwargs) -> List[Dict[str, str]]: - """Return the list of users targeted to a notification. + """Return the list of users targeted by a notification. :param notification_id: Notification UUID :return: A list of user data in the format of dicts containing user_id, username, email and last_login @@ -149,7 +149,7 @@ class PostgresDataSource(DataSource): return [unsubscribed.username for unsubscribed in channel.unsubscribed] def get_channel_groups(self, channel_id: str, **kwargs) -> List[str]: - """Return the list of groups members of to a channel. + """Return the list of groups members of a channel. :param channel_id: Channel UUID :return: A list of group's uuids -- GitLab