add cert_expires_at

This commit is contained in:
2025-10-15 15:10:47 -03:00
parent 54c92b3996
commit ffa04d9b15
37 changed files with 371 additions and 230 deletions

View File

@@ -0,0 +1,14 @@
from aws_lambda_powertools import Logger
from aws_lambda_powertools.event_handler.api_gateway import Router
from layercake.dynamodb import DynamoDBPersistenceLayer
from boto3clients import dynamodb_client
from config import ENROLLMENT_TABLE
logger = Logger(__name__)
router = Router()
dyn = DynamoDBPersistenceLayer(ENROLLMENT_TABLE, dynamodb_client)
@router.patch('/')
def cancel(): ...

View File

@@ -0,0 +1,28 @@
from http import HTTPStatus
from typing import Annotated
from aws_lambda_powertools.event_handler.api_gateway import Router
from aws_lambda_powertools.event_handler.openapi.params import Body
from layercake.dynamodb import (
DynamoDBPersistenceLayer,
KeyPair,
)
from api_gateway import JSONResponse
from boto3clients import dynamodb_client
from config import ENROLLMENT_TABLE
router = Router()
dyn = DynamoDBPersistenceLayer(ENROLLMENT_TABLE, dynamodb_client)
@router.patch('/<enrollment_id>/dedupwindow', compress=True)
def dedup_window(
enrollment_id: str,
lock_hash: Annotated[str, Body(embed=True)],
):
with dyn.transact_writer() as transact:
transact.delete(key=KeyPair(enrollment_id, 'LOCK'))
transact.delete(key=KeyPair('LOCK', lock_hash))
return JSONResponse(HTTPStatus.NO_CONTENT)

View File

@@ -0,0 +1,22 @@
from aws_lambda_powertools.event_handler.api_gateway import Router
from boto3clients import s3_client
from config import BUCKET_NAME
router = Router()
@router.get('/<enrollment_id>/download')
def download(enrollment_id: str):
params = {
'Bucket': BUCKET_NAME,
'Key': f'certs/{enrollment_id}.pdf',
'ResponseContentDisposition': f'attachment; filename="{enrollment_id}.pdf"',
}
return {
'presigned_url': s3_client.generate_presigned_url(
ClientMethod='get_object',
Params=params,
ExpiresIn=300, # 5 minutes
)
}

View File

@@ -0,0 +1,14 @@
from aws_lambda_powertools import Logger
from aws_lambda_powertools.event_handler.api_gateway import Router
from layercake.dynamodb import DynamoDBPersistenceLayer
from boto3clients import dynamodb_client
from config import ENROLLMENT_TABLE
logger = Logger(__name__)
router = Router()
dyn = DynamoDBPersistenceLayer(ENROLLMENT_TABLE, dynamodb_client)
@router.post('/')
def enroll(): ...

View File

@@ -0,0 +1,21 @@
from aws_lambda_powertools import Logger
from aws_lambda_powertools.event_handler.api_gateway import Router
from layercake.dynamodb import DynamoDBPersistenceLayer, KeyPair
from boto3clients import dynamodb_client
from config import USER_TABLE
logger = Logger(__name__)
router = Router()
dyn = DynamoDBPersistenceLayer(USER_TABLE, dynamodb_client)
@router.get('/<user_id>/orgs')
def get_orgs(user_id: str):
start_key = router.current_event.get_query_string_value('start_key', None)
return dyn.collection.query(
# Post-migration (users): rename `orgs` to `ORG`
key=KeyPair(user_id, 'orgs'),
start_key=start_key,
)

View File

@@ -20,7 +20,7 @@ Globals:
Architectures: Architectures:
- x86_64 - x86_64
Layers: Layers:
- !Sub arn:aws:lambda:sa-east-1:336641857101:layer:layercake:97 - !Sub arn:aws:lambda:sa-east-1:336641857101:layer:layercake:99
Environment: Environment:
Variables: Variables:
TZ: America/Sao_Paulo TZ: America/Sao_Paulo

View File

@@ -60,7 +60,7 @@ def test_edit_course(
assert ( assert (
r['cert']['s3_uri'] r['cert']['s3_uri']
== 's3://saladeaula.digital/certs/2a8963fc-4694-4fe2-953a-316d1b10f1f5.html' == 's3://saladeaula.digital/certs/templates/2a8963fc-4694-4fe2-953a-316d1b10f1f5.html'
) )

View File

@@ -592,7 +592,7 @@ wheels = [
[[package]] [[package]]
name = "layercake" name = "layercake"
version = "0.10.1" version = "0.11.0"
source = { directory = "../layercake" } source = { directory = "../layercake" }
dependencies = [ dependencies = [
{ name = "arnparse" }, { name = "arnparse" },

View File

@@ -86,7 +86,7 @@ class Course:
def _get_courses(ids: set) -> tuple[Course, ...]: def _get_courses(ids: set) -> tuple[Course, ...]:
pairs = tuple(KeyPair(idx, '0') for idx in ids) pairs = tuple(KeyPair(idx, '0') for idx in ids)
result = course_layer.collection.get_items( r = course_layer.collection.get_items(
KeyChain(pairs), KeyChain(pairs),
flatten_top=False, flatten_top=False,
) )
@@ -96,7 +96,7 @@ def _get_courses(ids: set) -> tuple[Course, ...]:
name=obj['name'], name=obj['name'],
access_period=obj['access_period'], access_period=obj['access_period'],
) )
for idx, obj in result.items() for idx, obj in r.items()
) )
return courses return courses

View File

