diff --git a/api.saladeaula.digital/app/routes/enrollments/cancel.py b/api.saladeaula.digital/app/routes/enrollments/cancel.py index 2d7a462..7c263b4 100644 --- a/api.saladeaula.digital/app/routes/enrollments/cancel.py +++ b/api.saladeaula.digital/app/routes/enrollments/cancel.py @@ -1,8 +1,5 @@ -from typing import Annotated - from aws_lambda_powertools import Logger from aws_lambda_powertools.event_handler.api_gateway import Router -from aws_lambda_powertools.event_handler.openapi.params import Body from layercake.dateutils import now from layercake.dynamodb import DynamoDBPersistenceLayer, KeyPair @@ -17,10 +14,7 @@ dyn = DynamoDBPersistenceLayer(ENROLLMENT_TABLE, dynamodb_client) @router.patch('//cancel') -def cancel( - enrollment_id: str, - lock_hash: Annotated[str | None, Body(embed=True)] = None, -): +def cancel(enrollment_id: str): now_ = now() canceled_by: Authenticated = router.context['user'] @@ -50,33 +44,7 @@ def cancel( } ) transact.delete( - key=KeyPair( - pk=enrollment_id, - sk='CANCEL_POLICY', - ), + key=KeyPair(enrollment_id, 'CANCEL_POLICY'), cond_expr='attribute_exists(sk)', exc_cls=CancelPolicyConflictError, ) - # Remove reminders and policies that no longer apply - transact.delete( - key=KeyPair( - pk=enrollment_id, - sk='SCHEDULE#REMINDER_NO_ACCESS_AFTER_3_DAYS', - ) - ) - transact.delete( - key=KeyPair( - pk=enrollment_id, - sk='SCHEDULE#REMINDER_ACCESS_PERIOD_BEFORE_30_DAYS', - ) - ) - transact.delete( - key=KeyPair( - pk=enrollment_id, - sk='METADATA#PARENT_SLOT', - ) - ) - - if lock_hash: - transact.delete(key=KeyPair(enrollment_id, 'LOCK')) - transact.delete(key=KeyPair('LOCK', lock_hash)) diff --git a/apps/admin.saladeaula.digital/app/routes/_.$orgid.enrollments._index/columns.tsx b/apps/admin.saladeaula.digital/app/routes/_.$orgid.enrollments._index/columns.tsx index ff4a9f0..6b2fc9c 100644 --- a/apps/admin.saladeaula.digital/app/routes/_.$orgid.enrollments._index/columns.tsx +++ b/apps/admin.saladeaula.digital/app/routes/_.$orgid.enrollments._index/columns.tsx @@ -1,7 +1,6 @@ 'use client' import type { ColumnDef } from '@tanstack/react-table' -import type { ComponentProps, MouseEvent } from 'react' import { useRequest, useToggle } from 'ahooks' import { CircleXIcon, @@ -9,6 +8,7 @@ import { FileBadgeIcon, LockOpenIcon } from 'lucide-react' +import type { ComponentProps, MouseEvent } from 'react' import { toast } from 'sonner' import { @@ -36,8 +36,8 @@ import { } from '@repo/ui/components/ui/dropdown-menu' import { Spinner } from '@repo/ui/components/ui/spinner' import { - type Enrollment, - columns as columns_ + columns as columns_, + type Enrollment } from '@repo/ui/routes/enrollments/columns' export type { Enrollment } @@ -123,7 +123,6 @@ function ActionMenu({ row }: { row: any }) { @@ -256,14 +255,10 @@ const getDaysRemaining = (ttl: number) => { function CancelItem({ id, cancelPolicy, - lock, onSuccess, ...props }: ItemProps & { cancelPolicy?: object - lock?: { - hash: string - } }) { const [loading, { set }] = useToggle(false) const [open, { set: setOpen }] = useToggle(false) @@ -274,8 +269,7 @@ function CancelItem({ const r = await fetch(`/~/api/enrollments/${id}/cancel`, { method: 'PATCH', - headers: new Headers({ 'Content-Type': 'application/json' }), - body: JSON.stringify({ lock_hash: lock?.hash }) + headers: new Headers({ 'Content-Type': 'application/json' }) }) if (r.ok) { diff --git a/enrollments-events/app/enrollment.py b/enrollments-events/app/enrollment.py index a1015b8..d3ea2be 100644 --- a/enrollments-events/app/enrollment.py +++ b/enrollments-events/app/enrollment.py @@ -61,6 +61,8 @@ Org = TypedDict('Org', {'org_id': str, 'name': str}) CreatedBy = TypedDict('CreatedBy', {'id': str, 'name': str}) +Seat = TypedDict('Seat', {'id': str, 'sk': str}) + DeduplicationWindow = TypedDict('DeduplicationWindow', {'offset_days': int}) Subscription = TypedDict( @@ -100,6 +102,11 @@ class SubscriptionFrozenError(Exception): super().__init__('Subscription is frozen') +class SeatNotFoundError(Exception): + def __init__(self, msg: str | dict): + super().__init__('Seat required') + + def enroll( enrollment: Enrollment, *, @@ -108,6 +115,7 @@ def enroll( subscription: Subscription | None = None, created_by: CreatedBy | None = None, scheduled_at: datetime | None = None, + seat: Seat | None = None, linked_entities: frozenset[LinkedEntity] = frozenset(), deduplication_window: DeduplicationWindow | None = None, persistence_layer: DynamoDBPersistenceLayer, @@ -138,6 +146,8 @@ def enroll( 'sk': 'CANCEL_POLICY', 'created_at': now_, } + # + | ({'seat': seat} if seat else {}) ) # Relationships between this enrollment and its related entities diff --git a/enrollments-events/app/events/enroll_scheduled.py b/enrollments-events/app/events/enroll_scheduled.py index a0b3f19..ca7b20d 100644 --- a/enrollments-events/app/events/enroll_scheduled.py +++ b/enrollments-events/app/events/enroll_scheduled.py @@ -30,6 +30,7 @@ def lambda_handler(event: EventBridgeEvent, context: LambdaContext) -> bool: offset_days = old_image.get('dedup_window_offset_days') billing_day = old_image.get('subscription_billing_day') created_by = old_image.get('created_by') + seat = old_image.get('seat') enrollment = Enrollment( course=old_image['course'], user=old_image['user'], @@ -53,6 +54,7 @@ def lambda_handler(event: EventBridgeEvent, context: LambdaContext) -> bool: subscription=subscription, cancel_policy=bool(subscription), created_by=created_by, + seat=seat, scheduled_at=datetime.fromisoformat(old_image['scheduled_at']), # Transfer the deduplication window if it exists deduplication_window={'offset_days': offset_days} if offset_days else None, diff --git a/enrollments-events/app/events/purge_reminders.py b/enrollments-events/app/events/purge_reminders.py new file mode 100644 index 0000000..d2e0843 --- /dev/null +++ b/enrollments-events/app/events/purge_reminders.py @@ -0,0 +1,41 @@ +from aws_lambda_powertools import Logger +from aws_lambda_powertools.utilities.data_classes import ( + EventBridgeEvent, + event_source, +) +from aws_lambda_powertools.utilities.typing import LambdaContext +from layercake.dynamodb import DynamoDBPersistenceLayer, KeyPair +from layercake.strutils import md5_hash + +from boto3clients import dynamodb_client +from config import ENROLLMENT_TABLE + +logger = Logger(__name__) +dyn = DynamoDBPersistenceLayer(ENROLLMENT_TABLE, dynamodb_client) + + +@event_source(data_class=EventBridgeEvent) +@logger.inject_lambda_context +def lambda_handler(event: EventBridgeEvent, context: LambdaContext) -> bool: + new_image = event.detail['new_image'] + enrollment_id = new_image['id'] + user_id = new_image['user']['id'] + course_id = new_image['course']['id'] + lock_hash = md5_hash(f'{user_id}{course_id}') + + with dyn.transact_writer() as transact: + transact.delete( + key=KeyPair(enrollment_id, 'SCHEDULE#REMINDER_NO_ACCESS_AFTER_3_DAYS') + ) + transact.delete( + key=KeyPair(enrollment_id, 'SCHEDULE#REMINDER_NO_ACTIVITY_AFTER_7_DAYS') + ) + transact.delete( + key=KeyPair(enrollment_id, 'SCHEDULE#REMINDER_ACCESS_PERIOD_BEFORE_30_DAYS') + ) + transact.delete(key=KeyPair(enrollment_id, 'CANCEL_POLICY')) + # Remove locks related to this enrollment + transact.delete(key=KeyPair(enrollment_id, 'LOCK')) + transact.delete(key=KeyPair('LOCK', lock_hash)) + + return True diff --git a/enrollments-events/template.yaml b/enrollments-events/template.yaml index b41c465..4055cde 100644 --- a/enrollments-events/template.yaml +++ b/enrollments-events/template.yaml @@ -326,6 +326,27 @@ Resources: sk: ['0'] status: [PENDING] + EventPurgeRemindersFunction: + Type: AWS::Serverless::Function + Properties: + Handler: events.purge_reminders.lambda_handler + LoggingConfig: + LogGroup: !Ref EventLog + Policies: + - DynamoDBCrudPolicy: + TableName: !Ref EnrollmentTable + Events: + DynamoDBEvent: + Type: EventBridgeRule + Properties: + Pattern: + resources: [!Ref EnrollmentTable] + detail-type: [MODIFY] + detail: + new_image: + sk: ['0'] + status: [CANCELED, FAILED] + # Deprecated EventSetAccessExpiredFunction: Type: AWS::Serverless::Function diff --git a/enrollments-events/tests/events/test_purge_reminders.py b/enrollments-events/tests/events/test_purge_reminders.py new file mode 100644 index 0000000..360d58e --- /dev/null +++ b/enrollments-events/tests/events/test_purge_reminders.py @@ -0,0 +1,29 @@ +from aws_lambda_powertools.utilities.typing import LambdaContext +from layercake.dynamodb import DynamoDBPersistenceLayer + +import events.purge_reminders as app + + +def test_purge_reminders( + dynamodb_seeds, + dynamodb_persistence_layer: DynamoDBPersistenceLayer, + lambda_context: LambdaContext, +): + event = { + 'detail': { + 'new_image': { + 'id': '14682b79-3df2-4351-9229-8b558af046a0', + 'sk': '0', + 'user': { + 'id': '1234', + 'name': 'Ozzy Osbourne', + 'email': 'ozzy@osbourne.com', + }, + 'course': { + 'id': '12334', + 'name': 'pytest', + }, + } + } + } + assert app.lambda_handler(event, lambda_context) # type: ignore diff --git a/konviva-events/app/enrollment.py b/konviva-events/app/enrollment.py index b3e1ed7..dc54575 100644 --- a/konviva-events/app/enrollment.py +++ b/konviva-events/app/enrollment.py @@ -140,8 +140,6 @@ def set_score( id, score, progress=progress, - user_id=user_id, - course_id=course_id, dynamodb_persistence_layer=dynamodb_persistence_layer, ) except EnrollmentConflictError as err: @@ -251,26 +249,6 @@ def _set_status_as_completed( } ) - # Remove reminders and policies that no longer apply - transact.delete( - key=KeyPair( - pk=id, - sk='CANCEL_POLICY', - ) - ) - transact.delete( - key=KeyPair( - pk=id, - sk='SCHEDULE#REMINDER_NO_ACTIVITY_AFTER_7_DAYS', - ) - ) - transact.delete( - key=KeyPair( - pk=id, - sk='SCHEDULE#REMINDER_ACCESS_PERIOD_BEFORE_30_DAYS', - ) - ) - return True @@ -279,13 +257,10 @@ def _set_status_as_failed( /, score: Decimal, progress: Decimal, - user_id: str, - course_id: str, *, dynamodb_persistence_layer: DynamoDBPersistenceLayer, ) -> bool: now_ = now() - lock_hash = md5_hash(f'{user_id}{course_id}') with dynamodb_persistence_layer.transact_writer() as transact: transact.update( @@ -308,32 +283,6 @@ def _set_status_as_failed( }, exc_cls=EnrollmentConflictError, ) - # Remove reminders and events that no longer apply - transact.delete( - key=KeyPair( - pk=id, - sk='CANCEL_POLICY', - ) - ) - transact.delete( - key=KeyPair( - pk=id, - sk='SCHEDULE#REMINDER_NO_ACTIVITY_AFTER_7_DAYS', - ) - ) - transact.delete( - key=KeyPair( - pk=id, - sk='SCHEDULE#REMINDER_ACCESS_PERIOD_BEFORE_30_DAYS', - ) - ) - # Remove locks related to this enrollment - transact.delete( - key=KeyPair(pk=id, sk='LOCK'), - ) - transact.delete( - key=KeyPair(pk='LOCK', sk=lock_hash), - ) return True diff --git a/layercake/layercake/dynamodb.py b/layercake/layercake/dynamodb.py index 24d693b..c50b8db 100644 --- a/layercake/layercake/dynamodb.py +++ b/layercake/layercake/dynamodb.py @@ -244,6 +244,13 @@ else: self.rename_key = rename_key self.remove_prefix = remove_prefix + if remove_prefix: + warnings.warn( + 'SortKey.remove_prefix() is deprecated and will be removed in the future.', + DeprecationWarning, + stacklevel=2, + ) + class Key(ABC, dict): @abstractmethod @@ -671,9 +678,7 @@ class DynamoDBPersistenceLayer: try: r = self.client.query(**attrs) except ClientError as err: - logger.info(attrs) - logger.exception(err) - raise + raise err else: return dict( items=[deserialize(v) for v in r.get('Items', [])], @@ -696,8 +701,7 @@ class DynamoDBPersistenceLayer: r = self.client.get_item(**attrs) except ClientError as err: logger.info(attrs) - logger.exception(err) - raise + raise err else: return deserialize(r.get('Item', {})) @@ -720,8 +724,7 @@ class DynamoDBPersistenceLayer: self.client.put_item(**attrs) except ClientError as err: logger.info(attrs) - logger.exception(err) - raise + raise err else: return True @@ -754,8 +757,7 @@ class DynamoDBPersistenceLayer: self.client.update_item(**attrs) except ClientError as err: logger.info(attrs) - logger.exception(err) - raise + raise err else: return True @@ -790,8 +792,7 @@ class DynamoDBPersistenceLayer: self.client.delete_item(**attrs) except ClientError as err: logger.info(attrs) - logger.exception(err) - raise + raise err else: return True @@ -1134,6 +1135,12 @@ class DynamoDBCollection: return sk.rename_key if isinstance(sk, SortKey): + warnings.warn( + 'SortKey.remove_prefix() is deprecated and will be removed ' + 'in the future.', + DeprecationWarning, + stacklevel=2, + ) return sk.removeprefix(sk.remove_prefix or '') return pk diff --git a/layercake/layercake/extra_types.py b/layercake/layercake/extra_types.py index d42aacd..4bb6464 100644 --- a/layercake/layercake/extra_types.py +++ b/layercake/layercake/extra_types.py @@ -1,5 +1,4 @@ import re -from datetime import date from typing import TYPE_CHECKING, Annotated, Any import ftfy @@ -74,6 +73,7 @@ class CreditCard(BaseModel): >>> cc CreditCard(holder_name='Mike Shinoda', number='4111111111111111', cvv='123', exp_month='01', exp_year='2026') """ + holder_name: NameStr number: PaymentCardNumber cvv: str = Field(..., min_length=3) diff --git a/layercake/uv.lock b/layercake/uv.lock index 9d35553..21f75fb 100644 --- a/layercake/uv.lock +++ b/layercake/uv.lock @@ -836,7 +836,7 @@ wheels = [ [[package]] name = "layercake" -version = "0.13.0" +version = "0.13.1" source = { editable = "." } dependencies = [ { name = "arnparse" },