add user if exists on konviva

This commit is contained in:
2025-07-11 13:55:32 -03:00
parent bd6fbf7166
commit 1b710c81f6
23 changed files with 1633 additions and 68 deletions

View File

@@ -53,7 +53,7 @@ app = APIGatewayHttpResolver(
app.use(middlewares=[AuthenticationMiddleware()]) app.use(middlewares=[AuthenticationMiddleware()])
app.include_router(courses.router, prefix='/courses') app.include_router(courses.router, prefix='/courses')
app.include_router(enrollments.router, prefix='/enrollments') app.include_router(enrollments.router, prefix='/enrollments')
app.include_router(enrollments.vacancies, prefix='/enrollments') app.include_router(enrollments.slots, prefix='/enrollments')
app.include_router(enrollments.enroll, prefix='/enrollments') app.include_router(enrollments.enroll, prefix='/enrollments')
app.include_router(enrollments.cancel, prefix='/enrollments') app.include_router(enrollments.cancel, prefix='/enrollments')
app.include_router(orders.router, prefix='/orders') app.include_router(orders.router, prefix='/orders')

View File

@@ -13,7 +13,7 @@ class KonvivaError(BadRequestError):
pass pass
@dataclass @dataclass(frozen=True)
class KonvivaToken: class KonvivaToken:
login: str login: str
token: str token: str

View File

@@ -15,9 +15,9 @@ from config import ELASTIC_CONN, ENROLLMENT_TABLE, USER_TABLE
from .cancel import router as cancel from .cancel import router as cancel
from .enroll import router as enroll from .enroll import router as enroll
from .vacancies import router as vacancies from .slots import router as slots
__all__ = ['vacancies', 'cancel', 'enroll'] __all__ = ['slots', 'cancel', 'enroll']
router = Router() router = Router()

View File

@@ -1,28 +1,19 @@
from aws_lambda_powertools.event_handler.api_gateway import Router from aws_lambda_powertools.event_handler.api_gateway import Router
from elasticsearch import Elasticsearch
from layercake.dynamodb import ( from layercake.dynamodb import (
DynamoDBCollection,
DynamoDBPersistenceLayer, DynamoDBPersistenceLayer,
KeyPair, KeyPair,
) )
from pydantic import UUID4, BaseModel from pydantic import UUID4, BaseModel
from boto3clients import dynamodb_client from boto3clients import dynamodb_client
from config import ELASTIC_CONN, ENROLLMENT_TABLE, USER_TABLE from config import ENROLLMENT_TABLE, USER_TABLE
from middlewares.audit_log_middleware import AuditLogMiddleware from middlewares.audit_log_middleware import AuditLogMiddleware
from middlewares.authentication_middleware import User from middlewares.authentication_middleware import User
from rules.enrollment import set_status_as_canceled from rules.enrollment import set_status_as_canceled
from .vacancies import router as vacancies
__all__ = ['vacancies']
router = Router() router = Router()
elastic_client = Elasticsearch(**ELASTIC_CONN)
enrollment_layer = DynamoDBPersistenceLayer(ENROLLMENT_TABLE, dynamodb_client) enrollment_layer = DynamoDBPersistenceLayer(ENROLLMENT_TABLE, dynamodb_client)
user_layer = DynamoDBPersistenceLayer(USER_TABLE, dynamodb_client) user_layer = DynamoDBPersistenceLayer(USER_TABLE, dynamodb_client)
user_collect = DynamoDBCollection(user_layer)
class Cancel(BaseModel): class Cancel(BaseModel):
@@ -37,7 +28,11 @@ class Cancel(BaseModel):
compress=True, compress=True,
tags=['Enrollment'], tags=['Enrollment'],
middlewares=[ middlewares=[
AuditLogMiddleware('ENROLLMENT_CANCEL', user_collect, ('id', 'course')) AuditLogMiddleware(
'ENROLLMENT_CANCEL',
collection=user_layer.collection,
audit_attrs=('id', 'course'),
),
], ],
) )
def cancel(id: str, payload: Cancel): def cancel(id: str, payload: Cancel):

