This commit is contained in:
2025-05-27 12:15:22 -03:00
parent 270e408c1d
commit 42e62ec183
30 changed files with 287 additions and 178 deletions

View File

@@ -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_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)**. - `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 ### Proteção contra duplicação
### Política de cancelamento ### Política de cancelamento

View File

@@ -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

View File

@@ -4,10 +4,43 @@ from aws_lambda_powertools.utilities.data_classes import (
event_source, event_source,
) )
from aws_lambda_powertools.utilities.typing import LambdaContext 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__) logger = Logger(__name__)
enrollment_layer = DynamoDBPersistenceLayer(ENROLLMENT_TABLE, dynamodb_client)
@event_source(data_class=EventBridgeEvent) @event_source(data_class=EventBridgeEvent)
@logger.inject_lambda_context @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

View File

@@ -13,7 +13,7 @@ from layercake.dynamodb import (
) )
from boto3clients import dynamodb_client 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__) logger = Logger(__name__)
user_layer = DynamoDBPersistenceLayer(USER_TABLE, dynamodb_client) user_layer = DynamoDBPersistenceLayer(USER_TABLE, dynamodb_client)

View File

@@ -30,6 +30,7 @@ Globals:
POWERTOOLS_LOGGER_LOG_EVENT: true POWERTOOLS_LOGGER_LOG_EVENT: true
USER_TABLE: !Ref UserTable USER_TABLE: !Ref UserTable
ENROLLMENT_TABLE: !Ref EnrollmentTable ENROLLMENT_TABLE: !Ref EnrollmentTable
ORDER_TABLE: !Ref OrderTable
Resources: Resources:
EventLog: EventLog:
@@ -40,7 +41,7 @@ Resources:
EventDelVacanciesFunction: EventDelVacanciesFunction:
Type: AWS::Serverless::Function Type: AWS::Serverless::Function
Properties: Properties:
Handler: events.del_vacancies.lambda_handler Handler: events.stopgap.del_vacancies.lambda_handler
LoggingConfig: LoggingConfig:
LogGroup: !Ref EventLog LogGroup: !Ref EventLog
Policies: Policies:
@@ -60,3 +61,43 @@ Resources:
new_image: new_image:
sk: [generated_items] sk: [generated_items]
status: [SUCCESS] 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]

View File

@@ -39,7 +39,7 @@ from layercake.funcs import pick
from boto3clients import dynamodb_client, idp_client from boto3clients import dynamodb_client, idp_client
from cognito import get_user from cognito import get_user
from conf import USER_TABLE from config import USER_TABLE
APIKEY_PREFIX = 'sk-' APIKEY_PREFIX = 'sk-'

View File

@@ -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'

View File

@@ -6,7 +6,7 @@ import requests
from aws_lambda_powertools.event_handler.exceptions import BadRequestError from aws_lambda_powertools.event_handler.exceptions import BadRequestError
from glom import glom 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): class KonvivaError(BadRequestError):

View File

@@ -7,7 +7,7 @@ from meilisearch import Client as Meilisearch
from api_gateway import JSONResponse from api_gateway import JSONResponse
from boto3clients import dynamodb_client from boto3clients import dynamodb_client
from conf import ( from config import (
COURSE_TABLE, COURSE_TABLE,
MEILISEARCH_API_KEY, MEILISEARCH_API_KEY,
MEILISEARCH_HOST, MEILISEARCH_HOST,

View File

@@ -11,7 +11,7 @@ from layercake.dynamodb import (
import elastic import elastic
from boto3clients import dynamodb_client 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 .cancel import router as cancel
from .enroll import router as enroll from .enroll import router as enroll

View File

@@ -7,7 +7,7 @@ from layercake.dynamodb import (
) )
from boto3clients import dynamodb_client from boto3clients import dynamodb_client
from conf import ( from config import (
ENROLLMENT_TABLE, ENROLLMENT_TABLE,
USER_TABLE, USER_TABLE,
) )

View File

@@ -6,7 +6,7 @@ from elasticsearch import Elasticsearch
from elasticsearch_dsl import Search from elasticsearch_dsl import Search
from layercake.funcs import pick from layercake.funcs import pick
from conf import ELASTIC_CONN, USER_TABLE from config import ELASTIC_CONN, USER_TABLE
router = Router() router = Router()
elastic_client = Elasticsearch(**ELASTIC_CONN) elastic_client = Elasticsearch(**ELASTIC_CONN)

View File

@@ -13,7 +13,7 @@ from layercake.dynamodb import (
import elastic import elastic
from boto3clients import dynamodb_client from boto3clients import dynamodb_client
from conf import ELASTIC_CONN, ORDER_TABLE from config import ELASTIC_CONN, ORDER_TABLE
router = Router() router = Router()
order_layer = DynamoDBPersistenceLayer(ORDER_TABLE, dynamodb_client) order_layer = DynamoDBPersistenceLayer(ORDER_TABLE, dynamodb_client)

View File

@@ -15,7 +15,7 @@ from layercake.dynamodb import (
from pydantic.main import BaseModel from pydantic.main import BaseModel
from boto3clients import dynamodb_client from boto3clients import dynamodb_client
from conf import USER_TABLE from config import USER_TABLE
from rules.org import update_policies from rules.org import update_policies
router = Router() router = Router()

View File

@@ -8,7 +8,7 @@ from layercake.dynamodb import (
import konviva import konviva
from boto3clients import dynamodb_client from boto3clients import dynamodb_client
from conf import USER_TABLE from config import USER_TABLE
from middlewares import User from middlewares import User
router = Router() router = Router()

View File

@@ -21,7 +21,7 @@ import cognito
import elastic import elastic
from api_gateway import JSONResponse from api_gateway import JSONResponse
from boto3clients import dynamodb_client, idp_client 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 middlewares import AuditLogMiddleware
from models import User from models import User
from rules.user import update_user from rules.user import update_user

View File

@@ -15,7 +15,7 @@ from pydantic import BaseModel, EmailStr
from api_gateway import JSONResponse from api_gateway import JSONResponse
from boto3clients import dynamodb_client from boto3clients import dynamodb_client
from conf import USER_TABLE from config import USER_TABLE
from middlewares import AuditLogMiddleware from middlewares import AuditLogMiddleware
from rules.user import add_email, del_email, set_email_as_primary from rules.user import add_email, del_email, set_email_as_primary

View File

@@ -11,7 +11,7 @@ from layercake.dynamodb import (
) )
from boto3clients import dynamodb_client from boto3clients import dynamodb_client
from conf import USER_TABLE from config import USER_TABLE
from .orgs import router as orgs from .orgs import router as orgs

View File

@@ -16,7 +16,7 @@ from pydantic import BaseModel
from api_gateway import JSONResponse from api_gateway import JSONResponse
from boto3clients import dynamodb_client from boto3clients import dynamodb_client
from conf import USER_TABLE from config import USER_TABLE
from middlewares.audit_log_middleware import AuditLogMiddleware from middlewares.audit_log_middleware import AuditLogMiddleware
from rules.user import del_org_member from rules.user import del_org_member

View File

@@ -5,8 +5,9 @@ from uuid import uuid4
from layercake.dateutils import now, ttl from layercake.dateutils import now, ttl
from layercake.dynamodb import DynamoDBPersistenceLayer, KeyPair, TransactItems 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 from models import Course, Enrollment
@@ -23,6 +24,10 @@ class Author(TypedDict):
class Vacancy(TypedDict): ... class Vacancy(TypedDict): ...
class DeduplicationWindow(TypedDict):
offset_days: int
class LifecycleEvents(str, Enum): class LifecycleEvents(str, Enum):
"""Lifecycle events related to scheduling actions.""" """Lifecycle events related to scheduling actions."""
@@ -49,20 +54,23 @@ def enroll(
enrollment: Enrollment, enrollment: Enrollment,
*, *,
tenant: Tenant, tenant: Tenant,
vacancy: Vacancy | None = None,
deduplication_window: DeduplicationWindow | None = None,
persistence_layer: DynamoDBPersistenceLayer, persistence_layer: DynamoDBPersistenceLayer,
) -> bool: ) -> bool:
"""Enrolls a user into a course and schedules lifecycle events.""" """Enrolls a user into a course and schedules lifecycle events."""
now_ = now() now_ = now()
user = enrollment.user user = enrollment.user
course = enrollment.course course = enrollment.course
tenant_id = tenant['id']
transact = TransactItems(persistence_layer.table_name) transact = TransactItems(persistence_layer.table_name)
transact.put( transact.put(
item={ item={
'sk': '0', 'sk': '0',
'create_date': now_, 'create_date': now_,
'metadata__tenant_id': tenant['id'], 'metadata__tenant_id': tenant_id,
'metadata__related_ids': {tenant['id'], user.id}, 'metadata__related_ids': {tenant_id, user.id},
**enrollment.model_dump(), **enrollment.model_dump(),
}, },
) )
@@ -70,7 +78,7 @@ def enroll(
item={ item={
'id': enrollment.id, 'id': enrollment.id,
'sk': 'metadata#tenant', 'sk': 'metadata#tenant',
'tenant_id': f'ORG#{tenant["id"]}', 'tenant_id': f'ORG#{tenant_id}',
'name': tenant['name'], 'name': tenant['name'],
'create_date': now_, 'create_date': now_,
}, },
@@ -97,7 +105,6 @@ def enroll(
'ttl': ttl(start_dt=now_ + timedelta(days=course.access_period - 30)), 'ttl': ttl(start_dt=now_ + timedelta(days=course.access_period - 30)),
}, },
) )
transact.put( transact.put(
item={ item={
'id': enrollment.id, '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) return persistence_layer.transact_write_items(transact)

View File

@@ -1,5 +1,5 @@
import konviva import konviva
from conf import KONVIVA_API_URL from config import KONVIVA_API_URL
def test_konviva_token(): def test_konviva_token():

2
http-api/uv.lock generated
View File

@@ -522,7 +522,7 @@ wheels = [
[[package]] [[package]]
name = "layercake" name = "layercake"
version = "0.3.3" version = "0.4.0"
source = { directory = "../layercake" } source = { directory = "../layercake" }
dependencies = [ dependencies = [
{ name = "arnparse" }, { name = "arnparse" },

View File

@@ -346,10 +346,29 @@ class TransactOperation:
self.exc_cls = exc_cls self.exc_cls = exc_cls
if TYPE_CHECKING:
from mypy_boto3_dynamodb.client import DynamoDBClient
else:
DynamoDBClient = object
class TransactItems: class TransactItems:
def __init__(self, table_name: str) -> None: def __init__(
self.table_name = table_name self,
self.items: list[TransactOperation] = [] 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( def put(
self, self,
@@ -365,9 +384,9 @@ class TransactItems:
attrs['ConditionExpression'] = cond_expr attrs['ConditionExpression'] = cond_expr
if not table_name: if not table_name:
table_name = self.table_name table_name = self._table_name
self.items.append( self._operations.append(
TransactOperation( TransactOperation(
{ {
'Put': dict( 'Put': dict(
@@ -403,9 +422,9 @@ class TransactItems:
attrs['ExpressionAttributeValues'] = serialize(expr_attr_values) attrs['ExpressionAttributeValues'] = serialize(expr_attr_values)
if not table_name: if not table_name:
table_name = self.table_name table_name = self._table_name
self.items.append( self._operations.append(
TransactOperation( TransactOperation(
{ {
'Update': dict( 'Update': dict(
@@ -432,9 +451,9 @@ class TransactItems:
attrs['ExpressionAttributeNames'] = expr_attr_names attrs['ExpressionAttributeNames'] = expr_attr_names
if not table_name: if not table_name:
table_name = self.table_name table_name = self._table_name
self.items.append( self._operations.append(
TransactOperation( TransactOperation(
{ {
'Get': dict( 'Get': dict(
@@ -468,9 +487,9 @@ class TransactItems:
attrs['ExpressionAttributeValues'] = serialize(expr_attr_values) attrs['ExpressionAttributeValues'] = serialize(expr_attr_values)
if not table_name: if not table_name:
table_name = self.table_name table_name = self._table_name
self.items.append( self._operations.append(
TransactOperation( TransactOperation(
{ {
'Delete': dict( 'Delete': dict(
@@ -502,9 +521,9 @@ class TransactItems:
attrs['ExpressionAttributeValues'] = serialize(expr_attr_values) attrs['ExpressionAttributeValues'] = serialize(expr_attr_values)
if not table_name: if not table_name:
table_name = self.table_name table_name = self._table_name
self.items.append( self._operations.append(
TransactOperation( TransactOperation(
{ {
'ConditionCheck': dict( 'ConditionCheck': dict(
@@ -517,17 +536,62 @@ class TransactItems:
) )
) )
def write_items(self) -> bool:
operations = self._operations.copy()
self._operations.clear()
if TYPE_CHECKING: try:
from mypy_boto3_dynamodb.client import DynamoDBClient self._client.transact_write_items(
else: TransactItems=[item.op for item in operations] # type: ignore
DynamoDBClient = object )
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: class DynamoDBPersistenceLayer:
def __init__(self, table_name: str, dynamodb_client: DynamoDBClient) -> None: def __init__(self, table_name: str, client: DynamoDBClient) -> None:
self.table_name = table_name self._table_name = table_name
self.dynamodb_client = dynamodb_client self._client = client
@property @property
def collect(self) -> 'DynamoDBCollection': def collect(self) -> 'DynamoDBCollection':
@@ -561,7 +625,7 @@ class DynamoDBPersistenceLayer:
- https://boto3.amazonaws.com/v1/documentation/api/latest/reference/services/dynamodb/client/query.html - https://boto3.amazonaws.com/v1/documentation/api/latest/reference/services/dynamodb/client/query.html
""" """
attrs: dict = { attrs: dict = {
'TableName': self.table_name, 'TableName': self._table_name,
'KeyConditionExpression': key_cond_expr, 'KeyConditionExpression': key_cond_expr,
'ScanIndexForward': index_forward, 'ScanIndexForward': index_forward,
} }
@@ -582,7 +646,7 @@ class DynamoDBPersistenceLayer:
attrs['Limit'] = limit attrs['Limit'] = limit
try: try:
response = self.dynamodb_client.query(**attrs) response = self._client.query(**attrs)
except ClientError as err: except ClientError as err:
logger.info(attrs) logger.info(attrs)
logger.exception(err) logger.exception(err)
@@ -601,12 +665,12 @@ class DynamoDBPersistenceLayer:
there will be no Item element in the response. there will be no Item element in the response.
""" """
attrs = { attrs = {
'TableName': self.table_name, 'TableName': self._table_name,
'Key': serialize(key), 'Key': serialize(key),
} }
try: try:
response = self.dynamodb_client.get_item(**attrs) response = self._client.get_item(**attrs)
except ClientError as err: except ClientError as err:
logger.info(attrs) logger.info(attrs)
logger.exception(err) logger.exception(err)
@@ -616,7 +680,7 @@ class DynamoDBPersistenceLayer:
def put_item(self, item: dict, *, cond_expr: str | None = None) -> bool: def put_item(self, item: dict, *, cond_expr: str | None = None) -> bool:
attrs = { attrs = {
'TableName': self.table_name, 'TableName': self._table_name,
'Item': serialize(item), 'Item': serialize(item),
} }
@@ -624,7 +688,7 @@ class DynamoDBPersistenceLayer:
attrs['ConditionExpression'] = cond_expr attrs['ConditionExpression'] = cond_expr
try: try:
self.dynamodb_client.put_item(**attrs) self._client.put_item(**attrs)
except ClientError as err: except ClientError as err:
logger.info(attrs) logger.info(attrs)
logger.exception(err) logger.exception(err)
@@ -642,7 +706,7 @@ class DynamoDBPersistenceLayer:
expr_attr_values: dict | None = None, expr_attr_values: dict | None = None,
) -> bool: ) -> bool:
attrs: dict = { attrs: dict = {
'TableName': self.table_name, 'TableName': self._table_name,
'Key': serialize(key), 'Key': serialize(key),
'UpdateExpression': update_expr, 'UpdateExpression': update_expr,
} }
@@ -657,7 +721,7 @@ class DynamoDBPersistenceLayer:
attrs['ExpressionAttributeValues'] = serialize(expr_attr_values) attrs['ExpressionAttributeValues'] = serialize(expr_attr_values)
try: try:
self.dynamodb_client.update_item(**attrs) self._client.update_item(**attrs)
except ClientError as err: except ClientError as err:
logger.info(attrs) logger.info(attrs)
logger.exception(err) logger.exception(err)
@@ -678,7 +742,7 @@ class DynamoDBPersistenceLayer:
or if it has an expected attribute value. or if it has an expected attribute value.
""" """
attrs: dict = { attrs: dict = {
'TableName': self.table_name, 'TableName': self._table_name,
'Key': serialize(key), 'Key': serialize(key),
} }
@@ -692,7 +756,7 @@ class DynamoDBPersistenceLayer:
attrs['ExpressionAttributeValues'] = serialize(expr_attr_values) attrs['ExpressionAttributeValues'] = serialize(expr_attr_values)
try: try:
self.dynamodb_client.delete_item(**attrs) self._client.delete_item(**attrs)
except ClientError as err: except ClientError as err:
logger.info(attrs) logger.info(attrs)
logger.exception(err) logger.exception(err)
@@ -700,50 +764,8 @@ class DynamoDBPersistenceLayer:
else: else:
return True return True
def transact_get_items(self, transact_items: TransactItems) -> list[dict[str, Any]]: def transact_items(self) -> TransactItems:
try: return TransactItems(table_name=self._table_name, client=self._client)
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 batch_writer( def batch_writer(
self, 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 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( return BatchWriter(
table_name=table_name or self.table_name, table_name=table_name or self._table_name,
client=self.dynamodb_client, client=self._client,
overwrite_by_pkeys=overwrite_by_pkeys, overwrite_by_pkeys=overwrite_by_pkeys,
) )
@@ -1011,15 +1033,15 @@ class DynamoDBCollection:
if not key.pairs: if not key.pairs:
return {} return {}
table_name = self.persistence_layer.table_name items = []
sortkeys = key.pairs[1:] if flatten_top else key.pairs sortkeys = key.pairs[1:] if flatten_top else key.pairs
transact = TransactItems(table_name)
with self.persistence_layer.transact_items() as transact:
# Add a get operation for each key for the transaction # Add a get operation for each key for the transaction
for pair in key.pairs: for pair in key.pairs:
transact.get(key=pair) transact.get(key=pair)
items = self.persistence_layer.transact_get_items(transact) items = transact.get_items()
if flatten_top: if flatten_top:
head, *tail = items head, *tail = items

View File

@@ -1,6 +1,6 @@
[project] [project]
name = "layercake" name = "layercake"
version = "0.4.0" version = "0.5.0"
description = "Packages shared dependencies to optimize deployment and ensure consistency across functions." description = "Packages shared dependencies to optimize deployment and ensure consistency across functions."
readme = "README.md" readme = "README.md"
authors = [ authors = [

View File

@@ -12,7 +12,6 @@ from layercake.dynamodb import (
PartitionKey, PartitionKey,
PrefixKey, PrefixKey,
SortKey, SortKey,
TransactItems,
TransactKey, TransactKey,
serialize, serialize,
) )
@@ -94,7 +93,8 @@ def test_transact_write_items(
): ):
class EmailConflictError(Exception): ... class EmailConflictError(Exception): ...
transact = TransactItems(dynamodb_persistence_layer.table_name) 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('5OxmMjL-ujoR5IMGegQz', '0'))
transact.put(item=KeyPair('cpf', '07879819908')) transact.put(item=KeyPair('cpf', '07879819908'))
transact.put( transact.put(
@@ -111,7 +111,7 @@ def test_transact_write_items(
) )
with pytest.raises(EmailConflictError): with pytest.raises(EmailConflictError):
dynamodb_persistence_layer.transact_write_items(transact) transact.write_items()
def test_collection_get_item( def test_collection_get_item(

View File

@@ -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

View File

@@ -13,7 +13,7 @@ from layercake.dynamodb import (
) )
from boto3clients import dynamodb_client from boto3clients import dynamodb_client
from conf import ORDER_TABLE, USER_TABLE from config import ORDER_TABLE, USER_TABLE
logger = Logger(__name__) logger = Logger(__name__)
user_layer = DynamoDBPersistenceLayer(USER_TABLE, dynamodb_client) user_layer = DynamoDBPersistenceLayer(USER_TABLE, dynamodb_client)
@@ -39,7 +39,7 @@ def lambda_handler(event: EventBridgeEvent, context: LambdaContext) -> bool:
flatten_top=False, 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. # so an exception is raised to trigger a retry.
if len(ids) < 2: if len(ids) < 2:
raise ValueError('IDs not found.') raise ValueError('IDs not found.')

View File

@@ -9,7 +9,7 @@ from layercake.dynamodb import (
) )
from boto3clients import dynamodb_client from boto3clients import dynamodb_client
from conf import ORDER_TABLE, USER_TABLE from config import ORDER_TABLE, USER_TABLE
logger = Logger(__name__) logger = Logger(__name__)
user_layer = DynamoDBPersistenceLayer(USER_TABLE, dynamodb_client) user_layer = DynamoDBPersistenceLayer(USER_TABLE, dynamodb_client)

View File

@@ -8,10 +8,11 @@ from layercake.dateutils import now
from layercake.dynamodb import ( from layercake.dynamodb import (
DynamoDBPersistenceLayer, DynamoDBPersistenceLayer,
KeyPair, KeyPair,
TransactItems,
) )
from boto3clients import dynamodb_client from boto3clients import dynamodb_client
from conf import ORDER_TABLE from config import ORDER_TABLE
logger = Logger(__name__) logger = Logger(__name__)
order_layer = DynamoDBPersistenceLayer(ORDER_TABLE, dynamodb_client) order_layer = DynamoDBPersistenceLayer(ORDER_TABLE, dynamodb_client)
@@ -21,7 +22,9 @@ order_layer = DynamoDBPersistenceLayer(ORDER_TABLE, dynamodb_client)
@logger.inject_lambda_context @logger.inject_lambda_context
def lambda_handler(event: EventBridgeEvent, context: LambdaContext) -> bool: def lambda_handler(event: EventBridgeEvent, context: LambdaContext) -> bool:
new_image = event.detail['new_image'] 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'), key=KeyPair(new_image['id'], '0'),
update_expr='SET #status = :status, update_date = :update_date', update_expr='SET #status = :status, update_date = :update_date',
expr_attr_names={ expr_attr_names={
@@ -29,8 +32,15 @@ def lambda_handler(event: EventBridgeEvent, context: LambdaContext) -> bool:
}, },
expr_attr_values={ expr_attr_values={
':status': 'PAID', ':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 return True

View File

@@ -111,5 +111,5 @@ Resources:
cnpj: cnpj:
- exists: true - exists: true
total: [0] total: [0]
status: [PENDING] status: [CREATING, PENDING]
payment_method: [MANUAL] payment_method: [MANUAL]