diff --git a/http-api/app.py b/http-api/app.py index f16c4d6..726b5ef 100644 --- a/http-api/app.py +++ b/http-api/app.py @@ -33,7 +33,10 @@ app.include_router(courses.router, prefix='/courses') app.include_router(enrollments.router, prefix='/enrollments') app.include_router(orders.router, prefix='/orders') app.include_router(users.router, prefix='/users') -app.include_router(orgs.router, prefix='/orgs') +app.include_router(users.logs, prefix='/users') +app.include_router(users.emails, prefix='/users') +app.include_router(users.orgs, prefix='/users') +app.include_router(orgs.policies, prefix='/orgs') app.include_router(webhooks.router, prefix='/webhooks') app.include_router(settings.router, prefix='/settings') app.include_router(lookup.router, prefix='/lookup') diff --git a/http-api/routes/courses/__init__.py b/http-api/routes/courses/__init__.py index 9303b7a..f0f217c 100644 --- a/http-api/routes/courses/__init__.py +++ b/http-api/routes/courses/__init__.py @@ -1,11 +1,11 @@ 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 NotFoundError from layercake.dynamodb import DynamoDBCollection, DynamoDBPersistenceLayer, KeyPair from meilisearch import Client as Meilisearch +from api_gateway import JSONResponse from boto3clients import dynamodb_client from course import create_course, update_course from middlewares import AuditLogMiddleware, Tenant, TenantMiddleware @@ -61,13 +61,13 @@ def get_courses(): ) def post_course(payload: Course): tenant: Tenant = router.context['tenant'] - org = Org(id=tenant.id, name=tenant.name) - - create_course(payload, org, persistence_layer=course_layer) - - return Response( + create_course( + payload, + Org(id=tenant.id, name=tenant.name), + persistence_layer=course_layer, + ) + return JSONResponse( body=payload, - content_type=content_types.APPLICATION_JSON, status_code=HTTPStatus.CREATED, ) @@ -91,9 +91,7 @@ def get_course(id: str): ) def put_course(id: str, payload: Course): update_course(id, payload, persistence_layer=course_layer) - - return Response( + return JSONResponse( body=payload, - content_type=content_types.APPLICATION_JSON, status_code=HTTPStatus.OK, ) diff --git a/http-api/routes/orgs/__init__.py b/http-api/routes/orgs/__init__.py index 1ae7ce5..33546e4 100644 --- a/http-api/routes/orgs/__init__.py +++ b/http-api/routes/orgs/__init__.py @@ -1,69 +1,3 @@ -from http import HTTPStatus +from .policies import router as policies -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, - ) +__all__ = ['policies'] diff --git a/http-api/routes/orgs/policies.py b/http-api/routes/orgs/policies.py new file mode 100644 index 0000000..1ae7ce5 --- /dev/null +++ b/http-api/routes/orgs/policies.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 f1cb749..86731e4 100644 --- a/http-api/routes/users/__init__.py +++ b/http-api/routes/users/__init__.py @@ -8,15 +8,12 @@ from aws_lambda_powertools.event_handler.exceptions import ( ) from elasticsearch import Elasticsearch from layercake.dynamodb import ( - ComposeKey, DynamoDBCollection, DynamoDBPersistenceLayer, KeyPair, MissingError, - PartitionKey, - PrefixKey, ) -from pydantic import UUID4, BaseModel, EmailStr, StringConstraints +from pydantic import UUID4, BaseModel, StringConstraints import cognito import elastic @@ -25,10 +22,16 @@ 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, set_email_as_primary + +from .emails import router as emails +from .logs import router as logs +from .orgs import router as orgs + +__all__ = ['logs', 'emails', 'orgs'] -class BadRequestError(MissingError, PowertoolsBadRequestError): ... +class BadRequestError(MissingError, PowertoolsBadRequestError): + pass router = Router() @@ -104,108 +107,3 @@ def get_idp(sub: str): user_pool_id=USER_POOOL_ID, idp_client=idp_client, ) - - -@router.get( - '//emails', - compress=True, - tags=['User'], - summary='Get user emails', -) -def get_emails(id: str): - return user_collect.query( - KeyPair(id, PrefixKey('emails')), - start_key=router.current_event.get_query_string_value('start_key', None), - ) - - -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): - add_email(id, payload.email, persistence_layer=user_layer) - return JSONResponse( - body=payload, - status_code=HTTPStatus.CREATED, - ) - - -class EmailAsPrimary(BaseModel): - new_email: EmailStr - old_email: EmailStr - email_verified: bool = False - - -@router.patch( - '//emails', - compress=True, - tags=['User'], - summary='Add user email as primary', - middlewares=[ - AuditLogMiddleware( - 'EMAIL_CHANGE', - user_collect, - ( - 'new_email', - 'old_email', - ), - ) - ], -) -def patch_email(id: str, payload: EmailAsPrimary): - set_email_as_primary( - id, - payload.new_email, - payload.old_email, - email_verified=payload.email_verified, - persistence_layer=user_layer, - ) - return JSONResponse(body=payload, status_code=HTTPStatus.OK) - - -@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, - tags=['User'], - summary='Get user logs', -) -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, 'logs')), - PartitionKey(ComposeKey(id, 'log', delimiter=':')), - start_key=router.current_event.get_query_string_value('start_key', None), - ) - - -@router.get( - '//orgs', - compress=True, - tags=['User'], - summary='Get user orgs', -) -def get_orgs(id: str): - return user_collect.query( - KeyPair(id, PrefixKey('orgs')), - start_key=router.current_event.get_query_string_value('start_key', None), - ) diff --git a/http-api/routes/users/emails.py b/http-api/routes/users/emails.py new file mode 100644 index 0000000..1bb7f5b --- /dev/null +++ b/http-api/routes/users/emails.py @@ -0,0 +1,105 @@ +from http import HTTPStatus + +from aws_lambda_powertools.event_handler.api_gateway import Router +from aws_lambda_powertools.event_handler.exceptions import ( + BadRequestError as PowertoolsBadRequestError, +) +from layercake.dynamodb import ( + DynamoDBCollection, + DynamoDBPersistenceLayer, + KeyPair, + MissingError, + PrefixKey, +) +from pydantic import BaseModel, EmailStr + +from api_gateway import JSONResponse +from boto3clients import dynamodb_client +from middlewares import AuditLogMiddleware +from settings import USER_TABLE +from user import add_email, del_email, set_email_as_primary + + +class BadRequestError(MissingError, PowertoolsBadRequestError): ... + + +router = Router() +user_layer = DynamoDBPersistenceLayer(USER_TABLE, dynamodb_client) +user_collect = DynamoDBCollection(user_layer, exception_cls=BadRequestError) + + +@router.get( + '//emails', + compress=True, + tags=['User'], + summary='Get user emails', +) +def get_emails(id: str): + return user_collect.query( + KeyPair(id, PrefixKey('emails')), + start_key=router.current_event.get_query_string_value('start_key', None), + ) + + +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): + add_email(id, payload.email, persistence_layer=user_layer) + return JSONResponse( + body=payload, + status_code=HTTPStatus.CREATED, + ) + + +class EmailAsPrimary(BaseModel): + new_email: EmailStr + old_email: EmailStr + email_verified: bool = False + + +@router.patch( + '//emails', + compress=True, + tags=['User'], + summary='Add user email as primary', + middlewares=[ + AuditLogMiddleware( + 'EMAIL_CHANGE', + user_collect, + ( + 'new_email', + 'old_email', + ), + ) + ], +) +def patch_email(id: str, payload: EmailAsPrimary): + set_email_as_primary( + id, + payload.new_email, + payload.old_email, + email_verified=payload.email_verified, + persistence_layer=user_layer, + ) + return JSONResponse(body=payload, status_code=HTTPStatus.OK) + + +@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): + del_email(id, payload.email, persistence_layer=user_layer) + return payload diff --git a/http-api/routes/users/logs.py b/http-api/routes/users/logs.py new file mode 100644 index 0000000..7a65e5a --- /dev/null +++ b/http-api/routes/users/logs.py @@ -0,0 +1,41 @@ +from aws_lambda_powertools.event_handler.api_gateway import Router +from aws_lambda_powertools.event_handler.exceptions import ( + BadRequestError as PowertoolsBadRequestError, +) +from layercake.dynamodb import ( + ComposeKey, + DynamoDBCollection, + DynamoDBPersistenceLayer, + MissingError, + PartitionKey, +) + +from boto3clients import dynamodb_client +from settings import USER_TABLE + +from .orgs import router as orgs + +__all__ = ['orgs'] + + +class BadRequestError(MissingError, PowertoolsBadRequestError): ... + + +router = Router() +user_layer = DynamoDBPersistenceLayer(USER_TABLE, dynamodb_client) +user_collect = DynamoDBCollection(user_layer, exception_cls=BadRequestError) + + +@router.get( + '//logs', + compress=True, + tags=['User'], + summary='Get user logs', +) +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, 'logs')), + PartitionKey(ComposeKey(id, 'log', delimiter=':')), + start_key=router.current_event.get_query_string_value('start_key', None), + ) diff --git a/http-api/routes/users/orgs.py b/http-api/routes/users/orgs.py new file mode 100644 index 0000000..dd379af --- /dev/null +++ b/http-api/routes/users/orgs.py @@ -0,0 +1,62 @@ +from http import HTTPStatus + +from aws_lambda_powertools.event_handler.api_gateway import Router +from aws_lambda_powertools.event_handler.exceptions import ( + BadRequestError as PowertoolsBadRequestError, +) +from layercake.dynamodb import ( + DynamoDBCollection, + DynamoDBPersistenceLayer, + KeyPair, + MissingError, + PrefixKey, +) +from layercake.extra_types import CnpjStr +from pydantic import BaseModel + +from api_gateway import JSONResponse +from boto3clients import dynamodb_client +from middlewares.audit_log_middleware import AuditLogMiddleware +from settings import USER_TABLE +from user import del_org_member + + +class BadRequestError(MissingError, PowertoolsBadRequestError): ... + + +router = Router() +user_layer = DynamoDBPersistenceLayer(USER_TABLE, dynamodb_client) +user_collect = DynamoDBCollection(user_layer, exception_cls=BadRequestError) + + +@router.get( + '//orgs', + compress=True, + tags=['User'], + summary='Get user orgs', +) +def get_orgs(id: str): + return user_collect.query( + KeyPair(id, PrefixKey('orgs')), + start_key=router.current_event.get_query_string_value('start_key', None), + ) + + +class Unassign(BaseModel): + id: str + name: str + cnpj: CnpjStr + + +@router.delete( + '//orgs', + compress=True, + tags=['User'], + summary='Delete user org', + middlewares=[ + AuditLogMiddleware('UNASSIGN_ORG', user_collect, ('id', 'name', 'cnpj')) + ], +) +def delete_org(id: str, payload: Unassign): + del_org_member(id, org_id=payload.id, persistence_layer=user_layer) + return JSONResponse(status_code=HTTPStatus.OK, body=payload) diff --git a/http-api/tests/routes/test_users.py b/http-api/tests/routes/test_users.py index c3e5caf..cd6a345 100644 --- a/http-api/tests/routes/test_users.py +++ b/http-api/tests/routes/test_users.py @@ -61,6 +61,7 @@ def test_get_orgs( ) assert r['statusCode'] == HTTPStatus.OK + print(r) def test_get_logs( diff --git a/http-api/user.py b/http-api/user.py index ea1be21..5111f4b 100644 --- a/http-api/user.py +++ b/http-api/user.py @@ -61,7 +61,6 @@ def del_email( persistence_layer: DynamoDBPersistenceLayer, ) -> bool: """Delete any email except the primary email.""" - transact = TransactItems(persistence_layer.table_name) transact.delete( key=KeyPair('email', email), @@ -97,7 +96,7 @@ def set_email_as_primary( now_ = now() expr = 'SET email_primary = :email_primary, update_date = :update_date' transact = TransactItems(persistence_layer.table_name) - + # Set the old email as non-primary transact.update( key=KeyPair(id, ComposeKey(old_email, 'emails')), update_expr=expr, @@ -129,3 +128,28 @@ def set_email_as_primary( ) return persistence_layer.transact_write_items(transact) + + +def del_org_member( + id: str, + *, + org_id: str, + persistence_layer: DynamoDBPersistenceLayer, +) -> bool: + transact = TransactItems(persistence_layer.table_name) + + # Remove the user's relationship with the organization and their privileges + transact.delete(key=KeyPair(id, f'acls#{org_id}')) + transact.delete(key=KeyPair(id, f'orgs#{org_id}')) + transact.update( + key=KeyPair(id, '0'), + update_expr='DELETE #tenant :org_id', + expr_attr_names={'#tenant': 'tenant__org_id'}, + expr_attr_values={':org_id': {org_id}}, + ) + + # Remove the user from the organization's admins and members list + transact.delete(key=KeyPair(org_id, f'admins#{id}')) + transact.delete(key=KeyPair(f'orgmembers#{org_id}', id)) + + return persistence_layer.transact_write_items(transact)