This commit is contained in:
2025-04-13 01:11:44 -03:00
parent bef51f492a
commit 273c580139
20 changed files with 552 additions and 29 deletions

View File

@@ -11,6 +11,7 @@ openapi:
uv run -m cli.openapi && \ uv run -m cli.openapi && \
uv run python -m http.server 80 -d swagger uv run python -m http.server 80 -d swagger
.PHONY: seeds
seeds: seeds:
uv run -m cli.seeds uv run -m cli.seeds

102
http-api/enrollment.py Normal file
View File

@@ -0,0 +1,102 @@
from typing import TypedDict
from uuid import uuid4
from layercake.dateutils import now
from layercake.dynamodb import DynamoDBPersistenceLayer, KeyPair, TransactItems
from settings import ORDER_TABLE
class Author(TypedDict):
id: str
name: str
class Course(TypedDict):
id: str
name: str
time_in_days: int
def set_status_as_canceled(
id: str,
*,
lock_hash: str,
author: Author,
course: Course | None = None,
vacancy_key: KeyPair | None = None,
persistence_layer: DynamoDBPersistenceLayer,
):
"""Cancel the enrollment if there's a `cancel_policy`
and put its vacancy back if `vacancy_key` is provided."""
now_ = now()
transact = TransactItems(persistence_layer.table_name)
transact.update(
key=KeyPair(id, '0'),
update_expr='SET #status = :canceled, update_date = :update',
expr_attr_names={
'#status': 'status',
},
expr_attr_values={
':canceled': 'CANCELED',
':update': now_,
},
)
transact.put(
item={
'id': id,
'sk': 'canceled_date',
'author': author,
'create_date': now_,
},
)
transact.delete(
key=KeyPair(id, 'cancel_policy'),
cond_expr='attribute_exists(sk)',
)
# Remove schedules lifecycle events, referencies and locks
transact.delete(key=KeyPair(id, 'schedules#archive_it'))
transact.delete(key=KeyPair(id, 'schedules#no_activity'))
transact.delete(key=KeyPair(id, 'schedules#access_period_ends'))
transact.delete(key=KeyPair(id, 'schedules#does_not_access'))
transact.delete(key=KeyPair(id, 'parent_vacancy'))
transact.delete(key=KeyPair(id, 'lock'))
transact.delete(key=KeyPair('lock', lock_hash))
if vacancy_key and course:
vacancy_pk, vacancy_sk = vacancy_key.values()
org_id = vacancy_pk.removeprefix('vacancies#')
order_id, enrollment_id = vacancy_sk.split('#')
transact.condition(
key=KeyPair(order_id, '0'),
cond_expr='attribute_exists(id)',
table_name=ORDER_TABLE,
)
# Put the vacancy back and assign a new ID
transact.put(
item={
'id': f'vacancies#{org_id}',
'sk': f'{order_id}#{uuid4()}',
'course': course,
'create_date': now_,
},
cond_expr='attribute_not_exists(sk)',
)
# Set the status of `generated_items` to `ROLLBACK` to know
# which vacancy is available for reuse
transact.update(
key=KeyPair(order_id, f'generated_items#{enrollment_id}'),
update_expr='SET #status = :status, update_date = :update',
expr_attr_names={
'#status': 'status',
},
expr_attr_values={
':status': 'ROLLBACK',
':update': now_,
},
cond_expr='attribute_exists(sk)',
table_name=ORDER_TABLE,
)
return persistence_layer.transact_write_items(transact)

40
http-api/org.py Normal file
View File

@@ -0,0 +1,40 @@
from layercake.dateutils import now
from layercake.dynamodb import DynamoDBPersistenceLayer, KeyPair, TransactItems
def update_policies(
id: str,
/,
payment_policy: dict = {},
billing_policy: dict = {},
*,
persistence_layer: DynamoDBPersistenceLayer,
):
now_ = now()
transact = TransactItems(persistence_layer.table_name)
if payment_policy:
transact.put(
item={
'id': id,
'sk': 'payment_policy',
'create_date': now_,
}
| payment_policy
)
else:
transact.delete(key=KeyPair(id, 'payment_policy'))
if billing_policy:
transact.put(
item={
'id': id,
'sk': 'billing_policy',
'create_date': now_,
}
| billing_policy
)
else:
transact.delete(key=KeyPair(id, 'billing_policy'))
return persistence_layer.transact_write_items(transact)

View File

