Skip to content

Commit ae39dbf

Browse files
Topic notification transition (#412)
### Description Sending messages to a topic doesn't work on iOS when the app is in background. This branch will fix this by sending the messages to the tokens of users subscribed to the topic, instead of sending them to the topic directly. We can send a message to 500 devices per request, so in most cases we won't need to make more than one request to firebase. The PR add tests for notifications. Due to the usage of Firebase messaging to send notifications, tests can not check notifications can be sent. Instead, they make sure our endpoints to register/unregister a device or subscribe to topics work. To prevent anyone from subscribing to `bookingadmin` topic and then be notified of all booking requests, the notification is now sent to all members of the room manager group. We way want to send the notification to just a few persons instead of the whole group. Fix #291 --------- Co-authored-by: Armand Didierjean <[email protected]>
1 parent 5256b03 commit ae39dbf

File tree

11 files changed

+489
-290
lines changed

11 files changed

+489
-290
lines changed

app/core/notification/cruds_notification.py

Lines changed: 40 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -45,6 +45,20 @@ async def get_messages_by_firebase_token(
4545
return result.scalars().all()
4646

4747

48+
async def get_messages_by_context_and_firebase_tokens(
49+
context: str,
50+
firebase_tokens: list[str],
51+
db: AsyncSession,
52+
) -> Sequence[models_notification.Message]:
53+
result = await db.execute(
54+
select(models_notification.Message).where(
55+
models_notification.Message.context == context,
56+
models_notification.Message.firebase_device_token.in_(firebase_tokens),
57+
),
58+
)
59+
return result.scalars().all()
60+
61+
4862
async def remove_message_by_context_and_firebase_device_token(
4963
context: str,
5064
firebase_device_token: str,
@@ -265,3 +279,29 @@ async def get_topic_membership_by_user_id_and_custom_topic(
265279
),
266280
)
267281
return result.scalars().first()
282+
283+
284+
async def get_user_ids_by_topic(
285+
custom_topic: CustomTopic,
286+
db: AsyncSession,
287+
) -> list[str]:
288+
result = await db.execute(
289+
select(models_notification.TopicMembership.user_id).where(
290+
models_notification.TopicMembership.topic == custom_topic.topic,
291+
models_notification.TopicMembership.topic_identifier
292+
== custom_topic.topic_identifier,
293+
),
294+
)
295+
return list(result.scalars().all())
296+
297+
298+
async def get_firebase_tokens_by_user_ids(
299+
user_ids: list[str],
300+
db: AsyncSession,
301+
) -> list[str]:
302+
result = await db.execute(
303+
select(models_notification.FirebaseDevice.firebase_device_token).where(
304+
models_notification.FirebaseDevice.user_id.in_(user_ids),
305+
),
306+
)
307+
return list(result.scalars().all())

app/core/notification/endpoints_notification.py

Lines changed: 40 additions & 35 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
from datetime import UTC, datetime
1+
from datetime import UTC, datetime, timedelta
22

33
from fastapi import APIRouter, Body, Depends, HTTPException, Path
44
from sqlalchemy.ext.asyncio import AsyncSession
@@ -61,21 +61,6 @@ async def register_firebase_device(
6161
db=db,
6262
)
6363

64-
# We also need to subscribe the new token to the topics the user is subscribed to
65-
topic_memberships = await cruds_notification.get_topic_memberships_by_user_id(
66-
user_id=user.id,
67-
db=db,
68-
)
69-
70-
for topic_membership in topic_memberships:
71-
await notification_manager.subscribe_tokens_to_topic(
72-
tokens=[firebase_token],
73-
custom_topic=CustomTopic(
74-
topic=topic_membership.topic,
75-
topic_identifier=topic_membership.topic_identifier,
76-
),
77-
)
78-
7964
firebase_device = models_notification.FirebaseDevice(
8065
user_id=user.id,
8166
firebase_device_token=firebase_token,
@@ -105,21 +90,6 @@ async def unregister_firebase_device(
10590
"""
10691
# Anybody may unregister a device if they know its token, which should be secret
10792

108-
# We also need to unsubscribe the token to the topics the user is subscribed to
109-
topic_memberships = await cruds_notification.get_topic_memberships_by_user_id(
110-
user_id=user.id,
111-
db=db,
112-
)
113-
114-
for topic_membership in topic_memberships:
115-
await notification_manager.unsubscribe_tokens_to_topic(
116-
tokens=[firebase_token],
117-
custom_topic=CustomTopic(
118-
topic=topic_membership.topic,
119-
topic_identifier=topic_membership.topic_identifier,
120-
),
121-
)
122-
12393
await cruds_notification.delete_firebase_devices(
12494
firebase_device_token=firebase_token,
12595
db=db,
@@ -254,12 +224,12 @@ async def get_topic(
254224

255225

256226
@router.get(
257-
"/notification/topics/{topic_str}",
227+
"/notification/topics/{topic}",
258228
status_code=200,
259229
response_model=list[str],
260230
)
261231
async def get_topic_identifier(
262-
topic_str: str,
232+
topic: Topic,
263233
db: AsyncSession = Depends(get_db),
264234
user: models_core.CoreUser = Depends(is_user),
265235
):
@@ -272,7 +242,7 @@ async def get_topic_identifier(
272242
memberships = await cruds_notification.get_topic_memberships_with_identifiers_by_user_id_and_topic(
273243
user_id=user.id,
274244
db=db,
275-
topic=Topic(topic_str),
245+
topic=topic,
276246
)
277247

278248
return [
@@ -289,7 +259,6 @@ async def get_topic_identifier(
289259
status_code=201,
290260
)
291261
async def send_notification(
292-
message: schemas_notification.Message,
293262
user: models_core.CoreUser = Depends(is_user_a_member_of(GroupType.admin)),
294263
notification_tool: NotificationTool = Depends(get_notification_tool),
295264
):
@@ -298,6 +267,42 @@ async def send_notification(
298267
299268
**Only admins can use this endpoint**
300269
"""
270+
message = schemas_notification.Message(
271+
context="notification-test",
272+
is_visible=True,
273+
title="Test notification",
274+
content="Ceci est un test de notification",
275+
# The notification will expire in 3 days
276+
expire_on=datetime.now(UTC) + timedelta(days=3),
277+
)
278+
await notification_tool.send_notification_to_user(
279+
user_id=user.id,
280+
message=message,
281+
)
282+
283+
284+
@router.post(
285+
"/notification/send/future",
286+
status_code=201,
287+
)
288+
async def send_future_notification(
289+
user: models_core.CoreUser = Depends(is_user_a_member_of(GroupType.admin)),
290+
notification_tool: NotificationTool = Depends(get_notification_tool),
291+
):
292+
"""
293+
Send ourself a test notification.
294+
295+
**Only admins can use this endpoint**
296+
"""
297+
message = schemas_notification.Message(
298+
context="future-notification-test",
299+
is_visible=True,
300+
title="Test notification",
301+
content="Ceci est un test de notification",
302+
# The notification will expire in 3 days
303+
expire_on=datetime.now(UTC) + timedelta(days=3),
304+
delivery_datetime=datetime.now(UTC) + timedelta(minutes=3),
305+
)
301306
await notification_tool.send_notification_to_user(
302307
user_id=user.id,
303308
message=message,

app/core/notification/notification_types.py

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,6 @@ class Topic(str, Enum):
88

99
cinema = "cinema"
1010
advert = "advert"
11-
bookingadmin = "bookingadmin"
1211
amap = "amap"
1312
booking = "booking"
1413
event = "event"

app/modules/advert/endpoints_advert.py

Lines changed: 13 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
import logging
22
import uuid
3-
from datetime import UTC, datetime
3+
from datetime import UTC, datetime, timedelta
44

55
from fastapi import Depends, File, HTTPException, Query, UploadFile
66
from fastapi.responses import FileResponse
@@ -265,23 +265,19 @@ async def create_advert(
265265
result = await cruds_advert.create_advert(db_advert=db_advert, db=db)
266266
except ValueError as error:
267267
raise HTTPException(status_code=400, detail=str(error))
268+
message = Message(
269+
context=f"advert-new-{id}",
270+
is_visible=True,
271+
title=f"📣 Annonce - {result.title}",
272+
content=result.content,
273+
# The notification will expire in 3 days
274+
expire_on=datetime.now(UTC) + timedelta(days=3),
275+
)
268276

269-
try:
270-
now = datetime.now(UTC)
271-
message = Message(
272-
context=f"advert-{result.id}",
273-
is_visible=True,
274-
title=f"📣 Annonce - {result.title}",
275-
content=result.content,
276-
# The notification will expire in 3 days
277-
expire_on=now.replace(day=now.day + 3),
278-
)
279-
await notification_tool.send_notification_to_topic(
280-
custom_topic=CustomTopic(topic=Topic.advert),
281-
message=message,
282-
)
283-
except Exception as error:
284-
hyperion_error_logger.error(f"Error while sending advert notification, {error}")
277+
await notification_tool.send_notification_to_topic(
278+
custom_topic=CustomTopic(Topic.advert),
279+
message=message,
280+
)
285281

286282
return result
287283

app/modules/amap/endpoints_amap.py

Lines changed: 28 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
import logging
22
import uuid
3-
from datetime import UTC, datetime
3+
from datetime import UTC, datetime, timedelta
44

55
from fastapi import Depends, HTTPException, Response
66
from redis import Redis
@@ -9,6 +9,7 @@
99
from app.core import models_core
1010
from app.core.groups.groups_type import GroupType
1111
from app.core.module import Module
12+
from app.core.notification.notification_types import CustomTopic, Topic
1213
from app.core.notification.schemas_notification import Message
1314
from app.core.users import cruds_users
1415
from app.core.users.endpoints_users import read_user
@@ -706,6 +707,7 @@ async def open_ordering_of_delivery(
706707
delivery_id: str,
707708
db: AsyncSession = Depends(get_db),
708709
user: models_core.CoreUser = Depends(is_user_a_member_of(GroupType.amap)),
710+
notification_tool: NotificationTool = Depends(get_notification_tool),
709711
):
710712
delivery = await cruds_amap.get_delivery_by_id(db=db, delivery_id=delivery_id)
711713
if delivery is None:
@@ -719,6 +721,19 @@ async def open_ordering_of_delivery(
719721

720722
await cruds_amap.open_ordering_of_delivery(delivery_id=delivery_id, db=db)
721723

724+
message = Message(
725+
context=f"amap-open-ordering-{delivery_id}",
726+
is_visible=True,
727+
title="🛒 AMAP - Nouvelle livraison disponible",
728+
content="Viens commander !",
729+
# The notification will expire in 3 days
730+
expire_on=datetime.now(UTC) + timedelta(days=3),
731+
)
732+
await notification_tool.send_notification_to_topic(
733+
custom_topic=CustomTopic(Topic.amap),
734+
message=message,
735+
)
736+
722737

723738
@module.router.post(
724739
"/amap/deliveries/{delivery_id}/lock",
@@ -891,23 +906,18 @@ async def create_cash_of_user(
891906
db=db,
892907
)
893908

894-
try:
895-
if result:
896-
now = datetime.now(UTC)
897-
message = Message(
898-
context=f"amap-cash-{user_id}",
899-
is_visible=True,
900-
title="AMAP - Solde mis à jour",
901-
content=f"Votre nouveau solde est de {result.balance} €.",
902-
# The notification will expire in 3 days
903-
expire_on=now.replace(day=now.day + 3),
904-
)
905-
await notification_tool.send_notification_to_user(
906-
user_id=user_id,
907-
message=message,
908-
)
909-
except Exception as error:
910-
hyperion_error_logger.error(f"Error while sending AMAP notification, {error}")
909+
message = Message(
910+
context=f"amap-cash-{user_id}",
911+
is_visible=True,
912+
title="AMAP - Solde mis à jour",
913+
content=f"Votre nouveau solde est de {cash} €.",
914+
# The notification will expire in 3 days
915+
expire_on=datetime.now(UTC) + timedelta(days=3),
916+
)
917+
await notification_tool.send_notification_to_user(
918+
user_id=user_id,
919+
message=message,
920+
)
911921

912922
return result
913923

app/modules/booking/endpoints_booking.py

Lines changed: 27 additions & 23 deletions
Original file line numberDiff line numberDiff line change
@@ -1,14 +1,15 @@
11
import logging
22
import uuid
3-
from datetime import UTC, datetime
3+
from datetime import UTC, datetime, timedelta
4+
from zoneinfo import ZoneInfo
45

56
from fastapi import Depends, HTTPException
67
from sqlalchemy.ext.asyncio import AsyncSession
78

89
from app.core import models_core
10+
from app.core.groups import cruds_groups
911
from app.core.groups.groups_type import GroupType
1012
from app.core.module import Module
11-
from app.core.notification.notification_types import CustomTopic, Topic
1213
from app.core.notification.schemas_notification import Message
1314
from app.dependencies import (
1415
get_db,
@@ -265,26 +266,29 @@ async def create_booking(
265266
)
266267
await cruds_booking.create_booking(booking=db_booking, db=db)
267268
result = await cruds_booking.get_booking_by_id(db=db, booking_id=db_booking.id)
269+
manager_group_id = result.room.manager_id
270+
manager_group = await cruds_groups.get_group_by_id(
271+
db=db,
272+
group_id=manager_group_id,
273+
)
274+
local_start = result.start.astimezone(ZoneInfo("Europe/Paris"))
275+
applicant_nickname = user.nickname if user.nickname else user.firstname
276+
content = f"{applicant_nickname} - {result.room.name} {local_start.strftime('%m/%d/%Y, %H:%M')} - {result.reason}"
277+
# Setting time to Paris timezone in order to have the correct time in the notification
278+
279+
if manager_group:
280+
message = Message(
281+
context=f"booking-new-{id}",
282+
is_visible=True,
283+
title="📅 Réservations - Nouvelle réservation",
284+
content=content,
285+
# The notification will expire in 3 days
286+
expire_on=datetime.now(UTC) + timedelta(days=3),
287+
)
268288

269-
try:
270-
if result:
271-
now = datetime.now(UTC)
272-
message = Message(
273-
# We use sunday date as context to avoid sending the recap twice
274-
context=f"booking-create-{result.id}",
275-
is_visible=True,
276-
title="Réservations - Nouvelle réservation 📅",
277-
content=f"{result.applicant.nickname} - {result.room.name} {result.start.strftime('%m/%d/%Y, %H:%M')} - {result.reason}",
278-
# The notification will expire the next sunday
279-
expire_on=now.replace(day=now.day + 3),
280-
)
281-
await notification_tool.send_notification_to_topic(
282-
custom_topic=CustomTopic(topic=Topic.bookingadmin),
283-
message=message,
284-
)
285-
except Exception as error:
286-
hyperion_error_logger.error(
287-
f"Error while sending cinema recap notification, {error}",
289+
await notification_tool.send_notification_to_users(
290+
user_ids=[user.id for user in manager_group.members],
291+
message=message,
288292
)
289293

290294
return result
@@ -326,7 +330,7 @@ async def edit_booking(
326330
db=db,
327331
)
328332
except ValueError as error:
329-
raise HTTPException(status_code=422, detail=str(error))
333+
raise HTTPException(status_code=400, detail=str(error))
330334

331335

332336
@module.router.patch(
@@ -434,7 +438,7 @@ async def create_room(
434438
)
435439
return await cruds_booking.create_room(db=db, room=room_db)
436440
except ValueError as error:
437-
raise HTTPException(status_code=422, detail=str(error))
441+
raise HTTPException(status_code=400, detail=str(error))
438442

439443

440444
@module.router.patch(

0 commit comments

Comments
 (0)