View File

@@ -18,7 +18,7 @@ from config import (
) )
from middlewares import Tenant, TenantMiddleware from middlewares import Tenant, TenantMiddleware
from models import Course, Enrollment, User from models import Course, Enrollment, User
from rules.enrollment import DeduplicationWindow, Vacancy, enroll from rules.enrollment import DeduplicationWindow, Slot, enroll
router = Router() router = Router()
@@ -32,16 +32,16 @@ processor = BatchProcessor()
class Item(BaseModel): class Item(BaseModel):
user: User user: User
course: Course course: Course
vacancy: Vacancy | None = None slot: Slot | None = None
deduplication_window: DeduplicationWindow | None = None deduplication_window: DeduplicationWindow | None = None
schedule_date: datetime | None = None schedule_date: datetime | None = None
@property @property
def id(self) -> str: def id(self) -> str:
if not self.vacancy: if not self.slot:
return str(uuid.uuid4()) return str(uuid.uuid4())
_, idx = self.vacancy.sk.split('#') _, idx = self.slot.sk.split('#')
return idx return idx
@@ -81,7 +81,7 @@ def handler(record: Item, context: dict):
'name': tenant.name, 'name': tenant.name,
}, },
deduplication_window=record.deduplication_window, deduplication_window=record.deduplication_window,
vacancy=record.vacancy, slot=record.slot,
persistence_layer=enrollment_layer, persistence_layer=enrollment_layer,
) )

View File

@@ -1,39 +0,0 @@
from aws_lambda_powertools.event_handler.api_gateway import Router
from layercake.dynamodb import (
ComposeKey,
DynamoDBCollection,
DynamoDBPersistenceLayer,
PartitionKey,
)
from boto3clients import dynamodb_client
from config import (
ENROLLMENT_TABLE,
USER_TABLE,
)
from middlewares import Tenant, TenantMiddleware
router = Router()
enrollment_layer = DynamoDBPersistenceLayer(ENROLLMENT_TABLE, dynamodb_client)
user_layer = DynamoDBPersistenceLayer(USER_TABLE, dynamodb_client)
user_collect = DynamoDBCollection(user_layer)
enrollment_collect = DynamoDBCollection(enrollment_layer)
@router.get(
'/vacancies',
compress=True,
tags=['Enrollment'],
middlewares=[
TenantMiddleware(user_collect),
],
)
def get_vacancies():
tenant: Tenant = router.context['tenant']
return enrollment_collect.query(
PartitionKey(
ComposeKey(str(tenant.id), prefix='vacancies'),
)
)

View File

