From 132863b3c8e6b0a0cef26559b62e6e70682adf8c Mon Sep 17 00:00:00 2001 From: TriangleYJ Date: Fri, 20 Jan 2023 19:02:46 +0000 Subject: [PATCH 01/17] Add basic FCMTopic view --- apps/user/urls.py | 5 ++-- apps/user/views/fcm.py | 57 +++++++++++++++++++++++++++++++++++++ apps/user/views/fcmtoken.py | 22 -------------- ara/firebase.py | 3 ++ 4 files changed, 63 insertions(+), 24 deletions(-) create mode 100644 apps/user/views/fcm.py delete mode 100644 apps/user/views/fcmtoken.py diff --git a/apps/user/urls.py b/apps/user/urls.py index 9281562a..e458e82c 100644 --- a/apps/user/urls.py +++ b/apps/user/urls.py @@ -1,11 +1,12 @@ from django.urls import include, path -from apps.user.views.fcmtoken import FCMTokenView +from apps.user.views.fcm import FCMTokenView, FCMTopicView from apps.user.views.me import MeView from apps.user.views.router import router urlpatterns = [ path("api/", include(router.urls)), path("api/me", MeView.as_view(), name="me"), - path("api/fcm_token/", FCMTokenView.as_view(), name="fcm"), + path("api/fcm/token/", FCMTokenView.as_view(), name="fcm_token"), + path("api/fcm/topic", FCMTopicView.as_view(), name="fcm_topic"), ] diff --git a/apps/user/views/fcm.py b/apps/user/views/fcm.py new file mode 100644 index 00000000..24dbb9c2 --- /dev/null +++ b/apps/user/views/fcm.py @@ -0,0 +1,57 @@ +from django.db.models.functions import Now +from django.utils.decorators import method_decorator +from django.views.decorators.csrf import ensure_csrf_cookie +from rest_framework import status +from rest_framework.response import Response +from rest_framework.views import APIView + +from apps.user.models import FCMToken + +# TODO: make model, and apply it +tmp_topic_storage = { + '1': set(['board/13', 'board/17', 'portal/popular', 'article/8148']), +} + +class FCMTokenView(APIView): + def patch(self, request, mode): + token = request.data["token"] + if mode == "delete": + FCMToken.objects.filter(token=token).delete() + pass + elif mode == "update": + if not request.user.is_authenticated: + return Response(status=status.HTTP_401_UNAUTHORIZED) + token = FCMToken(token=token, user=request.user, last_activated_at=Now()) + token.save() + return Response(status=status.HTTP_200_OK) + +class FCMTopicView(APIView): + def get(self, request): + # TODO: More better way? + if not request.user.is_authenticated: + return Response(status=status.HTTP_401_UNAUTHORIZED) + + user_topics = tmp_topic_storage.get(str(request.user.id)) + return Response(user_topics) + + def patch(self, request): + if not request.user.is_authenticated: + return Response(status=status.HTTP_401_UNAUTHORIZED) + + topic_put_list: list[str] = request.data.get('put') + topic_delete_list: list[str] = request.data.get('delete') + user_id = str(request.user.id) + + if tmp_topic_storage.get(user_id) == None: + tmp_topic_storage[user_id] = set() + for topic in topic_put_list: + print(topic, user_id) + tmp_topic_storage[user_id].add(topic) + + user_topics = tmp_topic_storage.get(user_id) + for topic in topic_delete_list: + if user_topics and topic in user_topics: + user_topics.remove(topic) + + + return Response(status=status.HTTP_200_OK) diff --git a/apps/user/views/fcmtoken.py b/apps/user/views/fcmtoken.py deleted file mode 100644 index f3fd22ab..00000000 --- a/apps/user/views/fcmtoken.py +++ /dev/null @@ -1,22 +0,0 @@ -from django.db.models.functions import Now -from django.utils.decorators import method_decorator -from django.views.decorators.csrf import ensure_csrf_cookie -from rest_framework import status -from rest_framework.response import Response -from rest_framework.views import APIView - -from apps.user.models import FCMToken - - -class FCMTokenView(APIView): - def patch(self, request, mode): - token = request.data["token"] - if mode == "delete": - FCMToken.objects.filter(token=token).delete() - pass - elif mode == "update": - if not request.user.is_authenticated: - return Response(status=status.HTTP_401_UNAUTHORIZED) - token = FCMToken(token=token, user=request.user, last_activated_at=Now()) - token.save() - return Response(status=status.HTTP_200_OK) diff --git a/ara/firebase.py b/ara/firebase.py index 851baf39..cf77ce0b 100644 --- a/ara/firebase.py +++ b/ara/firebase.py @@ -2,6 +2,9 @@ from apps.user.models import FCMToken +def fcm_subscrible(FCM_token, user): + pass + def fcm_notify_comment(user, title, body, open_url): ################## Disable FCM #################### From 9eb5971ee524ada87bf3b3b623637c9cc69b01e8 Mon Sep 17 00:00:00 2001 From: TriangleYJ Date: Sun, 22 Jan 2023 12:56:53 +0000 Subject: [PATCH 02/17] Add (un)subscription to fcm --- apps/user/views/fcm.py | 23 +++++++++++------------ ara/firebase.py | 11 +++++++++-- 2 files changed, 20 insertions(+), 14 deletions(-) diff --git a/apps/user/views/fcm.py b/apps/user/views/fcm.py index 24dbb9c2..df40f469 100644 --- a/apps/user/views/fcm.py +++ b/apps/user/views/fcm.py @@ -1,15 +1,13 @@ from django.db.models.functions import Now -from django.utils.decorators import method_decorator -from django.views.decorators.csrf import ensure_csrf_cookie from rest_framework import status from rest_framework.response import Response from rest_framework.views import APIView - from apps.user.models import FCMToken +from ara.firebase import fcm_subscrible, fcm_unsubscrible # TODO: make model, and apply it tmp_topic_storage = { - '1': set(['board/13', 'board/17', 'portal/popular', 'article/8148']), + '1': set(['board_13', 'board_17', 'portal_popular', 'article_8148']), } class FCMTokenView(APIView): @@ -40,18 +38,19 @@ def patch(self, request): topic_put_list: list[str] = request.data.get('put') topic_delete_list: list[str] = request.data.get('delete') + print(topic_put_list, topic_delete_list) + # TODO: santize user topic list to available topics user_id = str(request.user.id) if tmp_topic_storage.get(user_id) == None: tmp_topic_storage[user_id] = set() - for topic in topic_put_list: - print(topic, user_id) - tmp_topic_storage[user_id].add(topic) - - user_topics = tmp_topic_storage.get(user_id) - for topic in topic_delete_list: - if user_topics and topic in user_topics: - user_topics.remove(topic) + user_topics = tmp_topic_storage[user_id] + + user_tokens = list(FCMToken.objects.filter(user=request.user).values_list('token', flat=True).distinct()) + fcm_subscrible(user_tokens, topic_put_list) + user_topics.update(topic_put_list) + fcm_unsubscrible(user_tokens, topic_delete_list) + user_topics.difference_update(topic_delete_list) return Response(status=status.HTTP_200_OK) diff --git a/ara/firebase.py b/ara/firebase.py index cf77ce0b..5e7f4a63 100644 --- a/ara/firebase.py +++ b/ara/firebase.py @@ -2,9 +2,16 @@ from apps.user.models import FCMToken -def fcm_subscrible(FCM_token, user): - pass +def fcm_subscrible(FCM_tokens, subs): + for sub in subs: + response = messaging.subscribe_to_topic(FCM_tokens, sub) + +def fcm_unsubscrible(FCM_tokens, subs): + for sub in subs: + response = messaging.unsubscribe_from_topic(FCM_tokens, sub) +def fcm_notify_topic(topic): + pass def fcm_notify_comment(user, title, body, open_url): ################## Disable FCM #################### From 846b67569a4aa6b14e31edcd3e4c67e68b9f35e1 Mon Sep 17 00:00:00 2001 From: TriangleYJ Date: Mon, 23 Jan 2023 07:44:55 +0000 Subject: [PATCH 03/17] Add Article Notification --- .../0044_alter_notification_type.py | 18 ++++++++++++++++ apps/core/models/notification.py | 21 +++++++++++++++++++ apps/core/models/signals/__init__.py | 1 + apps/core/models/signals/article.py | 11 ++++++++++ 4 files changed, 51 insertions(+) create mode 100644 apps/core/migrations/0044_alter_notification_type.py create mode 100644 apps/core/models/signals/article.py diff --git a/apps/core/migrations/0044_alter_notification_type.py b/apps/core/migrations/0044_alter_notification_type.py new file mode 100644 index 00000000..3725a505 --- /dev/null +++ b/apps/core/migrations/0044_alter_notification_type.py @@ -0,0 +1,18 @@ +# Generated by Django 3.2.16 on 2023-01-23 07:06 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('core', '0043_board_comment_access_mask'), + ] + + operations = [ + migrations.AlterField( + model_name='notification', + name='type', + field=models.CharField(choices=[('default', 'default'), ('article_commented', 'article_commented'), ('comment_commented', 'comment_commented'), ('article_new', 'article_new')], default='default', max_length=32, verbose_name='알림 종류'), + ), + ] diff --git a/apps/core/models/notification.py b/apps/core/models/notification.py index 22b91c92..0286576d 100644 --- a/apps/core/models/notification.py +++ b/apps/core/models/notification.py @@ -8,6 +8,7 @@ ("default", "default"), ("article_commented", "article_commented"), ("comment_commented", "comment_commented"), + ("article_new", "article_new") ) @@ -55,6 +56,26 @@ def data(self) -> dict: "icon": "", "click_action": "", } + + # TODO: Support English + @classmethod + def notify_article(cls, article): + from apps.core.models import NotificationReadLog + title = f"{article.parent_board.ko_name} {article.parent_topic.ko_name} 게시판에 새로운 글이 달렸습니다." + + subscribers = [] + + NotificationReadLog.objects.bulk_create([NotificationReadLog( + read_by=article.created_by, + notification=cls.objects.create( + type="article_new", + title=title, + content=article.title, + related_article=article, + related_comment=None, + ), + ) for sub in subscribers]) + @classmethod def notify_commented(cls, comment): diff --git a/apps/core/models/signals/__init__.py b/apps/core/models/signals/__init__.py index 3f3cd163..dcc9db88 100644 --- a/apps/core/models/signals/__init__.py +++ b/apps/core/models/signals/__init__.py @@ -2,3 +2,4 @@ from .comment import * from .on_delete_cascade import * from .report import * +from .article import * diff --git a/apps/core/models/signals/article.py b/apps/core/models/signals/article.py new file mode 100644 index 00000000..65987d8f --- /dev/null +++ b/apps/core/models/signals/article.py @@ -0,0 +1,11 @@ +from django.db import models +from django.dispatch import receiver +from django.utils import timezone + +from apps.core.models import Article, Notification + +@receiver(models.signals.post_save, sender=Article) +def comment_post_save_signal(created, instance, **kwargs): + if created: + Notification.notify_article(instance) + print(created, instance) From 3402efe05c3237800e609f312ab68a090ee2e9c2 Mon Sep 17 00:00:00 2001 From: TriangleYJ Date: Mon, 23 Jan 2023 07:45:43 +0000 Subject: [PATCH 04/17] Add topic model --- apps/user/migrations/0021_fcmtopic.py | 30 +++++++++++++++++++++++++++ apps/user/models/__init__.py | 1 + apps/user/models/fcm_topic.py | 27 ++++++++++++++++++++++++ apps/user/views/fcm.py | 21 ++++++++++++------- 4 files changed, 71 insertions(+), 8 deletions(-) create mode 100644 apps/user/migrations/0021_fcmtopic.py create mode 100644 apps/user/models/fcm_topic.py diff --git a/apps/user/migrations/0021_fcmtopic.py b/apps/user/migrations/0021_fcmtopic.py new file mode 100644 index 00000000..6743c3ed --- /dev/null +++ b/apps/user/migrations/0021_fcmtopic.py @@ -0,0 +1,30 @@ +# Generated by Django 3.2.16 on 2023-01-23 07:26 + +from django.conf import settings +from django.db import migrations, models +import django.db.models.deletion +import django.utils.timezone + + +class Migration(migrations.Migration): + + dependencies = [ + migrations.swappable_dependency(settings.AUTH_USER_MODEL), + ('user', '0020_after_fcmtoken_is_web'), + ] + + operations = [ + migrations.CreateModel( + name='FCMTopic', + fields=[ + ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('created_at', models.DateTimeField(db_index=True, default=django.utils.timezone.now, verbose_name='생성 시간')), + ('topic', models.CharField(max_length=200, verbose_name='알림 주제')), + ('user', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='fcm_topic_set', to=settings.AUTH_USER_MODEL, verbose_name='유저')), + ], + options={ + 'ordering': ('-created_at',), + 'unique_together': {('user', 'topic')}, + }, + ), + ] diff --git a/apps/user/models/__init__.py b/apps/user/models/__init__.py index a19f341f..f234a369 100644 --- a/apps/user/models/__init__.py +++ b/apps/user/models/__init__.py @@ -1,3 +1,4 @@ from .fcm_token import * +from .fcm_topic import * from .signals import * from .user_profile import * diff --git a/apps/user/models/fcm_topic.py b/apps/user/models/fcm_topic.py new file mode 100644 index 00000000..64fdef25 --- /dev/null +++ b/apps/user/models/fcm_topic.py @@ -0,0 +1,27 @@ +from django.conf import settings +from django.db import models +from django.utils import timezone + + +class FCMTopic(models.Model): + class Meta: + ordering = ("-created_at",) + unique_together = ('user', 'topic',) + + created_at = models.DateTimeField( + default=timezone.now, + db_index=True, + verbose_name="생성 시간", + ) + + user = models.ForeignKey( + on_delete=models.CASCADE, + to=settings.AUTH_USER_MODEL, + related_name="fcm_topic_set", + verbose_name="유저", + ) + + topic = models.CharField( + max_length=200, + verbose_name="알림 주제" + ) diff --git a/apps/user/views/fcm.py b/apps/user/views/fcm.py index df40f469..2c1549c7 100644 --- a/apps/user/views/fcm.py +++ b/apps/user/views/fcm.py @@ -3,6 +3,7 @@ from rest_framework.response import Response from rest_framework.views import APIView from apps.user.models import FCMToken +from apps.user.models import FCMTopic from ara.firebase import fcm_subscrible, fcm_unsubscrible # TODO: make model, and apply it @@ -25,11 +26,12 @@ def patch(self, request, mode): class FCMTopicView(APIView): def get(self, request): - # TODO: More better way? + # TODO: More better way for authentication guard? if not request.user.is_authenticated: return Response(status=status.HTTP_401_UNAUTHORIZED) - user_topics = tmp_topic_storage.get(str(request.user.id)) + # user_topics = tmp_topic_storage.get(str(request.user.id)) + user_topics = FCMTopic.objects.filter(user=request.user).values_list('topic', flat=True).distinct() return Response(user_topics) def patch(self, request): @@ -42,15 +44,18 @@ def patch(self, request): # TODO: santize user topic list to available topics user_id = str(request.user.id) - if tmp_topic_storage.get(user_id) == None: - tmp_topic_storage[user_id] = set() - user_topics = tmp_topic_storage[user_id] + # if tmp_topic_storage.get(user_id) == None: + # tmp_topic_storage[user_id] = set() + # user_topics = tmp_topic_storage[user_id] user_tokens = list(FCMToken.objects.filter(user=request.user).values_list('token', flat=True).distinct()) fcm_subscrible(user_tokens, topic_put_list) - user_topics.update(topic_put_list) + for tpc in topic_put_list: + FCMTopic.objects.get_or_create(user=request.user, topic=tpc) + # user_topics.update(topic_put_list) fcm_unsubscrible(user_tokens, topic_delete_list) - user_topics.difference_update(topic_delete_list) - + for tpc in topic_delete_list: + FCMTopic.objects.filter(user=request.user, topic=tpc).delete() + # user_topics.difference_update(topic_delete_list) return Response(status=status.HTTP_200_OK) From c5be01eda9e04ad97dfc35abf3760cb1677469a8 Mon Sep 17 00:00:00 2001 From: TriangleYJ Date: Mon, 23 Jan 2023 13:40:14 +0000 Subject: [PATCH 05/17] Add article topic Notification and FCM --- apps/core/models/notification.py | 27 +++++++++++++++++++-------- apps/core/models/signals/article.py | 1 - apps/user/views/fcm.py | 14 ++++++-------- ara/firebase.py | 17 +++++++++++------ 4 files changed, 36 insertions(+), 23 deletions(-) diff --git a/apps/core/models/notification.py b/apps/core/models/notification.py index 0286576d..f94f505b 100644 --- a/apps/core/models/notification.py +++ b/apps/core/models/notification.py @@ -2,7 +2,9 @@ from django.utils.functional import cached_property from ara.db.models import MetaDataModel -from ara.firebase import fcm_notify_comment +from ara.firebase import fcm_notify_user, fcm_notify_topic +from django.contrib.auth.models import User +from apps.user.models import FCMTopic TYPE_CHOICES = ( ("default", "default"), @@ -61,20 +63,29 @@ def data(self) -> dict: @classmethod def notify_article(cls, article): from apps.core.models import NotificationReadLog - title = f"{article.parent_board.ko_name} {article.parent_topic.ko_name} 게시판에 새로운 글이 달렸습니다." + board_topic_str = f" - {article.parent_topic.ko_name}" if article.parent_topic else "" + title = f"[{article.parent_board.ko_name}{board_topic_str}] 새로운 글이 달렸습니다: {article.title[:32]}" + topic = f"board_{article.parent_board_id}" - subscribers = [] + subs_id = FCMTopic.objects.filter(topic=topic).values_list('user', flat=True) + subs = User.objects.filter(id__in=subs_id) NotificationReadLog.objects.bulk_create([NotificationReadLog( - read_by=article.created_by, + read_by=sub, notification=cls.objects.create( type="article_new", title=title, - content=article.title, + content=article.content_text[:32], related_article=article, related_comment=None, ), - ) for sub in subscribers]) + ) for sub in subs]) + fcm_notify_topic( + topic, + title, + article.content_text[:32], + f"post/{article.id}", + ) @classmethod @@ -93,7 +104,7 @@ def notify_article_commented(_parent_article, _comment): related_comment=None, ), ) - fcm_notify_comment( + fcm_notify_user( _parent_article.created_by, title, _comment.content[:32], @@ -112,7 +123,7 @@ def notify_comment_commented(_parent_article, _comment): related_comment=_comment.parent_comment, ), ) - fcm_notify_comment( + fcm_notify_user( _comment.parent_comment.created_by, title, _comment.content[:32], diff --git a/apps/core/models/signals/article.py b/apps/core/models/signals/article.py index 65987d8f..a8f13b21 100644 --- a/apps/core/models/signals/article.py +++ b/apps/core/models/signals/article.py @@ -8,4 +8,3 @@ def comment_post_save_signal(created, instance, **kwargs): if created: Notification.notify_article(instance) - print(created, instance) diff --git a/apps/user/views/fcm.py b/apps/user/views/fcm.py index 2c1549c7..63b49b3d 100644 --- a/apps/user/views/fcm.py +++ b/apps/user/views/fcm.py @@ -6,11 +6,6 @@ from apps.user.models import FCMTopic from ara.firebase import fcm_subscrible, fcm_unsubscrible -# TODO: make model, and apply it -tmp_topic_storage = { - '1': set(['board_13', 'board_17', 'portal_popular', 'article_8148']), -} - class FCMTokenView(APIView): def patch(self, request, mode): token = request.data["token"] @@ -40,22 +35,25 @@ def patch(self, request): topic_put_list: list[str] = request.data.get('put') topic_delete_list: list[str] = request.data.get('delete') - print(topic_put_list, topic_delete_list) + # print(topic_put_list, topic_delete_list) # TODO: santize user topic list to available topics user_id = str(request.user.id) + # tmp_topic_storage = { + # '1': set(['board_13', 'board_17', 'portal_popular', 'article_8148']), + # } # if tmp_topic_storage.get(user_id) == None: # tmp_topic_storage[user_id] = set() # user_topics = tmp_topic_storage[user_id] + # user_topics.update(topic_put_list) + # user_topics.difference_update(topic_delete_list) user_tokens = list(FCMToken.objects.filter(user=request.user).values_list('token', flat=True).distinct()) fcm_subscrible(user_tokens, topic_put_list) for tpc in topic_put_list: FCMTopic.objects.get_or_create(user=request.user, topic=tpc) - # user_topics.update(topic_put_list) fcm_unsubscrible(user_tokens, topic_delete_list) for tpc in topic_delete_list: FCMTopic.objects.filter(user=request.user, topic=tpc).delete() - # user_topics.difference_update(topic_delete_list) return Response(status=status.HTTP_200_OK) diff --git a/ara/firebase.py b/ara/firebase.py index 5e7f4a63..b0009a8b 100644 --- a/ara/firebase.py +++ b/ara/firebase.py @@ -10,23 +10,28 @@ def fcm_unsubscrible(FCM_tokens, subs): for sub in subs: response = messaging.unsubscribe_from_topic(FCM_tokens, sub) -def fcm_notify_topic(topic): - pass +def fcm_notify_topic(topic, title, body, open_url): + return + + try: + fcm_simple(title, body, open_url, topic=topic) + except Exception as e: + print(e) -def fcm_notify_comment(user, title, body, open_url): +def fcm_notify_user(user, title, body, open_url): ################## Disable FCM #################### return targets = FCMToken.objects.filter(user=user) for i in targets: try: - fcm_simple(i.token, title, body, open_url) + fcm_simple(title, body, open_url, token=i.token) except: FCMToken.objects.filter(token=i.token).delete() pass -def fcm_simple(FCM_token, title="Title", body="Body", open_url="/"): +def fcm_simple(title="Title", body="Body", open_url="/", **kwargs): # This registration token comes from the client FCM SDKs. # See documentation on defining a message payload. @@ -52,7 +57,7 @@ def fcm_simple(FCM_token, title="Title", body="Body", open_url="/"): # Maybe bug: fcm_options.link is not working ), data={"action_open_url": open_url}, - token=FCM_token, + **kwargs, ) response = messaging.send(message) From 27700efa8a4c0f82d097985c9d338c440a98485b Mon Sep 17 00:00:00 2001 From: TriangleYJ Date: Mon, 23 Jan 2023 15:39:36 +0000 Subject: [PATCH 06/17] Add article subscription --- apps/core/models/notification.py | 38 +++++++++++++++++++++++++++----- 1 file changed, 32 insertions(+), 6 deletions(-) diff --git a/apps/core/models/notification.py b/apps/core/models/notification.py index f94f505b..e8bc12e6 100644 --- a/apps/core/models/notification.py +++ b/apps/core/models/notification.py @@ -94,8 +94,14 @@ def notify_commented(cls, comment): def notify_article_commented(_parent_article, _comment): title = f"{_comment.created_by.profile.nickname} 님이 새로운 댓글을 작성했습니다." - NotificationReadLog.objects.create( - read_by=_parent_article.created_by, + topic = f"article_comment_{_parent_article.id}" + + subs_id = list(FCMTopic.objects.filter(topic=topic).values_list('user', flat=True)) + subs_id.append(_parent_article.created_by.id) + subs = User.objects.filter(id__in=subs_id) + + NotificationReadLog.objects.bulk_create([NotificationReadLog( + read_by=sub, notification=cls.objects.create( type="article_commented", title=title, @@ -103,18 +109,31 @@ def notify_article_commented(_parent_article, _comment): related_article=_parent_article, related_comment=None, ), - ) + ) for sub in subs]) + fcm_notify_user( _parent_article.created_by, title, _comment.content[:32], f"post/{_parent_article.id}", ) + fcm_notify_topic( + topic, + title, + _comment.content[:32], + f"post/{_parent_article.id}", + ) def notify_comment_commented(_parent_article, _comment): title = f"{_comment.created_by.profile.nickname} 님이 새로운 대댓글을 작성했습니다." - NotificationReadLog.objects.create( - read_by=_comment.parent_comment.created_by, + topic = f"article_comment_{_parent_article.id}" + + subs_id = list(FCMTopic.objects.filter(topic=topic).values_list('user', flat=True)) + subs_id.append(_parent_article.created_by.id) + subs = User.objects.filter(id__in=subs_id) + + NotificationReadLog.objects.bulk_create([NotificationReadLog( + read_by=sub, notification=cls.objects.create( type="comment_commented", title=title, @@ -122,13 +141,20 @@ def notify_comment_commented(_parent_article, _comment): related_article=_parent_article, related_comment=_comment.parent_comment, ), - ) + ) for sub in subs]) + fcm_notify_user( _comment.parent_comment.created_by, title, _comment.content[:32], f"post/{_parent_article.id}", ) + fcm_notify_topic( + topic, + title, + _comment.content[:32], + f"post/{_parent_article.id}", + ) article = ( comment.parent_article From 71c8d88b361934955c0a404731f4b53158edad8b Mon Sep 17 00:00:00 2001 From: TriangleYJ Date: Wed, 25 Jan 2023 13:08:13 +0000 Subject: [PATCH 07/17] Fix notify_comment_commented --- apps/core/models/notification.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/apps/core/models/notification.py b/apps/core/models/notification.py index e8bc12e6..b74b8f77 100644 --- a/apps/core/models/notification.py +++ b/apps/core/models/notification.py @@ -60,6 +60,7 @@ def data(self) -> dict: } # TODO: Support English + # TODO: add test code @classmethod def notify_article(cls, article): from apps.core.models import NotificationReadLog @@ -129,7 +130,7 @@ def notify_comment_commented(_parent_article, _comment): topic = f"article_comment_{_parent_article.id}" subs_id = list(FCMTopic.objects.filter(topic=topic).values_list('user', flat=True)) - subs_id.append(_parent_article.created_by.id) + subs_id.append(_comment.parent_comment.created_by.id) subs = User.objects.filter(id__in=subs_id) NotificationReadLog.objects.bulk_create([NotificationReadLog( From 956e83088fe4c6357aeeb33c2c8a06bfce7e39cc Mon Sep 17 00:00:00 2001 From: TriangleYJ Date: Wed, 8 Feb 2023 14:30:31 +0000 Subject: [PATCH 08/17] Upgrade isort version to resolve precommit error --- .pre-commit-config.yaml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index 9b207684..30e7deef 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -22,8 +22,8 @@ repos: args: [--fix=lf] - id: trailing-whitespace - - repo: https://github.com/compilerla/conventional-pre-commit - rev: v2.4.0 + - repo: https://github.com/PyCQA/isort + rev: 5.11.5 hooks: - id: conventional-pre-commit stages: [commit-msg] From 96df8783e724568ff01ba79824f1cc846d771dc8 Mon Sep 17 00:00:00 2001 From: TriangleYJ Date: Wed, 8 Feb 2023 14:31:02 +0000 Subject: [PATCH 09/17] Change location of Meta, apply precommit hook --- .../0044_alter_notification_type.py | 18 ++- apps/core/models/notification.py | 112 +++++++++++------- apps/core/models/signals/__init__.py | 2 +- apps/core/models/signals/article.py | 1 + apps/user/migrations/0021_fcmtopic.py | 43 +++++-- apps/user/models/fcm_token.py | 6 +- apps/user/models/fcm_topic.py | 16 +-- apps/user/views/fcm.py | 24 ++-- ara/firebase.py | 3 + 9 files changed, 146 insertions(+), 79 deletions(-) diff --git a/apps/core/migrations/0044_alter_notification_type.py b/apps/core/migrations/0044_alter_notification_type.py index 3725a505..13d444fb 100644 --- a/apps/core/migrations/0044_alter_notification_type.py +++ b/apps/core/migrations/0044_alter_notification_type.py @@ -6,13 +6,23 @@ class Migration(migrations.Migration): dependencies = [ - ('core', '0043_board_comment_access_mask'), + ("core", "0043_board_comment_access_mask"), ] operations = [ migrations.AlterField( - model_name='notification', - name='type', - field=models.CharField(choices=[('default', 'default'), ('article_commented', 'article_commented'), ('comment_commented', 'comment_commented'), ('article_new', 'article_new')], default='default', max_length=32, verbose_name='알림 종류'), + model_name="notification", + name="type", + field=models.CharField( + choices=[ + ("default", "default"), + ("article_commented", "article_commented"), + ("comment_commented", "comment_commented"), + ("article_new", "article_new"), + ], + default="default", + max_length=32, + verbose_name="알림 종류", + ), ), ] diff --git a/apps/core/models/notification.py b/apps/core/models/notification.py index b74b8f77..9e1f6601 100644 --- a/apps/core/models/notification.py +++ b/apps/core/models/notification.py @@ -1,16 +1,15 @@ from django.db import models from django.utils.functional import cached_property -from ara.db.models import MetaDataModel -from ara.firebase import fcm_notify_user, fcm_notify_topic -from django.contrib.auth.models import User from apps.user.models import FCMTopic +from ara.db.models import MetaDataModel +from ara.firebase import fcm_notify_topic, fcm_notify_user TYPE_CHOICES = ( ("default", "default"), ("article_commented", "article_commented"), ("comment_commented", "comment_commented"), - ("article_new", "article_new") + ("article_new", "article_new"), ) @@ -58,36 +57,43 @@ def data(self) -> dict: "icon": "", "click_action": "", } - + # TODO: Support English # TODO: add test code @classmethod def notify_article(cls, article): from apps.core.models import NotificationReadLog - board_topic_str = f" - {article.parent_topic.ko_name}" if article.parent_topic else "" + + board_topic_str = ( + f" - {article.parent_topic.ko_name}" if article.parent_topic else "" + ) title = f"[{article.parent_board.ko_name}{board_topic_str}] 새로운 글이 달렸습니다: {article.title[:32]}" topic = f"board_{article.parent_board_id}" - subs_id = FCMTopic.objects.filter(topic=topic).values_list('user', flat=True) + subs_id = FCMTopic.objects.filter(topic=topic).values_list("user", flat=True) subs = User.objects.filter(id__in=subs_id) - NotificationReadLog.objects.bulk_create([NotificationReadLog( - read_by=sub, - notification=cls.objects.create( - type="article_new", - title=title, - content=article.content_text[:32], - related_article=article, - related_comment=None, - ), - ) for sub in subs]) + NotificationReadLog.objects.bulk_create( + [ + NotificationReadLog( + read_by=sub, + notification=cls.objects.create( + type="article_new", + title=title, + content=article.content_text[:32], + related_article=article, + related_comment=None, + ), + ) + for sub in subs + ] + ) fcm_notify_topic( - topic, - title, - article.content_text[:32], - f"post/{article.id}", - ) - + topic, + title, + article.content_text[:32], + f"post/{article.id}", + ) @classmethod def notify_commented(cls, comment): @@ -97,21 +103,28 @@ def notify_article_commented(_parent_article, _comment): title = f"{_comment.created_by.profile.nickname} 님이 새로운 댓글을 작성했습니다." topic = f"article_comment_{_parent_article.id}" - subs_id = list(FCMTopic.objects.filter(topic=topic).values_list('user', flat=True)) + subs_id = list( + FCMTopic.objects.filter(topic=topic).values_list("user", flat=True) + ) subs_id.append(_parent_article.created_by.id) subs = User.objects.filter(id__in=subs_id) - NotificationReadLog.objects.bulk_create([NotificationReadLog( - read_by=sub, - notification=cls.objects.create( - type="article_commented", - title=title, - content=_comment.content[:32], - related_article=_parent_article, - related_comment=None, - ), - ) for sub in subs]) - + NotificationReadLog.objects.bulk_create( + [ + NotificationReadLog( + read_by=sub, + notification=cls.objects.create( + type="article_commented", + title=title, + content=_comment.content[:32], + related_article=_parent_article, + related_comment=None, + ), + ) + for sub in subs + ] + ) + fcm_notify_user( _parent_article.created_by, title, @@ -129,20 +142,27 @@ def notify_comment_commented(_parent_article, _comment): title = f"{_comment.created_by.profile.nickname} 님이 새로운 대댓글을 작성했습니다." topic = f"article_comment_{_parent_article.id}" - subs_id = list(FCMTopic.objects.filter(topic=topic).values_list('user', flat=True)) + subs_id = list( + FCMTopic.objects.filter(topic=topic).values_list("user", flat=True) + ) subs_id.append(_comment.parent_comment.created_by.id) subs = User.objects.filter(id__in=subs_id) - NotificationReadLog.objects.bulk_create([NotificationReadLog( - read_by=sub, - notification=cls.objects.create( - type="comment_commented", - title=title, - content=_comment.content[:32], - related_article=_parent_article, - related_comment=_comment.parent_comment, - ), - ) for sub in subs]) + NotificationReadLog.objects.bulk_create( + [ + NotificationReadLog( + read_by=sub, + notification=cls.objects.create( + type="comment_commented", + title=title, + content=_comment.content[:32], + related_article=_parent_article, + related_comment=_comment.parent_comment, + ), + ) + for sub in subs + ] + ) fcm_notify_user( _comment.parent_comment.created_by, diff --git a/apps/core/models/signals/__init__.py b/apps/core/models/signals/__init__.py index dcc9db88..a25342fe 100644 --- a/apps/core/models/signals/__init__.py +++ b/apps/core/models/signals/__init__.py @@ -1,5 +1,5 @@ +from .article import * from .block import * from .comment import * from .on_delete_cascade import * from .report import * -from .article import * diff --git a/apps/core/models/signals/article.py b/apps/core/models/signals/article.py index a8f13b21..0b35c303 100644 --- a/apps/core/models/signals/article.py +++ b/apps/core/models/signals/article.py @@ -4,6 +4,7 @@ from apps.core.models import Article, Notification + @receiver(models.signals.post_save, sender=Article) def comment_post_save_signal(created, instance, **kwargs): if created: diff --git a/apps/user/migrations/0021_fcmtopic.py b/apps/user/migrations/0021_fcmtopic.py index 6743c3ed..fd2465d6 100644 --- a/apps/user/migrations/0021_fcmtopic.py +++ b/apps/user/migrations/0021_fcmtopic.py @@ -1,30 +1,53 @@ # Generated by Django 3.2.16 on 2023-01-23 07:26 -from django.conf import settings -from django.db import migrations, models import django.db.models.deletion import django.utils.timezone +from django.conf import settings +from django.db import migrations, models class Migration(migrations.Migration): dependencies = [ migrations.swappable_dependency(settings.AUTH_USER_MODEL), - ('user', '0020_after_fcmtoken_is_web'), + ("user", "0020_after_fcmtoken_is_web"), ] operations = [ migrations.CreateModel( - name='FCMTopic', + name="FCMTopic", fields=[ - ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), - ('created_at', models.DateTimeField(db_index=True, default=django.utils.timezone.now, verbose_name='생성 시간')), - ('topic', models.CharField(max_length=200, verbose_name='알림 주제')), - ('user', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='fcm_topic_set', to=settings.AUTH_USER_MODEL, verbose_name='유저')), + ( + "id", + models.AutoField( + auto_created=True, + primary_key=True, + serialize=False, + verbose_name="ID", + ), + ), + ( + "created_at", + models.DateTimeField( + db_index=True, + default=django.utils.timezone.now, + verbose_name="생성 시간", + ), + ), + ("topic", models.CharField(max_length=200, verbose_name="알림 주제")), + ( + "user", + models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, + related_name="fcm_topic_set", + to=settings.AUTH_USER_MODEL, + verbose_name="유저", + ), + ), ], options={ - 'ordering': ('-created_at',), - 'unique_together': {('user', 'topic')}, + "ordering": ("-created_at",), + "unique_together": {("user", "topic")}, }, ), ] diff --git a/apps/user/models/fcm_token.py b/apps/user/models/fcm_token.py index eff5ca23..68784de7 100644 --- a/apps/user/models/fcm_token.py +++ b/apps/user/models/fcm_token.py @@ -4,9 +4,6 @@ class FCMToken(models.Model): - class Meta: - ordering = ("-created_at",) - created_at = models.DateTimeField( default=timezone.now, db_index=True, @@ -35,3 +32,6 @@ class Meta: is_web = models.BooleanField( default=True, db_index=True, verbose_name="토큰 부여 플랫폼이 웹인지 여부" ) + + class Meta: + ordering = ("-created_at",) diff --git a/apps/user/models/fcm_topic.py b/apps/user/models/fcm_topic.py index 64fdef25..5c44fd98 100644 --- a/apps/user/models/fcm_topic.py +++ b/apps/user/models/fcm_topic.py @@ -4,10 +4,6 @@ class FCMTopic(models.Model): - class Meta: - ordering = ("-created_at",) - unique_together = ('user', 'topic',) - created_at = models.DateTimeField( default=timezone.now, db_index=True, @@ -21,7 +17,11 @@ class Meta: verbose_name="유저", ) - topic = models.CharField( - max_length=200, - verbose_name="알림 주제" - ) + topic = models.CharField(max_length=200, verbose_name="알림 주제") + + class Meta: + ordering = ("-created_at",) + unique_together = ( + "user", + "topic", + ) diff --git a/apps/user/views/fcm.py b/apps/user/views/fcm.py index 63b49b3d..2136997e 100644 --- a/apps/user/views/fcm.py +++ b/apps/user/views/fcm.py @@ -2,10 +2,11 @@ from rest_framework import status from rest_framework.response import Response from rest_framework.views import APIView -from apps.user.models import FCMToken -from apps.user.models import FCMTopic + +from apps.user.models import FCMToken, FCMTopic from ara.firebase import fcm_subscrible, fcm_unsubscrible + class FCMTokenView(APIView): def patch(self, request, mode): token = request.data["token"] @@ -19,22 +20,27 @@ def patch(self, request, mode): token.save() return Response(status=status.HTTP_200_OK) + class FCMTopicView(APIView): def get(self, request): # TODO: More better way for authentication guard? if not request.user.is_authenticated: return Response(status=status.HTTP_401_UNAUTHORIZED) - + # user_topics = tmp_topic_storage.get(str(request.user.id)) - user_topics = FCMTopic.objects.filter(user=request.user).values_list('topic', flat=True).distinct() + user_topics = ( + FCMTopic.objects.filter(user=request.user) + .values_list("topic", flat=True) + .distinct() + ) return Response(user_topics) def patch(self, request): if not request.user.is_authenticated: return Response(status=status.HTTP_401_UNAUTHORIZED) - topic_put_list: list[str] = request.data.get('put') - topic_delete_list: list[str] = request.data.get('delete') + topic_put_list: list[str] = request.data.get("put") + topic_delete_list: list[str] = request.data.get("delete") # print(topic_put_list, topic_delete_list) # TODO: santize user topic list to available topics user_id = str(request.user.id) @@ -48,7 +54,11 @@ def patch(self, request): # user_topics.update(topic_put_list) # user_topics.difference_update(topic_delete_list) - user_tokens = list(FCMToken.objects.filter(user=request.user).values_list('token', flat=True).distinct()) + user_tokens = list( + FCMToken.objects.filter(user=request.user) + .values_list("token", flat=True) + .distinct() + ) fcm_subscrible(user_tokens, topic_put_list) for tpc in topic_put_list: FCMTopic.objects.get_or_create(user=request.user, topic=tpc) diff --git a/ara/firebase.py b/ara/firebase.py index b0009a8b..031dc539 100644 --- a/ara/firebase.py +++ b/ara/firebase.py @@ -6,10 +6,12 @@ def fcm_subscrible(FCM_tokens, subs): for sub in subs: response = messaging.subscribe_to_topic(FCM_tokens, sub) + def fcm_unsubscrible(FCM_tokens, subs): for sub in subs: response = messaging.unsubscribe_from_topic(FCM_tokens, sub) + def fcm_notify_topic(topic, title, body, open_url): return @@ -18,6 +20,7 @@ def fcm_notify_topic(topic, title, body, open_url): except Exception as e: print(e) + def fcm_notify_user(user, title, body, open_url): ################## Disable FCM #################### return From 4f0831a14a8aff29ffbdb3d866f159c57f9a551c Mon Sep 17 00:00:00 2001 From: TriangleYJ Date: Wed, 8 Feb 2023 15:39:01 +0000 Subject: [PATCH 10/17] Apply Review --- apps/user/views/fcm.py | 32 ++++++++------------------------ ara/firebase.py | 1 - 2 files changed, 8 insertions(+), 25 deletions(-) diff --git a/apps/user/views/fcm.py b/apps/user/views/fcm.py index 2136997e..ae48d476 100644 --- a/apps/user/views/fcm.py +++ b/apps/user/views/fcm.py @@ -1,33 +1,30 @@ -from django.db.models.functions import Now +from django.utils import timezone from rest_framework import status from rest_framework.response import Response from rest_framework.views import APIView from apps.user.models import FCMToken, FCMTopic from ara.firebase import fcm_subscrible, fcm_unsubscrible - +from rest_framework.permissions import IsAuthenticated +import typing class FCMTokenView(APIView): def patch(self, request, mode): token = request.data["token"] if mode == "delete": FCMToken.objects.filter(token=token).delete() - pass elif mode == "update": if not request.user.is_authenticated: return Response(status=status.HTTP_401_UNAUTHORIZED) - token = FCMToken(token=token, user=request.user, last_activated_at=Now()) + token = FCMToken(token=token, user=request.user, last_activated_at=timezone.now()) token.save() return Response(status=status.HTTP_200_OK) class FCMTopicView(APIView): - def get(self, request): - # TODO: More better way for authentication guard? - if not request.user.is_authenticated: - return Response(status=status.HTTP_401_UNAUTHORIZED) + permission_classes = [IsAuthenticated] - # user_topics = tmp_topic_storage.get(str(request.user.id)) + def get(self, request): user_topics = ( FCMTopic.objects.filter(user=request.user) .values_list("topic", flat=True) @@ -36,24 +33,11 @@ def get(self, request): return Response(user_topics) def patch(self, request): - if not request.user.is_authenticated: - return Response(status=status.HTTP_401_UNAUTHORIZED) - - topic_put_list: list[str] = request.data.get("put") - topic_delete_list: list[str] = request.data.get("delete") - # print(topic_put_list, topic_delete_list) + topic_put_list: typing.List[str] = request.data.get("put") + topic_delete_list: typing.List[str] = request.data.get("delete") # TODO: santize user topic list to available topics user_id = str(request.user.id) - # tmp_topic_storage = { - # '1': set(['board_13', 'board_17', 'portal_popular', 'article_8148']), - # } - # if tmp_topic_storage.get(user_id) == None: - # tmp_topic_storage[user_id] = set() - # user_topics = tmp_topic_storage[user_id] - # user_topics.update(topic_put_list) - # user_topics.difference_update(topic_delete_list) - user_tokens = list( FCMToken.objects.filter(user=request.user) .values_list("token", flat=True) diff --git a/ara/firebase.py b/ara/firebase.py index 031dc539..0bb57ef0 100644 --- a/ara/firebase.py +++ b/ara/firebase.py @@ -31,7 +31,6 @@ def fcm_notify_user(user, title, body, open_url): fcm_simple(title, body, open_url, token=i.token) except: FCMToken.objects.filter(token=i.token).delete() - pass def fcm_simple(title="Title", body="Body", open_url="/", **kwargs): From 830b1d53ea29db2f0818858ff8a42289028aff0b Mon Sep 17 00:00:00 2001 From: retroinspect Date: Tue, 6 Feb 2024 13:34:10 +0000 Subject: [PATCH 11/17] refactor: use seperated FCM class for mocking --- .pre-commit-config.yaml | 4 +-- apps/core/models/notification.py | 7 +++-- ara/fcm.py | 50 ++++++++++++++++++++++++++++++++ ara/firebase.py | 33 +++++++++------------ 4 files changed, 70 insertions(+), 24 deletions(-) create mode 100644 ara/fcm.py diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index 30e7deef..9b207684 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -22,8 +22,8 @@ repos: args: [--fix=lf] - id: trailing-whitespace - - repo: https://github.com/PyCQA/isort - rev: 5.11.5 + - repo: https://github.com/compilerla/conventional-pre-commit + rev: v2.4.0 hooks: - id: conventional-pre-commit stages: [commit-msg] diff --git a/apps/core/models/notification.py b/apps/core/models/notification.py index 9e1f6601..3f4d9e7e 100644 --- a/apps/core/models/notification.py +++ b/apps/core/models/notification.py @@ -1,3 +1,4 @@ +from django.contrib.auth import get_user_model from django.db import models from django.utils.functional import cached_property @@ -5,6 +6,8 @@ from ara.db.models import MetaDataModel from ara.firebase import fcm_notify_topic, fcm_notify_user +User = get_user_model() + TYPE_CHOICES = ( ("default", "default"), ("article_commented", "article_commented"), @@ -101,7 +104,7 @@ def notify_commented(cls, comment): def notify_article_commented(_parent_article, _comment): title = f"{_comment.created_by.profile.nickname} 님이 새로운 댓글을 작성했습니다." - topic = f"article_comment_{_parent_article.id}" + topic = f"article_commented_{_parent_article.id}" subs_id = list( FCMTopic.objects.filter(topic=topic).values_list("user", flat=True) @@ -140,7 +143,7 @@ def notify_article_commented(_parent_article, _comment): def notify_comment_commented(_parent_article, _comment): title = f"{_comment.created_by.profile.nickname} 님이 새로운 대댓글을 작성했습니다." - topic = f"article_comment_{_parent_article.id}" + topic = f"comment_commented_{_comment.id}" subs_id = list( FCMTopic.objects.filter(topic=topic).values_list("user", flat=True) diff --git a/ara/fcm.py b/ara/fcm.py new file mode 100644 index 00000000..b5e70f91 --- /dev/null +++ b/ara/fcm.py @@ -0,0 +1,50 @@ +""" +Every function calling messaging API should be mocked +to prevent test run uses API limit +""" +from firebase_admin import messaging + + +class FCM: + Message = messaging.Message + Notification = messaging.Notification + WebpushNotificationAction = messaging.WebpushNotificationAction + WebpushConfig = messaging.WebpushConfig + WebpushNotification = messaging.WebpushNotification + + def subscribe_to_topic(self, tokens, sub): + print("real: subscribe") + messaging.subscribe_to_topic(tokens, sub) + + def unsubscribe_from_topic(self, tokens, sub): + print("real: unsubscribe") + messaging.unsubscribe_from_topic(tokens, sub) + + def send(self, message): + print("real: send") + messaging.send(message) + + +class MockFCM(FCM): + def __init__(self): + self.journal = [] + + def subscribe_to_topic(self, tokens, sub): + print("mock: subscribe") + self.journal.append({"name": "subscribe_to_topic", "params": [tokens, sub]}) + + def unsubscribe_from_topic(self, tokens, sub): + print("mock: unsubscribe") + self.journal.append({"name": "unsubscribe_from_topic", "params": [tokens, sub]}) + + def send(self, message): + print("mock: send") + self.journal.append({"name": "send", "params": [message]}) + + def call_count(self, name): + return sum([item["name"] == name for item in self.journal]) + + +import sys + +fcm = FCM() if "pytest" not in sys.modules else MockFCM() diff --git a/ara/firebase.py b/ara/firebase.py index 0bb57ef0..39ecd3a8 100644 --- a/ara/firebase.py +++ b/ara/firebase.py @@ -1,20 +1,18 @@ -from firebase_admin import messaging - from apps.user.models import FCMToken +from ara.fcm import fcm + -def fcm_subscrible(FCM_tokens, subs): +def fcm_subscrible(FCM_tokens, subs): # TODO: fix typo for sub in subs: - response = messaging.subscribe_to_topic(FCM_tokens, sub) + response = fcm.subscribe_to_topic(FCM_tokens, sub) -def fcm_unsubscrible(FCM_tokens, subs): +def fcm_unsubscrible(FCM_tokens, subs): # TODO: fix typo for sub in subs: - response = messaging.unsubscribe_from_topic(FCM_tokens, sub) + response = fcm.unsubscribe_from_topic(FCM_tokens, sub) def fcm_notify_topic(topic, title, body, open_url): - return - try: fcm_simple(title, body, open_url, topic=topic) except Exception as e: @@ -22,9 +20,6 @@ def fcm_notify_topic(topic, title, body, open_url): def fcm_notify_user(user, title, body, open_url): - ################## Disable FCM #################### - return - targets = FCMToken.objects.filter(user=user) for i in targets: try: @@ -37,23 +32,21 @@ def fcm_simple(title="Title", body="Body", open_url="/", **kwargs): # This registration token comes from the client FCM SDKs. # See documentation on defining a message payload. - ################## Disable FCM #################### - return - message = messaging.Message( - notification=messaging.Notification( + message = fcm.Message( + notification=fcm.Notification( title=title, body=body, ), - webpush=messaging.WebpushConfig( - notification=messaging.WebpushNotification( + webpush=fcm.WebpushConfig( + notification=fcm.WebpushNotification( title=title, body=body, tag=open_url, renotify=True, icon="/img/icons/ara-pwa-192.png", actions=[ - messaging.WebpushNotificationAction("action_open", "Open"), - messaging.WebpushNotificationAction("action_close", "Close"), + fcm.WebpushNotificationAction("action_open", "Open"), + fcm.WebpushNotificationAction("action_close", "Close"), ], ), # Maybe bug: fcm_options.link is not working @@ -62,5 +55,5 @@ def fcm_simple(title="Title", body="Body", open_url="/", **kwargs): **kwargs, ) - response = messaging.send(message) + response = fcm.send(message) # Response is a message ID string. From fe33119b6755e9e9274ae0298250b43ef4825d63 Mon Sep 17 00:00:00 2001 From: retroinspect Date: Tue, 6 Feb 2024 13:35:27 +0000 Subject: [PATCH 12/17] refactor: merge migrations --- ...ype.py => 0057_alter_notification_type.py} | 5 ++-- .../migrations/0059_merge_20240206_2234.py | 12 ++++++++++ .../core/migrations/0060_alter_article_url.py | 24 +++++++++++++++++++ .../{0021_fcmtopic.py => 0022_fcmtopic.py} | 5 ++-- 4 files changed, 40 insertions(+), 6 deletions(-) rename apps/core/migrations/{0044_alter_notification_type.py => 0057_alter_notification_type.py} (83%) create mode 100644 apps/core/migrations/0059_merge_20240206_2234.py create mode 100644 apps/core/migrations/0060_alter_article_url.py rename apps/user/migrations/{0021_fcmtopic.py => 0022_fcmtopic.py} (93%) diff --git a/apps/core/migrations/0044_alter_notification_type.py b/apps/core/migrations/0057_alter_notification_type.py similarity index 83% rename from apps/core/migrations/0044_alter_notification_type.py rename to apps/core/migrations/0057_alter_notification_type.py index 13d444fb..d49e404d 100644 --- a/apps/core/migrations/0044_alter_notification_type.py +++ b/apps/core/migrations/0057_alter_notification_type.py @@ -1,12 +1,11 @@ -# Generated by Django 3.2.16 on 2023-01-23 07:06 +# Generated by Django 4.2.5 on 2023-11-02 15:18 from django.db import migrations, models class Migration(migrations.Migration): - dependencies = [ - ("core", "0043_board_comment_access_mask"), + ("core", "0056_alter_article_comment_count_alter_article_hit_count_and_more"), ] operations = [ diff --git a/apps/core/migrations/0059_merge_20240206_2234.py b/apps/core/migrations/0059_merge_20240206_2234.py new file mode 100644 index 00000000..9a8b7f82 --- /dev/null +++ b/apps/core/migrations/0059_merge_20240206_2234.py @@ -0,0 +1,12 @@ +# Generated by Django 4.2.9 on 2024-02-06 13:34 + +from django.db import migrations + + +class Migration(migrations.Migration): + dependencies = [ + ("core", "0057_alter_notification_type"), + ("core", "0058_merge_20240109_2331"), + ] + + operations = [] diff --git a/apps/core/migrations/0060_alter_article_url.py b/apps/core/migrations/0060_alter_article_url.py new file mode 100644 index 00000000..3da79a79 --- /dev/null +++ b/apps/core/migrations/0060_alter_article_url.py @@ -0,0 +1,24 @@ +# Generated by Django 4.2.9 on 2024-02-06 13:34 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + dependencies = [ + ("core", "0059_merge_20240206_2234"), + ] + + operations = [ + migrations.AlterField( + model_name="article", + name="url", + field=models.URLField( + blank=True, + db_index=True, + default=None, + max_length=256, + null=True, + verbose_name="포탈 링크", + ), + ), + ] diff --git a/apps/user/migrations/0021_fcmtopic.py b/apps/user/migrations/0022_fcmtopic.py similarity index 93% rename from apps/user/migrations/0021_fcmtopic.py rename to apps/user/migrations/0022_fcmtopic.py index fd2465d6..590f1a5b 100644 --- a/apps/user/migrations/0021_fcmtopic.py +++ b/apps/user/migrations/0022_fcmtopic.py @@ -1,4 +1,4 @@ -# Generated by Django 3.2.16 on 2023-01-23 07:26 +# Generated by Django 4.2.5 on 2023-11-02 15:18 import django.db.models.deletion import django.utils.timezone @@ -7,10 +7,9 @@ class Migration(migrations.Migration): - dependencies = [ migrations.swappable_dependency(settings.AUTH_USER_MODEL), - ("user", "0020_after_fcmtoken_is_web"), + ("user", "0021_remove_userprofile_extra_preferences"), ] operations = [ From 371f4af613a8b0b2c459da589059639523481b3a Mon Sep 17 00:00:00 2001 From: retroinspect Date: Tue, 6 Feb 2024 13:38:18 +0000 Subject: [PATCH 13/17] refactor: fix typo --- apps/user/views/fcm.py | 12 ++++++++---- 1 file changed, 8 insertions(+), 4 deletions(-) diff --git a/apps/user/views/fcm.py b/apps/user/views/fcm.py index ae48d476..47cc7537 100644 --- a/apps/user/views/fcm.py +++ b/apps/user/views/fcm.py @@ -1,12 +1,14 @@ +import typing + from django.utils import timezone from rest_framework import status +from rest_framework.permissions import IsAuthenticated from rest_framework.response import Response from rest_framework.views import APIView from apps.user.models import FCMToken, FCMTopic from ara.firebase import fcm_subscrible, fcm_unsubscrible -from rest_framework.permissions import IsAuthenticated -import typing + class FCMTokenView(APIView): def patch(self, request, mode): @@ -16,7 +18,9 @@ def patch(self, request, mode): elif mode == "update": if not request.user.is_authenticated: return Response(status=status.HTTP_401_UNAUTHORIZED) - token = FCMToken(token=token, user=request.user, last_activated_at=timezone.now()) + token = FCMToken( + token=token, user=request.user, last_activated_at=timezone.now() + ) token.save() return Response(status=status.HTTP_200_OK) @@ -35,7 +39,7 @@ def get(self, request): def patch(self, request): topic_put_list: typing.List[str] = request.data.get("put") topic_delete_list: typing.List[str] = request.data.get("delete") - # TODO: santize user topic list to available topics + # TODO: sanitize user topic list to available topics user_id = str(request.user.id) user_tokens = list( From 434b0699e7bcc1077601374f3e1a1d57fc28cb21 Mon Sep 17 00:00:00 2001 From: retroinspect Date: Tue, 6 Feb 2024 13:38:37 +0000 Subject: [PATCH 14/17] refactor: fix url for test --- apps/user/urls.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/apps/user/urls.py b/apps/user/urls.py index e458e82c..7028a020 100644 --- a/apps/user/urls.py +++ b/apps/user/urls.py @@ -7,6 +7,6 @@ urlpatterns = [ path("api/", include(router.urls)), path("api/me", MeView.as_view(), name="me"), - path("api/fcm/token/", FCMTokenView.as_view(), name="fcm_token"), - path("api/fcm/topic", FCMTopicView.as_view(), name="fcm_topic"), + path("api/fcm/token//", FCMTokenView.as_view(), name="fcm_token"), + path("api/fcm/topic/", FCMTopicView.as_view(), name="fcm_topic"), ] From 157bf387c98e1a0ae1ba28b191253420f911b0a6 Mon Sep 17 00:00:00 2001 From: retroinspect Date: Tue, 6 Feb 2024 13:56:48 +0000 Subject: [PATCH 15/17] test(fcm): add initial tests --- tests/conftest.py | 126 ++++++++++++++++++++++++++++++++++- tests/test_articles.py | 69 ------------------- tests/test_fcm.py | 148 +++++++++++++++++++++++++++++++++++++++++ 3 files changed, 273 insertions(+), 70 deletions(-) create mode 100644 tests/test_fcm.py diff --git a/tests/conftest.py b/tests/conftest.py index b5fa62db..f2fa7386 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -8,7 +8,8 @@ from django.utils import timezone from rest_framework.test import APIClient -from apps.user.models import UserProfile +from apps.core.models import Article, Board, Topic, UserProfile +from apps.core.models.board import NameType from ara import redis User = get_user_model() @@ -173,6 +174,129 @@ def set_school_admin(request): request.cls.api_client = APIClient() +@pytest.fixture(scope="class") +def set_boards(request): + request.cls.board = Board.objects.create( + slug="test board", + ko_name="테스트 게시판", + en_name="Test Board", + name_type=NameType.REGULAR, + ) + + request.cls.anon_board = Board.objects.create( + slug="anonymous", + ko_name="익명 게시판", + en_name="Anonymous", + name_type=NameType.ANONYMOUS, + ) + + request.cls.free_board = Board.objects.create( + slug="free", + ko_name="자유 게시판", + en_name="Free", + name_type=NameType.ANONYMOUS | NameType.REGULAR, + ) + + request.cls.realname_board = Board.objects.create( + slug="test realname board", + ko_name="테스트 실명 게시판", + en_name="Test realname Board", + name_type=NameType.REALNAME, + ) + + request.cls.regular_access_board = Board.objects.create( + slug="regular access", + ko_name="일반 접근 권한 게시판", + en_name="Regular Access Board", + read_access_mask=0b11011110, + write_access_mask=0b11011010, + ) + + # Though its name is 'advertiser accessible', enterprise is also accessible + request.cls.advertiser_accessible_board = Board.objects.create( + slug="advertiser accessible", + ko_name="외부인(홍보 계정) 접근 가능 게시판", + en_name="Advertiser Accessible Board", + read_access_mask=0b11111110, + write_access_mask=0b11111110, + ) + + request.cls.nonwritable_board = Board.objects.create( + slug="nonwritable", + ko_name="글 작성 불가 게시판", + en_name="Nonwritable Board", + write_access_mask=0b00000000, + ) + + request.cls.newsadmin_writable_board = Board.objects.create( + slug="newsadmin writable", + ko_name="뉴스게시판 관리인 글 작성 가능 게시판", + en_name="Newsadmin Writable Board", + write_access_mask=0b10000000, + ) + + request.cls.enterprise_writable_board = Board.objects.create( + slug="enterprise writable", + ko_name="입주업체 글 작성 가능 게시판", + en_name="Enterprise Writable Board", + write_access_mask=0b11011110, + ) + + +@pytest.fixture(scope="class") +def set_topics(request): + """set_board 먼저 적용""" + request.cls.topic = Topic.objects.create( + slug="test topic", + ko_name="테스트 토픽", + en_name="Test Topic", + parent_board=request.cls.board, + ) + + request.cls.realname_topic = Topic.objects.create( + slug="test realname topic", + ko_name="테스트 실명 토픽", + en_name="Test realname Topic", + parent_board=request.cls.realname_board, + ) + + +@pytest.fixture(scope="class") +def set_articles(request): + """set_board 먼저 적용""" + request.cls.article = Article.objects.create( + title="example article", + content="example content", + content_text="example content text", + name_type=NameType.REGULAR, + is_content_sexual=False, + is_content_social=False, + hit_count=0, + positive_vote_count=0, + negative_vote_count=0, + created_by=request.cls.user, + parent_topic=request.cls.topic, + parent_board=request.cls.board, + commented_at=timezone.now(), + ) + + request.cls.regular_access_article = Article.objects.create( + title="regular access article", + content="regular access article content", + content_text="regular access article content", + created_by=request.cls.user, + parent_board=request.cls.regular_access_board, + ) + + request.cls.advertiser_accessible_article = Article.objects.create( + title="advertiser readable article", + content="advertiser readable article content", + content_text="advertiser readable article content", + created_by=request.cls.user, + parent_board=request.cls.advertiser_accessible_board, + ) + + class RequestSetting: def http_request(self, user, method, path, data=None, querystring="", **kwargs): self.api_client.force_authenticate(user=user) diff --git a/tests/test_articles.py b/tests/test_articles.py index f522df3a..f92a87f8 100644 --- a/tests/test_articles.py +++ b/tests/test_articles.py @@ -10,75 +10,6 @@ from tests.conftest import RequestSetting, TestCase, Utils -@pytest.fixture(scope="class") -def set_boards(request): - request.cls.board = Board.objects.create( - slug="test board", - ko_name="테스트 게시판", - en_name="Test Board", - name_type=NameType.REGULAR, - ) - - request.cls.anon_board = Board.objects.create( - slug="anonymous", - ko_name="익명 게시판", - en_name="Anonymous", - name_type=NameType.ANONYMOUS, - ) - - request.cls.free_board = Board.objects.create( - slug="free", - ko_name="자유 게시판", - en_name="Free", - name_type=NameType.ANONYMOUS | NameType.REGULAR, - ) - - request.cls.realname_board = Board.objects.create( - slug="test realname board", - ko_name="테스트 실명 게시판", - en_name="Test realname Board", - name_type=NameType.REALNAME, - ) - - request.cls.regular_access_board = Board.objects.create( - slug="regular access", - ko_name="일반 접근 권한 게시판", - en_name="Regular Access Board", - read_access_mask=0b11011110, - write_access_mask=0b11011010, - ) - - # Though its name is 'advertiser accessible', enterprise is also accessible - request.cls.advertiser_accessible_board = Board.objects.create( - slug="advertiser accessible", - ko_name="외부인(홍보 계정) 접근 가능 게시판", - en_name="Advertiser Accessible Board", - read_access_mask=0b11111110, - write_access_mask=0b11111110, - ) - - request.cls.nonwritable_board = Board.objects.create( - slug="nonwritable", - ko_name="글 작성 불가 게시판", - en_name="Nonwritable Board", - write_access_mask=0b00000000, - ) - - request.cls.newsadmin_writable_board = Board.objects.create( - slug="newsadmin writable", - ko_name="뉴스게시판 관리인 글 작성 가능 게시판", - en_name="Newsadmin Writable Board", - write_access_mask=0b10000000, - ) - - request.cls.enterprise_writable_board = Board.objects.create( - slug="enterprise writable", - ko_name="입주업체 글 작성 가능 게시판", - en_name="Enterprise Writable Board", - write_access_mask=0b11011110, - ) - - @pytest.fixture(scope="class") def set_topics(request): """set_board 먼저 적용""" diff --git a/tests/test_fcm.py b/tests/test_fcm.py new file mode 100644 index 00000000..11b3de68 --- /dev/null +++ b/tests/test_fcm.py @@ -0,0 +1,148 @@ +import pytest +from django.utils import timezone +from rest_framework import status + +from apps.core.models.board import NameType +from apps.user.models.fcm_token import FCMToken +from ara.fcm import fcm +from tests.conftest import RequestSetting, TestCase, Utils + + +@pytest.fixture(scope="class") +def set_user_fcm_token(request): + request.cls.token = FCMToken.objects.create( + token="fcm_token_test", user=request.cls.user, last_activated_at=timezone.now() + ) + + +@pytest.mark.usefixtures( + "set_user_client", + "set_user_fcm_token", + "set_boards", + "set_topics", + "set_articles", +) +class TestFCM(TestCase, RequestSetting): + def test_token_update(self): + user = Utils.create_user( + username="no_token_user", + email="no_token_user@sparcs.org", + nickname="no_token_user", + ) + filtered_tokens = FCMToken.objects.filter(user=user) + assert len(filtered_tokens) == 0 + + res = self.http_request(user, "patch", "fcm/token/update", {"token": "token"}) + assert res.status_code == status.HTTP_200_OK + filtered_tokens = FCMToken.objects.filter(user=user) + assert len(filtered_tokens) == 1 + assert filtered_tokens[0].token == "token" + + res = self.http_request(user, "patch", "fcm/token/update", {"token": "token2"}) + assert res.status_code == status.HTTP_200_OK + filtered_tokens = FCMToken.objects.filter(user=user) + assert len(filtered_tokens) == 2 + assert filtered_tokens[0].token == "token2" + + def test_token_delete_none(self): + user = Utils.create_user( + username="no_token_user", + email="no_token_user@sparcs.org", + nickname="no_token_user", + ) + filtered_tokens = FCMToken.objects.filter(user=user) + assert len(filtered_tokens) == 0 + + res = self.http_request( + user, "patch", "fcm/token/delete", {"token": "none_token"} + ) + assert res.status_code == status.HTTP_200_OK + filtered_tokens = FCMToken.objects.filter(user=user) + assert len(filtered_tokens) == 0 + + def test_token_delete(self): + user = Utils.create_user( + username="no_token_user", + email="no_token_user@sparcs.org", + nickname="no_token_user", + ) + filtered_tokens = FCMToken.objects.filter(user=user) + assert len(filtered_tokens) == 0 + + res = self.http_request(user, "patch", "fcm/token/update", {"token": "token"}) + assert res.status_code == status.HTTP_200_OK + filtered_tokens = FCMToken.objects.filter(user=user) + assert len(filtered_tokens) == 1 + assert filtered_tokens[0].token == "token" + + res = self.http_request(user, "patch", "fcm/token/delete", {"token": "token"}) + assert res.status_code == status.HTTP_200_OK + filtered_tokens = FCMToken.objects.filter(user=user) + assert len(filtered_tokens) == 0 + + def test_topic_subscribe(self): + res = self.http_request( + self.user, "patch", "fcm/topic", {"put": ["topic1", "topic2"], "delete": []} + ) + assert res.status_code == status.HTTP_200_OK + assert fcm.call_count("subscribe_to_topic") == 2 + res = self.http_request(self.user, "get", "fcm/topic") + assert res.status_code == status.HTTP_200_OK + assert len(res.data) == 2 + + def test_topic_unsubscribe(self): + res = self.http_request( + self.user, "patch", "fcm/topic", {"put": ["topic1", "topic2"], "delete": []} + ) + + res = self.http_request( + self.user, + "patch", + "fcm/topic", + {"put": ["topic3"], "delete": ["topic1", "unsubscribed_topic"]}, + ) + + assert res.status_code == status.HTTP_200_OK + assert fcm.call_count("unsubscribe_from_topic") == 2 + + res = self.http_request(self.user, "get", "fcm/topic") + assert res.status_code == status.HTTP_200_OK + assert len(res.data) == 2 + + def test_board_notification(self): + """ + 게시판에 새로운 글이 올라오면 구독자들에게 웹 푸시 알림이 발송된다 + topic: board_{board.id} + """ + topic = f"board_{self.board.id}" + user_data = { + "title": "article for test_create", + "content": "content for test_create", + "content_text": "content_text for test_create", + "name_type": NameType.REGULAR.name, + "is_content_sexual": False, + "is_content_social": False, + "parent_topic": self.topic.id, + "parent_board": self.board.id, + } + + print(fcm.journal) + res = self.http_request(self.user, "post", "articles", user_data) + assert res.status_code == status.HTTP_201_CREATED + assert fcm.call_count("send") == 1 + + def test_post_notification(self): + """ + 게시글 내 새로운 댓글이 올라오면 구독자들에게 웹 푸시 알림이 발송된다 + topic: article_comment_{article.id} + """ + + pass + + def test_reply(self): + """ + 댓글에 대댓글이 달리는 경우 구독자들에게 웹 푸시 알림이 발송된다 + topic: comment_commented_{comment.id} + """ + + pass From 4dab773aabf3a3996254ea50985a1df461a16f89 Mon Sep 17 00:00:00 2001 From: retroinspect Date: Tue, 13 Feb 2024 12:52:02 +0000 Subject: [PATCH 16/17] test(fcm): add more util functions for mock fcm --- ara/fcm.py | 72 ++++++++++++++++++++++++++++++++++++++++--------- ara/firebase.py | 35 ++---------------------- 2 files changed, 62 insertions(+), 45 deletions(-) diff --git a/ara/fcm.py b/ara/fcm.py index b5e70f91..7fe4c568 100644 --- a/ara/fcm.py +++ b/ara/fcm.py @@ -6,12 +6,6 @@ class FCM: - Message = messaging.Message - Notification = messaging.Notification - WebpushNotificationAction = messaging.WebpushNotificationAction - WebpushConfig = messaging.WebpushConfig - WebpushNotification = messaging.WebpushNotification - def subscribe_to_topic(self, tokens, sub): print("real: subscribe") messaging.subscribe_to_topic(tokens, sub) @@ -20,26 +14,80 @@ def unsubscribe_from_topic(self, tokens, sub): print("real: unsubscribe") messaging.unsubscribe_from_topic(tokens, sub) - def send(self, message): + def send(self, title="Title", body="Body", open_url="/", **kwargs): print("real: send") - messaging.send(message) + # This registration token comes from the client FCM SDKs. + # See documentation on defining a message payload. + + message = fcm.Message( + notification=fcm.Notification( + title=title, + body=body, + ), + webpush=fcm.WebpushConfig( + notification=fcm.WebpushNotification( + title=title, + body=body, + tag=open_url, + renotify=True, + icon="/img/icons/ara-pwa-192.png", + actions=[ + fcm.WebpushNotificationAction("action_open", "Open"), + fcm.WebpushNotificationAction("action_close", "Close"), + ], + ), + # Maybe bug: fcm_options.link is not working + ), + data={"action_open_url": open_url}, + **kwargs, + ) + + response = messaging.send(message) + # Response is a message ID string. + + return response + + +from collections import defaultdict class MockFCM(FCM): def __init__(self): self.journal = [] + def get_params_dict(self, **kwargs): + args = defaultdict(lambda: None) + args.update(kwargs) + return args + def subscribe_to_topic(self, tokens, sub): print("mock: subscribe") - self.journal.append({"name": "subscribe_to_topic", "params": [tokens, sub]}) + self.journal.append( + { + "name": "subscribe_to_topic", + "params": self.get_params_dict(tokens=tokens, sub=sub), + } + ) def unsubscribe_from_topic(self, tokens, sub): print("mock: unsubscribe") - self.journal.append({"name": "unsubscribe_from_topic", "params": [tokens, sub]}) + self.journal.append( + { + "name": "unsubscribe_from_topic", + "params": self.get_params_dict(tokens=tokens, sub=sub), + } + ) - def send(self, message): + def send(self, title="Title", body="Body", open_url="/", **kwargs): print("mock: send") - self.journal.append({"name": "send", "params": [message]}) + self.journal.append( + { + "name": "send", + "params": self.get_params_dict( + title=title, body=body, open_url=open_url, **kwargs + ), + } + ) def call_count(self, name): return sum([item["name"] == name for item in self.journal]) diff --git a/ara/firebase.py b/ara/firebase.py index 39ecd3a8..0eec68fc 100644 --- a/ara/firebase.py +++ b/ara/firebase.py @@ -14,7 +14,7 @@ def fcm_unsubscrible(FCM_tokens, subs): # TODO: fix typo def fcm_notify_topic(topic, title, body, open_url): try: - fcm_simple(title, body, open_url, topic=topic) + fcm.send(title, body, open_url, topic=topic) except Exception as e: print(e) @@ -23,37 +23,6 @@ def fcm_notify_user(user, title, body, open_url): targets = FCMToken.objects.filter(user=user) for i in targets: try: - fcm_simple(title, body, open_url, token=i.token) + fcm.send(title, body, open_url, token=i.token) except: FCMToken.objects.filter(token=i.token).delete() - - -def fcm_simple(title="Title", body="Body", open_url="/", **kwargs): - # This registration token comes from the client FCM SDKs. - # See documentation on defining a message payload. - - message = fcm.Message( - notification=fcm.Notification( - title=title, - body=body, - ), - webpush=fcm.WebpushConfig( - notification=fcm.WebpushNotification( - title=title, - body=body, - tag=open_url, - renotify=True, - icon="/img/icons/ara-pwa-192.png", - actions=[ - fcm.WebpushNotificationAction("action_open", "Open"), - fcm.WebpushNotificationAction("action_close", "Close"), - ], - ), - # Maybe bug: fcm_options.link is not working - ), - data={"action_open_url": open_url}, - **kwargs, - ) - - response = fcm.send(message) - # Response is a message ID string. From 4a3a06f25aadb37ac5f4ac51b6cbecc39764d92f Mon Sep 17 00:00:00 2001 From: retroinspect Date: Tue, 13 Feb 2024 12:53:16 +0000 Subject: [PATCH 17/17] fix(fcm): add more tests and fix topic for comment_commented --- apps/core/models/notification.py | 2 +- tests/test_fcm.py | 74 ++++++++++++++++++++++++++++---- 2 files changed, 67 insertions(+), 9 deletions(-) diff --git a/apps/core/models/notification.py b/apps/core/models/notification.py index 3f4d9e7e..1e5aa532 100644 --- a/apps/core/models/notification.py +++ b/apps/core/models/notification.py @@ -143,7 +143,7 @@ def notify_article_commented(_parent_article, _comment): def notify_comment_commented(_parent_article, _comment): title = f"{_comment.created_by.profile.nickname} 님이 새로운 대댓글을 작성했습니다." - topic = f"comment_commented_{_comment.id}" + topic = f"comment_commented_{_comment.parent_comment.id}" subs_id = list( FCMTopic.objects.filter(topic=topic).values_list("user", flat=True) diff --git a/tests/test_fcm.py b/tests/test_fcm.py index 11b3de68..d735d893 100644 --- a/tests/test_fcm.py +++ b/tests/test_fcm.py @@ -1,8 +1,11 @@ import pytest +from django.db.models import signals from django.utils import timezone from rest_framework import status +from apps.core.models import Comment from apps.core.models.board import NameType +from apps.core.models.signals.comment import comment_post_save_signal from apps.user.models.fcm_token import FCMToken from ara.fcm import fcm from tests.conftest import RequestSetting, TestCase, Utils @@ -17,6 +20,8 @@ def set_user_fcm_token(request): @pytest.mark.usefixtures( "set_user_client", + "set_user_client2", + "set_user_client3", "set_user_fcm_token", "set_boards", "set_topics", @@ -112,9 +117,9 @@ def test_topic_unsubscribe(self): def test_board_notification(self): """ 게시판에 새로운 글이 올라오면 구독자들에게 웹 푸시 알림이 발송된다 - topic: board_{board.id} + fcm_topic: board_{board.id} """ - topic = f"board_{self.board.id}" + fcm_topic = f"board_{self.board.id}" user_data = { "title": "article for test_create", "content": "content for test_create", @@ -126,23 +131,76 @@ def test_board_notification(self): "parent_board": self.board.id, } - print(fcm.journal) + prev_send_count = fcm.call_count("send") res = self.http_request(self.user, "post", "articles", user_data) assert res.status_code == status.HTTP_201_CREATED - assert fcm.call_count("send") == 1 + cur_send_count = fcm.call_count("send") + assert prev_send_count + 1 == cur_send_count + assert fcm.journal[-1]["name"] == "send" + assert fcm.journal[-1]["params"]["topic"] == fcm_topic def test_post_notification(self): """ 게시글 내 새로운 댓글이 올라오면 구독자들에게 웹 푸시 알림이 발송된다 - topic: article_comment_{article.id} + fcm_topic: article_commented_{article.id} """ + fcm_topic = f"article_commented_{self.article.id}" + + prev_send_count = fcm.call_count("send") + comment = Comment.objects.create( + content="this is a test comment", + name_type=NameType.REGULAR, + created_by=self.user2, + parent_article=self.article, + ) + + cur_send_count = fcm.call_count("send") + assert prev_send_count + 2 == cur_send_count + + def is_sent_to_article_author(entry): + return ( + entry["name"] == "send" and entry["params"]["token"] == self.token.token + ) - pass + def is_sent_to_subscribers(entry): + return entry["name"] == "send" and entry["params"]["topic"] == fcm_topic + + assert any([is_sent_to_article_author(entry) for entry in fcm.journal[-2:]]) + assert any([is_sent_to_subscribers(entry) for entry in fcm.journal[-2:]]) def test_reply(self): """ 댓글에 대댓글이 달리는 경우 구독자들에게 웹 푸시 알림이 발송된다 - topic: comment_commented_{comment.id} + fcm_topic: comment_commented_{comment.id} """ + comment1 = Comment.objects.create( + content="this is a test comment", + name_type=NameType.REGULAR, + created_by=self.user, + parent_article=self.article, + ) + + fcm_topic = f"comment_commented_{comment1.id}" + + prev_send_count = fcm.call_count("send") + comment2 = Comment.objects.create( + content="this is a test reply", + name_type=NameType.REGULAR, + created_by=self.user2, + parent_comment=comment1, + ) + + cur_send_count = fcm.call_count("send") + # send to 4: article author, article subscriber, comment1 author, comment1 subscriber + assert prev_send_count + 4 == cur_send_count + + def is_sent_to_article_author(entry): + return ( + entry["name"] == "send" and entry["params"]["token"] == self.token.token + ) + + def is_sent_to_subscribers(entry): + return entry["name"] == "send" and entry["params"]["topic"] == fcm_topic - pass + assert any([is_sent_to_article_author(entry) for entry in fcm.journal[-2:]]) + assert any([is_sent_to_subscribers(entry) for entry in fcm.journal[-2:]])