update enrollment
This commit is contained in:
@@ -71,5 +71,5 @@ def send_email(
|
||||
)
|
||||
|
||||
return False
|
||||
|
||||
else:
|
||||
return True
|
||||
|
||||
73
enrollments-events/app/events/set_as_archived.py
Normal file
73
enrollments-events/app/events/set_as_archived.py
Normal file
@@ -0,0 +1,73 @@
|
||||
from aws_lambda_powertools import Logger
|
||||
from aws_lambda_powertools.utilities.data_classes import (
|
||||
EventBridgeEvent,
|
||||
event_source,
|
||||
)
|
||||
from aws_lambda_powertools.utilities.typing import LambdaContext
|
||||
from layercake.dateutils import now
|
||||
from layercake.dynamodb import DynamoDBPersistenceLayer, KeyPair
|
||||
|
||||
from boto3clients import dynamodb_client
|
||||
from config import (
|
||||
ENROLLMENT_TABLE,
|
||||
)
|
||||
|
||||
logger = Logger(__name__)
|
||||
dyn = DynamoDBPersistenceLayer(ENROLLMENT_TABLE, dynamodb_client)
|
||||
|
||||
|
||||
@event_source(data_class=EventBridgeEvent)
|
||||
@logger.inject_lambda_context
|
||||
def lambda_handler(event: EventBridgeEvent, context: LambdaContext) -> bool:
|
||||
"""After the certificate expires, the enrollment will be marked as archived."""
|
||||
old_image = event.detail['old_image']
|
||||
now_ = now()
|
||||
|
||||
try:
|
||||
with dyn.transact_writer() as transact:
|
||||
transact.update(
|
||||
key=KeyPair(
|
||||
pk=old_image['id'],
|
||||
sk='0',
|
||||
),
|
||||
update_expr='SET #status = :archived, updated_at = :updated_at',
|
||||
cond_expr='#status = :completed',
|
||||
expr_attr_names={
|
||||
'#status': 'status',
|
||||
},
|
||||
expr_attr_values={
|
||||
':completed': 'COMPLETED',
|
||||
':archived': 'ARCHIVED',
|
||||
':updated_at': now_,
|
||||
},
|
||||
)
|
||||
transact.put(
|
||||
item={
|
||||
'id': old_image['id'],
|
||||
'sk': 'ARCHIVED',
|
||||
'archived_at': now_,
|
||||
},
|
||||
cond_expr='attribute_not_exists(sk)',
|
||||
)
|
||||
transact.put(
|
||||
item={
|
||||
'id': old_image['id'],
|
||||
'sk': 'SCHEDULE#SET_AS_ARCHIVED#EXECUTED',
|
||||
'created_at': now_,
|
||||
},
|
||||
)
|
||||
except Exception as exc:
|
||||
logger.exception(exc)
|
||||
|
||||
dyn.put_item(
|
||||
item={
|
||||
'id': old_image['id'],
|
||||
'sk': 'SCHEDULE#SET_AS_ARCHIVED#FAILED',
|
||||
'reason': str(exc),
|
||||
'created_at': now_,
|
||||
},
|
||||
)
|
||||
|
||||
return False
|
||||
else:
|
||||
return True
|
||||
75
enrollments-events/app/events/set_as_expired.py
Normal file
75
enrollments-events/app/events/set_as_expired.py
Normal file
@@ -0,0 +1,75 @@
|
||||
from aws_lambda_powertools import Logger
|
||||
from aws_lambda_powertools.utilities.data_classes import (
|
||||
EventBridgeEvent,
|
||||
event_source,
|
||||
)
|
||||
from aws_lambda_powertools.utilities.typing import LambdaContext
|
||||
from layercake.dateutils import now
|
||||
from layercake.dynamodb import DynamoDBPersistenceLayer, KeyPair
|
||||
|
||||
from boto3clients import dynamodb_client
|
||||
from config import (
|
||||
ENROLLMENT_TABLE,
|
||||
)
|
||||
|
||||
logger = Logger(__name__)
|
||||
dyn = DynamoDBPersistenceLayer(ENROLLMENT_TABLE, dynamodb_client)
|
||||
|
||||
|
||||
@event_source(data_class=EventBridgeEvent)
|
||||
@logger.inject_lambda_context
|
||||
def lambda_handler(event: EventBridgeEvent, context: LambdaContext) -> bool:
|
||||
"""If there is no certificate and the access period has ended,
|
||||
the enrollment will be marked as expired."""
|
||||
old_image = event.detail['old_image']
|
||||
now_ = now()
|
||||
|
||||
try:
|
||||
with dyn.transact_writer() as transact:
|
||||
transact.update(
|
||||
key=KeyPair(
|
||||
pk=old_image['id'],
|
||||
sk='0',
|
||||
),
|
||||
update_expr='SET #status = :expired, updated_at = :updated_at',
|
||||
cond_expr='#status = :in_progress OR #status = :pending',
|
||||
expr_attr_names={
|
||||
'#status': 'status',
|
||||
},
|
||||
expr_attr_values={
|
||||
':pending': 'PENDING',
|
||||
':in_progress': 'IN_PROGRESS',
|
||||
':expired': 'EXPIRED',
|
||||
':updated_at': now_,
|
||||
},
|
||||
)
|
||||
transact.put(
|
||||
item={
|
||||
'id': old_image['id'],
|
||||
'sk': 'EXPIRED',
|
||||
'expired_at': now_,
|
||||
},
|
||||
cond_expr='attribute_not_exists(sk)',
|
||||
)
|
||||
transact.put(
|
||||
item={
|
||||
'id': old_image['id'],
|
||||
'sk': 'SCHEDULE#SET_AS_EXPIRED#EXECUTED',
|
||||
'created_at': now_,
|
||||
},
|
||||
)
|
||||
except Exception as exc:
|
||||
logger.exception(exc)
|
||||
|
||||
dyn.put_item(
|
||||
item={
|
||||
'id': old_image['id'],
|
||||
'sk': 'SCHEDULE#SET_AS_EXPIRED#FAILED',
|
||||
'reason': str(exc),
|
||||
'created_at': now_,
|
||||
},
|
||||
)
|
||||
|
||||
return False
|
||||
else:
|
||||
return True
|
||||
@@ -23,7 +23,7 @@ Globals:
|
||||
Architectures:
|
||||
- x86_64
|
||||
Layers:
|
||||
- !Sub arn:aws:lambda:sa-east-1:336641857101:layer:layercake:94
|
||||
- !Sub arn:aws:lambda:sa-east-1:336641857101:layer:layercake:96
|
||||
Environment:
|
||||
Variables:
|
||||
TZ: America/Sao_Paulo
|
||||
|
||||
31
enrollments-events/tests/events/test_set_as_archived.py
Normal file
31
enrollments-events/tests/events/test_set_as_archived.py
Normal file
@@ -0,0 +1,31 @@
|
||||
import app.events.set_as_archived as app
|
||||
from aws_lambda_powertools.utilities.typing import LambdaContext
|
||||
from layercake.dynamodb import (
|
||||
DynamoDBPersistenceLayer,
|
||||
SortKey,
|
||||
TransactKey,
|
||||
)
|
||||
|
||||
|
||||
def test_set_as_archived(
|
||||
seeds,
|
||||
dynamodb_persistence_layer: DynamoDBPersistenceLayer,
|
||||
lambda_context: LambdaContext,
|
||||
):
|
||||
event = {
|
||||
'detail': {
|
||||
'old_image': {
|
||||
'id': '845fe390-e3c3-4514-97f8-c42de0566cf0',
|
||||
'sk': '0',
|
||||
}
|
||||
}
|
||||
}
|
||||
assert app.lambda_handler(event, lambda_context) # type: ignore
|
||||
|
||||
r = dynamodb_persistence_layer.collection.get_items(
|
||||
TransactKey('845fe390-e3c3-4514-97f8-c42de0566cf0')
|
||||
+ SortKey('0')
|
||||
+ SortKey('SCHEDULE#SET_AS_ARCHIVED#EXECUTED', rename_key='executed')
|
||||
)
|
||||
assert r['status'] == 'ARCHIVED'
|
||||
assert 'executed' in r
|
||||
55
enrollments-events/tests/events/test_set_as_expired.py
Normal file
55
enrollments-events/tests/events/test_set_as_expired.py
Normal file
@@ -0,0 +1,55 @@
|
||||
import app.events.set_as_expired as app
|
||||
from aws_lambda_powertools.utilities.typing import LambdaContext
|
||||
from layercake.dynamodb import (
|
||||
DynamoDBPersistenceLayer,
|
||||
SortKey,
|
||||
TransactKey,
|
||||
)
|
||||
|
||||
|
||||
def test_set_as_expired(
|
||||
seeds,
|
||||
dynamodb_persistence_layer: DynamoDBPersistenceLayer,
|
||||
lambda_context: LambdaContext,
|
||||
):
|
||||
event = {
|
||||
'detail': {
|
||||
'old_image': {
|
||||
'id': '6437a282-6fe8-4e4d-9eb0-da1007238007',
|
||||
'sk': '0',
|
||||
}
|
||||
}
|
||||
}
|
||||
assert app.lambda_handler(event, lambda_context) # type: ignore
|
||||
|
||||
r = dynamodb_persistence_layer.collection.get_items(
|
||||
TransactKey('6437a282-6fe8-4e4d-9eb0-da1007238007')
|
||||
+ SortKey('0')
|
||||
+ SortKey('SCHEDULE#SET_AS_EXPIRED#EXECUTED', rename_key='executed')
|
||||
)
|
||||
assert r['status'] == 'EXPIRED'
|
||||
assert 'executed' in r
|
||||
|
||||
|
||||
def test_set_as_expired_failed(
|
||||
seeds,
|
||||
dynamodb_persistence_layer: DynamoDBPersistenceLayer,
|
||||
lambda_context: LambdaContext,
|
||||
):
|
||||
event = {
|
||||
'detail': {
|
||||
'old_image': {
|
||||
'id': '845fe390-e3c3-4514-97f8-c42de0566cf0',
|
||||
'sk': '0',
|
||||
}
|
||||
}
|
||||
}
|
||||
assert not app.lambda_handler(event, lambda_context) # type: ignore
|
||||
|
||||
r = dynamodb_persistence_layer.collection.get_items(
|
||||
TransactKey('845fe390-e3c3-4514-97f8-c42de0566cf0')
|
||||
+ SortKey('0')
|
||||
+ SortKey('SCHEDULE#SET_AS_EXPIRED#FAILED', rename_key='failed')
|
||||
)
|
||||
assert r['status'] == 'COMPLETED'
|
||||
assert 'failed' in r
|
||||
55
konviva-events/app/app.py
Normal file
55
konviva-events/app/app.py
Normal file
@@ -0,0 +1,55 @@
|
||||
from decimal import Decimal
|
||||
from http import HTTPStatus
|
||||
from typing import Any
|
||||
|
||||
from aws_lambda_powertools import Logger, Tracer
|
||||
from aws_lambda_powertools.event_handler.api_gateway import (
|
||||
APIGatewayHttpResolver,
|
||||
Response,
|
||||
)
|
||||
from aws_lambda_powertools.logging import correlation_paths
|
||||
from aws_lambda_powertools.utilities.typing import LambdaContext
|
||||
from layercake.dynamodb import DynamoDBPersistenceLayer, KeyPair, SortKey
|
||||
|
||||
from boto3clients import dynamodb_client
|
||||
from config import ENROLLMENT_TABLE
|
||||
from enrollment import EnrollmentNotFoundError, set_score, update_progress
|
||||
|
||||
logger = Logger(__name__)
|
||||
tracer = Tracer()
|
||||
app = APIGatewayHttpResolver(enable_validation=True)
|
||||
dyn = DynamoDBPersistenceLayer(ENROLLMENT_TABLE, dynamodb_client)
|
||||
|
||||
|
||||
@app.post('/')
|
||||
@tracer.capture_method
|
||||
def update_enrollment():
|
||||
json_body = app.current_event.json_body
|
||||
|
||||
status = json_body['status']
|
||||
score = round(Decimal(json_body['APROVEITAMENTO']), 2)
|
||||
progress = round(Decimal(json_body['ANDAMENTO']), 2)
|
||||
enrollment_id = dyn.collection.get_item(
|
||||
KeyPair(
|
||||
pk='konviva',
|
||||
sk=SortKey(json_body['ID_MATRICULA'], path_spec='enrollment_id'),
|
||||
),
|
||||
exc_cls=EnrollmentNotFoundError,
|
||||
)
|
||||
|
||||
if status == 'IN_PROGRESS':
|
||||
update_progress(enrollment_id, progress, dynamodb_persistence_layer=dyn)
|
||||
|
||||
if status == 'COMPLETED':
|
||||
set_score(enrollment_id, score, dynamodb_persistence_layer=dyn)
|
||||
|
||||
return Response(status_code=HTTPStatus.NO_CONTENT)
|
||||
|
||||
|
||||
@logger.inject_lambda_context(correlation_id_path=correlation_paths.API_GATEWAY_HTTP)
|
||||
@tracer.capture_lambda_handler
|
||||
def lambda_handler(
|
||||
event: dict[str, Any],
|
||||
context: LambdaContext,
|
||||
) -> dict[str, Any]:
|
||||
return app.resolve(event, context)
|
||||
423
konviva-events/app/enrollment.py
Normal file
423
konviva-events/app/enrollment.py
Normal file
@@ -0,0 +1,423 @@
|
||||
from datetime import datetime, timedelta
|
||||
from decimal import Decimal
|
||||
|
||||
from aws_lambda_powertools.event_handler.exceptions import (
|
||||
BadRequestError,
|
||||
NotFoundError,
|
||||
)
|
||||
from botocore.args import logger
|
||||
from glom import glom
|
||||
from layercake.dateutils import fromisoformat, now, ttl
|
||||
from layercake.dynamodb import DynamoDBPersistenceLayer, KeyPair, SortKey, TransactKey
|
||||
from layercake.strutils import md5_hash
|
||||
|
||||
|
||||
def update_progress(
|
||||
id: str,
|
||||
progress: Decimal,
|
||||
*,
|
||||
dynamodb_persistence_layer: DynamoDBPersistenceLayer,
|
||||
):
|
||||
now_ = now()
|
||||
ttl_ = ttl(start_dt=now_, days=7)
|
||||
|
||||
try:
|
||||
with dynamodb_persistence_layer.transact_writer() as transact:
|
||||
# Update progress only if the enrollment status is `IN_PROGRESS`
|
||||
transact.update(
|
||||
key=KeyPair(id, '0'),
|
||||
update_expr='SET progress = :progress, updated_at = :updated_at',
|
||||
cond_expr='#status = :in_progress',
|
||||
expr_attr_names={
|
||||
'#status': 'status',
|
||||
},
|
||||
expr_attr_values={
|
||||
':in_progress': 'IN_PROGRESS',
|
||||
':progress': progress,
|
||||
':updated_at': now_,
|
||||
},
|
||||
exc_cls=EnrollmentConflictError,
|
||||
)
|
||||
# Schedule a reminder if there is no activity after 7 days
|
||||
transact.put(
|
||||
item={
|
||||
'id': id,
|
||||
'sk': 'SCHEDULE#REMINDER_NO_ACTIVITY_AFTER_7_DAYS',
|
||||
'ttl': ttl_,
|
||||
'created_at': now_,
|
||||
}
|
||||
)
|
||||
except EnrollmentConflictError:
|
||||
with dynamodb_persistence_layer.transact_writer() as transact:
|
||||
# If the enrollment status is `PENDING`, set it to `IN_PROGRESS`
|
||||
# and update progress and updated_at date
|
||||
transact.update(
|
||||
key=KeyPair(id, '0'),
|
||||
update_expr='SET progress = :progress, #status = :in_progress, \
|
||||
updated_at = :updated_at',
|
||||
cond_expr='#status = :pending',
|
||||
expr_attr_names={
|
||||
'#status': 'status',
|
||||
},
|
||||
expr_attr_values={
|
||||
':in_progress': 'IN_PROGRESS',
|
||||
':pending': 'PENDING',
|
||||
':progress': progress,
|
||||
':updated_at': now_,
|
||||
},
|
||||
)
|
||||
# Record the start date if it does not already exist
|
||||
transact.put(
|
||||
item={
|
||||
'id': id,
|
||||
'sk': 'STARTED',
|
||||
'started_at': now_,
|
||||
},
|
||||
cond_expr='attribute_not_exists(sk)',
|
||||
)
|
||||
# Schedule a reminder for inactivity
|
||||
transact.put(
|
||||
item={
|
||||
'id': id,
|
||||
'sk': 'SCHEDULE#REMINDER_NO_ACTIVITY_AFTER_7_DAYS',
|
||||
'ttl': ttl_,
|
||||
'created_at': now_,
|
||||
}
|
||||
)
|
||||
# Remove reminders and policies that no longer apply
|
||||
transact.delete(
|
||||
key=KeyPair(
|
||||
pk=id,
|
||||
sk='SCHEDULE#REMINDER_NO_ACCESS_AFTER_3_DAYS',
|
||||
)
|
||||
)
|
||||
transact.delete(
|
||||
key=KeyPair(pk=id, sk='CANCEL_POLICY'),
|
||||
)
|
||||
return True
|
||||
|
||||
|
||||
def set_score(
|
||||
id: str,
|
||||
score: Decimal,
|
||||
*,
|
||||
dynamodb_persistence_layer: DynamoDBPersistenceLayer,
|
||||
):
|
||||
enrollment = dynamodb_persistence_layer.collection.get_items(
|
||||
TransactKey(id)
|
||||
+ SortKey('0')
|
||||
+ SortKey(
|
||||
sk='METADATA#COURSE',
|
||||
rename_key='metadata__course',
|
||||
)
|
||||
+ SortKey(
|
||||
sk='METADATA#DEDUPLICATION_WINDOW',
|
||||
path_spec='offset_days',
|
||||
rename_key='dedup_window_offset_days',
|
||||
),
|
||||
)
|
||||
now_ = now()
|
||||
user_id = enrollment['user']['id']
|
||||
course_id = glom(enrollment, 'course.id')
|
||||
created_at: datetime = fromisoformat(enrollment['created_at']) # type: ignore
|
||||
access_period = created_at + timedelta(
|
||||
days=int(glom(enrollment, 'metadata__course.access_period'))
|
||||
)
|
||||
|
||||
try:
|
||||
match score >= 70, now_ > access_period:
|
||||
case True, True:
|
||||
# Got a score of 70 or higher, but the access period has expired
|
||||
return _set_status_as_archived(
|
||||
id,
|
||||
dynamodb_persistence_layer=dynamodb_persistence_layer,
|
||||
)
|
||||
case True, False:
|
||||
# Got a score of 70 or higher, and still within the access period
|
||||
return _set_status_as_completed(
|
||||
id,
|
||||
score,
|
||||
user_id=user_id,
|
||||
course_id=course_id,
|
||||
cert_exp_interval=int(
|
||||
glom(enrollment, 'metadata__course.cert.exp_interval')
|
||||
),
|
||||
dedup_window_offset_days=int(
|
||||
enrollment['dedup_window_offset_days']
|
||||
),
|
||||
dynamodb_persistence_layer=dynamodb_persistence_layer,
|
||||
)
|
||||
case False, True:
|
||||
# Got a score below 70, and the access period has expired
|
||||
return _set_status_as_expired(
|
||||
id,
|
||||
dynamodb_persistence_layer=dynamodb_persistence_layer,
|
||||
)
|
||||
case _:
|
||||
# Got a score below 70, and still within the access period
|
||||
return _set_status_as_failed(
|
||||
id,
|
||||
score,
|
||||
user_id=user_id,
|
||||
course_id=course_id,
|
||||
dynamodb_persistence_layer=dynamodb_persistence_layer,
|
||||
)
|
||||
except EnrollmentConflictError as err:
|
||||
logger.exception(err)
|
||||
raise
|
||||
|
||||
|
||||
def _set_status_as_completed(
|
||||
id: str,
|
||||
/,
|
||||
score: Decimal,
|
||||
*,
|
||||
user_id: str,
|
||||
course_id: str,
|
||||
cert_exp_interval: int,
|
||||
dedup_window_offset_days: int,
|
||||
dynamodb_persistence_layer: DynamoDBPersistenceLayer,
|
||||
) -> bool:
|
||||
now_ = now()
|
||||
lock_hash = md5_hash(f'{user_id}{course_id}')
|
||||
archive_ttl = ttl(
|
||||
start_dt=now_,
|
||||
days=cert_exp_interval,
|
||||
)
|
||||
cert_expiration_reminder_ttl = ttl(
|
||||
start_dt=now_,
|
||||
days=cert_exp_interval - 30,
|
||||
)
|
||||
deduplication_lock_ttl = ttl(
|
||||
start_dt=now_,
|
||||
days=cert_exp_interval - dedup_window_offset_days,
|
||||
)
|
||||
|
||||
with dynamodb_persistence_layer.transact_writer() as transact:
|
||||
transact.update(
|
||||
key=KeyPair(pk=id, sk='0'),
|
||||
update_expr='SET #status = :completed, score = :score, \
|
||||
updated_at = :updated_at',
|
||||
cond_expr='#status = :in_progress',
|
||||
expr_attr_names={'#status': 'status'},
|
||||
expr_attr_values={
|
||||
':completed': 'COMPLETED',
|
||||
':in_progress': 'IN_PROGRESS',
|
||||
':score': score,
|
||||
':updated_at': now_,
|
||||
},
|
||||
exc_cls=EnrollmentConflictError,
|
||||
)
|
||||
transact.put(
|
||||
item={
|
||||
'id': id,
|
||||
'sk': 'COMPLETED',
|
||||
'completed_at': now_,
|
||||
},
|
||||
cond_expr='attribute_not_exists(sk)',
|
||||
)
|
||||
transact.put(
|
||||
item={
|
||||
'id': id,
|
||||
'sk': 'SCHEDULE#REMINDER_CERT_EXPIRATION_BEFORE_30_DAYS',
|
||||
'ttl': cert_expiration_reminder_ttl,
|
||||
'created_at': now_,
|
||||
}
|
||||
)
|
||||
transact.put(
|
||||
item={
|
||||
'id': id,
|
||||
'sk': 'SCHEDULE#SET_AS_ARCHIVED',
|
||||
'ttl': archive_ttl,
|
||||
'created_at': now_,
|
||||
}
|
||||
)
|
||||
transact.put(
|
||||
item={
|
||||
'id': id,
|
||||
'sk': 'LOCK',
|
||||
'ttl': deduplication_lock_ttl,
|
||||
'created_at': now_,
|
||||
}
|
||||
)
|
||||
transact.put(
|
||||
item={
|
||||
'id': 'LOCK',
|
||||
'sk': lock_hash,
|
||||
'enrollment_id': id,
|
||||
'ttl': deduplication_lock_ttl,
|
||||
'created_at': now_,
|
||||
}
|
||||
)
|
||||
# Remove reminders and policies that no longer apply
|
||||
transact.delete(
|
||||
key=KeyPair(
|
||||
pk=id,
|
||||
sk='SCHEDULE#SET_AS_EXPIRED',
|
||||
)
|
||||
)
|
||||
transact.delete(
|
||||
key=KeyPair(
|
||||
pk=id,
|
||||
sk='SCHEDULE#REMINDER_NO_ACTIVITY_AFTER_7_DAYS',
|
||||
)
|
||||
)
|
||||
transact.delete(
|
||||
key=KeyPair(
|
||||
pk=id,
|
||||
sk='SCHEDULE#REMINDER_ACCESS_PERIOD_BEFORE_30_DAYS',
|
||||
)
|
||||
)
|
||||
|
||||
return True
|
||||
|
||||
|
||||
def _set_status_as_failed(
|
||||
id: str,
|
||||
/,
|
||||
score: Decimal,
|
||||
user_id: str,
|
||||
course_id: str,
|
||||
*,
|
||||
dynamodb_persistence_layer: DynamoDBPersistenceLayer,
|
||||
) -> bool:
|
||||
now_ = now()
|
||||
lock_hash = md5_hash(f'{user_id}{course_id}')
|
||||
|
||||
with dynamodb_persistence_layer.transact_writer() as transact:
|
||||
transact.update(
|
||||
key=KeyPair(pk=id, sk='0'),
|
||||
update_expr='SET #status = :failed, score = :score, \
|
||||
updated_at = :updated_at',
|
||||
cond_expr='#status = :in_progress',
|
||||
expr_attr_names={'#status': 'status'},
|
||||
expr_attr_values={
|
||||
':failed': 'FAILED',
|
||||
':in_progress': 'IN_PROGRESS',
|
||||
':score': score,
|
||||
':updated_at': now_,
|
||||
},
|
||||
exc_cls=EnrollmentConflictError,
|
||||
)
|
||||
transact.put(
|
||||
item={
|
||||
'id': id,
|
||||
'sk': 'FAILED',
|
||||
'failed_at': now_,
|
||||
},
|
||||
cond_expr='attribute_not_exists(sk)',
|
||||
)
|
||||
# Remove reminders and events that no longer apply
|
||||
transact.delete(
|
||||
key=KeyPair(
|
||||
pk=id,
|
||||
sk='SCHEDULE#SET_AS_EXPIRED',
|
||||
)
|
||||
)
|
||||
transact.delete(
|
||||
key=KeyPair(
|
||||
pk=id,
|
||||
sk='SCHEDULE#REMINDER_NO_ACTIVITY_AFTER_7_DAYS',
|
||||
)
|
||||
)
|
||||
transact.delete(
|
||||
key=KeyPair(
|
||||
pk=id,
|
||||
sk='SCHEDULE#REMINDER_ACCESS_PERIOD_BEFORE_30_DAYS',
|
||||
)
|
||||
)
|
||||
# Remove locks related to this enrollment
|
||||
transact.delete(
|
||||
key=KeyPair(pk=id, sk='LOCK'),
|
||||
)
|
||||
transact.delete(
|
||||
key=KeyPair(pk='LOCK', sk=lock_hash),
|
||||
)
|
||||
|
||||
return True
|
||||
|
||||
|
||||
def _set_status_as_archived(
|
||||
id: str,
|
||||
*,
|
||||
dynamodb_persistence_layer: DynamoDBPersistenceLayer,
|
||||
):
|
||||
now_ = now()
|
||||
with dynamodb_persistence_layer.transact_writer() as transact:
|
||||
transact.update(
|
||||
key=KeyPair(pk=id, sk='0'),
|
||||
update_expr='SET #status = :archived, updated_at = :updated_at',
|
||||
cond_expr='#status = :completed',
|
||||
expr_attr_names={'#status': 'status'},
|
||||
expr_attr_values={
|
||||
':archived': 'ARCHIVED',
|
||||
':completed': 'COMPLETED',
|
||||
':updated_at': now_,
|
||||
},
|
||||
exc_cls=EnrollmentConflictError,
|
||||
)
|
||||
transact.put(
|
||||
item={
|
||||
'id': id,
|
||||
'sk': 'ARCHIVED',
|
||||
'archived_at': now_,
|
||||
},
|
||||
cond_expr='attribute_not_exists(sk)',
|
||||
)
|
||||
# Remove events that no longer apply
|
||||
transact.delete(
|
||||
key=KeyPair(
|
||||
pk=id,
|
||||
sk='SCHEDULE#SET_AS_ARCHIVED',
|
||||
)
|
||||
)
|
||||
|
||||
return True
|
||||
|
||||
|
||||
def _set_status_as_expired(
|
||||
id: str,
|
||||
*,
|
||||
dynamodb_persistence_layer: DynamoDBPersistenceLayer,
|
||||
):
|
||||
now_ = now()
|
||||
|
||||
with dynamodb_persistence_layer.transact_writer() as transact:
|
||||
transact.update(
|
||||
key=KeyPair(pk=id, sk='0'),
|
||||
update_expr='SET #status = :expired, updated_at = :updated_at',
|
||||
cond_expr='#status = :in_progress OR #status = :pending',
|
||||
expr_attr_names={
|
||||
'#status': 'status',
|
||||
},
|
||||
expr_attr_values={
|
||||
':pending': 'PENDING',
|
||||
':in_progress': 'IN_PROGRESS',
|
||||
':expired': 'EXPIRED',
|
||||
':updated_at': now_,
|
||||
},
|
||||
exc_cls=EnrollmentConflictError,
|
||||
)
|
||||
transact.put(
|
||||
item={
|
||||
'id': id,
|
||||
'sk': 'EXPIRED',
|
||||
'expired_at': now_,
|
||||
},
|
||||
cond_expr='attribute_not_exists(sk)',
|
||||
)
|
||||
# Remove events and policies that no longer apply
|
||||
transact.delete(
|
||||
key=KeyPair(
|
||||
pk=id,
|
||||
sk='SCHEDULE#SET_AS_EXPIRED',
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
class EnrollmentNotFoundError(NotFoundError):
|
||||
def __init__(self, *_):
|
||||
super().__init__('Enrollment not found')
|
||||
|
||||
|
||||
class EnrollmentConflictError(BadRequestError): ...
|
||||
@@ -1,7 +0,0 @@
|
||||
import os
|
||||
|
||||
USER_TABLE: str = os.getenv('USER_TABLE') # type: ignore
|
||||
ENROLLMENT_TABLE: str = os.getenv('ENROLLMENT_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
|
||||
@@ -17,7 +17,7 @@ Globals:
|
||||
Architectures:
|
||||
- x86_64
|
||||
Layers:
|
||||
- !Sub arn:aws:lambda:sa-east-1:336641857101:layer:layercake:94
|
||||
- !Sub arn:aws:lambda:sa-east-1:336641857101:layer:layercake:96
|
||||
Environment:
|
||||
Variables:
|
||||
TZ: America/Sao_Paulo
|
||||
@@ -55,8 +55,11 @@ Resources:
|
||||
Handler: app.lambda_handler
|
||||
LoggingConfig:
|
||||
LogGroup: !Ref HttpLog
|
||||
Policies:
|
||||
- DynamoDBCrudPolicy:
|
||||
TableName: !Ref EnrollmentTable
|
||||
Events:
|
||||
Session:
|
||||
Post:
|
||||
Type: HttpApi
|
||||
Properties:
|
||||
Path: /
|
||||
|
||||
223
konviva-events/tests/test_app.py
Normal file
223
konviva-events/tests/test_app.py
Normal file
@@ -0,0 +1,223 @@
|
||||
from http import HTTPMethod, HTTPStatus
|
||||
|
||||
from layercake.dateutils import now
|
||||
from layercake.dynamodb import DynamoDBPersistenceLayer, KeyPair, PartitionKey
|
||||
|
||||
from .conftest import HttpApiProxy, LambdaContext
|
||||
|
||||
|
||||
def test_start_progress(
|
||||
app,
|
||||
seeds,
|
||||
dynamodb_persistence_layer: DynamoDBPersistenceLayer,
|
||||
http_api_proxy: HttpApiProxy,
|
||||
lambda_context: LambdaContext,
|
||||
):
|
||||
# This data was added from seeds
|
||||
r = app.lambda_handler(
|
||||
http_api_proxy(
|
||||
raw_path='/',
|
||||
method=HTTPMethod.POST,
|
||||
body={
|
||||
'ID_MATRICULA': '123',
|
||||
'APROVEITAMENTO': '23.152173913043477',
|
||||
'ANDAMENTO': '38.888888888888886',
|
||||
'status': 'IN_PROGRESS',
|
||||
},
|
||||
),
|
||||
lambda_context,
|
||||
)
|
||||
assert r['statusCode'] == HTTPStatus.NO_CONTENT
|
||||
|
||||
# Check `seeds.jsonl` for sample data related to this query
|
||||
r = dynamodb_persistence_layer.collection.query(
|
||||
PartitionKey('d9da85f2-e09f-472d-9515-3d91d70f1e8a')
|
||||
)
|
||||
assert any(item.get('sk') == 'STARTED' for item in r['items'])
|
||||
assert any(
|
||||
item.get('sk') == 'SCHEDULE#REMINDER_NO_ACTIVITY_AFTER_7_DAYS'
|
||||
for item in r['items']
|
||||
)
|
||||
assert len(r['items']) == 3
|
||||
|
||||
|
||||
def test_update_progress(
|
||||
app,
|
||||
seeds,
|
||||
dynamodb_persistence_layer: DynamoDBPersistenceLayer,
|
||||
http_api_proxy: HttpApiProxy,
|
||||
lambda_context: LambdaContext,
|
||||
):
|
||||
# This data was added from seeds
|
||||
r = app.lambda_handler(
|
||||
http_api_proxy(
|
||||
raw_path='/',
|
||||
method=HTTPMethod.POST,
|
||||
body={
|
||||
'ID_MATRICULA': '456',
|
||||
'APROVEITAMENTO': '23.152173913043477',
|
||||
'ANDAMENTO': '12.888888888888886',
|
||||
'status': 'IN_PROGRESS',
|
||||
'event': 'Matrícula - atualização de conteúdo',
|
||||
},
|
||||
),
|
||||
lambda_context,
|
||||
)
|
||||
assert r['statusCode'] == HTTPStatus.NO_CONTENT
|
||||
|
||||
# Check `seeds.jsonl` for sample data related to this query
|
||||
r = dynamodb_persistence_layer.collection.query(
|
||||
PartitionKey('197991aa-52e2-4e0c-b2a4-a7c53bcfee02')
|
||||
)
|
||||
assert len(r['items']) == 2
|
||||
assert any(
|
||||
item.get('sk') == 'SCHEDULE#REMINDER_NO_ACTIVITY_AFTER_7_DAYS'
|
||||
for item in r['items']
|
||||
)
|
||||
|
||||
|
||||
def test_set_as_completed(
|
||||
app,
|
||||
seeds,
|
||||
dynamodb_persistence_layer: DynamoDBPersistenceLayer,
|
||||
http_api_proxy: HttpApiProxy,
|
||||
lambda_context: LambdaContext,
|
||||
):
|
||||
# Update `created_at` to avoid future test failures
|
||||
dynamodb_persistence_layer.update_item(
|
||||
key=KeyPair('6c7e3d9b-f5d1-4da4-9e55-0825bb6ff2b8', '0'),
|
||||
update_expr='SET created_at = :created_at',
|
||||
expr_attr_values={':created_at': now()},
|
||||
)
|
||||
|
||||
# This data was added from seeds
|
||||
r = app.lambda_handler(
|
||||
http_api_proxy(
|
||||
raw_path='/',
|
||||
method=HTTPMethod.POST,
|
||||
body={
|
||||
'ID_MATRICULA': '567',
|
||||
'APROVEITAMENTO': '89.152173913043477',
|
||||
'ANDAMENTO': '100',
|
||||
'status': 'COMPLETED',
|
||||
},
|
||||
),
|
||||
lambda_context,
|
||||
)
|
||||
assert r['statusCode'] == HTTPStatus.NO_CONTENT
|
||||
|
||||
# Check `seeds.jsonl` for sample data related to this query
|
||||
r = dynamodb_persistence_layer.collection.query(
|
||||
PartitionKey('6c7e3d9b-f5d1-4da4-9e55-0825bb6ff2b8')
|
||||
)
|
||||
|
||||
assert len(r['items']) == 7
|
||||
assert any(item.get('sk') == 'COMPLETED' for item in r['items'])
|
||||
assert any(item.get('sk') == 'LOCK' for item in r['items'])
|
||||
assert any(
|
||||
item.get('sk') == 'SCHEDULE#REMINDER_CERT_EXPIRATION_BEFORE_30_DAYS'
|
||||
for item in r['items']
|
||||
)
|
||||
assert any(item.get('sk') == 'SCHEDULE#SET_AS_ARCHIVED' for item in r['items'])
|
||||
|
||||
r = dynamodb_persistence_layer.collection.query(PartitionKey('LOCK'))
|
||||
assert len(r['items']) == 1
|
||||
|
||||
|
||||
def test_set_as_failed(
|
||||
app,
|
||||
seeds,
|
||||
dynamodb_persistence_layer: DynamoDBPersistenceLayer,
|
||||
http_api_proxy: HttpApiProxy,
|
||||
lambda_context: LambdaContext,
|
||||
):
|
||||
# Update `created_at` to avoid future test failures
|
||||
dynamodb_persistence_layer.update_item(
|
||||
key=KeyPair('6c7e3d9b-f5d1-4da4-9e55-0825bb6ff2b8', '0'),
|
||||
update_expr='SET created_at = :created_at',
|
||||
expr_attr_values={':created_at': now()},
|
||||
)
|
||||
|
||||
# This data was added from seeds
|
||||
r = app.lambda_handler(
|
||||
http_api_proxy(
|
||||
raw_path='/',
|
||||
method=HTTPMethod.POST,
|
||||
body={
|
||||
'ID_MATRICULA': '567',
|
||||
'APROVEITAMENTO': '12.152173913043477',
|
||||
'ANDAMENTO': '100',
|
||||
'status': 'COMPLETED',
|
||||
},
|
||||
),
|
||||
lambda_context,
|
||||
)
|
||||
assert r['statusCode'] == HTTPStatus.NO_CONTENT
|
||||
|
||||
# Check `seeds.jsonl` for sample data related to this query
|
||||
r = dynamodb_persistence_layer.collection.query(
|
||||
PartitionKey('6c7e3d9b-f5d1-4da4-9e55-0825bb6ff2b8')
|
||||
)
|
||||
assert any(item.get('sk') == 'FAILED' for item in r['items'])
|
||||
|
||||
|
||||
def test_set_as_archived(
|
||||
app,
|
||||
seeds,
|
||||
dynamodb_persistence_layer: DynamoDBPersistenceLayer,
|
||||
http_api_proxy: HttpApiProxy,
|
||||
lambda_context: LambdaContext,
|
||||
):
|
||||
# This data was added from seeds
|
||||
r = app.lambda_handler(
|
||||
http_api_proxy(
|
||||
raw_path='/',
|
||||
method=HTTPMethod.POST,
|
||||
body={
|
||||
'ID_MATRICULA': '899',
|
||||
'APROVEITAMENTO': '70',
|
||||
'ANDAMENTO': '100',
|
||||
'status': 'COMPLETED',
|
||||
},
|
||||
),
|
||||
lambda_context,
|
||||
)
|
||||
assert r['statusCode'] == HTTPStatus.NO_CONTENT
|
||||
|
||||
# Check `seeds.jsonl` for sample data related to this query
|
||||
r = dynamodb_persistence_layer.collection.query(
|
||||
PartitionKey('cc2c3bce-c34a-4e82-aa6c-1a19e70ec5ae')
|
||||
)
|
||||
assert any(item.get('sk') == 'ARCHIVED' for item in r['items'])
|
||||
assert len(r['items']) == 4
|
||||
|
||||
|
||||
def test_set_as_expired(
|
||||
app,
|
||||
seeds,
|
||||
dynamodb_persistence_layer: DynamoDBPersistenceLayer,
|
||||
http_api_proxy: HttpApiProxy,
|
||||
lambda_context: LambdaContext,
|
||||
):
|
||||
# This data was added from seeds
|
||||
r = app.lambda_handler(
|
||||
http_api_proxy(
|
||||
raw_path='/',
|
||||
method=HTTPMethod.POST,
|
||||
body={
|
||||
'ID_MATRICULA': '221',
|
||||
'APROVEITAMENTO': '69',
|
||||
'ANDAMENTO': '100',
|
||||
'status': 'COMPLETED',
|
||||
},
|
||||
),
|
||||
lambda_context,
|
||||
)
|
||||
assert r['statusCode'] == HTTPStatus.NO_CONTENT
|
||||
|
||||
# Check `seeds.jsonl` for sample data related to this query
|
||||
r = dynamodb_persistence_layer.collection.query(
|
||||
PartitionKey('5db53b35-0bae-4907-afda-a213cb5bf651')
|
||||
)
|
||||
assert any(item.get('sk') == 'EXPIRED' for item in r['items'])
|
||||
assert len(r['items']) == 3
|
||||
48
konviva-events/uv.lock
generated
48
konviva-events/uv.lock
generated
@@ -485,7 +485,7 @@ dev = [
|
||||
|
||||
[[package]]
|
||||
name = "layercake"
|
||||
version = "0.9.12"
|
||||
version = "0.9.14"
|
||||
source = { directory = "../layercake" }
|
||||
dependencies = [
|
||||
{ name = "arnparse" },
|
||||
@@ -497,6 +497,7 @@ dependencies = [
|
||||
{ name = "meilisearch" },
|
||||
{ name = "orjson" },
|
||||
{ name = "passlib" },
|
||||
{ name = "psycopg", extra = ["binary"] },
|
||||
{ name = "pycpfcnpj" },
|
||||
{ name = "pydantic", extra = ["email"] },
|
||||
{ name = "pydantic-extra-types" },
|
||||
@@ -519,6 +520,7 @@ requires-dist = [
|
||||
{ name = "meilisearch", specifier = ">=0.34.0" },
|
||||
{ name = "orjson", specifier = ">=3.10.15" },
|
||||
{ name = "passlib", specifier = ">=1.7.4" },
|
||||
{ name = "psycopg", extras = ["binary"], specifier = ">=3.2.9" },
|
||||
{ name = "pycpfcnpj", specifier = ">=1.8" },
|
||||
{ name = "pydantic", extras = ["email"], specifier = ">=2.10.6" },
|
||||
{ name = "pydantic-extra-types", specifier = ">=2.10.3" },
|
||||
@@ -622,6 +624,41 @@ wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/a3/58/35da89ee790598a0700ea49b2a66594140f44dec458c07e8e3d4979137fc/ply-3.11-py2.py3-none-any.whl", hash = "sha256:096f9b8350b65ebd2fd1346b12452efe5b9607f7482813ffca50c22722a807ce", size = 49567, upload-time = "2018-02-15T19:01:27.172Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "psycopg"
|
||||
version = "3.2.9"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "tzdata", marker = "sys_platform == 'win32'" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/27/4a/93a6ab570a8d1a4ad171a1f4256e205ce48d828781312c0bbaff36380ecb/psycopg-3.2.9.tar.gz", hash = "sha256:2fbb46fcd17bc81f993f28c47f1ebea38d66ae97cc2dbc3cad73b37cefbff700", size = 158122, upload-time = "2025-05-13T16:11:15.533Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/44/b0/a73c195a56eb6b92e937a5ca58521a5c3346fb233345adc80fd3e2f542e2/psycopg-3.2.9-py3-none-any.whl", hash = "sha256:01a8dadccdaac2123c916208c96e06631641c0566b22005493f09663c7a8d3b6", size = 202705, upload-time = "2025-05-13T16:06:26.584Z" },
|
||||
]
|
||||
|
||||
[package.optional-dependencies]
|
||||
binary = [
|
||||
{ name = "psycopg-binary", marker = "implementation_name != 'pypy'" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "psycopg-binary"
|
||||
version = "3.2.9"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/28/0b/f61ff4e9f23396aca674ed4d5c9a5b7323738021d5d72d36d8b865b3deaf/psycopg_binary-3.2.9-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:98bbe35b5ad24a782c7bf267596638d78aa0e87abc7837bdac5b2a2ab954179e", size = 4017127, upload-time = "2025-05-13T16:08:21.391Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/bc/00/7e181fb1179fbfc24493738b61efd0453d4b70a0c4b12728e2b82db355fd/psycopg_binary-3.2.9-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:72691a1615ebb42da8b636c5ca9f2b71f266be9e172f66209a361c175b7842c5", size = 4080322, upload-time = "2025-05-13T16:08:24.049Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/58/fd/94fc267c1d1392c4211e54ccb943be96ea4032e761573cf1047951887494/psycopg_binary-3.2.9-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:25ab464bfba8c401f5536d5aa95f0ca1dd8257b5202eede04019b4415f491351", size = 4655097, upload-time = "2025-05-13T16:08:27.376Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/41/17/31b3acf43de0b2ba83eac5878ff0dea5a608ca2a5c5dd48067999503a9de/psycopg_binary-3.2.9-cp313-cp313-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:0e8aeefebe752f46e3c4b769e53f1d4ad71208fe1150975ef7662c22cca80fab", size = 4482114, upload-time = "2025-05-13T16:08:30.781Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/85/78/b4d75e5fd5a85e17f2beb977abbba3389d11a4536b116205846b0e1cf744/psycopg_binary-3.2.9-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:b7e4e4dd177a8665c9ce86bc9caae2ab3aa9360b7ce7ec01827ea1baea9ff748", size = 4737693, upload-time = "2025-05-13T16:08:34.625Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/3b/95/7325a8550e3388b00b5e54f4ced5e7346b531eb4573bf054c3dbbfdc14fe/psycopg_binary-3.2.9-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:7fc2915949e5c1ea27a851f7a472a7da7d0a40d679f0a31e42f1022f3c562e87", size = 4437423, upload-time = "2025-05-13T16:08:37.444Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/1a/db/cef77d08e59910d483df4ee6da8af51c03bb597f500f1fe818f0f3b925d3/psycopg_binary-3.2.9-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:a1fa38a4687b14f517f049477178093c39c2a10fdcced21116f47c017516498f", size = 3758667, upload-time = "2025-05-13T16:08:40.116Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/95/3e/252fcbffb47189aa84d723b54682e1bb6d05c8875fa50ce1ada914ae6e28/psycopg_binary-3.2.9-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:5be8292d07a3ab828dc95b5ee6b69ca0a5b2e579a577b39671f4f5b47116dfd2", size = 3320576, upload-time = "2025-05-13T16:08:43.243Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/1c/cd/9b5583936515d085a1bec32b45289ceb53b80d9ce1cea0fef4c782dc41a7/psycopg_binary-3.2.9-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:778588ca9897b6c6bab39b0d3034efff4c5438f5e3bd52fda3914175498202f9", size = 3411439, upload-time = "2025-05-13T16:08:47.321Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/45/6b/6f1164ea1634c87956cdb6db759e0b8c5827f989ee3cdff0f5c70e8331f2/psycopg_binary-3.2.9-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:f0d5b3af045a187aedbd7ed5fc513bd933a97aaff78e61c3745b330792c4345b", size = 3477477, upload-time = "2025-05-13T16:08:51.166Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/7b/1d/bf54cfec79377929da600c16114f0da77a5f1670f45e0c3af9fcd36879bc/psycopg_binary-3.2.9-cp313-cp313-win_amd64.whl", hash = "sha256:2290bc146a1b6a9730350f695e8b670e1d1feb8446597bed0bbe7c3c30e0abcb", size = 2928009, upload-time = "2025-05-13T16:08:53.67Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "pycparser"
|
||||
version = "2.22"
|
||||
@@ -945,6 +982,15 @@ wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/17/69/cd203477f944c353c31bade965f880aa1061fd6bf05ded0726ca845b6ff7/typing_inspection-0.4.1-py3-none-any.whl", hash = "sha256:389055682238f53b04f7badcb49b989835495a96700ced5dab2d8feae4b26f51", size = 14552, upload-time = "2025-05-21T18:55:22.152Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "tzdata"
|
||||
version = "2025.2"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/95/32/1a225d6164441be760d75c2c42e2780dc0873fe382da3e98a2e1e48361e5/tzdata-2025.2.tar.gz", hash = "sha256:b60a638fcc0daffadf82fe0f57e53d06bdec2f36c4df66280ae79bce6bd6f2b9", size = 196380, upload-time = "2025-03-23T13:54:43.652Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/5c/23/c7abc0ca0a1526a0774eca151daeb8de62ec457e77262b66b359c3c7679e/tzdata-2025.2-py2.py3-none-any.whl", hash = "sha256:1a403fada01ff9221ca8044d701868fa132215d84beb92242d9acd2147f667a8", size = 347839, upload-time = "2025-03-23T13:54:41.845Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "unidecode"
|
||||
version = "1.4.0"
|
||||
|
||||
Reference in New Issue
Block a user