This commit is contained in:
2025-04-12 21:04:02 -03:00
parent 1b2ebcfb99
commit 86bdb41216
19 changed files with 259 additions and 60 deletions

View File

@@ -13,3 +13,4 @@ if 'PYTEST_VERSION' in os.environ:
DYNAMODB_ENDPOINT_URL = 'http://127.0.0.1:8000' DYNAMODB_ENDPOINT_URL = 'http://127.0.0.1:8000'
dynamodb_client = boto3.client('dynamodb', endpoint_url=DYNAMODB_ENDPOINT_URL) dynamodb_client = boto3.client('dynamodb', endpoint_url=DYNAMODB_ENDPOINT_URL)
idp_client = boto3.client('cognito-idp')

View File

@@ -57,10 +57,11 @@ class Elastic:
index: str, index: str,
doc: dict, doc: dict,
): ):
return self.client.index( return self.client.update(
index=index, index=index,
id=id, id=id,
document=_serialize_to_basic_types(doc), doc=_serialize_to_basic_types(doc),
doc_as_upsert=True,
) )
def delete_index(self, index: str) -> bool: def delete_index(self, index: str) -> bool:
@@ -90,12 +91,12 @@ if __name__ == '__main__':
# Populate DynamoDB tables with data from JSONL files # Populate DynamoDB tables with data from JSONL files
for file in tqdm(jsonl_files, desc='Processing files'): for file in tqdm(jsonl_files, desc='Processing files'):
with jsonlines.open(f'seeds/{file}') as lines: with open(f'seeds/{file}') as fp:
table_name = file.removesuffix('.jsonl') table_name = file.removesuffix('.jsonl')
reader = jsonlines.Reader(fp) reader = jsonlines.Reader(fp).iter(skip_invalid=True)
for line in tqdm(lines, desc=f'Processing lines in {file}'): for line in tqdm(reader, desc=f'Processing lines in {file}'):
put_item(line, table_name, dynamodb_client) put_item(line, table_name, dynamodb_client) # type: ignore
# Scan DynamoDB tables and index the data into Elasticsearch # Scan DynamoDB tables and index the data into Elasticsearch
for file in tqdm(jsonl_files, desc='Scanning tables'): for file in tqdm(jsonl_files, desc='Scanning tables'):

View File

@@ -1,3 +1,8 @@
from aws_lambda_powertools import Logger
logger = Logger(__name__)
class UnauthorizedError(Exception): class UnauthorizedError(Exception):
pass pass
@@ -10,3 +15,24 @@ def get_user(access_token: str, /, idp_client) -> dict[str, str]:
raise UnauthorizedError() raise UnauthorizedError()
else: else:
return {attr['Name']: attr['Value'] for attr in user['UserAttributes']} return {attr['Name']: attr['Value'] for attr in user['UserAttributes']}
def admin_get_user(
sub: str,
user_pool_id: str,
*,
idp_client,
) -> dict[str, str] | None:
"""Gets the specified user by user name in a user pool as an administrator.
Works on any user.
- https://boto3.amazonaws.com/v1/documentation/api/latest/reference/services/cognito-idp/client/admin_get_user.html
- https://docs.aws.amazon.com/cognito-user-identity-pools/latest/APIReference/API_AdminGetUser.html
"""
try:
user = idp_client.admin_get_user(Username=sub, UserPoolId=user_pool_id)
except idp_client.exceptions as err:
logger.exception(err)
return None
else:
return user

View File

@@ -10,13 +10,13 @@ def create_course(
/, /,
persistence_layer: DynamoDBPersistenceLayer, persistence_layer: DynamoDBPersistenceLayer,
): ):
current_time = now() now_ = now()
transact = TransactItems(persistence_layer.table_name) transact = TransactItems(persistence_layer.table_name)
transact.put( transact.put(
item={ item={
'sk': '0', 'sk': '0',
'tenant__org_id': {org.id}, 'tenant__org_id': {org.id},
'create_date': current_time, 'create_date': now_,
**course.model_dump(), **course.model_dump(),
} }
) )
@@ -26,7 +26,7 @@ def create_course(
'sk': 'tenant', 'sk': 'tenant',
'org_id': org.id, 'org_id': org.id,
'name': org.name, 'name': org.name,
'create_date': current_time, 'create_date': now_,
} }
) )
return persistence_layer.transact_write_items(transact) return persistence_layer.transact_write_items(transact)
@@ -38,7 +38,7 @@ def update_course(
/, /,
persistence_layer: DynamoDBPersistenceLayer, persistence_layer: DynamoDBPersistenceLayer,
): ):
current_time = now() now_ = now()
transact = TransactItems(persistence_layer.table_name) transact = TransactItems(persistence_layer.table_name)
transact.update( transact.update(
key=KeyPair(id, '0'), key=KeyPair(id, '0'),
@@ -50,7 +50,7 @@ def update_course(
':name': course.name, ':name': course.name,
':cert': course.cert.model_dump() if course.cert else None, ':cert': course.cert.model_dump() if course.cert else None,
':access_period': course.access_period, ':access_period': course.access_period,
':update_date': current_time, ':update_date': now_,
}, },
cond_expr='attribute_exists(sk)', cond_expr='attribute_exists(sk)',
) )

View File

