diff --git a/certs/cert.pdf b/certs/cert.pdf index 23056b7..8a1d57e 100644 Binary files a/certs/cert.pdf and b/certs/cert.pdf differ diff --git a/certs/hello.py b/certs/hello.py index 18ffce1..5aa4204 100644 --- a/certs/hello.py +++ b/certs/hello.py @@ -1,8 +1,12 @@ +import base64 +import io import locale from datetime import date from uuid import uuid4 +import qrcode from jinja2 import Template +from PIL import Image from weasyprint import HTML locale.setlocale(locale.LC_TIME, 'pt_BR') @@ -17,6 +21,22 @@ def cpf_fmt(s: str) -> str: return '{}.{}.{}-{}'.format(s[:3], s[3:6], s[6:9], s[9:]) +qr = qrcode.QRCode( + version=1, + error_correction=qrcode.constants.ERROR_CORRECT_H, + box_size=10, + border=3, +) +qr.add_data('https://eduseg.com.br') +qr.make(fit=True) +img = qr.make_image(fill_color='black', back_color='white') +img = img.resize((120, 120), Image.NEAREST) +buffer = io.BytesIO() +img.save(buffer, format='PNG') +img_str = base64.b64encode(buffer.getvalue()).decode('utf-8') +qrcode_base64 = f'data:image/png;base64,{img_str}' + + template = Template(html) html_rendered = template.render( id=uuid4(), @@ -27,6 +47,7 @@ html_rendered = template.render( today=today.strftime('%-d de %B de %Y'), started_date=today.strftime('%d/%m/%Y'), finished_date=today.strftime('%d/%m/%Y'), + qrcode=qrcode_base64, ) HTML(string=html_rendered, base_url='').write_pdf('cert.pdf') diff --git a/certs/nr10_complementar_sep.html b/certs/nr10_complementar_sep.html index 8025c41..65d1ef3 100644 --- a/certs/nr10_complementar_sep.html +++ b/certs/nr10_complementar_sep.html @@ -3,14 +3,14 @@ NR-10 Complementar (SEP) - - @@ -156,12 +181,24 @@ de {{ progress }}%

-

Realizado entre {{ start_date }} e {{ finish_date }}

+

Realizado entre {{ started_date }} e {{ finished_date }}

Florianópolis, SC, {{ today }}

+ +
+
+
+

Tiago Maciel do Santos

+

CEO/Diretor

+
+
+ +
+ +
-
+

Conteúdo programático ministrado

  • Organização do sistema elétrico de potência
  • @@ -193,18 +230,20 @@
-
-
+
+

Carga horária

40 horas

-
+

Instrutor e responsável técnico

-

Francis Ricardo Baretta

-

CPF 039.539.409-02

-

Eng. de Segurança no Trabalho Eng. Eletricista

-

CREA/SC 126693-0

+
+

Francis Ricardo Baretta

+

CPF 039.539.409-02

+

Eng. de Segurança no Trabalho Eng. Eletricista

+

CREA/SC 126693-0

+
diff --git a/certs/pyproject.toml b/certs/pyproject.toml index 75388cd..0bdf59f 100644 --- a/certs/pyproject.toml +++ b/certs/pyproject.toml @@ -7,6 +7,7 @@ requires-python = ">=3.13" dependencies = [ "jinja2>=3.1.6", "layercake", + "qrcode>=8.2", ] [tool.uv.sources] diff --git a/certs/uv.lock b/certs/uv.lock index bb3c7d2..c5c5307 100644 --- a/certs/uv.lock +++ b/certs/uv.lock @@ -184,6 +184,7 @@ source = { virtual = "." } dependencies = [ { name = "jinja2" }, { name = "layercake" }, + { name = "qrcode" }, ] [package.dev-dependencies] @@ -195,6 +196,7 @@ dev = [ requires-dist = [ { name = "jinja2", specifier = ">=3.1.6" }, { name = "layercake", directory = "../layercake" }, + { name = "qrcode", specifier = ">=8.2" }, ] [package.metadata.requires-dev] @@ -244,6 +246,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/20/94/c5790835a017658cbfabd07f3bfb549140c3ac458cfc196323996b10095a/charset_normalizer-3.4.2-py3-none-any.whl", hash = "sha256:7f56930ab0abd1c45cd15be65cc741c28b1c9a34876ce8c17a2fa107810c0af0", size = 52626, upload-time = "2025-05-02T08:34:40.053Z" }, ] +[[package]] +name = "colorama" +version = "0.4.6" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/d8/53/6f443c9a4a8358a93a6792e2acffb9d9d5cb0a5cfd8802644b7b1c9a02e4/colorama-0.4.6.tar.gz", hash = "sha256:08695f5cb7ed6e0531a20572697297273c47b8cae5a63ffc6d6ed5c201be6e44", size = 27697, upload-time = "2022-10-25T02:36:22.414Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/d1/d6/3965ed04c63042e047cb6a3e6ed1a63a35087b6a609aa3a15ed8ac56c221/colorama-0.4.6-py2.py3-none-any.whl", hash = "sha256:4f1d9991f5acc0ca119f9d443620b77f9d6b33703e51011c16baf57afb285fc6", size = 25335, upload-time = "2022-10-25T02:36:20.889Z" }, +] + [[package]] name = "cryptography" version = "44.0.3" @@ -471,7 +482,7 @@ wheels = [ [[package]] name = "layercake" -version = "0.2.15" +version = "0.6.5" source = { directory = "../layercake" } dependencies = [ { name = "arnparse" }, @@ -517,7 +528,6 @@ dev = [ { name = "jsonlines", specifier = ">=4.0.0" }, { name = "pytest", specifier = ">=8.3.5" }, { name = "pytest-cov", specifier = ">=6.0.0" }, - { name = "pytest-env", specifier = ">=1.1.5" }, { name = "ruff", specifier = ">=0.11.1" }, ] @@ -765,6 +775,18 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/81/c4/34e93fe5f5429d7570ec1fa436f1986fb1f00c3e0f43a589fe2bbcd22c3f/pytz-2025.2-py2.py3-none-any.whl", hash = "sha256:5ddf76296dd8c44c26eb8f4b6f35488f3ccbf6fbbd7adee0b7262d43f0ec2f00", size = 509225, upload-time = "2025-03-25T02:24:58.468Z" }, ] +[[package]] +name = "qrcode" +version = "8.2" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "colorama", marker = "sys_platform == 'win32'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/8f/b2/7fc2931bfae0af02d5f53b174e9cf701adbb35f39d69c2af63d4a39f81a9/qrcode-8.2.tar.gz", hash = "sha256:35c3f2a4172b33136ab9f6b3ef1c00260dd2f66f858f24d88418a015f446506c", size = 43317, upload-time = "2025-05-01T15:44:24.726Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/dd/b8/d2d6d731733f51684bbf76bf34dab3b70a9148e8f2cef2bb544fccec681a/qrcode-8.2-py3-none-any.whl", hash = "sha256:16e64e0716c14960108e85d853062c9e8bba5ca8252c0b4d0231b9df4060ff4f", size = 45986, upload-time = "2025-05-01T15:44:22.781Z" }, +] + [[package]] name = "requests" version = "2.32.3" diff --git a/http-api/app/routes/enrollments/enroll.py b/http-api/app/routes/enrollments/enroll.py index b3b27fa..7dd30f6 100644 --- a/http-api/app/routes/enrollments/enroll.py +++ b/http-api/app/routes/enrollments/enroll.py @@ -1,3 +1,4 @@ +import uuid from datetime import datetime from http import HTTPStatus @@ -17,7 +18,7 @@ from config import ( ) from middlewares import Tenant, TenantMiddleware from models import Course, Enrollment, User -from rules.enrollment import enroll +from rules.enrollment import DeduplicationWindow, Vacancy, enroll router = Router() @@ -31,9 +32,18 @@ processor = BatchProcessor() class Item(BaseModel): user: User course: Course - deduplication_window: dict = {} + vacancy: Vacancy | None = None + deduplication_window: DeduplicationWindow | None = None schedule_date: datetime | None = None + @property + def id(self) -> str: + if not self.vacancy: + return str(uuid.uuid4()) + + _, idx = self.vacancy.sk.split('#') + return idx + class Payload(BaseModel): items: tuple[Item, ...] @@ -59,6 +69,7 @@ def enroll_(payload: Payload): def handler(record: Item, context: dict): tenant: Tenant = context['tenant'] enrollment = Enrollment( + id=record.id, user=record.user, course=record.course, ) @@ -69,7 +80,8 @@ def handler(record: Item, context: dict): 'id': str(tenant.id), 'name': tenant.name, }, - deduplication_window=record.deduplication_window, # type: ignore + deduplication_window=record.deduplication_window, + vacancy=record.vacancy, persistence_layer=enrollment_layer, ) diff --git a/http-api/app/rules/course.py b/http-api/app/rules/course.py index 6d965e7..0e3dcb6 100644 --- a/http-api/app/rules/course.py +++ b/http-api/app/rules/course.py @@ -40,6 +40,7 @@ def update_course( persistence_layer: DynamoDBPersistenceLayer, ): now_ = now() + with persistence_layer.transact_writer() as transact: transact.update( key=KeyPair(id, '0'), diff --git a/http-api/app/rules/enrollment.py b/http-api/app/rules/enrollment.py index 4a9ca34..c19780a 100644 --- a/http-api/app/rules/enrollment.py +++ b/http-api/app/rules/enrollment.py @@ -1,6 +1,7 @@ +from dataclasses import asdict, dataclass from datetime import timedelta from enum import Enum -from typing import TypedDict +from typing import Self, TypedDict from uuid import uuid4 from layercake.dateutils import now, ttl @@ -10,22 +11,31 @@ from layercake.strutils import md5_hash from config import ORDER_TABLE from models import Course, Enrollment +Tenant = TypedDict('Tenant', {'id': str, 'name': str}) +Author = TypedDict('Author', {'id': str, 'name': str}) +DeduplicationWindow = TypedDict('DeduplicationWindow', {'offset_days': int}) -class Tenant(TypedDict): + +class RelatedId(str): + def __new__(cls, id: str, kind: str) -> Self: + return super().__new__(cls, id) + + def __init__(self, id: str, kind: 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.kind = kind + + +@dataclass(frozen=True) +class Vacancy: id: str - name: str + sk: str - -class Author(TypedDict): - id: str - name: str - - -class Vacancy(TypedDict): ... - - -class DeduplicationWindow(TypedDict): - offset_days: int + @property + def order_id(self) -> RelatedId: + idx, _ = self.sk.split('#') + return RelatedId(idx, 'order') class LifecycleEvents(str, Enum): @@ -55,6 +65,8 @@ def enroll( *, tenant: Tenant, vacancy: Vacancy | None = None, + author: Author | None = None, + related_ids: frozenset[RelatedId] = frozenset(), deduplication_window: DeduplicationWindow | None = None, persistence_layer: DynamoDBPersistenceLayer, ) -> bool: @@ -66,12 +78,15 @@ def enroll( lock_hash = md5_hash('%s%s' % (user.id, course.id)) with persistence_layer.transact_writer() as transact: + if vacancy: + related_ids = frozenset({vacancy.order_id}) | related_ids + transact.put( item={ 'sk': '0', 'create_date': now_, 'metadata__tenant_id': tenant_id, - 'metadata__related_ids': {tenant_id, user.id}, + 'metadata__related_ids': {tenant_id, user.id} | related_ids, **enrollment.model_dump(), }, ) @@ -95,17 +110,9 @@ def enroll( 'ttl': ttl(days=3, start_dt=now_), }, ) - transact.put( - item={ - 'id': enrollment.id, - 'sk': LifecycleEvents.ACCESS_PERIOD_REMINDER_30_DAYS, - 'name': user.name, - 'email': user.email, - 'course': course.name, - 'create_date': now_, - 'ttl': ttl(start_dt=now_ + timedelta(days=course.access_period - 30)), - }, - ) + # Enrollment expires by default when the access period ends. + # When the course is finished, it is automatically removed, + # and the `schedules#course_archived` event is created. transact.put( item={ 'id': enrollment.id, @@ -117,25 +124,78 @@ def enroll( 'ttl': ttl(start_dt=now_ + timedelta(days=course.access_period)), }, ) + transact.put( + item={ + 'id': enrollment.id, + 'sk': LifecycleEvents.ACCESS_PERIOD_REMINDER_30_DAYS, + 'name': user.name, + 'email': user.email, + 'course': course.name, + 'create_date': now_, + 'ttl': ttl(start_dt=now_ + timedelta(days=course.access_period - 30)), + }, + ) + + for related_id in related_ids: + kind = related_id.kind.lower() + transact.put( + item={ + 'id': enrollment.id, + 'sk': f'related_ids#{kind}', + 'create_date': now_, + f'{kind}_id': related_id, + } + ) + + if vacancy: + transact.put( + item={ + 'id': enrollment.id, + 'sk': 'parent_vacancy', + 'vacancy': asdict(vacancy), + 'create_date': now_, + } + ) + + class VacancyDoesNotExistError(Exception): + def __init__(self, *args): + super().__init__('Vacancy does not exist') + + transact.delete( + key=KeyPair(vacancy.id, vacancy.sk), + cond_expr='attribute_exists(sk)', + exc_cls=VacancyDoesNotExistError, + ) + transact.put( + item={ + 'id': enrollment.id, + 'sk': 'metadata#cancel_policy', + 'create_date': now_, + } + ) + + if author: + transact.put( + item={ + 'id': enrollment.id, + 'sk': 'metadata#author', + 'user_id': author['id'], + 'name': author['name'], + 'create_date': now_, + }, + ) class DeduplicationConflictError(Exception): def __init__(self, *args): super().__init__('Enrollment already exists') # Prevents the user from enrolling in the same course again until - # the deduplication window expires or is removed - transact.condition( - key=KeyPair('lock', lock_hash), - cond_expr='attribute_not_exists(sk)', - exc_cls=DeduplicationConflictError, - ) - + # the deduplication window expires or is removed. if deduplication_window: offset_days = deduplication_window['offset_days'] ttl_expiration = ttl( start_dt=now_ + timedelta(days=course.access_period - offset_days) ) - transact.put( item={ 'id': 'lock', @@ -144,6 +204,8 @@ def enroll( 'create_date': now_, 'ttl': ttl_expiration, }, + cond_expr='attribute_not_exists(sk)', + exc_cls=DeduplicationConflictError, ) transact.put( item={ @@ -163,6 +225,12 @@ def enroll( 'create_date': now_, }, ) + else: + transact.condition( + key=KeyPair('lock', lock_hash), + cond_expr='attribute_not_exists(sk)', + exc_cls=DeduplicationConflictError, + ) return True diff --git a/http-api/app/rules/user.py b/http-api/app/rules/user.py index 4a5e530..56b0080 100644 --- a/http-api/app/rules/user.py +++ b/http-api/app/rules/user.py @@ -1,3 +1,4 @@ +from datetime import timedelta from types import SimpleNamespace from typing import TypedDict @@ -9,6 +10,7 @@ from layercake.dynamodb import ( ComposeKey, DynamoDBPersistenceLayer, KeyPair, + SortKey, ) User = TypedDict('User', {'id': str, 'name': str, 'cpf': str}) @@ -23,7 +25,12 @@ def update_user( now_ = now() user = SimpleNamespace(**data) # Get the user's CPF, if it exists. - old_cpf = persistence_layer.get_item(KeyPair(user.id, '0')).get('cpf', None) + old_cpf = persistence_layer.collection.get_item( + KeyPair( + pk=user.id, + sk=SortKey('0', path_spec='cpf'), + ) + ) with persistence_layer.transact_writer() as transact: transact.update( @@ -128,9 +135,7 @@ def del_email( ) -> bool: """Delete any email except the primary email.""" with persistence_layer.transact_writer() as transact: - transact.delete( - key=KeyPair('email', email), - ) + transact.delete(key=KeyPair('email', email)) transact.delete( key=KeyPair(id, ComposeKey(email, prefix='emails')), cond_expr='email_primary <> :primary', diff --git a/http-api/tests/routes/test_enrollments.py b/http-api/tests/routes/test_enrollments.py index 5f8ee18..fd6b6e4 100644 --- a/http-api/tests/routes/test_enrollments.py +++ b/http-api/tests/routes/test_enrollments.py @@ -3,10 +3,11 @@ from http import HTTPMethod, HTTPStatus from layercake.dynamodb import ( ComposeKey, - DynamoDBCollection, DynamoDBPersistenceLayer, KeyPair, PartitionKey, + SortKey, + TransactKey, ) from ..conftest import HttpApiProxy, LambdaContext @@ -53,6 +54,10 @@ def test_enroll( 'deduplication_window': { 'offset_days': 60, }, + 'vacancy': { + 'id': 'vacancies#cJtK9SsnJhKPyxESe7g3DG', + 'sk': '3CNrFB9dy2RLit2pdeUWy4#8c9b55ef-e988-43ee-b2da-8594850605d7', + }, }, ], }, @@ -62,12 +67,75 @@ def test_enroll( assert r['statusCode'] == HTTPStatus.OK - fail, _ = json.loads(r['body']) + fail, succ = json.loads(r['body']) assert fail['cause'] == { 'type': 'DeduplicationConflictError', 'message': 'Enrollment already exists', } + enrollment = dynamodb_persistence_layer.collection.get_items( + TransactKey( + '8c9b55ef-e988-43ee-b2da-8594850605d7', + ) + + SortKey('0') + + SortKey('parent_vacancy', path_spec='vacancy') + + SortKey('related_ids#order', path_spec='order_id'), + ) + + assert succ['output']['id'] == '8c9b55ef-e988-43ee-b2da-8594850605d7' + assert enrollment['related_ids#order'] == '3CNrFB9dy2RLit2pdeUWy4' + assert enrollment['parent_vacancy'] == { + 'sk': '3CNrFB9dy2RLit2pdeUWy4#8c9b55ef-e988-43ee-b2da-8594850605d7', + 'id': 'vacancies#cJtK9SsnJhKPyxESe7g3DG', + } + + +def test_enroll_vacancy( + mock_app, + dynamodb_seeds, + dynamodb_persistence_layer: DynamoDBPersistenceLayer, + http_api_proxy: HttpApiProxy, + lambda_context: LambdaContext, +): + r = mock_app.lambda_handler( + http_api_proxy( + raw_path='/enrollments', + method=HTTPMethod.POST, + headers={'X-Tenant': 'cJtK9SsnJhKPyxESe7g3DG'}, + body={ + 'items': [ + { + 'user': { + 'id': '9a41e867-55e1-4573-bd27-7b5d1d1bcfde', + 'name': 'Tiago Maciel', + 'email': 'tiago@somosbeta.com.br', + 'cpf': '08679004901', + }, + 'course': { + 'id': '6d69a34a-cefd-40aa-a89b-dceb694c3e61', + 'name': 'pytest', + }, + 'deduplication_window': { + 'offset_days': 60, + }, + 'vacancy': { + 'id': 'vacancies#cJtK9SsnJhKPyxESe7g3DG', + 'sk': '3CNrFB9dy2RLit2pdeUWy4#does_not_exist', + }, + }, + ], + }, + ), + lambda_context, + ) + + assert r['statusCode'] == HTTPStatus.OK + fail, *_ = json.loads(r['body']) + assert fail['cause'] == { + 'type': 'VacancyDoesNotExistError', + 'message': 'Vacancy does not exist', + } + def test_vacancies( mock_app, @@ -119,11 +187,12 @@ def test_cancel_enrollment( assert r['statusCode'] == HTTPStatus.OK - collect = DynamoDBCollection(dynamodb_persistence_layer) - enrollment = collect.get_item(KeyPair('43ea4475-c369-4f90-b576-135b7df5106b', '0')) + enrollment = dynamodb_persistence_layer.collection.get_item( + KeyPair('43ea4475-c369-4f90-b576-135b7df5106b', '0') + ) assert enrollment['status'] == 'CANCELED' - vacancies = collect.query( + vacancies = dynamodb_persistence_layer.collection.query( PartitionKey(ComposeKey('cJtK9SsnJhKPyxESe7g3DG', 'vacancies')) ) - assert len(vacancies['items']) == 1 + assert len(vacancies['items']) == 2 diff --git a/http-api/tests/routes/test_users.py b/http-api/tests/routes/test_users.py index e1cd36f..5d11df6 100644 --- a/http-api/tests/routes/test_users.py +++ b/http-api/tests/routes/test_users.py @@ -247,12 +247,10 @@ def test_patch_email( assert r['statusCode'] == HTTPStatus.OK - collect = DynamoDBCollection(dynamodb_persistence_layer) - user = collect.get_item(KeyPair('5OxmMjL-ujoR5IMGegQz', '0')) - print(user) - # assert user['emails'] == { - # 'sergio@somosbeta.com.br', - # } + user = dynamodb_persistence_layer.collection.get_item( + KeyPair('5OxmMjL-ujoR5IMGegQz', '0') + ) + assert user['email'] == 'osergiosiqueira@gmail.com' def test_delete_email( diff --git a/http-api/tests/seeds.jsonl b/http-api/tests/seeds.jsonl index 54284fa..c03f26d 100644 --- a/http-api/tests/seeds.jsonl +++ b/http-api/tests/seeds.jsonl @@ -21,4 +21,5 @@ {"id": {"S": "email"}, "sk": {"S": "sergio@somosbeta.com.br"}} {"id": {"S": "cpf"}, "sk": {"S": "07879819908"}} {"id": {"S": "cpf"}, "sk": {"S": "08679004901"}} -{"id": {"S": "lock"}, "sk": {"S": "c2116a43f8f1aed659a10c83dab17ed3"}} \ No newline at end of file +{"id": {"S": "lock"}, "sk": {"S": "c2116a43f8f1aed659a10c83dab17ed3"}} +{"id": {"S": "vacancies#cJtK9SsnJhKPyxESe7g3DG"}, "sk": {"S": "3CNrFB9dy2RLit2pdeUWy4#8c9b55ef-e988-43ee-b2da-8594850605d7"}} \ No newline at end of file diff --git a/order-management/app/events/assign_tenant_cnpj.py b/order-management/app/events/assign_tenant_cnpj.py index 9cd7ca0..d3fbe44 100644 --- a/order-management/app/events/assign_tenant_cnpj.py +++ b/order-management/app/events/assign_tenant_cnpj.py @@ -65,11 +65,11 @@ def lambda_handler(event: EventBridgeEvent, context: LambdaContext) -> bool: ) for k, v in ids.items(): + kind = k.removesuffix('_id') transact.put( item={ 'id': new_image['id'], - 'sk': 'related_ids#%s' - % k.removesuffix('_id'), # e.g. related_ids#user + 'sk': f'related_ids#{kind}', # e.g. related_ids#user 'create_date': now_, k: v, }