@@ -1,11 +1,11 @@
import json import json
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 layercake.dynamodb import ( from layercake.dynamodb import (
DynamoDBCollection, DynamoDBCollection,
DynamoDBPersistenceLayer, DynamoDBPersistenceLayer,
KeyPair,
SortKey, SortKey,
TransactKey, TransactKey,
) )
@@ -13,6 +13,7 @@ from pydantic import UUID4, BaseModel
import elastic import elastic
from boto3clients import dynamodb_client from boto3clients import dynamodb_client
from enrollment import set_status_as_canceled
from middlewares.audit_log_middleware import AuditLogMiddleware from middlewares.audit_log_middleware import AuditLogMiddleware
from middlewares.authorizer_middleware import User from middlewares.authorizer_middleware import User
from settings import ELASTIC_CONN, ENROLLMENT_TABLE, USER_TABLE from settings import ELASTIC_CONN, ENROLLMENT_TABLE, USER_TABLE
@@ -20,8 +21,8 @@ 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) enrollment_layer = DynamoDBPersistenceLayer(ENROLLMENT_TABLE, dynamodb_client)
user_layer = DynamoDBPersistenceLayer(USER_TABLE, dynamodb_client)
enrollment_collect = DynamoDBCollection(enrollment_layer) enrollment_collect = DynamoDBCollection(enrollment_layer)
user_layer = DynamoDBPersistenceLayer(USER_TABLE, dynamodb_client)
user_collect = DynamoDBCollection(user_layer) user_collect = DynamoDBCollection(user_layer)
@@ -50,7 +51,7 @@ def get_enrollment(id: str):
+ SortKey('canceled_date') + SortKey('canceled_date')
+ SortKey('archived_date') + SortKey('archived_date')
+ SortKey('cancel_policy') + SortKey('cancel_policy')
+ SortKey('parent_vacancy') + SortKey('parent_vacancy', path_spec='vacancy')
+ SortKey('lock', path_spec='hash') + SortKey('lock', path_spec='hash')
+ SortKey('author') + SortKey('author')
+ SortKey('tenant') + SortKey('tenant')
@@ -58,14 +59,11 @@ def get_enrollment(id: str):
) )
class Course(TypedDict):
id: str
name: str
class Cancel(BaseModel): class Cancel(BaseModel):
id: UUID4 | str id: UUID4 | str
course: Course lock_hash: str
course: dict = {}
vacancy: dict = {}
@router.patch( @router.patch(
@@ -78,6 +76,16 @@ class Cancel(BaseModel):
) )
def cancel(id: str, payload: Cancel): def cancel(id: str, payload: Cancel):
user: User = router.context['user'] user: User = router.context['user']
set_status_as_canceled(
id,
lock_hash=payload.lock_hash,
author=user.model_dump(), # type: ignore
course=payload.course, # type: ignore
vacancy_key=KeyPair.parse_obj(payload.vacancy),
persistence_layer=enrollment_layer,
)
return payload return payload

View File

@@ -0,0 +1,69 @@
from http import HTTPStatus
from aws_lambda_powertools.event_handler import Response, content_types
from aws_lambda_powertools.event_handler.api_gateway import Router
from aws_lambda_powertools.event_handler.exceptions import (
BadRequestError,
)
from layercake.dynamodb import (
DynamoDBCollection,
DynamoDBPersistenceLayer,
SortKey,
TransactKey,
)
from pydantic.main import BaseModel
from typing_extensions import Literal
from boto3clients import dynamodb_client
from org import update_policies
from settings import USER_TABLE
router = Router()
org_layer = DynamoDBPersistenceLayer(USER_TABLE, dynamodb_client)
org_collect = DynamoDBCollection(org_layer, exception_cls=BadRequestError)
@router.get(
'/<id>/policies',
compress=True,
tags=['Organization'],
summary='Get organization policies',
)
def get_policies(id: str):
return org_collect.get_items(
TransactKey(id) + SortKey('billing_policy') + SortKey('payment_policy'),
flatten_top=False,
)
class BillingPolicy(BaseModel):
billing_day: int
payment_method: Literal['PIX', 'BANK_SLIP', 'MANUAL']
class PaymentPolicy(BaseModel):
due_days: int
class Policies(BaseModel):
billing_policy: BillingPolicy | None = None
payment_policy: PaymentPolicy | None = None
@router.put('/<id>/policies', compress=True, tags=['Organization'])
def put_policies(id: str, payload: Policies):
payment_policy = payload.payment_policy
billing_policy = payload.billing_policy
update_policies(
id,
payment_policy=payment_policy.model_dump() if payment_policy else {},
billing_policy=billing_policy.model_dump() if billing_policy else {},
persistence_layer=org_layer,
)
return Response(
body=payload,
content_type=content_types.APPLICATION_JSON,
status_code=HTTPStatus.OK,
)

View File

