Skip to content

Commit

Permalink
feat: add comprehensive tests for notification flow (and get these pa…
Browse files Browse the repository at this point in the history
…ssing) covering product creation events, error handling, retry mechanisms, and notification cleanup. Tests verify the full lifecycle of notifications including async event processing, error callbacks, retry behavior, and automatic cleanup of expired notifications.
  • Loading branch information
adrianmcphee committed Nov 25, 2024
1 parent f5de6b5 commit 7adb288
Show file tree
Hide file tree
Showing 8 changed files with 317 additions and 300 deletions.
3 changes: 1 addition & 2 deletions apps/capabilities/product_management/services.py
Original file line number Diff line number Diff line change
Expand Up @@ -340,9 +340,8 @@ def create_product(form_data: dict, person: Person, organisation: Organisation =
)
logger.info(f"Assigned {person} as ADMIN for product {product.id}")

# Emit product.created event with correct field names
event_bus = get_event_bus()
event_bus.publish('product.created', {
event_bus.emit_event('product.created', {
'organisation_id': product.organisation_id if product.organisation else None,
'person_id': product.person_id if product.person else None,
'name': product.name,
Expand Down
2 changes: 1 addition & 1 deletion apps/common/tests/conftest.py
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
from apps.common.tests.fixtures.auth import *
from apps.common.tests.fixtures.product_managment import *
from apps.common.tests.fixtures.product_management import *
from apps.common.tests.fixtures.security import *
from apps.common.tests.fixtures.talent import *
from apps.common.tests.fixtures.utils import *
176 changes: 0 additions & 176 deletions apps/common/tests/fixtures/product_managment.py

This file was deleted.

99 changes: 61 additions & 38 deletions apps/engagement/events.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,52 +4,75 @@
from apps.capabilities.commerce.models import Organisation
from apps.engagement.models import NotifiableEvent
from apps.capabilities.talent.models import Person
from apps.engagement.models import NotificationPreference, AppNotificationTemplate, AppNotification
from apps.capabilities.security.models import OrganisationPersonRoleAssignment
from apps.capabilities.commerce.models import Product

logger = logging.getLogger(__name__)

def handle_product_created(payload: Dict) -> None:
"""
Handle product creation notification
Creates NotifiableEvents for:
- Organisation owners and managers (for org products)
- Product owner (for personal products)
"""
def handle_product_created(payload: dict) -> None:
"""Handle product.created event"""
logger.info(f"Processing product created event: {payload}")

try:
organisation_id = payload.get('organisation_id')
product_id = payload.get('product_id')
if not product_id:
logger.error("No product_id in payload")
return

product = Product.objects.get(id=product_id)

params = {
'name': payload.get('name', product.name),
'url': payload.get('url', f'/products/{product.id}/summary/')
}

# Always notify the creator
person_id = payload.get('person_id')
if person_id:
creator = Person.objects.get(id=person_id)
event = NotifiableEvent.objects.create(
event_type=NotifiableEvent.EventType.PRODUCT_CREATED,
person=creator,
params=params
)
_create_notifications_for_event(event)

if organisation_id:
# Handle org-owned product
role_service = RoleService()
organisation = Organisation.objects.get(id=organisation_id)
org_managers = role_service.get_organisation_managers(organisation)
# If org-owned, also notify org admins
if product.is_owned_by_organisation():
admin_assignments = OrganisationPersonRoleAssignment.objects.filter(
organisation=product.organisation,
role__in=[
OrganisationPersonRoleAssignment.OrganisationRoles.OWNER,
OrganisationPersonRoleAssignment.OrganisationRoles.MANAGER
]
).exclude(person_id=person_id) # Don't double-notify the creator

for manager in org_managers:
NotifiableEvent.objects.create(
event_type=NotifiableEvent.EventType.PRODUCT_CREATED,
person=manager,
params={
'name': payload['name'],
'url': payload['url']
}
).create_notifications()

elif person_id:
# Handle personally-owned product
try:
owner = Person.objects.get(id=person_id)
NotifiableEvent.objects.create(
for assignment in admin_assignments:
event = NotifiableEvent.objects.create(
event_type=NotifiableEvent.EventType.PRODUCT_CREATED,
person=owner,
params={
'name': payload['name'],
'url': payload['url']
}
).create_notifications()
except Person.DoesNotExist:
logger.error(f"Product owner not found: {person_id}")
person=assignment.person,
params=params
)
_create_notifications_for_event(event)

except Exception as e:
logger.error(f"Error processing product created event: {e}")
logger.error(f"Error processing product created event: {str(e)}")
raise

def _create_notifications_for_event(event: NotifiableEvent) -> None:
"""Create notifications based on user preferences"""
try:
prefs = NotificationPreference.objects.get(person=event.person)
if prefs.product_notifications in [NotificationPreference.Type.APPS, NotificationPreference.Type.BOTH]:
template = AppNotificationTemplate.objects.get(event_type=event.event_type)
AppNotification.objects.create(
event=event,
title=template.title_template.format(**event.params),
message=template.message_template.format(**event.params)
)
except NotificationPreference.DoesNotExist:
logger.warning(f"No notification preferences found for person {event.person.id}")
except AppNotificationTemplate.DoesNotExist:
logger.error(f"No app notification template found for event type {event.event_type}")
except Exception as e:
logger.error(f"Error creating notifications for event: {str(e)}")
18 changes: 11 additions & 7 deletions apps/engagement/models.py
Original file line number Diff line number Diff line change
Expand Up @@ -23,7 +23,7 @@ class Type(models.TextChoices):

event_type = models.CharField(max_length=50, choices=EventType.choices)
person = models.ForeignKey('talent.Person', on_delete=models.CASCADE)
params = models.JSONField()
params = models.JSONField(default=dict)
delete_at = models.DateTimeField(default=default_delete_at)

class Meta:
Expand Down Expand Up @@ -73,17 +73,21 @@ def _template_is_valid(template, permitted_params):


class NotificationPreference(TimeStampMixin):
class Type(models.TextChoices):
NONE = "NONE", "No notifications"
APPS = "APPS", "In-app only"
EMAIL = "EMAIL", "Email only"
BOTH = "BOTH", "Both app and email"

person = models.OneToOneField(
"talent.Person",
on_delete=models.CASCADE,
related_name="notification_preferences"
)

product_notifications = models.CharField(
max_length=10,
choices=NotifiableEvent.Type.choices,
default=NotifiableEvent.Type.BOTH,
help_text=_("Notifications about products (creation, updates)")
choices=Type.choices,
default=Type.BOTH
)

class Meta:
Expand All @@ -102,9 +106,9 @@ def get_channel_for_event(self, event_type: str) -> str:

class EmailNotification(TimeStampMixin):
"""Record of emails sent to users"""
event = models.ForeignKey(NotifiableEvent, on_delete=models.CASCADE)
event = models.ForeignKey(NotifiableEvent, on_delete=models.CASCADE, null=True, blank=True)
title = models.CharField(max_length=400)
body = models.CharField(max_length=4000)
body = models.CharField(max_length=4000, null=True, blank=True)
sent_at = models.DateTimeField(auto_now_add=True)
delete_at = models.DateTimeField(default=default_delete_at)

Expand Down
Loading

0 comments on commit 7adb288

Please sign in to comment.