add webhook to docseal

This commit is contained in:
2025-11-03 20:08:49 -03:00
parent eca3ac42dc
commit fef60f2ae0
22 changed files with 290 additions and 29 deletions

View File

@@ -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)

View File

@@ -1,4 +1,4 @@
from typing import TypedDict from typing import NotRequired, TypedDict
import requests import requests
@@ -8,8 +8,22 @@ headers = {
'X-Auth-Token': DOCSEAL_KEY, 'X-Auth-Token': DOCSEAL_KEY,
} }
Submitter = TypedDict('Submitter', {'role': str, 'name': str, 'email': str}) Submitter = TypedDict(
EmailMessage = TypedDict('EmailMessage', {'subject': str, 'body': str}) 'Submitter',
{
'role': str,
'name': str,
'email': str,
'external_id': NotRequired[str],
},
)
EmailMessage = TypedDict(
'EmailMessage',
{
'subject': str,
'body': str,
},
)
def create_submission_from_pdf( def create_submission_from_pdf(

View File

@@ -45,9 +45,10 @@ def lambda_handler(event: EventBridgeEvent, context: LambdaContext) -> bool:
}, },
submitters=[ submitters=[
{ {
'role': 'Aluno', 'role': 'First Party',
'name': user['name'], 'name': user['name'],
'email': user['email'], 'email': user['email'],
'external_id': user['id'],
}, },
], ],
) )

View File

@@ -50,6 +50,39 @@ Resources:
Properties: Properties:
RetentionInDays: 90 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: EventSetSubscriptionCoveredFunction:
Type: AWS::Serverless::Function Type: AWS::Serverless::Function
Properties: Properties:
@@ -422,3 +455,13 @@ Resources:
- prefix: CERT_REPORTING#ORG - prefix: CERT_REPORTING#ORG
sk: sk:
- suffix: SCHEDULE#SEND_REPORT_EMAIL - 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

View File

@@ -1,5 +1,8 @@
import base64
import json
import os import os
from dataclasses import dataclass from dataclasses import dataclass
from http import HTTPMethod
import jsonlines import jsonlines
import pytest import pytest
@@ -17,6 +20,7 @@ def pytest_configure():
os.environ['DOCSEAL_KEY'] = 'gUWhWtYBgTaP8fc1q5GZ6JuUHaZzMgZna6KFBHz3Gzk' os.environ['DOCSEAL_KEY'] = 'gUWhWtYBgTaP8fc1q5GZ6JuUHaZzMgZna6KFBHz3Gzk'
os.environ['USER_TABLE'] = PYTEST_TABLE_NAME os.environ['USER_TABLE'] = PYTEST_TABLE_NAME
os.environ['COURSE_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['ORDER_TABLE'] = PYTEST_TABLE_NAME
os.environ['ENROLLMENT_TABLE'] = PYTEST_TABLE_NAME os.environ['ENROLLMENT_TABLE'] = PYTEST_TABLE_NAME
os.environ['BUCKET_NAME'] = 'saladeaula.digital' os.environ['BUCKET_NAME'] = 'saladeaula.digital'
@@ -36,6 +40,78 @@ def lambda_context() -> LambdaContext:
return 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&parameter1=value2&parameter2=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 @pytest.fixture
def dynamodb_client(): def dynamodb_client():
from boto3clients import dynamodb_client as client from boto3clients import dynamodb_client as client
@@ -80,3 +156,10 @@ def seeds(dynamodb_client):
TableName=PYTEST_TABLE_NAME, TableName=PYTEST_TABLE_NAME,
Item=serialize(line), Item=serialize(line),
) )
@pytest.fixture
def app():
import app
return app

View File