@@ -1,15 +1,28 @@
import json import json
from typing import Literal from typing import TypedDict
from aws_lambda_powertools.event_handler.api_gateway import Router from aws_lambda_powertools.event_handler.api_gateway import Router
from elasticsearch import Elasticsearch from elasticsearch import Elasticsearch
from pydantic import BaseModel from layercake.dynamodb import (
DynamoDBCollection,
DynamoDBPersistenceLayer,
SortKey,
TransactKey,
)
from pydantic import UUID4, BaseModel
import elastic import elastic
from settings import ELASTIC_CONN, ENROLLMENT_TABLE from boto3clients import dynamodb_client
from middlewares.audit_log_middleware import AuditLogMiddleware
from middlewares.authorizer_middleware import User
from settings import ELASTIC_CONN, ENROLLMENT_TABLE, USER_TABLE
router = Router() router = Router()
elastic_client = Elasticsearch(**ELASTIC_CONN) elastic_client = Elasticsearch(**ELASTIC_CONN)
enrollment_layer = DynamoDBPersistenceLayer(ENROLLMENT_TABLE, dynamodb_client)
user_layer = DynamoDBPersistenceLayer(USER_TABLE, dynamodb_client)
enrollment_collect = DynamoDBCollection(enrollment_layer)
user_collect = DynamoDBCollection(user_layer)
@router.get('/', compress=True, tags=['Enrollment']) @router.get('/', compress=True, tags=['Enrollment'])
@@ -28,16 +41,44 @@ def get_enrollments():
@router.get('/<id>', compress=True, tags=['Enrollment']) @router.get('/<id>', compress=True, tags=['Enrollment'])
def get_enrollment(id: str): def get_enrollment(id: str):
return {} return enrollment_collect.get_items(
TransactKey(id)
+ SortKey('0')
+ SortKey('started_date')
+ SortKey('finished_date')
+ SortKey('failed_date')
+ SortKey('canceled_date')
+ SortKey('archived_date')
+ SortKey('cancel_policy')
+ SortKey('parent_vacancy')
+ SortKey('lock', path_spec='hash')
+ SortKey('author')
+ SortKey('tenant')
+ SortKey('cert')
)
class CancelPayload(BaseModel): class Course(TypedDict):
status: Literal['CANCELED'] = 'CANCELED' id: str
name: str
@router.patch('/<id>', compress=True, tags=['Enrollment']) class Cancel(BaseModel):
def cancel(id: str, payload: CancelPayload): id: UUID4 | str
return {} course: Course
@router.patch(
'/<id>/cancel',
compress=True,
tags=['Enrollment'],
middlewares=[
AuditLogMiddleware('ENROLLMENT_CANCEL', user_collect, ('id', 'course'))
],
)
def cancel(id: str, payload: Cancel):
user: User = router.context['user']
return payload
@router.post('/', compress=True, tags=['Enrollment']) @router.post('/', compress=True, tags=['Enrollment'])

View File

@@ -22,11 +22,11 @@ LIMIT = 25
@router.get('/', include_in_schema=False) @router.get('/', include_in_schema=False)
def settings(): def settings():
user: User = router.context['user'] user: User = router.context['user']
acls = user_collect.get_items( acls = user_collect.query(
KeyPair(user.id, PrefixKey('acls')), KeyPair(user.id, PrefixKey('acls')),
limit=LIMIT, limit=LIMIT,
) )
tenants = user_collect.get_items( tenants = user_collect.query(
KeyPair(user.id, PrefixKey('orgs')), KeyPair(user.id, PrefixKey('orgs')),
limit=LIMIT, limit=LIMIT,
) )

View File

@@ -21,11 +21,12 @@ from layercake.dynamodb import (
) )
from pydantic import UUID4, BaseModel, StringConstraints from pydantic import UUID4, BaseModel, StringConstraints
import cognito
import elastic import elastic
from boto3clients import dynamodb_client from boto3clients import dynamodb_client, idp_client
from middlewares import AuditLogMiddleware from middlewares import AuditLogMiddleware
from models import User from models import User
from settings import ELASTIC_CONN, USER_TABLE from settings import ELASTIC_CONN, USER_POOOL_ID, USER_TABLE
class BadRequestError(MissingError, PowertoolsBadRequestError): ... class BadRequestError(MissingError, PowertoolsBadRequestError): ...
@@ -37,12 +38,7 @@ user_collect = DynamoDBCollection(user_layer, exception_cls=BadRequestError)
elastic_client = Elasticsearch(**ELASTIC_CONN) elastic_client = Elasticsearch(**ELASTIC_CONN)
@router.get( @router.get('/', compress=True, tags=['User'], summary='Get users')
'/',
compress=True,
tags=['User'],
summary='Get users',
)
def get_users(): def get_users():
event = router.current_event event = router.current_event
query = event.get_query_string_value('query', '{}') query = event.get_query_string_value('query', '{}')
@@ -67,29 +63,28 @@ def post_user(payload: User):
return Response(status_code=HTTPStatus.CREATED) return Response(status_code=HTTPStatus.CREATED)
class NewPasswordPayload(BaseModel): class NewPassword(BaseModel):
cognito_sub: UUID4 cognito_sub: UUID4
new_password: Annotated[str, StringConstraints(min_length=6)] new_password: Annotated[str, StringConstraints(min_length=6)]
@router.patch('/<id>', compress=True, tags=['User']) @router.patch('/<id>', compress=True, tags=['User'])
def patch_reset(id: str, payload: NewPasswordPayload): def patch_newpassword(id: str, payload: NewPassword):
return Response(status_code=HTTPStatus.OK) return Response(status_code=HTTPStatus.OK)
@router.get( @router.get('/<id>', compress=True, tags=['User'], summary='Get user')
'/<id>',
compress=True,
tags=['User'],
summary='Get user',
)
def get_user(id: str): def get_user(id: str):
return user_collect.get_item(KeyPair(id, '0')) return user_collect.get_item(KeyPair(id, '0'))
@router.get('/<id>/idp', compress=True, include_in_schema=False) @router.get('/<id>/idp', compress=True, include_in_schema=False)
def get_idp(id: str): def get_idp(id: str):
return [] return cognito.admin_get_user(
sub=id,
user_pool_id=USER_POOOL_ID,
idp_client=idp_client,
)
@router.get( @router.get(
@@ -99,7 +94,7 @@ def get_idp(id: str):
summary='Get user emails', summary='Get user emails',
) )
def get_emails(id: str): def get_emails(id: str):
return user_collect.get_items( return user_collect.query(
KeyPair(id, PrefixKey('emails')), KeyPair(id, PrefixKey('emails')),
start_key=router.current_event.get_query_string_value('start_key', None), start_key=router.current_event.get_query_string_value('start_key', None),
) )
@@ -112,7 +107,7 @@ def get_emails(id: str):
summary='Get user logs', summary='Get user logs',
) )
def get_logs(id: str): def get_logs(id: str):
return user_collect.get_items( return user_collect.query(
# Post-migration: uncomment to enable PartitionKey with a composite key (id with `logs` prefix). # Post-migration: uncomment to enable PartitionKey with a composite key (id with `logs` prefix).
# PartitionKey(ComposeKey(id, prefix='logs')), # PartitionKey(ComposeKey(id, prefix='logs')),
PartitionKey(ComposeKey(id, prefix='log', delimiter=':')), PartitionKey(ComposeKey(id, prefix='log', delimiter=':')),
@@ -127,7 +122,7 @@ def get_logs(id: str):
summary='Get user orgs', summary='Get user orgs',
) )
def get_orgs(id: str): def get_orgs(id: str):
return user_collect.get_items( return user_collect.query(
KeyPair(id, PrefixKey('orgs')), KeyPair(id, PrefixKey('orgs')),
start_key=router.current_event.get_query_string_value('start_key', None), start_key=router.current_event.get_query_string_value('start_key', None),
) )

