diff --git a/openbook/helpers.py b/openbook/helpers.py deleted file mode 100644 index dc0cd3848ddf1c51c377a275acfc2766fde83837..0000000000000000000000000000000000000000 --- a/openbook/helpers.py +++ /dev/null @@ -1,111 +0,0 @@ -import structlog - -from openbook_auth.models import User -from openbook_common.pubsub.publisher import publish_event -from openbook_communities.events.events import ( - SpaceCreatedEvent, - SpaceUpdatedEvent, - SpaceDeletedEvent, - SpaceUserAddedEvent, - SpaceUserLeftEvent, - SpaceUserRemovedEvent, - UserDeletedEvent, - UserNameUpdatedEvent, - UserEmailUpdatedEvent, -) -from openbook_communities.events.payloads import SpacePayload, UserPayload -from openbook_communities.events.topic import Topic as PubSubTopic -from openbook_communities.models import Community - -logger = structlog.getLogger(__name__) - - -def publish_space_created_event(community: Community): - try: - creator = UserPayload.from_user(community.creator) - space = SpacePayload.from_community(community) - event = SpaceCreatedEvent(creator, space) - publish_event(event=event, topic=PubSubTopic.OKUNA.value) - except Exception as e: - logger.error(__generate_publish_failure_message(SpaceCreatedEvent), str(e)) - - -def publish_space_updated_event(community: Community): - try: - creator = UserPayload.from_user(community.creator) - space = SpacePayload.from_community(community) - event = SpaceUpdatedEvent(creator, space) - publish_event(event=event, topic=PubSubTopic.OKUNA.value) - except Exception as e: - logger.error(__generate_publish_failure_message(SpaceUpdatedEvent), str(e)) - - -def publish_space_user_added_event(username: str, community: Community): - try: - space_member: User = User.objects.get(username=username) - user = UserPayload.from_user(space_member) - space = SpacePayload.from_community(community) - event = SpaceUserAddedEvent(user, space) - publish_event(event=event, topic=PubSubTopic.OKUNA.value) - except Exception as e: - logger.error(__generate_publish_failure_message(SpaceUserAddedEvent), str(e)) - - -def publish_user_name_updated_event(user: User): - try: - updated_user = UserPayload.from_user(user) - event = UserNameUpdatedEvent(updated_user) - publish_event(event=event, topic=PubSubTopic.OKUNA.value) - except Exception as e: - logger.error(__generate_publish_failure_message(UserNameUpdatedEvent), str(e)) - - -def publish_user_email_updated_event(user: User): - try: - updated_user = UserPayload.from_user(user) - event = UserEmailUpdatedEvent(updated_user) - publish_event(event=event, topic=PubSubTopic.OKUNA.value) - except Exception as e: - logger.error(__generate_publish_failure_message(UserEmailUpdatedEvent), str(e)) - - -def publish_space_deleted_event(community: Community): - try: - space = SpacePayload.from_community(community) - event = SpaceDeletedEvent(space) - publish_event(event=event, topic=PubSubTopic.OKUNA.value) - except Exception as e: - logger.error(__generate_publish_failure_message(SpaceDeletedEvent), str(e)) - - -def publish_user_deleted_event(user: User): - try: - deleted_user = UserPayload.from_user(user) - event = UserDeletedEvent(deleted_user) - publish_event(event=event, topic=PubSubTopic.OKUNA.value) - except Exception as e: - logger.error(__generate_publish_failure_message(UserDeletedEvent), str(e)) - - -def publish_space_user_left_event(user: User, community: Community): - try: - user = UserPayload.from_user(user) - space = SpacePayload.from_community(community) - event = SpaceUserLeftEvent(user, space) - publish_event(event=event, topic=PubSubTopic.OKUNA.value) - except Exception as e: - logger.error(__generate_publish_failure_message(SpaceUserLeftEvent), str(e)) - - -def publish_space_user_removed_event(user: User, community: Community): - try: - user = UserPayload.from_user(user) - space = SpacePayload.from_community(community) - event = SpaceUserRemovedEvent(user, space) - publish_event(event=event, topic=PubSubTopic.OKUNA.value) - except Exception as e: - logger.error(__generate_publish_failure_message(SpaceUserRemovedEvent), str(e)) - - -def __generate_publish_failure_message(cls): - return f"Failed to publish {cls.__name__} event: %s" diff --git a/openbook/settings/__init__.py b/openbook/settings/__init__.py index c74cddd24e3504bd168c66646a26008ddb93799d..bd847c47fab6977619802824e0121100e5eef786 100644 --- a/openbook/settings/__init__.py +++ b/openbook/settings/__init__.py @@ -491,6 +491,7 @@ CONTENT_IMAGE_ALT_TEXT_MAX_LENGTH = 128 TITLE_MAX_LENGTH = 128 USERNAME_MAX_LENGTH = 36 IDENTITY_MAX_LENGTH = 255 +USER_EMAIL_MAX_LENGTH = 254 IMAGE_SIZE_10_MB = 10485760 IMAGE_SIZE_08_MB = 8388608 FILE_UPLOAD_MAX_MEMORY_SIZE = IMAGE_SIZE_10_MB @@ -519,9 +520,9 @@ PROFILE_AVATAR_URL_MAX_LENGTH = 500 PROFILE_COVER_MAX_SIZE = int(os.environ.get("PROFILE_COVER_MAX_SIZE", "10485760")) PROFILE_COVER_URL_MAX_LENGTH = 500 PASSWORD_RESET_TIMEOUT_DAYS = 1 -COMMUNITY_NAME_MAX_LENGTH = 60 +COMMUNITY_NAME_MAX_LENGTH = 80 COMMUNITY_TITLE_MIN_LENGTH = 2 -COMMUNITY_TITLE_MAX_LENGTH = 60 +COMMUNITY_TITLE_MAX_LENGTH = 80 COMMUNITY_LOCATION_MAX_LENGTH = 192 COMMUNITY_DESCRIPTION_MAX_LENGTH = 600 COMMUNITY_USER_ADJECTIVE_MAX_LENGTH = 16 diff --git a/openbook/tests/test_events.py b/openbook/tests/test_events.py new file mode 100644 index 0000000000000000000000000000000000000000..e69de29bb2d1d6434b8b29ae775ad8c2e48c5391 diff --git a/openbook_auth/admin.py b/openbook_auth/admin.py index 9eedb4f71ea33100dad5796e67dbd8492a9c91e3..5d18083d3e98c5de0811db84c5328677f5b7861f 100644 --- a/openbook_auth/admin.py +++ b/openbook_auth/admin.py @@ -3,7 +3,7 @@ import traceback from django.contrib import admin from rangefilter.filters import DateRangeQuickSelectListFilterBuilder -from openbook.helpers import publish_user_name_updated_event +from openbook_auth.events import publish_user_name_updated_event from openbook_auth.forms import UserProfileFormSet from openbook_auth.models import User, UserProfile import structlog diff --git a/openbook_auth/events.py b/openbook_auth/events.py new file mode 100644 index 0000000000000000000000000000000000000000..67e2ff71389656f124bb29870f9712371255de0a --- /dev/null +++ b/openbook_auth/events.py @@ -0,0 +1,129 @@ +from enum import Enum +from typing import Optional + +import structlog +from openbook_auth.models import User +from openbook_common.pubsub.event import Event +from openbook_common.pubsub.topic import Topic +from openbook_common.pubsub.publisher import publish_event + +logger = structlog.getLogger(__name__) + + +class UserPayload: + def __init__( + self, + id: str, + name: str, + email: str, + identity: str, + avatar: Optional[str] = None, + about_me: Optional[str] = None, + location: Optional[str] = None, + ): + self.id = id + self.name = name + self.email = email + self.identity = identity + self.avatar = avatar + self.about_me = about_me + self.location = location + + def to_dict(self): + return { + "id": self.id, + "name": self.name, + "email": self.email, + "identity": self.identity, + "avatar": self.avatar, + "aboutMe": self.about_me, + "location": self.location, + } + + @classmethod + def from_user(cls, user: User): + avatar_url = user.profile.avatar.url if user.profile.avatar else None + return cls( + id=str(user.username), + name=user.profile.full_name(), + email=user.email, + identity=user.identity, + avatar=avatar_url, + about_me=user.profile.about_me, + location=user.profile.location, + ) + + +class UserEventType(Enum): + USER_NAME_UPDATED = "UserNameUpdated" + USER_EMAIL_UPDATED = "UserEmailUpdated" + USER_UPDATED = "UserUpdated" + USER_DELETED = "UserDeleted" + + +class UserUpdatedEvent(Event): + EVENT_VERSION = "1.1.0" + + def __init__(self, user: UserPayload): + super().__init__( + data={"user": user.to_dict()}, + event_type=UserEventType.USER_UPDATED.value, + event_version=UserUpdatedEvent.EVENT_VERSION, + ) + + +class UserNameUpdatedEvent(Event): + EVENT_VERSION = "1.1.0" + + def __init__(self, user: UserPayload): + super().__init__( + data={"user": user.to_dict()}, + event_type=UserEventType.USER_NAME_UPDATED.value, + event_version=UserNameUpdatedEvent.EVENT_VERSION, + ) + + +class UserEmailUpdatedEvent(Event): + EVENT_VERSION = "1.1.0" + + def __init__(self, user: UserPayload): + super().__init__( + data={"user": user.to_dict()}, + event_type=UserEventType.USER_EMAIL_UPDATED.value, + event_version=UserEmailUpdatedEvent.EVENT_VERSION, + ) + + +class UserDeletedEvent(Event): + EVENT_VERSION = "1.1.0" + + def __init__(self, user: UserPayload): + super().__init__( + data={"user": user.to_dict()}, + event_type=UserEventType.USER_DELETED.value, + event_version=UserDeletedEvent.EVENT_VERSION, + ) + + +def publish_user_deleted_event(user: User): + deleted_user = UserPayload.from_user(user) + event = UserDeletedEvent(deleted_user) + publish_event(event=event, topic=Topic.OKUNA.value) + + +def publish_user_name_updated_event(user: User): + updated_user = UserPayload.from_user(user) + event = UserNameUpdatedEvent(updated_user) + publish_event(event=event, topic=Topic.OKUNA.value) + + +def publish_user_updated_event(user: User): + updated_user = UserPayload.from_user(user) + event = UserUpdatedEvent(updated_user) + publish_event(event=event, topic=Topic.OKUNA.value) + + +def publish_user_email_updated_event(user: User): + updated_user = UserPayload.from_user(user) + event = UserEmailUpdatedEvent(updated_user) + publish_event(event=event, topic=Topic.OKUNA.value) diff --git a/openbook_auth/migrations/0033_alter_user_email.py b/openbook_auth/migrations/0033_alter_user_email.py new file mode 100644 index 0000000000000000000000000000000000000000..a43b6a0aaf3bc63d0fbb1f7db74d27df615e2de9 --- /dev/null +++ b/openbook_auth/migrations/0033_alter_user_email.py @@ -0,0 +1,18 @@ +# Generated by Django 5.0.8 on 2024-12-13 10:34 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ("openbook_auth", "0032_alter_user_role"), + ] + + operations = [ + migrations.AlterField( + model_name="user", + name="email", + field=models.CharField(max_length=254, unique=True, verbose_name="email address"), + ), + ] diff --git a/openbook_auth/models.py b/openbook_auth/models.py index 54c9227efd5f3206e6e96eee1464d90cc0ea1090..38584889cd3d2f379c20bd5be89f490a1a9c9eda 100644 --- a/openbook_auth/models.py +++ b/openbook_auth/models.py @@ -196,7 +196,14 @@ class User(ModelWithUUID, AbstractUser): related_name="translation_users", ) - email = models.EmailField(_("email address"), unique=True, null=False, blank=False) + email = models.CharField( + _("email address"), + max_length=settings.USER_EMAIL_MAX_LENGTH, + unique=True, + null=False, + blank=False, + ) + connections_circle = models.ForeignKey( "openbook_circles.Circle", on_delete=models.CASCADE, diff --git a/openbook_auth/schema/mutations.py b/openbook_auth/schema/mutations.py index f15c0de13b24c6c39722e6de9ffaca815cf66b8b..024c217eb5cde308ae475da17f32aa1d5c30b056 100644 --- a/openbook_auth/schema/mutations.py +++ b/openbook_auth/schema/mutations.py @@ -1,6 +1,7 @@ # ruff: noqa import uuid from copy import deepcopy +from datetime import datetime, timedelta import requests import strawberry @@ -8,15 +9,17 @@ import structlog from asgiref.sync import sync_to_async from django.conf import settings from django.core.cache import cache +from django.db.models import When, Value, Count, Case, IntegerField from django.utils.translation import get_language_from_request from graphql import GraphQLError from rest_framework.exceptions import ValidationError from openbook_common.tracking import track, TrackingEvent -from openbook.helpers import ( - publish_space_deleted_event, +from openbook_communities.events import publish_space_deleted_event +from openbook_auth.events import ( publish_user_deleted_event, publish_user_name_updated_event, + publish_user_updated_event, ) from openbook.utils import invalidate_discover_spaces_query_cache from openbook_auth.models import User as UserModel @@ -30,9 +33,12 @@ from openbook_auth.utils import verify_authorized_user from openbook.graphql_errors import GraphQLErrorCodes from openbook_common.helpers import is_parameter_null, to_input_dict from openbook_common.schema.types import Result +from openbook_common.utils.model_loaders import get_insight_model, get_community_model, get_topic_model +from openbook_communities.schema.helpers import create_filtered_spaces_query from openbook_notifications.api import NotificationApi from openbook_notifications.enums import NotificationProvider from openbook_devices.serializers import RegisterDeviceSerializer +from openbook_notifications.services import NotificationsService logger = structlog.get_logger(__name__) @@ -49,6 +55,9 @@ class Mutation: def update_authenticated_user_v2(self, info, input: UpdateAuthenticatedUserInput) -> AuthenticatedUser: current_user = verify_authorized_user(info) + user_entity = UserModel.objects.get(username=current_user.username) + user_current_name = user_entity.profile.name + input_dict = to_input_dict(input) if input.first_name: input_dict["name"] = input.first_name @@ -88,9 +97,57 @@ class Mutation: update_ory_user_names(current_user.id) publish_user_name_updated_event(user=current_user) + publish_user_updated_event(user=current_user) + locale = get_language_from_request(info.context.request) NotificationApi.create_or_update_subscriber(user=current_user, locale=locale) + user_updated_name = current_user.profile.name + + # send welcome email only for newly created users + # include recommended spaces and latest insights in the welcome email + if user_current_name is None and user_updated_name is not None: + community_model = get_community_model() + insight_model = get_insight_model() + topic_model = get_topic_model() + + whens = [] + topic_ids = list(current_user.profile.interests.values_list("id", flat=True).all()) + geolocation = current_user.geolocation + + if topic_ids: + whens.append( + When(create_filtered_spaces_query(topic_ids=topic_ids, geolocation=geolocation), then=Value(10)) + ) + + if geolocation: + whens.append(When(create_filtered_spaces_query(geolocation=geolocation), then=Value(20))) + + default_topics = list( + topic_model.objects.filter(is_discover_spaces_default=True).values_list("id", flat=True) + ) + if default_topics: + whens.append(When(create_filtered_spaces_query(topic_ids=default_topics), then=Value(30))) + + spaces = ( + community_model.objects.prefetch_related("links", "memberships__user__profile") + .annotate(member_count=Count("memberships")) + .annotate( + custom_order=Case( + *whens, + output_field=IntegerField(), + ) + ) + .filter(custom_order__isnull=False, is_deleted=False) + .distinct() + .order_by("custom_order", "-created")[:4] + ) + + insights = insight_model.objects.order_by("-date_published")[:3] + NotificationsService.send_welcome_email_series_notification( + user=current_user, spaces=spaces, insights=insights + ) + track(current_user, TrackingEvent("userUpdated")) cache.set(f"{settings.USER_CACHE_PREFIX}{current_user.username}", current_user, timeout=300) diff --git a/openbook_auth/tests/test_events.py b/openbook_auth/tests/test_events.py new file mode 100644 index 0000000000000000000000000000000000000000..6d7470d850d31247a5c2b5dfa3958663abc8131d --- /dev/null +++ b/openbook_auth/tests/test_events.py @@ -0,0 +1,72 @@ +import json +from typing import Any + +import pytest + +from openbook_auth.events import UserNameUpdatedEvent, UserPayload, UserDeletedEvent, UserEmailUpdatedEvent +from openbook_auth.models import User +from openbook_common.tests.helpers import make_user + + +# noinspection DuplicatedCode +def expected_user_payload(user: User) -> dict[str, Any]: + return { + "id": str(user.username), + "name": user.profile.full_name(), + "email": user.email, + "identity": user.identity, + "avatar": user.profile.avatar.url if user.profile.avatar else None, + "aboutMe": user.profile.about_me, + "location": user.profile.location, + } + + +@pytest.mark.django_db +def test_serialization_user_name_updated_event(): + # GIVEN + user = make_user() + + # WHEN + event = UserNameUpdatedEvent(UserPayload.from_user(user)) + + # THEN + expected_event_dict = { + "eventType": "UserNameUpdated", + "eventVersion": "1.1.0", + "data": (json.dumps({"user": expected_user_payload(user)}).encode("utf-8")), + } + assert event.to_dict() == expected_event_dict + + +@pytest.mark.django_db +def test_serialization_user_email_updated_event(): + # GIVEN + user = make_user() + + # WHEN + event = UserEmailUpdatedEvent(UserPayload.from_user(user)) + + # THEN + expected_event_dict = { + "eventType": "UserEmailUpdated", + "eventVersion": "1.1.0", + "data": (json.dumps({"user": expected_user_payload(user)}).encode("utf-8")), + } + assert event.to_dict() == expected_event_dict + + +@pytest.mark.django_db +def test_serialization_user_deleted_event(): + # GIVEN + user = make_user() + + # WHEN + event = UserDeletedEvent(UserPayload.from_user(user)) + + # THEN + expected_event_dict = { + "eventType": "UserDeleted", + "eventVersion": "1.1.0", + "data": (json.dumps({"user": expected_user_payload(user)}).encode("utf-8")), + } + assert event.to_dict() == expected_event_dict diff --git a/openbook_auth/tests/test_graphql.py b/openbook_auth/tests/test_graphql.py index 59405613e367330d1e1f998058095acc12909f11..4b3b40c9a524c72809d84e7e3fd164d50bac297f 100644 --- a/openbook_auth/tests/test_graphql.py +++ b/openbook_auth/tests/test_graphql.py @@ -3,6 +3,7 @@ import json import logging import sys +import uuid from datetime import datetime, timedelta from unittest import mock from unittest.mock import call @@ -138,6 +139,84 @@ class TestUsers: assert authenticated_user["isEmployee"] == user.is_employee assert authenticated_user["connectionStatusToMyself"] is None + @pytest.mark.asyncio + @mock.patch("django.utils.translation.get_language_from_request", return_value="en") + @mock.patch("openbook_notifications.api.NotificationApi.create_or_update_subscriber") + async def test_creates_user_if_not_exists( + self, mock_create_or_update_subscriber, mock_get_language_from_request, client + ): + username = uuid.uuid4() + email = "test@example.com" + ory_user = benedict({"username": username, "email": email}) + headers = await sync_to_async(make_authentication_headers_for_user)(ory_user) + endpoint = reverse("graphql-endpoint") + graphql_query = """ + query { + authenticatedUserV2 { + id + } + } + """ + + # Ensure user does not exist yet + result = await sync_to_async(User.objects.filter)(username=username) + assert await sync_to_async(result.exists)() is False + + response = await client.post( + path=endpoint, + data=json.dumps({"query": graphql_query}), + content_type="application/json", + **headers, + ) + + assert response.status_code == status.HTTP_200_OK + + authenticated_user = json.loads(response.content)["data"]["authenticatedUserV2"] + assert authenticated_user["id"] == str(username) + + # Ensure user was created + user = await sync_to_async(User.objects.get)(username=username) + assert user.email == email + + @pytest.mark.asyncio + @mock.patch("django.utils.translation.get_language_from_request", return_value="en") + @mock.patch("openbook_notifications.api.NotificationApi.create_or_update_subscriber") + async def test_creates_user_with_invalid_email( + self, mock_create_or_update_subscriber, mock_get_language_from_request, client + ): + username = uuid.uuid4() + email = "invalid address" + ory_user = benedict({"username": username, "email": email}) + headers = await sync_to_async(make_authentication_headers_for_user)(ory_user) + endpoint = reverse("graphql-endpoint") + graphql_query = """ + query { + authenticatedUserV2 { + id + } + } + """ + + # Ensure user does not exist yet + result = await sync_to_async(User.objects.filter)(username=username) + assert await sync_to_async(result.exists)() is False + + response = await client.post( + path=endpoint, + data=json.dumps({"query": graphql_query}), + content_type="application/json", + **headers, + ) + + assert response.status_code == status.HTTP_200_OK + + authenticated_user = json.loads(response.content)["data"]["authenticatedUserV2"] + assert authenticated_user["id"] == str(username) + + # Ensure user was created + user = await sync_to_async(User.objects.get)(username=username) + assert user.email == email + def test_get_user_by_name_returns_only_matching_users_without_logged_in_user(self): user = make_user(name="Peter", last_name="Moser") user2 = make_user(name="Peter", last_name="Muster") @@ -248,6 +327,7 @@ class TestUsers: # TODO HOLI-8088 remove test once update_authenticated_user is removed @mock.patch("openbook_auth.tasks.requests.patch") @mock.patch("openbook_auth.schema.mutations.publish_user_name_updated_event") + @mock.patch("openbook_auth.schema.mutations.publish_user_updated_event") @mock.patch("django.utils.translation.get_language_from_request", return_value="en") @mock.patch("openbook_notifications.api.NotificationApi.create_or_update_subscriber") @mock.patch("openbook_auth.schema.mutations.track") @@ -257,10 +337,11 @@ class TestUsers: mock_create_or_update_subscriber, mock_get_language_from_request, mock_publish_user_name_updated_event, - mock_patch, + mock_publish_user_updated_event, + mock_ory_patch, ): - response = self.create_response(200) - mock_patch.return_value = response + mock_ory_patch.return_value = self.create_response(200) + user = make_user(name="Fritz", last_name="Meier", identity="fritz") community = make_community() community.add_member(user) @@ -272,12 +353,6 @@ class TestUsers: last_name = "Shaw" engagement_level = EngagementLevelChoice.INITIATOR - json_body = [ - {"op": "add", "path": "/traits/given_name", "value": f"{first_name}"}, - {"op": "add", "path": "/traits/family_name", "value": f"{last_name}"}, - {"op": "add", "path": "/traits/name", "value": f"{first_name} {last_name}"}, - ] - request = HttpRequest() request.COOKIES = {} request.user = user @@ -309,7 +384,12 @@ class TestUsers: assert executed.errors is None - mock_patch.assert_called_once_with( + json_body = [ + {"op": "add", "path": "/traits/given_name", "value": f"{first_name}"}, + {"op": "add", "path": "/traits/family_name", "value": f"{last_name}"}, + {"op": "add", "path": "/traits/name", "value": f"{first_name} {last_name}"}, + ] + mock_ory_patch.assert_called_once_with( f"{settings.ORY_BASE_URL}/admin/identities/{user.username}", headers={"Authorization": f"Bearer {settings.ORY_ACCESS_TOKEN}"}, json=json_body, @@ -330,6 +410,7 @@ class TestUsers: assert geos.GEOSGeometry(json.dumps(geolocation_geometry)) == persisted_profile.geolocation mock_publish_user_name_updated_event.assert_called_once_with(user=user) + mock_publish_user_updated_event.assert_called_once_with(user=user) mock_create_or_update_subscriber.assert_called_once_with( user=user, locale=mock_get_language_from_request.return_value ) @@ -337,6 +418,7 @@ class TestUsers: @mock.patch("openbook_auth.tasks.requests.patch") @mock.patch("openbook_auth.schema.mutations.publish_user_name_updated_event") + @mock.patch("openbook_auth.schema.mutations.publish_user_updated_event") @mock.patch("django.utils.translation.get_language_from_request", return_value="en") @mock.patch("openbook_notifications.api.NotificationApi.create_or_update_subscriber") @mock.patch("openbook_auth.schema.mutations.track") @@ -346,6 +428,7 @@ class TestUsers: mock_create_or_update_subscriber, mock_get_language_from_request, mock_publish_user_name_updated_event, + mock_publish_user_updated_event, mock_patch, ): response = self.create_response(200) @@ -428,6 +511,7 @@ class TestUsers: assert geos.GEOSGeometry(json.dumps(geolocation_geometry)) == persisted_user.profile.geolocation mock_publish_user_name_updated_event.assert_called_once_with(user=user) + mock_publish_user_updated_event.assert_called_once_with(user=user) mock_create_or_update_subscriber.assert_called_once_with( user=user, locale=mock_get_language_from_request.return_value ) diff --git a/openbook_common/management/commands/generate_dummy_data.py b/openbook_common/management/commands/generate_dummy_data.py index feb26b4e19f24ebea2b77da779caed186a4ec8f3..8e72bcfa92171418a2001a50251efa1cef70936e 100644 --- a/openbook_common/management/commands/generate_dummy_data.py +++ b/openbook_common/management/commands/generate_dummy_data.py @@ -15,7 +15,7 @@ from faker import Faker from feed_posts.enums import VisibilityLevel from feed_posts.models import FeedPost from openbook import settings -from openbook.helpers import publish_user_name_updated_event +from openbook_auth.events import publish_user_name_updated_event from openbook_auth.models import User from openbook_auth.tasks import generate_identity_when_needed from openbook_common.tests.helpers import ( diff --git a/openbook_common/pubsub/__init__.py b/openbook_common/pubsub/__init__.py new file mode 100644 index 0000000000000000000000000000000000000000..e69de29bb2d1d6434b8b29ae775ad8c2e48c5391 diff --git a/openbook_common/pubsub/publisher.py b/openbook_common/pubsub/publisher.py index 16e96e99f37503176040e9e8fd6710023a252217..295a6b19ba3a1d296cc6a9d153520799a8573c86 100644 --- a/openbook_common/pubsub/publisher.py +++ b/openbook_common/pubsub/publisher.py @@ -3,6 +3,8 @@ from google.auth.credentials import AnonymousCredentials from openbook import settings import structlog +from google.cloud.pubsub_v1.types import PublisherOptions + from openbook_common.pubsub.event import Event from openbook_common.pubsub.wrappers import skip_test_events @@ -10,21 +12,38 @@ logger = structlog.getLogger(__name__) GOOGLE_CLOUD_PROJECT_ID = settings.GOOGLE_CLOUD_PROJECT_ID ENVIRONMENT_ID = settings.ENVIRONMENT_ID +PUBLISH_TIMEOUT_SECONDS = 15 + +# subscribers can choose to consume messages in order. using a single ordering key because it gives the highest +# guarantees, and we currently don't have scale issues that would justify relaxing guarantees. +ORDERING_KEY = "okuna" @skip_test_events def publish_event(topic: str, event: Event) -> None: + """ + Publishes an event to Google Cloud Pub/Sub. Guaranteed to not throw. + Awaits the result of the publish operation and logs any errors. + """ try: + publisher_options = PublisherOptions(enable_message_ordering=True) publisher = ( - pubsub_v1.PublisherClient(credentials=AnonymousCredentials()) + pubsub_v1.PublisherClient(credentials=AnonymousCredentials(), publisher_options=publisher_options) if ENVIRONMENT_ID == "local" - else pubsub_v1.PublisherClient() + else pubsub_v1.PublisherClient(publisher_options=publisher_options) ) topic_path = publisher.topic_path(GOOGLE_CLOUD_PROJECT_ID, topic) - publisher.publish(topic_path, **event.to_dict()) - logger.info(f"Event published {event.to_dict()}") - except Exception as e: - logger.error( - f"Failed to publish event to topic {topic}. Make sure that the Google Cloud Pub/Sub API is enabled for your project and that the GOOGLE_APPLICATION_CREDENTIALS environment variable is set correctly.", - str(e), - ) + future = publisher.publish(topic_path, ordering_key=ORDERING_KEY, **event.to_dict()) + + try: + future.result(PUBLISH_TIMEOUT_SECONDS) + except Exception as publish_error: + logger.error(_publish_error_msg(event, publish_error)) + + except Exception as setup_error: + # Handle general errors in the publishing setup or process + logger.error(_publish_error_msg(event, setup_error)) + + +def _publish_error_msg(event: Event, e: Exception) -> str: + return f"Failed to publish {type(event).__name__} event. Ensure Google Cloud Pub/Sub is enabled and GOOGLE_APPLICATION_CREDENTIALS is set correctly. Exception: {str(e)}" diff --git a/openbook_communities/events/topic.py b/openbook_common/pubsub/topic.py similarity index 100% rename from openbook_communities/events/topic.py rename to openbook_common/pubsub/topic.py diff --git a/openbook_communities/admin.py b/openbook_communities/admin.py index 35e4c49005c684a6c21872bb39cb5a16fe442ea0..d7f20295c7950ca72c1f4ae0db3f54aad84caf52 100644 --- a/openbook_communities/admin.py +++ b/openbook_communities/admin.py @@ -1,6 +1,10 @@ from django.contrib import admin -from openbook.helpers import publish_space_created_event, publish_space_user_added_event, publish_space_updated_event +from openbook_communities.events import ( + publish_space_created_event, + publish_space_updated_event, + publish_space_user_added_event, +) from openbook_communities.models import ( CollaborationTool, Community, diff --git a/openbook_communities/events.py b/openbook_communities/events.py new file mode 100644 index 0000000000000000000000000000000000000000..bcb34e8218c257c9b97939dffb063432b02c23ab --- /dev/null +++ b/openbook_communities/events.py @@ -0,0 +1,187 @@ +from enum import Enum +from typing import Optional + +import structlog +from django.contrib.gis.geos.point import Point + +from openbook_auth.events import UserPayload +from openbook_auth.models import User +from openbook_common.pubsub.event import Event +from openbook_common.pubsub.publisher import publish_event +from openbook_common.pubsub.topic import Topic as PubSubTopic +from openbook_communities.models import Community + +logger = structlog.getLogger(__name__) + + +class SpacePayload: + def __init__( + self, + id: str, + name: str, + slug: str, + avatar: str, + description: Optional[str] = None, + location: Optional[str] = None, + location_lat_lng: Optional[Point] = None, + ): + self.id = id + self.name = name + self.avatar = avatar + self.slug = slug + self.description = description + self.location = location + self.location_lat_lng = location_lat_lng + + def to_dict(self): + return { + "id": self.id, + "name": self.name, + "slug": self.slug, + "avatar": self.avatar, + "description": self.description, + "location": self.location, + "locationLatLng": ( + { + "latitude": self.location_lat_lng.x, + "longitude": self.location_lat_lng.y, + } + if self.location_lat_lng + else None + ), + } + + @classmethod + def from_community(cls, community: Community): + if community.avatar: + avatarUrl = community.avatar.url + else: + avatarUrl = None + + return cls( + id=str(community.id), + name=community.title, + slug=community.name, + avatar=avatarUrl, + description=community.description, + location=community.location, + location_lat_lng=community.geolocation, + ) + + +class SpaceUserRemovedEvent(Event): + EVENT_VERSION = "1.1.0" + + def __init__(self, user: UserPayload, space: SpacePayload): + super().__init__( + data={"user": user.to_dict(), "space": space.to_dict()}, + event_type=SpaceEventType.SPACE_USER_REMOVED.value, + event_version=SpaceUserRemovedEvent.EVENT_VERSION, + ) + + +class SpaceUserLeftEvent(Event): + EVENT_VERSION = "1.1.0" + + def __init__(self, user: UserPayload, space: SpacePayload): + super().__init__( + data={"user": user.to_dict(), "space": space.to_dict()}, + event_type=SpaceEventType.SPACE_USER_LEFT.value, + event_version=SpaceUserLeftEvent.EVENT_VERSION, + ) + + +class SpaceDeletedEvent(Event): + EVENT_VERSION = "1.1.0" + + def __init__(self, space: SpacePayload): + super().__init__( + data={"space": space.to_dict()}, + event_type=SpaceEventType.SPACE_DELETED.value, + event_version=SpaceDeletedEvent.EVENT_VERSION, + ) + + +class SpaceUserAddedEvent(Event): + EVENT_VERSION = "1.1.0" + + def __init__(self, user: UserPayload, space: SpacePayload): + super().__init__( + data={"user": user.to_dict(), "space": space.to_dict()}, + event_type=SpaceEventType.SPACE_USER_ADDED.value, + event_version=SpaceUserAddedEvent.EVENT_VERSION, + ) + + +class SpaceUpdatedEvent(Event): + EVENT_VERSION = "1.1.0" + + def __init__(self, creator: UserPayload, space: SpacePayload): + super().__init__( + data={"creator": creator.to_dict(), "space": space.to_dict()}, + event_type=SpaceEventType.SPACE_UPDATED.value, + event_version=SpaceUpdatedEvent.EVENT_VERSION, + ) + + +class SpaceCreatedEvent(Event): + EVENT_VERSION = "1.1.0" + + def __init__(self, creator: UserPayload, space: SpacePayload): + super().__init__( + data={"creator": creator.to_dict(), "space": space.to_dict()}, + event_type=SpaceEventType.SPACE_CREATED.value, + event_version=SpaceCreatedEvent.EVENT_VERSION, + ) + + +class SpaceEventType(Enum): + SPACE_CREATED = "SpaceCreated" + SPACE_UPDATED = "SpaceUpdated" + SPACE_DELETED = "SpaceDeleted" + + SPACE_USER_ADDED = "SpaceUserAdded" + SPACE_USER_LEFT = "SpaceUserLeft" + SPACE_USER_REMOVED = "SpaceUserRemoved" + + +def publish_space_created_event(community: Community): + creator = UserPayload.from_user(community.creator) + space = SpacePayload.from_community(community) + event = SpaceCreatedEvent(creator, space) + publish_event(event=event, topic=PubSubTopic.OKUNA.value) + + +def publish_space_updated_event(community: Community): + creator = UserPayload.from_user(community.creator) + space = SpacePayload.from_community(community) + event = SpaceUpdatedEvent(creator, space) + publish_event(event=event, topic=PubSubTopic.OKUNA.value) + + +def publish_space_user_added_event(username: str, community: Community): + space_member: User = User.objects.get(username=username) + user = UserPayload.from_user(space_member) + space = SpacePayload.from_community(community) + event = SpaceUserAddedEvent(user, space) + publish_event(event=event, topic=PubSubTopic.OKUNA.value) + + +def publish_space_deleted_event(community: Community): + space = SpacePayload.from_community(community) + event = SpaceDeletedEvent(space) + publish_event(event=event, topic=PubSubTopic.OKUNA.value) + + +def publish_space_user_left_event(user: User, community: Community): + user = UserPayload.from_user(user) + space = SpacePayload.from_community(community) + event = SpaceUserLeftEvent(user, space) + publish_event(event=event, topic=PubSubTopic.OKUNA.value) + + +def publish_space_user_removed_event(user: User, community: Community): + user = UserPayload.from_user(user) + space = SpacePayload.from_community(community) + event = SpaceUserRemovedEvent(user, space) + publish_event(event=event, topic=PubSubTopic.OKUNA.value) diff --git a/openbook_communities/events/events.py b/openbook_communities/events/events.py deleted file mode 100644 index 05eaa08d121955856ebb5490bccf2b1578b4ccca..0000000000000000000000000000000000000000 --- a/openbook_communities/events/events.py +++ /dev/null @@ -1,115 +0,0 @@ -from enum import Enum - -from openbook_common.pubsub.event import Event -from openbook_communities.events.payloads import SpacePayload, UserPayload - - -class EventType(Enum): - SPACE_CREATED = "SpaceCreated" - SPACE_UPDATED = "SpaceUpdated" - SPACE_USER_ADDED = "SpaceUserAdded" - USER_NAME_UPDATED = "UserNameUpdated" - USER_EMAIL_UPDATED = "UserEmailUpdated" - SPACE_DELETED = "SpaceDeleted" - USER_DELETED = "UserDeleted" - SPACE_USER_LEFT = "SpaceUserLeft" - SPACE_USER_REMOVED = "SpaceUserRemoved" - - -class SpaceCreatedEvent(Event): - EVENT_VERSION = "1.0.0" - - def __init__(self, creator: UserPayload, space: SpacePayload): - super().__init__( - data={"creator": creator.to_dict(), "space": space.to_dict()}, - event_type=EventType.SPACE_CREATED.value, - event_version=SpaceCreatedEvent.EVENT_VERSION, - ) - - -class SpaceUpdatedEvent(Event): - EVENT_VERSION = "1.0.0" - - def __init__(self, creator: UserPayload, space: SpacePayload): - super().__init__( - data={"creator": creator.to_dict(), "space": space.to_dict()}, - event_type=EventType.SPACE_UPDATED.value, - event_version=SpaceUpdatedEvent.EVENT_VERSION, - ) - - -class SpaceUserAddedEvent(Event): - EVENT_VERSION = "1.0.0" - - def __init__(self, user: UserPayload, space: SpacePayload): - super().__init__( - data={"user": user.to_dict(), "space": space.to_dict()}, - event_type=EventType.SPACE_USER_ADDED.value, - event_version=SpaceUserAddedEvent.EVENT_VERSION, - ) - - -class UserNameUpdatedEvent(Event): - EVENT_VERSION = "1.0.0" - - def __init__(self, user: UserPayload): - super().__init__( - data={"user": user.to_dict()}, - event_type=EventType.USER_NAME_UPDATED.value, - event_version=UserNameUpdatedEvent.EVENT_VERSION, - ) - - -class UserEmailUpdatedEvent(Event): - EVENT_VERSION = "1.0.0" - - def __init__(self, user: UserPayload): - super().__init__( - data={"user": user.to_dict()}, - event_type=EventType.USER_EMAIL_UPDATED.value, - event_version=UserEmailUpdatedEvent.EVENT_VERSION, - ) - - -class SpaceDeletedEvent(Event): - EVENT_VERSION = "1.0.0" - - def __init__(self, space: SpacePayload): - super().__init__( - data={"space": space.to_dict()}, - event_type=EventType.SPACE_DELETED.value, - event_version=SpaceDeletedEvent.EVENT_VERSION, - ) - - -class UserDeletedEvent(Event): - EVENT_VERSION = "1.0.0" - - def __init__(self, user: UserPayload): - super().__init__( - data={"user": user.to_dict()}, - event_type=EventType.USER_DELETED.value, - event_version=UserDeletedEvent.EVENT_VERSION, - ) - - -class SpaceUserLeftEvent(Event): - EVENT_VERSION = "1.0.0" - - def __init__(self, user: UserPayload, space: SpacePayload): - super().__init__( - data={"user": user.to_dict(), "space": space.to_dict()}, - event_type=EventType.SPACE_USER_LEFT.value, - event_version=SpaceUserLeftEvent.EVENT_VERSION, - ) - - -class SpaceUserRemovedEvent(Event): - EVENT_VERSION = "1.0.0" - - def __init__(self, user: UserPayload, space: SpacePayload): - super().__init__( - data={"user": user.to_dict(), "space": space.to_dict()}, - event_type=EventType.SPACE_USER_REMOVED.value, - event_version=SpaceUserRemovedEvent.EVENT_VERSION, - ) diff --git a/openbook_communities/events/payloads.py b/openbook_communities/events/payloads.py deleted file mode 100644 index 53bdcf55767acdacb80d70f926683b8ebf34ea7f..0000000000000000000000000000000000000000 --- a/openbook_communities/events/payloads.py +++ /dev/null @@ -1,64 +0,0 @@ -from openbook_communities.models import Community -from openbook_auth.models import User - - -class UserPayload: - def __init__(self, id: str, name: str, email: str, identity: str, avatar: str = ""): - self.id = id - self.name = name - self.email = email - self.identity = identity - self.avatar = avatar - - def to_dict(self): - return { - "id": self.id, - "name": self.name, - "email": self.email, - "identity": self.identity, - "avatar": self.avatar, - } - - @classmethod - def from_user(cls, user: User): - avatar_url = user.profile.avatar.url if user.profile.avatar else None - return cls( - id=str(user.username), - name=user.profile.full_name(), - email=user.email, - identity=user.identity, - avatar=avatar_url, - ) - - -class SpacePayload: - def __init__(self, id: str, name: str, slug: str, avatar: str, avatar_default_color: str): - self.id = id - self.name = name - self.avatar = avatar - self.slug = slug - self.avatarDefaultColor = avatar_default_color - - def to_dict(self): - return { - "id": self.id, - "name": self.name, - "slug": self.slug, - "avatar": self.avatar, - "avatarDefaultColor": self.avatarDefaultColor, - } - - @classmethod - def from_community(cls, community: Community): - if community.avatar: - avatarUrl = community.avatar.url - else: - avatarUrl = None - - return cls( - id=str(community.id), - name=community.title, - slug=community.name, - avatar=avatarUrl, - avatar_default_color=community.avatar_default_color, - ) diff --git a/openbook_communities/migrations/0041_alter_community_name_alter_community_title.py b/openbook_communities/migrations/0041_alter_community_name_alter_community_title.py new file mode 100644 index 0000000000000000000000000000000000000000..a336e8aee8ed0cfc3d04ea0801fc5bf50c9f48c7 --- /dev/null +++ b/openbook_communities/migrations/0041_alter_community_name_alter_community_title.py @@ -0,0 +1,22 @@ +# Generated by Django 5.0.9 on 2024-12-17 09:20 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + dependencies = [ + ("openbook_communities", "0040_alter_task_creator"), + ] + + operations = [ + migrations.AlterField( + model_name="community", + name="name", + field=models.SlugField(max_length=80, unique=True, verbose_name="name"), + ), + migrations.AlterField( + model_name="community", + name="title", + field=models.CharField(max_length=80, verbose_name="title"), + ), + ] diff --git a/openbook_communities/schema/mutations.py b/openbook_communities/schema/mutations.py index 1089510cf5a547a9426ec0907c3bf9ddc01e02be..2c1be6a8b289ccbeda9c6125df4dc4d0d8e07f26 100644 --- a/openbook_communities/schema/mutations.py +++ b/openbook_communities/schema/mutations.py @@ -8,11 +8,11 @@ from django.db import transaction from graphql import GraphQLError from openbook.graphql_errors import GraphQLErrorCodes -from openbook.helpers import ( +from openbook_communities.events import ( publish_space_created_event, publish_space_updated_event, - publish_space_deleted_event, publish_space_user_added_event, + publish_space_deleted_event, publish_space_user_left_event, publish_space_user_removed_event, ) diff --git a/openbook_communities/tests/test_events.py b/openbook_communities/tests/test_events.py index 55fc3f5f5d874875eabcb4f5d8916237ee1688c5..fea9d6a231b559ed06b04d67d895f2f36e9a6d3e 100644 --- a/openbook_communities/tests/test_events.py +++ b/openbook_communities/tests/test_events.py @@ -1,156 +1,172 @@ import json +from typing import Any -from django.test import testcases +import pytest +from openbook_auth.events import UserPayload +from openbook_auth.models import User from openbook_common.tests.helpers import make_community, make_user -from openbook_communities.events.events import ( - SpaceCreatedEvent, +from openbook_communities.events import ( + SpacePayload, + SpaceUserRemovedEvent, + SpaceUserLeftEvent, SpaceDeletedEvent, SpaceUserAddedEvent, - SpaceUserLeftEvent, - SpaceUserRemovedEvent, - UserDeletedEvent, - UserNameUpdatedEvent, + SpaceCreatedEvent, + SpaceUpdatedEvent, ) -from openbook_communities.events.payloads import SpacePayload, UserPayload - - -class SpaceCreatedEventTestCase(testcases.TestCase): - def test_should_create_space_created_event(self): - user = make_user() - community = make_community(creator=user) - - creator = UserPayload.from_user(user) - space = SpacePayload.from_community(community) - - data = {"creator": creator.to_dict(), "space": space.to_dict()} - - encoded_data = json.dumps(data).encode("utf-8") - - event = SpaceCreatedEvent(creator, space) - - event_dict = event.to_dict() - - eventPayload = {"eventType": "SpaceCreated", "eventVersion": "1.0.0", "data": encoded_data} - - self.assertEqual(eventPayload, event_dict) - - -class SpaceUserAddedEventTestCase(testcases.TestCase): - def test_should_create_space_user_added_event(self): - user = make_user() - community = make_community() - - user = UserPayload.from_user(user) - space = SpacePayload.from_community(community) - - data = {"user": user.to_dict(), "space": space.to_dict()} - - encoded_data = json.dumps(data).encode("utf-8") - - event = SpaceUserAddedEvent(user, space) - - event_dict = event.to_dict() - - eventPayload = {"eventType": "SpaceUserAdded", "eventVersion": "1.0.0", "data": encoded_data} - - self.assertEqual(eventPayload, event_dict) - - -class UserNameUpdatedEventTestCase(testcases.TestCase): - def test_should_create_user_name_updated_event(self): - user = make_user() - - user_payload = UserPayload.from_user(user) - - data = {"user": user_payload.to_dict()} - - encoded_data = json.dumps(data).encode("utf-8") - - event = UserNameUpdatedEvent(user_payload) - - event_dict = event.to_dict() - - eventPayload = {"eventType": "UserNameUpdated", "eventVersion": "1.0.0", "data": encoded_data} - - self.assertEqual(eventPayload, event_dict) - - -class SpaceDeletedEventTestCase(testcases.TestCase): - def test_should_create_space_deleted_event(self): - community = make_community() - - space = SpacePayload.from_community(community) - - data = {"space": space.to_dict()} - - encoded_data = json.dumps(data).encode("utf-8") - - event = SpaceDeletedEvent(space) - - event_dict = event.to_dict() - - eventPayload = {"eventType": "SpaceDeleted", "eventVersion": "1.0.0", "data": encoded_data} - - self.assertEqual(eventPayload, event_dict) - - -class UserDeletedEventTestCase(testcases.TestCase): - def test_should_create_space_deleted_event(self): - user = make_user() - - user = UserPayload.from_user(user) - - data = {"user": user.to_dict()} - - encoded_data = json.dumps(data).encode("utf-8") - - event = UserDeletedEvent(user) - - event_dict = event.to_dict() - - eventPayload = {"eventType": "UserDeleted", "eventVersion": "1.0.0", "data": encoded_data} - - self.assertEqual(eventPayload, event_dict) - - -class SpaceUserLeftEventTestCase(testcases.TestCase): - def test_should_create_space_user_left_event(self): - user = make_user() - community = make_community() - - user = UserPayload.from_user(user) - space = SpacePayload.from_community(community) - - data = {"user": user.to_dict(), "space": space.to_dict()} - - encoded_data = json.dumps(data).encode("utf-8") - - event = SpaceUserLeftEvent(user, space) - - event_dict = event.to_dict() - - eventPayload = {"eventType": "SpaceUserLeft", "eventVersion": "1.0.0", "data": encoded_data} - - self.assertEqual(eventPayload, event_dict) - - -class SpaceUserRemovedEventTestCase(testcases.TestCase): - def test_should_create_space_user_removed_event(self): - user = make_user() - community = make_community() - - user = UserPayload.from_user(user) - space = SpacePayload.from_community(community) - - data = {"user": user.to_dict(), "space": space.to_dict()} - - encoded_data = json.dumps(data).encode("utf-8") - - event = SpaceUserRemovedEvent(user, space) - - event_dict = event.to_dict() - - eventPayload = {"eventType": "SpaceUserRemoved", "eventVersion": "1.0.0", "data": encoded_data} - - self.assertEqual(eventPayload, event_dict) +from openbook_communities.models import Community + + +# noinspection DuplicatedCode +def expected_user_payload(user: User) -> dict[str, Any]: + return { + "id": str(user.username), + "name": user.profile.full_name(), + "email": user.email, + "identity": user.identity, + "avatar": user.profile.avatar.url if user.profile.avatar else None, + "aboutMe": user.profile.about_me, + "location": user.profile.location, + } + + +# noinspection DuplicatedCode +def expected_space_payload(community: Community) -> dict[str, Any]: + return { + "id": str(community.id), + "name": community.title, + "slug": community.name, + "avatar": community.avatar.url if community.avatar else None, + "description": community.description, + "location": community.location, + "locationLatLng": ( + { + "latitude": community.geolocation.x, + "longitude": community.geolocation.y, + } + if community.geolocation + else None + ), + } + + +@pytest.mark.django_db +def test_serialization_space_created_event(): + # GIVEN + user = make_user() + space = make_community(creator=user) + + # WHEN + event = SpaceCreatedEvent(UserPayload.from_user(user), SpacePayload.from_community(space)) + + # THEN + expected_event_dict = { + "eventType": "SpaceCreated", + "eventVersion": "1.1.0", + "data": ( + json.dumps({"creator": expected_user_payload(user), "space": expected_space_payload(space)}).encode( + "utf-8" + ) + ), + } + assert event.to_dict() == expected_event_dict + + +@pytest.mark.django_db +def test_serialization_space_updated_event(): + # GIVEN + user = make_user() + space = make_community(creator=user) + + # WHEN + event = SpaceUpdatedEvent(UserPayload.from_user(user), SpacePayload.from_community(space)) + + # THEN + expected_event_dict = { + "eventType": "SpaceUpdated", + "eventVersion": "1.1.0", + "data": ( + json.dumps({"creator": expected_user_payload(user), "space": expected_space_payload(space)}).encode( + "utf-8" + ) + ), + } + assert event.to_dict() == expected_event_dict + + +@pytest.mark.django_db +def test_serialization_space_deleted_event(): + # GIVEN + space = make_community() + + # WHEN + event = SpaceDeletedEvent(SpacePayload.from_community(space)) + + # THEN + expected_event_dict = { + "eventType": "SpaceDeleted", + "eventVersion": "1.1.0", + "data": (json.dumps({"space": expected_space_payload(space)}).encode("utf-8")), + } + assert event.to_dict() == expected_event_dict + + +@pytest.mark.django_db +def test_serialization_space_user_added_event(): + # GIVEN + user = make_user() + space = make_community() + + # WHEN + event = SpaceUserAddedEvent(UserPayload.from_user(user), SpacePayload.from_community(space)) + + # THEN + expected_event_dict = { + "eventType": "SpaceUserAdded", + "eventVersion": "1.1.0", + "data": ( + json.dumps({"user": expected_user_payload(user), "space": expected_space_payload(space)}).encode("utf-8") + ), + } + assert event.to_dict() == expected_event_dict + + +@pytest.mark.django_db +def test_serialization_space_user_left_event(): + # GIVEN + user = make_user() + space = make_community() + + # WHEN + event = SpaceUserLeftEvent(UserPayload.from_user(user), SpacePayload.from_community(space)) + + # THEN + expected_event_dict = { + "eventType": "SpaceUserLeft", + "eventVersion": "1.1.0", + "data": ( + json.dumps({"user": expected_user_payload(user), "space": expected_space_payload(space)}).encode("utf-8") + ), + } + assert event.to_dict() == expected_event_dict + + +@pytest.mark.django_db +def test_serialization_space_user_removed_event(): + # GIVEN + user = make_user() + space = make_community() + + # WHEN + event = SpaceUserRemovedEvent(UserPayload.from_user(user), SpacePayload.from_community(space)) + + # THEN + expected_event_dict = { + "eventType": "SpaceUserRemoved", + "eventVersion": "1.1.0", + "data": ( + json.dumps({"user": expected_user_payload(user), "space": expected_space_payload(space)}).encode("utf-8") + ), + } + assert event.to_dict() == expected_event_dict diff --git a/openbook_communities/tests/test_graphql_communities.py b/openbook_communities/tests/test_graphql_communities.py index b81aa4b2a3c4167e53bd8cfceb588eea9ecfd74e..834ff8b02aee303257be26e47c935e1303f0101e 100644 --- a/openbook_communities/tests/test_graphql_communities.py +++ b/openbook_communities/tests/test_graphql_communities.py @@ -17,6 +17,7 @@ from django.test import AsyncClient from requests.models import Response from openbook.graphql_schema import schema +from openbook.settings import COMMUNITY_TITLE_MAX_LENGTH, COMMUNITY_DESCRIPTION_MAX_LENGTH from openbook.tests.test_data import geolocation, geolocation_geometry from openbook_common.enums import VisibilityType from openbook_common.tests.helpers import ( @@ -2549,10 +2550,14 @@ class TestCommunities(TestCase): {"field": "title", "value": "", "expected_code": "blank"}, {"field": "title", "value": " ", "expected_code": "blank"}, {"field": "title", "value": "f", "expected_code": "min_length"}, - {"field": "title", "value": "f" * 61, "expected_code": "max_length"}, + {"field": "title", "value": "f" * (COMMUNITY_TITLE_MAX_LENGTH + 1), "expected_code": "max_length"}, {"field": "description", "value": None, "expected_code": "null"}, {"field": "description", "value": "", "expected_code": "blank"}, - {"field": "description", "value": "f" * 601, "expected_code": "max_length"}, + { + "field": "description", + "value": "f" * (COMMUNITY_DESCRIPTION_MAX_LENGTH + 1), + "expected_code": "max_length", + }, {"field": "topics", "value": None, "expected_code": "null"}, {"field": "topics", "value": [], "expected_code": "min_length"}, {"field": "topics", "value": topic_ids, "expected_code": "max_length"}, @@ -2709,10 +2714,14 @@ class TestCommunities(TestCase): {"field": "title", "value": None, "expected_error": "Expected non-nullable type"}, {"field": "title", "value": "", "expected_code": "blank"}, {"field": "title", "value": "f", "expected_code": "min_length"}, - {"field": "title", "value": "f" * 61, "expected_code": "max_length"}, + {"field": "title", "value": "f" * (COMMUNITY_TITLE_MAX_LENGTH + 1), "expected_code": "max_length"}, {"field": "description", "value": None, "expected_error": "Expected non-nullable type"}, {"field": "description", "value": "", "expected_code": "blank"}, - {"field": "description", "value": "f" * 601, "expected_code": "max_length"}, + { + "field": "description", + "value": "f" * (COMMUNITY_DESCRIPTION_MAX_LENGTH + 1), + "expected_code": "max_length", + }, {"field": "topics", "value": None, "expected_error": "Expected non-nullable type"}, {"field": "topics", "value": [], "expected_code": "min_length"}, {"field": "topics", "value": topic_ids, "expected_code": "max_length"}, @@ -2848,9 +2857,13 @@ class TestCommunities(TestCase): {"field": "id", "value": invalid_uuid, "expected_error": "No community with the provided id exists."}, {"field": "title", "value": "", "expected_code": "blank"}, {"field": "title", "value": "f", "expected_code": "min_length"}, - {"field": "title", "value": "f" * 61, "expected_code": "max_length"}, + {"field": "title", "value": "f" * (COMMUNITY_TITLE_MAX_LENGTH + 1), "expected_code": "max_length"}, {"field": "description", "value": "", "expected_code": "blank"}, - {"field": "description", "value": "f" * 601, "expected_code": "max_length"}, + { + "field": "description", + "value": "f" * (COMMUNITY_DESCRIPTION_MAX_LENGTH + 1), + "expected_code": "max_length", + }, {"field": "topics", "value": [], "expected_code": "min_length"}, {"field": "topics", "value": topic_ids, "expected_code": "max_length"}, { diff --git a/openbook_communities/tests/test_helpers.py b/openbook_communities/tests/test_helpers.py index fcb5eded3eaa861c9c5e982e69a63e34b63775d7..6116e8fa9bf3c1325b8b3ba326b0ac7c131c6d07 100644 --- a/openbook_communities/tests/test_helpers.py +++ b/openbook_communities/tests/test_helpers.py @@ -1,5 +1,6 @@ from django.db.models import QuerySet +from openbook.settings import COMMUNITY_NAME_MAX_LENGTH from openbook_auth.models import UserRole from openbook_common.tests.helpers import make_community, make_user from openbook_common.tests.models import OpenbookAPITestCase @@ -10,7 +11,7 @@ from openbook_communities.models import CommunityMembership class CommunitiesHelperTests(OpenbookAPITestCase): def test_create_community_name_from_title_and_hash(self): hash = "1234567890" - long_name = "f" * 60 + long_name = "f" * COMMUNITY_NAME_MAX_LENGTH make_community(title="bar") make_community(title="Some Title") make_community(title="bar-12345678") @@ -23,7 +24,7 @@ class CommunitiesHelperTests(OpenbookAPITestCase): self.assertEqual("some-title-23456789", create_community_name_from_title_and_hash("Some Title", "2345678901")) self.assertEqual("bar-123456789", create_community_name_from_title_and_hash("bar", hash)) self.assertEqual("bar-1234", create_community_name_from_title_and_hash("bar", "1234")) - self.assertEqual(f"{long_name[:51]}-12345678", create_community_name_from_title_and_hash(long_name, hash)) + self.assertEqual(f"{long_name[:71]}-12345678", create_community_name_from_title_and_hash(long_name, hash)) with self.assertRaises(Exception) as context: create_community_name_from_title_and_hash("bar", "12345678") diff --git a/openbook_notifications/notifications.py b/openbook_notifications/notifications.py index 61be89329e714a7530b09a90e5a1bc96462edb4f..cafdafff30a0c1c9685dc83eb31ce559e7f82d27 100644 --- a/openbook_notifications/notifications.py +++ b/openbook_notifications/notifications.py @@ -28,6 +28,7 @@ from openbook_notifications.payloads import ( PollUpdatesPayload, PostReactionPayload, CommentReactionPayload, + WelcomeEmailSeriesPayload, ) @@ -51,6 +52,7 @@ class Workflow(Enum): POLL_UPDATES = "poll-updates" POST_REACTIONS = "post-reactions" COMMENT_REACTIONS = "comment-reactions" + WELCOME_EMAIL_SERIES = "welcome-email-series" # INTERNAL USE ONLY, use NotificationsService to trigger notifications @@ -382,3 +384,17 @@ class CommentReactionNotification(Notification): payload=payload.to_dict(), transaction_id=transaction_id if transaction_id else str(uuid.uuid4()), ) + + +class WelcomeEmailSeriesNotification(Notification): + def __init__( + self, + user: str, + payload: WelcomeEmailSeriesPayload, + ): + super().__init__( + workflow=Workflow.WELCOME_EMAIL_SERIES.value, + recipients=user, + payload=payload.to_dict(), + transaction_id=user, # user id is used as transaction id so that we can easily unsubscribe from the email series + ) diff --git a/openbook_notifications/payloads.py b/openbook_notifications/payloads.py index 8000930ac2a9d877dd8325959473c8246c8d7e58..910d409b4211bef9b6fca8c0ce8eb42cd2de2ec3 100644 --- a/openbook_notifications/payloads.py +++ b/openbook_notifications/payloads.py @@ -36,6 +36,10 @@ class NotificationType(Enum): POLL_UPDATES = "POLL_UPDATES" POST_REACTION = "POST_REACTION" COMMENT_REACTION = "COMMENT_REACTION" + WELCOME_EMAIL_SERIES = "WELCOME_EMAIL_SERIES" + + +app_url = settings.APP_URL class UserData: @@ -43,6 +47,7 @@ class UserData: self, id: str, full_name: str, + profile_link: str, avatar_blurhash: Optional[str] = "", avatar_default_color: Optional[str] = "", avatar: Optional[str] = "", @@ -54,6 +59,7 @@ class UserData: self.avatar_blurhash = avatar_blurhash self.avatar_default_color = avatar_default_color self.avatar_label = avatar_label + self.profile_link = profile_link def to_dict(self): return { @@ -63,6 +69,7 @@ class UserData: "avatarBlurhash": self.avatar_blurhash, "avatarDefaultColor": self.avatar_default_color, "avatarLabel": self.avatar_label, + "profileLink": self.profile_link, } @classmethod @@ -74,6 +81,7 @@ class UserData: avatar_blurhash=user.profile.avatar_blurhash if user.profile.avatar_blurhash else "", avatar_default_color=str(user.avatar_default_color) if user.avatar_default_color else "", avatar_label=user.avatar_label, + profile_link=f"{app_url}/profile/{user.username}", ) @@ -83,6 +91,9 @@ class SpaceData: id: str, title: str, name: str, + goal: str, + link: str, + cover: str, avatar_blurhash: str = "", avatar_default_color: str = "", avatar: Optional[str] = "", @@ -90,18 +101,24 @@ class SpaceData: self.id = id self.name = name self.title = title + self.goal = goal self.avatar = avatar self.avatar_blurhash = avatar_blurhash self.avatar_default_color = avatar_default_color + self.link = link + self.cover = cover def to_dict(self): return { "id": self.id, "name": self.name, "title": self.title, + "goal": self.goal, "avatar": self.avatar, "avatarBlurhash": self.avatar_blurhash, "avatarDefaultColor": self.avatar_default_color, + "link": self.link, + "cover": self.cover, } @classmethod @@ -110,9 +127,12 @@ class SpaceData: id=str(community.id), name=community.name, title=community.title, + goal=community.description, avatar=community.avatar.url if community.avatar else "", avatar_blurhash=community.avatar_blurhash if community.avatar_blurhash else "", avatar_default_color=str(community.avatar_default_color) if community.avatar_default_color else "", + link=f"{app_url}/spaces/{community.name}", + cover=community.cover.url if community.cover else "", ) @@ -227,6 +247,38 @@ class PostData: ) +class InsightData: + def __init__( + self, + id: str, + title: str, + cover: str, + link: str, + ): + self.id = id + self.title = title + self.cover = cover + self.link = link + + def to_dict(self): + data = { + "id": self.id, + "title": self.title, + "cover": self.cover, + "link": self.link, + } + return data + + @classmethod + def from_insight(cls, insight): + return cls( + id=str(insight.id), + title=insight.title, + cover=insight.header_image.url, + link=f"{app_url}/insights/{insight.id}", + ) + + class CommentData: def __init__(self, id: str, text: str): self.id = id @@ -556,3 +608,18 @@ class CommentReactionPayload(Payload): notification_type=NotificationType.COMMENT_REACTION.value, notification_version=CommentReactionPayload.NOTIFICATION_VERSION, ) + + +class WelcomeEmailSeriesPayload(Payload): + NOTIFICATION_VERSION = "1.0.0" + + def __init__(self, user: User, spaces, insights): + super().__init__( + data={ + "user": UserData.from_user(user).to_dict(), + "recommendedSpaces": [SpaceData.from_community(space).to_dict() for space in spaces], + "recommendedInsights": [InsightData.from_insight(insight).to_dict() for insight in insights], + }, + notification_type=NotificationType.WELCOME_EMAIL_SERIES.value, + notification_version=WelcomeEmailSeriesPayload.NOTIFICATION_VERSION, + ) diff --git a/openbook_notifications/services.py b/openbook_notifications/services.py index 59e9020c63d2285ab543f65fe63ae0e1284cf5b6..8aa843b684c17eaf12ecf0bc6132cc73cf8de503 100644 --- a/openbook_notifications/services.py +++ b/openbook_notifications/services.py @@ -27,6 +27,7 @@ from openbook_notifications.notifications import ( PollUpdatesNotification, CommentReactionNotification, PostReactionNotification, + WelcomeEmailSeriesNotification, ) from openbook_notifications.payloads import ( SpaceMembershipRequestPayload, @@ -46,6 +47,7 @@ from openbook_notifications.payloads import ( PollUpdatesPayload, PostReactionPayload, CommentReactionPayload, + WelcomeEmailSeriesPayload, ) if TYPE_CHECKING: @@ -358,3 +360,11 @@ class NotificationsService: payload=CommentReactionPayload(reactor=reactor, comment=comment, context_id=context_id), ) notification.send() + + @staticmethod + def send_welcome_email_series_notification(user: User, spaces, insights): + notification = WelcomeEmailSeriesNotification( + user=str(user.username), + payload=WelcomeEmailSeriesPayload(user=user, spaces=spaces, insights=insights), + ) + notification.send() diff --git a/openbook_notifications/tests/test_payloads.py b/openbook_notifications/tests/test_payloads.py index e30bddfd1b8b636473926ce400d8b918737cc565..2b108dd58065ec1192b4e5fc8b2702554d349ada 100644 --- a/openbook_notifications/tests/test_payloads.py +++ b/openbook_notifications/tests/test_payloads.py @@ -4,6 +4,7 @@ import pytest from django.test import TestCase from novu.api import EventApi +from openbook import settings from openbook_common.tests.helpers import ( make_community, make_community_post, @@ -64,6 +65,7 @@ class TestPayloads(TestCase): avatar_blurhash="test_blurhash", avatar_default_color="#FFF000", avatar_label="TU", + profile_link=f"{settings.APP_URL}/profile/test-user-id", ) result = user_data.to_dict() @@ -76,6 +78,7 @@ class TestPayloads(TestCase): "avatarBlurhash": "test_blurhash", "avatarDefaultColor": "#FFF000", "avatarLabel": "TU", + "profileLink": f"{settings.APP_URL}/profile/test-user-id", }, ) @@ -92,29 +95,36 @@ class TestPayloads(TestCase): "avatarBlurhash": "", "avatarDefaultColor": self.user.avatar_default_color, "avatarLabel": self.user.avatar_label, + "profileLink": f"{settings.APP_URL}/profile/{self.user.username}", }, ) def test_space_data_to_dict(self): space_data = SpaceData( - id="test-space-id", + id=self.space.id, name="test-space", title="Test Space", avatar="avatar.jpg", avatar_blurhash="test-blurhash", avatar_default_color="#FFF000", + goal="test-goal", + link=f"{settings.APP_URL}/spaces/{self.space.id}", + cover="cover.jpg", ) result = space_data.to_dict() self.assertEqual( result, { - "id": "test-space-id", + "id": self.space.id, "name": "test-space", "title": "Test Space", "avatar": "avatar.jpg", "avatarBlurhash": "test-blurhash", "avatarDefaultColor": "#FFF000", + "goal": "test-goal", + "link": f"{settings.APP_URL}/spaces/{self.space.id}", + "cover": "cover.jpg", }, ) @@ -131,6 +141,9 @@ class TestPayloads(TestCase): "avatar": "", "avatarBlurhash": "", "avatarDefaultColor": self.space.avatar_default_color, + "goal": self.space.description, + "link": f"{settings.APP_URL}/spaces/{self.space.name}", + "cover": self.space.cover.url if self.space.cover else "", }, )