@@ -1,5 +1,6 @@
import json import json
from datetime import datetime, timedelta from datetime import datetime, timedelta
from typing import NotRequired, TypedDict
import requests import requests
from aws_lambda_powertools import Logger from aws_lambda_powertools import Logger
@@ -21,8 +22,7 @@ from config import (
) )
logger = Logger(__name__) logger = Logger(__name__)
enrollment_layer = DynamoDBPersistenceLayer(ENROLLMENT_TABLE, dynamodb_client) dyn = DynamoDBPersistenceLayer(ENROLLMENT_TABLE, dynamodb_client)
course_layer = DynamoDBPersistenceLayer(COURSE_TABLE, dynamodb_client)
@event_source(data_class=EventBridgeEvent) @event_source(data_class=EventBridgeEvent)
@@ -32,10 +32,11 @@ def lambda_handler(event: EventBridgeEvent, context: LambdaContext) -> bool:
now_ = now() now_ = now()
enrollment_id = new_image['id'] enrollment_id = new_image['id']
course_id = new_image['course']['id'] course_id = new_image['course']['id']
cert = course_layer.collection.get_item( cert = dyn.collection.get_item(
KeyPair( KeyPair(
pk=course_id, pk=course_id,
sk=SortKey('0', path_spec='cert', rename_key='cert'), sk=SortKey('0', path_spec='cert', rename_key='cert'),
table_name=COURSE_TABLE,
), ),
raise_on_error=False, raise_on_error=False,
default=None, default=None,
@@ -49,76 +50,36 @@ def lambda_handler(event: EventBridgeEvent, context: LambdaContext) -> bool:
started_at: datetime = fromisoformat(new_image['started_at']) # type: ignore started_at: datetime = fromisoformat(new_image['started_at']) # type: ignore
completed_at: datetime = fromisoformat(new_image['completed_at']) # type: ignore completed_at: datetime = fromisoformat(new_image['completed_at']) # type: ignore
# Certificate may have no expiration # Certificate may have no expiration
cert_expires_at = ( expires_at = (
completed_at + timedelta(days=int(cert['exp_interval'])) completed_at + timedelta(days=int(cert['exp_interval']))
if cert.get('exp_interval', 0) > 0 if cert.get('exp_interval', 0) > 0
else None else None
) )
s3_uri = _gen_cert(
enrollment_id,
cert=cert,
user=new_image['user'],
score=new_image['score'],
started_at=started_at,
completed_at=completed_at,
expires_at=expires_at,
)
try: update_expr = 'SET cert = :cert, updated_at = :now'
if 's3_uri' not in cert: expr_attr_values = {
raise ValueError('Template URI is missing') ':now': now_,
':cert': {'issued_at': now_} | ({'s3_uri': s3_uri} if s3_uri else {}),
}
# Send template URI and data to Paperforge API to generate a PDF if expires_at:
r = requests.post( update_expr = 'SET cert = :cert, cert_expires_at = :cert_expires_at, \
PAPERFORGE_API, updated_at = :now'
data=json.dumps( expr_attr_values[':cert_expires_at'] = expires_at
{
'template_uri': cert['s3_uri'],
'sign_uri': ESIGN_URI,
'args': {
'name': new_image['user']['name'],
'cpf': _cpffmt(new_image['user']['cpf']),
'score': new_image['score'],
'started_at': started_at.strftime('%d/%m/%Y'),
'completed_at': completed_at.strftime('%d/%m/%Y'),
'today': _datefmt(now_),
'year': now_.strftime('%Y'),
}
| (
{'expires_at': cert_expires_at.strftime('%d/%m/%Y')}
if cert_expires_at
else {}
),
},
),
timeout=5,
)
r.raise_for_status()
object_key = f'certs/{enrollment_id}.pdf' return dyn.update_item(
s3_uri = f's3://{BUCKET_NAME}/{object_key}' key=KeyPair(pk=enrollment_id, sk='0'),
update_expr=update_expr,
s3_client.put_object( expr_attr_values=expr_attr_values,
Bucket=BUCKET_NAME,
Key=object_key,
Body=r.content,
ContentType='application/pdf',
)
logger.debug(f'PDF uploaded successfully to {s3_uri}')
except ValueError as exc:
# PDF generation fails if template URI is missing
s3_uri = None
logger.exception(exc)
except requests.exceptions.RequestException as exc:
logger.exception(exc)
raise
return enrollment_layer.update_item(
key=KeyPair(
pk=enrollment_id,
sk='0',
),
update_expr='SET cert = :cert, updated_at = :now',
expr_attr_values={
':now': now_,
':cert': {
'issued_at': now_,
}
| ({'expires_at': cert_expires_at} if cert_expires_at else {})
| ({'s3_uri': s3_uri} if s3_uri else {}),
},
cond_expr='attribute_exists(sk)', cond_expr='attribute_exists(sk)',
) )
@@ -143,3 +104,67 @@ def _datefmt(dt: datetime) -> str:
'Dezembro', 'Dezembro',
] ]
return f'{dt.day:02d} de {months[dt.month - 1]} de {dt.year}' return f'{dt.day:02d} de {months[dt.month - 1]} de {dt.year}'
User = TypedDict('User', {'name': str, 'cpf': str})
Cert = TypedDict('Cert', {'s3_uri': NotRequired[str]})
def _gen_cert(
id: str,
*,
score: int | float,
cert: Cert,
user: User,
started_at: datetime,
completed_at: datetime,
expires_at: datetime | None = None,
) -> str | None:
now_ = now()
if 's3_uri' not in cert:
logger.debug('Template URI is missing')
return None
try:
# Send template URI and data to Paperforge API to generate a PDF
r = requests.post(
PAPERFORGE_API,
data=json.dumps(
{
'template_uri': cert['s3_uri'],
'sign_uri': ESIGN_URI,
'args': {
'name': user['name'],
'cpf': _cpffmt(user['cpf']),
'score': score,
'started_at': started_at.strftime('%d/%m/%Y'),
'completed_at': completed_at.strftime('%d/%m/%Y'),
'today': _datefmt(now_),
'year': now_.strftime('%Y'),
'expires_at': expires_at.strftime('%d/%m/%Y')
if expires_at
else None,
},
},
),
timeout=5,
)
r.raise_for_status()
object_key = f'certs/{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
return s3_uri

View File

@@ -29,10 +29,7 @@ def lambda_handler(event: EventBridgeEvent, context: LambdaContext) -> bool:
path_spec='offset_days', path_spec='offset_days',
rename_key='dedup_window_offset_days', rename_key='dedup_window_offset_days',
) )
+ SortKey('ORG', rename_key='org') + SortKey('ORG', rename_key='org'),
+ SortKey('konviva'),
# Post-migration: uncomment the following lines
# + SortKey('KONVIVA', rename_key='konviva')
flatten_top=False, flatten_top=False,
) )
user = User.model_validate(new_image['user']) user = User.model_validate(new_image['user'])

View File

@@ -1,5 +1,5 @@
import os import os
from datetime import datetime, timedelta from datetime import timedelta
import pytz import pytz
from aws_lambda_powertools import Logger from aws_lambda_powertools import Logger
@@ -8,7 +8,6 @@ from aws_lambda_powertools.utilities.data_classes import (
event_source, event_source,
) )
from aws_lambda_powertools.utilities.typing import LambdaContext from aws_lambda_powertools.utilities.typing import LambdaContext
from glom import glom
from layercake.dateutils import fromisoformat, now, ttl from layercake.dateutils import fromisoformat, now, ttl
from layercake.dynamodb import DynamoDBPersistenceLayer from layercake.dynamodb import DynamoDBPersistenceLayer
from layercake.funcs import pick from layercake.funcs import pick
@@ -25,14 +24,11 @@ tz = os.getenv('TZ', 'UTC')
@logger.inject_lambda_context @logger.inject_lambda_context
def lambda_handler(event: EventBridgeEvent, context: LambdaContext) -> bool | None: def lambda_handler(event: EventBridgeEvent, context: LambdaContext) -> bool | None:
new_image = event.detail['new_image'] new_image = event.detail['new_image']
expires_at = glom(new_image, 'cert.expires_at', default=None)
if not expires_at:
return None
enrollment_id = new_image['id'] enrollment_id = new_image['id']
org_id = new_image['org_id'] org_id = new_image['org_id']
expires_at: datetime = fromisoformat(expires_at).replace(tzinfo=pytz.timezone(tz)) # type: ignore 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 # The reporting month is the month before the certificate expires
month_start = (expires_at.replace(day=1) - timedelta(days=1)).replace(day=1) month_start = (expires_at.replace(day=1) - timedelta(days=1)).replace(day=1)
now_ = now() now_ = now()
@@ -59,11 +55,10 @@ def lambda_handler(event: EventBridgeEvent, context: LambdaContext) -> bool | No
item={ item={
'id': pk, 'id': pk,
'sk': f'{sk}#ENROLLMENT#{enrollment_id}', 'sk': f'{sk}#ENROLLMENT#{enrollment_id}',
'enrollment_id': new_image['id'],
'user': pick(('id', 'name'), new_image['user']), 'user': pick(('id', 'name'), new_image['user']),
'course': pick(('id', 'name'), new_image['course']), 'course': pick(('id', 'name'), new_image['course']),
'enrolled_at': new_image['created_at'], 'enrolled_at': new_image['created_at'],
'expires_at': expires_at, # type: ignore 'expires_at': expires_at,
'completed_at': new_image['completed_at'], 'completed_at': new_image['completed_at'],
'created_at': now_, 'created_at': now_,
}, },

