-
Notifications
You must be signed in to change notification settings - Fork 10
ASA Go: Send FCM push notifications when fire behaviour advisories are issued #5246
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: main
Are you sure you want to change the base?
Changes from 63 commits
7f05edf
916a864
344e413
0d6bb73
01326fd
abbf802
7f53e74
cbf12d9
6f94abb
4f70114
b9c5be7
926dc60
4549426
7fd7ed2
5beda5c
d2b7194
20dd931
2a3bcb4
4693591
7092310
1b527e4
7196112
28c6b7a
f9f1e7a
962ed4e
f724bcb
de1fd00
99b98d7
055b43c
7e88251
65a3e97
66e71b4
b40dc98
f62f4ef
6b3e975
4793133
5abf8ee
848a848
f751485
0ce34db
2a9d3e9
76745d0
5b404e1
d7be929
40df06d
fe2eb8c
3d3f4b3
639c48d
f94fa80
6cab844
10917ef
d682f2f
52c457e
e410bc2
f903c48
ffed8cd
d591d03
8028e5f
ad23ba2
2dd5621
4bbc3c5
dae425c
020a42c
d096cb4
8c6e2ba
c364e54
c2855cb
7cabde8
0a41807
7c01f34
813126f
40c5336
efca4c4
a2d40c9
ceda0b3
9b8ef97
b401ed8
64a4871
a7f087a
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,19 @@ | ||
| meta { | ||
| name: Get Notification Settings | ||
| type: http | ||
| seq: 1 | ||
| } | ||
|
|
||
| get { | ||
| url: {{API_BASE_URL}}/device/notification-settings?device_id=9242842270074025 | ||
| body: none | ||
| auth: bearer | ||
| } | ||
|
|
||
| params:query { | ||
| device_id: 9242842270074025 | ||
| } | ||
|
|
||
| auth:bearer { | ||
| token: {{AUTH_TOKEN}} | ||
| } |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,22 @@ | ||
| meta { | ||
| name: Update Notification Settings | ||
| type: http | ||
| seq: 2 | ||
| } | ||
|
|
||
| post { | ||
| url: {{API_BASE_URL}}/device/notification-settings | ||
| body: json | ||
| auth: bearer | ||
| } | ||
|
|
||
| auth:bearer { | ||
| token: {{AUTH_TOKEN}} | ||
| } | ||
|
|
||
| body:json { | ||
| { | ||
| "device_id": "9242842270074025", | ||
| "fire_zone_source_ids": ["24"] | ||
| } | ||
| } |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,6 @@ | ||
| vars { | ||
| API_BASE_URL: https://wps-pr-5246-e1e498-dev.apps.silver.devops.gov.bc.ca/api | ||
| } | ||
| vars:secret [ | ||
| AUTH_TOKEN | ||
| ] |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,134 @@ | ||
| import asyncio | ||
| import logging | ||
| from datetime import date, datetime, timedelta, timezone | ||
|
|
||
| from firebase_admin import exceptions as firebase_exceptions | ||
| from firebase_admin import messaging | ||
| from sqlalchemy.ext.asyncio import AsyncSession | ||
| from wps_shared.db.crud.auto_spatial_advisory import ZoneAdvisoryStatus, get_zones_with_advisories | ||
| from wps_shared.db.crud.fcm import get_device_tokens_for_zone, update_device_tokens_are_active | ||
| from wps_shared.db.models.auto_spatial_advisory import RunTypeEnum | ||
| from wps_shared.utils.time import get_vancouver_now | ||
|
|
||
| logger = logging.getLogger(__name__) | ||
|
|
||
| FCM_BATCH_SIZE = 500 | ||
|
|
||
|
|
||
| def build_notification_content(zone_with_advisory: ZoneAdvisoryStatus, for_date: date): | ||
| return f"{for_date.strftime('%a, %B %-d')} - An advisory has been issued for {zone_with_advisory.placename_label}" | ||
|
|
||
|
|
||
| def build_notification_title(zone_with_advisory: ZoneAdvisoryStatus): | ||
| zone = zone_with_advisory.placename_label.split("-")[0] | ||
| return f"Fire Behaviour Advisory, {zone}" | ||
|
|
||
|
|
||
| def build_fcm_message( | ||
| for_date: date, zone_with_advisory: ZoneAdvisoryStatus, device_tokens: list[str] | ||
| ): | ||
| title = build_notification_title(zone_with_advisory) | ||
| content = build_notification_content(zone_with_advisory, for_date) | ||
| tag = f"advisory-{zone_with_advisory.source_identifier}" | ||
brettedw marked this conversation as resolved.
Show resolved
Hide resolved
|
||
| ttl = timedelta(days=2) | ||
| apns_expiration = str(int((datetime.now(timezone.utc) + ttl).timestamp())) | ||
| message = messaging.MulticastMessage( | ||
| notification=messaging.Notification(title=title, body=content), | ||
| android=messaging.AndroidConfig( | ||
| ttl=ttl, notification=messaging.AndroidNotification(tag=tag) | ||
| ), | ||
| apns=messaging.APNSConfig( | ||
| headers={"apns-expiration": apns_expiration}, | ||
| payload=messaging.APNSPayload(aps=messaging.Aps(thread_id=tag)), | ||
| ), | ||
| tokens=device_tokens, | ||
| ) | ||
|
|
||
| return message | ||
|
|
||
|
|
||
| async def trigger_notifications( | ||
| session: AsyncSession, run_type: RunTypeEnum, run_datetime: datetime, for_date: date | ||
| ) -> None: | ||
| if run_type == RunTypeEnum.actual: | ||
| return | ||
|
|
||
| if for_date != get_vancouver_now().date(): | ||
|
Collaborator
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. I think we only want to send notifications in the morning (the 08:00 forecast SFMS run). I think this logic will also result in notifications being sent during the 3:45pm forecast SFMS run.
Collaborator
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. So what we could do is after the
Collaborator
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. I think this comment still needs addressing
Collaborator
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. This was left unchanged in case y'all wanted to test notifications locally again, I can address it now if satisified. |
||
| logger.info("Skipping FCM notifications: for_date=%s is not today", for_date) | ||
| return | ||
|
|
||
| logger.info("Checking for warnings/advisories to send FCM notifications for") | ||
| zones_with_advisories = await get_zones_with_advisories( | ||
| session, run_type, run_datetime, for_date | ||
| ) | ||
| logger.info( | ||
| f"{len(zones_with_advisories)} have warnings/advisories, checking for devices to notify" | ||
| ) | ||
| for zone_with_advisory in zones_with_advisories: | ||
| if not zone_with_advisory.placename_label: | ||
| logger.error( | ||
| "Skipping FCM notification: missing placename_label for zone source_identifier=%s", | ||
| zone_with_advisory.source_identifier, | ||
| ) | ||
| continue | ||
| device_tokens = await get_device_tokens_for_zone( | ||
| session, zone_with_advisory.source_identifier | ||
| ) | ||
| if len(device_tokens) == 0: | ||
| logger.info(f"No devices subscribed to {zone_with_advisory.placename_label}") | ||
| continue | ||
| logger.info( | ||
| f"{len(device_tokens)} are subscribed to {zone_with_advisory.placename_label} about to notify" | ||
| ) | ||
| for i in range(0, len(device_tokens), FCM_BATCH_SIZE): | ||
| batch = device_tokens[i : i + FCM_BATCH_SIZE] | ||
| message = build_fcm_message(for_date, zone_with_advisory, batch) | ||
| try: | ||
| logger.info(f"Notifiying {len(batch)} devices") | ||
| # messaging.send_each_for_multicast is a synchronous blocking call | ||
| response = await asyncio.to_thread(messaging.send_each_for_multicast, message) | ||
| except firebase_exceptions.FirebaseError: | ||
| logger.exception( | ||
| "FCM send failed for zone=%s date=%s token_count=%d", | ||
| zone_with_advisory.placename_label, | ||
| for_date, | ||
| len(batch), | ||
| ) | ||
| continue | ||
| await handle_fcm_response( | ||
| session, for_date, zone_with_advisory.placename_label, batch, response | ||
| ) | ||
|
|
||
|
|
||
| async def handle_fcm_response( | ||
| session, | ||
dgboss marked this conversation as resolved.
Outdated
Show resolved
Hide resolved
|
||
| for_date, | ||
dgboss marked this conversation as resolved.
Outdated
Show resolved
Hide resolved
|
||
| placename_label: str, | ||
| device_tokens: list[str], | ||
| response: messaging.BatchResponse, | ||
| ): | ||
| logger.info( | ||
| f"Received FCM response with successful notifications sent: {response.success_count}" | ||
| ) | ||
| # Only deactivate permanently invalid tokens (UnregisteredError — token is no longer registered). | ||
| # Transient failures (quota, server errors) are not deactivated; the token remains valid. | ||
| # Deactivated tokens are re-activated when the app re-registers on open. | ||
| permanently_failed = [ | ||
| device_tokens[idx] | ||
| for idx, resp in enumerate(response.responses) | ||
| if not resp.success and isinstance(resp.exception, messaging.UnregisteredError) | ||
| ] | ||
| transient_failed_count = response.failure_count - len(permanently_failed) | ||
|
|
||
| if response.failure_count > 0: | ||
| logger.warning( | ||
| "FCM send for zone=%s date=%s: %d permanent failures, %d transient failures out of %d tokens", | ||
| placename_label, | ||
| for_date, | ||
| len(permanently_failed), | ||
| transient_failed_count, | ||
| len(device_tokens), | ||
| ) | ||
|
|
||
| if permanently_failed: | ||
| await update_device_tokens_are_active(session, permanently_failed, False) | ||
Uh oh!
There was an error while loading. Please reload this page.