remove deduplication as default
This commit is contained in:
@@ -2,12 +2,18 @@ AWSTemplateFormatVersion: "2010-09-09"
|
|||||||
Transform: "AWS::Serverless-2016-10-31"
|
Transform: "AWS::Serverless-2016-10-31"
|
||||||
|
|
||||||
Parameters:
|
Parameters:
|
||||||
|
UsersTable:
|
||||||
|
Type: String
|
||||||
|
Default: betaeducacao-prod-users_d2o3r5gmm4it7j
|
||||||
CourseTable:
|
CourseTable:
|
||||||
Type: String
|
Type: String
|
||||||
Default: saladeaula_courses
|
Default: saladeaula_courses
|
||||||
EnrollmentTable:
|
EnrollmentTable:
|
||||||
Type: String
|
Type: String
|
||||||
Default: betaeducacao-prod-enrollments
|
Default: betaeducacao-prod-enrollments
|
||||||
|
OrderTable:
|
||||||
|
Type: String
|
||||||
|
Default: betaeducacao-prod-orders
|
||||||
BucketName:
|
BucketName:
|
||||||
Type: String
|
Type: String
|
||||||
Default: saladeaula.digital
|
Default: saladeaula.digital
|
||||||
@@ -66,10 +72,14 @@ Resources:
|
|||||||
LoggingConfig:
|
LoggingConfig:
|
||||||
LogGroup: !Ref HttpLog
|
LogGroup: !Ref HttpLog
|
||||||
Policies:
|
Policies:
|
||||||
|
- DynamoDBCrudPolicy:
|
||||||
|
TableName: !Ref UserTable
|
||||||
- DynamoDBCrudPolicy:
|
- DynamoDBCrudPolicy:
|
||||||
TableName: !Ref CourseTable
|
TableName: !Ref CourseTable
|
||||||
- DynamoDBCrudPolicy:
|
- DynamoDBCrudPolicy:
|
||||||
TableName: !Ref EnrollmentTable
|
TableName: !Ref EnrollmentTable
|
||||||
|
- DynamoDBCrudPolicy:
|
||||||
|
TableName: !Ref OrderTable
|
||||||
- S3CrudPolicy:
|
- S3CrudPolicy:
|
||||||
BucketName: !Ref BucketName
|
BucketName: !Ref BucketName
|
||||||
Events:
|
Events:
|
||||||
|
|||||||
@@ -1,5 +1,7 @@
|
|||||||
import os
|
import os
|
||||||
|
|
||||||
|
DEDUP_WINDOW_OFFSET_DAYS = 90
|
||||||
|
|
||||||
USER_TABLE: str = os.getenv('USER_TABLE') # type: ignore
|
USER_TABLE: str = os.getenv('USER_TABLE') # type: ignore
|
||||||
ORDER_TABLE: str = os.getenv('ORDER_TABLE') # type: ignore
|
ORDER_TABLE: str = os.getenv('ORDER_TABLE') # type: ignore
|
||||||
ENROLLMENT_TABLE: str = os.getenv('ENROLLMENT_TABLE') # type: ignore
|
ENROLLMENT_TABLE: str = os.getenv('ENROLLMENT_TABLE') # type: ignore
|
||||||
|
|||||||
@@ -5,9 +5,10 @@ from enum import Enum
|
|||||||
from typing import NotRequired, TypedDict
|
from typing import NotRequired, TypedDict
|
||||||
|
|
||||||
from layercake.dateutils import now, ttl
|
from layercake.dateutils import now, ttl
|
||||||
from layercake.dynamodb import DynamoDBPersistenceLayer, KeyPair
|
from layercake.dynamodb import DynamoDBPersistenceLayer
|
||||||
from layercake.strutils import md5_hash
|
from layercake.strutils import md5_hash
|
||||||
|
|
||||||
|
from config import DEDUP_WINDOW_OFFSET_DAYS
|
||||||
from schemas import Enrollment
|
from schemas import Enrollment
|
||||||
|
|
||||||
Org = TypedDict(
|
Org = TypedDict(
|
||||||
@@ -124,8 +125,11 @@ def enroll(
|
|||||||
|
|
||||||
# Prevents the user from enrolling in the same course again until
|
# Prevents the user from enrolling in the same course again until
|
||||||
# the deduplication window expires or is removed.
|
# the deduplication window expires or is removed.
|
||||||
if deduplication_window:
|
offset_days = (
|
||||||
offset_days = int(deduplication_window['offset_days'])
|
int(deduplication_window['offset_days'])
|
||||||
|
if deduplication_window
|
||||||
|
else DEDUP_WINDOW_OFFSET_DAYS
|
||||||
|
)
|
||||||
ttl_ = ttl(
|
ttl_ = ttl(
|
||||||
start_dt=now_,
|
start_dt=now_,
|
||||||
days=course.access_period - offset_days,
|
days=course.access_period - offset_days,
|
||||||
@@ -150,7 +154,9 @@ def enroll(
|
|||||||
'ttl': ttl_,
|
'ttl': ttl_,
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
# Deduplication window can be recalculated if needed
|
|
||||||
|
# The deduplication window can be recalculated based on user settings.
|
||||||
|
if deduplication_window:
|
||||||
transact.put(
|
transact.put(
|
||||||
item={
|
item={
|
||||||
'id': enrollment.id,
|
'id': enrollment.id,
|
||||||
@@ -159,11 +165,5 @@ def enroll(
|
|||||||
'created_at': now_,
|
'created_at': now_,
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
else:
|
|
||||||
transact.condition(
|
|
||||||
key=KeyPair('LOCK', lock_hash),
|
|
||||||
cond_expr='attribute_not_exists(sk)',
|
|
||||||
exc_cls=DeduplicationConflictError,
|
|
||||||
)
|
|
||||||
|
|
||||||
return True
|
return True
|
||||||
|
|||||||
@@ -92,7 +92,6 @@ def _handler(record: Course, context: dict) -> Enrollment:
|
|||||||
enroll(
|
enroll(
|
||||||
enrollment,
|
enrollment,
|
||||||
persistence_layer=enrollment_layer,
|
persistence_layer=enrollment_layer,
|
||||||
deduplication_window={'offset_days': 90},
|
|
||||||
linked_entities=frozenset(
|
linked_entities=frozenset(
|
||||||
{
|
{
|
||||||
LinkedEntity(
|
LinkedEntity(
|
||||||
|
|||||||
@@ -3,7 +3,7 @@ from datetime import datetime, timedelta
|
|||||||
from typing import NotRequired, TypedDict
|
from typing import NotRequired, TypedDict
|
||||||
|
|
||||||
import requests
|
import requests
|
||||||
from aws_lambda_powertools import Logger
|
from aws_lambda_powertools import Logger, Tracer
|
||||||
from aws_lambda_powertools.utilities.data_classes import (
|
from aws_lambda_powertools.utilities.data_classes import (
|
||||||
EventBridgeEvent,
|
EventBridgeEvent,
|
||||||
event_source,
|
event_source,
|
||||||
@@ -21,12 +21,14 @@ from config import (
|
|||||||
PAPERFORGE_API,
|
PAPERFORGE_API,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
tracer = Tracer()
|
||||||
logger = Logger(__name__)
|
logger = Logger(__name__)
|
||||||
dyn = DynamoDBPersistenceLayer(ENROLLMENT_TABLE, dynamodb_client)
|
dyn = DynamoDBPersistenceLayer(ENROLLMENT_TABLE, dynamodb_client)
|
||||||
|
|
||||||
|
|
||||||
@event_source(data_class=EventBridgeEvent)
|
|
||||||
@logger.inject_lambda_context
|
@logger.inject_lambda_context
|
||||||
|
@tracer.capture_lambda_handler
|
||||||
|
@event_source(data_class=EventBridgeEvent)
|
||||||
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']
|
||||||
now_ = now()
|
now_ = now()
|
||||||
|
|||||||
@@ -23,18 +23,25 @@ def lambda_handler(event: EventBridgeEvent, context: LambdaContext) -> bool:
|
|||||||
new_image = event.detail['new_image']
|
new_image = event.detail['new_image']
|
||||||
metadata = dyn.collection.get_items(
|
metadata = dyn.collection.get_items(
|
||||||
TransactKey(new_image['id'])
|
TransactKey(new_image['id'])
|
||||||
+ SortKey('METADATA#SUBSCRIPTION_COVERED', rename_key='subscription')
|
+ SortKey(
|
||||||
|
'METADATA#SUBSCRIPTION_COVERED',
|
||||||
|
rename_key='subscription',
|
||||||
|
)
|
||||||
+ SortKey(
|
+ SortKey(
|
||||||
'METADATA#DEDUPLICATION_WINDOW',
|
'METADATA#DEDUPLICATION_WINDOW',
|
||||||
path_spec='offset_days',
|
path_spec='offset_days',
|
||||||
rename_key='dedup_window_offset_days',
|
rename_key='dedup_window_offset_days',
|
||||||
)
|
)
|
||||||
+ SortKey('ORG', rename_key='org'),
|
+ SortKey(
|
||||||
|
'ORG',
|
||||||
|
rename_key='org',
|
||||||
|
),
|
||||||
flatten_top=False,
|
flatten_top=False,
|
||||||
)
|
)
|
||||||
user = User.model_validate(new_image['user'])
|
user = User.model_validate(new_image['user'])
|
||||||
course = Course.model_validate(new_image['course'])
|
course = Course.model_validate(new_image['course'])
|
||||||
subscription = metadata['subscription'] if 'subscription' in metadata else None
|
subscription = metadata['subscription'] if 'subscription' in metadata else None
|
||||||
|
offset_days = metadata.get('dedup_window_offset_days', None)
|
||||||
enrollment = Enrollment(
|
enrollment = Enrollment(
|
||||||
id=uuid4(),
|
id=uuid4(),
|
||||||
course=course,
|
course=course,
|
||||||
@@ -45,9 +52,7 @@ def lambda_handler(event: EventBridgeEvent, context: LambdaContext) -> bool:
|
|||||||
enrollment,
|
enrollment,
|
||||||
org=metadata.get('org', None),
|
org=metadata.get('org', None),
|
||||||
subscription=subscription,
|
subscription=subscription,
|
||||||
deduplication_window={
|
deduplication_window={'offset_days': offset_days} if offset_days else None,
|
||||||
'offset_days': metadata['dedup_window_offset_days'],
|
|
||||||
},
|
|
||||||
linked_entities=frozenset(
|
linked_entities=frozenset(
|
||||||
{
|
{
|
||||||
LinkedEntity(
|
LinkedEntity(
|
||||||
|
|||||||
@@ -31,12 +31,12 @@ def lambda_handler(event: EventBridgeEvent, context: LambdaContext) -> bool | No
|
|||||||
)
|
)
|
||||||
|
|
||||||
target_month = expires_at.strftime('%Y-%m')
|
target_month = expires_at.strftime('%Y-%m')
|
||||||
now_ = now()
|
now_ = now(tz)
|
||||||
pk = f'CERT#REPORTING#ORG#{org_id}'
|
pk = f'CERT_REPORTING#ORG#{org_id}'
|
||||||
|
|
||||||
try:
|
try:
|
||||||
if now_ > expires_at:
|
if now_ > expires_at:
|
||||||
raise InvalidDateError()
|
raise InvalidDateError('Invalid date')
|
||||||
|
|
||||||
# The reporting month is the month before the certificate expires
|
# The reporting month is the month before the certificate expires
|
||||||
report_month = (expires_at.replace(day=1) - timedelta(days=1)).replace(day=1)
|
report_month = (expires_at.replace(day=1) - timedelta(days=1)).replace(day=1)
|
||||||
@@ -52,7 +52,7 @@ def lambda_handler(event: EventBridgeEvent, context: LambdaContext) -> bool | No
|
|||||||
'created_at': now_,
|
'created_at': now_,
|
||||||
},
|
},
|
||||||
cond_expr='attribute_not_exists(sk)',
|
cond_expr='attribute_not_exists(sk)',
|
||||||
exc_cls=ReportingConflictError,
|
exc_cls=ReportExistsError,
|
||||||
)
|
)
|
||||||
transact.put(
|
transact.put(
|
||||||
item={
|
item={
|
||||||
@@ -64,7 +64,7 @@ def lambda_handler(event: EventBridgeEvent, context: LambdaContext) -> bool | No
|
|||||||
},
|
},
|
||||||
)
|
)
|
||||||
except Exception as exc:
|
except Exception as exc:
|
||||||
logger.exception(exc)
|
logger.info(exc)
|
||||||
|
|
||||||
try:
|
try:
|
||||||
dyn.put_item(
|
dyn.put_item(
|
||||||
@@ -89,4 +89,6 @@ def lambda_handler(event: EventBridgeEvent, context: LambdaContext) -> bool | No
|
|||||||
class InvalidDateError(Exception): ...
|
class InvalidDateError(Exception): ...
|
||||||
|
|
||||||
|
|
||||||
class ReportingConflictError(Exception): ...
|
class ReportExistsError(Exception):
|
||||||
|
def __init__(self, *args: object) -> None:
|
||||||
|
super().__init__('Report already exists')
|
||||||
|
|||||||
@@ -49,7 +49,7 @@ user_layer = DynamoDBPersistenceLayer(USER_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:
|
||||||
old_image = event.detail['old_image']
|
old_image = event.detail['old_image']
|
||||||
# Key pattern `CERT#REPORTING#ORG#{org_id}`
|
# Key pattern `CERT_REPORTING#ORG#{org_id}`
|
||||||
*_, org_id = old_image['id'].split('#')
|
*_, org_id = old_image['id'].split('#')
|
||||||
# Key pattern `MONTH#{month}#SCHEDULE#SEND_REPORT_EMAIL`
|
# Key pattern `MONTH#{month}#SCHEDULE#SEND_REPORT_EMAIL`
|
||||||
_, month, *_ = old_image['sk'].split('#')
|
_, month, *_ = old_image['sk'].split('#')
|
||||||
|
|||||||
@@ -22,7 +22,6 @@ Globals:
|
|||||||
Function:
|
Function:
|
||||||
CodeUri: app/
|
CodeUri: app/
|
||||||
Runtime: python3.13
|
Runtime: python3.13
|
||||||
Tracing: Active
|
|
||||||
Architectures:
|
Architectures:
|
||||||
- x86_64
|
- x86_64
|
||||||
Layers:
|
Layers:
|
||||||
@@ -312,7 +311,8 @@ Resources:
|
|||||||
Type: AWS::Serverless::Function
|
Type: AWS::Serverless::Function
|
||||||
Properties:
|
Properties:
|
||||||
Handler: events.issue_cert.lambda_handler
|
Handler: events.issue_cert.lambda_handler
|
||||||
# Timeout: 30
|
Tracing: Active
|
||||||
|
Timeout: 30
|
||||||
LoggingConfig:
|
LoggingConfig:
|
||||||
LogGroup: !Ref EventLog
|
LogGroup: !Ref EventLog
|
||||||
Policies:
|
Policies:
|
||||||
@@ -391,6 +391,6 @@ Resources:
|
|||||||
detail:
|
detail:
|
||||||
keys:
|
keys:
|
||||||
id:
|
id:
|
||||||
- prefix: CERT#REPORTING#ORG
|
- prefix: CERT_REPORTING#ORG
|
||||||
sk:
|
sk:
|
||||||
- suffix: SCHEDULE#SEND_REPORT_EMAIL
|
- suffix: SCHEDULE#SEND_REPORT_EMAIL
|
||||||
|
|||||||
@@ -45,7 +45,7 @@ def test_append_cert(
|
|||||||
)
|
)
|
||||||
|
|
||||||
r = dynamodb_persistence_layer.collection.get_items(
|
r = dynamodb_persistence_layer.collection.get_items(
|
||||||
TransactKey('CERT#REPORTING#ORG#1e2eaf0e-e319-49eb-ab33-1ddec156dc94')
|
TransactKey('CERT_REPORTING#ORG#1e2eaf0e-e319-49eb-ab33-1ddec156dc94')
|
||||||
+ SortKey(
|
+ SortKey(
|
||||||
sk=report_sk,
|
sk=report_sk,
|
||||||
rename_key='report_email',
|
rename_key='report_email',
|
||||||
@@ -61,3 +61,31 @@ def test_append_cert(
|
|||||||
|
|
||||||
assert 'course' in r['enrollment']
|
assert 'course' in r['enrollment']
|
||||||
assert 'ttl' in r['report_email']
|
assert 'ttl' in r['report_email']
|
||||||
|
|
||||||
|
|
||||||
|
def test_report_exists(
|
||||||
|
seeds,
|
||||||
|
dynamodb_persistence_layer: DynamoDBPersistenceLayer,
|
||||||
|
lambda_context: LambdaContext,
|
||||||
|
):
|
||||||
|
event = {
|
||||||
|
'detail': {
|
||||||
|
'new_image': {
|
||||||
|
'id': 'e45019d8-be7a-4a82-9b37-12a01f0127bb',
|
||||||
|
'sk': '0',
|
||||||
|
'course': {
|
||||||
|
'id': '431',
|
||||||
|
'name': 'How to Sing Better',
|
||||||
|
},
|
||||||
|
'cert_expires_at': '2025-07-02T00:00:00-03:06',
|
||||||
|
'user': {
|
||||||
|
'id': '1234',
|
||||||
|
'name': 'Tobias Summit',
|
||||||
|
},
|
||||||
|
'org_id': '00237409-9384-4692-9be5-b4443a41e1c4',
|
||||||
|
'created_at': '2025-01-01T00:00:00-03:06',
|
||||||
|
'completed_at': '2025-01-10T00:00:00-03:06',
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
assert app.lambda_handler(event, lambda_context) # type: ignore
|
||||||
|
|||||||
@@ -12,7 +12,7 @@ def test_send_report_email(
|
|||||||
dynamodb_persistence_layer: DynamoDBPersistenceLayer,
|
dynamodb_persistence_layer: DynamoDBPersistenceLayer,
|
||||||
lambda_context: LambdaContext,
|
lambda_context: LambdaContext,
|
||||||
):
|
):
|
||||||
pk = 'CERT#REPORTING#ORG#00237409-9384-4692-9be5-b4443a41e1c4'
|
pk = 'CERT_REPORTING#ORG#00237409-9384-4692-9be5-b4443a41e1c4'
|
||||||
event = {
|
event = {
|
||||||
'detail': {
|
'detail': {
|
||||||
'old_image': {
|
'old_image': {
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import app.events.enroll as app
|
import app.events.enroll as app
|
||||||
from aws_lambda_powertools.utilities.typing import LambdaContext
|
from aws_lambda_powertools.utilities.typing import LambdaContext
|
||||||
from layercake.dynamodb import DynamoDBPersistenceLayer, KeyPair
|
from layercake.dynamodb import DynamoDBPersistenceLayer, KeyPair, PartitionKey
|
||||||
|
|
||||||
|
|
||||||
def test_enroll(
|
def test_enroll(
|
||||||
@@ -30,3 +30,6 @@ def test_enroll(
|
|||||||
KeyPair(enrollment_id, f'LINKED_ENTITIES#PARENT#ORDER#{order_id}'),
|
KeyPair(enrollment_id, f'LINKED_ENTITIES#PARENT#ORDER#{order_id}'),
|
||||||
)
|
)
|
||||||
assert enrollment
|
assert enrollment
|
||||||
|
|
||||||
|
r = dynamodb_persistence_layer.collection.query(PartitionKey(enrollment['id']))
|
||||||
|
assert not any(x['sk'] == 'METADATA#DEDUPLICATION_WINDOW' for x in r['items'])
|
||||||
|
|||||||
@@ -3,7 +3,7 @@ from aws_lambda_powertools.utilities.typing import LambdaContext
|
|||||||
from layercake.dynamodb import DynamoDBPersistenceLayer, KeyPair
|
from layercake.dynamodb import DynamoDBPersistenceLayer, KeyPair
|
||||||
|
|
||||||
|
|
||||||
def test_reenroll(
|
def test_reenroll_custom_dedup_window(
|
||||||
seeds,
|
seeds,
|
||||||
dynamodb_persistence_layer: DynamoDBPersistenceLayer,
|
dynamodb_persistence_layer: DynamoDBPersistenceLayer,
|
||||||
lambda_context: LambdaContext,
|
lambda_context: LambdaContext,
|
||||||
@@ -46,3 +46,8 @@ def test_reenroll(
|
|||||||
)
|
)
|
||||||
)
|
)
|
||||||
assert child
|
assert child
|
||||||
|
|
||||||
|
dedup_window = dynamodb_persistence_layer.collection.get_item(
|
||||||
|
KeyPair(child_id, 'METADATA#DEDUPLICATION_WINDOW')
|
||||||
|
)
|
||||||
|
assert dedup_window
|
||||||
|
|||||||
@@ -38,8 +38,8 @@
|
|||||||
{"id": "294e9864-8284-4287-b153-927b15d90900", "sk": "tenant", "org_id": "123", "name": "EDUSEG", "create_date": "2025-09-12T17:11:00.556907-03:00"}
|
{"id": "294e9864-8284-4287-b153-927b15d90900", "sk": "tenant", "org_id": "123", "name": "EDUSEG", "create_date": "2025-09-12T17:11:00.556907-03:00"}
|
||||||
|
|
||||||
// Certificate reporting
|
// Certificate reporting
|
||||||
{"id": "CERT#REPORTING#ORG#00237409-9384-4692-9be5-b4443a41e1c4", "sk": "MONTH#2025-06", "status": "PENDING"}
|
{"id": "CERT_REPORTING#ORG#00237409-9384-4692-9be5-b4443a41e1c4", "sk": "MONTH#2025-06", "status": "PENDING"}
|
||||||
{"id": "CERT#REPORTING#ORG#00237409-9384-4692-9be5-b4443a41e1c4", "sk": "MONTH#2025-07#ENROLLMENT#ba4d48e6-3671-4060-988a-d6cf97dd0ea4", "completed_at": "2025-01-10T00:00:00-03:06", "enrolled_at": "2025-01-01T00:00:00-03:06", "expires_at": "2026-02-10T20:14:42.880991", "course": {"name": "How to Sing Better", "id": "431"}, "created_at": "2025-10-11T23:39:12.194344-03:00", "user": {"name": "Tobias Summit", "id": "1234"}, "enrollment_id": "e45019d8-be7a-4a82-9b37-12a01f0127bb"}
|
{"id": "CERT_REPORTING#ORG#00237409-9384-4692-9be5-b4443a41e1c4", "sk": "MONTH#2025-07#ENROLLMENT#ba4d48e6-3671-4060-988a-d6cf97dd0ea4", "completed_at": "2025-01-10T00:00:00-03:06", "enrolled_at": "2025-01-01T00:00:00-03:06", "expires_at": "2026-02-10T20:14:42.880991", "course": {"name": "How to Sing Better", "id": "431"}, "created_at": "2025-10-11T23:39:12.194344-03:00", "user": {"name": "Tobias Summit", "id": "1234"}, "enrollment_id": "e45019d8-be7a-4a82-9b37-12a01f0127bb"}
|
||||||
|
|
||||||
// Org
|
// Org
|
||||||
{"id": "1e2eaf0e-e319-49eb-ab33-1ddec156dc94", "sk": "0", "name": "pytest"}
|
{"id": "1e2eaf0e-e319-49eb-ab33-1ddec156dc94", "sk": "0", "name": "pytest"}
|
||||||
|
|||||||
@@ -8,7 +8,7 @@ Globals:
|
|||||||
Architectures:
|
Architectures:
|
||||||
- x86_64
|
- x86_64
|
||||||
Layers:
|
Layers:
|
||||||
- !Sub arn:aws:lambda:sa-east-1:336641857101:layer:layercake:96
|
- !Sub arn:aws:lambda:sa-east-1:336641857101:layer:layercake:99
|
||||||
Environment:
|
Environment:
|
||||||
Variables:
|
Variables:
|
||||||
LOG_LEVEL: DEBUG
|
LOG_LEVEL: DEBUG
|
||||||
|
|||||||
Reference in New Issue
Block a user