View File

@@ -52,14 +52,14 @@ def lambda_handler(event: EventBridgeEvent, context: LambdaContext) -> bool:
# Key pattern `CERT#REPORTING#ORG#{org_id}` # Key pattern `CERT#REPORTING#ORG#{org_id}`
*_, org_id = old_image['id'].split('#') *_, org_id = old_image['id'].split('#')
event_name = old_image['sk'] event_name = old_image['sk']
target_month = datetime.strptime(old_image['target_month'], '%Y-%m').date() target_month = old_image['target_month']
month = _monthfmt(target_month) pretty_month = _monthfmt(datetime.strptime(target_month, '%Y-%m').date())
now_ = now() now_ = now()
result = enrollment_layer.collection.query( r = enrollment_layer.collection.query(
KeyPair( KeyPair(
pk=old_image['id'], pk=old_image['id'],
sk='MONTH#{}#ENROLLMENT'.format(target_month.strftime('%Y-%m')), sk=f'MONTH#{target_month}#ENROLLMENT',
), ),
limit=150, limit=150,
) )
@@ -68,8 +68,8 @@ def lambda_handler(event: EventBridgeEvent, context: LambdaContext) -> bool:
{ {
'template_uri': CERT_REPORTING_URI, 'template_uri': CERT_REPORTING_URI,
'args': { 'args': {
'month': month, 'month': pretty_month,
'items': result['items'], 'items': r['items'],
}, },
}, },
cls=Encoder, cls=Encoder,
@@ -83,14 +83,14 @@ def lambda_handler(event: EventBridgeEvent, context: LambdaContext) -> bool:
to=_get_admin_emails(org_id), to=_get_admin_emails(org_id),
reply_to=REPLY_TO, reply_to=REPLY_TO,
bcc=BCC, bcc=BCC,
subject=SUBJECT.format(month=month), subject=SUBJECT.format(month=pretty_month),
) )
emailmsg.add_alternative(MESSAGE.format(month=month)) emailmsg.add_alternative(MESSAGE.format(month=pretty_month))
attachment = MIMEApplication(r.content) attachment = MIMEApplication(r.content)
attachment.add_header( attachment.add_header(
'Content-Disposition', 'Content-Disposition',
'attachment', 'attachment',
filename='{}.pdf'.format(target_month.strftime('%Y-%m')), filename=f'{target_month}.pdf',
) )
emailmsg.attach(attachment) emailmsg.attach(attachment)

View File

@@ -1,5 +1,3 @@
import json
import sqlite3
from datetime import timedelta from datetime import timedelta
from aws_lambda_powertools import Logger from aws_lambda_powertools import Logger
@@ -17,8 +15,6 @@ from config import (
ENROLLMENT_TABLE, ENROLLMENT_TABLE,
) )
sqlite3.register_converter('json', json.loads)
logger = Logger(__name__) logger = Logger(__name__)
enrollment_layer = DynamoDBPersistenceLayer(ENROLLMENT_TABLE, dynamodb_client) enrollment_layer = DynamoDBPersistenceLayer(ENROLLMENT_TABLE, dynamodb_client)
course_layer = DynamoDBPersistenceLayer(COURSE_TABLE, dynamodb_client) course_layer = DynamoDBPersistenceLayer(COURSE_TABLE, dynamodb_client)

View File