@@ -2,6 +2,7 @@ import json
from http import HTTPStatus from http import HTTPStatus
from typing import Annotated from typing import Annotated
from aws_lambda_powertools.event_handler import content_types
from aws_lambda_powertools.event_handler.api_gateway import ( from aws_lambda_powertools.event_handler.api_gateway import (
Response, Response,
Router, Router,
@@ -19,7 +20,7 @@ from layercake.dynamodb import (
PartitionKey, PartitionKey,
PrefixKey, PrefixKey,
) )
from pydantic import UUID4, BaseModel, StringConstraints from pydantic import UUID4, BaseModel, EmailStr, StringConstraints
import cognito import cognito
import elastic import elastic
@@ -27,6 +28,7 @@ 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_POOOL_ID, USER_TABLE from settings import ELASTIC_CONN, USER_POOOL_ID, USER_TABLE
from user import add_email, del_email
class BadRequestError(MissingError, PowertoolsBadRequestError): ... class BadRequestError(MissingError, PowertoolsBadRequestError): ...
@@ -63,13 +65,13 @@ def post_user(payload: User):
return Response(status_code=HTTPStatus.CREATED) return Response(status_code=HTTPStatus.CREATED)
class NewPassword(BaseModel): class Password(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.post('/<id>/password', compress=True, tags=['User'], include_in_schema=False)
def patch_newpassword(id: str, payload: NewPassword): def new_password(id: str, payload: Password):
return Response(status_code=HTTPStatus.OK) return Response(status_code=HTTPStatus.OK)
@@ -100,6 +102,38 @@ def get_emails(id: str):
) )
class Email(BaseModel):
email: EmailStr
@router.post(
'/<id>/emails',
compress=True,
tags=['User'],
summary='Add user email',
middlewares=[AuditLogMiddleware('EMAIL_ADD', user_collect, ('email',))],
)
def post_email(id: str, payload: Email):
assert add_email(id, payload.email, persistence_layer=user_layer)
return Response(
body=payload,
content_type=content_types.APPLICATION_JSON,
status_code=HTTPStatus.CREATED,
)
@router.delete(
'/<id>/emails',
compress=True,
tags=['User'],
summary='Delete user email',
middlewares=[AuditLogMiddleware('EMAIL_DEL', user_collect, ('email',))],
)
def delete_email(id: str, payload: Email):
assert del_email(id, payload.email, persistence_layer=user_layer)
return payload
@router.get( @router.get(
'/<id>/logs', '/<id>/logs',
compress=True, compress=True,
@@ -109,8 +143,8 @@ def get_emails(id: str):
def get_logs(id: str): def get_logs(id: str):
return user_collect.query( 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, 'logs')),
PartitionKey(ComposeKey(id, prefix='log', delimiter=':')), PartitionKey(ComposeKey(id, 'log', delimiter=':')),
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

@@ -88,7 +88,11 @@
{"id": {"S": "YarwKYcw2sxjYWJTu2wnUy"}, "sk": {"S": "lock"}, "create_date": {"S": "2024-11-04T16:27:37.042051-03:00"}, "hash": {"S": "f8f7996aa99d50eb85266be5a9fca1db"}, "ttl": {"N": "1759692457"}, "ttl_date": {"S": "2025-10-05T16:27:37.042051-03:00"}} {"id": {"S": "YarwKYcw2sxjYWJTu2wnUy"}, "sk": {"S": "lock"}, "create_date": {"S": "2024-11-04T16:27:37.042051-03:00"}, "hash": {"S": "f8f7996aa99d50eb85266be5a9fca1db"}, "ttl": {"N": "1759692457"}, "ttl_date": {"S": "2025-10-05T16:27:37.042051-03:00"}}
{"id": {"S": "YarwKYcw2sxjYWJTu2wnUy"}, "sk": {"S": "assignees#cJtK9SsnJhKPyxESe7g3DG"}, "create_date": {"S": "2024-11-04T16:27:37.042051-03:00"}, "name": {"S": "Beta Educa\u00e7\u00e3o"}, "scope": {"S": "ORG"}} {"id": {"S": "YarwKYcw2sxjYWJTu2wnUy"}, "sk": {"S": "assignees#cJtK9SsnJhKPyxESe7g3DG"}, "create_date": {"S": "2024-11-04T16:27:37.042051-03:00"}, "name": {"S": "Beta Educa\u00e7\u00e3o"}, "scope": {"S": "ORG"}}
{"id": {"S": "YarwKYcw2sxjYWJTu2wnUy"}, "sk": {"S": "mentions#QV4sXY3DvSTUMGJ4QqsrwJ"}, "scope": {"S": "ORDER"}, "create_date": {"S": "2024-11-04T16:27:37.042051-03:00"}} {"id": {"S": "YarwKYcw2sxjYWJTu2wnUy"}, "sk": {"S": "mentions#QV4sXY3DvSTUMGJ4QqsrwJ"}, "scope": {"S": "ORDER"}, "create_date": {"S": "2024-11-04T16:27:37.042051-03:00"}}
{"id": {"S": "YarwKYcw2sxjYWJTu2wnUy"}, "sk": {"S": "parent_draft"}, "draft": {"M": {"id": {"S": "vacancies#cJtK9SsnJhKPyxESe7g3DG"}, "sk": {"S": "QV4sXY3DvSTUMGJ4QqsrwJ#YarwKYcw2sxjYWJTu2wnUy"}}}, "create_date": {"S": "2024-11-04T16:27:37.042051-03:00"}} {"id": {"S": "YarwKYcw2sxjYWJTu2wnUy"}, "sk": {"S": "parent_vacancy"}, "vacancy": {"M": {"id": {"S": "vacancy#cJtK9SsnJhKPyxESe7g3DG"}, "sk": {"S": "QV4sXY3DvSTUMGJ4QqsrwJ#YarwKYcw2sxjYWJTu2wnUy"}}}, "create_date": {"S": "2024-11-04T16:27:37.042051-03:00"}}
{"id": {"S": "766k6jLry3x2vwvUZP24s4"}, "sk": {"S": "0"}, "course": {"M": {"id": {"S": "a6775b71-d68a-4263-8ab4-acb3a4f8a8b9"}, "name": {"S": "NR-18 PEMT PTA"}, "time_in_days": {"N": "365"}}}, "create_date": {"S": "2024-11-13T09:32:37.858091-03:00"}, "progress": {"N": "0"}, "score": {"NULL": true}, "status": {"S": "CANCELED"}, "update_date": {"S": "2024-11-13T09:34:25.057030-03:00"}, "user": {"M": {"id": {"S": "5OxmMjL-ujoR5IMGegQz"}, "cpf": {"S": "07879819908"}, "email": {"S": "sergio@somosbeta.com.br"}, "name": {"S": "S\u00e9rgio Rafael de Siqueira"}}}} {"id": {"S": "766k6jLry3x2vwvUZP24s4"}, "sk": {"S": "0"}, "course": {"M": {"id": {"S": "a6775b71-d68a-4263-8ab4-acb3a4f8a8b9"}, "name": {"S": "NR-18 PEMT PTA"}, "time_in_days": {"N": "365"}}}, "create_date": {"S": "2024-11-13T09:32:37.858091-03:00"}, "progress": {"N": "0"}, "score": {"NULL": true}, "status": {"S": "CANCELED"}, "update_date": {"S": "2024-11-13T09:34:25.057030-03:00"}, "user": {"M": {"id": {"S": "5OxmMjL-ujoR5IMGegQz"}, "cpf": {"S": "07879819908"}, "email": {"S": "sergio@somosbeta.com.br"}, "name": {"S": "S\u00e9rgio Rafael de Siqueira"}}}}
{"id": {"S": "766k6jLry3x2vwvUZP24s4"}, "sk": {"S": "canceled_date"}, "author": {"M": {"id": {"S": "9a41e867-55e1-4573-bd27-7b5d1d1bcfde"}, "name": {"S": "Tiago Maciel"}}}, "create_date": {"S": "2024-11-13T09:34:25.057030-03:00"}} {"id": {"S": "766k6jLry3x2vwvUZP24s4"}, "sk": {"S": "canceled_date"}, "author": {"M": {"id": {"S": "9a41e867-55e1-4573-bd27-7b5d1d1bcfde"}, "name": {"S": "Tiago Maciel"}}}, "create_date": {"S": "2024-11-13T09:34:25.057030-03:00"}}
{"sk": {"S": "3CNrFB9dy2RLit2pdeUWy4#GNo8r7EMr9mF73tPH3Tvnc"}, "course": {"M": {"name": {"S": "NR-10 B\u00e1sico"}, "time_in_days": {"N": "180"}, "id": {"S": "YDkh4BtTSWFCJVTmDkhqb3"}}}, "id": {"S": "vacancies#8TVSi5oACLxTiT8ycKPmaQ"}, "create_date": {"S": "2024-08-28T23:52:59.141966-03:00"}} {"sk": {"S": "3CNrFB9dy2RLit2pdeUWy4#GNo8r7EMr9mF73tPH3Tvnc"}, "course": {"M": {"name": {"S": "NR-10 B\u00e1sico"}, "time_in_days": {"N": "180"}, "id": {"S": "YDkh4BtTSWFCJVTmDkhqb3"}}}, "id": {"S": "vacancies#8TVSi5oACLxTiT8ycKPmaQ"}, "create_date": {"S": "2024-08-28T23:52:59.141966-03:00"}}
{"id": {"S": "7bzQhaPEB9uR8jRkzkFe2h"},"sk": {"S": "0"},"course": {"M": {"id": {"S": "a6775b71-d68a-4263-8ab4-acb3a4f8a8b9"},"name": {"S": "NR-18 PEMT PTA"},"time_in_days": {"N": "365"}}},"create_date": {"S": "2025-04-11T15:07:56.871203-03:00"},"konviva:id": {"N": "238697"},"progress": {"N": "0"},"score": {"NULL": true},"status": {"S": "PENDING"},"update_date": {"S": "2025-04-11T15:08:00.387831-03:00"},"user": {"M": {"id": {"S": "5OxmMjL-ujoR5IMGegQz"},"cpf": {"S": "07879819908"},"email": {"S": "sergio@somosbeta.com.br"},"name": {"S": "Sérgio Rafael Siqueira"}}}}
{"id": {"S": "7bzQhaPEB9uR8jRkzkFe2h"},"sk": {"S": "lock"},"create_date": {"S": "2025-04-11T15:07:56.871203-03:00"},"hash": {"S": "f8f7996aa99d50eb85266be5a9fca1db"},"ttl": {"N": "1773338876"},"ttl_date": {"S": "2026-03-12T15:07:56.871203-03:00"}}
{"id": {"S": "7bzQhaPEB9uR8jRkzkFe2h"},"sk": {"S": "parent_vacancy"},"vacancy": {"M": {"id": {"S": "vacancies#cJtK9SsnJhKPyxESe7g3DG"},"sk": {"S": "QV4sXY3DvSTUMGJ4QqsrwJ#7bzQhaPEB9uR8jRkzkFe2h"}}}}
{"id": {"S": "7bzQhaPEB9uR8jRkzkFe2h"},"sk": {"S": "cancel_policy"},"create_date": {"S": "2025-04-11T15:07:56.871203-03:00"}}

View File

@@ -39,3 +39,5 @@
{"sk": {"S": "lock"}, "id": {"S": "bTRW6w69aYYKjKy3FZeVjt"}, "lock_type": {"S": "CNPJ"}, "create_date": {"S": "2024-02-06T10:28:30.194688-03:00"}} {"sk": {"S": "lock"}, "id": {"S": "bTRW6w69aYYKjKy3FZeVjt"}, "lock_type": {"S": "CNPJ"}, "create_date": {"S": "2024-02-06T10:28:30.194688-03:00"}}
{"sk": {"S": "nfse"}, "nfse": {"S": "10184"}, "id": {"S": "bTRW6w69aYYKjKy3FZeVjt"}, "create_date": {"S": "2024-02-06T10:29:43.839357-03:00"}} {"sk": {"S": "nfse"}, "nfse": {"S": "10184"}, "id": {"S": "bTRW6w69aYYKjKy3FZeVjt"}, "create_date": {"S": "2024-02-06T10:29:43.839357-03:00"}}
{"sk": {"S": "user"}, "user_id": {"S": "mESNbpk4pMTASxtnstd37B"}, "id": {"S": "bTRW6w69aYYKjKy3FZeVjt"}, "create_date": {"S": "2024-02-06T10:28:06.906367-03:00"}} {"sk": {"S": "user"}, "user_id": {"S": "mESNbpk4pMTASxtnstd37B"}, "id": {"S": "bTRW6w69aYYKjKy3FZeVjt"}, "create_date": {"S": "2024-02-06T10:28:06.906367-03:00"}}
{"id": {"S": "QV4sXY3DvSTUMGJ4QqsrwJ"},"sk": {"S": "0"},"assignee": {"M": {"name": {"S": "Sérgio Rafael de Siqueira"}}},"cnpj": {"S": "15608435000190"},"create_date": {"S": "2024-11-02T19:39:57.174669-03:00"},"due_date": {"S": "2024-11-03T19:39:09.897000-03:00"},"email": {"S": "sergio@somosbeta.com.br"},"name": {"S": "Beta Educação"},"payment_date": {"S": "2024-11-02T19:40:12.143000-03:00"},"payment_method": {"S": "MANUAL"},"status": {"S": "PAID"},"total": {"N": "10"},"update_date": {"S": "2024-11-02T19:40:12.831120-03:00"}}
{"id": {"S": "QV4sXY3DvSTUMGJ4QqsrwJ"},"sk": {"S": "generated_items#7bzQhaPEB9uR8jRkzkFe2h"},"create_date": {"S": "2025-04-11T15:08:00.256341-03:00"},"status": {"S": "SUCCESS"}}

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:42 - !Sub arn:aws:lambda:sa-east-1:336641857101:layer:layercake:46
Environment: Environment:
Variables: Variables:
TZ: America/Sao_Paulo TZ: America/Sao_Paulo

View File

@@ -9,13 +9,14 @@ import pytest
from layercake.dynamodb import DynamoDBPersistenceLayer from layercake.dynamodb import DynamoDBPersistenceLayer
PYTEST_TABLE_NAME = 'pytest' PYTEST_TABLE_NAME = 'pytest'
PK = os.getenv('DYNAMODB_PARTITION_KEY', 'pk') PK = os.getenv('DYNAMODB_PARTITION_KEY')
SK = os.getenv('DYNAMODB_SORT_KEY', 'sk') SK = os.getenv('DYNAMODB_SORT_KEY')
patch = pytest.MonkeyPatch() patch = pytest.MonkeyPatch()
patch.setenv('USER_TABLE', PYTEST_TABLE_NAME) patch.setenv('USER_TABLE', PYTEST_TABLE_NAME)
patch.setenv('COURSE_TABLE', PYTEST_TABLE_NAME) patch.setenv('COURSE_TABLE', PYTEST_TABLE_NAME)
patch.setenv('ENROLLMENT_TABLE', PYTEST_TABLE_NAME)
@dataclass @dataclass

View File

@@ -0,0 +1,52 @@
from http import HTTPMethod, HTTPStatus
from layercake.dynamodb import (
ComposeKey,
DynamoDBCollection,
DynamoDBPersistenceLayer,
KeyPair,
PartitionKey,
)
from ..conftest import HttpApiProxy, LambdaContext
def test_cancel_enrollment(
mock_app,
dynamodb_seeds,
dynamodb_persistence_layer: DynamoDBPersistenceLayer,
http_api_proxy: HttpApiProxy,
lambda_context: LambdaContext,
):
r = mock_app.lambda_handler(
http_api_proxy(
raw_path='/enrollments/43ea4475-c369-4f90-b576-135b7df5106b/cancel',
method=HTTPMethod.PATCH,
headers={'X-Tenant': '*'},
body={
'id': '43ea4475-c369-4f90-b576-135b7df5106b',
'lock_hash': 'f8f7996aa99d50eb85266be5a9fca1db',
'course': {
'id': '123',
'name': 'NR-10',
'time_in_days': 720,
},
'vacancy': {
'sk': 'QV4sXY3DvSTUMGJ4QqsrwJ#43ea4475-c369-4f90-b576-135b7df5106b',
'id': 'vacancies#cJtK9SsnJhKPyxESe7g3DG',
},
},
),
lambda_context,
)
assert r['statusCode'] == HTTPStatus.OK
collect = DynamoDBCollection(dynamodb_persistence_layer)
enrollment = collect.get_item(KeyPair('43ea4475-c369-4f90-b576-135b7df5106b', '0'))
assert enrollment['status'] == 'CANCELED'
vacancies = collect.query(
PartitionKey(ComposeKey('cJtK9SsnJhKPyxESe7g3DG', 'vacancies'))
)
assert len(vacancies['items']) == 1

View File

@@ -0,0 +1,55 @@
import json
from http import HTTPMethod, HTTPStatus
from layercake.dynamodb import (
DynamoDBCollection,
DynamoDBPersistenceLayer,
KeyPair,
)
from ..conftest import HttpApiProxy, LambdaContext
def test_get_policies(
mock_app,
dynamodb_seeds,
dynamodb_persistence_layer: DynamoDBPersistenceLayer,
http_api_proxy: HttpApiProxy,
lambda_context: LambdaContext,
):
r = mock_app.lambda_handler(
http_api_proxy(
raw_path='/orgs/cJtK9SsnJhKPyxESe7g3DG/policies',
method=HTTPMethod.GET,
headers={'X-Tenant': '*'},
),
lambda_context,
)
assert r['statusCode'] == HTTPStatus.OK
assert json.loads(r['body']) == {
'billing_policy': {'billing_day': 1, 'payment_method': 'PIX'},
'payment_policy': {'due_days': 90},
}
def test_put_org(
mock_app,
dynamodb_seeds,
dynamodb_persistence_layer: DynamoDBPersistenceLayer,
http_api_proxy: HttpApiProxy,
lambda_context: LambdaContext,
):
r = mock_app.lambda_handler(
http_api_proxy(
raw_path='/orgs/cJtK9SsnJhKPyxESe7g3DG/policies',
method=HTTPMethod.PUT,
headers={'X-Tenant': '*'},
body={},
),
lambda_context,
)
assert r['statusCode'] == HTTPStatus.OK
collect = DynamoDBCollection(dynamodb_persistence_layer)
course = collect.get_item(KeyPair('cJtK9SsnJhKPyxESe7g3DG', '0'))
assert course['name'] == 'EDUSEG'

View File

@@ -1,6 +1,8 @@
import json import json
from http import HTTPMethod, HTTPStatus from http import HTTPMethod, HTTPStatus
from layercake.dynamodb import DynamoDBCollection, DynamoDBPersistenceLayer, KeyPair
from ..conftest import HttpApiProxy, LambdaContext from ..conftest import HttpApiProxy, LambdaContext
@@ -105,3 +107,61 @@ def test_post_user(
) )
assert r['statusCode'] == HTTPStatus.CREATED assert r['statusCode'] == HTTPStatus.CREATED
def test_post_email(
mock_app,
dynamodb_client,
dynamodb_seeds,
dynamodb_persistence_layer: DynamoDBPersistenceLayer,
http_api_proxy: HttpApiProxy,
lambda_context: LambdaContext,
):
r = mock_app.lambda_handler(
http_api_proxy(
raw_path='/users/5OxmMjL-ujoR5IMGegQz/emails',
method=HTTPMethod.POST,
body={
'email': 'sergio+pytest@somosbeta.com.br',
},
),
lambda_context,
)
assert r['statusCode'] == HTTPStatus.CREATED
collect = DynamoDBCollection(dynamodb_persistence_layer)
user = collect.get_item(KeyPair('5OxmMjL-ujoR5IMGegQz', '0'))
assert user['emails'] == {
'sergio@somosbeta.com.br',
'osergiosiqueira@gmail.com',
'sergio+pytest@somosbeta.com.br',
}
def test_delete_email(
mock_app,
dynamodb_client,
dynamodb_seeds,
dynamodb_persistence_layer: DynamoDBPersistenceLayer,
http_api_proxy: HttpApiProxy,
lambda_context: LambdaContext,
):
r = mock_app.lambda_handler(
http_api_proxy(
raw_path='/users/5OxmMjL-ujoR5IMGegQz/emails',
method=HTTPMethod.DELETE,
body={
'email': 'osergiosiqueira@gmail.com',
},
),
lambda_context,
)
assert r['statusCode'] == HTTPStatus.OK
collect = DynamoDBCollection(dynamodb_persistence_layer)
user = collect.get_item(KeyPair('5OxmMjL-ujoR5IMGegQz', '0'))
assert user['emails'] == {
'sergio@somosbeta.com.br',
}

