diff --git a/enrollments-events/app/events/allocate_slots.py b/enrollments-events/app/events/allocate_slots.py new file mode 100644 index 0000000..984a099 --- /dev/null +++ b/enrollments-events/app/events/allocate_slots.py @@ -0,0 +1,101 @@ +from dataclasses import asdict, dataclass +from uuid import uuid4 + +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.dateutils import now +from layercake.dynamodb import ( + DynamoDBPersistenceLayer, + KeyChain, + KeyPair, + SortKey, + TransactKey, +) + +from boto3clients import dynamodb_client +from config import COURSE_TABLE, ENROLLMENT_TABLE, ORDER_TABLE + +logger = Logger(__name__) +order_layer = DynamoDBPersistenceLayer(ORDER_TABLE, dynamodb_client) +course_layer = DynamoDBPersistenceLayer(COURSE_TABLE, dynamodb_client) +enrollment_layer = 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'] + now_ = now() + order_id = new_image['id'] + order = order_layer.collection.get_items( + TransactKey(order_id) + SortKey('0') + SortKey('items', path_spec='items'), + ) + tenant_id = order['tenant_id'] + items = { + item['id']: int(item['quantity']) + for item in order['items'] + # Ignore items with non-positive unit price; + # negative values are treated as discounts + if item['unit_price'] > 0 + } + + courses = _get_courses(set(items.keys())) + slots = tuple(course for course in courses for _ in range(items.get(course.id, 0))) + logger.info('Course slots generated', slots=slots) + + with enrollment_layer.transact_writer() as transact: + for slot in slots: + transact.put( + item={ + 'id': f'vacancies#{tenant_id}', + 'sk': f'{order_id}#{uuid4()}', + # Post-migration: uncomment the follow lines + # 'id': f'slots#org#{tenant_id}', + # 'sk': f'order#{order_id}#enrollment#{uuid4()}', + 'course': asdict(slot), + 'created_at': now_, + }, + cond_expr='attribute_not_exists(sk)', + ) + + return order_layer.update_item( + key=KeyPair(new_image['id'], new_image['sk']), + update_expr='SET #status = :status, updated_at = :updated_at', + expr_attr_names={ + '#status': 'status', + }, + expr_attr_values={ + ':status': 'SUCCESS', + ':updated_at': now_, + }, + cond_expr='attribute_exists(sk)', + ) + + +@dataclass(frozen=True) +class Course: + id: str + name: str + time_in_days: int + + +def _get_courses(ids: set) -> tuple[Course, ...]: + pairs = tuple(KeyPair(idx, '0') for idx in ids) + result = course_layer.collection.get_items( + KeyChain(pairs), + flatten_top=False, + ) + courses = tuple( + Course( + id=idx, + name=obj['name'], + time_in_days=int(obj['access_period']), + ) + for idx, obj in result.items() + ) + + return courses diff --git a/enrollments-events/app/events/stopgap/enroll.py b/enrollments-events/app/events/stopgap/enroll.py index 0a798ac..ec8e063 100644 --- a/enrollments-events/app/events/stopgap/enroll.py +++ b/enrollments-events/app/events/stopgap/enroll.py @@ -24,11 +24,6 @@ logger = Logger(__name__) enrollment_layer = DynamoDBPersistenceLayer(ENROLLMENT_TABLE, dynamodb_client) -class DeduplicationConflictError(Exception): - def __init__(self, *args): - super().__init__('Enrollment already exists') - - @event_source(data_class=EventBridgeEvent) @logger.inject_lambda_context def lambda_handler(event: EventBridgeEvent, context: LambdaContext) -> bool: diff --git a/enrollments-events/template.yaml b/enrollments-events/template.yaml index 05dc0f3..06f660e 100644 --- a/enrollments-events/template.yaml +++ b/enrollments-events/template.yaml @@ -73,6 +73,8 @@ Resources: TableName: !Ref OrderTable - DynamoDBCrudPolicy: TableName: !Ref EnrollmentTable + - DynamoDBReadPolicy: + TableName: !Ref CourseTable Events: DynamoDBEvent: Type: EventBridgeRule @@ -82,8 +84,11 @@ Resources: detail-type: [INSERT] detail: new_image: - sk: [slots] - mode: [BATCH] + # Post-migration: uncomment the following lines + # sk: [slots] + # mode: [BATCH] + sk: [generated_items] + scope: [MULTI_USER] status: [PENDING] EventReminderNoAccess3DaysFunction: diff --git a/enrollments-events/tests/events/emails/test_reminder_no_access_3_days.py b/enrollments-events/tests/events/emails/test_reminder_no_access_3_days.py index 2af4193..2af7920 100644 --- a/enrollments-events/tests/events/emails/test_reminder_no_access_3_days.py +++ b/enrollments-events/tests/events/emails/test_reminder_no_access_3_days.py @@ -9,7 +9,7 @@ def test_reminder_no_access_3_days( ): event = { 'detail': { - 'new_image': { + 'old_image': { 'id': '47ZxxcVBjvhDS5TE98tpfQ', 'sk': 'schedules#reminder_no_access_3_days', } diff --git a/enrollments-events/tests/events/stopgap/test_enroll.py b/enrollments-events/tests/events/stopgap/test_enroll.py index b336275..49cd6c1 100644 --- a/enrollments-events/tests/events/stopgap/test_enroll.py +++ b/enrollments-events/tests/events/stopgap/test_enroll.py @@ -1,5 +1,3 @@ -import pprint - import app.events.stopgap.enroll as app from aws_lambda_powertools.utilities.typing import LambdaContext from layercake.dynamodb import ( @@ -37,4 +35,5 @@ def test_enroll( + SortKey('metadata#course') ) - pprint.pprint(result) + assert 'metadata#course' in result + assert 'metadata#deduplication_window' in result diff --git a/enrollments-events/tests/events/test_allocate_slots.py b/enrollments-events/tests/events/test_allocate_slots.py new file mode 100644 index 0000000..12df2bb --- /dev/null +++ b/enrollments-events/tests/events/test_allocate_slots.py @@ -0,0 +1,26 @@ +import app.events.allocate_slots as app +from aws_lambda_powertools.utilities.typing import LambdaContext +from layercake.dynamodb import DynamoDBPersistenceLayer, PartitionKey + + +def test_allocate_slots( + dynamodb_seeds, + dynamodb_client, + dynamodb_persistence_layer: DynamoDBPersistenceLayer, + lambda_context: LambdaContext, +): + event = { + 'detail': { + 'new_image': { + 'id': 'JeCybf6oiv6CF3PchhBqdG', + 'sk': 'generated_items', + } + } + } + assert app.lambda_handler(event, lambda_context) # type: ignore + + result = dynamodb_persistence_layer.collection.query( + PartitionKey('vacancies#PWQyjGZeVcoD5zc5eTkvpr') + ) + + assert len(result['items']) == 4 diff --git a/enrollments-events/tests/seeds.jsonl b/enrollments-events/tests/seeds.jsonl index 59f636b..a44e31e 100644 --- a/enrollments-events/tests/seeds.jsonl +++ b/enrollments-events/tests/seeds.jsonl @@ -1,3 +1,8 @@ {"id": {"S": "47ZxxcVBjvhDS5TE98tpfQ"}, "sk": {"S": "0"}, "course": {"M": {"id": {"S": "42"}, "name": {"S": "NR-35 Segurança nos Trabalhos em Altura (Teórico)"},"time_in_days": {"N": "720"}}},"create_date": {"S": "2025-04-10T11:58:33.303347-03:00"},"konviva:id": {"N": "238662"},"progress": {"N": "16.67"},"score": {"NULL": true},"status": {"S": "IN_PROGRESS"}, "update_date": {"S": "2025-04-10T15:44:03.023054-03:00"}, "user": {"M": {"id": {"S": "5OxmMjL-ujoR5IMGegQz"}, "cpf": {"S": "07879819908"}, "email": {"S": "sergio@somosbeta.com.br"}, "name": {"S": "Sérgio Rafael Siqueira"}}}} {"id": {"S": "47ZxxcVBjvhDS5TE98tpfQ"}, "sk": {"S": "konviva"}, "create_date": {"S": "2025-04-10T11:58:35.035729-03:00"}, "konviva_id": {"N": "238662"}} -{"id": {"S": "47ZxxcVBjvhDS5TE98tpfQ"}, "sk": {"S": "tenant"}, "create_date": {"S": "2025-04-10T11:58:33.303347-03:00"}, "name": {"S": "Beta Educação"},"org_id": {"S": "cJtK9SsnJhKPyxESe7g3DG"}} \ No newline at end of file +{"id": {"S": "47ZxxcVBjvhDS5TE98tpfQ"}, "sk": {"S": "tenant"}, "create_date": {"S": "2025-04-10T11:58:33.303347-03:00"}, "name": {"S": "Beta Educação"},"org_id": {"S": "cJtK9SsnJhKPyxESe7g3DG"}} +{"id": {"S": "JeCybf6oiv6CF3PchhBqdG"}, "sk": {"S": "0"}, "assignee": {"M": {"name": {"S": "Rodrigo Silva"}}},"cnpj": {"S": "54183587001708"},"create_date": {"S": "2025-07-16T15:54:24.304566-03:00"},"due_date": {"S": "2025-07-17T15:50:08.139000-03:00"},"email": {"S": "silva.rodrigo@manserv.com.br"},"name": {"S": "MANSERV MONTAGEM E MANUTENCAO S/A"},"payment_date": {"NULL": true},"payment_method": {"S": "MANUAL"},"status": {"S": "PAID"},"tenant_id": {"S": "PWQyjGZeVcoD5zc5eTkvpr"},"total": {"N": "0"},"updated_at": {"S": "2025-07-16T15:54:27.074086-03:00"},"update_date": {"S": "2025-07-16T15:54:26.697884-03:00"}} +{"id": {"S": "JeCybf6oiv6CF3PchhBqdG"}, "sk": {"S": "items"},"items": {"L": [{"M": {"id": {"S": "a955518e-ebcb-4441-b914-ddc9ecef84f0"},"name": {"S": "NR-11 Operador de Munck"},"quantity": {"N": "3"},"unit_price": {"N": "99"}}}, {"M": {"id": {"S": "123"},"name": {"S": "pytest"},"quantity": {"N": "1"},"unit_price": {"N": "99"}}},{"M": {"id": {"S": "23020"},"name": {"S": "Desconto 100%"},"quantity": {"N": "1"},"unit_price": {"N": "-297"}}}]},"updated_at": {"S": "2025-07-16T15:54:27.154404-03:00"}} +{"id": {"S": "JeCybf6oiv6CF3PchhBqdG"},"sk": {"S": "generated_items"},"create_date": {"S": "2025-07-16T15:54:30.160729-03:00"},"scope": {"S": "MULTI_USER"},"status": {"S": "SUCCESS"},"update_date": {"S": "2025-07-16T15:54:33.674670-03:00"}} +{"id": {"S": "a955518e-ebcb-4441-b914-ddc9ecef84f0"},"sk": {"S": "0"},"access_period": {"N": "360"},"cert": {"M": {"exp_interval": {"N": "360"}}},"created_at": {"S": "2025-07-14T15:09:18.559528-03:00"},"metadata__konviva_class_id": {"N": "281"},"name": {"S": "NR-11 Operador de Munck"},"tenant_id": {"S": "*"}} +{"id": {"S": "123"},"sk": {"S": "0"},"access_period": {"N": "360"},"cert": {"M": {"exp_interval": {"N": "360"}}},"created_at": {"S": "2025-07-14T15:09:18.559528-03:00"},"metadata__konviva_class_id": {"N": "281"},"name": {"S": "pytest"},"tenant_id": {"S": "*"}} \ No newline at end of file diff --git a/enrollments-events/uv.lock b/enrollments-events/uv.lock index 2b92998..9ece079 100644 --- a/enrollments-events/uv.lock +++ b/enrollments-events/uv.lock @@ -331,6 +331,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/0f/e7/aa315e6a749d9b96c2504a1ba0ba031ba2d0517e972ce22682e3fccecb09/cssselect2-0.8.0-py3-none-any.whl", hash = "sha256:46fc70ebc41ced7a32cd42d58b1884d72ade23d21e5a4eaaf022401c13f0e76e", size = 15454, upload-time = "2025-03-05T14:46:06.463Z" }, ] +[[package]] +name = "dictdiffer" +version = "0.9.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/61/7b/35cbccb7effc5d7e40f4c55e2b79399e1853041997fcda15c9ff160abba0/dictdiffer-0.9.0.tar.gz", hash = "sha256:17bacf5fbfe613ccf1b6d512bd766e6b21fb798822a133aa86098b8ac9997578", size = 31513, upload-time = "2021-07-22T13:24:29.276Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/47/ef/4cb333825d10317a36a1154341ba37e6e9c087bac99c1990ef07ffdb376f/dictdiffer-0.9.0-py2.py3-none-any.whl", hash = "sha256:442bfc693cfcadaf46674575d2eba1c53b42f5e404218ca2c2ff549f2df56595", size = 16754, upload-time = "2021-07-22T13:24:26.783Z" }, +] + [[package]] name = "dnspython" version = "2.7.0" @@ -340,48 +349,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/68/1b/e0a87d256e40e8c888847551b20a017a6b98139178505dc7ffb96f04e954/dnspython-2.7.0-py3-none-any.whl", hash = "sha256:b4c34b7d10b51bcc3a5071e7b8dee77939f1e878477eeecc965e9835f63c6c86", size = 313632, upload-time = "2024-10-05T20:14:57.687Z" }, ] -[[package]] -name = "elastic-transport" -version = "8.17.1" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "certifi" }, - { name = "urllib3" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/6a/54/d498a766ac8fa475f931da85a154666cc81a70f8eb4a780bc8e4e934e9ac/elastic_transport-8.17.1.tar.gz", hash = "sha256:5edef32ac864dca8e2f0a613ef63491ee8d6b8cfb52881fa7313ba9290cac6d2", size = 73425, upload-time = "2025-03-13T07:28:30.776Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/cf/cd/b71d5bc74cde7fc6fd9b2ff9389890f45d9762cbbbf81dc5e51fd7588c4a/elastic_transport-8.17.1-py3-none-any.whl", hash = "sha256:192718f498f1d10c5e9aa8b9cf32aed405e469a7f0e9d6a8923431dbb2c59fb8", size = 64969, upload-time = "2025-03-13T07:28:29.031Z" }, -] - -[[package]] -name = "elasticsearch" -version = "8.18.1" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "elastic-transport" }, - { name = "python-dateutil" }, - { name = "typing-extensions" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/2a/e4/40fc0e8d9a646889ac3f865cd35e41835f3cf888c716c7aae82248e022f0/elasticsearch-8.18.1.tar.gz", hash = "sha256:998035f17a8c1fba7ae26b183dca797dcf95db86da6a7ecba56d31afc40f07c7", size = 750746, upload-time = "2025-04-29T09:32:16.361Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/33/62/f62e8a5c7c6f7b27481c9ffc248fb32078ad88878aa4f3731a83a14cc797/elasticsearch-8.18.1-py3-none-any.whl", hash = "sha256:1a8c8b5ec3ce5be88f96d2f898375671648e96272978bce0dee3137d9326aabb", size = 906320, upload-time = "2025-04-29T09:32:12.527Z" }, -] - -[[package]] -name = "elasticsearch-dsl" -version = "8.18.0" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "elastic-transport" }, - { name = "elasticsearch" }, - { name = "python-dateutil" }, - { name = "typing-extensions" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/72/6d/00cbeee412a2dc825f0df18c98463a2e0b423b86800fba6c50ea2c627962/elasticsearch_dsl-8.18.0.tar.gz", hash = "sha256:763465dba9eae166add10567e924c65730aa122819b08bfe9a077e91b13b30d1", size = 31886, upload-time = "2025-04-16T11:54:14.412Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/77/a9/b200790a22585aeb023d88bd8b9fb222820e2976ce4239d401670116ae3c/elasticsearch_dsl-8.18.0-py3-none-any.whl", hash = "sha256:0522c5bb20c7abae69855109e650bf1166d486cbf706b5e1b29c28936a9102a3", size = 10406, upload-time = "2025-04-16T11:54:12.677Z" }, -] - [[package]] name = "email-validator" version = "2.2.0" @@ -558,13 +525,12 @@ wheels = [ [[package]] name = "layercake" -version = "0.6.12" +version = "0.7.2" source = { directory = "../layercake" } dependencies = [ { name = "arnparse" }, { name = "aws-lambda-powertools", extra = ["all"] }, - { name = "elasticsearch" }, - { name = "elasticsearch-dsl" }, + { name = "dictdiffer" }, { name = "ftfy" }, { name = "glom" }, { name = "jinja2" }, @@ -578,6 +544,7 @@ dependencies = [ { name = "requests" }, { name = "smart-open", extra = ["s3"] }, { name = "sqlite-utils" }, + { name = "unidecode" }, { name = "weasyprint" }, ] @@ -585,8 +552,7 @@ dependencies = [ requires-dist = [ { name = "arnparse", specifier = ">=0.0.2" }, { name = "aws-lambda-powertools", extras = ["all"], specifier = ">=3.8.0" }, - { name = "elasticsearch", specifier = ">=8.17.2" }, - { name = "elasticsearch-dsl", specifier = ">=8.17.1" }, + { name = "dictdiffer", specifier = ">=0.9.0" }, { name = "ftfy", specifier = ">=6.3.1" }, { name = "glom", specifier = ">=24.11.0" }, { name = "jinja2", specifier = ">=3.1.6" }, @@ -600,6 +566,7 @@ requires-dist = [ { name = "requests", specifier = ">=2.32.3" }, { name = "smart-open", extras = ["s3"], specifier = ">=7.1.0" }, { name = "sqlite-utils", specifier = ">=3.38" }, + { name = "unidecode", specifier = ">=1.4.0" }, { name = "weasyprint", specifier = ">=65.0" }, ] @@ -1073,6 +1040,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/31/08/aa4fdfb71f7de5176385bd9e90852eaf6b5d622735020ad600f2bab54385/typing_inspection-0.4.0-py3-none-any.whl", hash = "sha256:50e72559fcd2a6367a19f7a7e610e6afcb9fac940c650290eed893d61386832f", size = 14125, upload-time = "2025-02-25T17:27:57.754Z" }, ] +[[package]] +name = "unidecode" +version = "1.4.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/94/7d/a8a765761bbc0c836e397a2e48d498305a865b70a8600fd7a942e85dcf63/Unidecode-1.4.0.tar.gz", hash = "sha256:ce35985008338b676573023acc382d62c264f307c8f7963733405add37ea2b23", size = 200149, upload-time = "2025-04-24T08:45:03.798Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/8f/b7/559f59d57d18b44c6d1250d2eeaa676e028b9c527431f5d0736478a73ba1/Unidecode-1.4.0-py3-none-any.whl", hash = "sha256:c3c7606c27503ad8d501270406e345ddb480a7b5f38827eafe4fa82a137f0021", size = 235837, upload-time = "2025-04-24T08:45:01.609Z" }, +] + [[package]] name = "urllib3" version = "2.4.0"