From 648da66c90253e8ebea1a155b3c30d7900978217 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?S=C3=A9rgio=20Rafael=20Siqueira?= Date: Mon, 10 Nov 2025 21:37:12 -0300 Subject: [PATCH] add user to org --- api.saladeaula.digital/app/app.py | 1 + .../app/routes/orgs/__init__.py | 4 +- .../app/routes/orgs/admins.py | 17 ++ .../app/routes/register/__init__.py | 13 ++ .../app/routes/users/__init__.py | 3 +- .../app/routes/users/add.py | 178 ++++++++++++++++++ .../tests/routes/test_users.py | 57 ++++++ api.saladeaula.digital/tests/seeds.jsonl | 20 +- 8 files changed, 286 insertions(+), 7 deletions(-) create mode 100644 api.saladeaula.digital/app/routes/orgs/admins.py create mode 100644 api.saladeaula.digital/app/routes/users/add.py diff --git a/api.saladeaula.digital/app/app.py b/api.saladeaula.digital/app/app.py index 212ae16..9d0ca87 100644 --- a/api.saladeaula.digital/app/app.py +++ b/api.saladeaula.digital/app/app.py @@ -40,6 +40,7 @@ app.include_router(enrollments.download_cert, prefix='/enrollments') app.include_router(enrollments.enroll, prefix='/enrollments') app.include_router(users.router, prefix='/users') app.include_router(users.emails, prefix='/users') +app.include_router(users.add, prefix='/users') app.include_router(users.orgs, prefix='/users') app.include_router(orders.router, prefix='/orders') app.include_router(orgs.custom_pricing, prefix='/orgs') diff --git a/api.saladeaula.digital/app/routes/orgs/__init__.py b/api.saladeaula.digital/app/routes/orgs/__init__.py index 0d0cf91..e751bfe 100644 --- a/api.saladeaula.digital/app/routes/orgs/__init__.py +++ b/api.saladeaula.digital/app/routes/orgs/__init__.py @@ -1,4 +1,4 @@ - +from .admins import router as admins from .custom_pricing import router as custom_pricing -__all__ = ['custom_pricing'] +__all__ = ['admins', 'custom_pricing'] diff --git a/api.saladeaula.digital/app/routes/orgs/admins.py b/api.saladeaula.digital/app/routes/orgs/admins.py new file mode 100644 index 0000000..2637650 --- /dev/null +++ b/api.saladeaula.digital/app/routes/orgs/admins.py @@ -0,0 +1,17 @@ +from aws_lambda_powertools.event_handler.api_gateway import Router +from layercake.dynamodb import DynamoDBPersistenceLayer, KeyPair + +from boto3clients import dynamodb_client +from config import COURSE_TABLE, USER_TABLE + +router = Router() +dyn = DynamoDBPersistenceLayer(USER_TABLE, dynamodb_client) + + +@router.get('//admins') +def get_admins(org_id: str): + return dyn.collection.query( + KeyPair(org_id, 'admins#'), + table_name=COURSE_TABLE, + limit=100, + ) diff --git a/api.saladeaula.digital/app/routes/register/__init__.py b/api.saladeaula.digital/app/routes/register/__init__.py index e69de29..9a565e0 100644 --- a/api.saladeaula.digital/app/routes/register/__init__.py +++ b/api.saladeaula.digital/app/routes/register/__init__.py @@ -0,0 +1,13 @@ +from aws_lambda_powertools.event_handler.api_gateway import Router +from layercake.dynamodb import DynamoDBPersistenceLayer + +from boto3clients import dynamodb_client +from config import USER_TABLE + +router = Router() +dyn = DynamoDBPersistenceLayer(USER_TABLE, dynamodb_client) + + +@router.post('/register') +def register(): + return {} diff --git a/api.saladeaula.digital/app/routes/users/__init__.py b/api.saladeaula.digital/app/routes/users/__init__.py index f7919a5..9e92f40 100644 --- a/api.saladeaula.digital/app/routes/users/__init__.py +++ b/api.saladeaula.digital/app/routes/users/__init__.py @@ -7,10 +7,11 @@ from layercake.dynamodb import DynamoDBPersistenceLayer, KeyPair from boto3clients import dynamodb_client from config import USER_TABLE +from .add import router as add from .emails import router as emails from .orgs import router as orgs -__all__ = ['emails', 'orgs'] +__all__ = ['add', 'emails', 'orgs'] router = Router() dyn = DynamoDBPersistenceLayer(USER_TABLE, dynamodb_client) diff --git a/api.saladeaula.digital/app/routes/users/add.py b/api.saladeaula.digital/app/routes/users/add.py new file mode 100644 index 0000000..20c6cf1 --- /dev/null +++ b/api.saladeaula.digital/app/routes/users/add.py @@ -0,0 +1,178 @@ +from http import HTTPStatus +from typing import Annotated +from uuid import uuid4 + +from aws_lambda_powertools.event_handler.api_gateway import Router +from aws_lambda_powertools.event_handler.exceptions import ( + BadRequestError, + NotFoundError, +) +from aws_lambda_powertools.event_handler.openapi.params import Body +from layercake.dateutils import now +from layercake.dynamodb import DynamoDBPersistenceLayer, KeyPair, SortKey +from layercake.extra_types import CnpjStr, CpfStr, NameStr +from pydantic import UUID4, BaseModel, EmailStr, Field + +from api_gateway import JSONResponse +from boto3clients import dynamodb_client +from config import USER_TABLE + +router = Router() +dyn = DynamoDBPersistenceLayer(USER_TABLE, dynamodb_client) + + +class Org(BaseModel): + id: str + name: str + cnpj: CnpjStr + + +class User(BaseModel): + id: UUID4 = Field(default_factory=uuid4) + name: NameStr + cpf: CpfStr + email: EmailStr + + +class CPFConflictError(Exception): ... + + +class EmailConflictError(Exception): ... + + +class UserConflictError(BadRequestError): ... + + +class UserNotFoundError(NotFoundError): ... + + +class OrgMissingError(NotFoundError): ... + + +@router.post('/') +def add_user( + user: Annotated[User, Body(embed=True)], + org: Annotated[Org, Body(embed=True)], +): + if _create_user(user, org): + return JSONResponse(HTTPStatus.CREATED) + + now_ = now() + user_id = _get_user_id(user) + + with dyn.transact_writer() as transact: + transact.update( + key=KeyPair(user_id, '0'), + update_expr='ADD org_id :org_id', + expr_attr_values={ + ':org_id': {org.id}, + }, + ) + transact.put( + item={ + 'id': user_id, + # Post-migration: rename `orgs` to `ORG` + 'sk': f'orgs#{org.id}', + 'name': org.name, + 'cnpj': org.cnpj, + 'created_at': now_, + } + ) + transact.put( + item={ + # Post-migration: rename `orgmembers` to `ORGMEMBER` + 'id': f'orgmembers#{org.id}', + 'sk': user_id, + 'created_at': now_, + }, + cond_expr='attribute_not_exists(sk)', + exc_cls=UserConflictError, + ) + transact.condition( + key=KeyPair(org.id, '0'), + cond_expr='attribute_exists(sk)', + exc_cls=OrgMissingError, + ) + + return JSONResponse(HTTPStatus.CREATED) + + +def _create_user(user: User, org: Org) -> bool: + now_ = now() + + try: + with dyn.transact_writer() as transact: + transact.put( + item={ + **user.model_dump(), + 'sk': '0', + 'org_id': {org.id}, + 'created_at': now_, + }, + ) + transact.put( + item={ + # Post-migration: rename `cpf` to `CPF` + 'id': 'cpf', + 'sk': user.cpf, + }, + cond_expr='attribute_not_exists(sk)', + exc_cls=CPFConflictError, + ) + transact.put( + item={ + # Post-migration: rename `email` to `EMAIL` + 'id': 'email', + 'sk': user.email, + }, + cond_expr='attribute_not_exists(sk)', + exc_cls=EmailConflictError, + ) + transact.put( + item={ + 'id': user.id, + # Post-migration: rename `orgs` to `ORG` + 'sk': f'orgs#{org.id}', + 'name': org.name, + 'cnpj': org.cnpj, + 'created_at': now_, + } + ) + transact.put( + item={ + # Post-migration: rename `orgmembers` to `ORGMEMBER` + 'id': f'orgmembers#{org.id}', + 'sk': user.id, + 'created_at': now_, + } + ) + transact.condition( + key=KeyPair(org.id, '0'), + cond_expr='attribute_exists(sk)', + exc_cls=OrgMissingError, + ) + except (CPFConflictError, EmailConflictError): + return False + else: + return True + + +def _get_user_id(user: User) -> str: + user_id = dyn.collection.get_items( + KeyPair( + pk='email', + sk=SortKey(user.email, path_spec='user_id'), + rename_key='id', + ) + + KeyPair( + pk='cpf', + sk=SortKey(user.cpf, path_spec='user_id'), + rename_key='id', + ), + flatten_top=False, + ).get('id') + + if not user_id: + raise UserNotFoundError() + + return user_id diff --git a/api.saladeaula.digital/tests/routes/test_users.py b/api.saladeaula.digital/tests/routes/test_users.py index 7c42ab8..473d396 100644 --- a/api.saladeaula.digital/tests/routes/test_users.py +++ b/api.saladeaula.digital/tests/routes/test_users.py @@ -37,3 +37,60 @@ def test_get_orgs( lambda_context, ) assert r['statusCode'] == HTTPStatus.OK + + +def test_add_user( + app, + seeds, + http_api_proxy: HttpApiProxy, + lambda_context: LambdaContext, +): + r = app.lambda_handler( + http_api_proxy( + raw_path='/users', + method=HTTPMethod.POST, + body={ + 'user': { + 'name': 'Sérgio R Siqueira', + 'email': 'sergio@somosbeta.com.br', + 'cpf': '07879819908', + }, + 'org': { + 'id': 'f6000f79-6e5c-49a0-952f-3bda330ef278', + 'name': 'Branco do Brasil', + 'cnpj': '00000000000191', + }, + }, + ), + lambda_context, + ) + assert r['statusCode'] == HTTPStatus.CREATED + + +def test_org_not_found( + app, + seeds, + http_api_proxy: HttpApiProxy, + lambda_context: LambdaContext, +): + r = app.lambda_handler( + http_api_proxy( + raw_path='/users', + method=HTTPMethod.POST, + body={ + 'user': { + 'name': 'Sérgio R Siqueira', + 'email': 'sergio@somosbeta.com.br', + 'cpf': '07879819908', + }, + 'org': { + 'id': '123', + 'name': 'Banco do Brasil', + 'cnpj': '00000000000191', + }, + }, + ), + lambda_context, + ) + body = json.loads(r['body']) + assert body['type'] == 'OrgMissingError' diff --git a/api.saladeaula.digital/tests/seeds.jsonl b/api.saladeaula.digital/tests/seeds.jsonl index d97a802..0e6c431 100644 --- a/api.saladeaula.digital/tests/seeds.jsonl +++ b/api.saladeaula.digital/tests/seeds.jsonl @@ -1,10 +1,22 @@ -{"id": "2a8963fc-4694-4fe2-953a-316d1b10f1f5", "sk": "0", "name": "pytest" } +// Users +{"id": "15bacf02-1535-4bee-9022-19d106fd7518", "sk": "0", "name": "Sérgio R Siqueira", "email": "sergio@somosbeta.com.br", "cpf": "07879819908"} // User emails {"id": "15bacf02-1535-4bee-9022-19d106fd7518", "sk": "emails#sergio@somosbeta.com.br"} // User orgs -{"id": "213a6682-2c59-4404-9189-12eec0a846d4", "sk": "orgs#286f7729-7765-482a-880a-0b153ea799be", "name": "ACME", "cnpj": "00000000000191"} +{"id": "213a6682-2c59-4404-9189-12eec0a846d4", "sk": "orgs#286f7729-7765-482a-880a-0b153ea799be", "name": "Banco do Brasil", "cnpj": "00000000000191"} -// Enrollment -{"id": "578ec87f-94c7-4840-8780-bb4839cc7e64", "sk": "0", "course": {"id": "af3258f0-bccf-4781-aec6-d4c618d234a7", "name": "pytest", "access_period": 180}, "user": {"id": "068b4600-cc36-4b55-b832-bb620021705a", "name": "Benjamin Burnley", "email": "burnley@breakingbenjamin.com"}} \ No newline at end of file +// Enrollments +{"id": "578ec87f-94c7-4840-8780-bb4839cc7e64", "sk": "0", "course": {"id": "af3258f0-bccf-4781-aec6-d4c618d234a7", "name": "pytest", "access_period": 180}, "user": {"id": "068b4600-cc36-4b55-b832-bb620021705a", "name": "Benjamin Burnley", "email": "burnley@breakingbenjamin.com"}} + +// Orgs +{"id": "2a8963fc-4694-4fe2-953a-316d1b10f1f5", "sk": "0", "name": "pytest", "cnpj": "04978826000180"} +{"id": "f6000f79-6e5c-49a0-952f-3bda330ef278", "sk": "0", "name": "Banco do Brasil", "cnpj": "00000000000191"} + +// CNPJs +{"id": "cnpj", "sk": "04978826000180", "org_id": "2a8963fc-4694-4fe2-953a-316d1b10f1f5"} +{"id": "cnpj", "sk": "00000000000191", "org_id": "6000f79-6e5c-49a0-952f-3bda330ef278"} + +// CPFs +{"id": "cpf", "sk": "07879819908", "user_id": "15bacf02-1535-4bee-9022-19d106fd7518""} \ No newline at end of file