diff --git a/http-api/rules/__init__.py b/http-api/rules/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/http-api/rules/course.py b/http-api/rules/course.py new file mode 100644 index 0000000..c00b543 --- /dev/null +++ b/http-api/rules/course.py @@ -0,0 +1,57 @@ +from layercake.dateutils import now +from layercake.dynamodb import DynamoDBPersistenceLayer, KeyPair, TransactItems + +from models import Course, Org + + +def create_course( + course: Course, + org: Org, + /, + persistence_layer: DynamoDBPersistenceLayer, +): + now_ = now() + transact = TransactItems(persistence_layer.table_name) + transact.put( + item={ + 'sk': '0', + 'tenant__org_id': {org.id}, + 'create_date': now_, + **course.model_dump(), + } + ) + transact.put( + item={ + 'id': course.id, + 'sk': 'tenant', + 'org_id': org.id, + 'name': org.name, + 'create_date': now_, + } + ) + return persistence_layer.transact_write_items(transact) + + +def update_course( + id: str, + course: Course, + /, + persistence_layer: DynamoDBPersistenceLayer, +): + now_ = now() + transact = TransactItems(persistence_layer.table_name) + transact.update( + key=KeyPair(id, '0'), + update_expr='SET #name = :name, access_period = :access_period, cert = :cert, update_date = :update_date', + expr_attr_names={ + '#name': 'name', + }, + expr_attr_values={ + ':name': course.name, + ':cert': course.cert.model_dump() if course.cert else None, + ':access_period': course.access_period, + ':update_date': now_, + }, + cond_expr='attribute_exists(sk)', + ) + return persistence_layer.transact_write_items(transact) diff --git a/http-api/rules/enrollment.py b/http-api/rules/enrollment.py new file mode 100644 index 0000000..0fa4338 --- /dev/null +++ b/http-api/rules/enrollment.py @@ -0,0 +1,102 @@ +from typing import TypedDict +from uuid import uuid4 + +from layercake.dateutils import now +from layercake.dynamodb import DynamoDBPersistenceLayer, KeyPair, TransactItems + +from settings import ORDER_TABLE + + +class Author(TypedDict): + id: str + name: str + + +class Course(TypedDict): + id: str + name: str + time_in_days: int + + +def set_status_as_canceled( + id: str, + *, + lock_hash: str, + author: Author, + course: Course | None = None, + vacancy_key: KeyPair | None = None, + persistence_layer: DynamoDBPersistenceLayer, +): + """Cancel the enrollment if there's a `cancel_policy` + and put its vacancy back if `vacancy_key` is provided.""" + now_ = now() + transact = TransactItems(persistence_layer.table_name) + transact.update( + key=KeyPair(id, '0'), + update_expr='SET #status = :canceled, update_date = :update', + expr_attr_names={ + '#status': 'status', + }, + expr_attr_values={ + ':canceled': 'CANCELED', + ':update': now_, + }, + ) + transact.put( + item={ + 'id': id, + 'sk': 'canceled_date', + 'author': author, + 'create_date': now_, + }, + ) + transact.delete( + key=KeyPair(id, 'cancel_policy'), + cond_expr='attribute_exists(sk)', + ) + # Remove schedules lifecycle events, referencies and locks + transact.delete(key=KeyPair(id, 'schedules#archive_it')) + transact.delete(key=KeyPair(id, 'schedules#no_activity')) + transact.delete(key=KeyPair(id, 'schedules#access_period_ends')) + transact.delete(key=KeyPair(id, 'schedules#does_not_access')) + transact.delete(key=KeyPair(id, 'parent_vacancy')) + transact.delete(key=KeyPair(id, 'lock')) + transact.delete(key=KeyPair('lock', lock_hash)) + + if vacancy_key and course: + vacancy_pk, vacancy_sk = vacancy_key.values() + org_id = vacancy_pk.removeprefix('vacancies#') + order_id, enrollment_id = vacancy_sk.split('#') + + transact.condition( + key=KeyPair(order_id, '0'), + cond_expr='attribute_exists(id)', + table_name=ORDER_TABLE, + ) + # Put the vacancy back and assign a new ID + transact.put( + item={ + 'id': f'vacancies#{org_id}', + 'sk': f'{order_id}#{uuid4()}', + 'course': course, + 'create_date': now_, + }, + cond_expr='attribute_not_exists(sk)', + ) + # Set the status of `generated_items` to `ROLLBACK` to know + # which vacancy is available for reuse + transact.update( + key=KeyPair(order_id, f'generated_items#{enrollment_id}'), + update_expr='SET #status = :status, update_date = :update', + expr_attr_names={ + '#status': 'status', + }, + expr_attr_values={ + ':status': 'ROLLBACK', + ':update': now_, + }, + cond_expr='attribute_exists(sk)', + table_name=ORDER_TABLE, + ) + + return persistence_layer.transact_write_items(transact) diff --git a/http-api/rules/org.py b/http-api/rules/org.py new file mode 100644 index 0000000..3415ce0 --- /dev/null +++ b/http-api/rules/org.py @@ -0,0 +1,40 @@ +from layercake.dateutils import now +from layercake.dynamodb import DynamoDBPersistenceLayer, KeyPair, TransactItems + + +def update_policies( + id: str, + /, + payment_policy: dict = {}, + billing_policy: dict = {}, + *, + persistence_layer: DynamoDBPersistenceLayer, +): + now_ = now() + transact = TransactItems(persistence_layer.table_name) + + if payment_policy: + transact.put( + item={ + 'id': id, + 'sk': 'payment_policy', + 'create_date': now_, + } + | payment_policy + ) + else: + transact.delete(key=KeyPair(id, 'payment_policy')) + + if billing_policy: + transact.put( + item={ + 'id': id, + 'sk': 'billing_policy', + 'create_date': now_, + } + | billing_policy + ) + else: + transact.delete(key=KeyPair(id, 'billing_policy')) + + return persistence_layer.transact_write_items(transact) diff --git a/http-api/rules/user.py b/http-api/rules/user.py new file mode 100644 index 0000000..6ef124f --- /dev/null +++ b/http-api/rules/user.py @@ -0,0 +1,227 @@ +from types import SimpleNamespace +from typing import TypedDict + +from aws_lambda_powertools.event_handler.exceptions import ( + BadRequestError, +) +from botocore.exceptions import ClientError +from botocore.tokens import timedelta +from layercake.dateutils import now, ttl +from layercake.dynamodb import ( + ComposeKey, + DynamoDBPersistenceLayer, + KeyPair, + TransactItems, +) + + +class CPFConflictError(BadRequestError): + pass + + +User = TypedDict('User', {'id': str, 'name': str, 'cpf': str}) + + +def update_user( + userdata: User, + /, + *, + persistence_layer: DynamoDBPersistenceLayer, +) -> bool: + now_ = now() + ttl_ = now_ + timedelta(hours=24) + user = SimpleNamespace(**userdata) + # Get the user's CPF, if it exists. + old_cpf = persistence_layer.get_item(KeyPair(user.id, '0')).get('cpf', None) + + transact = TransactItems(persistence_layer.table_name) + transact.update( + key=KeyPair(user.id, '0'), + update_expr='SET #name = :name, cpf = :cpf, update_date = :update_date', + expr_attr_names={ + '#name': 'name', + }, + expr_attr_values={ + ':name': user.name, + ':cpf': user.cpf, + ':update_date': now_, + }, + cond_expr='attribute_exists(sk)', + ) + # Prevent the user from updating more than once every 24 hours + transact.put( + item={ + 'id': user.id, + 'sk': 'last_profile_edit', + 'create_date': now_, + 'ttl': ttl(start_dt=ttl_), + 'ttl_date': ttl_, + }, + cond_expr='attribute_not_exists(sk)', + ) + + if user.cpf != old_cpf: + transact.put( + item={ + 'id': 'cpf', + 'sk': user.cpf, + 'user_id': user.id, + 'create_date': now_, + }, + cond_expr='attribute_not_exists(sk)', + ) + + # Ensures that the old CPF is discarded + if old_cpf: + transact.delete(key=KeyPair('cpf', old_cpf)) + + try: + persistence_layer.transact_write_items(transact) + except ClientError: + raise CPFConflictError('CPF is already in use.') + else: + return True + + +def add_email( + id: str, + email: str, + /, + *, + persistence_layer: DynamoDBPersistenceLayer, +): + now_ = now() + transact = TransactItems(persistence_layer.table_name) + transact.update( + key=KeyPair(id, '0'), + update_expr='ADD emails :email', + expr_attr_values={ + ':email': {email}, + }, + ) + transact.put( + item={ + 'id': id, + 'sk': f'emails#{email}', + 'email_primary': False, + 'email_verified': False, + 'create_date': now_, + }, + cond_expr='attribute_not_exists(sk)', + ) + transact.put( + item={ + 'id': 'email', + 'sk': email, + 'user_id': id, + 'create_date': now_, + }, + cond_expr='attribute_not_exists(sk)', + ) + + try: + return persistence_layer.transact_write_items(transact) + except ClientError: + raise BadRequestError('Email already exists.') + + +def del_email( + id: str, + email: str, + /, + *, + persistence_layer: DynamoDBPersistenceLayer, +) -> bool: + """Delete any email except the primary email.""" + transact = TransactItems(persistence_layer.table_name) + transact.delete( + key=KeyPair('email', email), + ) + transact.delete( + key=KeyPair(id, ComposeKey(email, prefix='emails')), + cond_expr='email_primary <> :primary', + expr_attr_values={':primary': True}, + ) + transact.update( + key=KeyPair(id, '0'), + update_expr='DELETE emails :email', + expr_attr_values={ + ':email': {email}, + }, + ) + + try: + return persistence_layer.transact_write_items(transact) + except ClientError: + raise BadRequestError('Cannot remove the primary email.') + + +def set_email_as_primary( + id: str, + new_email: str, + old_email: str, + /, + *, + email_verified: bool = False, + persistence_layer: DynamoDBPersistenceLayer, +): + now_ = now() + expr = 'SET email_primary = :email_primary, update_date = :update_date' + transact = TransactItems(persistence_layer.table_name) + # Set the old email as non-primary + transact.update( + key=KeyPair(id, ComposeKey(old_email, 'emails')), + update_expr=expr, + expr_attr_values={ + ':email_primary': False, + ':update_date': now_, + }, + ) + # Set the new email as primary + transact.update( + key=KeyPair(id, ComposeKey(new_email, 'emails')), + update_expr=expr, + expr_attr_values={ + ':email_primary': True, + ':update_date': now_, + }, + ) + transact.update( + key=KeyPair(id, '0'), + update_expr=( + 'SET email = :email, email_verified = :email_verified, ' + 'update_date = :update_date' + ), + expr_attr_values={ + ':email': new_email, + ':email_verified': email_verified, + ':update_date': now_, + }, + ) + + return persistence_layer.transact_write_items(transact) + + +def del_org_member( + id: str, + *, + org_id: str, + persistence_layer: DynamoDBPersistenceLayer, +) -> bool: + transact = TransactItems(persistence_layer.table_name) + + # Remove the user's relationship with the organization and their privileges + transact.delete(key=KeyPair(id, f'acls#{org_id}')) + transact.delete(key=KeyPair(id, f'orgs#{org_id}')) + transact.update( + key=KeyPair(id, '0'), + update_expr='DELETE #tenant :org_id', + expr_attr_names={'#tenant': 'tenant__org_id'}, + expr_attr_values={':org_id': {org_id}}, + ) + + # Remove the user from the organization's admins and members list + transact.delete(key=KeyPair(org_id, f'admins#{id}')) + transact.delete(key=KeyPair(f'orgmembers#{org_id}', id)) + + return persistence_layer.transact_write_items(transact)