fix enroll and reenroll relationship

This commit is contained in:
2025-10-14 18:11:24 -03:00
parent 466ff824dd
commit a7e5a0a528
20 changed files with 125 additions and 89 deletions

View File

@@ -1,4 +1,7 @@
from abc import ABC
from dataclasses import dataclass
from datetime import timedelta
from enum import Enum
from typing import NotRequired, Self, TypedDict
from layercake.dateutils import now, ttl
@@ -32,15 +35,16 @@ Subscription = TypedDict(
)
class LinkedEntity(str):
def __new__(cls, id: str, type: str) -> Self:
return super().__new__(cls, '#'.join([type.lower(), id]))
class Kind(str, Enum):
ORDER = 'ORDER'
ENROLLMENT = 'ENROLLMENT'
def __init__(self, id: str, type: str) -> None:
# __init__ is used to store the parameters for later reference.
# For immutable types like str, __init__ cannot change the instance's value.
self.id = id
self.type = type
@dataclass(frozen=True)
class LinkedEntity(ABC):
id: str
kind: Kind
table_name: str | None = None
class DeduplicationConflictError(Exception):
@@ -76,29 +80,27 @@ def enroll(
)
# Relationships between this enrollment and its related entities
for parent_entity in linked_entities:
perent_id = parent_entity.id
entity_sk = f'LINKED_ENTITIES#{parent_entity.type}'
keyprefix = parent_entity.type.lower()
for entity in linked_entities:
# Parent knows the child
transact.put(
item={
'id': perent_id,
'sk': f'{entity_sk}#CHILD',
'id': entity.id,
'sk': f'LINKED_ENTITIES#{entity.kind.value}#CHILD',
'created_at': now_,
f'{keyprefix}_id': enrollment.id,
'enrollment_id': enrollment.id,
},
cond_expr='attribute_not_exists(sk)',
table_name=entity.table_name,
)
keyprefix = entity.kind.value.lower()
# Child knows the parent
transact.put(
item={
'id': enrollment.id,
'sk': f'{entity_sk}#PARENT',
'sk': f'LINKED_ENTITIES#{entity.kind.value}#PARENT',
'created_at': now_,
f'{keyprefix}_id': perent_id,
f'{keyprefix}_id': entity.id,
},
cond_expr='attribute_not_exists(sk)',
)

View File

@@ -81,6 +81,7 @@ def lambda_handler(event: EventBridgeEvent, context: LambdaContext) -> bool:
class Course:
id: str
name: str
access_period: int
def _get_courses(ids: set) -> tuple[Course, ...]:
@@ -93,6 +94,7 @@ def _get_courses(ids: set) -> tuple[Course, ...]:
Course(
id=idx,
name=obj['name'],
access_period=obj['access_period'],
)
for idx, obj in result.items()
)

View File

@@ -18,7 +18,11 @@ from layercake.dynamodb import (
from boto3clients import dynamodb_client
from config import COURSE_TABLE, ENROLLMENT_TABLE, ORDER_TABLE
from enrollment import LinkedEntity, enroll
from enrollment import (
Kind,
LinkedEntity,
enroll,
)
from schemas import Course, Enrollment, User
logger = Logger(__name__)
@@ -89,7 +93,15 @@ def _handler(record: Course, context: dict) -> Enrollment:
enrollment,
persistence_layer=enrollment_layer,
deduplication_window={'offset_days': 90},
linked_entities=frozenset({LinkedEntity(context['order_id'], 'ORDER')}),
linked_entities=frozenset(
{
LinkedEntity(
id=context['order_id'],
kind=Kind.ORDER,
table_name=ORDER_TABLE,
),
}
),
)
return enrollment

View File

