diff --git a/enrollments-events/app/app.py b/enrollments-events/app/app.py new file mode 100644 index 0000000..27168fe --- /dev/null +++ b/enrollments-events/app/app.py @@ -0,0 +1,69 @@ +from http import HTTPStatus +from typing import Any + +import requests +from aws_lambda_powertools import Logger, Tracer +from aws_lambda_powertools.event_handler.api_gateway import ( + APIGatewayHttpResolver, + Response, +) +from aws_lambda_powertools.logging import correlation_paths +from aws_lambda_powertools.utilities.typing import LambdaContext +from layercake.dynamodb import DynamoDBPersistenceLayer, KeyPair + +from boto3clients import dynamodb_client, s3_client +from config import BUCKET_NAME, ENROLLMENT_TABLE +from docseal import headers + +logger = Logger(__name__) +tracer = Tracer() +app = APIGatewayHttpResolver(enable_validation=True) +dyn = DynamoDBPersistenceLayer(ENROLLMENT_TABLE, dynamodb_client) + + +@app.post('/') +@tracer.capture_method +def postback(): + json_body = app.current_event.json_body + enrollment_id = json_body['data']['name'] + combined_document_url = json_body['data'].get('combined_document_url') + + if not combined_document_url: + return Response(status_code=HTTPStatus.NO_CONTENT) + + try: + r = requests.get(combined_document_url, headers=headers) + r.raise_for_status() + + object_key = f'certs/{enrollment_id}.pdf' + s3_uri = f's3://{BUCKET_NAME}/{object_key}' + + s3_client.put_object( + Bucket=BUCKET_NAME, + Key=object_key, + Body=r.content, + ContentType='application/pdf', + ) + + logger.debug(f'PDF uploaded successfully to {s3_uri}') + except requests.exceptions.RequestException as exc: + logger.exception(exc) + raise + + dyn.update_item( + key=KeyPair(enrollment_id, '0'), + cond_expr='attribute_exists(sk)', + update_expr='SET cert.s3_uri = :s3_uri', + expr_attr_values={':s3_uri': s3_uri}, + ) + + return Response(status_code=HTTPStatus.NO_CONTENT) + + +@logger.inject_lambda_context(correlation_id_path=correlation_paths.API_GATEWAY_HTTP) +@tracer.capture_lambda_handler +def lambda_handler( + event: dict[str, Any], + context: LambdaContext, +) -> dict[str, Any]: + return app.resolve(event, context) diff --git a/enrollments-events/app/docseal.py b/enrollments-events/app/docseal.py index 5c80d07..e3991d3 100644 --- a/enrollments-events/app/docseal.py +++ b/enrollments-events/app/docseal.py @@ -1,4 +1,4 @@ -from typing import TypedDict +from typing import NotRequired, TypedDict import requests @@ -8,8 +8,22 @@ headers = { 'X-Auth-Token': DOCSEAL_KEY, } -Submitter = TypedDict('Submitter', {'role': str, 'name': str, 'email': str}) -EmailMessage = TypedDict('EmailMessage', {'subject': str, 'body': str}) +Submitter = TypedDict( + 'Submitter', + { + 'role': str, + 'name': str, + 'email': str, + 'external_id': NotRequired[str], + }, +) +EmailMessage = TypedDict( + 'EmailMessage', + { + 'subject': str, + 'body': str, + }, +) def create_submission_from_pdf( diff --git a/enrollments-events/app/events/ask_to_sign.py b/enrollments-events/app/events/ask_to_sign.py index 5958178..b7b5938 100644 --- a/enrollments-events/app/events/ask_to_sign.py +++ b/enrollments-events/app/events/ask_to_sign.py @@ -45,9 +45,10 @@ def lambda_handler(event: EventBridgeEvent, context: LambdaContext) -> bool: }, submitters=[ { - 'role': 'Aluno', + 'role': 'First Party', 'name': user['name'], 'email': user['email'], + 'external_id': user['id'], }, ], ) diff --git a/enrollments-events/template.yaml b/enrollments-events/template.yaml index b2d6303..a3fbb04 100644 --- a/enrollments-events/template.yaml +++ b/enrollments-events/template.yaml @@ -50,6 +50,39 @@ Resources: Properties: RetentionInDays: 90 + HttpLog: + Type: AWS::Logs::LogGroup + Properties: + RetentionInDays: 90 + + HttpApi: + Type: AWS::Serverless::HttpApi + Properties: + CorsConfiguration: + AllowOrigins: ["*"] + AllowMethods: [POST, OPTIONS] + AllowHeaders: [Content-Type, X-Requested-With] + + HttpApiFunction: + Type: AWS::Serverless::Function + Properties: + Handler: app.lambda_handler + Timeout: 12 + LoggingConfig: + LogGroup: !Ref HttpLog + Policies: + - DynamoDBWritePolicy: + TableName: !Ref EnrollmentTable + - S3WritePolicy: + BucketName: !Ref BucketName + Events: + Post: + Type: HttpApi + Properties: + Path: / + Method: POST + ApiId: !Ref HttpApi + EventSetSubscriptionCoveredFunction: Type: AWS::Serverless::Function Properties: @@ -422,3 +455,13 @@ Resources: - prefix: CERT_REPORTING#ORG sk: - suffix: SCHEDULE#SEND_REPORT_EMAIL + +Outputs: + HttpApiUrl: + Description: URL of your API endpoint + Value: + Fn::Sub: "https://${HttpApi}.execute-api.${AWS::Region}.${AWS::URLSuffix}" + HttpApiId: + Description: Api ID of HttpApi + Value: + Ref: HttpApi diff --git a/enrollments-events/tests/conftest.py b/enrollments-events/tests/conftest.py index 56d327e..b1ac1d1 100644 --- a/enrollments-events/tests/conftest.py +++ b/enrollments-events/tests/conftest.py @@ -1,5 +1,8 @@ +import base64 +import json import os from dataclasses import dataclass +from http import HTTPMethod import jsonlines import pytest @@ -17,6 +20,7 @@ def pytest_configure(): os.environ['DOCSEAL_KEY'] = 'gUWhWtYBgTaP8fc1q5GZ6JuUHaZzMgZna6KFBHz3Gzk' os.environ['USER_TABLE'] = PYTEST_TABLE_NAME os.environ['COURSE_TABLE'] = PYTEST_TABLE_NAME + os.environ['ENROLLMENT_TABLE'] = PYTEST_TABLE_NAME os.environ['ORDER_TABLE'] = PYTEST_TABLE_NAME os.environ['ENROLLMENT_TABLE'] = PYTEST_TABLE_NAME os.environ['BUCKET_NAME'] = 'saladeaula.digital' @@ -36,6 +40,78 @@ def lambda_context() -> LambdaContext: return LambdaContext() +class HttpApiProxy: + def __call__( + self, + raw_path: str, + method: str = HTTPMethod.GET, + body: dict = {}, + *, + headers: dict = {}, + auth_flow_type: str = 'USER_AUTH', + queryStringParameters: dict = {}, + **kwargs, + ) -> dict: + return { + 'version': '2.0', + 'routeKey': '$default', + 'rawPath': raw_path, + 'rawQueryString': 'parameter1=value1¶meter1=value2¶meter2=value', + 'cookies': ['cookie1', 'cookie2'], + 'headers': headers, + 'queryStringParameters': queryStringParameters, + 'requestContext': { + 'accountId': '123456789012', + 'apiId': 'api-id', + 'authorizer': { + 'lambda': { + 'user': { + 'name': 'Sérgio R Siqueira', + 'email': 'sergio@somosbeta.com.br', + 'email_verified': 'true', + 'custom:user_id': '5OxmMjL-ujoR5IMGegQz', + 'sub': 'c4f30dbd-083e-4b84-aa50-c31afe9b9c01', + }, + 'auth_flow_type': auth_flow_type, + }, + 'jwt': { + 'claims': {'claim1': 'value1', 'claim2': 'value2'}, + 'scopes': ['scope1', 'scope2'], + }, + }, + 'domainName': 'id.execute-api.us-east-1.amazonaws.com', + 'domainPrefix': 'id', + 'http': { + 'method': str(method), + 'path': raw_path, + 'protocol': 'HTTP/1.1', + 'sourceIp': '192.168.0.1/32', + 'userAgent': 'agent', + }, + 'requestId': 'id', + 'routeKey': '$default', + 'stage': '$default', + 'time': '12/Mar/2020:19:03:58 +0000', + 'timeEpoch': 1583348638390, + }, + 'body': _base64_dict(body), + 'pathParameters': {'parameter1': 'value1'}, + 'isBase64Encoded': True, + 'stageVariables': {'stageVariable1': 'value1', 'stageVariable2': 'value2'}, + } + + +def _base64_dict(obj: dict = {}) -> str | None: + if not obj: + return None + return base64.b64encode(json.dumps(obj).encode()).decode() + + +@pytest.fixture +def http_api_proxy(): + return HttpApiProxy() + + @pytest.fixture def dynamodb_client(): from boto3clients import dynamodb_client as client @@ -80,3 +156,10 @@ def seeds(dynamodb_client): TableName=PYTEST_TABLE_NAME, Item=serialize(line), ) + + +@pytest.fixture +def app(): + import app + + return app 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 b7795d9..dcab221 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,7 +1,8 @@ -import app.events.send_reminder_emails as app from aws_lambda_powertools.utilities.typing import LambdaContext from layercake.dynamodb import DynamoDBPersistenceLayer, KeyPair +import events.send_reminder_emails as app + def test_reminder_access_period_before_30_days( seeds, diff --git a/enrollments-events/tests/events/emails/test_reminder_cert_expiration_before_30_days.py b/enrollments-events/tests/events/emails/test_reminder_cert_expiration_before_30_days.py index 8fb6595..2b1c725 100644 --- a/enrollments-events/tests/events/emails/test_reminder_cert_expiration_before_30_days.py +++ b/enrollments-events/tests/events/emails/test_reminder_cert_expiration_before_30_days.py @@ -1,6 +1,7 @@ -import app.events.send_reminder_emails as app from aws_lambda_powertools.utilities.typing import LambdaContext +import events.send_reminder_emails as app + def test_reminder_cert_expiration_before_30_days( seeds, diff --git a/enrollments-events/tests/events/emails/test_reminder_no_access_after_3_days.py b/enrollments-events/tests/events/emails/test_reminder_no_access_after_3_days.py index 1ed3faf..422ae16 100644 --- a/enrollments-events/tests/events/emails/test_reminder_no_access_after_3_days.py +++ b/enrollments-events/tests/events/emails/test_reminder_no_access_after_3_days.py @@ -1,6 +1,7 @@ -import app.events.send_reminder_emails as app from aws_lambda_powertools.utilities.typing import LambdaContext +import events.send_reminder_emails as app + def test_reminder_no_access_after_3_days( seeds, diff --git a/enrollments-events/tests/events/emails/test_reminder_no_activity_after_7_days.py b/enrollments-events/tests/events/emails/test_reminder_no_activity_after_7_days.py index 2d70025..45567c6 100644 --- a/enrollments-events/tests/events/emails/test_reminder_no_activity_after_7_days.py +++ b/enrollments-events/tests/events/emails/test_reminder_no_activity_after_7_days.py @@ -1,6 +1,7 @@ -import app.events.send_reminder_emails as app from aws_lambda_powertools.utilities.typing import LambdaContext +import events.send_reminder_emails as app + def test_reminder_no_activity_after_7_days( seeds, diff --git a/enrollments-events/tests/events/reporting/test_append_cert.py b/enrollments-events/tests/events/reporting/test_append_cert.py index 2b7146d..dd1d17f 100644 --- a/enrollments-events/tests/events/reporting/test_append_cert.py +++ b/enrollments-events/tests/events/reporting/test_append_cert.py @@ -1,6 +1,5 @@ from datetime import timedelta -import app.events.reporting.append_cert as app from aws_lambda_powertools.utilities.typing import LambdaContext from layercake.dateutils import now from layercake.dynamodb import ( @@ -9,6 +8,8 @@ from layercake.dynamodb import ( TransactKey, ) +import events.reporting.append_cert as app + def test_append_cert( seeds, 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 3306be5..27cc217 100644 --- a/enrollments-events/tests/events/reporting/test_send_report_email.py +++ b/enrollments-events/tests/events/reporting/test_send_report_email.py @@ -1,10 +1,11 @@ -import app.events.reporting.send_report_email as app from aws_lambda_powertools.utilities.typing import LambdaContext from layercake.dynamodb import ( DynamoDBPersistenceLayer, KeyPair, ) +import events.reporting.send_report_email as app + def test_send_report_email( monkeypatch, diff --git a/enrollments-events/tests/events/stopgap/test_patch_course_metadata.py b/enrollments-events/tests/events/stopgap/test_patch_course_metadata.py index 7ee2421..df208c7 100644 --- a/enrollments-events/tests/events/stopgap/test_patch_course_metadata.py +++ b/enrollments-events/tests/events/stopgap/test_patch_course_metadata.py @@ -1,11 +1,11 @@ -import app.events.stopgap.patch_course_metadata as app from aws_lambda_powertools.utilities.typing import LambdaContext from layercake.dynamodb import ( DynamoDBPersistenceLayer, - SortKey, - TransactKey, + KeyPair, ) +import events.stopgap.patch_course_metadata as app + def test_enroll( seeds, @@ -27,10 +27,8 @@ def test_enroll( } assert app.lambda_handler(event, lambda_context) # type: ignore - result = dynamodb_persistence_layer.collection.get_items( - TransactKey('47ZxxcVBjvhDS5TE98tpfQ') - + SortKey('0') - + SortKey('METADATA#DEDUPLICATION_WINDOW') + r = dynamodb_persistence_layer.collection.get_item( + KeyPair('47ZxxcVBjvhDS5TE98tpfQ', '0') ) - assert 'METADATA#DEDUPLICATION_WINDOW' in result + assert 'access_expires_at' in r diff --git a/enrollments-events/tests/events/test_allocate_slots.py b/enrollments-events/tests/events/test_allocate_slots.py index 2e6e0df..55edea7 100644 --- a/enrollments-events/tests/events/test_allocate_slots.py +++ b/enrollments-events/tests/events/test_allocate_slots.py @@ -1,7 +1,8 @@ -import app.events.allocate_slots as app from aws_lambda_powertools.utilities.typing import LambdaContext from layercake.dynamodb import DynamoDBPersistenceLayer, PartitionKey +import events.allocate_slots as app + def test_allocate_slots( seeds, diff --git a/enrollments-events/tests/events/test_ask_to_sign.py b/enrollments-events/tests/events/test_ask_to_sign.py index 97e409b..6b5cdae 100644 --- a/enrollments-events/tests/events/test_ask_to_sign.py +++ b/enrollments-events/tests/events/test_ask_to_sign.py @@ -14,6 +14,7 @@ def test_ask_to_sign( 's3_uri': 's3://saladeaula.digital/certs/samples/nr11-operador-de-munck.pdf' }, 'user': { + 'id': '5OxmMjL-ujoR5IMGegQz', 'name': 'Sérgio R Siqueira', 'email': 'sergio@somosbeta.com.br', }, diff --git a/enrollments-events/tests/events/test_enroll.py b/enrollments-events/tests/events/test_enroll.py index 575c400..09313e9 100644 --- a/enrollments-events/tests/events/test_enroll.py +++ b/enrollments-events/tests/events/test_enroll.py @@ -1,7 +1,8 @@ -import app.events.enroll as app from aws_lambda_powertools.utilities.typing import LambdaContext from layercake.dynamodb import DynamoDBPersistenceLayer, KeyPair, PartitionKey +import events.enroll as app + def test_enroll( seeds, diff --git a/enrollments-events/tests/events/test_reenroll_if_failed.py b/enrollments-events/tests/events/test_reenroll_if_failed.py index e1b0dc5..ae061a2 100644 --- a/enrollments-events/tests/events/test_reenroll_if_failed.py +++ b/enrollments-events/tests/events/test_reenroll_if_failed.py @@ -1,7 +1,8 @@ -import app.events.reenroll_if_failed as app from aws_lambda_powertools.utilities.typing import LambdaContext from layercake.dynamodb import DynamoDBPersistenceLayer, KeyPair +import events.reenroll_if_failed as app + def test_reenroll_custom_dedup_window( seeds, diff --git a/enrollments-events/tests/events/test_schedule_reminders.py b/enrollments-events/tests/events/test_schedule_reminders.py index cafb1cb..7ab6f94 100644 --- a/enrollments-events/tests/events/test_schedule_reminders.py +++ b/enrollments-events/tests/events/test_schedule_reminders.py @@ -1,10 +1,11 @@ -import app.events.schedule_reminders as app from aws_lambda_powertools.utilities.typing import LambdaContext from layercake.dynamodb import ( DynamoDBPersistenceLayer, PartitionKey, ) +import events.schedule_reminders as app + def test_schedule_reminders( seeds, diff --git a/enrollments-events/tests/events/test_set_access_expired.py b/enrollments-events/tests/events/test_set_access_expired.py index 13043a7..1b56c25 100644 --- a/enrollments-events/tests/events/test_set_access_expired.py +++ b/enrollments-events/tests/events/test_set_access_expired.py @@ -1,4 +1,3 @@ -import app.events.set_access_expired as app from aws_lambda_powertools.utilities.typing import LambdaContext from layercake.dynamodb import ( DynamoDBPersistenceLayer, @@ -6,6 +5,8 @@ from layercake.dynamodb import ( TransactKey, ) +import events.set_access_expired as app + def test_set_access_expired( seeds, diff --git a/enrollments-events/tests/events/test_set_cert_expired.py b/enrollments-events/tests/events/test_set_cert_expired.py index ea58bd4..aad27a4 100644 --- a/enrollments-events/tests/events/test_set_cert_expired.py +++ b/enrollments-events/tests/events/test_set_cert_expired.py @@ -1,4 +1,3 @@ -import app.events.set_cert_expired as app from aws_lambda_powertools.utilities.typing import LambdaContext from layercake.dynamodb import ( DynamoDBPersistenceLayer, @@ -6,6 +5,8 @@ from layercake.dynamodb import ( TransactKey, ) +import events.set_cert_expired as app + def test_set_cert_expired( seeds, diff --git a/enrollments-events/tests/seeds.jsonl b/enrollments-events/tests/seeds.jsonl index b5af7a1..ae4d7db 100644 --- a/enrollments-events/tests/seeds.jsonl +++ b/enrollments-events/tests/seeds.jsonl @@ -25,7 +25,7 @@ // Enrollment {"id": "6437a282-6fe8-4e4d-9eb0-da1007238007", "sk": "0", "status": "IN_PROGRESS", "progress": 10} -{"id": "845fe390-e3c3-4514-97f8-c42de0566cf0", "sk": "0", "status": "COMPLETED", "progress": 100} +{"id": "845fe390-e3c3-4514-97f8-c42de0566cf0", "sk": "0", "status": "COMPLETED", "progress": 100, "cert": {"s3_uri": "s3://saladeaula.digital/certs/samples/nr11-operador-de-munck.pdf", "issued_at": "2025-08-24T01:44:42.703012-03:06"}} {"id": "1ee108ae-67d4-4545-bf6d-4e641cdaa4e0", "sk": "0", "status": "COMPLETED", "score": 100, "course": {"id": "123", "name": "CIPA Grau de Risco 1"}, "user": {"name": "Kurt Cobain"}, "cert": {"s3_uri": "s3://saladeaula.digital/issuedcerts/1ee108ae-67d4-4545-bf6d-4e641cdaa4e0.pdf"}} {"id": "1ee108ae-67d4-4545-bf6d-4e641cdaa4e0", "sk": "STARTED", "started_at": "2025-08-24T01:44:42.703012-03:06"} diff --git a/enrollments-events/tests/test_app.py b/enrollments-events/tests/test_app.py new file mode 100644 index 0000000..0afb12d --- /dev/null +++ b/enrollments-events/tests/test_app.py @@ -0,0 +1,38 @@ +from http import HTTPMethod, HTTPStatus + +from layercake.dynamodb import DynamoDBPersistenceLayer, KeyPair + +from .conftest import HttpApiProxy, LambdaContext + +enrollment_id = '845fe390-e3c3-4514-97f8-c42de0566cf0' +body = { + 'data': { + 'name': enrollment_id, + 'slug': 'jzNV3ZrF6T16tX', + 'combined_document_url': 'https://docs.eduseg.com.br/file/WyI3MGVhNGQwYS02YTE5LTQyYzItODgyNy1iZTc0Zjc2ZTlkNmUiLCJibG9iIiwxNzYyMjEwMjg2XQ--72caad853fdc1c64c5321368c7d883828be0b859ed39689355bac9dffe71b8e2/e249c51b-3e68-42eb-bb4b-20659263ce1c.pdf', + }, +} + + +def test_postback( + app, + seeds, + dynamodb_persistence_layer: DynamoDBPersistenceLayer, + http_api_proxy: HttpApiProxy, + lambda_context: LambdaContext, +): + # This data was added from seeds + r = app.lambda_handler( + http_api_proxy( + raw_path='/', + method=HTTPMethod.POST, + body=body, + ), + lambda_context, + ) + assert r['statusCode'] == HTTPStatus.NO_CONTENT + + r = dynamodb_persistence_layer.get_item( + KeyPair('845fe390-e3c3-4514-97f8-c42de0566cf0', '0') + ) + print(r) diff --git a/enrollments-events/tests/test_docseal.py b/enrollments-events/tests/test_docseal.py index 2b6201c..5929c11 100644 --- a/enrollments-events/tests/test_docseal.py +++ b/enrollments-events/tests/test_docseal.py @@ -1,6 +1,6 @@ import base64 import uuid -from unittest.mock import MagicMock, patch +from unittest.mock import patch from docseal import create_submission_from_pdf @@ -13,10 +13,12 @@ Seu certificado já está pronto e aguardando apenas a sua assinatura digital. """ -def test_create_submission_from_pdf(): - r = MagicMock() +def Response(*args, **kwargs): + return type('Response', (), {'raise_for_status': object}) - with patch('docseal.requests.post', r): + +def test_create_submission_from_pdf(): + with patch('docseal.requests.post', Response): with open('tests/sample.pdf', 'rb') as f: file = base64.b64encode(f.read()) create_submission_from_pdf(