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 python -m http.server 80 -d swagger
.PHONY: seeds
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
from typing import TypedDict
from aws_lambda_powertools.event_handler.api_gateway import Router
from elasticsearch import Elasticsearch
from layercake.dynamodb import (
DynamoDBCollection,
DynamoDBPersistenceLayer,
KeyPair,
SortKey,
TransactKey,
)
@@ -13,6 +13,7 @@ from pydantic import UUID4, BaseModel
import elastic
from boto3clients import dynamodb_client
from enrollment import set_status_as_canceled
from middlewares.audit_log_middleware import AuditLogMiddleware
from middlewares.authorizer_middleware import User
from settings import ELASTIC_CONN, ENROLLMENT_TABLE, USER_TABLE
@@ -20,8 +21,8 @@ from settings import ELASTIC_CONN, ENROLLMENT_TABLE, USER_TABLE
router = Router()
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_layer = DynamoDBPersistenceLayer(USER_TABLE, dynamodb_client)
user_collect = DynamoDBCollection(user_layer)
@@ -50,7 +51,7 @@ def get_enrollment(id: str):
+ SortKey('canceled_date')
+ SortKey('archived_date')
+ SortKey('cancel_policy')
+ SortKey('parent_vacancy')
+ SortKey('parent_vacancy', path_spec='vacancy')
+ SortKey('lock', path_spec='hash')
+ SortKey('author')
+ SortKey('tenant')
@@ -58,14 +59,11 @@ def get_enrollment(id: str):
)
class Course(TypedDict):
id: str
name: str
class Cancel(BaseModel):
id: UUID4 | str
course: Course
lock_hash: str
course: dict = {}
vacancy: dict = {}
@router.patch(
@@ -78,6 +76,16 @@ class Cancel(BaseModel):
)
def cancel(id: str, payload: Cancel):
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

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 typing import Annotated
from aws_lambda_powertools.event_handler import content_types
from aws_lambda_powertools.event_handler.api_gateway import (
Response,
Router,
@@ -19,7 +20,7 @@ from layercake.dynamodb import (
PartitionKey,
PrefixKey,
)
from pydantic import UUID4, BaseModel, StringConstraints
from pydantic import UUID4, BaseModel, EmailStr, StringConstraints
import cognito
import elastic
@@ -27,6 +28,7 @@ from boto3clients import dynamodb_client, idp_client
from middlewares import AuditLogMiddleware
from models import User
from settings import ELASTIC_CONN, USER_POOOL_ID, USER_TABLE
from user import add_email, del_email
class BadRequestError(MissingError, PowertoolsBadRequestError): ...
@@ -63,13 +65,13 @@ def post_user(payload: User):
return Response(status_code=HTTPStatus.CREATED)
class NewPassword(BaseModel):
class Password(BaseModel):
cognito_sub: UUID4
new_password: Annotated[str, StringConstraints(min_length=6)]
@router.patch('/<id>', compress=True, tags=['User'])
def patch_newpassword(id: str, payload: NewPassword):
@router.post('/<id>/password', compress=True, tags=['User'], include_in_schema=False)
def new_password(id: str, payload: Password):
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(
'/<id>/logs',
compress=True,
@@ -109,8 +143,8 @@ def get_emails(id: str):
def get_logs(id: str):
return user_collect.query(
# Post-migration: uncomment to enable PartitionKey with a composite key (id with `logs` prefix).
# PartitionKey(ComposeKey(id, prefix='logs')),
PartitionKey(ComposeKey(id, prefix='log', delimiter=':')),
# PartitionKey(ComposeKey(id, 'logs')),
PartitionKey(ComposeKey(id, 'log', delimiter=':')),
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": "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": "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": "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"}}
{"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": "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"}}
{"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:
- x86_64
Layers:
- !Sub arn:aws:lambda:sa-east-1:336641857101:layer:layercake:42
- !Sub arn:aws:lambda:sa-east-1:336641857101:layer:layercake:46
Environment:
Variables:
TZ: America/Sao_Paulo

View File

@@ -9,13 +9,14 @@ import pytest
from layercake.dynamodb import DynamoDBPersistenceLayer
PYTEST_TABLE_NAME = 'pytest'
PK = os.getenv('DYNAMODB_PARTITION_KEY', 'pk')
SK = os.getenv('DYNAMODB_SORT_KEY', 'sk')
PK = os.getenv('DYNAMODB_PARTITION_KEY')
SK = os.getenv('DYNAMODB_SORT_KEY')
patch = pytest.MonkeyPatch()
patch.setenv('USER_TABLE', PYTEST_TABLE_NAME)
patch.setenv('COURSE_TABLE', PYTEST_TABLE_NAME)
patch.setenv('ENROLLMENT_TABLE', PYTEST_TABLE_NAME)
@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
from http import HTTPMethod, HTTPStatus
from layercake.dynamodb import DynamoDBCollection, DynamoDBPersistenceLayer, KeyPair
from ..conftest import HttpApiProxy, LambdaContext
@@ -105,3 +107,61 @@ def test_post_user(
)
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": "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": "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#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"}}
@@ -11,5 +12,11 @@
{"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": "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": "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]]
name = "layercake"
version = "0.2.5"
version = "0.2.9"
source = { directory = "../layercake" }
dependencies = [
{ name = "arnparse" },