diff --git a/README.md b/README.md index 65b776c..0f9d38b 100644 --- a/README.md +++ b/README.md @@ -37,6 +37,7 @@ Quando o responsável é uma pessoa física (CPF). ```json {"id": "9omWNKymwU5U4aeun6mWzZ", "sk": "0", "course": {"id": "10", "name": "pytest"}, "org_id": "100"} {"id": "9omWNKymwU5U4aeun6mWzZ", "sk": "METADATA#COURSE", "access_period": 360, "cert": {"exp_interval": 365}} +{"id": "9omWNKymwU5U4aeun6mWzZ", "sk": "CERT", "s3_uri": "", "expires_at": "2026-03-01T11:07:32.762178-03:00", "created_at": "2025-04-06T11:07:32.762178-03:00"} {"id": "9omWNKymwU5U4aeun6mWzZ", "sk": "METADATA#DEDUPLICATION_WINDOW", "offset_days": 90} {"id": "9omWNKymwU5U4aeun6mWzZ", "sk": "ORG", "org_id": "100", "name": "EDUSEG"} {"id": "9omWNKymwU5U4aeun6mWzZ", "sk": "CREATED_BY", "user_id": "202", "name": "Tiago Maciel"} @@ -44,10 +45,10 @@ Quando o responsável é uma pessoa física (CPF). {"id": "9omWNKymwU5U4aeun6mWzZ", "sk": "STARTED", "started_at": "2025-04-06T11:07:32.762178-03:00"} {"id": "9omWNKymwU5U4aeun6mWzZ", "sk": "COMPLETED", "completed_at": "2025-04-06T11:07:32.762178-03:00"} {"id": "9omWNKymwU5U4aeun6mWzZ", "sk": "FAILED", "failed_at": "2025-04-06T11:07:32.762178-03:00"} -{"id": "9omWNKymwU5U4aeun6mWzZ", "sk": "CANCELED", "canceled_at": "2025-04-06T11:07:32.762178-03:00"} +{"id": "9omWNKymwU5U4aeun6mWzZ", "sk": "CANCELED", "canceled_at": "2025-04-06T11:07:32.762178-03:00", "canceled_by": {"id": "", "name": ""}} {"id": "9omWNKymwU5U4aeun6mWzZ", "sk": "ARCHIVED", "archived_at": "2025-04-06T11:07:32.762178-03:00"} {"id": "9omWNKymwU5U4aeun6mWzZ", "sk": "EXPIRED", "expired_at": "2025-04-06T11:07:32.762178-03:00"} -{"id": "9omWNKymwU5U4aeun6mWzZ", "sk": "LINKED_ENTITIES#ORDER", "order_id": "101"} +{"id": "9omWNKymwU5U4aeun6mWzZ", "sk": "RELATION#ORDER", "order_id": "101"} {"id": "9omWNKymwU5U4aeun6mWzZ", "sk": "LOCK", "hash": "1e67e29464877783e49e07fb7d9dd372", "ttl": 1874507093} {"id": "9omWNKymwU5U4aeun6mWzZ", "sk": "SCHEDUE#REMINDER_NO_ACCESS_AFTER_3_DAYS", "ttl": 1874507093} {"id": "9omWNKymwU5U4aeun6mWzZ", "sk": "SCHEDUE#REMINDER_NO_ACTIVITY_AFTER_7_DAYS", "ttl": 1874507093} @@ -81,7 +82,7 @@ Quando uma matrícula é criada, também é agendados emails/eventos. Quando o status da matrícula for alterado para `COMPLETED`, os eventos `SET_AS_EXPIRED` e `REMINDER_ACCESS_PERIOD_BEFORE_30_DAYS` serão _removidos_ a adicionado o evento `SET_AS_ARCHIVED`. ```json -{"id": "9omWNKymwU5U4aeun6mWzZ", "sk": "0", ...} +{"id": "9omWNKymwU5U4aeun6mWzZ", "sk": "0", "ttl": 1874507093} {"id": "9omWNKymwU5U4aeun6mWzZ", "sk": "SCHEDULE#SET_AS_ARCHIVED", "ttl": 1874507093} ``` @@ -103,12 +104,12 @@ Se um certificado for emitido para a matrícula, o período de proteção será Apenas matrículas com `CANCEL_POLICY` podem ser canceladas. -Se houver `METADATA#SOURCE_SLOT`, deve ser devolvido. +Se houver `METADATA#SOURCE`, deve ser devolvido. ```json {"id": "9omWNKymwU5U4aeun6mWzZ", "sk": "0"} {"id": "9omWNKymwU5U4aeun6mWzZ", "sk": "CANCEL_POLICY"} -{"id": "9omWNKymwU5U4aeun6mWzZ", "sk": "METADATA#SOURCE_SLOT", "slot": {"id": "SLOT#ORG#123", "sk": "ORDER#1221#ENROLLMENT#9omWNKymwU5U4aeun6mWzZ"}} +{"id": "9omWNKymwU5U4aeun6mWzZ", "sk": "METADATA#SOURCE", "slot": {"id": "SLOT#ORG#123", "sk": "ORDER#1221#ENROLLMENT#9omWNKymwU5U4aeun6mWzZ"}} ``` # Cursos diff --git a/enrollments-events/app/certs/sample.html b/enrollments-events/app/certs/sample.html index 930cc3c..2eac9f2 100644 --- a/enrollments-events/app/certs/sample.html +++ b/enrollments-events/app/certs/sample.html @@ -2,7 +2,7 @@ - NR-10 Complementar (SEP) + CIPA Grau de Risco 1 + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + - -
- +