View File

@@ -13,6 +13,8 @@
{"id": {"S": "cJtK9SsnJhKPyxESe7g3DG"}, "sk": {"S": "tags#MqiRQWy9cSBEci93aKBvTd"}, "tag": {"S": "Test"}, "create_date": {"S": "2023-09-22T18:31:11.475449-03:00"}} {"id": {"S": "cJtK9SsnJhKPyxESe7g3DG"}, "sk": {"S": "tags#MqiRQWy9cSBEci93aKBvTd"}, "tag": {"S": "Test"}, "create_date": {"S": "2023-09-22T18:31:11.475449-03:00"}}
{"id": {"S": "cJtK9SsnJhKPyxESe7g3DG"}, "sk": {"S": "tags#NQ3YmmTesfoSyHCkPKGKEF"}, "tag": {"S": "F\u00e1brica"}, "create_date": {"S": "2023-09-22T18:31:11.475449-03:00"}} {"id": {"S": "cJtK9SsnJhKPyxESe7g3DG"}, "sk": {"S": "tags#NQ3YmmTesfoSyHCkPKGKEF"}, "tag": {"S": "F\u00e1brica"}, "create_date": {"S": "2023-09-22T18:31:11.475449-03:00"}}
{"id": {"S": "cJtK9SsnJhKPyxESe7g3DG"}, "sk": {"S": "tags#bg45io8igarjsA4BzPQyrz"}, "tag": {"S": "Escrit\u00f3rio"}, "create_date": {"S": "2023-09-22T18:31:11.475449-03:00"}} {"id": {"S": "cJtK9SsnJhKPyxESe7g3DG"}, "sk": {"S": "tags#bg45io8igarjsA4BzPQyrz"}, "tag": {"S": "Escrit\u00f3rio"}, "create_date": {"S": "2023-09-22T18:31:11.475449-03:00"}}
{"id": {"S": "cJtK9SsnJhKPyxESe7g3DG"}, "sk": {"S": "payment_policy"}, "due_days": {"N": "90"}}
{"id": {"S": "cJtK9SsnJhKPyxESe7g3DG"}, "sk": {"S": "billing_policy"}, "billing_day": {"N": "2"}, "payment_method": {"S": "PIX"}}
{"sk": {"S": "admin#18606"}, "create_date": {"S": "2023-09-22T18:31:11.475449-03:00"}, "id": {"S": "cJtK9SsnJhKPyxESe7g3DG"}, "email": {"S": "mateushickmann@petrobras.com.br"}, "name": {"S": "MATEUS LATTIK HICKMANN"}} {"sk": {"S": "admin#18606"}, "create_date": {"S": "2023-09-22T18:31:11.475449-03:00"}, "id": {"S": "cJtK9SsnJhKPyxESe7g3DG"}, "email": {"S": "mateushickmann@petrobras.com.br"}, "name": {"S": "MATEUS LATTIK HICKMANN"}}
{"sk": {"S": "admin#21425"}, "create_date": {"S": "2023-09-22T18:34:06.480230-03:00"}, "id": {"S": "cJtK9SsnJhKPyxESe7g3DG"}, "email": {"S": "sergio@inbep.com.br"}, "name": {"S": "S\u00e9rgio Siqueira"}} {"sk": {"S": "admin#21425"}, "create_date": {"S": "2023-09-22T18:34:06.480230-03:00"}, "id": {"S": "cJtK9SsnJhKPyxESe7g3DG"}, "email": {"S": "sergio@inbep.com.br"}, "name": {"S": "S\u00e9rgio Siqueira"}}
{"sk": {"S": "admin#2338"}, "create_date": {"S": "2023-09-22T18:28:10.121068-03:00"}, "id": {"S": "cJtK9SsnJhKPyxESe7g3DG"}, "email": {"S": "empresa@inbep.com.br"}, "name": {"S": "Empresa Teste"}} {"sk": {"S": "admin#2338"}, "create_date": {"S": "2023-09-22T18:28:10.121068-03:00"}, "id": {"S": "cJtK9SsnJhKPyxESe7g3DG"}, "email": {"S": "empresa@inbep.com.br"}, "name": {"S": "Empresa Teste"}}
@@ -68,3 +70,6 @@
{"id": {"S": "webhook_requests#*#0e7f4d2e62ec525fc94465a6dd7299d2"}, "sk": {"S": "2025-03-03T15:47:42.039256-03:00"}, "request": {"M": {"id": {"S": "cJtK9SsnJhKPyxESe7g3DG"}, "headers": {"M": {"Accept": {"S": "*/*"}, "Accept-Encoding": {"S": "gzip, deflate"}, "Connection": {"S": "keep-alive"}, "Content-Length": {"S": "108"}, "Content-Type": {"S": "application/json"}, "User-Agent": {"S": "eduseg/python-requests/2.32.3"}}}, "payload": {"M": {"id": {"S": "5OxmMjL-ujoR5IMGegQz"}, "email": {"S": "sergio@somosbeta.com.br"}, "name": {"S": "S\u00e9rgio Rafael de Siqueira"}}}}}, "response": {"M": {"body": {"S": "{\"message\":\"Workflow was started\"}"}, "elapsed_time": {"S": "0.14"}, "headers": {"M": {"alt-svc": {"S": "h3=\":443\"; ma=86400"}, "cf-cache-status": {"S": "DYNAMIC"}, "CF-RAY": {"S": "8fc528b57b62a3f0-GRU"}, "Connection": {"S": "keep-alive"}, "Content-Length": {"S": "34"}, "Content-Type": {"S": "application/json; charset=utf-8"}, "Date": {"S": "Fri, 03 Jan 2025 18:47:44 GMT"}, "etag": {"S": "W/\"22-6OS7cK0FzqnV2NeDHdOSGS1bVUs\""}, "NEL": {"S": "{\"success_fraction\":0,\"report_to\":\"cf-nel\",\"max_age\":604800}"}, "Report-To": {"S": "{\"endpoints\":[{\"url\":\"https:\\/\\/a.nel.cloudflare.com\\/report\\/v4?s=FMkgKCTOnFvNgtE30yiYnM4XtK8q99O62s7Ep57KDNc3YGiy3W1j%2BzfS0vqJNylSAn5viU3MMGJvTIwPsYQ6jQ298t1p0hYd1KPwURhxchME4hc%2BsO0NNAv%2FFEpXv1ZYWw%3D%3D\"}],\"group\":\"cf-nel\",\"max_age\":604800}"}, "Server": {"S": "cloudflare"}, "server-timing": {"S": "cfL4;desc=\"?proto=TCP&rtt=1016&min_rtt=998&rtt_var=411&sent=4&recv=7&lost=0&retrans=0&sent_bytes=2836&recv_bytes=999&delivery_rate=2522648&cwnd=251&unsent_bytes=0&cid=1b26053952909423&ts=93&x=0\""}, "Strict-Transport-Security": {"S": "max-age=0; includeSubDomains"}, "vary": {"S": "Accept-Encoding"}}}, "status_code": {"S": "200"}}}, "update_date": {"S": "2025-01-03T15:47:44.537597-03:00"}, "url": {"S": "https://n8n.sergio.run/webhook/56bb43b8-533c-4e8b-bdaa-3f7c2b0e548f"}} {"id": {"S": "webhook_requests#*#0e7f4d2e62ec525fc94465a6dd7299d2"}, "sk": {"S": "2025-03-03T15:47:42.039256-03:00"}, "request": {"M": {"id": {"S": "cJtK9SsnJhKPyxESe7g3DG"}, "headers": {"M": {"Accept": {"S": "*/*"}, "Accept-Encoding": {"S": "gzip, deflate"}, "Connection": {"S": "keep-alive"}, "Content-Length": {"S": "108"}, "Content-Type": {"S": "application/json"}, "User-Agent": {"S": "eduseg/python-requests/2.32.3"}}}, "payload": {"M": {"id": {"S": "5OxmMjL-ujoR5IMGegQz"}, "email": {"S": "sergio@somosbeta.com.br"}, "name": {"S": "S\u00e9rgio Rafael de Siqueira"}}}}}, "response": {"M": {"body": {"S": "{\"message\":\"Workflow was started\"}"}, "elapsed_time": {"S": "0.14"}, "headers": {"M": {"alt-svc": {"S": "h3=\":443\"; ma=86400"}, "cf-cache-status": {"S": "DYNAMIC"}, "CF-RAY": {"S": "8fc528b57b62a3f0-GRU"}, "Connection": {"S": "keep-alive"}, "Content-Length": {"S": "34"}, "Content-Type": {"S": "application/json; charset=utf-8"}, "Date": {"S": "Fri, 03 Jan 2025 18:47:44 GMT"}, "etag": {"S": "W/\"22-6OS7cK0FzqnV2NeDHdOSGS1bVUs\""}, "NEL": {"S": "{\"success_fraction\":0,\"report_to\":\"cf-nel\",\"max_age\":604800}"}, "Report-To": {"S": "{\"endpoints\":[{\"url\":\"https:\\/\\/a.nel.cloudflare.com\\/report\\/v4?s=FMkgKCTOnFvNgtE30yiYnM4XtK8q99O62s7Ep57KDNc3YGiy3W1j%2BzfS0vqJNylSAn5viU3MMGJvTIwPsYQ6jQ298t1p0hYd1KPwURhxchME4hc%2BsO0NNAv%2FFEpXv1ZYWw%3D%3D\"}],\"group\":\"cf-nel\",\"max_age\":604800}"}, "Server": {"S": "cloudflare"}, "server-timing": {"S": "cfL4;desc=\"?proto=TCP&rtt=1016&min_rtt=998&rtt_var=411&sent=4&recv=7&lost=0&retrans=0&sent_bytes=2836&recv_bytes=999&delivery_rate=2522648&cwnd=251&unsent_bytes=0&cid=1b26053952909423&ts=93&x=0\""}, "Strict-Transport-Security": {"S": "max-age=0; includeSubDomains"}, "vary": {"S": "Accept-Encoding"}}}, "status_code": {"S": "200"}}}, "update_date": {"S": "2025-01-03T15:47:44.537597-03:00"}, "url": {"S": "https://n8n.sergio.run/webhook/56bb43b8-533c-4e8b-bdaa-3f7c2b0e548f"}}
{"id": {"S": "webhooks#*"}, "sk": {"S": "1e3759c9c4cfa7aaf86ac281bdb8fd6f"}, "author": {"M": {"id": {"S": "5OxmMjL-ujoR5IMGegQz"}, "name": {"S": "S\u00e9rgio Rafael de Siqueira"}}}, "create_date": {"S": "2025-01-06T15:00:39.363207-03:00"}, "event_type": {"S": "insert"}, "resource": {"S": "users"}, "url": {"S": "https://hook.us2.make.com/hgkc5oj5dbpfld5qvu1xmsu22ond93l9"}} {"id": {"S": "webhooks#*"}, "sk": {"S": "1e3759c9c4cfa7aaf86ac281bdb8fd6f"}, "author": {"M": {"id": {"S": "5OxmMjL-ujoR5IMGegQz"}, "name": {"S": "S\u00e9rgio Rafael de Siqueira"}}}, "create_date": {"S": "2025-01-06T15:00:39.363207-03:00"}, "event_type": {"S": "insert"}, "resource": {"S": "users"}, "url": {"S": "https://hook.us2.make.com/hgkc5oj5dbpfld5qvu1xmsu22ond93l9"}}
{"id": {"S": "log:5OxmMjL-ujoR5IMGegQz"},"sk": {"S": "2024-02-26T11:48:17.605911-03:00"},"action": {"S": "OPEN_EMAIL"},"data": {"M": {"ip_address": {"S": "66.249.83.73"},"message_id": {"S": "0103018de5de75cf-670bf01f-7ccf-4ce0-88b4-7c85cd0d06d0-000000"},"recipients": {"L": [{"S": "sergio@somosbeta.com.br"}]},"subject": {"S": "Re: (sem assunto)"},"timestamp": {"S": "2024-02-26T14:48:16.976Z"},"user_agent": {"S": "Mozilla/5.0 (Windows NT 5.1; rv:11.0) Gecko Firefox/11.0 (via ggpht.com GoogleImageProxy)"}}},"ttl": {"N": "1772030897"}} {"id": {"S": "log:5OxmMjL-ujoR5IMGegQz"},"sk": {"S": "2024-02-26T11:48:17.605911-03:00"},"action": {"S": "OPEN_EMAIL"},"data": {"M": {"ip_address": {"S": "66.249.83.73"},"message_id": {"S": "0103018de5de75cf-670bf01f-7ccf-4ce0-88b4-7c85cd0d06d0-000000"},"recipients": {"L": [{"S": "sergio@somosbeta.com.br"}]},"subject": {"S": "Re: (sem assunto)"},"timestamp": {"S": "2024-02-26T14:48:16.976Z"},"user_agent": {"S": "Mozilla/5.0 (Windows NT 5.1; rv:11.0) Gecko Firefox/11.0 (via ggpht.com GoogleImageProxy)"}}},"ttl": {"N": "1772030897"}}
{"id": {"S": "7zf52CWrTS3csRBFWU5rkq"},"sk": {"S": "0"},"cognito:sub": {"S": "58efed8d-d276-41a8-8502-4ab8b5a6415e"},"cpf": {"S": "04330965275"},"createDate": {"S": "2025-04-08T10:24:46.493980-03:00"},"email": {"S": "barbara.gomes@sinobras.com.br"},"email_verified": {"BOOL": true},"konviva:id": {"N": "199205"},"lastLogin": {"S": "2025-04-10T12:01:48.380215-03:00"},"name": {"S": "Barbara Kamyla Vasconcelos Gomes"},"tenant__org_id": {"SS": ["EkvQwpmmL6vzWtJunM5dCJ"]},"update_date": {"S": "2025-04-10T08:50:22.530758-03:00"}}
{"id": {"S": "7zf52CWrTS3csRBFWU5rkq"},"sk": {"S": "acls#EkvQwpmmL6vzWtJunM5dCJ"},"create_date": {"S": "2025-04-10T09:36:41.133157-03:00"},"roles": {"L": [{"S": "ADMIN"}]}}
{"id": {"S": "7zf52CWrTS3csRBFWU5rkq"},"sk": {"S": "orgs#EkvQwpmmL6vzWtJunM5dCJ"},"cnpj": {"S": "07933914000154"},"create_date": {"S": "2025-04-08T10:24:46.493980-03:00"},"name": {"S": "SIDERURGICA NORTE BRASIL S.A"}}