View File

@@ -1,7 +1,8 @@
{"id": {"S": "apikey"}, "sk": {"S": "MzI1MDQ0NTctZjEzMy00YzAwLTkzNmItNmFhNzEyY2E5ZjQw"}, "tenant": {"M": {"id": {"S": "*"}, "name": {"S": "default"}}}, "user": {"M": {"id": {"S": "5OxmMjL-ujoR5IMGegQz"}, "name": {"S": "Sérgio R Siqueira"}, "email": {"S": "sergio@somosbeta.com.br"}}}} {"id": {"S": "apikey"}, "sk": {"S": "MzI1MDQ0NTctZjEzMy00YzAwLTkzNmItNmFhNzEyY2E5ZjQw"}, "tenant": {"M": {"id": {"S": "*"}, "name": {"S": "default"}}}, "user": {"M": {"id": {"S": "5OxmMjL-ujoR5IMGegQz"}, "name": {"S": "Sérgio R Siqueira"}, "email": {"S": "sergio@somosbeta.com.br"}}}}
{"id": {"S": "5OxmMjL-ujoR5IMGegQz"}, "sk": {"S": "0"}, "update_date": {"S": "2024-02-08T16:42:33.776409-03:00"}, "create_date": {"S": "2019-03-25T00:00:00-03:00"}, "email_verified": {"BOOL": true}, "cognito:sub": {"S": "58efed8d-d276-41a8-8502-4ab8b5a6415e"}, "cpf": {"S": "07879819908"}, "email": {"S": "sergio@somosbeta.com.br"}, "name": {"S": "S\u00e9rgio Rafael de Siqueira"}, "last_login": {"S": "2024-02-08T20:53:45.818126-03:00"}, "tenant:org_id": {"L": [{"S": "cJtK9SsnJhKPyxESe7g3DG"}]}} {"id": {"S": "5OxmMjL-ujoR5IMGegQz"}, "sk": {"S": "0"}, "update_date": {"S": "2024-02-08T16:42:33.776409-03:00"}, "create_date": {"S": "2019-03-25T00:00:00-03:00"}, "email_verified": {"BOOL": true}, "cognito:sub": {"S": "58efed8d-d276-41a8-8502-4ab8b5a6415e"}, "cpf": {"S": "07879819908"}, "email": {"S": "sergio@somosbeta.com.br"}, "name": {"S": "S\u00e9rgio Rafael de Siqueira"}, "last_login": {"S": "2024-02-08T20:53:45.818126-03:00"}, "emails": {"SS": ["sergio@somosbeta.com.br","osergiosiqueira@gmail.com"]}, "tenant:org_id": {"L": [{"S": "cJtK9SsnJhKPyxESe7g3DG"}]}}
{"id": {"S": "5OxmMjL-ujoR5IMGegQz"}, "sk": {"S": "cognito"}, "create_date": {"S": "2025-03-03T17:12:26.443507-03:00"}, "sub": {"S": "58efed8d-d276-41a8-8502-4ab8b5a6415e"}} {"id": {"S": "5OxmMjL-ujoR5IMGegQz"}, "sk": {"S": "cognito"}, "create_date": {"S": "2025-03-03T17:12:26.443507-03:00"}, "sub": {"S": "58efed8d-d276-41a8-8502-4ab8b5a6415e"}}
{"id": {"S": "5OxmMjL-ujoR5IMGegQz"}, "sk": {"S": "emails#sergio@somosbeta.com.br"}, "email_verified": {"BOOL": true}, "update_date": {"S": "2024-02-08T16:42:33.776409-03:00"}, "create_date": {"S": "2019-03-25T00:00:00-03:00"}, "email_primary": {"BOOL": true}, "mx_record_exists": {"BOOL": true}, "update_date": {"S": "2023-11-09T12:13:04.308986-03:00"}} {"id": {"S": "5OxmMjL-ujoR5IMGegQz"}, "sk": {"S": "emails#sergio@somosbeta.com.br"}, "email_verified": {"BOOL": true}, "update_date": {"S": "2024-02-08T16:42:33.776409-03:00"}, "create_date": {"S": "2019-03-25T00:00:00-03:00"}, "email_primary": {"BOOL": true}, "mx_record_exists": {"BOOL": true}, "update_date": {"S": "2023-11-09T12:13:04.308986-03:00"}}
{"id": {"S": "5OxmMjL-ujoR5IMGegQz"}, "sk": {"S": "emails#osergiosiqueira@gmail.com"}, "email_verified": {"BOOL": true}, "update_date": {"S": "2024-02-08T16:42:33.776409-03:00"}, "create_date": {"S": "2019-03-25T00:00:00-03:00"}, "email_primary": {"BOOL": false}, "mx_record_exists": {"BOOL": true}, "update_date": {"S": "2023-11-09T12:13:04.308986-03:00"}}
{"id": {"S": "5OxmMjL-ujoR5IMGegQz"}, "sk": {"S": "acls#*"}, "create_date": {"S": "2022-06-13T15:00:24.309410-03:00"}, "roles": {"L": [{"S": "ADMIN"}]}} {"id": {"S": "5OxmMjL-ujoR5IMGegQz"}, "sk": {"S": "acls#*"}, "create_date": {"S": "2022-06-13T15:00:24.309410-03:00"}, "roles": {"L": [{"S": "ADMIN"}]}}
{"id": {"S": "5OxmMjL-ujoR5IMGegQz"}, "sk": {"S": "acls#cJtK9SsnJhKPyxESe7g3DG"}, "create_date": {"S": "2025-03-14T10:06:34.628078-03:00"}, "roles": {"L": [{"S": "ADMIN"}]}} {"id": {"S": "5OxmMjL-ujoR5IMGegQz"}, "sk": {"S": "acls#cJtK9SsnJhKPyxESe7g3DG"}, "create_date": {"S": "2025-03-14T10:06:34.628078-03:00"}, "roles": {"L": [{"S": "ADMIN"}]}}
{"id": {"S": "5OxmMjL-ujoR5IMGegQz"}, "sk": {"S": "orgs#cJtK9SsnJhKPyxESe7g3DG"}, "cnpj": {"S": "15608435000190"}, "create_date": {"S": "2025-03-13T16:36:50.073156-03:00"}, "name": {"S": "Beta Educação"}} {"id": {"S": "5OxmMjL-ujoR5IMGegQz"}, "sk": {"S": "orgs#cJtK9SsnJhKPyxESe7g3DG"}, "cnpj": {"S": "15608435000190"}, "create_date": {"S": "2025-03-13T16:36:50.073156-03:00"}, "name": {"S": "Beta Educação"}}
@@ -11,5 +12,11 @@
{"id": {"S": "cJtK9SsnJhKPyxESe7g3DG"}, "sk": {"S": "payment_policy"}, "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": "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": "0"}, "course": {"M": {"id": {"S": "a6775b71-d68a-4263-8ab4-acb3a4f8a8b9"}, "name": {"S": "NR-18 PEMT PTA"}, "time_in_days": {"N": "365"}}}, "status": {"S": "PENDING"}}
{"id": {"S": "43ea4475-c369-4f90-b576-135b7df5106b"}, "sk": {"S": "cancel_policy"}} {"id": {"S": "43ea4475-c369-4f90-b576-135b7df5106b"}, "sk": {"S": "cancel_policy"}}
{"id": {"S": "43ea4475-c369-4f90-b576-135b7df5106b"}, "sk": {"S": "parent_vacancy"}, "vacancy": {"M": {"id": {"S": "vacancies#cJtK9SsnJhKPyxESe7g3DG"}, "sk": {"S": "QV4sXY3DvSTUMGJ4QqsrwJ#YarwKYcw2sxjYWJTu2wnUy"}}}, "create_date": {"S": "2024-11-04T16:27:37.042051-03:00"}}
{"id": {"S": "43ea4475-c369-4f90-b576-135b7df5106b"}, "sk": {"S": "lock"}, "create_date": {"S": "2024-11-04T16:27:37.042051-03:00"}, "hash": {"S": "f8f7996aa99d50eb85266be5a9fca1db"}, "ttl": {"N": "1759692457"}, "ttl_date": {"S": "2025-10-05T16:27:37.042051-03:00"}}
{"id": {"S": "QV4sXY3DvSTUMGJ4QqsrwJ"}, "sk": {"S": "0"}}
{"id": {"S": "QV4sXY3DvSTUMGJ4QqsrwJ"}, "sk": {"S": "generated_items#43ea4475-c369-4f90-b576-135b7df5106b"}}
{"id": {"S": "email"}, "sk": {"S": "sergio@somosbeta.com.br"}}
{"id": {"S": "cpf"}, "sk": {"S": "07879819908"}}

