diff --git a/rezervo/alembic/versions/ccf2b2be66fc_scheduled_push_notifications.py b/rezervo/alembic/versions/ccf2b2be66fc_scheduled_push_notifications.py new file mode 100644 index 0000000..dd7ce5e --- /dev/null +++ b/rezervo/alembic/versions/ccf2b2be66fc_scheduled_push_notifications.py @@ -0,0 +1,86 @@ +"""scheduled push notifications + +Revision ID: ccf2b2be66fc +Revises: 27d034ace1bf +Create Date: 2026-07-06 22:26:06.383649 + +""" + +import sqlalchemy as sa +from alembic import op +from sqlalchemy.dialects import postgresql + +# revision identifiers, used by Alembic. +revision = "ccf2b2be66fc" +down_revision = "27d034ace1bf" +branch_labels = None +depends_on = None + + +def upgrade() -> None: + op.create_table( + "scheduled_push_notifications", + sa.Column("id", sa.UUID(), nullable=False), + sa.Column("user_id", sa.UUID(), nullable=False), + sa.Column("message", sa.String(), nullable=False), + sa.Column("send_at", sa.DateTime(), nullable=False), + sa.Column("cancellation_key", sa.String(), nullable=True), + sa.ForeignKeyConstraint(["user_id"], ["users.id"], ondelete="cascade"), + sa.PrimaryKeyConstraint("id"), + ) + op.create_index( + op.f("ix_scheduled_push_notifications_cancellation_key"), + "scheduled_push_notifications", + ["cancellation_key"], + unique=False, + ) + op.create_index( + op.f("ix_scheduled_push_notifications_id"), + "scheduled_push_notifications", + ["id"], + unique=False, + ) + op.create_index( + op.f("ix_scheduled_push_notifications_send_at"), + "scheduled_push_notifications", + ["send_at"], + unique=False, + ) + op.create_index( + op.f("ix_scheduled_push_notifications_user_id"), + "scheduled_push_notifications", + ["user_id"], + unique=False, + ) + op.add_column( + "push_notification_subscriptions", + sa.Column( + "grants", + postgresql.JSONB(astext_type=sa.Text()), + nullable=False, + server_default=sa.text( + '\'{"booking": false, "community": false, "reminder": false}\'::jsonb' + ), + ), + ) + + +def downgrade() -> None: + op.drop_column("push_notification_subscriptions", "grants") + op.drop_index( + op.f("ix_scheduled_push_notifications_user_id"), + table_name="scheduled_push_notifications", + ) + op.drop_index( + op.f("ix_scheduled_push_notifications_send_at"), + table_name="scheduled_push_notifications", + ) + op.drop_index( + op.f("ix_scheduled_push_notifications_id"), + table_name="scheduled_push_notifications", + ) + op.drop_index( + op.f("ix_scheduled_push_notifications_cancellation_key"), + table_name="scheduled_push_notifications", + ) + op.drop_table("scheduled_push_notifications") diff --git a/rezervo/api/community.py b/rezervo/api/community.py index 0c18e3b..b89f7ee 100644 --- a/rezervo/api/community.py +++ b/rezervo/api/community.py @@ -4,7 +4,6 @@ from rezervo.api.common import get_db, token_auth_scheme from rezervo.database import crud -from rezervo.database.crud import get_user_config_by_id from rezervo.notify.push import notify_friend_request_web_push from rezervo.schemas.community import ( Community, @@ -44,11 +43,19 @@ def update_relationship( ) if updated_relationship is UserRelationship.REQUEST_SENT: - receiver_push_subscriptions = get_user_config_by_id( # type: ignore - db, payload.user_id - ).config.notifications.push_notification_subscriptions - if receiver_push_subscriptions is not None: - for subscription in receiver_push_subscriptions: - notify_friend_request_web_push(subscription, db_user.name) + receiver_config = crud.get_user_config_by_id(db, payload.user_id) + receiver_notifications = ( + receiver_config.config.notifications + if receiver_config is not None + else None + ) + if receiver_notifications is not None: + receiver_push_subscriptions = ( + receiver_notifications.push_notification_subscriptions + ) + if receiver_push_subscriptions is not None: + for subscription in receiver_push_subscriptions: + if subscription.grants.community: + notify_friend_request_web_push(subscription, db_user.name) return updated_relationship diff --git a/rezervo/api/features.py b/rezervo/api/features.py index 9a25550..f45545f 100644 --- a/rezervo/api/features.py +++ b/rezervo/api/features.py @@ -13,7 +13,7 @@ class Features(CamelModel): - class_reminder_notifications: bool + slack_connected: bool @router.get("/features", response_model=Features) @@ -27,7 +27,7 @@ def get_features( raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED) admin_config = AdminConfig(**db_user.admin_config) return Features( - class_reminder_notifications=( + slack_connected=( admin_config.notifications is not None and admin_config.notifications.slack is not None and admin_config.notifications.slack.user_id is not None diff --git a/rezervo/api/notifications/push.py b/rezervo/api/notifications/push.py index aa94e3f..ccdf3ee 100644 --- a/rezervo/api/notifications/push.py +++ b/rezervo/api/notifications/push.py @@ -5,7 +5,11 @@ from rezervo.api.common import get_db, token_auth_scheme from rezervo.database import crud from rezervo.schemas.config.app import AppConfig -from rezervo.schemas.config.config import PushNotificationSubscription, read_app_config +from rezervo.schemas.config.config import ( + PushNotificationGrants, + PushNotificationSubscription, + read_app_config, +) router = APIRouter() @@ -56,14 +60,17 @@ def unsubscribe_from_push_notifications( return None -@router.post("/notifications/push/verify", response_model=bool) +@router.post("/notifications/push/verify", response_model=PushNotificationGrants | None) def verify_push_notifications_subscription( subscription: PushNotificationSubscription, token=Depends(token_auth_scheme), db: Session = Depends(get_db), app_config: AppConfig = Depends(read_app_config), -): +) -> PushNotificationGrants | None: db_user = crud.user_from_token(db, app_config, token) if db_user is None: raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED) - return crud.verify_push_notification_subscription(db, db_user.id, subscription) + for existing in crud.get_user_push_notification_subscriptions(db, db_user.id): + if existing.endpoint == subscription.endpoint: + return existing.grants + return None diff --git a/rezervo/api/preferences.py b/rezervo/api/preferences.py index 79dbc0c..c50584a 100644 --- a/rezervo/api/preferences.py +++ b/rezervo/api/preferences.py @@ -4,6 +4,7 @@ from rezervo.api.common import get_db, token_auth_scheme from rezervo.database import crud +from rezervo.notify.notify import reconcile_scheduled_push_reminders from rezervo.schemas.config.app import AppConfig from rezervo.schemas.config.config import read_app_config from rezervo.schemas.config.user import UserPreferences @@ -36,4 +37,5 @@ def upsert_user_preferences( db_user.preferences = preferences.model_dump() db.commit() db.refresh(db_user) + reconcile_scheduled_push_reminders(db, db_user.id, preferences.notifications) return db_user.preferences diff --git a/rezervo/chains/common.py b/rezervo/chains/common.py index 195c0b5..fb92678 100644 --- a/rezervo/chains/common.py +++ b/rezervo/chains/common.py @@ -2,6 +2,7 @@ from rezervo import models from rezervo.chains.active import get_chain +from rezervo.database import crud from rezervo.database.database import SessionLocal from rezervo.errors import AuthenticationError, BookingError from rezervo.notify.slack import delete_scheduled_dm_slack, notify_cancellation_slack @@ -59,6 +60,10 @@ async def cancel_booking( auth_data, _class, config, user_id ) if res is None: + with SessionLocal() as db: + crud.delete_scheduled_push_notifications_for_class( + db, user_id, chain_identifier, _class.id + ) if config.notifications is not None and config.notifications.slack is not None: update_slack_notifications_with_cancellation( chain_identifier, _class, config.notifications.slack diff --git a/rezervo/cli/cli.py b/rezervo/cli/cli.py index 1cdc1a6..ce5af5e 100644 --- a/rezervo/cli/cli.py +++ b/rezervo/cli/cli.py @@ -21,6 +21,7 @@ from rezervo.errors import AuthenticationError, BookingError from rezervo.notify.apprise import aprs from rezervo.notify.notify import notify_auth_failure, notify_booking_failure +from rezervo.notify.scheduled import send_due_scheduled_push_notifications from rezervo.schemas.config.user import ( ChainIdentifier, ) @@ -274,6 +275,14 @@ def purge_slack_receipts_cli(): log.debug("No expired Slack notification receipts") +@cli.command(name="process_scheduled_push") +def process_scheduled_push_cli(): + """ + Dispatch scheduled web push notifications that are due (e.g. class reminders) + """ + send_due_scheduled_push_notifications() + + @cli.command(name="extend_auth_sessions") async def extend_auth_sessions_cli(): """ diff --git a/rezervo/cli/cron.py b/rezervo/cli/cron.py index 591b072..862e447 100644 --- a/rezervo/cli/cron.py +++ b/rezervo/cli/cron.py @@ -62,6 +62,12 @@ def initialize_cron(): schedule="0 0 * * *", comment="purge slack receipts", ) + upsert_cli_cron_job( + crontab, + command="process_scheduled_push", + schedule="* * * * *", + comment="process scheduled push notifications", + ) upsert_cli_cron_job( crontab, command="purge_playwright", diff --git a/rezervo/database/crud.py b/rezervo/database/crud.py index f04d5a3..fbedb9d 100644 --- a/rezervo/database/crud.py +++ b/rezervo/database/crud.py @@ -21,6 +21,7 @@ from rezervo.schemas.config.app import AppConfig from rezervo.schemas.config.config import ( Config, + PushNotificationGrants, PushNotificationSubscription, PushNotificationSubscriptionKeys, config_from_stored, @@ -36,7 +37,10 @@ UserPreferences, config_from_chain_user, ) -from rezervo.schemas.schedule import UserSession, session_model_from_user_session +from rezervo.schemas.schedule import ( + UserSession, + session_model_from_user_session, +) from rezervo.utils.ical_utils import generate_calendar_token @@ -340,6 +344,7 @@ def get_user_push_notification_subscriptions( PushNotificationSubscription( endpoint=db_subscription.endpoint, keys=PushNotificationSubscriptionKeys(**db_subscription.keys), + grants=PushNotificationGrants(**db_subscription.grants), ) for db_subscription in db.query(models.PushNotificationSubscription).filter_by( user_id=user_id @@ -347,6 +352,13 @@ def get_user_push_notification_subscriptions( ] +def user_has_push_notification_subscriptions(db, user_id: UUID) -> bool: + return ( + db.query(models.PushNotificationSubscription).filter_by(user_id=user_id).first() + is not None + ) + + def update_last_used_push_notification_subscription( db, subscription: PushNotificationSubscription ): @@ -398,10 +410,12 @@ def upsert_push_notification_subscription( user_id=user_id, endpoint=subscription.endpoint, keys=subscription.keys.model_dump(), + grants=subscription.grants.model_dump(), ) db.add(db_subscription) else: - db_subscription.keys = subscription.keys + db_subscription.keys = subscription.keys.model_dump() + db_subscription.grants = subscription.grants.model_dump() db.commit() db.refresh(db_subscription) return db_subscription @@ -424,30 +438,95 @@ def delete_push_notification_subscription( return True -def verify_push_notification_subscription( - db, user_id: UUID, subscription: PushNotificationSubscription -) -> bool: +def purge_slack_receipts(db) -> int: + row_count = ( + db.query(models.SlackClassNotificationReceipt) + .filter(models.SlackClassNotificationReceipt.expires_at < datetime.now()) + .delete() + ) + db.commit() + return row_count + + +def class_reminder_cancellation_key( + chain_identifier: ChainIdentifier, class_id: str +) -> str: + return f"{chain_identifier}:{class_id}" + + +def add_scheduled_push_notification( + db: Session, + user_id: UUID, + message: str, + send_at: datetime, + cancellation_key: str | None = None, +) -> models.ScheduledPushNotification: + scheduled = models.ScheduledPushNotification( + user_id=user_id, + message=message, + send_at=send_at, + cancellation_key=cancellation_key, + ) + db.add(scheduled) + db.commit() + db.refresh(scheduled) + return scheduled + + +def get_due_scheduled_push_notifications( + db: Session, now: datetime +) -> list[models.ScheduledPushNotification]: return ( - db.query(models.PushNotificationSubscription) + db.query(models.ScheduledPushNotification) + .filter(models.ScheduledPushNotification.send_at <= now) + .all() + ) + + +def delete_scheduled_push_notifications_for_class( + db: Session, user_id: UUID, chain_identifier: ChainIdentifier, class_id: str +) -> int: + row_count = ( + db.query(models.ScheduledPushNotification) .filter_by( user_id=user_id, - endpoint=subscription.endpoint, - keys=subscription.keys.model_dump(), + cancellation_key=class_reminder_cancellation_key( + chain_identifier, class_id + ), ) - .one_or_none() - ) is not None + .delete() + ) + db.commit() + return row_count -def purge_slack_receipts(db) -> int: +def delete_scheduled_push_reminders_for_user(db: Session, user_id: UUID) -> int: row_count = ( - db.query(models.SlackClassNotificationReceipt) - .filter(models.SlackClassNotificationReceipt.expires_at < datetime.now()) + db.query(models.ScheduledPushNotification) + .filter( + models.ScheduledPushNotification.user_id == user_id, + models.ScheduledPushNotification.cancellation_key.isnot(None), + ) .delete() ) db.commit() return row_count +def get_upcoming_booked_sessions(db: Session, user_id: UUID) -> list[UserSession]: + now = datetime.now().astimezone() + db_sessions = ( + db.query(models.Session) + .filter( + models.Session.user_id == user_id, + models.Session.status.in_([SessionState.BOOKED, SessionState.WAITLIST]), + ) + .all() + ) + sessions = [UserSession.model_validate(s) for s in db_sessions] + return [s for s in sessions if s.class_data.start_time > now] + + def get_user_relationship_index(db: Session, user_id: UUID): relationships = ( db.query(UserRelation) diff --git a/rezervo/models.py b/rezervo/models.py index 5a11a09..a4147ee 100644 --- a/rezervo/models.py +++ b/rezervo/models.py @@ -62,6 +62,7 @@ class PushNotificationSubscription(Base): ) endpoint: Mapped[str] = mapped_column(primary_key=True) keys: Mapped[dict] = mapped_column() + grants: Mapped[dict] = mapped_column() last_used: Mapped[datetime | None] = mapped_column() def __repr__(self): @@ -179,6 +180,27 @@ def __repr__(self): ) +class ScheduledPushNotification(Base): + __tablename__ = "scheduled_push_notifications" + + id: Mapped[uuid.UUID] = mapped_column( + primary_key=True, index=True, default=uuid.uuid4 + ) + user_id: Mapped[uuid.UUID] = mapped_column( + ForeignKey("users.id", ondelete="cascade"), index=True + ) + message: Mapped[str] = mapped_column() + send_at: Mapped[datetime] = mapped_column(index=True) + cancellation_key: Mapped[str | None] = mapped_column(index=True) + + def __repr__(self): + return ( + f"" + ) + + class UserRelation(Base): __tablename__ = "user_relations" id: Mapped[uuid.UUID] = mapped_column( diff --git a/rezervo/notify/notify.py b/rezervo/notify/notify.py index 7e11870..625a803 100644 --- a/rezervo/notify/notify.py +++ b/rezervo/notify/notify.py @@ -2,14 +2,22 @@ from enum import Enum from uuid import UUID +from sqlalchemy.orm import Session + from rezervo.database.crud import ( + add_scheduled_push_notification, + class_reminder_cancellation_key, + delete_scheduled_push_reminders_for_user, get_friend_ids_in_class, + get_upcoming_booked_sessions, get_user, get_user_push_notification_subscriptions, + user_has_push_notification_subscriptions, ) from rezervo.database.database import SessionLocal from rezervo.errors import AuthenticationError, BookingError from rezervo.notify.push import ( + build_class_reminder_message, notify_auth_failure_web_push, notify_booking_failure_web_push, notify_booking_web_push, @@ -23,9 +31,17 @@ schedule_class_reminder_slack, ) from rezervo.notify.types import AllowedTimeWindow +from rezervo.notify.utils import compute_reminder_datetime from rezervo.schemas.config import config -from rezervo.schemas.config.user import AllowedTimeWindowConfig, ChainIdentifier, Class -from rezervo.schemas.schedule import RezervoClass +from rezervo.schemas.config.user import ( + AllowedTimeWindowConfig, + ChainIdentifier, + Class, +) +from rezervo.schemas.config.user import ( + Notifications as UserNotifications, +) +from rezervo.schemas.schedule import BaseRezervoClass, RezervoClass from rezervo.utils.logging_utils import log @@ -38,8 +54,9 @@ def notify_auth_failure( push_subscriptions = notifications_config.push_notification_subscriptions if push_subscriptions is not None: for subscription in push_subscriptions: - notify_auth_failure_web_push(subscription, error, check_run) - notified = True + if subscription.grants.booking: + notify_auth_failure_web_push(subscription, error, check_run) + notified = True slack_config = notifications_config.slack if slack_config is not None and slack_config.user_id is not None: notify_auth_failure_slack( @@ -66,10 +83,11 @@ def notify_booking_failure( push_subscriptions = notifications_config.push_notification_subscriptions if push_subscriptions is not None: for subscription in push_subscriptions: - notify_booking_failure_web_push( - subscription, _class_config, error, check_run - ) - notified = True + if subscription.grants.booking: + notify_booking_failure_web_push( + subscription, _class_config, error, check_run + ) + notified = True slack_config = notifications_config.slack if slack_config is not None and slack_config.user_id is not None: notify_booking_failure_slack( @@ -91,21 +109,21 @@ async def notify_booking( notifications_config: config.Notifications, chain_identifier: ChainIdentifier, booked_class: RezervoClass, + user_id: UUID, ical_url: str | None = None, ) -> None: notified = False push_subscriptions = notifications_config.push_notification_subscriptions if push_subscriptions is not None: for subscription in push_subscriptions: - notify_booking_web_push(subscription, booked_class) - notified = True + if subscription.grants.booking: + notify_booking_web_push(subscription, booked_class) + notified = True + scheduled_reminder_id = schedule_class_reminder( + notifications_config, user_id, chain_identifier, booked_class + ) slack_config = notifications_config.slack if slack_config is not None and slack_config.user_id is not None: - scheduled_reminder_id = None - if notifications_config.reminder_hours_before is not None: - scheduled_reminder_id = schedule_class_reminder( - notifications_config, chain_identifier, booked_class - ) if notifications_config.transfersh is not None: transfersh_url = notifications_config.transfersh.url else: @@ -128,20 +146,31 @@ async def notify_booking( def schedule_class_reminder( notifications_config: config.Notifications, + user_id: UUID, chain_identifier: ChainIdentifier, booked_class: RezervoClass, ) -> str | None: - slack_config = notifications_config.slack - if slack_config is not None and slack_config.user_id is not None: - if notifications_config.reminder_hours_before is None: - return None - time_window = ( - parse_allowed_time_window_config( - notifications_config.reminder_allowed_time_window + if notifications_config.reminder_hours_before is None: + return None + time_window = parse_allowed_time_window_config( + notifications_config.reminder_allowed_time_window + ) + with SessionLocal() as db: + if user_has_push_notification_subscriptions(db, user_id): + schedule_class_reminder_web_push( + db, + user_id, + chain_identifier, + booked_class, + notifications_config.reminder_hours_before, + time_window, ) - if notifications_config.reminder_allowed_time_window is not None - else None - ) + slack_config = notifications_config.slack + if ( + notifications_config.reminder_slack + and slack_config is not None + and slack_config.user_id is not None + ): return schedule_class_reminder_slack( slack_config.bot_token, slack_config.user_id, @@ -151,20 +180,67 @@ def schedule_class_reminder( notifications_config.reminder_hours_before, time_window, ) - log.warning("No notification targets, class reminder will not be sent") return None +def schedule_class_reminder_web_push( + db: Session, + user_id: UUID, + chain_identifier: ChainIdentifier, + booked_class: BaseRezervoClass, + hours_before: float, + time_window: AllowedTimeWindow | None, +) -> None: + reminder_datetime, hours_before = compute_reminder_datetime( + booked_class.start_time, hours_before, time_window + ) + add_scheduled_push_notification( + db, + user_id, + build_class_reminder_message(booked_class, hours_before), + reminder_datetime.astimezone().replace(tzinfo=None), + class_reminder_cancellation_key(chain_identifier, booked_class.id), + ) + + +def reconcile_scheduled_push_reminders( + db: Session, + user_id: UUID, + notifications_config: UserNotifications | None, +) -> None: + delete_scheduled_push_reminders_for_user(db, user_id) + if ( + notifications_config is None + or notifications_config.reminder_hours_before is None + or not user_has_push_notification_subscriptions(db, user_id) + ): + return + time_window = parse_allowed_time_window_config( + notifications_config.reminder_allowed_time_window + ) + for session in get_upcoming_booked_sessions(db, user_id): + schedule_class_reminder_web_push( + db, + user_id, + session.chain, + session.class_data, + notifications_config.reminder_hours_before, + time_window, + ) + + def parse_allowed_time_window_config( - window_config: AllowedTimeWindowConfig, + window_config: AllowedTimeWindowConfig | None, ) -> AllowedTimeWindow | None: + if window_config is None: + return None not_before = datetime.time( - hour=window_config.not_before.hour, minute=window_config.not_after.minute + hour=window_config.not_before.hour, minute=window_config.not_before.minute ) not_after = datetime.time( hour=window_config.not_after.hour, minute=window_config.not_after.minute ) - if not_before >= not_after: + if not_before == not_after: return None window = AllowedTimeWindow() window.not_after = not_after @@ -192,6 +268,8 @@ async def notify_class_friends( push_subscriptions = get_user_push_notification_subscriptions(db, friend_id) if push_subscriptions is not None: for subscription in push_subscriptions: + if not subscription.grants.community: + continue match notification_type: case ClassFriendNotificationType.BOOKING: notify_friend_of_booking_web_push( diff --git a/rezervo/notify/push.py b/rezervo/notify/push.py index ecb3eaf..c94c7ff 100644 --- a/rezervo/notify/push.py +++ b/rezervo/notify/push.py @@ -2,6 +2,7 @@ import json import re +import pytz from apprise import NotifyType from pywebpush import WebPushException, webpush # type: ignore[import-untyped] from requests import Response @@ -14,7 +15,7 @@ from rezervo.schemas.config.app import CONFIG_FILE from rezervo.schemas.config.config import PushNotificationSubscription, read_app_config from rezervo.schemas.config.user import Class -from rezervo.schemas.schedule import RezervoClass +from rezervo.schemas.schedule import BaseRezervoClass, RezervoClass from rezervo.utils.apprise_utils import aprs_ctx from rezervo.utils.logging_utils import log @@ -176,6 +177,17 @@ def notify_auth_failure_web_push( return +def build_class_reminder_message( + booked_class: BaseRezervoClass, hours_before: float +) -> str: + start_time = booked_class.start_time.astimezone(pytz.timezone("Europe/Oslo")) + return ( + f"Husk {booked_class.activity.name} " + f"({start_time.strftime('%Y-%m-%d %H:%M')}, {booked_class.location.studio}) " + f"om {hours_before:g} time{'r' if hours_before > 1 else ''}!" + ) + + def notify_friend_request_web_push( subscription: PushNotificationSubscription, sender_name: str ) -> None: diff --git a/rezervo/notify/scheduled.py b/rezervo/notify/scheduled.py new file mode 100644 index 0000000..564aab5 --- /dev/null +++ b/rezervo/notify/scheduled.py @@ -0,0 +1,32 @@ +import datetime + +from sqlalchemy.orm import Session + +from rezervo.database import crud +from rezervo.database.database import SessionLocal +from rezervo.models import ScheduledPushNotification +from rezervo.notify.push import notify_web_push +from rezervo.utils.logging_utils import log + + +def send_due_scheduled_push_notifications() -> None: + now = datetime.datetime.now() + with SessionLocal() as db: + due = crud.get_due_scheduled_push_notifications(db, now) + if not due: + log.debug("No due scheduled push notifications") + return + log.info(f"Processing {len(due)} due scheduled push notification(s)") + for scheduled in due: + _dispatch_scheduled_push_notification(db, scheduled) + db.delete(scheduled) + db.commit() + + +def _dispatch_scheduled_push_notification( + db: Session, scheduled: ScheduledPushNotification +) -> None: + subscriptions = crud.get_user_push_notification_subscriptions(db, scheduled.user_id) + for subscription in subscriptions: + if subscription.grants.reminder: + notify_web_push(subscription, scheduled.message) diff --git a/rezervo/notify/slack.py b/rezervo/notify/slack.py index fc10577..112685c 100644 --- a/rezervo/notify/slack.py +++ b/rezervo/notify/slack.py @@ -25,7 +25,7 @@ from rezervo.utils.logging_utils import log from .types import AllowedTimeWindow -from .utils import activity_url, upload_ical_to_transfersh +from .utils import activity_url, compute_reminder_datetime, upload_ical_to_transfersh def notify_slack( @@ -161,15 +161,9 @@ def schedule_class_reminder_slack( hours_before: float, time_window: AllowedTimeWindow | None, ) -> str | None: - reminder_datetime = _class.start_time - datetime.timedelta(hours=hours_before) - if time_window is not None: - reminder_datetime = window_backward_adjusted_datetime( - reminder_datetime, time_window - ) - reminder_datetime = max( - datetime.datetime.now() + datetime.timedelta(minutes=1), reminder_datetime - ) - hours_before = (_class.start_time - reminder_datetime).total_seconds() / 3600 + reminder_datetime, hours_before = compute_reminder_datetime( + _class.start_time, hours_before, time_window + ) reminder_timestamp = int(time.mktime(reminder_datetime.timetuple())) message = ( f"Husk *{activity_url(host, chain_identifier, _class)}* " @@ -179,24 +173,6 @@ def schedule_class_reminder_slack( return schedule_dm_slack(slack_token, user_id, reminder_timestamp, message) -def window_backward_adjusted_datetime( - dt: datetime.datetime, window: AllowedTimeWindow -) -> datetime.datetime: - """ - Move datetime backwards until within given time window - """ - - t = dt.time() - is_too_early = t < window.not_before - is_too_late = t > window.not_after - if not is_too_early and not is_too_late: - return dt - adjusted_dt = dt.replace(hour=window.not_after.hour, minute=window.not_after.minute) - if is_too_early: - adjusted_dt -= datetime.timedelta(days=1) - return adjusted_dt - - AUTH_FAILURE_REASONS = { AuthenticationError.INVALID_CREDENTIALS: "Ugyldig brukernavn eller passord :key:", AuthenticationError.AUTH_TEMPORARILY_BLOCKED: "Midlertidig utestengt :no_entry:", diff --git a/rezervo/notify/utils.py b/rezervo/notify/utils.py index e59782c..15f675c 100644 --- a/rezervo/notify/utils.py +++ b/rezervo/notify/utils.py @@ -1,7 +1,11 @@ +import datetime from urllib.parse import urlparse +import pytz + from rezervo.consts import URL_QUERY_PARAM_CLASS_ID, URL_QUERY_PARAM_ISO_WEEK from rezervo.http_client import HttpClient +from rezervo.notify.types import AllowedTimeWindow from rezervo.schemas.config.user import ChainIdentifier from rezervo.schemas.schedule import RezervoClass from rezervo.utils.time_utils import compact_iso_week_str @@ -23,6 +27,49 @@ def transfersh_direct_url(url: str): return u._replace(path=f"/get{u.path}").geturl() +def window_backward_adjusted_datetime( + dt: datetime.datetime, window: AllowedTimeWindow +) -> datetime.datetime: + """ + Move datetime backwards until within given time window, interpreting the + window in the local timezone it is configured in + """ + + tz = pytz.timezone("Europe/Oslo") + local_dt = dt.astimezone(tz) + t = local_dt.time() + if window.not_before <= window.not_after: + within_window = window.not_before <= t <= window.not_after + else: # window crosses midnight + within_window = t >= window.not_before or t <= window.not_after + if within_window: + return dt + adjusted_dt = local_dt.replace( + tzinfo=None, hour=window.not_after.hour, minute=window.not_after.minute + ) + if t < window.not_after: + adjusted_dt -= datetime.timedelta(days=1) + return tz.localize(adjusted_dt) + + +def compute_reminder_datetime( + class_start_time: datetime.datetime, + hours_before: float, + time_window: AllowedTimeWindow | None, +) -> tuple[datetime.datetime, float]: + reminder_datetime = class_start_time - datetime.timedelta(hours=hours_before) + if time_window is not None: + reminder_datetime = window_backward_adjusted_datetime( + reminder_datetime, time_window + ) + earliest = datetime.datetime.now(class_start_time.tzinfo) + datetime.timedelta( + minutes=1 + ) + reminder_datetime = max(earliest, reminder_datetime) + hours_before = (class_start_time - reminder_datetime).total_seconds() / 3600 + return reminder_datetime, hours_before + + def activity_url( host: str | None, chain_identifier: ChainIdentifier, _class: RezervoClass ): diff --git a/rezervo/providers/provider.py b/rezervo/providers/provider.py index b8204cb..afac46a 100644 --- a/rezervo/providers/provider.py +++ b/rezervo/providers/provider.py @@ -209,7 +209,10 @@ async def try_book_class( ) # TODO: clean this # ical_url = f"{ICAL_URL}/?id={_class.id}&token={token}" # TODO: consider re-introducing ical await notify_booking( - config.notifications, chain_identifier, time_zone_adjusted_class + config.notifications, + chain_identifier, + time_zone_adjusted_class, + user_id, ) await notify_class_friends_of_booking(user_id, time_zone_adjusted_class) return booking_result diff --git a/rezervo/schemas/config/config.py b/rezervo/schemas/config/config.py index a9a9e30..2228d2f 100644 --- a/rezervo/schemas/config/config.py +++ b/rezervo/schemas/config/config.py @@ -34,9 +34,18 @@ class PushNotificationSubscriptionKeys(OrmBase): auth: str +class PushNotificationGrants(OrmBase): + booking: bool = False + community: bool = False + reminder: bool = False + + class PushNotificationSubscription(OrmBase): endpoint: str keys: PushNotificationSubscriptionKeys + grants: PushNotificationGrants = pydantic.Field( + default_factory=PushNotificationGrants + ) class Notifications(user.Notifications, admin.Notifications, app.Notifications): diff --git a/rezervo/schemas/config/user.py b/rezervo/schemas/config/user.py index 6a566f2..ec36e9e 100644 --- a/rezervo/schemas/config/user.py +++ b/rezervo/schemas/config/user.py @@ -22,6 +22,7 @@ class AllowedTimeWindowConfig(CamelOrmBase): class Notifications(CamelOrmBase): + reminder_slack: bool = False reminder_hours_before: float | None = None reminder_allowed_time_window: AllowedTimeWindowConfig | None = None