diff --git a/README.md b/README.md index d5432d6..272bbcd 100644 --- a/README.md +++ b/README.md @@ -28,6 +28,11 @@ Quando uma matrícula é criada, também é agendados emails/eventos. - `course_archived` após o certificado expirar, a matrícula será marcada como **arquivada (ARCHIVED)**. - `course_expired` se não houver certificado e o período de acesso for atingido, a matrícula será marcada com **expirada (EXPIRED)**. +```json +{"id": "9omWNKymwU5U4aeun6mWzZ", "sk": "schedules#reminder_no_access_3_days", "name": "Sérgio R Siqueira", "email": "osergiosiqueira@gmail.com", "ttl": 1874507093} +{"id": "9omWNKymwU5U4aeun6mWzZ", "sk": "schedules#course_expired", "name": "Sérgio R Siqueira", "email": "osergiosiqueira@gmail.com", "ttl": 1874507093} +``` + ### Proteção contra duplicação ### Política de cancelamento diff --git a/enrollment-management/app/conf.py b/enrollment-management/app/conf.py deleted file mode 100644 index ad958ca..0000000 --- a/enrollment-management/app/conf.py +++ /dev/null @@ -1,5 +0,0 @@ -import os - -USER_TABLE: str = os.getenv('USER_TABLE') # type: ignore -ORDER_TABLE: str = os.getenv('ORDER_TABLE') # type: ignore -ENROLLMENT_TABLE: str = os.getenv('ENROLLMENT_TABLE') # type: ignore diff --git a/enrollment-management/app/events/set_status_as_archived.py b/enrollment-management/app/events/set_status_as_archived.py index 85c9a1c..33dd1ba 100644 --- a/enrollment-management/app/events/set_status_as_archived.py +++ b/enrollment-management/app/events/set_status_as_archived.py @@ -4,10 +4,43 @@ from aws_lambda_powertools.utilities.data_classes import ( event_source, ) from aws_lambda_powertools.utilities.typing import LambdaContext +from layercake.dateutils import now +from layercake.dynamodb import DynamoDBPersistenceLayer, KeyPair, TransactItems + +from boto3clients import dynamodb_client +from config import ENROLLMENT_TABLE logger = Logger(__name__) +enrollment_layer = DynamoDBPersistenceLayer(ENROLLMENT_TABLE, dynamodb_client) @event_source(data_class=EventBridgeEvent) @logger.inject_lambda_context -def lambda_handler(event: EventBridgeEvent, context: LambdaContext) -> None: ... +def lambda_handler(event: EventBridgeEvent, context: LambdaContext) -> bool: + new_image = event.detail['new_image'] + now_ = now() + transact = TransactItems(enrollment_layer.table_name) + transact.update( + key=KeyPair(new_image['id'], '0'), + update_expr='SET #status = :archived, update_date = :update_date', + cond_expr='#status = :completed', + expr_attr_names={ + '#status': 'status', + }, + expr_attr_values={ + ':archived': 'ARCHIVED', + ':completed': 'COMPLETED', + ':update_date': now_, + }, + ) + transact.put( + item={ + 'id': new_image['id'], + 'sk': 'archived_date', + 'create_date': now_, + }, + ) + + enrollment_layer.transact_write_items(transact) + + return True diff --git a/enrollment-management/app/events/stopgap/del_vacancies.py b/enrollment-management/app/events/stopgap/del_vacancies.py index 27a5572..ef41bf2 100644 --- a/enrollment-management/app/events/stopgap/del_vacancies.py +++ b/enrollment-management/app/events/stopgap/del_vacancies.py @@ -13,7 +13,7 @@ from layercake.dynamodb import ( ) from boto3clients import dynamodb_client -from conf import ENROLLMENT_TABLE, ORDER_TABLE, USER_TABLE +from config import ENROLLMENT_TABLE, ORDER_TABLE, USER_TABLE logger = Logger(__name__) user_layer = DynamoDBPersistenceLayer(USER_TABLE, dynamodb_client) diff --git a/enrollment-management/template.yaml b/enrollment-management/template.yaml index 1b98946..e2f8a55 100644 --- a/enrollment-management/template.yaml +++ b/enrollment-management/template.yaml @@ -30,6 +30,7 @@ Globals: POWERTOOLS_LOGGER_LOG_EVENT: true USER_TABLE: !Ref UserTable ENROLLMENT_TABLE: !Ref EnrollmentTable + ORDER_TABLE: !Ref OrderTable Resources: EventLog: @@ -40,7 +41,7 @@ Resources: EventDelVacanciesFunction: Type: AWS::Serverless::Function Properties: - Handler: events.del_vacancies.lambda_handler + Handler: events.stopgap.del_vacancies.lambda_handler LoggingConfig: LogGroup: !Ref EventLog Policies: @@ -60,3 +61,43 @@ Resources: new_image: sk: [generated_items] status: [SUCCESS] + + EventSetStatusAsArchivedFunction: + Type: AWS::Serverless::Function + Properties: + Handler: events.set_status_as_archived.lambda_handler + LoggingConfig: + LogGroup: !Ref EventLog + Policies: + - DynamoDBWritePolicy: + TableName: !Ref EnrollmentTable + Events: + DynamoDBEvent: + Type: EventBridgeRule + Properties: + Pattern: + resources: [!Ref EnrollmentTable] + detail-type: [EXPIRE] + detail: + keys: + sk: [schedules#course_archived] + + EventSetStatusAsExpiredFunction: + Type: AWS::Serverless::Function + Properties: + Handler: events.set_status_as_expired.lambda_handler + LoggingConfig: + LogGroup: !Ref EventLog + Policies: + - DynamoDBWritePolicy: + TableName: !Ref EnrollmentTable + Events: + DynamoDBEvent: + Type: EventBridgeRule + Properties: + Pattern: + resources: [!Ref EnrollmentTable] + detail-type: [EXPIRE] + detail: + keys: + sk: [schedules#course_expired] diff --git a/http-api/app/auth.py b/http-api/app/auth.py index fd0293b..7bb4692 100644 --- a/http-api/app/auth.py +++ b/http-api/app/auth.py @@ -39,7 +39,7 @@ from layercake.funcs import pick from boto3clients import dynamodb_client, idp_client from cognito import get_user -from conf import USER_TABLE +from config import USER_TABLE APIKEY_PREFIX = 'sk-' diff --git a/http-api/app/conf.py b/http-api/app/conf.py deleted file mode 100644 index 282c1b9..0000000 --- a/http-api/app/conf.py +++ /dev/null @@ -1,36 +0,0 @@ -import os - -USER_TABLE: str = os.getenv('USER_TABLE') # type: ignore -ORDER_TABLE: str = os.getenv('ORDER_TABLE') # type: ignore -ENROLLMENT_TABLE: str = os.getenv('ENROLLMENT_TABLE') # type: ignore -COURSE_TABLE: str = os.getenv('COURSE_TABLE') # type: ignore - -KONVIVA_API_URL: str = os.getenv('KONVIVA_API_URL') # type: ignore -KONVIVA_SECRET_KEY: str = os.getenv('KONVIVA_SECRET_KEY') # type: ignore - -MEILISEARCH_HOST: str = os.getenv('MEILISEARCH_HOST') # type: ignore -MEILISEARCH_API_KEY: str = os.getenv('MEILISEARCH_API_KEY') # type: ignore - - -match os.getenv('AWS_SAM_LOCAL'), os.getenv('PYTEST_VERSION'): - case str() as SAM_LOCAL, _ if SAM_LOCAL: # Only when running `sam local start-api` - MEILISEARCH_HOST = 'http://host.docker.internal:7700' - ELASTIC_CONN = { - 'hosts': 'http://host.docker.internal:9200', - } - case _, str() as PYTEST if PYTEST: # Only when running `pytest` - MEILISEARCH_HOST = 'http://127.0.0.1:7700' - ELASTIC_CONN = { - 'hosts': 'http://127.0.0.1:9200', - } - case _: - MEILISEARCH_HOST: str = os.getenv('MEILISEARCH_HOST') # type: ignore - - ELASTIC_CLOUD_ID = os.getenv('ELASTIC_CLOUD_ID') - ELASTIC_AUTH_PASS = os.getenv('ELASTIC_AUTH_PASS') - ELASTIC_CONN = { - 'cloud_id': ELASTIC_CLOUD_ID, - 'basic_auth': ('elastic', ELASTIC_AUTH_PASS), - } - -USER_POOOL_ID = 'sa-east-1_s6YmVSfXj' diff --git a/http-api/app/konviva.py b/http-api/app/konviva.py index bc1d193..d2e2ae3 100644 --- a/http-api/app/konviva.py +++ b/http-api/app/konviva.py @@ -6,7 +6,7 @@ import requests from aws_lambda_powertools.event_handler.exceptions import BadRequestError from glom import glom -from conf import KONVIVA_API_URL, KONVIVA_SECRET_KEY +from config import KONVIVA_API_URL, KONVIVA_SECRET_KEY class KonvivaError(BadRequestError): diff --git a/http-api/app/routes/courses/__init__.py b/http-api/app/routes/courses/__init__.py index a245ff7..2739405 100644 --- a/http-api/app/routes/courses/__init__.py +++ b/http-api/app/routes/courses/__init__.py @@ -7,7 +7,7 @@ from meilisearch import Client as Meilisearch from api_gateway import JSONResponse from boto3clients import dynamodb_client -from conf import ( +from config import ( COURSE_TABLE, MEILISEARCH_API_KEY, MEILISEARCH_HOST, diff --git a/http-api/app/routes/enrollments/__init__.py b/http-api/app/routes/enrollments/__init__.py index cca1851..489c8fb 100644 --- a/http-api/app/routes/enrollments/__init__.py +++ b/http-api/app/routes/enrollments/__init__.py @@ -11,7 +11,7 @@ from layercake.dynamodb import ( import elastic from boto3clients import dynamodb_client -from conf import ELASTIC_CONN, ENROLLMENT_TABLE, USER_TABLE +from config import ELASTIC_CONN, ENROLLMENT_TABLE, USER_TABLE from .cancel import router as cancel from .enroll import router as enroll diff --git a/http-api/app/routes/enrollments/vacancies.py b/http-api/app/routes/enrollments/vacancies.py index 5e0163b..cebd075 100644 --- a/http-api/app/routes/enrollments/vacancies.py +++ b/http-api/app/routes/enrollments/vacancies.py @@ -7,7 +7,7 @@ from layercake.dynamodb import ( ) from boto3clients import dynamodb_client -from conf import ( +from config import ( ENROLLMENT_TABLE, USER_TABLE, ) diff --git a/http-api/app/routes/lookup/__init__.py b/http-api/app/routes/lookup/__init__.py index 7bb0e07..202ca8e 100644 --- a/http-api/app/routes/lookup/__init__.py +++ b/http-api/app/routes/lookup/__init__.py @@ -6,7 +6,7 @@ from elasticsearch import Elasticsearch from elasticsearch_dsl import Search from layercake.funcs import pick -from conf import ELASTIC_CONN, USER_TABLE +from config import ELASTIC_CONN, USER_TABLE router = Router() elastic_client = Elasticsearch(**ELASTIC_CONN) diff --git a/http-api/app/routes/orders/__init__.py b/http-api/app/routes/orders/__init__.py index bbb8593..8104c3e 100644 --- a/http-api/app/routes/orders/__init__.py +++ b/http-api/app/routes/orders/__init__.py @@ -13,7 +13,7 @@ from layercake.dynamodb import ( import elastic from boto3clients import dynamodb_client -from conf import ELASTIC_CONN, ORDER_TABLE +from config import ELASTIC_CONN, ORDER_TABLE router = Router() order_layer = DynamoDBPersistenceLayer(ORDER_TABLE, dynamodb_client) diff --git a/http-api/app/routes/orgs/policies.py b/http-api/app/routes/orgs/policies.py index a7d2f2f..3515e8c 100644 --- a/http-api/app/routes/orgs/policies.py +++ b/http-api/app/routes/orgs/policies.py @@ -15,7 +15,7 @@ from layercake.dynamodb import ( from pydantic.main import BaseModel from boto3clients import dynamodb_client -from conf import USER_TABLE +from config import USER_TABLE from rules.org import update_policies router = Router() diff --git a/http-api/app/routes/settings/__init__.py b/http-api/app/routes/settings/__init__.py index a7e182f..69539bb 100644 --- a/http-api/app/routes/settings/__init__.py +++ b/http-api/app/routes/settings/__init__.py @@ -8,7 +8,7 @@ from layercake.dynamodb import ( import konviva from boto3clients import dynamodb_client -from conf import USER_TABLE +from config import USER_TABLE from middlewares import User router = Router() diff --git a/http-api/app/routes/users/__init__.py b/http-api/app/routes/users/__init__.py index 1090db4..fa927de 100644 --- a/http-api/app/routes/users/__init__.py +++ b/http-api/app/routes/users/__init__.py @@ -21,7 +21,7 @@ import cognito import elastic from api_gateway import JSONResponse from boto3clients import dynamodb_client, idp_client -from conf import ELASTIC_CONN, USER_POOOL_ID, USER_TABLE +from config import ELASTIC_CONN, USER_POOOL_ID, USER_TABLE from middlewares import AuditLogMiddleware from models import User from rules.user import update_user diff --git a/http-api/app/routes/users/emails.py b/http-api/app/routes/users/emails.py index 31ab9b4..c4fc312 100644 --- a/http-api/app/routes/users/emails.py +++ b/http-api/app/routes/users/emails.py @@ -15,7 +15,7 @@ from pydantic import BaseModel, EmailStr from api_gateway import JSONResponse from boto3clients import dynamodb_client -from conf import USER_TABLE +from config import USER_TABLE from middlewares import AuditLogMiddleware from rules.user import add_email, del_email, set_email_as_primary diff --git a/http-api/app/routes/users/logs.py b/http-api/app/routes/users/logs.py index b5ce9f7..3067f6b 100644 --- a/http-api/app/routes/users/logs.py +++ b/http-api/app/routes/users/logs.py @@ -11,7 +11,7 @@ from layercake.dynamodb import ( ) from boto3clients import dynamodb_client -from conf import USER_TABLE +from config import USER_TABLE from .orgs import router as orgs diff --git a/http-api/app/routes/users/orgs.py b/http-api/app/routes/users/orgs.py index c02e9f2..7f072a4 100644 --- a/http-api/app/routes/users/orgs.py +++ b/http-api/app/routes/users/orgs.py @@ -16,7 +16,7 @@ from pydantic import BaseModel from api_gateway import JSONResponse from boto3clients import dynamodb_client -from conf import USER_TABLE +from config import USER_TABLE from middlewares.audit_log_middleware import AuditLogMiddleware from rules.user import del_org_member diff --git a/http-api/app/rules/enrollment.py b/http-api/app/rules/enrollment.py index a0b51a3..8cecec3 100644 --- a/http-api/app/rules/enrollment.py +++ b/http-api/app/rules/enrollment.py @@ -5,8 +5,9 @@ from uuid import uuid4 from layercake.dateutils import now, ttl from layercake.dynamodb import DynamoDBPersistenceLayer, KeyPair, TransactItems +from layercake.strutils import md5_hash -from conf import ORDER_TABLE +from config import ORDER_TABLE from models import Course, Enrollment @@ -23,6 +24,10 @@ class Author(TypedDict): class Vacancy(TypedDict): ... +class DeduplicationWindow(TypedDict): + offset_days: int + + class LifecycleEvents(str, Enum): """Lifecycle events related to scheduling actions.""" @@ -49,20 +54,23 @@ def enroll( enrollment: Enrollment, *, tenant: Tenant, + vacancy: Vacancy | None = None, + deduplication_window: DeduplicationWindow | None = None, persistence_layer: DynamoDBPersistenceLayer, ) -> bool: """Enrolls a user into a course and schedules lifecycle events.""" now_ = now() user = enrollment.user course = enrollment.course + tenant_id = tenant['id'] transact = TransactItems(persistence_layer.table_name) transact.put( item={ 'sk': '0', 'create_date': now_, - 'metadata__tenant_id': tenant['id'], - 'metadata__related_ids': {tenant['id'], user.id}, + 'metadata__tenant_id': tenant_id, + 'metadata__related_ids': {tenant_id, user.id}, **enrollment.model_dump(), }, ) @@ -70,7 +78,7 @@ def enroll( item={ 'id': enrollment.id, 'sk': 'metadata#tenant', - 'tenant_id': f'ORG#{tenant["id"]}', + 'tenant_id': f'ORG#{tenant_id}', 'name': tenant['name'], 'create_date': now_, }, @@ -97,7 +105,6 @@ def enroll( 'ttl': ttl(start_dt=now_ + timedelta(days=course.access_period - 30)), }, ) - transact.put( item={ 'id': enrollment.id, @@ -110,6 +117,43 @@ def enroll( }, ) + # Prevents the user from enrolling in the same course again until + # the deduplication window expires or is removed + if deduplication_window: + lock_hash = md5_hash('%s%s' % (user.id, course.id)) + offset_days = deduplication_window['offset_days'] + ttl_expiration = ttl( + start_dt=now_ + timedelta(days=course.access_period - offset_days) + ) + transact.put( + item={ + 'id': 'lock', + 'sk': lock_hash, + 'enrollment_id': enrollment.id, + 'create_date': now_, + 'ttl': ttl_expiration, + }, + cond_expr='attribute_not_exists(sk)', + ) + transact.put( + item={ + 'id': enrollment.id, + 'sk': 'metadata#lock', + 'hash': lock_hash, + 'create_date': now_, + 'ttl': ttl_expiration, + }, + ) + # Deduplication window can be recalculated if needed + transact.put( + item={ + 'id': enrollment.id, + 'sk': 'metadata#deduplication_window', + 'offset_days': offset_days, + 'create_date': now_, + }, + ) + return persistence_layer.transact_write_items(transact) diff --git a/http-api/tests/test_konviva.py b/http-api/tests/test_konviva.py index e07204f..269aebe 100644 --- a/http-api/tests/test_konviva.py +++ b/http-api/tests/test_konviva.py @@ -1,5 +1,5 @@ import konviva -from conf import KONVIVA_API_URL +from config import KONVIVA_API_URL def test_konviva_token(): diff --git a/http-api/uv.lock b/http-api/uv.lock index 318a57f..ea1cb0c 100644 --- a/http-api/uv.lock +++ b/http-api/uv.lock @@ -522,7 +522,7 @@ wheels = [ [[package]] name = "layercake" -version = "0.3.3" +version = "0.4.0" source = { directory = "../layercake" } dependencies = [ { name = "arnparse" }, diff --git a/layercake/layercake/dynamodb.py b/layercake/layercake/dynamodb.py index 084fd84..e75029e 100644 --- a/layercake/layercake/dynamodb.py +++ b/layercake/layercake/dynamodb.py @@ -346,10 +346,29 @@ class TransactOperation: self.exc_cls = exc_cls +if TYPE_CHECKING: + from mypy_boto3_dynamodb.client import DynamoDBClient +else: + DynamoDBClient = object + + class TransactItems: - def __init__(self, table_name: str) -> None: - self.table_name = table_name - self.items: list[TransactOperation] = [] + def __init__( + self, + table_name: str, + client: DynamoDBClient, + ) -> None: + self._table_name = table_name + self._operations: list[TransactOperation] = [] + self._client = client + + def __enter__(self) -> Self: + """Remove operations from previous execution.""" + self._operations.clear() + return self + + def __exit__(self, exc_type, exc_val, exc_tb) -> bool: + return False def put( self, @@ -365,9 +384,9 @@ class TransactItems: attrs['ConditionExpression'] = cond_expr if not table_name: - table_name = self.table_name + table_name = self._table_name - self.items.append( + self._operations.append( TransactOperation( { 'Put': dict( @@ -403,9 +422,9 @@ class TransactItems: attrs['ExpressionAttributeValues'] = serialize(expr_attr_values) if not table_name: - table_name = self.table_name + table_name = self._table_name - self.items.append( + self._operations.append( TransactOperation( { 'Update': dict( @@ -432,9 +451,9 @@ class TransactItems: attrs['ExpressionAttributeNames'] = expr_attr_names if not table_name: - table_name = self.table_name + table_name = self._table_name - self.items.append( + self._operations.append( TransactOperation( { 'Get': dict( @@ -468,9 +487,9 @@ class TransactItems: attrs['ExpressionAttributeValues'] = serialize(expr_attr_values) if not table_name: - table_name = self.table_name + table_name = self._table_name - self.items.append( + self._operations.append( TransactOperation( { 'Delete': dict( @@ -502,9 +521,9 @@ class TransactItems: attrs['ExpressionAttributeValues'] = serialize(expr_attr_values) if not table_name: - table_name = self.table_name + table_name = self._table_name - self.items.append( + self._operations.append( TransactOperation( { 'ConditionCheck': dict( @@ -517,17 +536,62 @@ class TransactItems: ) ) + def write_items(self) -> bool: + operations = self._operations.copy() + self._operations.clear() -if TYPE_CHECKING: - from mypy_boto3_dynamodb.client import DynamoDBClient -else: - DynamoDBClient = object + try: + self._client.transact_write_items( + TransactItems=[item.op for item in operations] # type: ignore + ) + except ClientError as err: + error_msg = glom(err, 'response.Error.Message', default='') + cancellations = err.response.get('CancellationReasons', []) + reasons = [] + + for idx, reason in enumerate(cancellations): + if 'Message' not in reason: + continue + + item = operations[idx] + + if item.exc_cls: + raise item.exc_cls(error_msg) + + reasons.append( + { + 'code': reason.get('Code'), + 'message': reason.get('Message'), + 'operation': item.op, + } + ) + + raise TransactionCanceledException(error_msg, reasons) + else: + return True + + def get_items(self) -> list[dict[str, Any]]: + operations = self._operations.copy() + self._operations.clear() + + try: + response = self._client.transact_get_items( + TransactItems=[item.op for item in operations] # type: ignore + ) + except ClientError as err: + logger.exception(err) + raise + else: + return [ + deserialize(response.get('Item', {})) + for response in response.get('Responses', []) + ] class DynamoDBPersistenceLayer: - def __init__(self, table_name: str, dynamodb_client: DynamoDBClient) -> None: - self.table_name = table_name - self.dynamodb_client = dynamodb_client + def __init__(self, table_name: str, client: DynamoDBClient) -> None: + self._table_name = table_name + self._client = client @property def collect(self) -> 'DynamoDBCollection': @@ -561,7 +625,7 @@ class DynamoDBPersistenceLayer: - https://boto3.amazonaws.com/v1/documentation/api/latest/reference/services/dynamodb/client/query.html """ attrs: dict = { - 'TableName': self.table_name, + 'TableName': self._table_name, 'KeyConditionExpression': key_cond_expr, 'ScanIndexForward': index_forward, } @@ -582,7 +646,7 @@ class DynamoDBPersistenceLayer: attrs['Limit'] = limit try: - response = self.dynamodb_client.query(**attrs) + response = self._client.query(**attrs) except ClientError as err: logger.info(attrs) logger.exception(err) @@ -601,12 +665,12 @@ class DynamoDBPersistenceLayer: there will be no Item element in the response. """ attrs = { - 'TableName': self.table_name, + 'TableName': self._table_name, 'Key': serialize(key), } try: - response = self.dynamodb_client.get_item(**attrs) + response = self._client.get_item(**attrs) except ClientError as err: logger.info(attrs) logger.exception(err) @@ -616,7 +680,7 @@ class DynamoDBPersistenceLayer: def put_item(self, item: dict, *, cond_expr: str | None = None) -> bool: attrs = { - 'TableName': self.table_name, + 'TableName': self._table_name, 'Item': serialize(item), } @@ -624,7 +688,7 @@ class DynamoDBPersistenceLayer: attrs['ConditionExpression'] = cond_expr try: - self.dynamodb_client.put_item(**attrs) + self._client.put_item(**attrs) except ClientError as err: logger.info(attrs) logger.exception(err) @@ -642,7 +706,7 @@ class DynamoDBPersistenceLayer: expr_attr_values: dict | None = None, ) -> bool: attrs: dict = { - 'TableName': self.table_name, + 'TableName': self._table_name, 'Key': serialize(key), 'UpdateExpression': update_expr, } @@ -657,7 +721,7 @@ class DynamoDBPersistenceLayer: attrs['ExpressionAttributeValues'] = serialize(expr_attr_values) try: - self.dynamodb_client.update_item(**attrs) + self._client.update_item(**attrs) except ClientError as err: logger.info(attrs) logger.exception(err) @@ -678,7 +742,7 @@ class DynamoDBPersistenceLayer: or if it has an expected attribute value. """ attrs: dict = { - 'TableName': self.table_name, + 'TableName': self._table_name, 'Key': serialize(key), } @@ -692,7 +756,7 @@ class DynamoDBPersistenceLayer: attrs['ExpressionAttributeValues'] = serialize(expr_attr_values) try: - self.dynamodb_client.delete_item(**attrs) + self._client.delete_item(**attrs) except ClientError as err: logger.info(attrs) logger.exception(err) @@ -700,50 +764,8 @@ class DynamoDBPersistenceLayer: else: return True - def transact_get_items(self, transact_items: TransactItems) -> list[dict[str, Any]]: - try: - response = self.dynamodb_client.transact_get_items( - TransactItems=[item.op for item in transact_items.items] # type: ignore - ) - except ClientError as err: - logger.exception(err) - raise - else: - return [ - deserialize(response.get('Item', {})) - for response in response.get('Responses', []) - ] - - def transact_write_items(self, transact_items: TransactItems) -> bool: - try: - self.dynamodb_client.transact_write_items( - TransactItems=[item.op for item in transact_items.items] # type: ignore - ) - except ClientError as err: - error_msg = glom(err, 'response.Error.Message', default='') - cancellations = err.response.get('CancellationReasons', []) - reasons = [] - - for idx, reason in enumerate(cancellations): - if 'Message' not in reason: - continue - - item = transact_items.items[idx] - - if item.exc_cls: - raise item.exc_cls(error_msg) - - reasons.append( - { - 'code': reason.get('Code'), - 'message': reason.get('Message'), - 'operation': item.op, - } - ) - - raise TransactionCanceledException(error_msg, reasons) - else: - return True + def transact_items(self) -> TransactItems: + return TransactItems(table_name=self._table_name, client=self._client) def batch_writer( self, @@ -775,8 +797,8 @@ class DynamoDBPersistenceLayer: DynamoDB.Table.batch_writer https://boto3.amazonaws.com/v1/documentation/api/latest/reference/services/dynamodb/table/batch_writer.html#DynamoDB.Table.batch_writer """ return BatchWriter( - table_name=table_name or self.table_name, - client=self.dynamodb_client, + table_name=table_name or self._table_name, + client=self._client, overwrite_by_pkeys=overwrite_by_pkeys, ) @@ -1011,15 +1033,15 @@ class DynamoDBCollection: if not key.pairs: return {} - table_name = self.persistence_layer.table_name + items = [] sortkeys = key.pairs[1:] if flatten_top else key.pairs - transact = TransactItems(table_name) - # Add a get operation for each key for the transaction - for pair in key.pairs: - transact.get(key=pair) + with self.persistence_layer.transact_items() as transact: + # Add a get operation for each key for the transaction + for pair in key.pairs: + transact.get(key=pair) - items = self.persistence_layer.transact_get_items(transact) + items = transact.get_items() if flatten_top: head, *tail = items diff --git a/layercake/pyproject.toml b/layercake/pyproject.toml index d0cc5be..3d53546 100644 --- a/layercake/pyproject.toml +++ b/layercake/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "layercake" -version = "0.4.0" +version = "0.5.0" 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 eecfbf2..82308c9 100644 --- a/layercake/tests/test_dynamodb.py +++ b/layercake/tests/test_dynamodb.py @@ -12,7 +12,6 @@ from layercake.dynamodb import ( PartitionKey, PrefixKey, SortKey, - TransactItems, TransactKey, serialize, ) @@ -94,24 +93,25 @@ def test_transact_write_items( ): class EmailConflictError(Exception): ... - transact = TransactItems(dynamodb_persistence_layer.table_name) - transact.put(item=KeyPair('5OxmMjL-ujoR5IMGegQz', '0')) - transact.put(item=KeyPair('cpf', '07879819908')) - transact.put( - item=KeyPair('email', 'sergio@somosbeta.com.br'), - cond_expr='attribute_not_exists(sk)', - ) - transact.put( - item=KeyPair( - '5OxmMjL-ujoR5IMGegQz', - ComposeKey('sergio@somosbeta.com.br', 'emails'), - ), - cond_expr='attribute_not_exists(sk)', - exc_cls=EmailConflictError, - ) + with dynamodb_persistence_layer.transact_items() as transact: + # transact = TransactItems(dynamodb_persistence_layer.table_name) + transact.put(item=KeyPair('5OxmMjL-ujoR5IMGegQz', '0')) + transact.put(item=KeyPair('cpf', '07879819908')) + transact.put( + item=KeyPair('email', 'sergio@somosbeta.com.br'), + cond_expr='attribute_not_exists(sk)', + ) + transact.put( + item=KeyPair( + '5OxmMjL-ujoR5IMGegQz', + ComposeKey('sergio@somosbeta.com.br', 'emails'), + ), + cond_expr='attribute_not_exists(sk)', + exc_cls=EmailConflictError, + ) - with pytest.raises(EmailConflictError): - dynamodb_persistence_layer.transact_write_items(transact) + with pytest.raises(EmailConflictError): + transact.write_items() def test_collection_get_item( diff --git a/order-management/app/conf.py b/order-management/app/conf.py deleted file mode 100644 index ad958ca..0000000 --- a/order-management/app/conf.py +++ /dev/null @@ -1,5 +0,0 @@ -import os - -USER_TABLE: str = os.getenv('USER_TABLE') # type: ignore -ORDER_TABLE: str = os.getenv('ORDER_TABLE') # type: ignore -ENROLLMENT_TABLE: str = os.getenv('ENROLLMENT_TABLE') # type: ignore diff --git a/order-management/app/events/assign_tenant_cnpj.py b/order-management/app/events/assign_tenant_cnpj.py index df54ecd..7f1656d 100644 --- a/order-management/app/events/assign_tenant_cnpj.py +++ b/order-management/app/events/assign_tenant_cnpj.py @@ -13,7 +13,7 @@ from layercake.dynamodb import ( ) from boto3clients import dynamodb_client -from conf import ORDER_TABLE, USER_TABLE +from config import ORDER_TABLE, USER_TABLE logger = Logger(__name__) user_layer = DynamoDBPersistenceLayer(USER_TABLE, dynamodb_client) @@ -39,7 +39,7 @@ def lambda_handler(event: EventBridgeEvent, context: LambdaContext) -> bool: flatten_top=False, ) - # Sometimes, a DynamoDB insertion may take longer to complete, + # Sometimes the function executes before the user insertion completes, # so an exception is raised to trigger a retry. if len(ids) < 2: raise ValueError('IDs not found.') diff --git a/order-management/app/events/assign_tenant_cpf.py b/order-management/app/events/assign_tenant_cpf.py index dbce635..782fcd2 100644 --- a/order-management/app/events/assign_tenant_cpf.py +++ b/order-management/app/events/assign_tenant_cpf.py @@ -9,7 +9,7 @@ from layercake.dynamodb import ( ) from boto3clients import dynamodb_client -from conf import ORDER_TABLE, USER_TABLE +from config import ORDER_TABLE, USER_TABLE logger = Logger(__name__) user_layer = DynamoDBPersistenceLayer(USER_TABLE, dynamodb_client) diff --git a/order-management/app/events/stopgap/set_as_paid.py b/order-management/app/events/stopgap/set_as_paid.py index a0cde76..806ea08 100644 --- a/order-management/app/events/stopgap/set_as_paid.py +++ b/order-management/app/events/stopgap/set_as_paid.py @@ -8,10 +8,11 @@ from layercake.dateutils import now from layercake.dynamodb import ( DynamoDBPersistenceLayer, KeyPair, + TransactItems, ) from boto3clients import dynamodb_client -from conf import ORDER_TABLE +from config import ORDER_TABLE logger = Logger(__name__) order_layer = DynamoDBPersistenceLayer(ORDER_TABLE, dynamodb_client) @@ -21,7 +22,9 @@ order_layer = DynamoDBPersistenceLayer(ORDER_TABLE, dynamodb_client) @logger.inject_lambda_context def lambda_handler(event: EventBridgeEvent, context: LambdaContext) -> bool: new_image = event.detail['new_image'] - order_layer.update_item( + now_ = now() + transact = TransactItems(order_layer.table_name) + transact.update( key=KeyPair(new_image['id'], '0'), update_expr='SET #status = :status, update_date = :update_date', expr_attr_names={ @@ -29,8 +32,15 @@ def lambda_handler(event: EventBridgeEvent, context: LambdaContext) -> bool: }, expr_attr_values={ ':status': 'PAID', - ':update_date': now(), + ':update_date': now_, }, ) - + transact.put( + item={ + 'id': new_image['id'], + 'sk': 'paid_date', + 'create_date': now_, + } + ) + order_layer.transact_write_items(transact) return True diff --git a/order-management/template.yaml b/order-management/template.yaml index eaa82d6..80c0a2d 100644 --- a/order-management/template.yaml +++ b/order-management/template.yaml @@ -111,5 +111,5 @@ Resources: cnpj: - exists: true total: [0] - status: [PENDING] + status: [CREATING, PENDING] payment_method: [MANUAL]