Certificamos que

+

{{ name }}

+

+ Portador(a) do CPF {{ cpf }} , concluiu o + curso de CIPA Grau de Risco 1 com + aproveitamento de + {{ progress }}% +

+

Realizado entre {{ started_at }} e {{ completed_at }}

+

São José, SC, {{ today }}

+ +
+
{name}
+
+ +
+
+ Tiago Maciel dos Santos + +

Tiago Maciel do Santos

+

Responsável legal

+

EDUSEG LTDA

+

CNPJ 15.608.435/0001-90

+
+
@@ -233,17 +428,26 @@

Carga horária

-

40 horas

+

8 horas

Instrutor e responsável técnico

-

Francis Ricardo Baretta

+ +

Francis Ricardo Baretta

CPF 039.539.409-02

Eng. de Segurança no Trabalho Eng. Eletricista

CREA/SC 126693-0

+ +
diff --git a/http-api/app/rules/enrollment.py b/http-api/app/rules/enrollment.py index 2965cf1..77fdb99 100644 --- a/http-api/app/rules/enrollment.py +++ b/http-api/app/rules/enrollment.py @@ -1,9 +1,11 @@ from dataclasses import asdict, dataclass -from datetime import timedelta -from enum import Enum from typing import Self, TypedDict from uuid import uuid4 +from aws_lambda_powertools.event_handler.exceptions import ( + BadRequestError, + NotFoundError, +) from layercake.dateutils import now, ttl from layercake.dynamodb import DynamoDBPersistenceLayer, KeyPair from layercake.strutils import md5_hash @@ -38,43 +40,21 @@ class Slot: return LinkedEntity(idx, 'order') -class LifecycleEvents(str, Enum): - """Lifecycle events related to scheduling actions.""" - - # Reminder if the user does not access within 3 days - # REMINDER_NO_ACCESS_3_DAYS = 'schedules#reminder_no_access_3_days' - DOES_NOT_ACCESS = 'schedules#does_not_access' - - # When there is no activity 7 days after the first access - # NO_ACTIVITY_7_DAYS = 'schedules#no_activity_7_days' - NO_ACTIVITY = 'schedules#no_activity' - - # Reminder 30 days before the access period expires - # ACCESS_PERIOD_REMINDER_30_DAYS = 'schedules#access_period_reminder_30_days' - ACCESS_PERIOD_ENDS = 'schedules#access_period_ends' - - # Reminder for certificate expiration set to 30 days from now - CERT_EXPIRATION_REMINDER_30_DAYS = 'schedules#cert_expiration_reminder_30_days' - - # Archive the course after the certificate expires - # SET_AS_ARCHIVE = 'schedules#set_as_archive' - ARCHIVE_IT = 'schedules#archive_it' - - # When the access period ends for a course without a certificate - # SET_AS_EXPIRE = 'schedules#set_as_expire' - EXPIRATION = 'schedules#expiration' - - -class SlotDoesNotExistError(Exception): +class SlotDoesNotExistError(NotFoundError): def __init__(self, *args): - super().__init__('Slot does not exist') + super().__init__('Slot not found') -class DeduplicationConflictError(Exception): +class DeduplicationConflictError(BadRequestError): def __init__(self, *args): super().__init__('Enrollment already exists') +class EnrollmentConflictError(BadRequestError): + def __init__(self, *_): + super().__init__('Enrollment status conflict') + + def enroll( enrollment: Enrollment, *, @@ -145,7 +125,7 @@ def enroll( transact.put( item={ 'id': enrollment.id, - 'sk': 'cancel_policy', + 'sk': 'CANCEL_POLICY', 'created_at': now_, } ) @@ -165,9 +145,7 @@ def enroll( # the deduplication window expires or is removed. if deduplication_window: offset_days = deduplication_window['offset_days'] - ttl_expiration = ttl( - start_dt=now_ + timedelta(days=course.access_period - offset_days) - ) + ttl_expiration = ttl(start_dt=now_, days=course.access_period - offset_days) transact.put( item={ 'id': 'lock', @@ -224,11 +202,13 @@ def set_status_as_canceled( transact.update( key=KeyPair(id, '0'), update_expr='SET #status = :canceled, updated_at = :updated_at', + cond_expr='#status = :pending', expr_attr_names={ '#status': 'status', }, expr_attr_values={ ':canceled': 'CANCELED', + ':pending': 'PENDING', ':updated_at': now_, }, ) @@ -241,20 +221,50 @@ def set_status_as_canceled( }, ) transact.delete( - key=KeyPair(id, 'CANCEL_POLICY'), + key=KeyPair( + pk=id, + sk='CANCEL_POLICY', + ), cond_expr='attribute_exists(sk)', + exc_cls=EnrollmentConflictError, ) - # Remove schedules lifecycle events, referencies and locks - transact.delete(key=KeyPair(id, 'SCHEDULE#REMINDER_NO_ACCESS_AFTER_3_DAYS')) - transact.delete(key=KeyPair(id, 'SCHEDULE#REMINDER_NO_ACTIVITY_AFTER_7_DAYS')) + # Remove reminders and policies that no longer apply transact.delete( - key=KeyPair(id, 'SCHEDULE#REMINDER_ACCESS_PERIOD_BEFORE_30_DAYS') + key=KeyPair( + pk=id, + sk='SCHEDULE#REMINDER_NO_ACCESS_AFTER_3_DAYS', + ) ) transact.delete( - key=KeyPair(id, 'SCHEDULE#REMINDER_CERT_EXPIRATION_BEFORE_30_DAYS') + 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', + ) + ) + transact.delete( + key=KeyPair( + pk=id, + sk='SCHEDULE#REMINDER_CERT_EXPIRATION_BEFORE_30_DAYS', + ) + ) + transact.delete( + key=KeyPair( + pk=id, + sk='SCHEDULE#SET_AS_EXPIRED', + ) + ) + transact.delete( + key=KeyPair( + pk=id, + sk='parent_vacancy', + ) ) - transact.delete(key=KeyPair(id, 'SCHEDULE#SET_AS_EXPIRED')) - transact.delete(key=KeyPair(id, 'parent_vacancy')) if lock_hash: transact.delete(key=KeyPair(id, 'LOCK')) diff --git a/order-events/app/events/billing/cancel_enrollment.py b/order-events/app/events/billing/cancel_enrollment.py index b82761f..1d73f21 100644 --- a/order-events/app/events/billing/cancel_enrollment.py +++ b/order-events/app/events/billing/cancel_enrollment.py @@ -30,7 +30,7 @@ def lambda_handler(event: EventBridgeEvent, context: LambdaContext) -> bool: + SortKey('METADATA#SUBSCRIPTION_COVERED') # Post-migration: uncomment the following line # + SortKey('CANCELED', path_spec='canceled_by', rename_key='canceled_by') - + SortKey('canceled', path_spec='author', rename_key='canceled_by') + + SortKey('CANCELED', path_spec='author', rename_key='canceled_by') ) created_at: datetime = fromisoformat(new_image['created_at']) # type: ignore @@ -67,7 +67,7 @@ def lambda_handler(event: EventBridgeEvent, context: LambdaContext) -> bool: 'created_at': now_, } | pick(('user', 'course', 'enrolled_at'), old_enrollment) - # Add author if present + # Add created_by if present | ({'author': canceled_by} if canceled_by else {}), # Post-migration: uncomment the following line # | ({'created_by': canceled_by} if canceled_by else {}),