@@ -1,7 +1,8 @@
import app.events.send_reminder_emails as app
from aws_lambda_powertools.utilities.typing import LambdaContext from aws_lambda_powertools.utilities.typing import LambdaContext
from layercake.dynamodb import DynamoDBPersistenceLayer, KeyPair from layercake.dynamodb import DynamoDBPersistenceLayer, KeyPair
import events.send_reminder_emails as app
def test_reminder_access_period_before_30_days( def test_reminder_access_period_before_30_days(
seeds, seeds,

View File

@@ -1,6 +1,7 @@
import app.events.send_reminder_emails as app
from aws_lambda_powertools.utilities.typing import LambdaContext from aws_lambda_powertools.utilities.typing import LambdaContext
import events.send_reminder_emails as app
def test_reminder_cert_expiration_before_30_days( def test_reminder_cert_expiration_before_30_days(
seeds, seeds,

View File

@@ -1,6 +1,7 @@
import app.events.send_reminder_emails as app
from aws_lambda_powertools.utilities.typing import LambdaContext from aws_lambda_powertools.utilities.typing import LambdaContext
import events.send_reminder_emails as app
def test_reminder_no_access_after_3_days( def test_reminder_no_access_after_3_days(
seeds, seeds,

View File

@@ -1,6 +1,7 @@
import app.events.send_reminder_emails as app
from aws_lambda_powertools.utilities.typing import LambdaContext from aws_lambda_powertools.utilities.typing import LambdaContext
import events.send_reminder_emails as app
def test_reminder_no_activity_after_7_days( def test_reminder_no_activity_after_7_days(
seeds, seeds,

View File

@@ -1,6 +1,5 @@
from datetime import timedelta from datetime import timedelta
import app.events.reporting.append_cert as app
from aws_lambda_powertools.utilities.typing import LambdaContext from aws_lambda_powertools.utilities.typing import LambdaContext
from layercake.dateutils import now from layercake.dateutils import now
from layercake.dynamodb import ( from layercake.dynamodb import (
@@ -9,6 +8,8 @@ from layercake.dynamodb import (
TransactKey, TransactKey,
) )
import events.reporting.append_cert as app
def test_append_cert( def test_append_cert(
seeds, seeds,

View File

@@ -1,10 +1,11 @@
import app.events.reporting.send_report_email as app
from aws_lambda_powertools.utilities.typing import LambdaContext from aws_lambda_powertools.utilities.typing import LambdaContext
from layercake.dynamodb import ( from layercake.dynamodb import (
DynamoDBPersistenceLayer, DynamoDBPersistenceLayer,
KeyPair, KeyPair,
) )
import events.reporting.send_report_email as app
def test_send_report_email( def test_send_report_email(
monkeypatch, monkeypatch,

View File

@@ -1,11 +1,11 @@
import app.events.stopgap.patch_course_metadata as app
from aws_lambda_powertools.utilities.typing import LambdaContext from aws_lambda_powertools.utilities.typing import LambdaContext
from layercake.dynamodb import ( from layercake.dynamodb import (
DynamoDBPersistenceLayer, DynamoDBPersistenceLayer,
SortKey, KeyPair,
TransactKey,
) )
import events.stopgap.patch_course_metadata as app
def test_enroll( def test_enroll(
seeds, seeds,
@@ -27,10 +27,8 @@ def test_enroll(
} }
assert app.lambda_handler(event, lambda_context) # type: ignore assert app.lambda_handler(event, lambda_context) # type: ignore
result = dynamodb_persistence_layer.collection.get_items( r = dynamodb_persistence_layer.collection.get_item(
TransactKey('47ZxxcVBjvhDS5TE98tpfQ') KeyPair('47ZxxcVBjvhDS5TE98tpfQ', '0')
+ SortKey('0')
+ SortKey('METADATA#DEDUPLICATION_WINDOW')
) )
assert 'METADATA#DEDUPLICATION_WINDOW' in result assert 'access_expires_at' in r

View File

@@ -1,7 +1,8 @@
import app.events.allocate_slots as app
from aws_lambda_powertools.utilities.typing import LambdaContext from aws_lambda_powertools.utilities.typing import LambdaContext
from layercake.dynamodb import DynamoDBPersistenceLayer, PartitionKey from layercake.dynamodb import DynamoDBPersistenceLayer, PartitionKey
import events.allocate_slots as app
def test_allocate_slots( def test_allocate_slots(
seeds, seeds,

View File

@@ -14,6 +14,7 @@ def test_ask_to_sign(
's3_uri': 's3://saladeaula.digital/certs/samples/nr11-operador-de-munck.pdf' 's3_uri': 's3://saladeaula.digital/certs/samples/nr11-operador-de-munck.pdf'
}, },
'user': { 'user': {
'id': '5OxmMjL-ujoR5IMGegQz',
'name': 'Sérgio R Siqueira', 'name': 'Sérgio R Siqueira',
'email': 'sergio@somosbeta.com.br', 'email': 'sergio@somosbeta.com.br',
}, },

View File

@@ -1,7 +1,8 @@
import app.events.enroll as app
from aws_lambda_powertools.utilities.typing import LambdaContext from aws_lambda_powertools.utilities.typing import LambdaContext
from layercake.dynamodb import DynamoDBPersistenceLayer, KeyPair, PartitionKey from layercake.dynamodb import DynamoDBPersistenceLayer, KeyPair, PartitionKey
import events.enroll as app
def test_enroll( def test_enroll(
seeds, seeds,

View File

@@ -1,7 +1,8 @@
import app.events.reenroll_if_failed as app
from aws_lambda_powertools.utilities.typing import LambdaContext from aws_lambda_powertools.utilities.typing import LambdaContext
from layercake.dynamodb import DynamoDBPersistenceLayer, KeyPair from layercake.dynamodb import DynamoDBPersistenceLayer, KeyPair
import events.reenroll_if_failed as app
def test_reenroll_custom_dedup_window( def test_reenroll_custom_dedup_window(
seeds, seeds,

View File

@@ -1,10 +1,11 @@
import app.events.schedule_reminders as app
from aws_lambda_powertools.utilities.typing import LambdaContext from aws_lambda_powertools.utilities.typing import LambdaContext
from layercake.dynamodb import ( from layercake.dynamodb import (
DynamoDBPersistenceLayer, DynamoDBPersistenceLayer,
PartitionKey, PartitionKey,
) )
import events.schedule_reminders as app
def test_schedule_reminders( def test_schedule_reminders(
seeds, seeds,

View File

@@ -1,4 +1,3 @@
import app.events.set_access_expired as app
from aws_lambda_powertools.utilities.typing import LambdaContext from aws_lambda_powertools.utilities.typing import LambdaContext
from layercake.dynamodb import ( from layercake.dynamodb import (
DynamoDBPersistenceLayer, DynamoDBPersistenceLayer,
@@ -6,6 +5,8 @@ from layercake.dynamodb import (
TransactKey, TransactKey,
) )
import events.set_access_expired as app
def test_set_access_expired( def test_set_access_expired(
seeds, seeds,

View File

@@ -1,4 +1,3 @@
import app.events.set_cert_expired as app
from aws_lambda_powertools.utilities.typing import LambdaContext from aws_lambda_powertools.utilities.typing import LambdaContext
from layercake.dynamodb import ( from layercake.dynamodb import (
DynamoDBPersistenceLayer, DynamoDBPersistenceLayer,
@@ -6,6 +5,8 @@ from layercake.dynamodb import (
TransactKey, TransactKey,
) )
import events.set_cert_expired as app
def test_set_cert_expired( def test_set_cert_expired(
seeds, seeds,

View File

@@ -25,7 +25,7 @@
// Enrollment // Enrollment
{"id": "6437a282-6fe8-4e4d-9eb0-da1007238007", "sk": "0", "status": "IN_PROGRESS", "progress": 10} {"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": "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"} {"id": "1ee108ae-67d4-4545-bf6d-4e641cdaa4e0", "sk": "STARTED", "started_at": "2025-08-24T01:44:42.703012-03:06"}

View File

@@ -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)

View File

@@ -1,6 +1,6 @@
import base64 import base64
import uuid import uuid
from unittest.mock import MagicMock, patch from unittest.mock import patch
from docseal import create_submission_from_pdf 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(): def Response(*args, **kwargs):
r = MagicMock() 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: with open('tests/sample.pdf', 'rb') as f:
file = base64.b64encode(f.read()) file = base64.b64encode(f.read())
create_submission_from_pdf( create_submission_from_pdf(