diff --git a/enrollments-events/app/enrollment.py b/enrollments-events/app/enrollment.py index 7c450a2..c0c6478 100644 --- a/enrollments-events/app/enrollment.py +++ b/enrollments-events/app/enrollment.py @@ -85,22 +85,19 @@ def enroll( transact.put( item={ 'id': entity.id, - 'sk': f'LINKED_ENTITIES#{entity.kind.value}#CHILD', + 'sk': f'LINKED_ENTITIES#CHILD#ENROLLMENT#{enrollment.id}', 'created_at': now_, - 'enrollment_id': enrollment.id, }, cond_expr='attribute_not_exists(sk)', table_name=entity.table_name, ) - keyprefix = entity.kind.value.lower() # Child knows the parent transact.put( item={ 'id': enrollment.id, - 'sk': f'LINKED_ENTITIES#{entity.kind.value}#PARENT', + 'sk': f'LINKED_ENTITIES#PARENT#{entity.kind.value}#{entity.id}', 'created_at': now_, - f'{keyprefix}_id': entity.id, }, cond_expr='attribute_not_exists(sk)', ) diff --git a/enrollments-events/app/events/emails/__init__.py b/enrollments-events/app/events/emails/__init__.py index fc0d6f3..f964ff2 100644 --- a/enrollments-events/app/events/emails/__init__.py +++ b/enrollments-events/app/events/emails/__init__.py @@ -1,14 +1,13 @@ -from typing import TYPE_CHECKING, TypedDict +from typing import TYPE_CHECKING from aws_lambda_powertools import Logger -from layercake.dateutils import now -from layercake.dynamodb import DynamoDBPersistenceLayer from layercake.email_ import Message from layercake.strutils import first_word, truncate_str from . import ( reminder_access_period_before_30_days, reminder_cert_expiration_before_30_days, + reminder_cert_expired, reminder_no_access_after_3_days, reminder_no_activity_after_7_days, ) @@ -18,7 +17,6 @@ if TYPE_CHECKING: else: SESV2Client = object -Event = TypedDict('Event', {'id': str, 'sk': str}) TEMPLATES = { 'reminder_access_period_before_30_days': { @@ -29,6 +27,10 @@ TEMPLATES = { 'subject': reminder_cert_expiration_before_30_days.SUBJECT, 'message': reminder_cert_expiration_before_30_days.MESSAGE, }, + 'reminder_cert_expired': { + 'subject': reminder_cert_expired.SUBJECT, + 'message': reminder_cert_expired.MESSAGE, + }, 'reminder_no_access_after_3_days': { 'subject': reminder_no_access_after_3_days.SUBJECT, 'message': reminder_no_access_after_3_days.MESSAGE, @@ -49,13 +51,9 @@ def send_email( context: dict = {}, *, sender: tuple[str, str], - event: Event, sesv2_client: SESV2Client, - dynamodb_persistence_layer: DynamoDBPersistenceLayer, -) -> bool: - now_ = now() +) -> None: name, _ = to - event_name = event['sk'] emailmsg = Message( from_=sender, to=to, @@ -70,33 +68,10 @@ def send_email( ) ) - try: - sesv2_client.send_email( - Content={ - 'Raw': { - 'Data': emailmsg.as_bytes(), - }, - } - ) - dynamodb_persistence_layer.put_item( - item={ - 'id': event['id'], - 'sk': f'{event_name}#EXECUTED', - 'created_at': now_, - } - ) - logger.info('Email sent') - except Exception as exc: - logger.exception(exc) - - dynamodb_persistence_layer.put_item( - item={ - 'id': event['id'], - 'sk': f'{event_name}#FAILED', - 'created_at': now_, - } - ) - - return False - else: - return True + sesv2_client.send_email( + Content={ + 'Raw': { + 'Data': emailmsg.as_bytes(), + }, + } + ) diff --git a/enrollments-events/app/events/reporting/append_cert.py b/enrollments-events/app/events/reporting/append_cert.py index 3371e29..e7b1e24 100644 --- a/enrollments-events/app/events/reporting/append_cert.py +++ b/enrollments-events/app/events/reporting/append_cert.py @@ -29,46 +29,62 @@ def lambda_handler(event: EventBridgeEvent, context: LambdaContext) -> bool | No expires_at = fromisoformat(new_image['cert_expires_at']).replace( # type: ignore tzinfo=pytz.timezone(tz) ) - # The reporting month is the month before the certificate expires - month_start = (expires_at.replace(day=1) - timedelta(days=1)).replace(day=1) + + target_month = expires_at.strftime('%Y-%m') now_ = now() pk = f'CERT#REPORTING#ORG#{org_id}' - sk = 'MONTH#{}'.format(expires_at.strftime('%Y-%m')) - - if now_ > expires_at: - return None try: + if now_ > expires_at: + raise InvalidDateError() + + # The reporting month is the month before the certificate expires + report_month = (expires_at.replace(day=1) - timedelta(days=1)).replace(day=1) + report_sk = report_month.strftime('%Y-%m') + with dyn.transact_writer() as transact: transact.put( item={ 'id': pk, - 'sk': 'MONTH#{}#SCHEDULE#SEND_REPORT_EMAIL'.format( - month_start.strftime('%Y-%m') - ), - 'target_month': expires_at.strftime('%Y-%m'), - 'ttl': ttl(start_dt=month_start), - } + 'sk': f'MONTH#{report_sk}', + 'status': 'PENDING', + 'target_month': target_month, + }, + cond_expr='attribute_not_exists(sk)', + exc_cls=ReportingConflictError, ) - transact.put( item={ 'id': pk, - 'sk': f'{sk}#ENROLLMENT#{enrollment_id}', - 'user': pick(('id', 'name'), new_image['user']), - 'course': pick(('id', 'name'), new_image['course']), - 'enrolled_at': new_image['created_at'], - 'expires_at': expires_at, - 'completed_at': new_image['completed_at'], - 'created_at': now_, + 'sk': f'MONTH#{report_sk}#SCHEDULE#SEND_REPORT_EMAIL', + 'target_month': target_month, + 'ttl': ttl(start_dt=report_month), }, - cond_expr='attribute_not_exists(sk)', - exc_cls=EnrollmentConflictError, ) - except EnrollmentConflictError: + except Exception as exc: + logger.exception(exc) + + try: + dyn.put_item( + item={ + 'id': pk, + 'sk': f'MONTH#{target_month}#ENROLLMENT#{enrollment_id}', + 'user': pick(('id', 'name'), new_image['user']), + 'course': pick(('id', 'name'), new_image['course']), + 'enrolled_at': new_image['created_at'], + 'expires_at': expires_at, + 'completed_at': new_image['completed_at'], + 'created_at': now_, + }, + cond_expr='attribute_not_exists(sk)', + ) + except Exception: return False - - return True + else: + return True -class EnrollmentConflictError(Exception): ... +class InvalidDateError(Exception): ... + + +class ReportingConflictError(Exception): ... diff --git a/enrollments-events/app/events/reporting/send_report_email.py b/enrollments-events/app/events/reporting/send_report_email.py index bf55367..d458f35 100644 --- a/enrollments-events/app/events/reporting/send_report_email.py +++ b/enrollments-events/app/events/reporting/send_report_email.py @@ -51,6 +51,9 @@ def lambda_handler(event: EventBridgeEvent, context: LambdaContext) -> bool: old_image = event.detail['old_image'] # Key pattern `CERT#REPORTING#ORG#{org_id}` *_, org_id = old_image['id'].split('#') + # Key pattern `MONTH#{month}#SCHEDULE#SEND_REPORT_EMAIL` + _, month, *_ = old_image['sk'].split('#') + event_name = old_image['sk'] target_month = old_image['target_month'] pretty_month = _monthfmt(datetime.strptime(target_month, '%Y-%m').date()) @@ -95,6 +98,28 @@ def lambda_handler(event: EventBridgeEvent, context: LambdaContext) -> bool: emailmsg.attach(attachment) try: + with enrollment_layer.transact_writer() as transact: + transact.update( + key=KeyPair( + pk=old_image['id'], + sk=f'MONTH#{month}', + ), + update_expr='SET #status = :status, updated_at = :updated_at', + expr_attr_names={'#status': 'status'}, + expr_attr_values={ + ':status': 'CLOSED', + ':updated_at': now_, + }, + cond_expr='attribute_exists(sk)', + ) + transact.put( + item={ + 'id': old_image['id'], + 'sk': f'{event_name}#EXECUTED', + 'created_at': now_, + } + ) + sesv2_client.send_email( Content={ 'Raw': { @@ -102,14 +127,6 @@ def lambda_handler(event: EventBridgeEvent, context: LambdaContext) -> bool: }, } ) - enrollment_layer.put_item( - item={ - 'id': old_image['id'], - 'sk': f'{event_name}#EXECUTED', - 'created_at': now_, - } - ) - logger.info('Email sent') except Exception as exc: logger.exception(exc) enrollment_layer.put_item( @@ -121,6 +138,7 @@ def lambda_handler(event: EventBridgeEvent, context: LambdaContext) -> bool: ) return False else: + logger.info('Email sent') return True diff --git a/enrollments-events/app/events/send_reminder_emails.py b/enrollments-events/app/events/send_reminder_emails.py index 0a2b127..75a4758 100644 --- a/enrollments-events/app/events/send_reminder_emails.py +++ b/enrollments-events/app/events/send_reminder_emails.py @@ -4,6 +4,7 @@ from aws_lambda_powertools.utilities.data_classes import ( event_source, ) from aws_lambda_powertools.utilities.typing import LambdaContext +from layercake.dateutils import now from layercake.dynamodb import DynamoDBPersistenceLayer, KeyPair from boto3clients import dynamodb_client, sesv2_client @@ -22,7 +23,9 @@ dyn = DynamoDBPersistenceLayer(ENROLLMENT_TABLE, dynamodb_client) @logger.inject_lambda_context def lambda_handler(event: EventBridgeEvent, context: LambdaContext) -> bool: old_image = event.detail['old_image'] - event_name = old_image['sk'].removeprefix('SCHEDULE#').lower() + now_ = now() + pk, sk = old_image['id'], old_image['sk'] + event_name = sk.removeprefix('SCHEDULE#').lower() template = TEMPLATES[event_name] # If email is missing, use enrollment email @@ -32,18 +35,35 @@ def lambda_handler(event: EventBridgeEvent, context: LambdaContext) -> bool: old_image['email'] = r['user']['email'] old_image['course'] = r['course']['name'] - return send_email( - to=(old_image['name'], old_image['email']), - subject=template['subject'], - message=template['message'], - context={ - 'course': old_image['course'], - }, - sender=EMAIL_SENDER, - sesv2_client=sesv2_client, - event={ - 'id': old_image['id'], - 'sk': old_image['sk'], - }, - dynamodb_persistence_layer=dyn, - ) + try: + send_email( + to=(old_image['name'], old_image['email']), + subject=template['subject'], + message=template['message'], + context={'course': old_image['course']}, + sender=EMAIL_SENDER, + sesv2_client=sesv2_client, + ) + + dyn.put_item( + item={ + 'id': pk, + 'sk': f'{sk}#EXECUTED', + 'created_at': now_, + } + ) + except Exception as exc: + logger.exception(exc) + + dyn.put_item( + item={ + 'id': pk, + 'sk': f'{sk}#FAILED', + 'created_at': now_, + } + ) + + return False + else: + logger.info('Email sent') + return True diff --git a/enrollments-events/app/schemas.py b/enrollments-events/app/schemas.py index 7ab893e..2a5e471 100644 --- a/enrollments-events/app/schemas.py +++ b/enrollments-events/app/schemas.py @@ -21,14 +21,9 @@ class User(BaseModel): cpf: CpfStr | None = None -class Cert(BaseModel): - exp_interval: int - - class Course(BaseModel): id: UUID4 | str = Field(default_factory=uuid4) name: str - cert: Cert | None = None access_period: int = 90 # 3 months @@ -46,10 +41,7 @@ class Enrollment(BaseModel): **kwargs, ) -> dict[str, Any]: return super().model_dump( - exclude={ - 'user': {'email_verified'}, - 'course': {'cert'}, - }, + exclude={'user': {'email_verified'}}, *args, **kwargs, ) diff --git a/enrollments-events/template.yaml b/enrollments-events/template.yaml index 0c2747f..90f41c8 100644 --- a/enrollments-events/template.yaml +++ b/enrollments-events/template.yaml @@ -369,7 +369,7 @@ Resources: LoggingConfig: LogGroup: !Ref EventLog Policies: - - DynamoDBReadPolicy: + - DynamoDBCrudPolicy: TableName: !Ref EnrollmentTable - DynamoDBReadPolicy: TableName: !Ref UserTable diff --git a/enrollments-events/tests/events/emails/test_reminder_access_period_before_30_days.py b/enrollments-events/tests/events/emails/test_reminder_access_period_before_30_days.py index 91f652b..b7795d9 100644 --- a/enrollments-events/tests/events/emails/test_reminder_access_period_before_30_days.py +++ b/enrollments-events/tests/events/emails/test_reminder_access_period_before_30_days.py @@ -1,20 +1,27 @@ import app.events.send_reminder_emails as app from aws_lambda_powertools.utilities.typing import LambdaContext +from layercake.dynamodb import DynamoDBPersistenceLayer, KeyPair def test_reminder_access_period_before_30_days( seeds, + dynamodb_persistence_layer: DynamoDBPersistenceLayer, lambda_context: LambdaContext, ): + app.send_email = lambda *args, **kwargs: ... + + pk = '47ZxxcVBjvhDS5TE98tpfQ' + sk = 'SCHEDULE#REMINDER_ACCESS_PERIOD_BEFORE_30_DAYS' event = { 'detail': { 'old_image': { - 'id': '47ZxxcVBjvhDS5TE98tpfQ', - 'sk': 'SCHEDULE#REMINDER_ACCESS_PERIOD_BEFORE_30_DAYS', + 'id': pk, + 'sk': sk, } } } - app.send_email = lambda *args, **kwargs: ... - assert app.lambda_handler(event, lambda_context) # type: ignore + + r = dynamodb_persistence_layer.collection.get_item(KeyPair(pk, f'{sk}#EXECUTED')) + assert r diff --git a/enrollments-events/tests/events/reporting/test_append_cert.py b/enrollments-events/tests/events/reporting/test_append_cert.py index 2ed50f7..36c31f4 100644 --- a/enrollments-events/tests/events/reporting/test_append_cert.py +++ b/enrollments-events/tests/events/reporting/test_append_cert.py @@ -39,9 +39,9 @@ def test_append_cert( assert app.lambda_handler(event, lambda_context) # type: ignore # The reporting month is the month before the certificate expires - month_start = (cert_expires_at.replace(day=1) - timedelta(days=1)).replace(day=1) + report_month = (cert_expires_at.replace(day=1) - timedelta(days=1)).replace(day=1) report_sk = 'MONTH#{}#SCHEDULE#SEND_REPORT_EMAIL'.format( - month_start.strftime('%Y-%m') + report_month.strftime('%Y-%m') ) r = dynamodb_persistence_layer.collection.get_items( diff --git a/enrollments-events/tests/events/reporting/test_send_report_email.py b/enrollments-events/tests/events/reporting/test_send_report_email.py index d5e8749..ff58067 100644 --- a/enrollments-events/tests/events/reporting/test_send_report_email.py +++ b/enrollments-events/tests/events/reporting/test_send_report_email.py @@ -2,6 +2,7 @@ import app.events.reporting.send_report_email as app from aws_lambda_powertools.utilities.typing import LambdaContext from layercake.dynamodb import ( DynamoDBPersistenceLayer, + KeyPair, ) @@ -11,10 +12,11 @@ def test_send_report_email( dynamodb_persistence_layer: DynamoDBPersistenceLayer, lambda_context: LambdaContext, ): + pk = 'CERT#REPORTING#ORG#00237409-9384-4692-9be5-b4443a41e1c4' event = { 'detail': { 'old_image': { - 'id': 'CERT#REPORTING#ORG#00237409-9384-4692-9be5-b4443a41e1c4', + 'id': pk, 'sk': 'MONTH#2025-06#SCHEDULE#SEND_REPORT_EMAIL', 'target_month': '2025-07', }, @@ -23,3 +25,6 @@ def test_send_report_email( monkeypatch.setattr(app.sesv2_client, 'send_email', lambda *args, **kwargs: ...) assert app.lambda_handler(event, lambda_context) # type: ignore + + r = dynamodb_persistence_layer.collection.get_item(KeyPair(pk, 'MONTH#2025-06')) + assert r['status'] == 'CLOSED' diff --git a/enrollments-events/tests/events/test_enroll.py b/enrollments-events/tests/events/test_enroll.py index f2ce5fe..ed0f0a7 100644 --- a/enrollments-events/tests/events/test_enroll.py +++ b/enrollments-events/tests/events/test_enroll.py @@ -20,14 +20,13 @@ def test_enroll( assert app.lambda_handler(event, lambda_context) # type: ignore # Parent knows the child - order_entity = dynamodb_persistence_layer.collection.get_item( - KeyPair(order_id, 'LINKED_ENTITIES#ORDER#CHILD') + r = dynamodb_persistence_layer.collection.query( + KeyPair(order_id, 'LINKED_ENTITIES#CHILD') ) - assert order_entity + *_, enrollment_id = r['items'][0]['sk'].split('#') # Child knows the parent - enrollment_entity = dynamodb_persistence_layer.collection.get_item( - KeyPair(order_entity['enrollment_id'], 'LINKED_ENTITIES#ORDER#PARENT'), + enrollment = dynamodb_persistence_layer.collection.get_item( + KeyPair(enrollment_id, f'LINKED_ENTITIES#PARENT#ORDER#{order_id}'), ) - - assert enrollment_entity['order_id'] == order_id + assert enrollment diff --git a/enrollments-events/tests/events/test_reenroll_if_failed.py b/enrollments-events/tests/events/test_reenroll_if_failed.py index 84ae659..a42cdfb 100644 --- a/enrollments-events/tests/events/test_reenroll_if_failed.py +++ b/enrollments-events/tests/events/test_reenroll_if_failed.py @@ -8,11 +8,11 @@ def test_reenroll( dynamodb_persistence_layer: DynamoDBPersistenceLayer, lambda_context: LambdaContext, ): - parent_id = '294e9864-8284-4287-b153-927b15d90900' + enrollment_id = '294e9864-8284-4287-b153-927b15d90900' event = { 'detail': { 'new_image': { - 'id': parent_id, + 'id': enrollment_id, 'sk': '0', 'user': { 'id': '2beb8642-aab4-4088-86d4-2966fac7c570', @@ -30,19 +30,19 @@ def test_reenroll( assert app.lambda_handler(event, lambda_context) # type: ignore # Parent knows the child - current_entity = dynamodb_persistence_layer.collection.get_item( + r = dynamodb_persistence_layer.collection.query( KeyPair( - pk=parent_id, - sk='LINKED_ENTITIES#ENROLLMENT#CHILD', + pk=enrollment_id, + sk='LINKED_ENTITIES#CHILD', ) ) - assert current_entity + *_, child_id = r['items'][0]['sk'].split('#') # Child knows the parent - new_entity = dynamodb_persistence_layer.collection.get_item( + child = dynamodb_persistence_layer.collection.get_item( KeyPair( - pk=current_entity['enrollment_id'], - sk='LINKED_ENTITIES#ENROLLMENT#PARENT', + pk=child_id, + sk=f'LINKED_ENTITIES#PARENT#ENROLLMENT#{enrollment_id}', ) ) - assert new_entity['enrollment_id'] == parent_id + assert child diff --git a/enrollments-events/tests/seeds.jsonl b/enrollments-events/tests/seeds.jsonl index 403fbb8..2dc2aca 100644 --- a/enrollments-events/tests/seeds.jsonl +++ b/enrollments-events/tests/seeds.jsonl @@ -38,6 +38,7 @@ {"id": "294e9864-8284-4287-b153-927b15d90900", "sk": "tenant", "org_id": "123", "name": "EDUSEG", "create_date": "2025-09-12T17:11:00.556907-03:00"} // Certificate reporting +{"id": "CERT#REPORTING#ORG#00237409-9384-4692-9be5-b4443a41e1c4", "sk": "MONTH#2025-06", "status": "PENDING"} {"id": "CERT#REPORTING#ORG#00237409-9384-4692-9be5-b4443a41e1c4", "sk": "MONTH#2025-07#ENROLLMENT#ba4d48e6-3671-4060-988a-d6cf97dd0ea4", "completed_at": "2025-01-10T00:00:00-03:06", "enrolled_at": "2025-01-01T00:00:00-03:06", "expires_at": "2026-02-10T20:14:42.880991", "course": {"name": "How to Sing Better", "id": "431"}, "created_at": "2025-10-11T23:39:12.194344-03:00", "user": {"name": "Tobias Summit", "id": "1234"}, "enrollment_id": "e45019d8-be7a-4a82-9b37-12a01f0127bb"} // Org diff --git a/http-api/uv.lock b/http-api/uv.lock index 4a085d0..3bbb32c 100644 --- a/http-api/uv.lock +++ b/http-api/uv.lock @@ -521,7 +521,7 @@ wheels = [ [[package]] name = "layercake" -version = "0.10.1" +version = "0.11.0" source = { directory = "../layercake" } dependencies = [ { name = "arnparse" }, diff --git a/orders-events/app/events/billing/close_window.py b/orders-events/app/events/billing/close_window.py index 7d1cdcd..dbd34a2 100644 --- a/orders-events/app/events/billing/close_window.py +++ b/orders-events/app/events/billing/close_window.py @@ -72,7 +72,7 @@ def lambda_handler(event: EventBridgeEvent, context: LambdaContext) -> bool: sk=f'START#{start_date}#END#{end_date}', ), update_expr='SET #status = :status, s3_uri = :s3_uri, \ - updated_at = :updated_at', + updated_at = :updated_at', expr_attr_names={'#status': 'status'}, expr_attr_values={ ':status': 'CLOSED',