@@ -37,7 +37,7 @@ def lambda_handler(event: EventBridgeEvent, context: LambdaContext) -> bool:
transact.update( transact.update(
key=KeyPair(new_image['id'], '0'), key=KeyPair(new_image['id'], '0'),
update_expr='SET subscription_covered = :subscription_covered, \ update_expr='SET subscription_covered = :subscription_covered, \
updated_at = :now', updated_at = :now',
expr_attr_values={ expr_attr_values={
':subscription_covered': True, ':subscription_covered': True,
':now': now_, ':now': now_,

View File

@@ -26,7 +26,7 @@ Globals:
Architectures: Architectures:
- x86_64 - x86_64
Layers: Layers:
- !Sub arn:aws:lambda:sa-east-1:336641857101:layer:layercake:98 - !Sub arn:aws:lambda:sa-east-1:336641857101:layer:layercake:99
Environment: Environment:
Variables: Variables:
TZ: America/Sao_Paulo TZ: America/Sao_Paulo
@@ -96,6 +96,8 @@ Resources:
detail: detail:
new_image: new_image:
sk: ["0"] sk: ["0"]
access_expires_at:
- exists: false
EventPatchKonvivaFunction: EventPatchKonvivaFunction:
Type: AWS::Serverless::Function Type: AWS::Serverless::Function
@@ -355,6 +357,8 @@ Resources:
sk: ["0"] sk: ["0"]
new_image: new_image:
status: [COMPLETED] status: [COMPLETED]
cert_expires_at:
- exists: true
org_id: org_id:
- exists: true - exists: true

View File

@@ -1,11 +1,10 @@
import os import os
from dataclasses import dataclass from dataclasses import dataclass
from uuid import uuid4
import jsonlines import jsonlines
import pytest import pytest
PYTEST_TABLE_NAME = f'pytest-{uuid4()}' PYTEST_TABLE_NAME = 'pytest'
PK = 'id' PK = 'id'
SK = 'sk' SK = 'sk'

View File

@@ -15,7 +15,7 @@ def test_append_cert(
dynamodb_persistence_layer: DynamoDBPersistenceLayer, dynamodb_persistence_layer: DynamoDBPersistenceLayer,
lambda_context: LambdaContext, lambda_context: LambdaContext,
): ):
expires_at = now() + timedelta(days=360) cert_expires_at = now() + timedelta(days=360)
event = { event = {
'detail': { 'detail': {
'new_image': { 'new_image': {
@@ -25,9 +25,7 @@ def test_append_cert(
'id': '431', 'id': '431',
'name': 'How to Sing Better', 'name': 'How to Sing Better',
}, },
'cert': { 'cert_expires_at': cert_expires_at.isoformat(),
'expires_at': expires_at.isoformat(),
},
'user': { 'user': {
'id': '1234', 'id': '1234',
'name': 'Tobias Summit', 'name': 'Tobias Summit',
@@ -41,7 +39,7 @@ def test_append_cert(
assert app.lambda_handler(event, lambda_context) # type: ignore assert app.lambda_handler(event, lambda_context) # type: ignore
# The reporting month is the month before the certificate expires # The reporting month is the month before the certificate expires
month_start = (expires_at.replace(day=1) - timedelta(days=1)).replace(day=1) month_start = (cert_expires_at.replace(day=1) - timedelta(days=1)).replace(day=1)
report_sk = 'MONTH#{}#SCHEDULE#SEND_REPORT_EMAIL'.format( report_sk = 'MONTH#{}#SCHEDULE#SEND_REPORT_EMAIL'.format(
month_start.strftime('%Y-%m') month_start.strftime('%Y-%m')
) )
@@ -54,7 +52,7 @@ def test_append_cert(
) )
+ SortKey( + SortKey(
sk='MONTH#{}#ENROLLMENT#e45019d8-be7a-4a82-9b37-12a01f0127bb'.format( sk='MONTH#{}#ENROLLMENT#e45019d8-be7a-4a82-9b37-12a01f0127bb'.format(
expires_at.strftime('%Y-%m') cert_expires_at.strftime('%Y-%m')
), ),
rename_key='enrollment', rename_key='enrollment',
), ),

View File

@@ -36,3 +36,43 @@ def test_issue_cert(
) )
assert 'cert' in r assert 'cert' in r
assert 'cert_expires_at' in r
assert (
r['cert']['s3_uri']
== 's3://saladeaula.digital/certs/1ee108ae-67d4-4545-bf6d-4e641cdaa4e0.pdf'
)
def test_non_exp_interval(
seeds,
dynamodb_persistence_layer: DynamoDBPersistenceLayer,
lambda_context: LambdaContext,
):
enrollment_id = '1ee108ae-67d4-4545-bf6d-4e641cdaa4e0'
event = {
'detail': {
'new_image': {
'id': enrollment_id,
'completed_at': '2025-09-21T14:20:36.276467-03:00',
'started_at': '2025-09-19T14:34:54.704548-03:00',
'user': {
'name': 'Josh Kiszka',
'cpf': '74630003037',
},
'course': {
'id': '12334',
'name': 'pytest',
},
'score': 79,
'status': 'COMPLETED',
}
}
}
assert app.lambda_handler(event, lambda_context) # type: ignore
r = dynamodb_persistence_layer.get_item(
key=KeyPair('1ee108ae-67d4-4545-bf6d-4e641cdaa4e0', '0')
)
assert 'cert' in r
assert 'cert_expires_at' not in r

View File

@@ -12,7 +12,7 @@
// Course // Course
{"id": "123", "sk": "0", "access_period": 360, "cert": {"exp_interval": 700, "s3_uri": "s3://saladeaula.digital/certs/samples/cipa-grau-de-risco-1.html"}, "created_at": "2025-07-14T15:09:18.559528-03:00", "metadata__konviva_class_id": "281", "name": "pytest", "tenant_id": "*"} {"id": "123", "sk": "0", "access_period": 360, "cert": {"exp_interval": 700, "s3_uri": "s3://saladeaula.digital/certs/samples/cipa-grau-de-risco-1.html"}, "created_at": "2025-07-14T15:09:18.559528-03:00", "metadata__konviva_class_id": "281", "name": "pytest", "tenant_id": "*"}
{"id": "12334", "sk": "0", "access_period": 360} {"id": "12334", "sk": "0", "access_period": 360, "cert": {"s3_uri": "s3://saladeaula.digital/certs/samples/cipa-grau-de-risco-1.html"}}
{"id": "a955518e-ebcb-4441-b914-ddc9ecef84f0", "sk": "0", "access_period": "360", "cert": {"exp_interval": 360}, "created_at": "2025-07-14T15:09:18.559528-03:00", "metadata__konviva_class_id": "281", "name": "NR-11 Operador de Munck", "tenant_id": "*"} {"id": "a955518e-ebcb-4441-b914-ddc9ecef84f0", "sk": "0", "access_period": "360", "cert": {"exp_interval": 360}, "created_at": "2025-07-14T15:09:18.559528-03:00", "metadata__konviva_class_id": "281", "name": "NR-11 Operador de Munck", "tenant_id": "*"}
{"id": "6a403773-aeac-4e6a-ac39-dc958e4be52a", "sk": "0", "access_period": "360", "cert": {"exp_interval": 360}, "created_at": "2025-07-14T15:09:18.559528-03:00", "metadata__konviva_class_id": "281", "name": "Reciclagem em NR-11 - Operador de Empilhadeira", "tenant_id": "*"} {"id": "6a403773-aeac-4e6a-ac39-dc958e4be52a", "sk": "0", "access_period": "360", "cert": {"exp_interval": 360}, "created_at": "2025-07-14T15:09:18.559528-03:00", "metadata__konviva_class_id": "281", "name": "Reciclagem em NR-11 - Operador de Empilhadeira", "tenant_id": "*"}
{"id": "e1c44881-2fe3-484e-ada2-12b6bf5b9398", "sk": "0", "name": "NR-35 Segurança nos Trabalhos em Altura (Teórico)", "updated_at": "2025-08-22T00:00:24.431267-03:00", "access_period": 360, "created_at": "2024-12-30T00:11:33.088916-03:00", "metadata__konviva_class_id": 1, "tenant_id": "*", "cert": {"exp_interval": 700}, "metadata__unit_price": 119} {"id": "e1c44881-2fe3-484e-ada2-12b6bf5b9398", "sk": "0", "name": "NR-35 Segurança nos Trabalhos em Altura (Teórico)", "updated_at": "2025-08-22T00:00:24.431267-03:00", "access_period": 360, "created_at": "2024-12-30T00:11:33.088916-03:00", "metadata__konviva_class_id": 1, "tenant_id": "*", "cert": {"exp_interval": 700}, "metadata__unit_price": 119}

View File

@@ -501,7 +501,7 @@ wheels = [
[[package]] [[package]]
name = "layercake" name = "layercake"
version = "0.10.1" version = "0.11.0"
source = { directory = "../layercake" } source = { directory = "../layercake" }
dependencies = [ dependencies = [
{ name = "arnparse" }, { name = "arnparse" },

View File

@@ -14,7 +14,7 @@ Globals:
Architectures: Architectures:
- x86_64 - x86_64
Layers: Layers:
- !Sub arn:aws:lambda:sa-east-1:336641857101:layer:layercake:98 - !Sub arn:aws:lambda:sa-east-1:336641857101:layer:layercake:99
Environment: Environment:
Variables: Variables:
TZ: America/Sao_Paulo TZ: America/Sao_Paulo

View File

@@ -507,7 +507,7 @@ wheels = [
[[package]] [[package]]
name = "layercake" name = "layercake"
version = "0.10.1" version = "0.11.0"
source = { directory = "../layercake" } source = { directory = "../layercake" }
dependencies = [ dependencies = [
{ name = "arnparse" }, { name = "arnparse" },

View File

@@ -1,20 +1,17 @@
from decimal import Decimal from decimal import Decimal
from aws_lambda_powertools import Logger
from aws_lambda_powertools.event_handler.exceptions import ( from aws_lambda_powertools.event_handler.exceptions import (
BadRequestError, BadRequestError,
NotFoundError, NotFoundError,
) )
from botocore.args import logger
from glom import glom
from layercake.dateutils import now, ttl from layercake.dateutils import now, ttl
from layercake.dynamodb import DynamoDBPersistenceLayer, KeyPair, SortKey, TransactKey from layercake.dynamodb import DynamoDBPersistenceLayer, KeyPair, SortKey, TransactKey
from layercake.strutils import md5_hash from layercake.strutils import md5_hash
from boto3clients import dynamodb_client
from config import COURSE_TABLE from config import COURSE_TABLE
# @TODO Find a better way logger = Logger(__name__)
course_layer = DynamoDBPersistenceLayer(COURSE_TABLE, dynamodb_client)
def update_progress( def update_progress(
@@ -119,15 +116,8 @@ def set_score(
), ),
) )
user_id = enrollment['user']['id'] user_id = enrollment['user']['id']
course_id = glom(enrollment, 'course.id') course_id = enrollment['course']['id']
exp_interval = course_layer.collection.get_item( dedup_window_offset_days = int(enrollment['dedup_window_offset_days'])
KeyPair(
pk=course_id,
sk=SortKey('0', path_spec='cert.exp_interval'),
),
raise_on_error=False,
default=0,
)
try: try:
if score >= 70: if score >= 70:
@@ -138,8 +128,7 @@ def set_score(
progress=progress, progress=progress,
user_id=user_id, user_id=user_id,
course_id=course_id, course_id=course_id,
cert_exp_interval=int(exp_interval), dedup_window_offset_days=dedup_window_offset_days,
dedup_window_offset_days=int(enrollment['dedup_window_offset_days']),
dynamodb_persistence_layer=dynamodb_persistence_layer, dynamodb_persistence_layer=dynamodb_persistence_layer,
) )
@@ -165,23 +154,21 @@ def _set_status_as_completed(
*, *,
user_id: str, user_id: str,
course_id: str, course_id: str,
cert_exp_interval: int,
dedup_window_offset_days: int, dedup_window_offset_days: int,
dynamodb_persistence_layer: DynamoDBPersistenceLayer, dynamodb_persistence_layer: DynamoDBPersistenceLayer,
) -> bool: ) -> bool:
now_ = now() now_ = now()
lock_hash = md5_hash(f'{user_id}{course_id}') lock_hash = md5_hash(f'{user_id}{course_id}')
cert_exp_ttl = ttl( exp_interval = int(
start_dt=now_, dynamodb_persistence_layer.collection.get_item(
days=cert_exp_interval, KeyPair(
) pk=course_id,
cert_exp_reminder_ttl = ttl( sk=SortKey('0', path_spec='cert.exp_interval'),
start_dt=now_, table_name=COURSE_TABLE,
days=cert_exp_interval - 30, ),
) raise_on_error=False,
dedup_lock_ttl = ttl( default=0,
start_dt=now_, )
days=cert_exp_interval - dedup_window_offset_days,
) )
with dynamodb_persistence_layer.transact_writer() as transact: with dynamodb_persistence_layer.transact_writer() as transact:
@@ -204,12 +191,17 @@ def _set_status_as_completed(
exc_cls=EnrollmentConflictError, exc_cls=EnrollmentConflictError,
) )
if cert_exp_interval: if exp_interval:
dedup_lock_ttl = ttl(
start_dt=now_,
days=exp_interval - dedup_window_offset_days,
)
transact.put( transact.put(
item={ item={
'id': id, 'id': id,
'sk': 'SCHEDULE#SET_CERT_EXPIRED', 'sk': 'SCHEDULE#SET_CERT_EXPIRED',
'ttl': cert_exp_ttl, 'ttl': ttl(start_dt=now_, days=exp_interval),
'created_at': now_, 'created_at': now_,
} }
) )
@@ -217,7 +209,7 @@ def _set_status_as_completed(
item={ item={
'id': id, 'id': id,
'sk': 'SCHEDULE#REMINDER_CERT_EXPIRATION_BEFORE_30_DAYS', 'sk': 'SCHEDULE#REMINDER_CERT_EXPIRATION_BEFORE_30_DAYS',
'ttl': cert_exp_reminder_ttl, 'ttl': ttl(start_dt=now_, days=exp_interval - 30),
'created_at': now_, 'created_at': now_,
} }
) )

View File

@@ -23,10 +23,10 @@ def lambda_handler(event: EventBridgeEvent, context: LambdaContext) -> bool:
# r = dyn.get_item(KeyPair(new_image['id'], 'KONVIVA')) # r = dyn.get_item(KeyPair(new_image['id'], 'KONVIVA'))
try: try:
result = konviva.cancel_enrollment(r['enrollment_id']) r = konviva.cancel_enrollment(r['enrollment_id'])
except Exception as exc: except Exception as exc:
logger.exception(exc) logger.exception(exc)
return False return False
else: else:
logger.info('Enrollment canceled', result=result) logger.info('Enrollment canceled', result=r)
return True return True

View File

@@ -35,10 +35,10 @@ def lambda_handler(event: EventBridgeEvent, context: LambdaContext) -> bool:
except konviva.EmailAlreadyExistsError as exc: except konviva.EmailAlreadyExistsError as exc:
logger.exception(exc, email=new_image['email']) logger.exception(exc, email=new_image['email'])
result = konviva.get_users_by_email(new_image['email']) r = konviva.get_users_by_email(new_image['email'])
user_id = glom(result, '0.IDUsuario') user_id = glom(r, '0.IDUsuario')
if not result: if not r:
raise UserNotFoundError('User not found') raise UserNotFoundError('User not found')
except Exception: except Exception:
raise raise

View File

@@ -31,10 +31,10 @@ def lambda_handler(event: EventBridgeEvent, context: LambdaContext) -> bool:
payload.update(CPF=new_image['cpf']) payload.update(CPF=new_image['cpf'])
try: try:
result = konviva.update_user(id=user_id, **payload) r = konviva.update_user(id=user_id, **payload)
except Exception as exc: except Exception as exc:
logger.exception(exc) logger.exception(exc)
return False return False
else: else:
logger.info('User updated', result=result, payload=payload) logger.info('User updated', result=r, payload=payload)
return True return True

View File

@@ -20,7 +20,7 @@ Globals:
Architectures: Architectures:
- x86_64 - x86_64
Layers: Layers:
- !Sub arn:aws:lambda:sa-east-1:336641857101:layer:layercake:96 - !Sub arn:aws:lambda:sa-east-1:336641857101:layer:layercake:99
Environment: Environment:
Variables: Variables:
TZ: America/Sao_Paulo TZ: America/Sao_Paulo

View File

@@ -497,7 +497,7 @@ dev = [
[[package]] [[package]]
name = "layercake" name = "layercake"
version = "0.10.1" version = "0.11.0"
source = { directory = "../layercake" } source = { directory = "../layercake" }
dependencies = [ dependencies = [
{ name = "arnparse" }, { name = "arnparse" },

View File

@@ -253,7 +253,6 @@ class KeyPair(Key):
sk: str, sk: str,
*, *,
rename_key: str | None = None, rename_key: str | None = None,
retain_key: bool = False,
table_name: str | None = None, table_name: str | None = None,
) -> None: ) -> None:
""" """
@@ -267,24 +266,17 @@ class KeyPair(Key):
The sort key. The sort key.
rename_key : str, optional rename_key : str, optional
If provided, renames the sort key in the output. If provided, renames the sort key in the output.
retain_key : bool, optional
Use the key itself as value if True; otherwise, use the extracted value.
table_name : str, optional table_name : str, optional
""" """
super().__init__(**{PK: pk, SK: sk}) super().__init__(**{PK: pk, SK: sk})
self._rename_key = rename_key self._rename_key = rename_key
self._retain_key = retain_key
self._table_name = table_name self._table_name = table_name
@property @property
def rename_key(self) -> str | None: def rename_key(self) -> str | None:
return self._rename_key return self._rename_key
@property
def retain_key(self) -> bool:
return self._retain_key
@property @property
def table_name(self) -> str | None: def table_name(self) -> str | None:
return self._table_name return self._table_name
@@ -435,14 +427,11 @@ class TransactWriter:
if cond_expr: if cond_expr:
attrs['ConditionExpression'] = cond_expr attrs['ConditionExpression'] = cond_expr
if not table_name:
table_name = self._table_name
self._add_op_and_process( self._add_op_and_process(
TransactOperation( TransactOperation(
{ {
'Put': dict( 'Put': dict(
TableName=table_name, TableName=table_name or self._table_name,
Item=serialize(item), Item=serialize(item),
**attrs, **attrs,
) )
@@ -473,14 +462,11 @@ class TransactWriter:
if expr_attr_values: if expr_attr_values:
attrs['ExpressionAttributeValues'] = serialize(expr_attr_values) attrs['ExpressionAttributeValues'] = serialize(expr_attr_values)
if not table_name:
table_name = self._table_name
self._add_op_and_process( self._add_op_and_process(
TransactOperation( TransactOperation(
{ {
'Update': dict( 'Update': dict(
TableName=table_name, TableName=table_name or self._table_name,
Key=serialize(key), Key=serialize(key),
UpdateExpression=update_expr, UpdateExpression=update_expr,
**attrs, **attrs,
@@ -511,14 +497,11 @@ class TransactWriter:
if expr_attr_values: if expr_attr_values:
attrs['ExpressionAttributeValues'] = serialize(expr_attr_values) attrs['ExpressionAttributeValues'] = serialize(expr_attr_values)
if not table_name:
table_name = self._table_name
self._add_op_and_process( self._add_op_and_process(
TransactOperation( TransactOperation(
{ {
'Delete': dict( 'Delete': dict(
TableName=table_name, TableName=table_name or self._table_name,
Key=serialize(key), Key=serialize(key),
**attrs, **attrs,
) )
@@ -545,14 +528,11 @@ class TransactWriter:
if expr_attr_values: if expr_attr_values:
attrs['ExpressionAttributeValues'] = serialize(expr_attr_values) attrs['ExpressionAttributeValues'] = serialize(expr_attr_values)
if not table_name:
table_name = self._table_name
self._add_op_and_process( self._add_op_and_process(
TransactOperation( TransactOperation(
{ {
'ConditionCheck': dict( 'ConditionCheck': dict(
TableName=table_name, TableName=table_name or self._table_name,
Key=serialize(key), Key=serialize(key),
**attrs, **attrs,
) )
@@ -619,6 +599,7 @@ class DynamoDBPersistenceLayer:
filter_expr: str | None = None, filter_expr: str | None = None,
limit: int | None = None, limit: int | None = None,
index_forward: bool = True, index_forward: bool = True,
table_name: str | None = None,
) -> dict[str, Any]: ) -> dict[str, Any]:
"""You must provide the name of the partition key attribute """You must provide the name of the partition key attribute
and a single value for that attribute. and a single value for that attribute.
@@ -637,7 +618,7 @@ class DynamoDBPersistenceLayer:
- https://boto3.amazonaws.com/v1/documentation/api/latest/reference/services/dynamodb/client/query.html - https://boto3.amazonaws.com/v1/documentation/api/latest/reference/services/dynamodb/client/query.html
""" """
attrs: dict = { attrs: dict = {
'TableName': self.table_name, 'TableName': table_name or self.table_name,
'KeyConditionExpression': key_cond_expr, 'KeyConditionExpression': key_cond_expr,
'ScanIndexForward': index_forward, 'ScanIndexForward': index_forward,
} }
@@ -658,18 +639,18 @@ class DynamoDBPersistenceLayer:
attrs['Limit'] = limit attrs['Limit'] = limit
try: try:
response = self.client.query(**attrs) r = self.client.query(**attrs)
except ClientError as err: except ClientError as err:
logger.info(attrs) logger.info(attrs)
logger.exception(err) logger.exception(err)
raise raise
else: else:
return dict( return dict(
items=[deserialize(v) for v in response.get('Items', [])], items=[deserialize(v) for v in r.get('Items', [])],
last_key=response.get('LastEvaluatedKey', None), last_key=r.get('LastEvaluatedKey', None),
) )
def get_item(self, key: dict) -> dict: def get_item(self, key: dict, table_name: str | None = None) -> dict:
"""The GetItem operation returns a set of attributes for the item """The GetItem operation returns a set of attributes for the item
with the given primary key. with the given primary key.
@@ -677,22 +658,28 @@ class DynamoDBPersistenceLayer:
there will be no Item element in the response. there will be no Item element in the response.
""" """
attrs = { attrs = {
'TableName': self.table_name, 'TableName': table_name or self.table_name,
'Key': serialize(key), 'Key': serialize(key),
} }
try: try:
response = self.client.get_item(**attrs) r = self.client.get_item(**attrs)
except ClientError as err: except ClientError as err:
logger.info(attrs) logger.info(attrs)
logger.exception(err) logger.exception(err)
raise raise
else: else:
return deserialize(response.get('Item', {})) return deserialize(r.get('Item', {}))
def put_item(self, item: dict, *, cond_expr: str | None = None) -> bool: def put_item(
self,
item: dict,
*,
cond_expr: str | None = None,
table_name: str | None = None,
) -> bool:
attrs = { attrs = {
'TableName': self.table_name, 'TableName': table_name or self.table_name,
'Item': serialize(item), 'Item': serialize(item),
} }
@@ -716,9 +703,10 @@ class DynamoDBPersistenceLayer:
cond_expr: str | None = None, cond_expr: str | None = None,
expr_attr_names: dict | None = None, expr_attr_names: dict | None = None,
expr_attr_values: dict | None = None, expr_attr_values: dict | None = None,
table_name: str | None = None,
) -> bool: ) -> bool:
attrs: dict = { attrs: dict = {
'TableName': self.table_name, 'TableName': table_name or self.table_name,
'Key': serialize(key), 'Key': serialize(key),
'UpdateExpression': update_expr, 'UpdateExpression': update_expr,
} }
@@ -748,13 +736,14 @@ class DynamoDBPersistenceLayer:
cond_expr: str | None = None, cond_expr: str | None = None,
expr_attr_names: dict | None = None, expr_attr_names: dict | None = None,
expr_attr_values: dict | None = None, expr_attr_values: dict | None = None,
table_name: str | None = None,
) -> bool: ) -> bool:
"""Deletes a single item in a table by primary key. You can perform """Deletes a single item in a table by primary key. You can perform
a conditional delete operation that deletes the item if it exists, a conditional delete operation that deletes the item if it exists,
or if it has an expected attribute value. or if it has an expected attribute value.
""" """
attrs: dict = { attrs: dict = {
'TableName': self.table_name, 'TableName': table_name or self.table_name,
'Key': serialize(key), 'Key': serialize(key),
} }
@@ -780,9 +769,13 @@ class DynamoDBPersistenceLayer:
def collection(self) -> 'DynamoDBCollection': def collection(self) -> 'DynamoDBCollection':
return DynamoDBCollection(self) return DynamoDBCollection(self)
def transact_writer(self, flush_amount: int = 50) -> TransactWriter: def transact_writer(
self,
flush_amount: int = 50,
table_name: str | None = None,
) -> TransactWriter:
return TransactWriter( return TransactWriter(
table_name=self.table_name, table_name=table_name or self.table_name,
client=self.client, client=self.client,
flush_amount=flush_amount, flush_amount=flush_amount,
) )
@@ -913,19 +906,20 @@ class DynamoDBCollection:
Raises the provided exception if the item is not found Raises the provided exception if the item is not found
and raise_on_error is True. and raise_on_error is True.
""" """
exc_cls = exc_cls or self.exc_cls table_name = getattr(key, 'table_name', None)
data = self.persistence_layer.get_item(key)
path_spec = getattr(key[SK], 'path_spec', None) path_spec = getattr(key[SK], 'path_spec', None)
r = self.persistence_layer.get_item(key, table_name)
if raise_on_error and not data: if raise_on_error and not r:
exc_cls = exc_cls or self.exc_cls
raise exc_cls(f'Item with {key} not found.') raise exc_cls(f'Item with {key} not found.')
if path_spec and data: if path_spec and r:
from glom import glom from glom import glom
return glom(data, path_spec, default=default) return glom(r, path_spec, default=default)
return data or default return r or default
def put_item( def put_item(
self, self,
@@ -954,6 +948,8 @@ class DynamoDBCollection:
bool bool
True if the operation is successful, False otherwise. True if the operation is successful, False otherwise.
""" """
table_name = getattr(key, 'table_name', None)
if isinstance(ttl, int): if isinstance(ttl, int):
kwargs.update({'ttl': ttl}) kwargs.update({'ttl': ttl})
@@ -963,6 +959,7 @@ class DynamoDBCollection:
return self.persistence_layer.put_item( return self.persistence_layer.put_item(
item=key | kwargs, item=key | kwargs,
cond_expr=cond_expr, cond_expr=cond_expr,
table_name=table_name,
) )
def delete_item( def delete_item(
@@ -991,11 +988,14 @@ class DynamoDBCollection:
bool bool
True if the item is successfully deleted, False otherwise. True if the item is successfully deleted, False otherwise.
""" """
table_name = getattr(key, 'table_name', None)
return self.persistence_layer.delete_item( return self.persistence_layer.delete_item(
key=key, key=key,
cond_expr=cond_expr, cond_expr=cond_expr,
expr_attr_names=expr_attr_names, expr_attr_names=expr_attr_names,
expr_attr_values=expr_attr_values, expr_attr_values=expr_attr_values,
table_name=table_name,
) )
def get_items( def get_items(
@@ -1059,7 +1059,7 @@ class DynamoDBCollection:
if not key.pairs: if not key.pairs:
return {} return {}
sortkeys = key.pairs[1:] if flatten_top else key.pairs pairs = key.pairs[1:] if flatten_top else key.pairs
client = self.persistence_layer.client client = self.persistence_layer.client
table_name = self.persistence_layer.table_name table_name = self.persistence_layer.table_name
@@ -1074,8 +1074,8 @@ class DynamoDBCollection:
for pair in key.pairs for pair in key.pairs
] ]
response = client.transact_get_items(TransactItems=transact_items) # type: ignore r = client.transact_get_items(TransactItems=transact_items) # type: ignore
items = [deserialize(r.get('Item', {})) for r in response.get('Responses', [])] items = [deserialize(r.get('Item', {})) for r in r.get('Responses', [])]
if flatten_top: if flatten_top:
head, *tail = items head, *tail = items
@@ -1103,16 +1103,14 @@ class DynamoDBCollection:
if getattr(sk, 'rename_key', None): if getattr(sk, 'rename_key', None):
return sk.rename_key return sk.rename_key
if not isinstance(sk, SortKey): if isinstance(sk, SortKey):
return pk return sk.removeprefix(sk.remove_prefix or '')
key = pk if pair.retain_key else sk return pk
return key.removeprefix(sk.remove_prefix or '')
return head | { return head | {
_map_key(pair): _extract_sk_values(pair, obj) _map_key(pair): _extract_sk_values(pair, obj)
for pair, obj in zip(sortkeys, tail) for pair, obj in zip(pairs, tail)
if obj if obj
} }
@@ -1179,7 +1177,7 @@ class DynamoDBCollection:
else '#pk = :pk' else '#pk = :pk'
) )
response = self.persistence_layer.query( r = self.persistence_layer.query(
key_cond_expr=key_cond_expr, key_cond_expr=key_cond_expr,
expr_attr_name=key.expr_attr_name() | expr_attr_name, expr_attr_name=key.expr_attr_name() | expr_attr_name,
expr_attr_values=key.expr_attr_values() | expr_attr_values, expr_attr_values=key.expr_attr_values() | expr_attr_values,
@@ -1189,10 +1187,8 @@ class DynamoDBCollection:
start_key=_startkey_b64decode(start_key) if start_key else {}, start_key=_startkey_b64decode(start_key) if start_key else {},
) )
items = response['items'] items = r['items']
last_key = ( last_key = _startkey_b64encode(r['last_key']) if r['last_key'] else None
_startkey_b64encode(response['last_key']) if response['last_key'] else None
)
def _removeprefix( def _removeprefix(
items: list[dict[str, Any]], /, key: str, prefix: str items: list[dict[str, Any]], /, key: str, prefix: str

View File

@@ -1,6 +1,6 @@
[project] [project]
name = "layercake" name = "layercake"
version = "0.10.1" version = "0.11.0"
description = "Packages shared dependencies to optimize deployment and ensure consistency across functions." description = "Packages shared dependencies to optimize deployment and ensure consistency across functions."
readme = "README.md" readme = "README.md"
authors = [ authors = [

View File

@@ -178,7 +178,7 @@ def test_collection_get_item_path_spec(
KeyPair( KeyPair(
pk='5OxmMjL-ujoR5IMGegQz', pk='5OxmMjL-ujoR5IMGegQz',
sk=SortKey( sk=SortKey(
ComposeKey('sergio@somosbeta.com.br', prefix='emails'), 'emails#sergio@somosbeta.com.br',
path_spec='mx_record_exists', path_spec='mx_record_exists',
), ),
), ),
@@ -193,7 +193,7 @@ def test_collection_put_item(
assert dynamodb_persistence_layer.collection.put_item( assert dynamodb_persistence_layer.collection.put_item(
KeyPair( KeyPair(
'5OxmMjL-ujoR5IMGegQz', '5OxmMjL-ujoR5IMGegQz',
ComposeKey('6d1044d5-18c5-437c-9219-fc2ace7e5ebc', prefix='orgs'), 'orgs#6d1044d5-18c5-437c-9219-fc2ace7e5ebc',
), ),
name='Beta Educação', name='Beta Educação',
ttl=ttl(days=3), ttl=ttl(days=3),
@@ -202,7 +202,7 @@ def test_collection_put_item(
data = dynamodb_persistence_layer.collection.get_item( data = dynamodb_persistence_layer.collection.get_item(
KeyPair( KeyPair(
pk='5OxmMjL-ujoR5IMGegQz', pk='5OxmMjL-ujoR5IMGegQz',
sk=ComposeKey('6d1044d5-18c5-437c-9219-fc2ace7e5ebc', prefix='orgs'), sk='orgs#6d1044d5-18c5-437c-9219-fc2ace7e5ebc',
), ),
) )
@@ -219,7 +219,7 @@ def test_collection_delete_item(
assert dynamodb_persistence_layer.collection.delete_item( assert dynamodb_persistence_layer.collection.delete_item(
KeyPair( KeyPair(
'5OxmMjL-ujoR5IMGegQz', '5OxmMjL-ujoR5IMGegQz',
ComposeKey('sergio@somsbeta.com.br', prefix='emails'), 'emails#sergio@somsbeta.com.br',
) )
) )
@@ -232,6 +232,7 @@ def test_collection_query(
logs = dynamodb_persistence_layer.collection.query( logs = dynamodb_persistence_layer.collection.query(
PartitionKey( PartitionKey(
ComposeKey('5OxmMjL-ujoR5IMGegQz', prefix='logs'), ComposeKey('5OxmMjL-ujoR5IMGegQz', prefix='logs'),
# 'logs#5OxmMjL-ujoR5IMGegQz',
), ),
) )
assert len(logs['items']) == 2 assert len(logs['items']) == 2
@@ -280,7 +281,6 @@ def test_collection_get_items(
'cJtK9SsnJhKPyxESe7g3DG', table_name=dynamodb_persistence_layer.table_name 'cJtK9SsnJhKPyxESe7g3DG', table_name=dynamodb_persistence_layer.table_name
) )
+ SortKey('0') + SortKey('0')
+ SortKey('0')
+ SortKey( + SortKey(
'metadata#billing_policy', 'metadata#billing_policy',
path_spec='payment_method', path_spec='payment_method',
@@ -407,7 +407,7 @@ def test_collection_get_items_pair_path_spec(
+ KeyPair( + KeyPair(
'email', 'email',
SortKey('osergiosiqueira@gmail.com', path_spec='user_id'), SortKey('osergiosiqueira@gmail.com', path_spec='user_id'),
retain_key=True, rename_key='email',
), ),
flatten_top=False, flatten_top=False,
) )

2
layercake/uv.lock generated
View File

@@ -675,7 +675,7 @@ wheels = [
[[package]] [[package]]
name = "layercake" name = "layercake"
version = "0.10.0" version = "0.10.1"
source = { editable = "." } source = { editable = "." }
dependencies = [ dependencies = [
{ name = "arnparse" }, { name = "arnparse" },

View File

@@ -24,7 +24,7 @@ order_layer = DynamoDBPersistenceLayer(ORDER_TABLE, dynamodb_client)
def lambda_handler(event: EventBridgeEvent, context: LambdaContext) -> bool: def lambda_handler(event: EventBridgeEvent, context: LambdaContext) -> bool:
new_image = event.detail['new_image'] new_image = event.detail['new_image']
now_ = now() now_ = now()
data = user_layer.collection.get_items( r = user_layer.collection.get_items(
KeyPair( KeyPair(
pk='cnpj', pk='cnpj',
sk=SortKey(new_image['cnpj'], path_spec='user_id'), sk=SortKey(new_image['cnpj'], path_spec='user_id'),
@@ -40,10 +40,10 @@ def lambda_handler(event: EventBridgeEvent, context: LambdaContext) -> bool:
# Sometimes the function executes before the user insertion completes, # Sometimes the function executes before the user insertion completes,
# so an exception is raised to trigger a retry. # so an exception is raised to trigger a retry.
if len(data) < 2: if len(r) < 2:
raise ValueError('IDs not found') raise ValueError('IDs not found')
logger.info('IDs found', data=data) logger.info('IDs found', result=r)
with order_layer.transact_writer() as transact: with order_layer.transact_writer() as transact:
transact.update( transact.update(
@@ -52,7 +52,7 @@ def lambda_handler(event: EventBridgeEvent, context: LambdaContext) -> bool:
# Post-migration: uncomment the following line # Post-migration: uncomment the following line
# update_expr='SET org_id = :org_id, updated_at = :updated_at', # update_expr='SET org_id = :org_id, updated_at = :updated_at',
expr_attr_values={ expr_attr_values={
':org_id': data['org_id'], ':org_id': r['org_id'],
':updated_at': now_, ':updated_at': now_,
}, },
) )
@@ -61,7 +61,7 @@ def lambda_handler(event: EventBridgeEvent, context: LambdaContext) -> bool:
key=KeyPair(new_image['id'], 'author'), key=KeyPair(new_image['id'], 'author'),
update_expr='SET user_id = :user_id, updated_at = :updated_at', update_expr='SET user_id = :user_id, updated_at = :updated_at',
expr_attr_values={ expr_attr_values={
':user_id': data['user_id'], ':user_id': r['user_id'],
':updated_at': now_, ':updated_at': now_,
}, },
) )

View File

@@ -23,10 +23,10 @@ def lambda_handler(event: EventBridgeEvent, context: LambdaContext) -> bool:
new_image = event.detail['new_image'] new_image = event.detail['new_image']
order_id = new_image['id'] order_id = new_image['id']
org_id = new_image['tenant_id'] org_id = new_image['tenant_id']
# Post-migration: Uncomment the following line # Post-migration (orders): Uncomment the following line
# org_id = new_image['org_id'] # org_id = new_image['org_id']
result = enrollment_layer.collection.query( r = enrollment_layer.collection.query(
KeyPair( KeyPair(
# Post-migration: Uncomment the following line # Post-migration: Uncomment the following line
# f'SLOT#ORG#{org_id}', # f'SLOT#ORG#{org_id}',
@@ -38,12 +38,12 @@ def lambda_handler(event: EventBridgeEvent, context: LambdaContext) -> bool:
logger.info( logger.info(
'Slots found', 'Slots found',
total_items=len(result['items']), total_items=len(r['items']),
slots=result['items'], slots=r['items'],
) )
with enrollment_layer.batch_writer() as batch: with enrollment_layer.batch_writer() as batch:
for pair in result['items']: for pair in r['items']:
batch.delete_item( batch.delete_item(
Key={ Key={
'id': {'S': pair['id']}, 'id': {'S': pair['id']},

View File

@@ -26,7 +26,7 @@ Globals:
Architectures: Architectures:
- x86_64 - x86_64
Layers: Layers:
- !Sub arn:aws:lambda:sa-east-1:336641857101:layer:layercake:98 - !Sub arn:aws:lambda:sa-east-1:336641857101:layer:layercake:99
Environment: Environment:
Variables: Variables:
TZ: America/Sao_Paulo TZ: America/Sao_Paulo

2
orders-events/uv.lock generated
View File

@@ -576,7 +576,7 @@ wheels = [
[[package]] [[package]]
name = "layercake" name = "layercake"
version = "0.10.1" version = "0.11.0"
source = { directory = "../layercake" } source = { directory = "../layercake" }
dependencies = [ dependencies = [
{ name = "arnparse" }, { name = "arnparse" },