@@ -28,7 +28,7 @@ class LinkedEntity(str):
@dataclass(frozen=True) @dataclass(frozen=True)
class Vacancy: class Slot:
id: str id: str
sk: str sk: str
@@ -69,7 +69,7 @@ def enroll(
enrollment: Enrollment, enrollment: Enrollment,
*, *,
tenant: Tenant, tenant: Tenant,
vacancy: Vacancy | None = None, slot: Slot | None = None,
author: Author | None = None, author: Author | None = None,
linked_entities: frozenset[LinkedEntity] = frozenset(), linked_entities: frozenset[LinkedEntity] = frozenset(),
deduplication_window: DeduplicationWindow | None = None, deduplication_window: DeduplicationWindow | None = None,
@@ -83,14 +83,14 @@ def enroll(
lock_hash = md5_hash('%s%s' % (user.id, course.id)) lock_hash = md5_hash('%s%s' % (user.id, course.id))
with persistence_layer.transact_writer() as transact: with persistence_layer.transact_writer() as transact:
if vacancy: if slot:
linked_entities = frozenset({vacancy.order_id}) | linked_entities linked_entities = frozenset({slot.order_id}) | linked_entities
transact.put( transact.put(
item={ item={
'sk': '0', 'sk': '0',
'create_date': now_, 'created_at': now_,
'tenant': tenant_id, 'tenant_id': tenant_id,
**enrollment.model_dump(), **enrollment.model_dump(),
}, },
) )
@@ -157,14 +157,14 @@ def enroll(
} }
) )
if vacancy: if slot:
transact.put( transact.put(
item={ item={
'id': enrollment.id, 'id': enrollment.id,
# Post-migration: uncomment the following line # Post-migration: uncomment the following line
# 'sk': 'metadata#parent_slot', # 'sk': 'metadata#parent_slot',
'sk': 'parent_vacancy', 'sk': 'parent_vacancy',
'vacancy': asdict(vacancy), 'vacancy': asdict(slot),
'created_at': now_, 'created_at': now_,
} }
) )
@@ -174,7 +174,7 @@ def enroll(
super().__init__('Slot does not exist') super().__init__('Slot does not exist')
transact.delete( transact.delete(
key=KeyPair(vacancy.id, vacancy.sk), key=KeyPair(slot.id, slot.sk),
cond_expr='attribute_exists(sk)', cond_expr='attribute_exists(sk)',
exc_cls=SlotDoesNotExistError, exc_cls=SlotDoesNotExistError,
) )

View File

@@ -0,0 +1,13 @@
import os
import boto3
def get_dynamodb_client():
if os.getenv('AWS_LAMBDA_FUNCTION_NAME'):
return boto3.client('dynamodb')
return boto3.client('dynamodb', endpoint_url='http://127.0.0.1:8000')
dynamodb_client = get_dynamodb_client()

View File

@@ -0,0 +1,6 @@
import os
USER_TABLE: str = os.getenv('USER_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

View File

@@ -0,0 +1,70 @@
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 glom import glom
from layercake.dateutils import now
from layercake.dynamodb import DynamoDBPersistenceLayer, KeyPair
import konviva
from boto3clients import dynamodb_client
from config import USER_TABLE
logger = Logger(__name__)
user_layer = DynamoDBPersistenceLayer(USER_TABLE, dynamodb_client)
class UserNotFoundError(Exception): ...
@event_source(data_class=EventBridgeEvent)
@logger.inject_lambda_context
def lambda_handler(event: EventBridgeEvent, context: LambdaContext) -> bool:
new_image = event.detail['new_image']
now_ = now()
try:
user_id = konviva.create_user(
id=new_image['id'],
name=new_image['name'],
email=new_image['email'],
cpf=new_image.get('cpf', None),
)
except konviva.EmailAlreadyExists:
logger.info(
'Email already exists, retrieving existing user',
email=new_image['email'],
)
r = konviva.get_users_by_email(new_image['email'])
user_id = glom(r, '0.IDUsuario')
if not r:
raise UserNotFoundError()
except Exception:
raise
with user_layer.transact_writer() as transact:
transact.update(
key=KeyPair(new_image['id'], '0'),
update_expr='SET metadata__konviva_user_id = :user_id, \
updated_at = :updated_at',
expr_attr_values={
':user_id': user_id,
':updated_at': now_,
},
cond_expr='attribute_exists(sk)',
)
# Post-migration: remove the following line
transact.put(
item={
'id': new_image['id'],
'sk': 'konviva',
'konvivaId': user_id,
'created_at': now_,
}
)
return True

View File

@@ -0,0 +1,99 @@
import random
import string
from dataclasses import dataclass
from urllib.parse import quote_plus, urlparse
import requests
from aws_lambda_powertools.event_handler.exceptions import BadRequestError
from glom import glom
from config import KONVIVA_API_URL, KONVIVA_SECRET_KEY
ALU = 2
GES = 3
headers = {
'Authorization': f'KONVIVA {KONVIVA_SECRET_KEY}',
'Content-Type': 'application/json',
}
def random_str(maxlen: int = 10) -> str:
"""Returns a random string of letters."""
return ''.join(random.choice(string.ascii_letters) for _ in range(maxlen))
class KonvivaError(BadRequestError):
pass
class EmailAlreadyExists(KonvivaError):
pass
@dataclass(frozen=True)
class User:
IDUsuario: int
Identificador: str
NomeUsuario: str
Email: str
CPF: str | None = None
def create_user(
id: str,
name: str,
email: str,
cpf: str | None,
) -> dict:
url = urlparse(KONVIVA_API_URL)._replace(
path='/action/api/integrarUsuario',
query='sendMail=false',
)
r = requests.post(
url=url.geturl(),
headers=headers,
json={
'Identificador': id,
'NomeUsuario': name,
'Login': email,
'Email': email,
'CPF': cpf,
'Senha': random_str(30),
'Situacao': 'ATIVO',
'UnidadesPerfil': [
{
'IDPerfil': ALU,
'CODPerfil': 'ALU',
'CODUnidade': '1380e6d0-fee8-4cb9-9bf0-a087e703b1a4',
}
],
},
)
r.raise_for_status()
# Because Konviva does not return the proper HTTP status code
if err := glom(r.json(), 'errors', default=None):
err = err[0] if isinstance(err, list) else err
raise EmailAlreadyExists(err)
return r.json().get('IDUsuario')
def get_users_by_email(email: str) -> list[dict]:
url = urlparse(KONVIVA_API_URL)._replace(
path='/action/api/getUsuariosByQuery',
query=f'q=Email=={quote_plus(email)}',
)
r = requests.get(
url=url.geturl(),
headers=headers,
)
r.raise_for_status()
if glom(r.json(), '0.errors', default=None):
return []
return r.json()

6
konviva-events/config.py Normal file
View File

@@ -0,0 +1,6 @@
import os
USER_TABLE: str = os.getenv('USER_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

View File

@@ -0,0 +1,33 @@
[project]
name = "konviva-events"
version = "0.1.0"
description = ""
readme = ""
requires-python = ">=3.13"
dependencies = ["layercake"]
[dependency-groups]
dev = [
"jsonlines>=4.0.0",
"pytest>=8.3.4",
"pytest-cov>=6.0.0",
"ruff>=0.9.1",
]
[tool.pytest.ini_options]
pythonpath = ["app/"]
addopts = "--cov --cov-report html -v"
[tool.ruff]
target-version = "py311"
src = ["app"]
[tool.ruff.format]
quote-style = "single"
[tool.ruff.lint]
select = ["E", "F", "I"]
[tool.uv.sources]
layercake = { path = "../layercake" }

View File

@@ -0,0 +1,3 @@
{
"extraPaths": ["app/"]
}

View File

@@ -0,0 +1,9 @@
version = 0.1
[default.deploy.parameters]
stack_name = "saladeaula-konviva-events"
resolve_s3 = true
s3_prefix = "konviva-events"
region = "sa-east-1"
confirm_changeset = false
capabilities = "CAPABILITY_IAM"
image_repositories = []

View File

@@ -0,0 +1,57 @@
AWSTemplateFormatVersion: 2010-09-09
Transform: AWS::Serverless-2016-10-31
Parameters:
UserTable:
Type: String
Default: betaeducacao-prod-users_d2o3r5gmm4it7j
Globals:
Function:
CodeUri: app/
Runtime: python3.13
Tracing: Active
Architectures:
- x86_64
Layers:
- !Sub arn:aws:lambda:sa-east-1:336641857101:layer:layercake:79
Environment:
Variables:
TZ: America/Sao_Paulo
LOG_LEVEL: DEBUG
DYNAMODB_PARTITION_KEY: id
POWERTOOLS_LOGGER_SAMPLE_RATE: 0.1
POWERTOOLS_LOGGER_LOG_EVENT: true
USER_TABLE: !Ref UserTable
KONVIVA_API_URL: https://lms.saladeaula.digital
KONVIVA_SECRET_KEY: "{{resolve:ssm:/betaeducacao/konviva/secret_key/str}}"
Resources:
EventLog:
Type: AWS::Logs::LogGroup
Properties:
RetentionInDays: 90
EventCreateUserFunction:
Type: AWS::Serverless::Function
Properties:
Handler: events.create_user.lambda_handler
LoggingConfig:
LogGroup: !Ref EventLog
Policies:
- DynamoDBWritePolicy:
TableName: !Ref UserTable
Events:
DynamoDBEvent:
Type: EventBridgeRule
Properties:
Pattern:
resources: [!Ref UserTable]
detail-type: [INSERT]
detail:
new_image:
sk: ["0"]
cnpj:
- exists: false
metadata__konviva_user_id:
- exists: false

View File

View File

@@ -0,0 +1,63 @@
import os
from dataclasses import dataclass
import jsonlines
import pytest
PYTEST_TABLE_NAME = 'pytest'
PK = 'id'
SK = 'sk'
# https://docs.pytest.org/en/7.1.x/reference/reference.html#pytest.hookspec.pytest_configure
def pytest_configure():
os.environ['TZ'] = 'America/Sao_Paulo'
os.environ['DYNAMODB_PARTITION_KEY'] = PK
os.environ['DYNAMODB_SORT_KEY'] = SK
os.environ['USER_TABLE'] = PYTEST_TABLE_NAME
os.environ['KONVIVA_API_URL'] = 'https://lms.saladeaula.digital'
@dataclass
class LambdaContext:
function_name: str = 'test'
memory_limit_in_mb: int = 128
invoked_function_arn: str = 'arn:aws:lambda:eu-west-1:809313241:function:test'
aws_request_id: str = '52fdfc07-2182-154f-163f-5f0f9a621d72'
@pytest.fixture
def lambda_context() -> LambdaContext:
return LambdaContext()
@pytest.fixture
def dynamodb_client():
from boto3clients import dynamodb_client as client
client.create_table(
AttributeDefinitions=[
{'AttributeName': PK, 'AttributeType': 'S'},
{'AttributeName': SK, 'AttributeType': 'S'},
],
TableName=PYTEST_TABLE_NAME,
KeySchema=[
{'AttributeName': PK, 'KeyType': 'HASH'},
{'AttributeName': SK, 'KeyType': 'RANGE'},
],
ProvisionedThroughput={
'ReadCapacityUnits': 123,
'WriteCapacityUnits': 123,
},
)
yield client
client.delete_table(TableName=PYTEST_TABLE_NAME)
@pytest.fixture()
def dynamodb_seeds(dynamodb_client):
with jsonlines.open('tests/seeds.jsonl') as lines:
for line in lines:
dynamodb_client.put_item(TableName=PYTEST_TABLE_NAME, Item=line)

View File

View File

@@ -0,0 +1,19 @@
from aws_lambda_powertools.utilities.typing import LambdaContext
import events.create_user as app
def test_create_user(dynamodb_client, dynamodb_seeds, lambda_context: LambdaContext):
event = {
'detail': {
'new_image': {
'id': '123',
'sk': '0',
'name': 'Sérgio R Siqueira',
'email': 'sergio@somosbeta.com.br',
'cpf': '07879819908',
}
}
}
assert app.lambda_handler(event, lambda_context) # type: ignore

View File

@@ -0,0 +1 @@
{"id": {"S": "123"}, "sk": {"S": "0"}}

View File

@@ -0,0 +1,23 @@
import pytest
import konviva
def test_create_user_email_exists():
with pytest.raises(konviva.KonvivaError):
konviva.create_user(
id='',
name='Sérgio R Siquira',
email='sergio@somosbeta.com.br',
cpf='0879819908',
)
def test_get_users_by_email():
r = konviva.get_users_by_email('sergio@somosbeta.com.br')
assert len(r) >= 1
def test_get_users_by_email_notfound():
r = konviva.get_users_by_email('fake@fake.com')
assert r == []

1207
konviva-events/uv.lock generated Normal file

File diff suppressed because it is too large Load Diff