@@ -56,6 +56,9 @@ def lambda_handler(event: EventBridgeEvent, context: LambdaContext) -> bool:
)
try:
if 's3_uri' not in cert:
raise ValueError('Template URI is missing')
# Send template URI and data to Paperforge API to generate a PDF
r = requests.post(
PAPERFORGE_API,
@@ -79,10 +82,11 @@ def lambda_handler(event: EventBridgeEvent, context: LambdaContext) -> bool:
),
},
),
timeout=5,
)
r.raise_for_status()
object_key = f'issuedcerts/{enrollment_id}.pdf'
object_key = f'certs/{enrollment_id}.pdf'
s3_uri = f's3://{BUCKET_NAME}/{object_key}'
s3_client.put_object(
@@ -93,10 +97,10 @@ def lambda_handler(event: EventBridgeEvent, context: LambdaContext) -> bool:
)
logger.debug(f'PDF uploaded successfully to {s3_uri}')
except KeyError:
except ValueError as exc:
# PDF generation fails if template URI is missing
s3_uri = None
logger.debug('Template URI is missing')
logger.exception(exc)
except requests.exceptions.RequestException as exc:
logger.exception(exc)
raise

View File

@@ -10,7 +10,7 @@ from layercake.dynamodb import DynamoDBPersistenceLayer, SortKey, TransactKey
from boto3clients import dynamodb_client
from config import ENROLLMENT_TABLE
from enrollment import LinkedEntity, enroll
from enrollment import Kind, LinkedEntity, enroll
from schemas import Course, Enrollment, User
logger = Logger(__name__)
@@ -52,7 +52,12 @@ def lambda_handler(event: EventBridgeEvent, context: LambdaContext) -> bool:
'offset_days': metadata['dedup_window_offset_days'],
},
linked_entities=frozenset(
{LinkedEntity(new_image['id'], 'ENROLLMENT')},
{
LinkedEntity(
id=new_image['id'],
kind=Kind.ENROLLMENT,
),
},
),
persistence_layer=dyn,
)

View File