85
http-api/user.py Normal file
View File

@@ -0,0 +1,85 @@
from aws_lambda_powertools.event_handler.exceptions import (
BadRequestError,
)
from botocore.exceptions import ClientError
from layercake.dateutils import now
from layercake.dynamodb import (
ComposeKey,
DynamoDBPersistenceLayer,
KeyPair,
TransactItems,
)
def add_email(
id: str,
email: str,
/,
*,
persistence_layer: DynamoDBPersistenceLayer,
):
now_ = now()
transact = TransactItems(persistence_layer.table_name)
transact.update(
key=KeyPair(id, '0'),
update_expr='ADD emails :email',
expr_attr_values={
':email': {email},
},
)
transact.put(
item={
'id': id,
'sk': f'emails#{email}',
'email_primary': False,
'email_verified': False,
'create_date': now_,
},
cond_expr='attribute_not_exists(sk)',
)
transact.put(
item={
'id': 'email',
'sk': email,
'user_id': id,
'create_date': now_,
},
cond_expr='attribute_not_exists(sk)',
)
try:
return persistence_layer.transact_write_items(transact)
except ClientError:
raise BadRequestError('Email already exists.')
def del_email(
id: str,
email: str,
/,
*,
persistence_layer: DynamoDBPersistenceLayer,
) -> bool:
"""Delete any email except the primary email."""
transact = TransactItems(persistence_layer.table_name)
transact.delete(
key=KeyPair('email', email),
)
transact.delete(
key=KeyPair(id, ComposeKey(email, 'emails')),
cond_expr='email_primary <> :primary',
expr_attr_values={':primary': True},
)
transact.update(
key=KeyPair(id, '0'),
update_expr='DELETE emails :email',
expr_attr_values={
':email': {email},
},
)
try:
return persistence_layer.transact_write_items(transact)
except ClientError:
raise BadRequestError('Cannot remove the primary email.')

