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