View File

@@ -32,3 +32,5 @@ match os.getenv('AWS_SAM_LOCAL'), os.getenv('PYTEST_VERSION'):
'cloud_id': ELASTIC_CLOUD_ID, 'cloud_id': ELASTIC_CLOUD_ID,
'basic_auth': ('elastic', ELASTIC_AUTH_PASS), 'basic_auth': ('elastic', ELASTIC_AUTH_PASS),
} }
USER_POOOL_ID = 'sa-east-1_s6YmVSfXj'

View File

@@ -23,7 +23,7 @@ Globals:
Architectures: Architectures:
- x86_64 - x86_64
Layers: Layers:
- !Sub arn:aws:lambda:sa-east-1:336641857101:layer:layercake:35 - !Sub arn:aws:lambda:sa-east-1:336641857101:layer:layercake:42
Environment: Environment:
Variables: Variables:
TZ: America/Sao_Paulo TZ: America/Sao_Paulo
@@ -82,6 +82,15 @@ Resources:
TableName: !Ref CourseTable TableName: !Ref CourseTable
- DynamoDBCrudPolicy: - DynamoDBCrudPolicy:
TableName: !Ref OrderTable TableName: !Ref OrderTable
- DynamoDBCrudPolicy:
TableName: !Ref EnrollmentTable
- Version: 2012-10-17
Statement:
- Effect: Allow
Action:
- cognito-idp:AdminGetUser
- cognito-idp:AdminSetUserPassword
Resource: !Sub arn:aws:cognito-idp:${AWS::Region}:${AWS::AccountId}:userpool/*
Events: Events:
Preflight: Preflight:
Type: HttpApi Type: HttpApi

View File

@@ -59,7 +59,7 @@ def test_post_course(
assert r['statusCode'] == HTTPStatus.CREATED assert r['statusCode'] == HTTPStatus.CREATED
collect = DynamoDBCollection(dynamodb_persistence_layer) collect = DynamoDBCollection(dynamodb_persistence_layer)
logs = collect.get_items( logs = collect.query(
PartitionKey( PartitionKey(
ComposeKey('5OxmMjL-ujoR5IMGegQz', prefix='log', delimiter=':'), ComposeKey('5OxmMjL-ujoR5IMGegQz', prefix='log', delimiter=':'),
) )

View File

@@ -8,5 +8,8 @@
{"id": {"S": "log:5OxmMjL-ujoR5IMGegQz"}, "sk": {"S": "2024-02-08T16:42:33.776409-03:00"}, "action": {"S": "OPEN_EMAIL"}} {"id": {"S": "log:5OxmMjL-ujoR5IMGegQz"}, "sk": {"S": "2024-02-08T16:42:33.776409-03:00"}, "action": {"S": "OPEN_EMAIL"}}
{"id": {"S": "log:5OxmMjL-ujoR5IMGegQz"}, "sk": {"S": "2019-03-25T00:00:00-03:00"}, "action": {"S": "CLICK_EMAIL"}} {"id": {"S": "log:5OxmMjL-ujoR5IMGegQz"}, "sk": {"S": "2019-03-25T00:00:00-03:00"}, "action": {"S": "CLICK_EMAIL"}}
{"id": {"S": "cJtK9SsnJhKPyxESe7g3DG"}, "sk": {"S": "0"}, "name": {"S": "EDUSEG"}, "cnpj": {"S": "15608435000190"}, "email": {"S": "org+15608435000190@users.noreply.betaeducacao.com.br"}} {"id": {"S": "cJtK9SsnJhKPyxESe7g3DG"}, "sk": {"S": "0"}, "name": {"S": "EDUSEG"}, "cnpj": {"S": "15608435000190"}, "email": {"S": "org+15608435000190@users.noreply.betaeducacao.com.br"}}
{"id": {"S": "cJtK9SsnJhKPyxESe7g3DG"}, "sk": {"S": "payment_policies"}, "due_days": {"N": "90"}} {"id": {"S": "cJtK9SsnJhKPyxESe7g3DG"}, "sk": {"S": "payment_policy"}, "due_days": {"N": "90"}}
{"id": {"S": "cJtK9SsnJhKPyxESe7g3DG"}, "sk": {"S": "billing_policy"}, "billing_day": {"N": "1"}, "payment_method": {"S": "PIX"}}
{"id": {"S": "90d7f0d2-d9a4-4467-a31c-f9a7955964cf"}, "sk": {"S": "0"}, "access_period": {"N": "720"}, "create_date": {"S": "2024-12-30T00:00:33.088916-03:00"},"konviva__class_id": {"N": "266"},"name": {"S": "Reciclagem em NR-18 Básico"},"tenant__org_id": {"SS": ["cJtK9SsnJhKPyxESe7g3DG"]}} {"id": {"S": "90d7f0d2-d9a4-4467-a31c-f9a7955964cf"}, "sk": {"S": "0"}, "access_period": {"N": "720"}, "create_date": {"S": "2024-12-30T00:00:33.088916-03:00"},"konviva__class_id": {"N": "266"},"name": {"S": "Reciclagem em NR-18 Básico"},"tenant__org_id": {"SS": ["cJtK9SsnJhKPyxESe7g3DG"]}}
{"id": {"S": "43ea4475-c369-4f90-b576-135b7df5106b"}, "sk": {"S": "0"}, "status": {"S": "PENDING"}}
{"id": {"S": "43ea4475-c369-4f90-b576-135b7df5106b"}, "sk": {"S": "cancel_policy"}}

24
http-api/uv.lock generated
View File

@@ -521,7 +521,7 @@ wheels = [
[[package]] [[package]]
name = "layercake" name = "layercake"
version = "0.1.16" version = "0.2.5"
source = { directory = "../layercake" } source = { directory = "../layercake" }
dependencies = [ dependencies = [
{ name = "arnparse" }, { name = "arnparse" },
@@ -538,6 +538,7 @@ dependencies = [
{ name = "pydantic-extra-types" }, { name = "pydantic-extra-types" },
{ name = "pytz" }, { name = "pytz" },
{ name = "requests" }, { name = "requests" },
{ name = "uuid-utils" },
{ name = "weasyprint" }, { name = "weasyprint" },
] ]
@@ -557,6 +558,7 @@ requires-dist = [
{ name = "pydantic-extra-types", specifier = ">=2.10.3" }, { name = "pydantic-extra-types", specifier = ">=2.10.3" },
{ name = "pytz", specifier = ">=2025.1" }, { name = "pytz", specifier = ">=2025.1" },
{ name = "requests", specifier = ">=2.32.3" }, { name = "requests", specifier = ">=2.32.3" },
{ name = "uuid-utils", specifier = ">=0.10.0" },
{ name = "weasyprint", specifier = ">=65.0" }, { name = "weasyprint", specifier = ">=65.0" },
] ]
@@ -938,6 +940,26 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/c8/19/4ec628951a74043532ca2cf5d97b7b14863931476d117c471e8e2b1eb39f/urllib3-2.3.0-py3-none-any.whl", hash = "sha256:1cee9ad369867bfdbbb48b7dd50374c0967a0bb7710050facf0dd6911440e3df", size = 128369 }, { url = "https://files.pythonhosted.org/packages/c8/19/4ec628951a74043532ca2cf5d97b7b14863931476d117c471e8e2b1eb39f/urllib3-2.3.0-py3-none-any.whl", hash = "sha256:1cee9ad369867bfdbbb48b7dd50374c0967a0bb7710050facf0dd6911440e3df", size = 128369 },
] ]
[[package]]
name = "uuid-utils"
version = "0.10.0"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/66/0a/cbdb2eb4845dafeb632d02a18f47b02f87f2ce4f25266f5e3c017976ce89/uuid_utils-0.10.0.tar.gz", hash = "sha256:5db0e1890e8f008657ffe6ded4d9459af724ab114cfe82af1557c87545301539", size = 18828 }
wheels = [
{ url = "https://files.pythonhosted.org/packages/44/54/9d22fa16b19e5d1676eba510f08a9c458d96e2a62ff2c8ebad64251afb18/uuid_utils-0.10.0-cp39-abi3-macosx_10_12_x86_64.macosx_11_0_arm64.macosx_10_12_universal2.whl", hash = "sha256:8d5a4508feefec62456cd6a41bcdde458d56827d908f226803b886d22a3d5e63", size = 573006 },
{ url = "https://files.pythonhosted.org/packages/08/8e/f895c6e52aa603e521fbc13b8626ba5dd99b6e2f5a55aa96ba5b232f4c53/uuid_utils-0.10.0-cp39-abi3-macosx_10_12_x86_64.whl", hash = "sha256:dbefc2b9113f9dfe56bdae58301a2b3c53792221410d422826f3d1e3e6555fe7", size = 292543 },
{ url = "https://files.pythonhosted.org/packages/b6/58/cc4834f377a5e97d6e184408ad96d13042308de56643b6e24afe1f6f34df/uuid_utils-0.10.0-cp39-abi3-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:ffc49c33edf87d1ec8112a9b43e4cf55326877716f929c165a2cc307d31c73d5", size = 323340 },
{ url = "https://files.pythonhosted.org/packages/37/e3/6aeddf148f6a7dd7759621b000e8c85382ec83f52ae79b60842d1dc3ab6b/uuid_utils-0.10.0-cp39-abi3-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:0636b6208f69d5a4e629707ad2a89a04dfa8d1023e1999181f6830646ca048a1", size = 329653 },
{ url = "https://files.pythonhosted.org/packages/0c/00/dd6c2164ace70b7b1671d9129267df331481d7d1e5f9c5e6a564f07953f6/uuid_utils-0.10.0-cp39-abi3-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:7bc06452856b724df9dedfc161c3582199547da54aeb81915ec2ed54f92d19b0", size = 365471 },
{ url = "https://files.pythonhosted.org/packages/b4/e7/0ab8080fcae5462a7b5e555c1cef3d63457baffb97a59b9bc7b005a3ecb1/uuid_utils-0.10.0-cp39-abi3-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:263b2589111c61decdd74a762e8f850c9e4386fb78d2cf7cb4dfc537054cda1b", size = 325844 },
{ url = "https://files.pythonhosted.org/packages/73/39/52d94e9ef75b03f44b39ffc6ac3167e93e74ef4d010a93d25589d9f48540/uuid_utils-0.10.0-cp39-abi3-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:a558db48b7096de6b4d2d2210d82bba8586a6d55f99106b03bb7d01dc5c5bcd6", size = 344389 },
{ url = "https://files.pythonhosted.org/packages/7c/29/4824566f62666238290d99c62a58e4ab2a8b9cf2eccf94cebd9b3359131e/uuid_utils-0.10.0-cp39-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:807465067f3c892514230326ac71a79b28a8dfe2c88ecd2d5675fc844f3c76b5", size = 510078 },
{ url = "https://files.pythonhosted.org/packages/5e/8f/bbcc7130d652462c685f0d3bd26bb214b754215b476340885a4cb50fb89a/uuid_utils-0.10.0-cp39-abi3-musllinux_1_2_i686.whl", hash = "sha256:57423d4a2b9d7b916de6dbd75ba85465a28f9578a89a97f7d3e098d9aa4e5d4a", size = 515937 },
{ url = "https://files.pythonhosted.org/packages/23/f8/34e0c00f5f188604d336713e6a020fcf53b10998e8ab24735a39ab076740/uuid_utils-0.10.0-cp39-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:76d8d660f18ff6b767e319b1b5f927350cd92eafa4831d7ef5b57fdd1d91f974", size = 494111 },
{ url = "https://files.pythonhosted.org/packages/1a/52/b7f0066cc90a7a9c28d54061ed195cd617fde822e5d6ac3ccc88509c3c44/uuid_utils-0.10.0-cp39-abi3-win32.whl", hash = "sha256:6c11a71489338837db0b902b75e1ba7618d5d29f05fde4f68b3f909177dbc226", size = 173520 },
{ url = "https://files.pythonhosted.org/packages/8b/15/f04f58094674d333974243fb45d2c740cf4b79186fb707168e57943c84a3/uuid_utils-0.10.0-cp39-abi3-win_amd64.whl", hash = "sha256:11c55ae64f6c0a7a0c741deae8ca2a4eaa11e9c09dbb7bec2099635696034cf7", size = 182965 },
]
[[package]] [[package]]
name = "wcwidth" name = "wcwidth"
version = "0.2.13" version = "0.2.13"

View File

@@ -1 +0,0 @@
::: layercake.dynamodb

View File

@@ -12,7 +12,6 @@ from uuid import UUID
from aws_lambda_powertools import Logger from aws_lambda_powertools import Logger
from boto3.dynamodb.types import TypeDeserializer, TypeSerializer from boto3.dynamodb.types import TypeDeserializer, TypeSerializer
from botocore.exceptions import ClientError from botocore.exceptions import ClientError
from glom import glom
from .dateutils import now, timestamp from .dateutils import now, timestamp
from .funcs import omit from .funcs import omit
@@ -129,12 +128,40 @@ if TYPE_CHECKING:
@dataclass @dataclass
class SortKey(str): class SortKey(str):
"""
SortKey encapsulates the sort key value and optionally stores additional attributes
for nested data extraction.
Parameters
----------
sk: str
The sort key value.
table_name: str, optional
Optional name of the table associated with the sort key.
path_spec: str, optional
Optional specification for nested data extraction.
"""
sk: str sk: str
table_name: str | None = None table_name: str | None = None
path_spec: str | None = None path_spec: str | None = None
else: else:
class SortKey(str): class SortKey(str):
"""
SortKey encapsulates the sort key value and optionally stores additional attributes
for nested data extraction.
Parameters
----------
sk: str
The sort key value.
table_name: str, optional
Optional name of the table associated with the sort key.
path_spec: str, optional
Optional specification for nested data extraction.
"""
def __new__( def __new__(
cls, cls,
sk: str, sk: str,
@@ -574,6 +601,27 @@ class PaginatedResult(TypedDict):
class DynamoDBCollection: class DynamoDBCollection:
"""
DynamoDBCollection provides a high-level abstraction for performing common CRUD operations
and queries on a DynamoDB table. It leverages an underlying persistence layer to handle
serialization and deserialization of data, key composition, transaction operations, and TTL management.
This collection class simplifies interaction with DynamoDB items, allowing users to:
- Retrieve a single item or multiple items via transactions.
- Insert (put) items with optional TTL (time-to-live) settings.
- Delete items based on keys and conditions.
- Query items using partition keys or composite key pairs with optional filtering and pagination.
Parameters
----------
persistence_layer: DynamoDBPersistenceLayer
The persistence layer instance responsible for direct DynamoDB operations.
exception_cls: Type[Exception], optional
The exception class to be raised when a requested item is not found.
tz: str, optional
The timezone identifier used for date/time operations.
"""
def __init__( def __init__(
self, self,
persistence_layer: DynamoDBPersistenceLayer, persistence_layer: DynamoDBPersistenceLayer,
@@ -781,6 +829,8 @@ class DynamoDBCollection:
head, tail = {}, items head, tail = {}, items
def _getin(sk: SortKey, v: dict) -> dict: def _getin(sk: SortKey, v: dict) -> dict:
from glom import glom
v = omit((PK, SK), v) v = omit((PK, SK), v)
return glom(v, sk.path_spec) if sk.path_spec else v return glom(v, sk.path_spec) if sk.path_spec else v

View File

@@ -165,9 +165,11 @@ if TYPE_CHECKING:
CnpjStr = Annotated[str, ...] CnpjStr = Annotated[str, ...]
else: else:
class CpfStr(CpfCnpj): ... class CpfStr(CpfCnpj):
pass
class CnpjStr(CpfCnpj): ... class CnpjStr(CpfCnpj):
pass
if __name__ == '__main__': if __name__ == '__main__':

View File

@@ -7,14 +7,56 @@ def pick(
exclude_none: bool = True, exclude_none: bool = True,
default: Any = None, default: Any = None,
) -> dict[str, Any]: ) -> dict[str, Any]:
"""Returns a partial copy of an object containing only the keys specified.""" """
return { Return a partial dict with only the specified keys.
k: dct.get(k, default) for k in keys if k in dct or not exclude_none
} Parameters
----------
keys: list[str] or tuple[str, ...]
Keys to select.
dct: dict[str, Any]
Source dict.
exclude_none: bool, optional
If True, omit keys not in dct; if False, include them with `default`.
default: Any, optional
Value for keys not in dct when exclude_none is False.
Returns
-------
dict[str, Any]
A dict with the picked key/value pairs.
Examples
--------
>>> d = {'a': 1, 'b': 2, 'c': 3}
>>> pick(['a', 'c'], d)
{'a': 1, 'c': 3}
>>> pick(['a', 'd'], d, exclude_none=False, default='missing')
{'a': 1, 'd': 'missing'}
"""
return {k: dct.get(k, default) for k in keys if k in dct or not exclude_none}
def omit( def omit(keys: list[str] | tuple[str, ...], dct: dict[str, Any]) -> dict[str, Any]:
keys: list[str] | tuple[str, ...], dct: dict[str, Any] """
) -> dict[str, Any]: Return a partial dict omitting the specified keys.
"""Returns a partial copy of an object omitting the keys specified."""
Parameters
----------
keys: list[str] or tuple[str, ...]
Keys to omit.
dct: dict[str, Any]
Source dict.
Returns
-------
dict[str, Any]
A dict without the omitted key/value pairs.
Examples
--------
>>> d = {'a': 1, 'b': 2, 'c': 3}
>>> omit(['b'], d)
{'a': 1, 'c': 3}
"""
return {k: dct[k] for k in dct.keys() if k not in keys} return {k: dct[k] for k in dct.keys() if k not in keys}

View File

@@ -11,4 +11,5 @@ plugins:
docstring_style: numpy docstring_style: numpy
nav: nav:
- index.md - index.md
- API reference: reference.md - DynamoDB: dynamodb.md
- API: api.md

2
layercake/uv.lock generated
View File

@@ -600,7 +600,7 @@ wheels = [
[[package]] [[package]]
name = "layercake" name = "layercake"
version = "0.2.4" version = "0.2.5"
source = { editable = "." } source = { editable = "." }
dependencies = [ dependencies = [
{ name = "arnparse" }, { name = "arnparse" },