2
http-api/uv.lock generated
View File

@@ -521,7 +521,7 @@ wheels = [
[[package]] [[package]]
name = "layercake" name = "layercake"
version = "0.2.5" version = "0.2.9"
source = { directory = "../layercake" } source = { directory = "../layercake" }
dependencies = [ dependencies = [
{ name = "arnparse" }, { name = "arnparse" },

View File

@@ -14,7 +14,7 @@ from boto3.dynamodb.types import TypeDeserializer, TypeSerializer
from botocore.exceptions import ClientError from botocore.exceptions import ClientError
from .dateutils import now, timestamp from .dateutils import now, timestamp
from .funcs import omit from .funcs import omit, pick
TZ = os.getenv('TZ', 'UTC') TZ = os.getenv('TZ', 'UTC')
PK = os.getenv('DYNAMODB_PARTITION_KEY', 'pk') PK = os.getenv('DYNAMODB_PARTITION_KEY', 'pk')
@@ -75,8 +75,9 @@ else:
def __new__( def __new__(
cls, cls,
keyparts: str | tuple[str, ...], keyparts: str | tuple[str, ...],
*, /,
prefix: str | None = None, prefix: str | None = None,
*,
delimiter: str = '#', delimiter: str = '#',
) -> str: ) -> str:
if isinstance(keyparts, str): if isinstance(keyparts, str):
@@ -90,8 +91,9 @@ else:
def __init__( def __init__(
self, self,
keyparts: str | tuple[str, ...], keyparts: str | tuple[str, ...],
*, /,
prefix: str | None = None, prefix: str | None = None,
*,
delimiter: str = '#', delimiter: str = '#',
) -> None: ) -> None:
# __init__ is used to store the parameters for later reference. # __init__ is used to store the parameters for later reference.
@@ -258,7 +260,7 @@ class KeyPair(Key):
match obj: match obj:
case dict(): case dict():
pair = obj.values() pair = pick((PK, SK), obj).values() # Gotta keep them in order
case _: case _:
pair = obj pair = obj

View File

@@ -1,6 +1,6 @@
[project] [project]
name = "layercake" name = "layercake"
version = "0.2.5" version = "0.2.9"
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

@@ -76,6 +76,7 @@ def test_keypair():
assert KeyPair('123', 'abc').expr_attr_values() == {':pk': '123', ':sk': 'abc'} assert KeyPair('123', 'abc').expr_attr_values() == {':pk': '123', ':sk': 'abc'}
assert KeyPair.parse_obj({'id': '123', 'sk': 'abc'}) == {'id': '123', 'sk': 'abc'} assert KeyPair.parse_obj({'id': '123', 'sk': 'abc'}) == {'id': '123', 'sk': 'abc'}
assert KeyPair.parse_obj({'sk': 'abc', 'id': '123'}) == {'id': '123', 'sk': 'abc'}
assert KeyPair.parse_obj(['123', 'abc']) == {'id': '123', 'sk': 'abc'} assert KeyPair.parse_obj(['123', 'abc']) == {'id': '123', 'sk': 'abc'}
assert KeyPair.parse_obj([]) is None assert KeyPair.parse_obj([]) is None
@@ -103,7 +104,7 @@ def test_transact_write_items(
transact.put( transact.put(
item=KeyPair( item=KeyPair(
'5OxmMjL-ujoR5IMGegQz', '5OxmMjL-ujoR5IMGegQz',
ComposeKey('sergio@somosbeta.com.br', prefix='emails'), ComposeKey('sergio@somosbeta.com.br', 'emails'),
), ),
cond_expr='attribute_not_exists(sk)', cond_expr='attribute_not_exists(sk)',
) )

2
layercake/uv.lock generated
View File

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