@@ -29,8 +29,7 @@ def lambda_handler(event: EventBridgeEvent, context: LambdaContext) -> bool:
pk=old_image['id'],
sk='0',
),
update_expr='SET access_expired = :true, \
updated_at = :now',
update_expr='SET access_expired = :true, updated_at = :now',
expr_attr_values={
':true': True,
':now': now_,

View File

@@ -308,6 +308,7 @@ Resources:
Type: AWS::Serverless::Function
Properties:
Handler: events.issue_cert.lambda_handler
# Timeout: 30
LoggingConfig:
LogGroup: !Ref EventLog
Policies:
@@ -331,10 +332,10 @@ Resources:
old_image:
status: [IN_PROGRESS]
EventCertReportingAppendCertFunction:
EventReportingAppendCertFunction:
Type: AWS::Serverless::Function
Properties:
Handler: events.cert_reporting.append_cert.lambda_handler
Handler: events.reporting.append_cert.lambda_handler
LoggingConfig:
LogGroup: !Ref EventLog
Policies:
@@ -346,23 +347,19 @@ Resources:
Properties:
Pattern:
resources: [!Ref EnrollmentTable]
detail-type: [MODIFY]
detail:
keys:
sk: ["0"]
new_image:
status: [COMPLETED]
cert:
exists: true
org_id:
exists: true
old_image:
cert:
exists: false
- exists: true
EventCertReportingSendReportEmailFunction:
EventReportingSendReportEmailFunction:
Type: AWS::Serverless::Function
Properties:
Handler: events.cert_reporting.send_report_email.lambda_handler
Handler: events.reporting.send_report_email.lambda_handler
LoggingConfig:
LogGroup: !Ref EventLog
Policies:

View File

@@ -20,6 +20,7 @@ def pytest_configure():
os.environ['ORDER_TABLE'] = PYTEST_TABLE_NAME
os.environ['ENROLLMENT_TABLE'] = PYTEST_TABLE_NAME
os.environ['BUCKET_NAME'] = 'saladeaula.digital'
os.environ['LOG_LEVEL'] = 'DEBUG'
@dataclass

View File

@@ -1,6 +1,6 @@
from datetime import timedelta
import app.events.cert_reporting.append_cert as app
import app.events.reporting.append_cert as app
from aws_lambda_powertools.utilities.typing import LambdaContext
from layercake.dateutils import now
from layercake.dynamodb import (

View File

@@ -1,4 +1,4 @@
import app.events.cert_reporting.send_report_email as app
import app.events.reporting.send_report_email as app
from aws_lambda_powertools.utilities.typing import LambdaContext
from layercake.dynamodb import (
DynamoDBPersistenceLayer,

View File

@@ -1,6 +1,6 @@
import app.events.enroll as app
from aws_lambda_powertools.utilities.typing import LambdaContext
from layercake.dynamodb import DynamoDBPersistenceLayer
from layercake.dynamodb import DynamoDBPersistenceLayer, KeyPair
def test_enroll(
@@ -8,12 +8,26 @@ def test_enroll(
dynamodb_persistence_layer: DynamoDBPersistenceLayer,
lambda_context: LambdaContext,
):
order_id = 'cpYSbBcie2NDbZhDKCxCih'
event = {
'detail': {
'new_image': {
'id': 'cpYSbBcie2NDbZhDKCxCih',
'id': order_id,
'sk': 'generated_items',
}
}
}
assert app.lambda_handler(event, lambda_context) # type: ignore
# Parent knows the child
order_entity = dynamodb_persistence_layer.collection.get_item(
KeyPair(order_id, 'LINKED_ENTITIES#ORDER#CHILD')
)
assert order_entity
# Child knows the parent
enrollment_entity = dynamodb_persistence_layer.collection.get_item(
KeyPair(order_entity['enrollment_id'], 'LINKED_ENTITIES#ORDER#PARENT'),
)
assert enrollment_entity['order_id'] == order_id

View File

@@ -30,19 +30,19 @@ def test_reenroll(
assert app.lambda_handler(event, lambda_context) # type: ignore
# Parent knows the child
child_entity = dynamodb_persistence_layer.collection.get_item(
current_entity = dynamodb_persistence_layer.collection.get_item(
KeyPair(
pk=parent_id,
sk='LINKED_ENTITIES#ENROLLMENT#CHILD',
)
)
assert child_entity
assert current_entity
# Child knows the parent
parent_entity = dynamodb_persistence_layer.collection.get_item(
new_entity = dynamodb_persistence_layer.collection.get_item(
KeyPair(
pk=child_entity['enrollment_id'],
pk=current_entity['enrollment_id'],
sk='LINKED_ENTITIES#ENROLLMENT#PARENT',
)
)
assert parent_entity['enrollment_id'] == parent_id
assert new_entity['enrollment_id'] == parent_id

View File

@@ -1,4 +1,5 @@
import urllib.parse as parse
from os import rename
from aws_lambda_powertools.event_handler.api_gateway import Router
from layercake.dynamodb import (
@@ -73,16 +74,16 @@ def get_enrollment(id: str):
record = enrollment_layer.collection.get_items(
TransactKey(id)
+ SortKey('0')
+ SortKey('ORG', rename_key='org')
# + SortKey('STARTED', rename_key='started_at', path_spec='started_at')
# + SortKey('COMPLETED', rename_key='completed_at', path_spec='completed_at')
# + SortKey('FAILED', rename_key='failed_at', path_spec='failed_at')
+ SortKey('CANCELED', rename_key='canceled')
+ SortKey('ARCHIVED', rename_key='archived_at', path_spec='archived_at')
# + SortKey('ARCHIVED', rename_key='archived_at', path_spec='archived_at')
+ SortKey('CANCEL_POLICY', rename_key='cancel_policy')
+ SortKey('LOCK', rename_key='lock')
+ SortKey('parent_vacancy', path_spec='vacancy')
+ SortKey('author')
+ SortKey('tenant')
)
events = enrollment_layer.collection.query(KeyPair(id, 'SCHEDULE#'))

View File

@@ -14,7 +14,7 @@ enrollment_layer = DynamoDBPersistenceLayer(ENROLLMENT_TABLE, dynamodb_client)
def download(id: str):
params = {
'Bucket': BUCKET_NAME,
'Key': f'issuedcerts/{id}.pdf',
'Key': f'certs/{id}.pdf',
'ResponseContentDisposition': f'attachment; filename="{id}.pdf"',
}

View File

@@ -55,7 +55,7 @@ def update_progress(
key=KeyPair(id, '0'),
update_expr='SET progress = :progress, \
#status = :in_progress, \
started_at = :now, \
started_at = if_not_exists(started_at, :now), \
updated_at = :now',
cond_expr='#status = :pending',
expr_attr_names={
@@ -69,15 +69,6 @@ def update_progress(
},
exc_cls=EnrollmentConflictError,
)
# Record the start date if it does not already exist
transact.put(
item={
'id': id,
'sk': 'STARTED',
'created_at': now_,
},
cond_expr='attribute_not_exists(sk)',
)
# Schedule a reminder for inactivity
transact.put(
item={
@@ -192,7 +183,7 @@ def _set_status_as_completed(
update_expr='SET #status = :completed, \
progress = :progress, \
score = :score, \
completed_at = :now, \
completed_at = if_not_exists(completed_at, :now), \
updated_at = :now',
cond_expr='#status = :in_progress',
expr_attr_names={'#status': 'status'},
@@ -205,14 +196,6 @@ def _set_status_as_completed(
},
exc_cls=EnrollmentConflictError,
)
transact.put(
item={
'id': id,
'sk': 'COMPLETED',
'created_at': now_,
},
cond_expr='attribute_not_exists(sk)',
)
if cert_exp_interval:
transact.put(
@@ -293,7 +276,7 @@ def _set_status_as_failed(
progress = :progress, \
score = :score, \
access_expired = :true, \
failed_at = :now, \
failed_at = if_not_exists(failed_at, :now), \
updated_at = :now',
cond_expr='#status = :in_progress',
expr_attr_names={'#status': 'status'},

View File

@@ -33,12 +33,13 @@ def test_start_progress(
r = dynamodb_persistence_layer.collection.query(
PartitionKey('d9da85f2-e09f-472d-9515-3d91d70f1e8a')
)
assert any(item.get('sk') == 'STARTED' for item in r['items'])
docx = next((x for x in r['items'] if x['sk'] == '0'), {})
assert 'started_at' in docx
assert any(
item.get('sk') == 'SCHEDULE#REMINDER_NO_ACTIVITY_AFTER_7_DAYS'
for item in r['items']
)
assert len(r['items']) == 3
assert len(r['items']) == 2
def test_update_progress(
@@ -110,8 +111,9 @@ def test_set_as_completed(
PartitionKey('6c7e3d9b-f5d1-4da4-9e55-0825bb6ff2b8')
)
assert len(r['items']) == 8
assert any(item.get('sk') == 'COMPLETED' for item in r['items'])
docx = next((x for x in r['items'] if x['sk'] == '0'), {})
assert 'completed_at' in docx
assert len(r['items']) == 7
assert any(item.get('sk') == 'LOCK' for item in r['items'])
assert any(
item.get('sk') == 'SCHEDULE#REMINDER_CERT_EXPIRATION_BEFORE_30_DAYS'

46
konviva-events/uv.lock generated
View File

@@ -31,14 +31,14 @@ wheels = [
[[package]]
name = "authlib"
version = "1.6.1"
version = "1.6.5"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "cryptography" },
]
sdist = { url = "https://files.pythonhosted.org/packages/8e/a1/d8d1c6f8bc922c0b87ae0d933a8ed57be1bef6970894ed79c2852a153cd3/authlib-1.6.1.tar.gz", hash = "sha256:4dffdbb1460ba6ec8c17981a4c67af7d8af131231b5a36a88a1e8c80c111cdfd", size = 159988, upload-time = "2025-07-20T07:38:42.834Z" }
sdist = { url = "https://files.pythonhosted.org/packages/cd/3f/1d3bbd0bf23bdd99276d4def22f29c27a914067b4cf66f753ff9b8bbd0f3/authlib-1.6.5.tar.gz", hash = "sha256:6aaf9c79b7cc96c900f0b284061691c5d4e61221640a948fe690b556a6d6d10b", size = 164553, upload-time = "2025-10-02T13:36:09.489Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/f9/58/cc6a08053f822f98f334d38a27687b69c6655fb05cd74a7a5e70a2aeed95/authlib-1.6.1-py2.py3-none-any.whl", hash = "sha256:e9d2031c34c6309373ab845afc24168fe9e93dc52d252631f52642f21f5ed06e", size = 239299, upload-time = "2025-07-20T07:38:39.259Z" },
{ url = "https://files.pythonhosted.org/packages/f8/aa/5082412d1ee302e9e7d80b6949bc4d2a8fa1149aaab610c5fc24709605d6/authlib-1.6.5-py2.py3-none-any.whl", hash = "sha256:3e0e0507807f842b02175507bdee8957a1d5707fd4afb17c32fb43fee90b6e3a", size = 243608, upload-time = "2025-10-02T13:36:07.637Z" },
]
[[package]]
@@ -430,6 +430,18 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/31/b4/b9b800c45527aadd64d5b442f9b932b00648617eb5d63d2c7a6587b7cafc/jmespath-1.0.1-py3-none-any.whl", hash = "sha256:02e2e4cc71b5bcab88332eebf907519190dd9e6e82107fa7f83b1003a6252980", size = 20256, upload-time = "2022-06-17T18:00:10.251Z" },
]
[[package]]
name = "joserfc"
version = "1.4.0"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "cryptography" },
]
sdist = { url = "https://files.pythonhosted.org/packages/26/a0/4b8dfecc8ec3c15aa1f2ff7d5b947344378b5b595ce37c8a8fe6e25c1400/joserfc-1.4.0.tar.gz", hash = "sha256:e8c2f327bf10a937d284d57e9f8aec385381e5e5850469b50a7dade1aba59759", size = 196339, upload-time = "2025-10-09T07:47:00.835Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/55/05/342459b7629c6fcb5f99a646886ee2904491955b8cce6b26b0b9a498f67c/joserfc-1.4.0-py3-none-any.whl", hash = "sha256:46917e6b53f1ec0c7e20d34d6f3e6c27da0fa43d0d4ebfb89aada7c86582933a", size = 66390, upload-time = "2025-10-09T07:46:59.591Z" },
]
[[package]]
name = "jsonlines"
version = "4.0.0"
@@ -485,7 +497,7 @@ dev = [
[[package]]
name = "layercake"
version = "0.9.14"
version = "0.10.1"
source = { directory = "../layercake" }
dependencies = [
{ name = "arnparse" },
@@ -494,6 +506,7 @@ dependencies = [
{ name = "dictdiffer" },
{ name = "ftfy" },
{ name = "glom" },
{ name = "joserfc" },
{ name = "meilisearch" },
{ name = "orjson" },
{ name = "passlib" },
@@ -501,7 +514,7 @@ dependencies = [
{ name = "pycpfcnpj" },
{ name = "pydantic", extra = ["email"] },
{ name = "pydantic-extra-types" },
{ name = "pyjwt" },
{ name = "python-multipart" },
{ name = "pytz" },
{ name = "requests" },
{ name = "smart-open", extra = ["s3"] },
@@ -512,11 +525,12 @@ dependencies = [
[package.metadata]
requires-dist = [
{ name = "arnparse", specifier = ">=0.0.2" },
{ name = "authlib", specifier = ">=1.6.1" },
{ name = "authlib", specifier = ">=1.6.5" },
{ name = "aws-lambda-powertools", extras = ["all"], specifier = ">=3.18.0" },
{ name = "dictdiffer", specifier = ">=0.9.0" },
{ name = "ftfy", specifier = ">=6.3.1" },
{ name = "glom", specifier = ">=24.11.0" },
{ name = "joserfc", specifier = ">=1.2.2" },
{ name = "meilisearch", specifier = ">=0.34.0" },
{ name = "orjson", specifier = ">=3.10.15" },
{ name = "passlib", specifier = ">=1.7.4" },
@@ -524,7 +538,7 @@ requires-dist = [
{ name = "pycpfcnpj", specifier = ">=1.8" },
{ name = "pydantic", extras = ["email"], specifier = ">=2.10.6" },
{ name = "pydantic-extra-types", specifier = ">=2.10.3" },
{ name = "pyjwt", specifier = ">=2.10.1" },
{ name = "python-multipart", specifier = ">=0.0.20" },
{ name = "pytz", specifier = ">=2025.1" },
{ name = "requests", specifier = ">=2.32.3" },
{ name = "smart-open", extras = ["s3"], specifier = ">=7.1.0" },
@@ -761,15 +775,6 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/c7/21/705964c7812476f378728bdf590ca4b771ec72385c533964653c68e86bdc/pygments-2.19.2-py3-none-any.whl", hash = "sha256:86540386c03d588bb81d44bc3928634ff26449851e99741617ecb9037ee5ec0b", size = 1225217, upload-time = "2025-06-21T13:39:07.939Z" },
]
[[package]]
name = "pyjwt"
version = "2.10.1"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/e7/46/bd74733ff231675599650d3e47f361794b22ef3e3770998dda30d3b63726/pyjwt-2.10.1.tar.gz", hash = "sha256:3cc5772eb20009233caf06e9d8a0577824723b44e6648ee0a2aedb6cf9381953", size = 87785, upload-time = "2024-11-28T03:43:29.933Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/61/ad/689f02752eeec26aed679477e80e632ef1b682313be70793d798c1d5fc8f/PyJWT-2.10.1-py3-none-any.whl", hash = "sha256:dcdd193e30abefd5debf142f9adfcdd2b58004e644f25406ffaebd50bd98dacb", size = 22997, upload-time = "2024-11-28T03:43:27.893Z" },
]
[[package]]
name = "pytest"
version = "8.4.1"
@@ -821,6 +826,15 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/5f/ed/539768cf28c661b5b068d66d96a2f155c4971a5d55684a514c1a0e0dec2f/python_dotenv-1.1.1-py3-none-any.whl", hash = "sha256:31f23644fe2602f88ff55e1f5c79ba497e01224ee7737937930c448e4d0e24dc", size = 20556, upload-time = "2025-06-24T04:21:06.073Z" },
]
[[package]]
name = "python-multipart"
version = "0.0.20"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/f3/87/f44d7c9f274c7ee665a29b885ec97089ec5dc034c7f3fafa03da9e39a09e/python_multipart-0.0.20.tar.gz", hash = "sha256:8dd0cab45b8e23064ae09147625994d090fa46f5b0d1e13af944c331a7fa9d13", size = 37158, upload-time = "2024-12-16T19:45:46.972Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/45/58/38b5afbc1a800eeea951b9285d3912613f2603bdf897a4ab0f4bd7f405fc/python_multipart-0.0.20-py3-none-any.whl", hash = "sha256:8a62d3a8335e06589fe01f2a3e178cdcc632f3fbe0d492ad9ee0ec35aab1f104", size = 24546, upload-time = "2024-12-16T19:45:44.423Z" },
]
[[package]]
name = "pytz"
version = "2025.2"