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.include_router(courses.router, prefix='/courses')
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.cancel, prefix='/enrollments')
app.include_router(orders.router, prefix='/orders')

View File

@@ -13,7 +13,7 @@ class KonvivaError(BadRequestError):
pass
@dataclass
@dataclass(frozen=True)
class KonvivaToken:
login: 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 .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()

View File

@@ -1,28 +1,19 @@
from aws_lambda_powertools.event_handler.api_gateway import Router
from elasticsearch import Elasticsearch
from layercake.dynamodb import (
DynamoDBCollection,
DynamoDBPersistenceLayer,
KeyPair,
)
from pydantic import UUID4, BaseModel
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.authentication_middleware import User
from rules.enrollment import set_status_as_canceled
from .vacancies import router as vacancies
__all__ = ['vacancies']
router = Router()
elastic_client = Elasticsearch(**ELASTIC_CONN)
enrollment_layer = DynamoDBPersistenceLayer(ENROLLMENT_TABLE, dynamodb_client)
user_layer = DynamoDBPersistenceLayer(USER_TABLE, dynamodb_client)
user_collect = DynamoDBCollection(user_layer)
class Cancel(BaseModel):
@@ -37,7 +28,11 @@ class Cancel(BaseModel):
compress=True,
tags=['Enrollment'],
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):

View File

@@ -18,7 +18,7 @@ from config import (
)
from middlewares import Tenant, TenantMiddleware
from models import Course, Enrollment, User
from rules.enrollment import DeduplicationWindow, Vacancy, enroll
from rules.enrollment import DeduplicationWindow, Slot, enroll
router = Router()
@@ -32,16 +32,16 @@ processor = BatchProcessor()
class Item(BaseModel):
user: User
course: Course
vacancy: Vacancy | None = None
slot: Slot | None = None
deduplication_window: DeduplicationWindow | None = None
schedule_date: datetime | None = None
@property
def id(self) -> str:
if not self.vacancy:
if not self.slot:
return str(uuid.uuid4())
_, idx = self.vacancy.sk.split('#')
_, idx = self.slot.sk.split('#')
return idx
@@ -81,7 +81,7 @@ def handler(record: Item, context: dict):
'name': tenant.name,
},
deduplication_window=record.deduplication_window,
vacancy=record.vacancy,
slot=record.slot,
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)
class Vacancy:
class Slot:
id: str
sk: str
@@ -69,7 +69,7 @@ def enroll(
enrollment: Enrollment,
*,
tenant: Tenant,
vacancy: Vacancy | None = None,
slot: Slot | None = None,
author: Author | None = None,
linked_entities: frozenset[LinkedEntity] = frozenset(),
deduplication_window: DeduplicationWindow | None = None,
@@ -83,14 +83,14 @@ def enroll(
lock_hash = md5_hash('%s%s' % (user.id, course.id))
with persistence_layer.transact_writer() as transact:
if vacancy:
linked_entities = frozenset({vacancy.order_id}) | linked_entities
if slot:
linked_entities = frozenset({slot.order_id}) | linked_entities
transact.put(
item={
'sk': '0',
'create_date': now_,
'tenant': tenant_id,
'created_at': now_,
'tenant_id': tenant_id,
**enrollment.model_dump(),
},
)
@@ -157,14 +157,14 @@ def enroll(
}
)
if vacancy:
if slot:
transact.put(
item={
'id': enrollment.id,
# Post-migration: uncomment the following line
# 'sk': 'metadata#parent_slot',
'sk': 'parent_vacancy',
'vacancy': asdict(vacancy),
'vacancy': asdict(slot),
'created_at': now_,
}
)
@@ -174,7 +174,7 @@ def enroll(
super().__init__('Slot does not exist')
transact.delete(
key=KeyPair(vacancy.id, vacancy.sk),
key=KeyPair(slot.id, slot.sk),
cond_expr='attribute_exists(sk)',
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