From 273c580139008e0f0548b6232af7f01c04caa418 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?S=C3=A9rgio=20Rafael=20Siqueira?= Date: Sun, 13 Apr 2025 01:11:44 -0300 Subject: [PATCH] add fix --- http-api/Makefile | 1 + http-api/enrollment.py | 102 ++++++++++++++++++++++ http-api/org.py | 40 +++++++++ http-api/routes/enrollments/__init__.py | 26 ++++-- http-api/routes/orgs/__init__.py | 69 +++++++++++++++ http-api/routes/users/__init__.py | 46 ++++++++-- http-api/seeds/test-enrollments.jsonl | 6 +- http-api/seeds/test-orders.jsonl | 2 + http-api/template.yaml | 2 +- http-api/tests/conftest.py | 5 +- http-api/tests/routes/test_enrollments.py | 52 +++++++++++ http-api/tests/routes/test_orgs.py | 55 ++++++++++++ http-api/tests/routes/test_users.py | 60 +++++++++++++ http-api/tests/seeds.jsonl | 11 ++- http-api/user.py | 85 ++++++++++++++++++ http-api/uv.lock | 2 +- layercake/layercake/dynamodb.py | 10 ++- layercake/pyproject.toml | 2 +- layercake/tests/test_dynamodb.py | 3 +- layercake/uv.lock | 2 +- 20 files changed, 552 insertions(+), 29 deletions(-) create mode 100644 http-api/enrollment.py create mode 100644 http-api/org.py create mode 100644 http-api/routes/orgs/__init__.py create mode 100644 http-api/tests/routes/test_enrollments.py create mode 100644 http-api/tests/routes/test_orgs.py create mode 100644 http-api/user.py diff --git a/http-api/Makefile b/http-api/Makefile index 21871ea..b305ad0 100644 --- a/http-api/Makefile +++ b/http-api/Makefile @@ -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 diff --git a/http-api/enrollment.py b/http-api/enrollment.py new file mode 100644 index 0000000..0fa4338 --- /dev/null +++ b/http-api/enrollment.py @@ -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) diff --git a/http-api/org.py b/http-api/org.py new file mode 100644 index 0000000..3415ce0 --- /dev/null +++ b/http-api/org.py @@ -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) diff --git a/http-api/routes/enrollments/__init__.py b/http-api/routes/enrollments/__init__.py index 8e289a1..37c1b21 100644 --- a/http-api/routes/enrollments/__init__.py +++ b/http-api/routes/enrollments/__init__.py @@ -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 diff --git a/http-api/routes/orgs/__init__.py b/http-api/routes/orgs/__init__.py new file mode 100644 index 0000000..1ae7ce5 --- /dev/null +++ b/http-api/routes/orgs/__init__.py @@ -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( + '//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('//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, + ) diff --git a/http-api/routes/users/__init__.py b/http-api/routes/users/__init__.py index 1a26d5b..ff86548 100644 --- a/http-api/routes/users/__init__.py +++ b/http-api/routes/users/__init__.py @@ -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('/', compress=True, tags=['User']) -def patch_newpassword(id: str, payload: NewPassword): +@router.post('//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( + '//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( + '//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( '//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), ) diff --git a/http-api/seeds/test-enrollments.jsonl b/http-api/seeds/test-enrollments.jsonl index d439f1b..83c0a33 100644 --- a/http-api/seeds/test-enrollments.jsonl +++ b/http-api/seeds/test-enrollments.jsonl @@ -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"}} diff --git a/http-api/seeds/test-orders.jsonl b/http-api/seeds/test-orders.jsonl index 6510091..6d2236f 100644 --- a/http-api/seeds/test-orders.jsonl +++ b/http-api/seeds/test-orders.jsonl @@ -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"}} diff --git a/http-api/template.yaml b/http-api/template.yaml index 3fbd3af..582eb84 100644 --- a/http-api/template.yaml +++ b/http-api/template.yaml @@ -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 diff --git a/http-api/tests/conftest.py b/http-api/tests/conftest.py index 2bb9be5..834a221 100644 --- a/http-api/tests/conftest.py +++ b/http-api/tests/conftest.py @@ -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 diff --git a/http-api/tests/routes/test_enrollments.py b/http-api/tests/routes/test_enrollments.py new file mode 100644 index 0000000..9756f04 --- /dev/null +++ b/http-api/tests/routes/test_enrollments.py @@ -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 diff --git a/http-api/tests/routes/test_orgs.py b/http-api/tests/routes/test_orgs.py new file mode 100644 index 0000000..33f4612 --- /dev/null +++ b/http-api/tests/routes/test_orgs.py @@ -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' diff --git a/http-api/tests/routes/test_users.py b/http-api/tests/routes/test_users.py index dfdb4d6..0cbe677 100644 --- a/http-api/tests/routes/test_users.py +++ b/http-api/tests/routes/test_users.py @@ -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', + } diff --git a/http-api/tests/seeds.jsonl b/http-api/tests/seeds.jsonl index 5d364df..067697a 100644 --- a/http-api/tests/seeds.jsonl +++ b/http-api/tests/seeds.jsonl @@ -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"}} diff --git a/http-api/user.py b/http-api/user.py new file mode 100644 index 0000000..89c2982 --- /dev/null +++ b/http-api/user.py @@ -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.') diff --git a/http-api/uv.lock b/http-api/uv.lock index 08dd0d9..372bb44 100644 --- a/http-api/uv.lock +++ b/http-api/uv.lock @@ -521,7 +521,7 @@ wheels = [ [[package]] name = "layercake" -version = "0.2.5" +version = "0.2.9" source = { directory = "../layercake" } dependencies = [ { name = "arnparse" }, diff --git a/layercake/layercake/dynamodb.py b/layercake/layercake/dynamodb.py index 06c7163..5cd4979 100644 --- a/layercake/layercake/dynamodb.py +++ b/layercake/layercake/dynamodb.py @@ -14,7 +14,7 @@ from boto3.dynamodb.types import TypeDeserializer, TypeSerializer from botocore.exceptions import ClientError from .dateutils import now, timestamp -from .funcs import omit +from .funcs import omit, pick TZ = os.getenv('TZ', 'UTC') PK = os.getenv('DYNAMODB_PARTITION_KEY', 'pk') @@ -75,8 +75,9 @@ else: def __new__( cls, keyparts: str | tuple[str, ...], - *, + /, prefix: str | None = None, + *, delimiter: str = '#', ) -> str: if isinstance(keyparts, str): @@ -90,8 +91,9 @@ else: def __init__( self, keyparts: str | tuple[str, ...], - *, + /, prefix: str | None = None, + *, delimiter: str = '#', ) -> None: # __init__ is used to store the parameters for later reference. @@ -258,7 +260,7 @@ class KeyPair(Key): match obj: case dict(): - pair = obj.values() + pair = pick((PK, SK), obj).values() # Gotta keep them in order case _: pair = obj diff --git a/layercake/pyproject.toml b/layercake/pyproject.toml index 64a8284..c48b2a3 100644 --- a/layercake/pyproject.toml +++ b/layercake/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "layercake" -version = "0.2.5" +version = "0.2.9" description = "Packages shared dependencies to optimize deployment and ensure consistency across functions." readme = "README.md" authors = [ diff --git a/layercake/tests/test_dynamodb.py b/layercake/tests/test_dynamodb.py index 843d7d9..33176d4 100644 --- a/layercake/tests/test_dynamodb.py +++ b/layercake/tests/test_dynamodb.py @@ -76,6 +76,7 @@ def test_keypair(): 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({'sk': 'abc', 'id': '123'}) == {'id': '123', 'sk': 'abc'} assert KeyPair.parse_obj(['123', 'abc']) == {'id': '123', 'sk': 'abc'} assert KeyPair.parse_obj([]) is None @@ -103,7 +104,7 @@ def test_transact_write_items( transact.put( item=KeyPair( '5OxmMjL-ujoR5IMGegQz', - ComposeKey('sergio@somosbeta.com.br', prefix='emails'), + ComposeKey('sergio@somosbeta.com.br', 'emails'), ), cond_expr='attribute_not_exists(sk)', ) diff --git a/layercake/uv.lock b/layercake/uv.lock index 26152b6..b1be57e 100644 --- a/layercake/uv.lock +++ b/layercake/uv.lock @@ -600,7 +600,7 @@ wheels = [ [[package]] name = "layercake" -version = "0.2.5" +version = "0.2.8" source = { editable = "." } dependencies = [ { name = "arnparse" },