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,
}