diff --git a/.github/workflows/CD.yml b/.github/workflows/CD.yml
index fec4dee..0bc90cf 100644
--- a/.github/workflows/CD.yml
+++ b/.github/workflows/CD.yml
@@ -22,10 +22,10 @@ jobs:
- name: Checkout repository
uses: actions/checkout@v4
- - name: Setup Python 3.9
+ - name: Setup Python 3.13
uses: actions/setup-python@v5
with:
- python-version: '3.9'
+ python-version: '3.13'
- name: Set AWS Account ID and other variables
run: |
@@ -41,7 +41,7 @@ jobs:
echo "STACK_NAME=DevMediasStack${{github.ref_name}}" >> $GITHUB_ENV
- name: Setup AWS Credentials
- uses: aws-actions/configure-aws-credentials@v4 # --> ATUALIZADO
+ uses: aws-actions/configure-aws-credentials@v4
with:
aws-region: ${{ vars.AWS_REGION }}
role-to-assume: arn:aws:iam::${{ env.AWS_ACCOUNT_ID }}:role/GithubActionsRole
@@ -51,7 +51,7 @@ jobs:
run: |
npm install -g aws-cdk
cd iac
- pip install -r requirements.txt
+ pip install -r requirements-infra.txt
- name: DeployWithCDK
run: |
diff --git a/.github/workflows/CI.yml b/.github/workflows/CI.yml
index b117781..2112f59 100644
--- a/.github/workflows/CI.yml
+++ b/.github/workflows/CI.yml
@@ -11,14 +11,14 @@ jobs:
steps:
- uses: actions/checkout@v2
- - name: Set up Python 3.9
+ - name: Set up Python 3.13
uses: actions/setup-python@v2
with:
- python-version: 3.9
+ python-version: 3.13
- name: Install dependencies
run: |
python -m pip install --upgrade pip
- pip install -r requirements-dev.txt
+ pip install -r requirements-app.txt -r requirements-dev.txt
- name: Runs tests
run: pytest
env:
diff --git a/.gitignore b/.gitignore
index a67768f..6815f0e 100644
--- a/.gitignore
+++ b/.gitignore
@@ -134,3 +134,10 @@ dmypy.json
/.vscode/
/.idea/
/iac/lambda_layer_out_temp/
+
+.DS_Store
+**/.DS_Store
+
+/docs
+/PLANOS DE ENSINO 2026
+.cursor
\ No newline at end of file
diff --git a/iac/adjust_layer_directory.py b/iac/adjust_layer_directory.py
index 1e69bd9..d4db5f6 100644
--- a/iac/adjust_layer_directory.py
+++ b/iac/adjust_layer_directory.py
@@ -6,7 +6,7 @@
# --- Configurações ---
BUILD_DIRECTORY = "build"
PYTHON_TOP_LEVEL_DIR = os.path.join(BUILD_DIRECTORY, "python")
-REQUIREMENTS_FILE = "requirements-layer.txt"
+REQUIREMENTS_FILE = "requirements-app.txt"
# --- CONSTRUÇÃO CORRETA DO CAMINHO ---
# Pega o diretório do projeto (a raiz 'dev_medias_back') subindo um nível a partir do script atual.
diff --git a/iac/app.py b/iac/app.py
index e3c5d21..26a9bc7 100644
--- a/iac/app.py
+++ b/iac/app.py
@@ -4,7 +4,7 @@
import aws_cdk as cdk
from adjust_layer_directory import adjust_layer_directory
-from iac.iac_stack import IacStack
+from stack.iac_stack import IacStack
print("Starting the CDK")
@@ -19,27 +19,26 @@
aws_account_id = os.environ.get("AWS_ACCOUNT_ID")
stack_name = os.environ.get("STACK_NAME")
-github_ref_name = os.environ.get("GITHUB_REF_NAME")
-
-if 'prod' == github_ref_name:
- stage = 'PROD'
-
-elif 'homolog' == github_ref_name:
- stage = 'HOMOLOG'
-
-elif 'dev' == github_ref_name:
- stage = 'DEV'
-
-else:
- stage = 'TEST'
+stage = os.environ.get("GITHUB_REF_NAME").capitalize()
+stack_name = os.environ.get("STACK_NAME")
tags = {
'project': 'DevMedias',
'stage': stage,
- 'stack': 'BACK',
+ 'stack': stack_name,
'owner': 'DevCommunity',
}
-IacStack(app, stack_name, env=cdk.Environment(account=aws_account_id, region=aws_region), tags=tags)
+IacStack(
+ app,
+ stack_id=stack_name,
+ stack_name=stack_name,
+ stage=stage,
+ env=cdk.Environment(
+ account=aws_account_id,
+ region=aws_region
+ ),
+ tags=tags
+)
app.synth()
diff --git a/iac/components/apigw_construct.py b/iac/components/apigw_construct.py
new file mode 100644
index 0000000..c63830e
--- /dev/null
+++ b/iac/components/apigw_construct.py
@@ -0,0 +1,52 @@
+from aws_cdk import aws_apigateway as apigateway
+from constructs import Construct
+from aws_cdk.aws_apigateway import RestApi, Cors, CorsOptions
+
+class ApigwConstruct(Construct):
+ rest_api: RestApi
+
+ def __init__(self, scope: Construct, construct_id: str, stage: str, **kwargs):
+ super().__init__(scope, construct_id, **kwargs)
+
+ self.stage = stage
+
+ cors_options = CorsOptions(
+ allow_origins=Cors.ALL_ORIGINS,
+ allow_methods=["GET", "POST", "PUT", "DELETE", "OPTIONS"],
+ allow_headers=Cors.DEFAULT_HEADERS
+ )
+
+ self.rest_api = RestApi(
+ self,
+ id=f"DevMedias_RestApi_{self.stage}",
+ rest_api_name=f"DevMedias_RestApi_{self.stage}",
+ description=f"This is the DevMedias RestApi for {self.stage}",
+ deploy_options=apigateway.StageOptions(
+ stage_name=stage.lower(),
+ logging_level=apigateway.MethodLoggingLevel.OFF,
+ data_trace_enabled=False,
+ metrics_enabled=True,
+ ),
+ default_cors_preflight_options=cors_options,
+ )
+
+ # implementação de uma key para mínima proteção de rotas abertas como create_curso
+
+ api_key = self.rest_api.add_api_key(
+ id="AdminApiKey",
+ api_key_name="admin-key"
+ )
+
+ plan = self.rest_api.add_usage_plan("UsagePlan",
+ name="AdminPlan",
+ api_stages=[apigateway.UsagePlanPerApiStage(
+ api=self.rest_api,
+ stage=self.rest_api.deployment_stage,
+ )]
+ )
+ plan.add_api_key(api_key)
+
+ self.api_gateway_resource = self.rest_api.root.add_resource(
+ path_part="mss-medias",
+ default_cors_preflight_options=cors_options
+ )
\ No newline at end of file
diff --git a/iac/components/dynamo_construct.py b/iac/components/dynamo_construct.py
new file mode 100644
index 0000000..3edad79
--- /dev/null
+++ b/iac/components/dynamo_construct.py
@@ -0,0 +1,51 @@
+from aws_cdk import (
+ RemovalPolicy,
+ aws_dynamodb as dynamodb,
+)
+from constructs import Construct
+
+# Manter alinhado com src.shared.infra.external.dynamo.academic_catalog_naming.ACADEMIC_CATALOG_TABLE_PREFIX
+_ACADEMIC_CATALOG_PREFIX = "DevMediasAcademicCatalogTable"
+
+RETAINED_STAGES = {"prod", "homolog"}
+
+
+class DynamoConstruct(Construct):
+ """Tabela single-table (pk + sk) para cursos e disciplinas."""
+
+ academic_catalog_table: dynamodb.Table
+
+ def __init__(
+ self,
+ scope: Construct,
+ construct_id: str,
+ stack_name: str,
+ stage: str,
+ **kwargs,
+ ) -> None:
+ super().__init__(scope, construct_id, **kwargs)
+
+ stage_lower = stage.lower()
+
+ removal_policy = (
+ RemovalPolicy.RETAIN if stage_lower in RETAINED_STAGES else RemovalPolicy.DESTROY
+ )
+
+ self.academic_catalog_table = dynamodb.Table(
+ self,
+ id="AcademicCatalogTable",
+ partition_key=dynamodb.Attribute(
+ name="pk",
+ type=dynamodb.AttributeType.STRING,
+ ),
+ sort_key=dynamodb.Attribute(
+ name="sk",
+ type=dynamodb.AttributeType.STRING,
+ ),
+ billing_mode=dynamodb.BillingMode.PAY_PER_REQUEST,
+ removal_policy=removal_policy,
+ table_name=f"{_ACADEMIC_CATALOG_PREFIX}-{stage_lower}",
+ point_in_time_recovery_specification=dynamodb.PointInTimeRecoverySpecification(
+ point_in_time_recovery_enabled=(stage_lower == "prod")
+ ),
+ )
diff --git a/iac/components/lambda_construct.py b/iac/components/lambda_construct.py
new file mode 100644
index 0000000..d5c422a
--- /dev/null
+++ b/iac/components/lambda_construct.py
@@ -0,0 +1,215 @@
+from aws_cdk import (
+ aws_lambda as lambda_,
+ aws_s3 as s3,
+ aws_s3_notifications as s3n,
+ Duration
+)
+from aws_cdk import aws_iam as iam
+from constructs import Construct
+from aws_cdk.aws_apigateway import Resource, LambdaIntegration
+
+
+class LambdaConstruct(Construct):
+
+ stage: str
+ stack_name: str
+ funtions_that_need_dynamo_db_access: list[lambda_.Function] = []
+
+ def create_lambda_api_gateway_integration(
+ self,
+ module_name: str,
+ method: str,
+ api_resource: Resource,
+ api_key_required: bool = False,
+ environment_variables: dict = {"STAGE": "TEST"},
+ public: bool = False,
+ subfolder: str = "",
+ ) -> lambda_.Function:
+
+ code = lambda_.Code.from_asset(f"../src/modules/{subfolder}/{module_name}") if subfolder else lambda_.Code.from_asset(f"../src/modules/{module_name}")
+ handler = f"app.{module_name}_presenter.lambda_handler"
+
+ function = lambda_.Function(
+ self, module_name.title(),
+ code=code,
+ handler=handler,
+ function_name=f"{module_name}-{self.stack_name}-{self.stage}"[:63],
+ runtime=lambda_.Runtime.PYTHON_3_13,
+ layers=[self.lambda_layer],
+ environment=environment_variables,
+ timeout=Duration.seconds(30),
+ memory_size=512
+ )
+
+ if public:
+ api_resource.add_resource("public").add_resource(module_name.replace("_", "-")).add_method(
+ method,
+ integration=LambdaIntegration(function),
+ api_key_required=api_key_required
+ )
+ else:
+ api_resource.add_resource(module_name.replace("_", "-")).add_method(
+ method,
+ integration=LambdaIntegration(function),
+ api_key_required=api_key_required
+ )
+
+ return function
+
+ def create_lambda_s3_object_creation_deletion_trigger_integration(
+ self,
+ module_name: str,
+ bucket_plans: s3.Bucket,
+ bucket_subjects: s3.Bucket,
+ environment_variables: dict
+ ) -> lambda_.Function:
+
+ function = lambda_.Function(
+ self,
+ module_name.title(),
+ code=lambda_.Code.from_asset(f"../src/modules/{ module_name }"),
+ handler=f"app.{module_name}_presenter.lambda_handler",
+ function_name=f"{module_name}-{self.stack_name}-{self.stage}"[:63],
+ runtime=lambda_.Runtime.PYTHON_3_13,
+ layers=[self.lambda_layer],
+ environment=environment_variables,
+ timeout=Duration.seconds(300), # increased time for excel and bedrock
+ memory_size=1024
+ )
+
+ bucket_plans.add_event_notification(
+ s3.EventType.OBJECT_CREATED,
+ s3n.LambdaDestination(function)
+ )
+
+ # bucket.add_event_notification(
+ # s3.EventType.OBJECT_REMOVED_DELETE,
+ # s3n.LambdaDestination(function)
+ # )
+
+ bucket_plans.grant_read(function) # read the plans
+ bucket_subjects.grant_read(function) # read all subjects
+ bucket_subjects.grant_write(function) # write all subjects
+
+ return function
+
+
+ def __init__(
+ self,
+ scope: Construct,
+ construct_id: str,
+ stage: str,
+ stack_name: str,
+ api_gateway_resource: Resource,
+ plans_bucket: s3.Bucket,
+ subject_bucket: s3.Bucket,
+ environment_variables: dict,
+ **kargs
+ ) -> None:
+
+ super().__init__(scope, construct_id, **kargs)
+
+ self.stage = stage
+ self.stack_name = stack_name
+
+ self.lambda_layer = lambda_.LayerVersion(
+ self,
+ id=f"{stack_name}_LambdaLayer_{stage}",
+ layer_version_name=f"{stack_name}-LambdaLayer-{self.stage}",
+ # a pasta .build foi obtida do adjust layer directory, certifique-se de que a configuração da pasta layer gerada la esta igual
+ code=lambda_.Code.from_asset("./build"),
+ compatible_runtimes=[lambda_.Runtime.PYTHON_3_13]
+ )
+
+ self.contact_us = self.create_lambda_api_gateway_integration(
+ module_name="contact_us",
+ method="POST",
+ api_resource=api_gateway_resource,
+ environment_variables=environment_variables,
+ public=True
+ )
+
+ ses_send_policy = iam.PolicyStatement(
+ effect=iam.Effect.ALLOW,
+ actions=["ses:SendEmail"],
+ resources=["*"],
+ conditions={
+ "StringEquals": {
+ "ses:FromAddress": environment_variables.get("FROM_EMAIL")
+ }
+ }
+ )
+ self.contact_us.add_to_role_policy(ses_send_policy)
+
+ self.grade_optimizer_function = self.create_lambda_api_gateway_integration(
+ module_name="grade_optmizer",
+ method="POST",
+ api_resource=api_gateway_resource,
+ environment_variables=environment_variables
+ )
+
+ self.genetic_algorithm_function = self.create_lambda_api_gateway_integration(
+ module_name="genetic_algorithm",
+ method="POST",
+ api_resource=api_gateway_resource,
+ environment_variables=environment_variables
+ )
+
+ self.calculate_mean_function = self.create_lambda_api_gateway_integration(
+ module_name="calculate_mean",
+ method="POST",
+ api_resource=api_gateway_resource,
+ environment_variables=environment_variables
+ )
+
+ self.plans_extractor_function = self.create_lambda_s3_object_creation_deletion_trigger_integration(
+ module_name="plans_extractor",
+ bucket_plans=plans_bucket,
+ bucket_subjects=subject_bucket,
+ environment_variables=environment_variables
+ )
+
+ self.get_all_disciplinas_function = self.create_lambda_api_gateway_integration(
+ module_name="get_all_disciplinas",
+ method="GET",
+ api_resource=api_gateway_resource,
+ environment_variables=environment_variables,
+ subfolder="disciplina"
+ )
+
+ self.get_all_cursos_function = self.create_lambda_api_gateway_integration(
+ module_name="get_all_cursos",
+ method="GET",
+ api_resource=api_gateway_resource,
+ environment_variables=environment_variables,
+ subfolder="curso"
+ )
+
+ self.create_curso_function = self.create_lambda_api_gateway_integration(
+ module_name="create_curso",
+ method="POST",
+ api_resource=api_gateway_resource,
+ environment_variables=environment_variables,
+ subfolder="curso",
+ api_key_required=True
+ )
+
+ bedrock_policy = iam.PolicyStatement(
+ effect=iam.Effect.ALLOW,
+ actions=[
+ "bedrock:InvokeModel",
+ "aws-marketplace:ViewSubscriptions",
+ "aws-marketplace:Subscribe"
+ ],
+ resources=["*"]
+ )
+
+ self.plans_extractor_function.add_to_role_policy(
+ bedrock_policy
+ )
+
+ self.funtions_that_need_dynamo_db_access.append(self.plans_extractor_function)
+ self.funtions_that_need_dynamo_db_access.append(self.get_all_disciplinas_function)
+ self.funtions_that_need_dynamo_db_access.append(self.get_all_cursos_function)
+ self.funtions_that_need_dynamo_db_access.append(self.create_curso_function)
+
\ No newline at end of file
diff --git a/iac/components/s3_construct.py b/iac/components/s3_construct.py
new file mode 100644
index 0000000..5f9106a
--- /dev/null
+++ b/iac/components/s3_construct.py
@@ -0,0 +1,106 @@
+from constructs import Construct
+from aws_cdk import Duration, RemovalPolicy, Aws
+from aws_cdk import aws_cloudfront as cloudfront, aws_cloudfront_origins as origins, aws_s3
+
+
+class S3Construct(Construct):
+ plans_bucket: aws_s3.Bucket
+ subject_bucket: aws_s3.Bucket
+ cloudfront_distribution_plans: cloudfront.Distribution
+ cloudfront_distribution_subjects: cloudfront.Distribution
+
+ def _build_distribution(
+ self,
+ distribution_id: str,
+ bucket: aws_s3.Bucket,
+ stage: str,
+ default_ttl: Duration,
+ ) -> cloudfront.Distribution:
+ cache_policy = cloudfront.CachePolicy(
+ self,
+ f"{distribution_id}CachePolicy",
+ cache_policy_name=f"DevMedias-{distribution_id}-Cache-{stage}",
+ comment=f"Cache policy for {distribution_id}",
+ min_ttl=Duration.seconds(1),
+ max_ttl=Duration.days(365),
+ default_ttl=default_ttl,
+ enable_accept_encoding_gzip=True,
+ enable_accept_encoding_brotli=True,
+ )
+
+ origin_request_policy = cloudfront.OriginRequestPolicy(
+ self,
+ f"{distribution_id}OriginRequestPolicy",
+ origin_request_policy_name=f"DevMedias-{distribution_id}-ORP-{stage}",
+ comment=f"Origin request policy for {distribution_id}",
+ header_behavior=cloudfront.OriginRequestHeaderBehavior.allow_list(
+ "Origin",
+ "Access-Control-Request-Headers",
+ "Access-Control-Request-Method",
+ ),
+ )
+
+ return cloudfront.Distribution(
+ self,
+ id=distribution_id,
+ comment=f"DevMedias {distribution_id} S3 CDN {stage}",
+ price_class=cloudfront.PriceClass.PRICE_CLASS_ALL,
+ default_behavior=cloudfront.BehaviorOptions(
+ origin=origins.S3BucketOrigin.with_origin_access_control(bucket),
+ compress=True,
+ allowed_methods=cloudfront.AllowedMethods.ALLOW_GET_HEAD_OPTIONS,
+ cached_methods=cloudfront.CachedMethods.CACHE_GET_HEAD_OPTIONS,
+ viewer_protocol_policy=cloudfront.ViewerProtocolPolicy.REDIRECT_TO_HTTPS,
+ cache_policy=cache_policy,
+ origin_request_policy=origin_request_policy,
+ response_headers_policy=cloudfront.ResponseHeadersPolicy.CORS_ALLOW_ALL_ORIGINS_WITH_PREFLIGHT,
+ ),
+ )
+
+ def create_bucket_with_distribution(
+ self,
+ *,
+ resource_prefix: str,
+ bucket_name: str,
+ default_ttl: Duration,
+ stage: str,
+ ) -> tuple[aws_s3.Bucket, cloudfront.Distribution]:
+ bucket = aws_s3.Bucket(
+ self,
+ f"{resource_prefix}Bucket",
+ bucket_name=bucket_name,
+ block_public_access=aws_s3.BlockPublicAccess.BLOCK_ALL,
+ removal_policy=self.removal_policy,
+ auto_delete_objects=self.removal_policy == RemovalPolicy.DESTROY,
+ )
+
+ distribution = self._build_distribution(
+ distribution_id=f"CloudFrontDistribution{resource_prefix}",
+ bucket=bucket,
+ stage=stage,
+ default_ttl=default_ttl,
+ )
+
+ return bucket, distribution
+
+ def __init__(self, scope: Construct, construct_id: str, stage: str, **kwargs):
+ super().__init__(scope, construct_id, **kwargs)
+
+ self.stage = stage.lower()
+ self.removal_policy = RemovalPolicy.RETAIN if stage.upper() == "PROD" else RemovalPolicy.DESTROY
+
+ identifier = f"2026-{Aws.ACCOUNT_ID}-{Aws.REGION}"
+
+ self.plans_bucket, self.cloudfront_distribution_plans = self.create_bucket_with_distribution(
+ resource_prefix="Plans",
+ bucket_name=f"devmedias-plans-{self.stage}-{identifier}",
+ default_ttl=Duration.seconds(30),
+ stage=stage,
+ )
+
+ self.subject_bucket, self.cloudfront_distribution_subjects = self.create_bucket_with_distribution(
+ resource_prefix="Subjects",
+ bucket_name=f"devmedias-subjects-{self.stage}-{identifier}",
+ default_ttl=Duration.seconds(86400),
+ stage=stage,
+ )
\ No newline at end of file
diff --git a/iac/components/ssm_construct.py b/iac/components/ssm_construct.py
new file mode 100644
index 0000000..080b78b
--- /dev/null
+++ b/iac/components/ssm_construct.py
@@ -0,0 +1,50 @@
+from constructs import Construct
+from aws_cdk import Resource, aws_ssm as ssm
+from aws_cdk.aws_apigateway import RestApi
+from aws_cdk import aws_s3 as s3
+
+class SsmConstruct(Construct):
+
+ def __init__(
+ self,
+ scope: Construct,
+ construct_id: str,
+ stage: str,
+ mss_name_identification_for_path: str,
+ api: RestApi,
+ api_gateway_resource: Resource,
+ buckets: dict[str, s3.Bucket] = None,
+ extra_params: dict[str, str] = None,
+ **kwargs
+ ):
+ super().__init__(scope, construct_id, **kwargs)
+
+ # é necessário a '/' após a url pois no CD do front estamos contando como se ela ja estivesse la
+
+ # stage lower é necessário aqui pois no actions do front, stage é recebido como lower
+
+ stage = stage.lower()
+
+ mss_name_identification_for_path = mss_name_identification_for_path.lower().replace("-", "_")
+
+ if api:
+ ssm.StringParameter(self,
+ id=f"ApiUrl_{stage}",
+ parameter_name=f"/{mss_name_identification_for_path}/{stage}/api/url",
+ string_value=f"{api.url}{api_gateway_resource.path.lstrip('/')}/"
+ )
+
+ for logical_name, bucket in (buckets or {}).items():
+ ssm.StringParameter(self,
+ id=f"Bucket_{logical_name}_{stage}",
+ parameter_name=f"/{mss_name_identification_for_path}/{stage}/buckets/{logical_name}",
+ string_value=bucket.bucket_name
+ )
+
+ for key, value in (extra_params or {}).items():
+ safe_id = key.replace("/", "_")
+ ssm.StringParameter(self,
+ id=f"Extra_{safe_id}_{stage}",
+ parameter_name=f"/{mss_name_identification_for_path}/{stage}/{key}",
+ string_value=value
+ )
\ No newline at end of file
diff --git a/iac/iac/iac_stack.py b/iac/iac/iac_stack.py
deleted file mode 100644
index 0fbbfa0..0000000
--- a/iac/iac/iac_stack.py
+++ /dev/null
@@ -1,115 +0,0 @@
-import os
-from aws_cdk import (
- aws_lambda as lambda_,
- aws_apigateway as apigateway,
- aws_logs as logs,
- aws_iam as iam,
- Stack
-)
-
-from constructs import Construct
-
-from .plans_stack import PlansStack
-
-
-from .lambda_stack import LambdaStack
-from aws_cdk.aws_apigateway import RestApi, Cors
-
-from .subject_stack import SubjectStack
-from .lambda_contact_us_stack import LambdaContactUsStack
-
-import json
-
-class IacStack(Stack):
- lambda_stack: LambdaStack
-
- def __init__(self, scope: Construct, construct_id: str, **kwargs) -> None:
- super().__init__(scope, construct_id, **kwargs)
-
- self.github_ref_name = os.environ.get("GITHUB_REF_NAME")
- self.aws_region = os.environ.get("AWS_REGION")
- self.s3_assets_cdn = os.environ.get("S3_ASSETS_CDN")
-
- if 'prod' in self.github_ref_name:
- stage = 'PROD'
-
- elif 'homolog' in self.github_ref_name:
- stage = 'HOMOLOG'
-
- else:
- stage = 'DEV'
-
- # log_group = logs.LogGroup(self, f"DevMedias_ApiGateway_AccessLogs_{stage}")
-
- self.rest_api = RestApi(self, f"DevMedias_RestApi_{self.github_ref_name}",
- rest_api_name=f"DevMedias_RestApi_{self.github_ref_name}",
- description="This is the DevMedias RestApi",
- default_cors_preflight_options=
- {
- "allow_origins": Cors.ALL_ORIGINS,
- "allow_methods": ["GET", "POST", "PUT", "DELETE", "OPTIONS"],
- "allow_headers": ["*"]
- },
- deploy_options=apigateway.StageOptions(
- stage_name="prod", # deixar como o padrao que estava errado, tem que comunicar que para arrumar aqui é apenas trocar pela variavel stage
- # access_log_destination=apigateway.LogGroupLogDestination(log_group),
- # access_log_format=apigateway.AccessLogFormat.custom(
- # json.dumps({
- # "requestId": "$context.requestId",
- # "ip": "$context.identity.sourceIp",
- # "caller": "$context.identity.caller",
- # "user": "$context.identity.user",
- # "requestTime": "$context.requestTime",
- # "httpMethod": "$context.httpMethod",
- # "resourcePath": "$context.resourcePath",
- # "status": "$context.status",
- # "protocol": "$context.protocol",
- # "responseLength": "$context.responseLength",
- # "queryString": "$context.requestOverride.path.querystring"
- # })
- # ),
- logging_level=apigateway.MethodLoggingLevel.OFF, #INFO
- data_trace_enabled=False, #True
- metrics_enabled=True
- )
- )
-
- api_gateway_resource = self.rest_api.root.add_resource("mss-medias", default_cors_preflight_options=
- {
- "allow_origins": Cors.ALL_ORIGINS,
- "allow_methods": ["GET", "POST", "PUT", "DELETE", "OPTIONS"],
- "allow_headers": Cors.DEFAULT_HEADERS
- }
- )
-
- self.subject_stack = SubjectStack(self)
- self.plans_stack = PlansStack(self)
-
- ENVIRONMENT_VARIABLES = {
- "STAGE": stage,
- "PLANS_BUCKET_NAME": self.plans_stack.bucket.bucket_name
- }
-
- self.lambda_stack = LambdaStack(
- self,
- api_gateway_resource=api_gateway_resource,
- plans_bucket=self.plans_stack.bucket,
- environment_variables=ENVIRONMENT_VARIABLES
- )
-
- bedrock_policy = iam.PolicyStatement(
- effect=iam.Effect.ALLOW,
- actions=[
- "bedrock:InvokeModel"
- ],
- resources=["*"] # Simplified to avoid ARN parsing issues
- )
-
- self.lambda_stack.plans_extractor_function.add_to_role_policy(
- bedrock_policy
- )
-
- self.contact_us_lambda_stack = LambdaContactUsStack(self, api_gateway_resource=api_gateway_resource,
- lambda_layer=self.lambda_stack.lambda_layer,
- stage=stage)
-
diff --git a/iac/iac/lambda_contact_us_stack.py b/iac/iac/lambda_contact_us_stack.py
deleted file mode 100644
index 397446c..0000000
--- a/iac/iac/lambda_contact_us_stack.py
+++ /dev/null
@@ -1,53 +0,0 @@
-import os
-
-from aws_cdk import (
- aws_lambda as lambda_,
- NestedStack, Duration, aws_iam
-)
-from aws_cdk.aws_apigateway import Resource, CognitoUserPoolsAuthorizer, LambdaIntegration
-from aws_cdk.aws_lambda import LayerVersion
-
-from constructs import Construct
-
-
-class LambdaContactUsStack(Construct):
- def __init__(self, scope: Construct, api_gateway_resource: Resource,
- lambda_layer: LayerVersion = None, stage: str = None) -> None:
- super().__init__(scope, "DevMedias_LambdaContactUs")
-
- module_name = "contact_us"
-
- environment_variables = {
- "FROM_EMAIL": os.environ.get("FROM_EMAIL"),
- "REPLY_TO_EMAIL": os.environ.get("REPLY_TO_EMAIL"),
- "HIDDEN_COPY": os.environ.get("HIDDEN_COPY"),
- "STAGE": stage
- }
-
- function = lambda_.Function(
- self, module_name,
- code=lambda_.Code.from_asset(f"../lambda_function/contact_us"),
- handler=f"app.send_email_feedback_presenter.lambda_handler",
- runtime=lambda_.Runtime.PYTHON_3_9,
- layers=[lambda_layer],
- memory_size=512,
- environment=environment_variables,
- timeout=Duration.seconds(15),
- )
-
-
- api_gateway_resource.add_resource("public").add_resource(module_name.replace("_", "-")).add_method("POST",
- integration=LambdaIntegration(
- function))
- ses_admin_policy = aws_iam.PolicyStatement(
- effect=aws_iam.Effect.ALLOW,
- actions=[
- "ses:*",
- ],
- resources=[
- "*"
- ]
- )
- function.add_to_role_policy(ses_admin_policy)
-
-
\ No newline at end of file
diff --git a/iac/iac/lambda_stack.py b/iac/iac/lambda_stack.py
deleted file mode 100644
index 44e9947..0000000
--- a/iac/iac/lambda_stack.py
+++ /dev/null
@@ -1,95 +0,0 @@
-from aws_cdk import (
- aws_lambda as lambda_,
- aws_s3 as s3,
- aws_s3_notifications as s3n,
- aws_lambda_event_sources as lambda_event_sources,
- Duration
-)
-from constructs import Construct
-from aws_cdk.aws_apigateway import Resource, LambdaIntegration
-
-
-class LambdaStack(Construct):
-
- functions_that_need_dynamo_permissions = []
-
- def create_lambda_api_gateway_integration(self, module_name: str, method: str, api_resource: Resource, environment_variables: dict = {"STAGE": "TEST"}):
- function = lambda_.Function(
- self, module_name.title(),
- code=lambda_.Code.from_asset(f"../src/modules/{module_name}"),
- handler=f"app.{module_name}_presenter.lambda_handler",
- runtime=lambda_.Runtime.PYTHON_3_9,
- layers=[self.lambda_layer],
- environment=environment_variables,
- timeout=Duration.seconds(30)
- )
-
- api_resource.add_resource(module_name.replace("_", "-")).add_method(method,
- integration=LambdaIntegration(
- function))
-
- return function
-
- def create_lambda_s3_object_creation_deletion_trigger_integration(
- self,
- module_name: str,
- bucket: s3.Bucket,
- environment_variables: dict
- ) -> lambda_.Function:
-
- function = lambda_.Function(
- self,
- module_name.title(),
- code=lambda_.Code.from_asset(f"../src/modules/{ module_name }"),
- handler=f"app.{module_name}_presenter.lambda_handler",
- runtime=lambda_.Runtime.PYTHON_3_9,
- layers=[self.lambda_layer],
- environment=environment_variables,
- timeout=Duration.seconds(90) # increased time for excel and bedrock
- )
-
- bucket.add_event_notification(
- s3.EventType.OBJECT_CREATED,
- s3n.LambdaDestination(function)
- )
-
- # bucket.add_event_notification(
- # s3.EventType.OBJECT_REMOVED_DELETE,
- # s3n.LambdaDestination(function)
- # )
-
- bucket.grant_read(function)
-
- return function
-
-
- def __init__(
- self,
- scope: Construct,
- api_gateway_resource: Resource,
- plans_bucket: s3.Bucket,
- environment_variables: dict
- ) -> None:
-
- super().__init__(scope, "DevMediasLambda")
-
- self.lambda_layer = lambda_.LayerVersion(self, "DevMedias_Layer",
- code=lambda_.Code.from_asset("./build"),
- compatible_runtimes=[lambda_.Runtime.PYTHON_3_9]
- )
-
- self.grade_optimizer_function = self.create_lambda_api_gateway_integration("grade_optmizer",
- "POST",
- api_resource=api_gateway_resource,
- environment_variables=environment_variables)
-
- self.calculate_mean_function = self.create_lambda_api_gateway_integration("calculate_mean",
- "POST",
- api_resource=api_gateway_resource,
- environment_variables=environment_variables)
-
- self.plans_extractor_function = self.create_lambda_s3_object_creation_deletion_trigger_integration(
- module_name="plans_extractor",
- bucket=plans_bucket,
- environment_variables=environment_variables
- )
\ No newline at end of file
diff --git a/iac/iac/plans_stack.py b/iac/iac/plans_stack.py
deleted file mode 100644
index 600fe78..0000000
--- a/iac/iac/plans_stack.py
+++ /dev/null
@@ -1,132 +0,0 @@
-from aws_cdk import aws_s3, aws_cloudfront, RemovalPolicy, Duration, aws_iam as iam
-from constructs import Construct
-import os
-import uuid
-
-
-class PlansStack(Construct):
-
- def __init__(self, scope: Construct, **kwargs) -> None:
- super().__init__(scope, "PlansStack")
- self.github_ref_name = os.environ.get("GITHUB_REF_NAME")
- self.aws_region = os.environ.get("AWS_REGION")
- self.aws_account_id = os.environ.get("AWS_ACCOUNT_ID")
-
- REMOVAL_POLICY = RemovalPolicy.RETAIN if 'prod' in self.github_ref_name else RemovalPolicy.DESTROY
-
- self.bucket = aws_s3.Bucket(
- self, "PlansBucket",
- block_public_access=aws_s3.BlockPublicAccess.BLOCK_ALL,
- removal_policy=REMOVAL_POLICY
- )
-
- oac = aws_cloudfront.CfnOriginAccessControl(
- self, "OAC", origin_access_control_config={
- "name": f"DevMedias Plans Bucket OAC {self.github_ref_name}",
- "originAccessControlOriginType": "s3",
- "signingBehavior": "always",
- "signingProtocol": "sigv4"
- }
- )
-
- cloudFrontWebDistribution = aws_cloudfront.CloudFrontWebDistribution(
- self, "CloudFrontWebDistributionPlans",
- comment=f"DevMedias Plans S3 CDN {self.github_ref_name}",
- origin_configs=[
- aws_cloudfront.SourceConfiguration(
- s3_origin_source=aws_cloudfront.S3OriginConfig(
- s3_bucket_source=self.bucket,
-
- ),
- behaviors=[aws_cloudfront.Behavior(
- is_default_behavior=True,
- compress=True,
- allowed_methods=aws_cloudfront.CloudFrontAllowedMethods.GET_HEAD_OPTIONS,
- cached_methods=aws_cloudfront.CloudFrontAllowedCachedMethods.GET_HEAD_OPTIONS,
- viewer_protocol_policy=aws_cloudfront.ViewerProtocolPolicy.REDIRECT_TO_HTTPS,
- forwarded_values=aws_cloudfront.CfnDistribution.ForwardedValuesProperty(
- query_string=True,
- headers=[
- "Origin",
- "Access-Control-Request-Headers",
- "Access-Control-Request-Method"
- ]
- ),
- )]
- )
- ],
- price_class=aws_cloudfront.PriceClass.PRICE_CLASS_ALL,
- viewer_protocol_policy=aws_cloudfront.ViewerProtocolPolicy.REDIRECT_TO_HTTPS,
- )
-
- cfn_distribution = cloudFrontWebDistribution.node.default_child
- cfn_distribution.add_property_override(
- "DistributionConfig.Origins.0.OriginAccessControlId",
- oac.get_att("Id")
- )
-
- cache_policy_id = str(uuid.uuid5(uuid.NAMESPACE_DNS, f"CP-{self.github_ref_name}"))
-
- def get_policy_id():
- try:
- cache_policy = aws_cloudfront.CachePolicy.from_cache_policy_id(cache_policy_id)
- return cache_policy.cache_policy_id
- except:
- cache_policy = aws_cloudfront.CachePolicy(
- self,
- cache_policy_id,
- cache_policy_name=f"DevMediasS3PlansCachingOptimized-{self.github_ref_name}",
- comment=f"DevMedias Policy for {self.github_ref_name}. Policy with caching enabled. Supports Gzip and Brotli compression.",
- min_ttl=Duration.seconds(1),
- max_ttl=Duration.days(365),
- default_ttl=Duration.seconds(30), # precisamos mesmo de um time to live?
- enable_accept_encoding_gzip=True,
- enable_accept_encoding_brotli=True
- )
- return cache_policy.cache_policy_id
-
- cfn_distribution.add_property_override(
- "DistributionConfig.DefaultCacheBehavior.CachePolicyId",
- get_policy_id()
- )
-
- origin_request_policy_id = str(uuid.uuid5(uuid.NAMESPACE_DNS, f"ORP-{self.github_ref_name}"))
-
- def get_origin_request_policy_id():
- try:
- origin_request_policy = aws_cloudfront.OriginRequestPolicy.from_origin_request_policy_id(origin_request_policy_id)
- return origin_request_policy.origin_request_policy_id
- except:
- origin_request_policy = aws_cloudfront.OriginRequestPolicy(
- self,
- origin_request_policy_id,
- comment=f"DevMedias Policy for S3 PlansBucket origin with CORS {self.github_ref_name}",
- origin_request_policy_name=f"CORS-S3Origin-Plans-{self.github_ref_name}",
- header_behavior=aws_cloudfront.OriginRequestHeaderBehavior.allow_list(
- "Origin",
- "Access-Control-Request-Headers",
- "Access-Control-Request-Method"
- )
- )
- return origin_request_policy.origin_request_policy_id
-
- cfn_distribution.add_property_override(
- "DistributionConfig.DefaultCacheBehavior.OriginRequestPolicyId",
- get_origin_request_policy_id()
- )
-
- response_headers_policy = aws_cloudfront.ResponseHeadersPolicy.CORS_ALLOW_ALL_ORIGINS_WITH_PREFLIGHT
-
- cfn_distribution.add_property_override(
- "DistributionConfig.DefaultCacheBehavior.ResponseHeadersPolicyId",
- response_headers_policy.response_headers_policy_id
- )
-
- self.bucket.add_to_resource_policy(iam.PolicyStatement(
- actions=["s3:GetObject"],
- resources=[f"arn:aws:s3:::{self.bucket.bucket_name}/*"],
- principals=[iam.ServicePrincipal(
- f"cloudfront.amazonaws.com"
- )]
- ))
-
diff --git a/iac/iac/subject_stack.py b/iac/iac/subject_stack.py
deleted file mode 100644
index ead32b7..0000000
--- a/iac/iac/subject_stack.py
+++ /dev/null
@@ -1,139 +0,0 @@
-import os
-import uuid
-
-from constructs import Construct
-
-from aws_cdk import (
- Duration,
- aws_s3,
- RemovalPolicy,
- aws_iam as iam, aws_cloudfront
-)
-
-
-class SubjectStack(Construct):
-
-
- def __init__(self, scope: Construct, **kwargs) -> None:
- super().__init__(scope, "SubjectStack")
- self.github_ref_name = os.environ.get("GITHUB_REF_NAME")
- self.aws_region = os.environ.get("AWS_REGION")
- self.aws_account_id = os.environ.get("AWS_ACCOUNT_ID")
-
- REMOVAL_POLICY = RemovalPolicy.RETAIN if 'prod' in self.github_ref_name else RemovalPolicy.DESTROY
-
- self.bucket = aws_s3.Bucket(
- self, "SubjectBucket",
- block_public_access=aws_s3.BlockPublicAccess.BLOCK_ALL,
- removal_policy=REMOVAL_POLICY
- )
-
- oac = aws_cloudfront.CfnOriginAccessControl(
- self, "OAC", origin_access_control_config={
- "name": f"DevMedias Subject Bucket OAC {self.github_ref_name}",
- "originAccessControlOriginType": "s3",
- "signingBehavior": "always",
- "signingProtocol": "sigv4"
- }
- )
-
- cloudFrontWebDistribution = aws_cloudfront.CloudFrontWebDistribution(
- self, "CloudFrontWebDistributionSubject",
- comment=f"DevMedias Subject S3 CDN {self.github_ref_name}",
- origin_configs=[
- aws_cloudfront.SourceConfiguration(
- s3_origin_source=aws_cloudfront.S3OriginConfig(
- s3_bucket_source=self.bucket,
-
- ),
- behaviors=[aws_cloudfront.Behavior(
- is_default_behavior=True,
- compress=True,
- allowed_methods=aws_cloudfront.CloudFrontAllowedMethods.GET_HEAD_OPTIONS,
- cached_methods=aws_cloudfront.CloudFrontAllowedCachedMethods.GET_HEAD_OPTIONS,
- viewer_protocol_policy=aws_cloudfront.ViewerProtocolPolicy.REDIRECT_TO_HTTPS,
- forwarded_values=aws_cloudfront.CfnDistribution.ForwardedValuesProperty(
- query_string=True,
- headers=[
- "Origin",
- "Access-Control-Request-Headers",
- "Access-Control-Request-Method"
- ]
- ),
- )]
- )
- ],
- price_class=aws_cloudfront.PriceClass.PRICE_CLASS_ALL,
- viewer_protocol_policy=aws_cloudfront.ViewerProtocolPolicy.REDIRECT_TO_HTTPS,
- )
-
- cfn_distribution = cloudFrontWebDistribution.node.default_child
- cfn_distribution.add_property_override(
- "DistributionConfig.Origins.0.OriginAccessControlId",
- oac.get_att("Id")
- )
-
- cache_policy_id = str(uuid.uuid5(uuid.NAMESPACE_DNS, f"CP-{self.github_ref_name}"))
-
- def get_policy_id():
- try:
- cache_policy = aws_cloudfront.CachePolicy.from_cache_policy_id(cache_policy_id)
- return cache_policy.cache_policy_id
- except:
- cache_policy = aws_cloudfront.CachePolicy(
- self,
- cache_policy_id,
- cache_policy_name=f"DevMediasS3CachingOptimized-{self.github_ref_name}",
- comment=f"DevMedias Policy for SubjectBucket {self.github_ref_name}. Policy with caching enabled. Supports Gzip and Brotli compression.",
- min_ttl=Duration.seconds(1),
- max_ttl=Duration.days(365),
- default_ttl=Duration.seconds(86400),
- enable_accept_encoding_gzip=True,
- enable_accept_encoding_brotli=True
- )
- return cache_policy.cache_policy_id
-
- cfn_distribution.add_property_override(
- "DistributionConfig.DefaultCacheBehavior.CachePolicyId",
- get_policy_id()
- )
-
- origin_request_policy_id = str(uuid.uuid5(uuid.NAMESPACE_DNS, f"ORP-{self.github_ref_name}"))
-
- def get_origin_request_policy_id():
- try:
- origin_request_policy = aws_cloudfront.OriginRequestPolicy.from_origin_request_policy_id(origin_request_policy_id)
- return origin_request_policy.origin_request_policy_id
- except:
- origin_request_policy = aws_cloudfront.OriginRequestPolicy(
- self,
- origin_request_policy_id,
- comment=f"DevMedias Policy for SubjectBucket origin with CORS {self.github_ref_name}",
- origin_request_policy_name=f"CORS-S3Origin-Subject-{self.github_ref_name}",
- header_behavior=aws_cloudfront.OriginRequestHeaderBehavior.allow_list(
- "Origin",
- "Access-Control-Request-Headers",
- "Access-Control-Request-Method"
- )
- )
- return origin_request_policy.origin_request_policy_id
-
- cfn_distribution.add_property_override(
- "DistributionConfig.DefaultCacheBehavior.OriginRequestPolicyId",
- get_origin_request_policy_id()
- )
-
- response_headers_policy = aws_cloudfront.ResponseHeadersPolicy.CORS_ALLOW_ALL_ORIGINS_WITH_PREFLIGHT
-
- cfn_distribution.add_property_override(
- "DistributionConfig.DefaultCacheBehavior.ResponseHeadersPolicyId",
- response_headers_policy.response_headers_policy_id
- )
-
- self.bucket.add_to_resource_policy(iam.PolicyStatement(
- actions=["s3:GetObject"],
- resources=[f"arn:aws:s3:::{self.bucket.bucket_name}/*"],
- principals=[iam.ServicePrincipal(
- f"cloudfront.amazonaws.com"
- )]
- ))
diff --git a/iac/local/docker/dynamo/.env.example b/iac/local/docker/dynamo/.env.example
new file mode 100644
index 0000000..58b97cd
--- /dev/null
+++ b/iac/local/docker/dynamo/.env.example
@@ -0,0 +1,9 @@
+STAGE=TEST
+# Prefixo do nome do container no Docker (troque por repo/stack, ex.: outro_projeto).
+STACK_NAME=devmedias
+# Porta publicada no host (cada clone do compose em outro repo: 8001, 8002, …).
+DYNAMO_HOST_PORT=8000
+ENDPOINT_URL=http://localhost:8000
+# Nome físico da tabela (igual ao CDK: DevMediasAcademicCatalogTable-{stage em minúsculo}).
+# Se omitir, Environments usa o mesmo padrão a partir de STAGE (ex.: TEST → ...-test).
+ACADEMIC_CATALOG_TABLE_NAME=DevMediasAcademicCatalogTable-test
diff --git a/iac/local/docker/dynamo/README.md b/iac/local/docker/dynamo/README.md
new file mode 100644
index 0000000..406a0f6
--- /dev/null
+++ b/iac/local/docker/dynamo/README.md
@@ -0,0 +1,55 @@
+# DynamoDB Local
+
+## Subir
+
+Na pasta deste arquivo:
+
+```bash
+docker compose -f docker_compose.yaml up -d
+```
+
+Copie **`.env.example`** para **`.env`** na mesma pasta. O compose usa **`STACK_NAME`** no nome do container (ex.: `devmedias-dynamodb-local` vs `outro_repo-dynamodb-local`) para separar instâncias por projeto.
+
+Se o container falhar com **`Unrecognized option: -sharedDb`**, o Java estava recebendo flags do DynamoDB **antes** de `-jar DynamoDBLocal.jar`. O compose deste repo define **`entrypoint` + `command`** nessa ordem para evitar isso.
+
+- **`DYNAMO_HOST_PORT`**: porta no host (default `8000`). Outro repositório na mesma máquina: `8001`, e no app `ENDPOINT_URL=http://localhost:8001` (também em `STAGE=TEST`, se definido).
+- O serviço escuta na porta mapeada (default **http://127.0.0.1:8000**).
+
+## NoSQL Workbench
+
+1. Abra o **Operation builder** (ou **Visualizer**).
+2. **Manage connections** → **DynamoDB local**.
+3. URL do endpoint: `http://localhost:8000` (ou `http://127.0.0.1:8000`).
+4. Região: por exemplo `sa-east-1` (deve bater com `Environments` / credenciais fictícias `test`).
+
+## Tabela única (`ACADEMIC_CATALOG_TABLE_NAME`)
+
+O nome físico segue o **CDK** (`iac/components/dynamo_construct.py`): `DevMediasAcademicCatalogTable-{stage}` em minúsculas no sufixo (ex.: `DevMediasAcademicCatalogTable-test` com `STAGE=TEST`). No app, se `ACADEMIC_CATALOG_TABLE_NAME` não estiver definido, `Environments` usa o mesmo padrão (`src/.../academic_catalog_naming.py`).
+
+- **Partition key** (string): `pk`
+- **Sort key** (string): `sk`
+
+Itens de curso e disciplina compartilham a tabela:
+
+- `pk` = `{GLOBAL|userId}#CURSO#{código}` ou `{GLOBAL|userId}#DISCIPLINA#{code}`
+- `sk` = `METADATA` (registro canônico; reserva outras SKs no futuro)
+- `entity_type` = `CURSO` | `DISCIPLINA` (filtro no scan)
+
+`GLOBAL` = catálogo padrão (usuário não logado). Com usuário logado, instancie o repositório com `user_id` para ler/gravar só o escopo daquele dono.
+
+Variável principal: **`ACADEMIC_CATALOG_TABLE_NAME`**. Ainda são aceitos, por compatibilidade: `ENTITY_TABLE_NAME`, `DISCIPLINA_TABLE_NAME`, `CURSO_TABLE_NAME`.
+
+## Criar tabela e popular dados
+
+- **`src/shared/infra/external/dynamo/academic_catalog_table_setup.py`** — função `ensure_academic_catalog_table()` (cria a tabela com `pk` / `sk` se não existir). Fica junto do código de infra Dynamo.
+
+Na **raiz do repositório** (com o Dynamo Local no ar):
+
+```bash
+STAGE=TEST python iac/local/docker/dynamo/load_curso_mock_to_dynamo.py
+STAGE=TEST python iac/local/docker/dynamo/load_disciplina_mock_to_dynamo.py
+```
+
+Cada loader chama o setup e grava no escopo **GLOBAL** a partir dos mocks em `src/shared/infra/repositories/*_mock.py`.
+
+Se `ENDPOINT_URL` ou `ACADEMIC_CATALOG_TABLE_NAME` forem diferentes do default, exporte antes de rodar.
diff --git a/iac/local/docker/dynamo/docker_compose.yaml b/iac/local/docker/dynamo/docker_compose.yaml
new file mode 100644
index 0000000..0702bd0
--- /dev/null
+++ b/iac/local/docker/dynamo/docker_compose.yaml
@@ -0,0 +1,20 @@
+# DynamoDB Local — single-table (pk/sk). Copie .env.example → .env e ajuste STACK_NAME por repositório/projeto.
+#
+# Entrypoint explícito: flags como -sharedDb têm de ir APÓS "-jar DynamoDBLocal.jar"; se só passar
+# command: ["-sharedDb"] o Java trata como opção da JVM ("Unrecognized option: -sharedDb").
+#
+# STACK_NAME prefixa o nome do container. DYNAMO_HOST_PORT: porta no host para vários stacks.
+
+services:
+ dynamodb-local:
+ image: amazon/dynamodb-local:latest
+ container_name: ${STACK_NAME:-devmedias}-dynamodb-local
+ working_dir: /home/dynamodblocal
+ entrypoint:
+ - java
+ - -Djava.library.path=./DynamoDBLocal_lib
+ - -jar
+ - DynamoDBLocal.jar
+ command: ["-sharedDb", "-inMemory"]
+ ports:
+ - "127.0.0.1:${DYNAMO_HOST_PORT:-8000}:8000"
diff --git a/iac/local/docker/dynamo/load_curso_mock_to_dynamo.py b/iac/local/docker/dynamo/load_curso_mock_to_dynamo.py
new file mode 100644
index 0000000..c6bbf8e
--- /dev/null
+++ b/iac/local/docker/dynamo/load_curso_mock_to_dynamo.py
@@ -0,0 +1,37 @@
+"""
+Carrega os cursos do mock no DynamoDB local (escopo GLOBAL).
+
+Execute na raiz do repositório:
+
+ STAGE=TEST python iac/local/docker/dynamo/load_curso_mock_to_dynamo.py
+"""
+
+from __future__ import annotations
+
+import sys
+from pathlib import Path
+
+_REPO_ROOT = Path(__file__).resolve().parents[4]
+if str(_REPO_ROOT) not in sys.path:
+ sys.path.insert(0, str(_REPO_ROOT))
+
+from src.shared.infra.external.dynamo.academic_catalog_table_setup import (
+ ensure_academic_catalog_table,
+)
+from src.shared.infra.repositories.curso_repository_dynamo import CursoRepositoryDynamo
+from src.shared.infra.repositories.curso_repository_mock import CursoRepositoryMock
+
+
+def main() -> None:
+ ensure_academic_catalog_table()
+ mock = CursoRepositoryMock()
+ repo = CursoRepositoryDynamo(user_id=None)
+ n = 0
+ for curso in mock.cursos:
+ repo.create_curso(curso)
+ n += 1
+ print(f"Inseridos {n} cursos no Dynamo (GLOBAL).")
+
+
+if __name__ == "__main__":
+ main()
diff --git a/iac/local/docker/dynamo/load_disciplina_mock_to_dynamo.py b/iac/local/docker/dynamo/load_disciplina_mock_to_dynamo.py
new file mode 100644
index 0000000..6d040d4
--- /dev/null
+++ b/iac/local/docker/dynamo/load_disciplina_mock_to_dynamo.py
@@ -0,0 +1,37 @@
+"""
+Carrega as disciplinas do mock no DynamoDB local (escopo GLOBAL).
+
+Execute na raiz do repositório:
+
+ STAGE=TEST python iac/local/docker/dynamo/load_disciplina_mock_to_dynamo.py
+"""
+
+from __future__ import annotations
+
+import sys
+from pathlib import Path
+
+_REPO_ROOT = Path(__file__).resolve().parents[4]
+if str(_REPO_ROOT) not in sys.path:
+ sys.path.insert(0, str(_REPO_ROOT))
+
+from src.shared.infra.external.dynamo.academic_catalog_table_setup import (
+ ensure_academic_catalog_table,
+)
+from src.shared.infra.repositories.disciplina_repository_dynamo import DisciplinaRepositoryDynamo
+from src.shared.infra.repositories.disciplina_repository_mock import DisciplinaRepositoryMock
+
+
+def main() -> None:
+ ensure_academic_catalog_table()
+ mock = DisciplinaRepositoryMock()
+ repo = DisciplinaRepositoryDynamo(user_id=None)
+ n = 0
+ for disciplina in mock.disciplinas:
+ repo.create_disciplina(disciplina)
+ n += 1
+ print(f"Inseridas {n} disciplinas no Dynamo (GLOBAL).")
+
+
+if __name__ == "__main__":
+ main()
diff --git a/iac/requirements-dev.txt b/iac/requirements-dev.txt
deleted file mode 100644
index 9270945..0000000
--- a/iac/requirements-dev.txt
+++ /dev/null
@@ -1 +0,0 @@
-pytest==6.2.5
diff --git a/iac/requirements-infra.txt b/iac/requirements-infra.txt
new file mode 100644
index 0000000..c4987ea
--- /dev/null
+++ b/iac/requirements-infra.txt
@@ -0,0 +1 @@
+aws-cdk-lib==2.211.0
\ No newline at end of file
diff --git a/iac/requirements.txt b/iac/requirements.txt
deleted file mode 100644
index b5f2f63..0000000
--- a/iac/requirements.txt
+++ /dev/null
@@ -1,2 +0,0 @@
-aws-cdk-lib==2.81.0
-constructs>=10.0.0,<11.0.0
diff --git a/iac/iac/__init__.py b/iac/stack/__init__.py
similarity index 100%
rename from iac/iac/__init__.py
rename to iac/stack/__init__.py
diff --git a/iac/stack/iac_stack.py b/iac/stack/iac_stack.py
new file mode 100644
index 0000000..76af9d8
--- /dev/null
+++ b/iac/stack/iac_stack.py
@@ -0,0 +1,92 @@
+import os
+from aws_cdk import (
+ Stack
+)
+from constructs import Construct
+
+from components.apigw_construct import ApigwConstruct
+from components.dynamo_construct import DynamoConstruct
+from components.lambda_construct import LambdaConstruct
+from components.s3_construct import S3Construct
+from components.ssm_construct import SsmConstruct
+
+class IacStack(Stack):
+ lambda_construct: LambdaConstruct
+
+ def __init__(
+ self,
+ scope: Construct,
+ stack_id: str,
+ stack_name: str,
+ stage: str,
+ **kwargs
+ ) -> None:
+ super().__init__(scope, stack_id, **kwargs)
+
+ self.github_ref_name = os.environ.get("GITHUB_REF_NAME", "")
+ self.aws_region = os.environ.get("AWS_REGION")
+ self.s3_assets_cdn = os.environ.get("S3_ASSETS_CDN")
+
+ self.apigw_construct = ApigwConstruct(
+ self,
+ construct_id=f"{stack_name}Apigw",
+ stage=stage
+ )
+
+ self.s3_construct = S3Construct(
+ self,
+ construct_id=f"{stack_name}S3",
+ stage=stage
+ )
+
+ self.dynamo_construct = DynamoConstruct(
+ self,
+ construct_id=f"{stack_name}Dynamo",
+ stack_name=stack_name,
+ stage=stage,
+ )
+
+ ENVIRONMENT_VARIABLES = {
+ "STAGE": stage.upper(),
+ "PLANS_BUCKET_NAME": self.s3_construct.plans_bucket.bucket_name,
+ "SUBJECT_BUCKET_NAME": self.s3_construct.subject_bucket.bucket_name,
+ "ACADEMIC_CATALOG_TABLE_NAME": self.dynamo_construct.academic_catalog_table.table_name,
+ "FROM_EMAIL": os.environ.get("FROM_EMAIL"),
+ "REPLY_TO_EMAIL": os.environ.get("REPLY_TO_EMAIL"),
+ "HIDDEN_COPY": os.environ.get("HIDDEN_COPY"),
+ }
+
+ self.lambda_construct = LambdaConstruct(
+ self,
+ construct_id=f"{stack_name}Lambda",
+ api_gateway_resource=self.apigw_construct.api_gateway_resource,
+ stage=stage,
+ stack_name=stack_name,
+ plans_bucket=self.s3_construct.plans_bucket,
+ subject_bucket=self.s3_construct.subject_bucket,
+ environment_variables=ENVIRONMENT_VARIABLES
+ )
+
+ for function in self.lambda_construct.funtions_that_need_dynamo_db_access:
+ self.dynamo_construct.academic_catalog_table.grant_read_write_data(function)
+
+ # nova instância SSM manager para passar automaticamente variáveis a um hub de segredos
+ # da prórpia conta, evitando ter que manualmente passa-las para o github secrets
+
+ # isso evita problemas de discrepância nos endpoints
+
+ # atenção aqui, isso deve suprir ao que estamos precisando / pegando de variáveis de
+ # ambiente no CD do front
+
+ self.ssm_construct = SsmConstruct(
+ self,
+ construct_id=f"{stack_name}Ssm",
+ mss_name_identification_for_path="devmedias",
+ api=self.apigw_construct.rest_api,
+ api_gateway_resource=self.apigw_construct.api_gateway_resource,
+ buckets=None, # o que deve ser salvo são os CDNs, visto que os buckets bloqueiam acesso pela URL publica
+ extra_params={
+ "cdn/subjects": self.s3_construct.cloudfront_distribution_subjects.distribution_domain_name
+ },
+ stage=stage
+ )
diff --git a/requirements-app.txt b/requirements-app.txt
new file mode 100644
index 0000000..059d6d2
--- /dev/null
+++ b/requirements-app.txt
@@ -0,0 +1,10 @@
+# Lambda layer + runtime deps. Keep this lean: AWS unzipped layer limit is 250 MB.
+# boto3/botocore come from the Lambda Python runtime — do not bundle them here.
+
+# Shared (domain, GA, Dynamo entities)
+pydantic==2.11.7
+python-dotenv==1.1.1
+numpy==2.2.3
+
+# plans_extractor — course_extractor (PyMuPDF coordinate/regex extraction)
+pymupdf==1.26.7
diff --git a/requirements-dev.txt b/requirements-dev.txt
index 6173aea..cb83628 100644
--- a/requirements-dev.txt
+++ b/requirements-dev.txt
@@ -1,5 +1,3 @@
-pytest==6.2.5
-pytest-cov==4.0.0
-boto3==1.24.88
-python-dotenv==0.21.0
-PyMuPDF==1.26.4
\ No newline at end of file
+# CI / local tests only — not bundled into the Lambda layer (see requirements-app.txt).
+pytest==8.4.1
+pytest-cov==6.2.1
diff --git a/requirements-layer.txt b/requirements-layer.txt
deleted file mode 100644
index 87dbb7c..0000000
--- a/requirements-layer.txt
+++ /dev/null
@@ -1,3 +0,0 @@
-pypdf
-pandas
-openpyxl
\ No newline at end of file
diff --git a/lambda_function/__init__.py b/src/modules/contact_us/__init__.py
similarity index 100%
rename from lambda_function/__init__.py
rename to src/modules/contact_us/__init__.py
diff --git a/lambda_function/contact_us/app/__init.py b/src/modules/contact_us/app/__init.py
similarity index 100%
rename from lambda_function/contact_us/app/__init.py
rename to src/modules/contact_us/app/__init.py
diff --git a/src/modules/contact_us/app/assets/dev_medias_logo.png b/src/modules/contact_us/app/assets/dev_medias_logo.png
new file mode 100644
index 0000000..15ffcae
Binary files /dev/null and b/src/modules/contact_us/app/assets/dev_medias_logo.png differ
diff --git a/lambda_function/contact_us/app/send_email_feedback_presenter.py b/src/modules/contact_us/app/contact_us_presenter.py
similarity index 99%
rename from lambda_function/contact_us/app/send_email_feedback_presenter.py
rename to src/modules/contact_us/app/contact_us_presenter.py
index 4992f17..eee8c91 100644
--- a/lambda_function/contact_us/app/send_email_feedback_presenter.py
+++ b/src/modules/contact_us/app/contact_us_presenter.py
@@ -20,6 +20,7 @@ def send_email(event, context):
return LambdaHttpResponse(status_code=400, body="Mensagem não informada").toDict()
email = Email(subject=subject, message=message, user_email=user_email)
+
try:
client.send_email(
Destination={
diff --git a/lambda_function/contact_us/__init__.py b/src/modules/contact_us/app/entities/__init__.py
similarity index 100%
rename from lambda_function/contact_us/__init__.py
rename to src/modules/contact_us/app/entities/__init__.py
diff --git a/lambda_function/contact_us/app/entities/email.py b/src/modules/contact_us/app/entities/email.py
similarity index 88%
rename from lambda_function/contact_us/app/entities/email.py
rename to src/modules/contact_us/app/entities/email.py
index 340c711..d73fdd2 100644
--- a/lambda_function/contact_us/app/entities/email.py
+++ b/src/modules/contact_us/app/entities/email.py
@@ -1,9 +1,17 @@
+import base64
import os
+from pathlib import Path
from typing import Tuple, Any, Dict
from datetime import datetime
import boto3
+_LOGO_PATH = Path(__file__).resolve().parent.parent / "assets" / "dev_medias_logo.png"
+
+
+def _logo_data_uri() -> str:
+ return f"data:image/png;base64,{base64.b64encode(_LOGO_PATH.read_bytes()).decode('ascii')}"
+
class Email:
email_address: str
@@ -18,6 +26,7 @@ def __init__(self, subject: str = None, message: str = None, user_name: str = No
self.subject = subject
self.message = message
self.date_time = datetime.now().strftime("%d/%m/%Y %H:%M:%S")
+ logo_src = _logo_data_uri()
self.body = f"""
@@ -30,7 +39,7 @@ def __init__(self, subject: str = None, message: str = None, user_name: str = No
-
Feedback Enviado!
|
diff --git a/lambda_function/contact_us/app/entities/__init__.py b/src/modules/curso/create_curso/app/__init__.py
similarity index 100%
rename from lambda_function/contact_us/app/entities/__init__.py
rename to src/modules/curso/create_curso/app/__init__.py
diff --git a/src/modules/curso/create_curso/app/create_curso_controller.py b/src/modules/curso/create_curso/app/create_curso_controller.py
new file mode 100644
index 0000000..aaacfde
--- /dev/null
+++ b/src/modules/curso/create_curso/app/create_curso_controller.py
@@ -0,0 +1,53 @@
+from src.shared.helpers.errors.controller_errors import MissingParameters, WrongTypeParameter
+from src.shared.helpers.errors.usecase_errors import DuplicatedItem
+from src.shared.helpers.external_interfaces.external_interface import IRequest, IResponse
+from src.shared.helpers.external_interfaces.http_codes import BadRequest, Conflict, Created, InternalServerError
+
+from .create_curso_usecase import CreateCursoUsecase
+from .create_curso_viewmodel import CreateCursoViewmodel
+
+
+class CreateCursoController:
+
+ def __init__(self, usecase: CreateCursoUsecase):
+ self.usecase = usecase
+
+ def __call__(self, request: IRequest) -> IResponse:
+ try:
+ if request.data.get('código') is None:
+ raise MissingParameters('código')
+ if type(request.data.get('código')) != str:
+ raise WrongTypeParameter(
+ fieldName='código',
+ fieldTypeExpected='str',
+ fieldTypeReceived=request.data.get('código').__class__.__name__,
+ )
+
+ if request.data.get('nome') is None:
+ raise MissingParameters('nome')
+ if type(request.data.get('nome')) != str:
+ raise WrongTypeParameter(
+ fieldName='nome',
+ fieldTypeExpected='str',
+ fieldTypeReceived=request.data.get('nome').__class__.__name__,
+ )
+
+ curso = self.usecase(
+ código=request.data.get('código'),
+ nome=request.data.get('nome'),
+ )
+ viewmodel = CreateCursoViewmodel(curso)
+
+ return Created(viewmodel.to_dict())
+
+ except MissingParameters as error:
+ return BadRequest(error.message)
+
+ except WrongTypeParameter as error:
+ return BadRequest(error.message)
+
+ except DuplicatedItem as error:
+ return Conflict(error.message)
+
+ except Exception as error:
+ return InternalServerError(error)
diff --git a/src/modules/curso/create_curso/app/create_curso_presenter.py b/src/modules/curso/create_curso/app/create_curso_presenter.py
new file mode 100644
index 0000000..7d04449
--- /dev/null
+++ b/src/modules/curso/create_curso/app/create_curso_presenter.py
@@ -0,0 +1,17 @@
+from src.shared.environments import Environments
+from src.shared.helpers.external_interfaces.http_lambda_requests import LambdaHttpRequest, LambdaHttpResponse
+
+from .create_curso_controller import CreateCursoController
+from .create_curso_usecase import CreateCursoUsecase
+
+repository = Environments.get_curso_repo()
+usecase = CreateCursoUsecase(repository)
+controller = CreateCursoController(usecase)
+
+
+def lambda_handler(event, context):
+ httpRequest = LambdaHttpRequest(data=event)
+ response = controller(httpRequest)
+ httpResponse = LambdaHttpResponse(status_code=response.status_code, body=response.body, headers=response.headers)
+
+ return httpResponse.toDict()
diff --git a/src/modules/curso/create_curso/app/create_curso_usecase.py b/src/modules/curso/create_curso/app/create_curso_usecase.py
new file mode 100644
index 0000000..39517f2
--- /dev/null
+++ b/src/modules/curso/create_curso/app/create_curso_usecase.py
@@ -0,0 +1,19 @@
+from src.shared.domain.entities.curso import Curso
+from src.shared.domain.repositories.curso_repository_interface import ICursoRepository
+from src.shared.helpers.errors.usecase_errors import DuplicatedItem
+
+
+class CreateCursoUsecase:
+
+ def __init__(self, repository: ICursoRepository):
+ self.repository = repository
+
+ def __call__(self, código: str, nome: str) -> Curso:
+ existing_curso = self.repository.get_curso(código)
+
+ if existing_curso is not None:
+ raise DuplicatedItem(message='código')
+
+ curso = Curso(código=código, nome=nome)
+
+ return self.repository.create_curso(curso)
diff --git a/src/modules/curso/create_curso/app/create_curso_viewmodel.py b/src/modules/curso/create_curso/app/create_curso_viewmodel.py
new file mode 100644
index 0000000..e7713a2
--- /dev/null
+++ b/src/modules/curso/create_curso/app/create_curso_viewmodel.py
@@ -0,0 +1,9 @@
+from src.shared.domain.entities.curso import Curso
+
+
+class CreateCursoViewmodel:
+ def __init__(self, curso: Curso):
+ self.curso = curso
+
+ def to_dict(self) -> dict:
+ return self.curso.model_dump(mode='json')
diff --git a/src/modules/plans_extractor/app/_iinit__.py b/src/modules/curso/get_all_cursos/app/__init__.py
similarity index 100%
rename from src/modules/plans_extractor/app/_iinit__.py
rename to src/modules/curso/get_all_cursos/app/__init__.py
diff --git a/src/modules/curso/get_all_cursos/app/get_all_cursos_controller.py b/src/modules/curso/get_all_cursos/app/get_all_cursos_controller.py
new file mode 100644
index 0000000..26c943f
--- /dev/null
+++ b/src/modules/curso/get_all_cursos/app/get_all_cursos_controller.py
@@ -0,0 +1,24 @@
+from src.shared.helpers.errors.usecase_errors import NoItemsFound
+from src.shared.helpers.external_interfaces.external_interface import IRequest, IResponse
+from src.shared.helpers.external_interfaces.http_codes import InternalServerError, NotFound, OK
+
+from .get_all_cursos_usecase import GetAllCursosUsecase
+from .get_all_cursos_viewmodel import GetAllCursosViewmodel
+
+
+class GetAllCursosController:
+
+ def __init__(self, usecase: GetAllCursosUsecase):
+ self.usecase = usecase
+
+ def __call__(self, request: IRequest) -> IResponse:
+ try:
+ cursos = self.usecase()
+ viewmodel = GetAllCursosViewmodel(cursos)
+ return OK(viewmodel.to_dict())
+
+ except NoItemsFound as error:
+ return NotFound(error)
+
+ except Exception as error:
+ return InternalServerError(error)
diff --git a/src/modules/curso/get_all_cursos/app/get_all_cursos_presenter.py b/src/modules/curso/get_all_cursos/app/get_all_cursos_presenter.py
new file mode 100644
index 0000000..b676a66
--- /dev/null
+++ b/src/modules/curso/get_all_cursos/app/get_all_cursos_presenter.py
@@ -0,0 +1,17 @@
+from src.shared.environments import Environments
+from src.shared.helpers.external_interfaces.http_lambda_requests import LambdaHttpRequest, LambdaHttpResponse
+
+from .get_all_cursos_controller import GetAllCursosController
+from .get_all_cursos_usecase import GetAllCursosUsecase
+
+repository = Environments.get_curso_repo()
+usecase = GetAllCursosUsecase(repository)
+controller = GetAllCursosController(usecase)
+
+
+def lambda_handler(event, context):
+ httpRequest = LambdaHttpRequest(data=event)
+ response = controller(httpRequest)
+ httpResponse = LambdaHttpResponse(status_code=response.status_code, body=response.body, headers=response.headers)
+
+ return httpResponse.toDict()
diff --git a/src/modules/curso/get_all_cursos/app/get_all_cursos_usecase.py b/src/modules/curso/get_all_cursos/app/get_all_cursos_usecase.py
new file mode 100644
index 0000000..643e3bb
--- /dev/null
+++ b/src/modules/curso/get_all_cursos/app/get_all_cursos_usecase.py
@@ -0,0 +1,17 @@
+from src.shared.domain.entities.curso import Curso
+from src.shared.domain.repositories.curso_repository_interface import ICursoRepository
+from src.shared.helpers.errors.usecase_errors import NoItemsFound
+
+
+class GetAllCursosUsecase:
+
+ def __init__(self, repository: ICursoRepository):
+ self.repository = repository
+
+ def __call__(self) -> list[Curso]:
+ cursos = self.repository.get_all_cursos()
+
+ if not cursos:
+ raise NoItemsFound(message='cursos')
+
+ return cursos
diff --git a/src/modules/curso/get_all_cursos/app/get_all_cursos_viewmodel.py b/src/modules/curso/get_all_cursos/app/get_all_cursos_viewmodel.py
new file mode 100644
index 0000000..d9893b3
--- /dev/null
+++ b/src/modules/curso/get_all_cursos/app/get_all_cursos_viewmodel.py
@@ -0,0 +1,9 @@
+from src.shared.domain.entities.curso import Curso
+
+
+class GetAllCursosViewmodel:
+ def __init__(self, cursos: list[Curso]):
+ self.cursos = cursos
+
+ def to_dict(self) -> list[dict]:
+ return [curso.model_dump(mode='json') for curso in self.cursos]
diff --git a/src/modules/disciplina/__init__.py b/src/modules/disciplina/__init__.py
new file mode 100644
index 0000000..e69de29
diff --git a/src/modules/disciplina/get_all_disciplinas/app/__init__.py b/src/modules/disciplina/get_all_disciplinas/app/__init__.py
new file mode 100644
index 0000000..e69de29
diff --git a/src/modules/disciplina/get_all_disciplinas/app/get_all_disciplinas_controller.py b/src/modules/disciplina/get_all_disciplinas/app/get_all_disciplinas_controller.py
new file mode 100644
index 0000000..9effbd1
--- /dev/null
+++ b/src/modules/disciplina/get_all_disciplinas/app/get_all_disciplinas_controller.py
@@ -0,0 +1,26 @@
+from src.shared.helpers.external_interfaces.external_interface import IRequest, IResponse
+from src.shared.helpers.external_interfaces.http_codes import OK, InternalServerError
+from .get_all_disciplinas_usecase import GetAllDisciplinasUsecase
+from .get_all_disciplinas_viewmodel import GetAllDisciplinasViewmodel
+from src.shared.helpers.errors.usecase_errors import NoItemsFound
+from src.shared.helpers.external_interfaces.http_codes import NotFound
+
+class GetAllDisciplinasController:
+
+ def __init__(self, usecase: GetAllDisciplinasUsecase):
+ self.usecase = usecase
+
+ def __call__(self, request: IRequest) -> IResponse:
+ try:
+
+ #TODO implement user logic from request (requester user)
+
+ disciplinas = self.usecase()
+ viewmodel = GetAllDisciplinasViewmodel(disciplinas)
+ return OK(viewmodel.to_dict())
+
+ except NoItemsFound as error:
+ return NotFound(error)
+
+ except Exception as e:
+ return InternalServerError(e)
\ No newline at end of file
diff --git a/src/modules/disciplina/get_all_disciplinas/app/get_all_disciplinas_presenter.py b/src/modules/disciplina/get_all_disciplinas/app/get_all_disciplinas_presenter.py
new file mode 100644
index 0000000..e3d609b
--- /dev/null
+++ b/src/modules/disciplina/get_all_disciplinas/app/get_all_disciplinas_presenter.py
@@ -0,0 +1,18 @@
+from src.shared.environments import Environments
+from .get_all_disciplinas_controller import GetAllDisciplinasController
+from .get_all_disciplinas_usecase import GetAllDisciplinasUsecase
+from src.shared.helpers.external_interfaces.http_lambda_requests import LambdaHttpRequest, LambdaHttpResponse
+
+repository = Environments.get_disciplina_repo()
+usecase = GetAllDisciplinasUsecase(repository)
+controller = GetAllDisciplinasController(usecase)
+
+
+def lambda_handler(event, context):
+
+ httpRequest = LambdaHttpRequest(data=event)
+ response = controller(httpRequest)
+ httpResponse = LambdaHttpResponse(status_code=response.status_code, body=response.body, headers=response.headers)
+
+ return httpResponse.toDict()
+
diff --git a/src/modules/disciplina/get_all_disciplinas/app/get_all_disciplinas_usecase.py b/src/modules/disciplina/get_all_disciplinas/app/get_all_disciplinas_usecase.py
new file mode 100644
index 0000000..079f468
--- /dev/null
+++ b/src/modules/disciplina/get_all_disciplinas/app/get_all_disciplinas_usecase.py
@@ -0,0 +1,20 @@
+from src.shared.domain.entities.disciplina import Disciplina
+from src.shared.domain.repositories.disciplina_repository_interface import IDisciplinaRepository
+from src.shared.helpers.errors.usecase_errors import NoItemsFound
+
+class GetAllDisciplinasUsecase:
+
+ def __init__(self, repository: IDisciplinaRepository):
+ self.repository = repository
+
+ #TODO implement user logic from request (requester user)
+
+ def __call__(self) -> list[Disciplina]:
+
+ disciplinas = self.repository.get_all_disciplinas()
+
+ if not disciplinas:
+
+ raise NoItemsFound(message='disciplinas')
+
+ return disciplinas
\ No newline at end of file
diff --git a/src/modules/disciplina/get_all_disciplinas/app/get_all_disciplinas_viewmodel.py b/src/modules/disciplina/get_all_disciplinas/app/get_all_disciplinas_viewmodel.py
new file mode 100644
index 0000000..c7c4cc0
--- /dev/null
+++ b/src/modules/disciplina/get_all_disciplinas/app/get_all_disciplinas_viewmodel.py
@@ -0,0 +1,9 @@
+from src.shared.domain.entities.disciplina import Disciplina
+
+
+class GetAllDisciplinasViewmodel:
+ def __init__(self, disciplinas: list[Disciplina]):
+ self.disciplinas = disciplinas
+
+ def to_dict(self) -> list[dict]:
+ return [disciplina.model_dump(mode="json") for disciplina in self.disciplinas]
diff --git a/src/modules/genetic_algorithm/__init__.py b/src/modules/genetic_algorithm/__init__.py
new file mode 100644
index 0000000..e69de29
diff --git a/src/modules/genetic_algorithm/app/__init__.py b/src/modules/genetic_algorithm/app/__init__.py
new file mode 100644
index 0000000..e69de29
diff --git a/src/modules/genetic_algorithm/app/genetic_algorithm_controller.py b/src/modules/genetic_algorithm/app/genetic_algorithm_controller.py
new file mode 100644
index 0000000..6228160
--- /dev/null
+++ b/src/modules/genetic_algorithm/app/genetic_algorithm_controller.py
@@ -0,0 +1,222 @@
+import traceback
+from .genetic_algorithm_usecase import GeneticAlgorithmUsecase
+from .genetic_algorithm_viewmodel import GeneticAlgorithmViewmodel
+from src.shared.domain.entities.nota import Nota
+from src.shared.helpers.errors.controller_errors import MissingParameters, WrongTypeParameter
+from src.shared.helpers.errors.domain_errors import EntityError, EntityParameterError
+from src.shared.helpers.errors.function_errors import FunctionInputError
+from src.shared.helpers.errors.usecase_errors import CombinationNotFound, InvalidInput
+from src.shared.helpers.external_interfaces.external_interface import IRequest, IResponse
+from src.shared.helpers.external_interfaces.http_codes import OK, BadRequest, InternalServerError, NotFound
+
+
+class GeneticAlgorithmController:
+
+ def __init__(self, usecase: GeneticAlgorithmUsecase):
+ self.usecase = usecase
+
+ def __call__(self, request: IRequest) -> IResponse:
+ try:
+ # ==========================================
+ # VALIDAÇÃO: provas_que_tenho
+ # ==========================================
+ provas_que_tenho = request.data.get('provas_que_tenho')
+ if provas_que_tenho is None:
+ raise MissingParameters('provas_que_tenho')
+ if not isinstance(provas_que_tenho, list):
+ raise WrongTypeParameter(
+ fieldName="provas_que_tenho",
+ fieldTypeExpected="list",
+ fieldTypeReceived=type(provas_que_tenho).__name__
+ )
+
+ # Validação de cada nota e peso da lista provas_que_tenho
+ for nota in provas_que_tenho:
+ if not isinstance(nota.get('valor'), (int, float)):
+ raise WrongTypeParameter(
+ fieldName="provas_que_tenho item",
+ fieldTypeExpected="float",
+ fieldTypeReceived=type(nota.get('valor')).__name__
+ )
+ if not isinstance(nota.get('peso'), (int, float)):
+ raise WrongTypeParameter(
+ fieldName="provas_que_tenho peso item",
+ fieldTypeExpected="float",
+ fieldTypeReceived=type(nota.get('peso')).__name__
+ )
+ if nota['peso'] < 0 or nota['peso'] > 1:
+ raise InvalidInput("provas_que_tenho peso item", "Must be between 0 and 1")
+
+ current_tests = [nota['valor'] for nota in provas_que_tenho]
+ spec_current_test_weight = [nota['peso'] for nota in provas_que_tenho]
+
+ # ==========================================
+ # VALIDAÇÃO: trabalhos_que_tenho
+ # ==========================================
+ trabalhos_que_tenho = request.data.get('trabalhos_que_tenho')
+ if trabalhos_que_tenho is None:
+ raise MissingParameters('trabalhos_que_tenho')
+ if not isinstance(trabalhos_que_tenho, list):
+ raise WrongTypeParameter(
+ fieldName="trabalhos_que_tenho",
+ fieldTypeExpected="list",
+ fieldTypeReceived=type(trabalhos_que_tenho).__name__
+ )
+
+ # Validação de cada nota e peso da lista trabalhos_que_tenho
+ for nota in trabalhos_que_tenho:
+ if not isinstance(nota.get('valor'), (int, float)):
+ raise WrongTypeParameter(
+ fieldName="trabalhos_que_tenho item",
+ fieldTypeExpected="float",
+ fieldTypeReceived=type(nota.get('valor')).__name__
+ )
+ if not isinstance(nota.get('peso'), (int, float)):
+ raise WrongTypeParameter(
+ fieldName="trabalhos_que_tenho peso item",
+ fieldTypeExpected="float",
+ fieldTypeReceived=type(nota.get('peso')).__name__
+ )
+ if nota['peso'] < 0 or nota['peso'] > 1:
+ raise InvalidInput("trabalhos_que_tenho peso item", "Must be between 0 and 1")
+
+ current_assignments = [nota['valor'] for nota in trabalhos_que_tenho]
+ spec_current_assignment_weight = [nota['peso'] for nota in trabalhos_que_tenho]
+
+ # ==========================================
+ # VALIDAÇÃO: provas_que_quero
+ # ==========================================
+ provas_que_quero = request.data.get('provas_que_quero')
+ if provas_que_quero is None:
+ raise MissingParameters('provas_que_quero')
+ if not isinstance(provas_que_quero, list):
+ raise WrongTypeParameter(
+ fieldName="provas_que_quero",
+ fieldTypeExpected="list",
+ fieldTypeReceived=type(provas_que_quero).__name__
+ )
+
+ # Validação de cada peso da lista provas_que_quero
+ for nota in provas_que_quero:
+ if not isinstance(nota.get('peso'), (int, float)):
+ raise WrongTypeParameter(
+ fieldName="provas_que_quero peso item",
+ fieldTypeExpected="float",
+ fieldTypeReceived=type(nota.get('peso')).__name__
+ )
+ if nota['peso'] < 0 or nota['peso'] > 1:
+ raise InvalidInput("provas_que_quero peso item", "Must be between 0 and 1")
+
+ num_remaining_tests = len(provas_que_quero)
+ spec_remaining_test_weight = [nota['peso'] for nota in provas_que_quero]
+
+ # ==========================================
+ # VALIDAÇÃO: trabalhos_que_quero
+ # ==========================================
+ trabalhos_que_quero = request.data.get('trabalhos_que_quero')
+ if trabalhos_que_quero is None:
+ raise MissingParameters('trabalhos_que_quero')
+ if not isinstance(trabalhos_que_quero, list):
+ raise WrongTypeParameter(
+ fieldName="trabalhos_que_quero",
+ fieldTypeExpected="list",
+ fieldTypeReceived=type(trabalhos_que_quero).__name__
+ )
+
+ # Validação de cada peso da lista trabalhos_que_quero
+ for nota in trabalhos_que_quero:
+ if not isinstance(nota.get('peso'), (int, float)):
+ raise WrongTypeParameter(
+ fieldName="trabalhos_que_quero peso item",
+ fieldTypeExpected="float",
+ fieldTypeReceived=type(nota.get('peso')).__name__
+ )
+ if nota['peso'] < 0 or nota['peso'] > 1:
+ raise InvalidInput("trabalhos_que_quero peso item", "Must be between 0 and 1")
+
+ num_remaining_assignments = len(trabalhos_que_quero)
+ spec_remaining_assignment_weight = [nota['peso'] for nota in trabalhos_que_quero]
+
+ # ==========================================
+ # VALIDAÇÃO: pesos gerais e média
+ # ==========================================
+ peso_prova = request.data.get('peso_prova')
+ if peso_prova is None:
+ raise MissingParameters('peso_prova')
+ if not isinstance(peso_prova, (int, float)):
+ raise WrongTypeParameter(
+ fieldName="peso_prova",
+ fieldTypeExpected="float",
+ fieldTypeReceived=type(peso_prova).__name__
+ )
+ if peso_prova < 0 or peso_prova > 1:
+ raise InvalidInput("peso_prova", "Must be between 0 and 1")
+
+ peso_trabalho = request.data.get('peso_trabalho')
+ if peso_trabalho is None:
+ raise MissingParameters('peso_trabalho')
+ if not isinstance(peso_trabalho, (int, float)):
+ raise WrongTypeParameter(
+ fieldName="peso_trabalho",
+ fieldTypeExpected="float",
+ fieldTypeReceived=type(peso_trabalho).__name__
+ )
+ if peso_trabalho < 0 or peso_trabalho > 1:
+ raise InvalidInput("peso_trabalho", "Must be between 0 and 1")
+
+ media_desejada = request.data.get('media_desejada')
+ if media_desejada is None:
+ raise MissingParameters('media_desejada')
+ if not isinstance(media_desejada, (int, float)):
+ raise WrongTypeParameter(
+ fieldName="media_desejada",
+ fieldTypeExpected="float",
+ fieldTypeReceived=type(media_desejada).__name__
+ )
+ if media_desejada < 0 or media_desejada > 10:
+ raise InvalidInput("media_desejada", "Must be between 0 and 10")
+
+ if peso_prova + peso_trabalho != 1.0:
+ raise InvalidInput("peso_prova and/or peso_trabalho", "Must sum 1.0")
+
+ # ==========================================
+ # EXECUÇÃO DO USECASE
+ # ==========================================
+ spec_assignment_weight = spec_current_assignment_weight + spec_remaining_assignment_weight
+ spec_test_weight = spec_current_test_weight + spec_remaining_test_weight
+
+ combinacao_de_notas = self.usecase(
+ current_tests=current_tests,
+ current_assignments=current_assignments,
+ num_remaining_tests=num_remaining_tests,
+ num_remaining_assignments=num_remaining_assignments,
+ test_weight=peso_prova,
+ assignment_weight=peso_trabalho,
+ target_average=media_desejada,
+ max_grade=10.0,
+ population_size=150,
+ generations=200,
+ spec_test_weight=spec_test_weight,
+ spec_assignment_weight=spec_assignment_weight
+ )
+
+ viewmodel = GeneticAlgorithmViewmodel(combinacao_de_notas)
+ return OK(viewmodel.to_dict())
+
+ except InvalidInput as err:
+ return BadRequest(body=err.message)
+ except CombinationNotFound as err:
+ return NotFound(body=err.message)
+ except EntityParameterError as err:
+ return BadRequest(body=err.message)
+ except FunctionInputError as err:
+ return BadRequest(body=err.message)
+ except WrongTypeParameter as err:
+ return BadRequest(body=err.message)
+ except MissingParameters as err:
+ return BadRequest(body=err.message)
+ except EntityError as err:
+ return BadRequest(body=err.message)
+ except Exception as err:
+ traceback.print_exc()
+ return InternalServerError(body=str(err.args[0]) if err.args else "Internal Server Error")
\ No newline at end of file
diff --git a/src/modules/genetic_algorithm/app/genetic_algorithm_presenter.py b/src/modules/genetic_algorithm/app/genetic_algorithm_presenter.py
new file mode 100644
index 0000000..e83c1ca
--- /dev/null
+++ b/src/modules/genetic_algorithm/app/genetic_algorithm_presenter.py
@@ -0,0 +1,16 @@
+from .genetic_algorithm_controller import GeneticAlgorithmController
+from .genetic_algorithm_usecase import GeneticAlgorithmUsecase
+from src.shared.helpers.external_interfaces.http_lambda_requests import LambdaHttpRequest, LambdaHttpResponse
+
+
+usecase = GeneticAlgorithmUsecase()
+controller = GeneticAlgorithmController(usecase)
+
+def lambda_handler(event, context):
+
+ httpRequest = LambdaHttpRequest(data=event)
+ response = controller(httpRequest)
+ httpResponse = LambdaHttpResponse(status_code=response.status_code, body=response.body, headers=response.headers)
+
+ return httpResponse.toDict()
+
diff --git a/src/modules/genetic_algorithm/app/genetic_algorithm_usecase.py b/src/modules/genetic_algorithm/app/genetic_algorithm_usecase.py
new file mode 100644
index 0000000..04ee437
--- /dev/null
+++ b/src/modules/genetic_algorithm/app/genetic_algorithm_usecase.py
@@ -0,0 +1,101 @@
+from src.shared.domain.entities.boletim_ga import Boletim_GA
+from src.shared.helpers.errors.usecase_errors import CombinationNotFound
+from src.shared.genetic_algorithm_solver import GradeGeneticAlgorithm
+from decimal import Decimal, ROUND_HALF_DOWN
+
+
+def _round_grade_for_front(value: float) -> float:
+ """
+ Applies Maua display rule for grades:
+ - output only in 0.5 steps (e.g. 5.5, 6.0)
+ - midpoint ties do not round up
+ """
+ doubled = Decimal(str(value)) * Decimal("2")
+ rounded_doubled = doubled.quantize(Decimal("1"), rounding=ROUND_HALF_DOWN)
+ return float(rounded_doubled / Decimal("2"))
+
+
+def _round_weight_for_front(value: float) -> float:
+ """
+ Applies Maua rounding rule for frontend output:
+ - one decimal place
+ - ties (x.x5) do not round up
+ """
+ return float(Decimal(str(value)).quantize(Decimal("0.1"), rounding=ROUND_HALF_DOWN))
+
+
+class GeneticAlgorithmUsecase:
+ def __init__(self):
+ pass
+
+ def __call__(
+ self,
+ current_tests: list[float],
+ current_assignments: list[float],
+ num_remaining_tests: int,
+ num_remaining_assignments: int,
+ test_weight: float,
+ assignment_weight: float,
+ target_average: float,
+ spec_test_weight: list[float],
+ spec_assignment_weight: list[float],
+ max_grade: float = 10.0,
+ population_size: int = 150,
+ generations: int = 200,
+ ) -> Boletim_GA:
+
+ boletim = Boletim_GA(
+ current_tests=current_tests,
+ current_assignments=current_assignments,
+ num_remaining_tests=num_remaining_tests,
+ num_remaining_assignments=num_remaining_assignments,
+ test_weight=test_weight,
+ assignment_weight=assignment_weight,
+ spec_test_weight=spec_test_weight,
+ spec_assignment_weight=spec_assignment_weight,
+ max_grade=max_grade,
+ )
+
+ ga = GradeGeneticAlgorithm(
+ boletim=boletim,
+ target_average=target_average,
+ max_grade=max_grade,
+ population_size=population_size,
+ generations=generations,
+ )
+
+ solution, fitness, final_avg = ga.run()
+
+ if solution is None:
+ raise CombinationNotFound()
+
+ all_tests = current_tests + solution["tests"]
+ all_assignments = current_assignments + solution["assignments"]
+
+ boletim.target_avg = target_average
+ boletim.final_avg = final_avg
+ boletim.provas = [
+ {
+ "valor": _round_grade_for_front(nota),
+ "peso": _round_weight_for_front(boletim.spec_test_weight[i]),
+ }
+ for i, nota in enumerate(all_tests)
+ ]
+ boletim.trabalhos = [
+ {
+ "valor": _round_grade_for_front(nota),
+ "peso": _round_weight_for_front(boletim.spec_assignment_weight[i]),
+ }
+ for i, nota in enumerate(all_assignments)
+ ]
+
+ diff = abs(final_avg - target_average)
+ if diff <= 0.05:
+ boletim.message = "O algoritmo retornou uma combinação válida de notas"
+ elif diff <= 0.2:
+ boletim.message = f"O algoritmo retornou uma solução próxima (diferença: {diff:.2f})"
+ else:
+ boletim.message = f"O algoritmo não conseguiu encontrar uma solução próxima (diferença: {diff:.2f})"
+
+ return boletim
+
\ No newline at end of file
diff --git a/src/modules/genetic_algorithm/app/genetic_algorithm_viewmodel.py b/src/modules/genetic_algorithm/app/genetic_algorithm_viewmodel.py
new file mode 100644
index 0000000..025e3a8
--- /dev/null
+++ b/src/modules/genetic_algorithm/app/genetic_algorithm_viewmodel.py
@@ -0,0 +1,18 @@
+from src.shared.domain.entities.boletim_ga import Boletim_GA
+
+
+class GeneticAlgorithmViewmodel:
+ def __init__(self, boletim: Boletim_GA):
+ self.boletim = boletim
+
+ def to_dict(self) -> dict:
+ return {
+ "notas": {
+ "provas": self.boletim.provas,
+ "trabalhos": self.boletim.trabalhos,
+ },
+ "message": self.boletim.message,
+ }
+
+
+
diff --git a/src/modules/plans_extractor/app/__init__.py b/src/modules/plans_extractor/app/__init__.py
new file mode 100644
index 0000000..8b13789
--- /dev/null
+++ b/src/modules/plans_extractor/app/__init__.py
@@ -0,0 +1 @@
+
diff --git a/src/modules/plans_extractor/app/course_extractor.py b/src/modules/plans_extractor/app/course_extractor.py
new file mode 100644
index 0000000..7b79b1a
--- /dev/null
+++ b/src/modules/plans_extractor/app/course_extractor.py
@@ -0,0 +1,471 @@
+import json
+import logging
+import re
+import unicodedata
+from pathlib import PurePosixPath
+from typing import Any
+from urllib.parse import unquote_plus
+
+import pymupdf
+from botocore.exceptions import ClientError
+
+from .helper.course.course import Course
+from .parser import build_disciplina
+from src.shared.environments import Environments
+from src.shared.infra.repositories.disciplina_repository_dynamo import DisciplinaRepositoryDynamo
+
+logger = logging.getLogger(__name__)
+logger.setLevel(logging.INFO)
+
+COURSE_CODE_BY_FOLDER = {
+ "administracao": "ADM",
+ "analise e desenvolvimento de sistemas": "ADS",
+ "arquitetura e urbanismo": "ARQ",
+ "ciencia da computacao": "CIC",
+ "design": "DSG",
+ "economia": "UNK",
+ "engenharia civil": "ECV",
+ "engenharia de alimentos": "EAL",
+ "engenharia de computacao": "ECM",
+ "engenharia de controle e automacao": "ECA",
+ "engenharia de producao": "EPM",
+ "engenharia eletrica": "EET",
+ "engenharia eletronica": "EEN",
+ "engenharia mecanica": "EMC",
+ "engenharia quimica": "EQM",
+ "relacoes internacionais": "RI",
+ "sistemas da informacao": "SIN",
+ "sistemas de informacao": "SIN",
+}
+
+COURSE_NAME_BY_FOLDER = {
+ "administracao": "Administração",
+ "analise e desenvolvimento de sistemas": "Análise e Desenvolvimento de Sistemas",
+ "arquitetura e urbanismo": "Arquitetura e Urbanismo",
+ "ciencia da computacao": "Ciência da Computação",
+ "design": "Design",
+ "economia": "Economia",
+ "engenharia civil": "Engenharia Civil",
+ "engenharia de alimentos": "Engenharia de Alimentos",
+ "engenharia de computacao": "Engenharia de Computação",
+ "engenharia de controle e automacao": "Engenharia de Controle e Automação",
+ "engenharia de producao": "Engenharia de Produção",
+ "engenharia eletrica": "Engenharia Elétrica",
+ "engenharia eletronica": "Engenharia Eletrônica",
+ "engenharia mecanica": "Engenharia Mecânica",
+ "engenharia quimica": "Engenharia Química",
+ "relacoes internacionais": "Relações Internacionais",
+ "sistemas da informacao": "Sistemas da Informação",
+ "sistemas de informacao": "Sistemas de Informação",
+}
+
+HEADER_CROP_COORDS = pymupdf.Rect(0, 0, 595, 620)
+
+INFO_COORDS: dict[str, pymupdf.Rect] = {
+ "course_code": pymupdf.Rect(396, 715, 564, 730),
+ "course_name": pymupdf.Rect(25, 717, 396, 729)
+}
+
+COURSE_CRITERIA_HEADER_REGEX = re.compile(r"AVALIAÇÃO (.*) e CRITÉRIOS DE APROVAÇÃO", re.IGNORECASE)
+COURSE_EXAMS_AND_PROJECTS_HEADER_REGEX = re.compile(
+ r"INFORMA[ÇC][ÕO]ES?\s+SOBRE\s+PROVAS?\s+E\s+TRABALHOS?",
+ re.IGNORECASE,
+)
+COURSE_PROGRAM_HEADER_REGEX = re.compile(r"PROGRAMA DA DISCIPLINA", re.IGNORECASE)
+
+END_EXTRACTION_REGEX = re.compile(r"PLANO DE ENSINO PARA O ANO LETIVO DE \d{4}", re.IGNORECASE)
+EVALUATION_SIGNAL_REGEXES = (
+ re.compile(r"PESO\s+DE\s+MP\s*\(?(?:kp|k p)\)?", re.IGNORECASE),
+ re.compile(r"PESO\s+DE\s+MT\s*\(?(?:kt|k t)\)?", re.IGNORECASE),
+ re.compile(r"\b(?:T\d+[A-Z]?|P\d+|PSUB)\b", re.IGNORECASE),
+ re.compile(r"CRIT[ÉE]RIO\s+DE\s+AVALIA", re.IGNORECASE),
+ re.compile(r"INFORMA[ÇC][ÕO]ES?\s+SOBRE\s+PROVAS?\s+E\s+TRABALHOS?", re.IGNORECASE),
+ re.compile(r"PROVA\s+SUB(?:STITUTIVA|STITUTA)?", re.IGNORECASE),
+ re.compile(r"M[ÉE]DIA\s+DE\s+(?:PROVAS|TRABALHOS)", re.IGNORECASE),
+)
+EVALUATION_RELEVANT_LINE_REGEX = re.compile(
+ r"(PESO|PROVA|TRABALH|CRIT[ÉE]RIO\s+DE\s+AVALIA|(?:\bT\d+[A-Z]?\b)|(?:\bP\d+\b)|PSUB|MP|MT|k\d+)",
+ re.IGNORECASE,
+)
+
+
+def _normalize_folder_name(value: str) -> str:
+ normalized = unicodedata.normalize("NFKD", value)
+ without_accents = "".join(char for char in normalized if not unicodedata.combining(char))
+ return " ".join(without_accents.casefold().split())
+
+
+def _course_code_from_folder(folder_name: str) -> str:
+ normalized = _normalize_folder_name(folder_name)
+ course_code = COURSE_CODE_BY_FOLDER.get(normalized)
+ if course_code is None:
+ logger.warning("Could not map course folder '%s' to a known code; using UNK", folder_name)
+ return "UNK"
+ return course_code
+
+
+def _course_name_from_folder(folder_name: str) -> str:
+ normalized = _normalize_folder_name(folder_name)
+ canonical = COURSE_NAME_BY_FOLDER.get(normalized)
+ if canonical is not None:
+ return canonical
+ return " ".join(folder_name.strip().split())
+
+
+def _series_number_from_folder(folder_name: str) -> int:
+ match = re.search(r"\d+", folder_name)
+ if not match:
+ raise ValueError(f"Could not extract series number from folder: {folder_name}")
+ return int(match.group())
+
+
+def _parse_s3_key(key: str) -> tuple[str, str | None, int | None, str | None]:
+ """Extract `(code, curso_code, ano, course_name)` from an S3 key.
+
+ Expects path format: {Curso}/{Série}/{CODE}.pdf
+ Example: Ciência da Computação/1o semestre/Banco de dados.pdf
+ """
+ path = PurePosixPath(unquote_plus(key))
+ filename = path.name
+ if not filename.lower().endswith(".pdf"):
+ raise ValueError(f"S3 object is not a PDF: {key}")
+
+ stem = filename[:-4]
+
+ parts = path.parts
+ if len(parts) >= 3:
+ curso_folder = parts[-3]
+ serie_folder = parts[-2]
+ try:
+ return (
+ stem,
+ _course_code_from_folder(curso_folder),
+ _series_number_from_folder(serie_folder),
+ _course_name_from_folder(curso_folder),
+ )
+ except ValueError as exc:
+ logger.warning("Could not parse curso/serie from %r: %s", key, exc)
+
+ logger.warning(
+ "S3 key %r does not match {CURSO}/{SERIE}/{CODE}.pdf format; "
+ "saving disciplina without course occurrence",
+ key,
+ )
+ return stem, None, None, None
+
+def extract_course_info_from_header(page: pymupdf.Page) -> dict[str, str]:
+ ptm = page.transformation_matrix
+ info_dict = {}
+
+ for key, rect in INFO_COORDS.items():
+ info = page.get_textbox(rect * ~ptm)
+ if info == "":
+ raise ValueError(f"Could not extract {key} from the PDF.")
+ info_dict[key] = info
+
+ return info_dict
+
+def extract_course_criteria(doc: pymupdf.Document) -> str:
+ extracting = False
+ criteria_text = ""
+
+ for page in doc:
+ ptm = page.transformation_matrix
+ page.set_cropbox(HEADER_CROP_COORDS * ~ptm)
+
+ text = page.get_text()
+ for line in text.splitlines():
+ if COURSE_CRITERIA_HEADER_REGEX.search(line):
+ extracting = True
+ continue
+ elif END_EXTRACTION_REGEX.search(line):
+ extracting = False
+
+ if extracting:
+ criteria_text += line + "\n"
+
+ if criteria_text == "":
+ raise ValueError("Could not extract course criteria from the PDF.")
+
+ return criteria_text
+
+def extract_course_exams_and_projects_info(doc: pymupdf.Document) -> str:
+ extracting = False
+ exams_and_projects_text = ""
+
+ for page in doc:
+ text = page.get_text()
+ for line in text.splitlines():
+ if COURSE_EXAMS_AND_PROJECTS_HEADER_REGEX.search(line):
+ extracting = True
+ continue
+ elif END_EXTRACTION_REGEX.search(line):
+ extracting = False
+
+ if extracting:
+ exams_and_projects_text += line + "\n"
+
+ if exams_and_projects_text.strip() and _has_evaluation_signal(exams_and_projects_text):
+ return exams_and_projects_text
+
+ if exams_and_projects_text.strip():
+ logger.warning("Primary exams/projects extraction looked uninformative; trying fallback")
+
+ if exams_and_projects_text == "" or not _has_evaluation_signal(exams_and_projects_text):
+ fallback_text = _extract_exams_and_projects_fallback_text(doc)
+ if fallback_text:
+ logger.warning("Using fallback extraction for exams and projects section")
+ return fallback_text
+ raise ValueError("Could not extract exams and projects info from the PDF.")
+
+ return exams_and_projects_text
+
+
+def _has_evaluation_signal(text: str) -> bool:
+ return any(regex.search(text) for regex in EVALUATION_SIGNAL_REGEXES)
+
+
+def _extract_exams_and_projects_fallback_text(doc: pymupdf.Document) -> str:
+ lines: list[str] = []
+ for page in doc:
+ lines.extend(page.get_text().splitlines())
+
+ useful_lines: list[str] = []
+ seen: set[str] = set()
+ for index, line in enumerate(lines):
+ if not any(regex.search(line) for regex in EVALUATION_SIGNAL_REGEXES):
+ continue
+
+ start = max(0, index - 1)
+ end = min(len(lines), index + 2)
+ for candidate in lines[start:end]:
+ normalized = candidate.strip()
+ if not normalized:
+ continue
+ if not EVALUATION_RELEVANT_LINE_REGEX.search(normalized):
+ continue
+ if normalized in seen:
+ continue
+ seen.add(normalized)
+ useful_lines.append(normalized)
+
+ return "\n".join(useful_lines)
+
+
+def extract_course_program(doc: pymupdf.Document) -> str:
+ extracting = False
+ program_text = ""
+
+ for page in doc:
+ text = page.get_text()
+ for line in text.splitlines():
+ if COURSE_PROGRAM_HEADER_REGEX.search(line):
+ extracting = True
+ continue
+
+ if extracting:
+ program_text += line + "\n"
+
+ return program_text
+
+def generate_json_with_bedrock(course_info: Course, bedrock_client: Any | None = None) -> dict[str, Any]:
+ PROMPT_TEMPLATE = """Você é um extrator de dados acadêmicos. A partir do dicionário Python abaixo (gerado por um script de scraping), extraia e estruture as informações no formato JSON especificado.
+
+ ## Entrada
+ ```
+ {INPUT_DATA}
+ ```
+
+ ## Saída esperada
+ Retorne APENAS um JSON válido, sem texto adicional, sem markdown, sem explicações. O JSON deve seguir exatamente esta estrutura:
+
+ {{
+ "course": "",
+ "name": "",
+ "code": "",
+ "period": "",
+ "examWeight": ,
+ "assignmentWeight": ,
+ "exams": [
+ {{
+ "name": "",
+ "weight":
+ }}
+ ],
+ "assignments": [
+ {{
+ "name": "",
+ "weight":
+ }}
+ ],
+ "courses": {{}}
+ }}
+
+ ## Regras de extração
+ - "course" deve ser o nome da disciplina presente no PDF; o backend sobrescreve esse campo com o nome do curso vindo da pasta do S3 antes de persistir.
+ - "examWeight" vem do campo "Peso de MP(kp)" dividido pela soma de kp+kt (ex: kp=5, kt=5 → examWeight=0.5)
+ - "assignmentWeight" vem do campo "Peso de MT(kt)" dividido pela soma de kp+kt
+ - Ao extrair "exams" e "assignments", use prioritariamente o trecho "INFORMAÇÕES SOBRE PROVAS E TRABALHOS" quando ele existir.
+ - Se houver pontuação explícita para componentes avaliativos (ex.: "X vale 2", "Y vale 6"), calcule os pesos relativos dividindo cada valor pela soma total dos valores do grupo.
+ - Só use distribuição de pesos iguais quando não houver qualquer informação explícita de pontuação ou peso no texto.
+ - Para disciplina anual com duas provas semestrais, aplicar pesos 2/5 e 3/5 (RN CEPE 16/2014), preferindo primeiro semestre=0.4 e segundo semestre=0.6 quando identificados
+ - Para disciplina semestral, distribuir pesos das provas por média simples quando não houver pesos explícitos
+ - "exams" deve listar todas as provas mencionadas (P1, P2, PS1, etc.), inclusive quando elas aparecem no programa da disciplina.
+ - Para provas bimestrais com pesos iguais, cada uma recebe weight = 1 / (número de provas regulares)
+ - "assignments" deve listar todos os trabalhos mencionados (T1, T2, T3, projeto, relatório, etc.) com pesos coerentes com os valores explícitos; na ausência deles, usar pesos iguais.
+ - "period" deve ser extraído se mencionado (ex: "1º semestre de 2024"), senão null
+ - "courses" deve ser sempre um objeto vazio {{}}
+ - Todos os campos numéricos de peso devem ser números (não strings)"""
+
+ if bedrock_client is None:
+ import boto3
+
+ client = boto3.client("bedrock-runtime", region_name="us-east-1")
+ else:
+ client = bedrock_client
+ model_id = "amazon.nova-lite-v1:0"
+
+ PROMPT = PROMPT_TEMPLATE.format(INPUT_DATA=json.dumps(course_info.__dict__, ensure_ascii=False))
+
+ native_request = {
+ "messages": [
+ {
+ "role": "user",
+ "content": [{"text": PROMPT}],
+ }
+ ],
+ "inferenceConfig": {
+ "max_new_tokens": 1000,
+ "temperature": 0,
+ },
+ }
+
+ request = json.dumps(native_request)
+
+ try:
+ response = client.invoke_model(modelId=model_id, body=request)
+ except (ClientError, Exception) as e:
+ logger.error("ERROR: Can't invoke '%s'. Reason: %s", model_id, e)
+ raise
+
+ model_response = json.loads(response["body"].read())
+ response_text = model_response["output"]["message"]["content"][0]["text"]
+
+ if response_text.startswith("```"):
+ response_text = response_text.split("```")[1]
+ if response_text.startswith("json"):
+ response_text = response_text[4:]
+ response_text = response_text.strip()
+
+ logger.info("Bedrock response: %s", response_text)
+ return json.loads(response_text)
+
+def _key_candidates(raw_key: str) -> list[str]:
+ decoded = unquote_plus(raw_key)
+ seen: list[str] = []
+ for value in (decoded, raw_key, unicodedata.normalize("NFC", decoded), unicodedata.normalize("NFD", decoded)):
+ if value and value not in seen:
+ seen.append(value)
+ return seen
+
+
+def load_pdf_from_s3(bucket: str, key: str) -> pymupdf.Document:
+ """Download PDF from S3 and return as pymupdf Document."""
+ import boto3
+
+ s3 = boto3.client("s3")
+
+ last_error: Exception | None = None
+ for candidate_key in _key_candidates(key):
+ logger.info("Loading s3://%s/%s", bucket, candidate_key)
+ try:
+ response = s3.get_object(Bucket=bucket, Key=candidate_key)
+ pdf_bytes = response["Body"].read()
+ return pymupdf.open(stream=pdf_bytes, filetype="pdf")
+ except s3.exceptions.NoSuchKey as exc:
+ logger.warning("Object not found at s3://%s/%s, trying next candidate", bucket, candidate_key)
+ last_error = exc
+ except Exception as exc:
+ logger.error("Error getting object %s from bucket %s: %s", candidate_key, bucket, exc)
+ raise
+
+ raise FileNotFoundError(f"S3 object not found in bucket {bucket} (tried keys: {_key_candidates(key)})") from last_error
+
+
+def _repository() -> DisciplinaRepositoryDynamo:
+ """Get DynamoDB repository instance."""
+ return Environments.get_disciplina_repo()
+
+
+def _process_s3_record(record: dict[str, Any], repository: DisciplinaRepositoryDynamo) -> bool:
+ """Process a single S3 event record and persist extracted disciplina to DynamoDB."""
+ try:
+ bucket = record["s3"]["bucket"]["name"]
+ raw_key = record["s3"]["object"]["key"]
+
+ code, curso, ano, course_name = _parse_s3_key(raw_key)
+ logger.info("Parsed S3 key: code=%s, curso=%s, ano=%s, course_name=%s", code, curso, ano, course_name)
+
+ doc = load_pdf_from_s3(bucket, raw_key)
+
+ header_info = extract_course_info_from_header(doc[0])
+
+ course_criteria = extract_course_criteria(doc)
+
+ exams_and_projects_info = extract_course_exams_and_projects_info(doc)
+ course_program = extract_course_program(doc)
+
+ course = Course(
+ name=header_info["course_name"],
+ code=header_info["course_code"],
+ criteria=course_criteria,
+ exams_and_projects_info=f"{exams_and_projects_info}\nPROGRAMA DA DISCIPLINA\n{course_program}",
+ )
+
+ extracted_data = generate_json_with_bedrock(course)
+ # Source of truth for disciplina code is the S3 object key.
+ # This avoids model hallucinations/variations (e.g., EEN281 -> EEE281)
+ # that would persist under the wrong primary key in Dynamo.
+ extracted_data["code"] = code
+
+ if course_name:
+ extracted_data["course"] = course_name
+
+ existing = repository.get_disciplina(code)
+ course_occurrence: dict[str, int] = {curso: ano} if curso and ano is not None else {}
+ if existing is None:
+ courses_to_persist = course_occurrence
+ else:
+ courses_to_persist = dict(existing.courses)
+ courses_to_persist.update(course_occurrence)
+
+ disciplina = build_disciplina(extracted_data, courses=courses_to_persist)
+
+ if existing is None:
+ logger.info("Creating disciplina %s with courses=%s", code, courses_to_persist)
+ repository.create_disciplina(disciplina)
+ else:
+ logger.info("Updating existing disciplina %s", code)
+ repository.update_disciplina(disciplina)
+
+ return True
+ except Exception as e:
+ logger.error("Error processing S3 record: %s", e)
+ return False
+
+
+def lambda_handler(event: dict[str, Any], context: Any) -> dict[str, Any]:
+ """AWS Lambda handler for processing syllabus PDFs from S3."""
+ records = event.get("Records", [])
+ repository = _repository()
+
+ processed = 0
+ skipped = 0
+ for record in records:
+ if _process_s3_record(record, repository):
+ processed += 1
+ else:
+ skipped += 1
+
+ logger.info("Lambda execution complete: processed=%d, skipped=%d", processed, skipped)
+ return {"processed": processed, "skipped": skipped}
diff --git a/src/modules/plans_extractor/app/helper/__init__.py b/src/modules/plans_extractor/app/helper/__init__.py
new file mode 100644
index 0000000..8b13789
--- /dev/null
+++ b/src/modules/plans_extractor/app/helper/__init__.py
@@ -0,0 +1 @@
+
diff --git a/src/modules/plans_extractor/app/helper/course/__init__.py b/src/modules/plans_extractor/app/helper/course/__init__.py
new file mode 100644
index 0000000..8b13789
--- /dev/null
+++ b/src/modules/plans_extractor/app/helper/course/__init__.py
@@ -0,0 +1 @@
+
diff --git a/src/modules/plans_extractor/app/helper/course/course.py b/src/modules/plans_extractor/app/helper/course/course.py
new file mode 100644
index 0000000..e8c5f93
--- /dev/null
+++ b/src/modules/plans_extractor/app/helper/course/course.py
@@ -0,0 +1,6 @@
+class Course:
+ def __init__(self, name, code, criteria, exams_and_projects_info):
+ self.name = name
+ self.code = code
+ self.criteria = criteria
+ self.exams_and_projects_info = exams_and_projects_info
diff --git a/src/modules/plans_extractor/app/parser.py b/src/modules/plans_extractor/app/parser.py
new file mode 100644
index 0000000..2d383e9
--- /dev/null
+++ b/src/modules/plans_extractor/app/parser.py
@@ -0,0 +1,267 @@
+import logging
+import math
+import unicodedata
+from typing import Any
+
+from pydantic import ValidationError
+
+from src.shared.domain.entities.disciplina import Disciplina
+
+logger = logging.getLogger(__name__)
+LOWERCASE_WORDS = {"a", "as", "da", "das", "de", "do", "dos", "e", "em", "na", "nas", "no", "nos"}
+FIRST_SEMESTER_HINTS = ("1 semestre", "1 sem", "primeiro semestre", "semestre 1")
+SECOND_SEMESTER_HINTS = ("2 semestre", "2 sem", "segundo semestre", "semestre 2")
+SUBSTITUTIVE_HINTS = ("substitutiva", "substitutivo", "substituta", "substituto", "psub", "p sub")
+
+
+def _to_float(value: Any, default: float = 0.0) -> float:
+ if value is None:
+ return default
+ if isinstance(value, bool):
+ raise ValueError("Boolean value is not valid for numeric fields")
+ return float(value)
+
+
+def _normalize_ratio(value: Any, field_name: str) -> float:
+ numeric = _to_float(value)
+ if numeric < 0:
+ raise ValueError(f"{field_name} must be >= 0")
+ if numeric > 1:
+ if numeric <= 10:
+ numeric /= 10
+ elif numeric <= 100:
+ numeric /= 100
+ else:
+ raise ValueError(f"{field_name} must be <= 1")
+ return numeric
+
+
+def _normalize_name(value: Any) -> str:
+ if value is None:
+ return ""
+
+ words = str(value).strip().split()
+ if not words:
+ return ""
+
+ normalized_words: list[str] = []
+ for index, word in enumerate(words):
+ lower_word = word.casefold()
+ if index > 0 and lower_word in LOWERCASE_WORDS:
+ normalized_words.append(lower_word)
+ else:
+ normalized_words.append(lower_word.capitalize())
+ return " ".join(normalized_words)
+
+
+def _normalize_period(value: Any) -> str:
+ period_text = "anual" if value is None else str(value).strip().casefold()
+ period_map = {
+ "s": "S",
+ "semestral": "S",
+ "semestre": "S",
+ "a": "A",
+ "anual": "A",
+ "ano": "A",
+ "t": "T",
+ "trimestral": "T",
+ "trimestre": "T",
+ }
+ return period_map.get(period_text, "A")
+
+
+def _normalize_text(value: Any) -> str:
+ if value is None:
+ return ""
+ normalized = unicodedata.normalize("NFKD", str(value))
+ without_accents = "".join(char for char in normalized if not unicodedata.combining(char))
+ return " ".join(without_accents.casefold().split())
+
+
+def _normalize_items(items: Any, field_name: str) -> list[dict[str, Any]]:
+ if not items:
+ return []
+ if not isinstance(items, list):
+ raise ValueError(f"{field_name} must be a list")
+
+ normalized_items: list[dict[str, Any]] = []
+ for index, item in enumerate(items):
+ if not isinstance(item, dict):
+ raise ValueError(f"{field_name}[{index}] must be an object")
+ normalized_items.append(
+ {
+ "name": item.get("name"),
+ "weight": _normalize_ratio(item.get("weight"), f"{field_name}[{index}].weight"),
+ }
+ )
+ return normalized_items
+
+
+def _truncate_weight(value: float) -> float:
+ # Business rule: weights with at most 3 decimal places, without rounding up.
+ return math.floor(value * 1000) / 1000
+
+
+def _truncate_items_weights(items: list[dict[str, Any]]) -> list[dict[str, Any]]:
+ for item in items:
+ item["weight"] = _truncate_weight(item["weight"])
+ return items
+
+
+def _is_substitutive_item(name: Any) -> bool:
+ normalized = _normalize_text(name)
+ return any(hint in normalized for hint in SUBSTITUTIVE_HINTS)
+
+
+def _remove_substitutive_items(items: list[dict[str, Any]]) -> list[dict[str, Any]]:
+ return [item for item in items if not _is_substitutive_item(item.get("name"))]
+
+
+def _normalize_items_distribution(
+ items: list[dict[str, Any]], fallback_weights: list[float] | None = None
+) -> list[dict[str, Any]]:
+ if not items:
+ return items
+
+ weights = [item["weight"] for item in items]
+ has_invalid_weight = any(weight <= 0 for weight in weights)
+ weights_sum = sum(weights)
+ if has_invalid_weight or weights_sum <= 0:
+ if fallback_weights is None:
+ fallback_weights = [1 / len(items)] * len(items)
+ for index, item in enumerate(items):
+ item["weight"] = fallback_weights[index]
+ return items
+
+ for item in items:
+ item["weight"] = item["weight"] / weights_sum
+ return items
+
+
+def _fallback_exam_weights(count: int, period: str) -> list[float]:
+ if count <= 0:
+ return []
+ if count == 1:
+ return [1.0]
+ if period == "S":
+ # RN CEPE 16/2014 Art. 7 §1: semestral uses simple average.
+ return [1 / count] * count
+ if count == 2:
+ return [0.4, 0.6]
+
+ first_group_count = min(2, count - 1)
+ last_group_count = count - first_group_count
+ return [0.4 / first_group_count] * first_group_count + [0.6 / last_group_count] * last_group_count
+
+
+def _semester_bucket(item_name: Any) -> int | None:
+ normalized_name = _normalize_text(item_name)
+ if any(hint in normalized_name for hint in FIRST_SEMESTER_HINTS):
+ return 1
+ if any(hint in normalized_name for hint in SECOND_SEMESTER_HINTS):
+ return 2
+ return None
+
+
+def _reconcile_annual_semester_split(exams: list[dict[str, Any]], period: str) -> None:
+ if period != "A" or len(exams) != 2:
+ return
+
+ weights = [item["weight"] for item in exams]
+ if not (abs(weights[0] - 0.5) <= 0.01 and abs(weights[1] - 0.5) <= 0.01):
+ return
+
+ first_index = None
+ second_index = None
+ for index, item in enumerate(exams):
+ semester = _semester_bucket(item.get("name"))
+ if semester == 1 and first_index is None:
+ first_index = index
+ elif semester == 2 and second_index is None:
+ second_index = index
+
+ if first_index is None and second_index is None:
+ # Guard-rail fallback: for annual disciplines with exactly two exams and
+ # an ambiguous 50/50 split, keep deterministic semester weighting order.
+ exams[0]["weight"] = 0.4
+ exams[1]["weight"] = 0.6
+ return
+
+ if first_index is None and second_index is not None:
+ first_index = 1 - second_index
+ if second_index is None and first_index is not None:
+ second_index = 1 - first_index
+ if first_index == second_index:
+ exams[0]["weight"] = 0.4
+ exams[1]["weight"] = 0.6
+ return
+
+ exams[first_index]["weight"] = 0.4
+ exams[second_index]["weight"] = 0.6
+
+
+def _normalize_exams(items: Any, period: str) -> list[dict[str, Any]]:
+ normalized_items = _normalize_items(items, "exams")
+ normalized_items = _remove_substitutive_items(normalized_items)
+ if not normalized_items:
+ return []
+
+ fallback = _fallback_exam_weights(len(normalized_items), period)
+ normalized_items = _normalize_items_distribution(normalized_items, fallback_weights=fallback)
+ _reconcile_annual_semester_split(normalized_items, period)
+ return _truncate_items_weights(normalized_items)
+
+
+def _normalize_assignments(items: Any) -> list[dict[str, Any]]:
+ normalized_items = _normalize_items(items, "assignments")
+ normalized_items = _remove_substitutive_items(normalized_items)
+ if not normalized_items:
+ return []
+ normalized_items = _normalize_items_distribution(normalized_items)
+ return _truncate_items_weights(normalized_items)
+
+
+def _normalize_assessment_weights(exam_weight: Any, assignment_weight: Any) -> tuple[float, float]:
+ normalized_exam_weight = _normalize_ratio(exam_weight, "exam_weight")
+ normalized_assignment_weight = _normalize_ratio(assignment_weight, "assignment_weight")
+ total = normalized_exam_weight + normalized_assignment_weight
+
+ if total > 0:
+ normalized_exam_weight /= total
+ normalized_assignment_weight /= total
+
+ return _truncate_weight(normalized_exam_weight), _truncate_weight(normalized_assignment_weight)
+
+
+def build_disciplina(extracted_data: dict[str, Any], courses: dict[str, int]) -> Disciplina:
+ """Validate Bedrock output and add course occurrence data owned by the S3 key."""
+ payload = dict(extracted_data)
+
+ payload["name"] = _normalize_name(payload.get("name"))
+ payload["period"] = _normalize_period(payload.get("period"))
+ raw_exam_weight = payload.get("exam_weight", payload.get("examWeight"))
+ raw_assignment_weight = payload.get("assignment_weight", payload.get("assignmentWeight"))
+ # Remove alias keys from model output to avoid precedence conflicts
+ # during pydantic validation when normalized snake_case fields are set.
+ payload.pop("examWeight", None)
+ payload.pop("assignmentWeight", None)
+ payload["exam_weight"], payload["assignment_weight"] = _normalize_assessment_weights(
+ raw_exam_weight,
+ raw_assignment_weight,
+ )
+ payload["exams"] = _normalize_exams(payload.get("exams"), payload["period"])
+ payload["assignments"] = _normalize_assignments(payload.get("assignments"))
+
+ if payload["exam_weight"] == 0:
+ payload["exams"] = []
+ if payload["assignment_weight"] == 0:
+ payload["assignments"] = []
+
+ # courses is derived from the S3 object name, not from the model output.
+ payload["courses"] = courses
+
+ try:
+ return Disciplina.model_validate(payload)
+ except ValidationError as exc:
+ logger.error("Invalid Disciplina payload from Bedrock: %s", exc.errors())
+ raise
diff --git a/src/modules/plans_extractor/app/plans_extractor_presenter.py b/src/modules/plans_extractor/app/plans_extractor_presenter.py
index 951d328..8c5b3a2 100644
--- a/src/modules/plans_extractor/app/plans_extractor_presenter.py
+++ b/src/modules/plans_extractor/app/plans_extractor_presenter.py
@@ -1,305 +1,7 @@
-
-import json
-import boto3
-import os
-import re
-from io import BytesIO
-from pypdf import PdfReader
-import pandas as pd
-from urllib.parse import unquote_plus
-
-def clean_and_optimize_text(raw_text: str) -> str:
- """
- Limpa e otimiza o texto extraído de um PDF para minimizar o uso de tokens.
- """
- # 1. Remove as tags
- text = re.sub(r'\\s*', '', raw_text)
-
- # 2. Remove marcadores de página e de tabelas
- text = re.sub(r'--- PAGE \d+ ---', '', text)
- text = re.sub(r'"The following table:"', '', text, flags=re.IGNORECASE)
-
- # 3. Reformata as linhas da tabela para um formato mais limpo
- text = re.sub(r'"([^"]+)"\s*,\s*"([^"]*)"\s*,\s*"([^"]*);"', r'Semana \1: \2 (EAA: \3)', text)
- text = re.sub(r'"([^"]+)"\s*,\s*,\s*"([^"]*);"', r'Semana \1: (EAA: \2)', text)
-
- # 4. Normaliza espaços em branco e remove linhas vazias excessivas
- text = re.sub(r'(\n\s*){2,}', '\n', text)
-
- return text.strip()
+"""Lambda entrypoint — delegates to course_extractor (handler name required by IaC)."""
def lambda_handler(event, context):
- """
- Função principal da Lambda que é acionada por um evento do S3.
- """
- print("Evento recebido:", json.dumps(event))
-
- s3 = boto3.client("s3")
- bedrock_region = os.environ.get("BEDROCK_REGION", "us-east-1")
- bedrock = boto3.client("bedrock-runtime", region_name=bedrock_region)
-
- try:
- first_record_bucket = event["Records"][0]['s3']['bucket']['name']
- print(f"Carregando a fonte da verdade de: {first_record_bucket}/relacao_disciplinas.xlsx")
- excel_response = s3.get_object(Bucket=first_record_bucket, Key="relacao_disciplinas.xlsx")
- excel_bytes = excel_response["Body"].read()
- df_truth = pd.read_excel(BytesIO(excel_bytes), skiprows=2)
- print("Fonte da verdade carregada com sucesso.")
-
- for record in event["Records"]:
- bucket_name = record['s3']['bucket']['name']
- object_key = unquote_plus(record['s3']['object']['key'])
-
- if object_key.startswith("plans/"):
- print(f"Processing plan file: {object_key}")
-
- filename = os.path.basename(object_key)
- subject_code = filename.split('.')[0]
- print(f"Extracted subject code: {subject_code}")
-
- context_from_excel = ""
- all_matching_rows = df_truth[df_truth['CODIGO DISCIPLINA'] == subject_code]
-
- if not all_matching_rows.empty:
- print(f"Encontradas {len(all_matching_rows)} entradas para {subject_code} no Excel.")
- info_list = all_matching_rows.to_dict(orient='records')
-
- context_from_excel = (
- "Aqui estão os dados da fonte da verdade (Excel) para esta disciplina. "
- "Use estes dados para preencher ou corrigir as informações do PDF, especialmente os campos 'period' e 'courses'.\n"
- f"{json.dumps(info_list, indent=2, ensure_ascii=False)}"
- )
- else:
- context_from_excel = "AVISO: Nenhuma informação de contexto encontrada no arquivo Excel para este código de disciplina."
- print(f"Contexto para {subject_code} não encontrado no Excel.")
-
- pdf_response = s3.get_object(Bucket=bucket_name, Key=object_key)
- pdf_bytes = pdf_response['Body'].read()
- raw_text = "".join([page.extract_text() or "" for page in PdfReader(BytesIO(pdf_bytes)).pages])
- optimized_text = clean_and_optimize_text(raw_text)
- content_for_claude = {"type": "text", "content": optimized_text}
-
- final_data = extract_course_data_with_claude(
- bedrock,
- content_for_claude,
- object_key,
- context_from_excel
- )
-
-
- print("Dados Finais (processados pelo Claude com contexto):")
- print(json.dumps(final_data, indent=2))
-
- else:
- print(f"Skipping file, not a plan file: {object_key}")
-
- return {'statusCode': 200, 'body': json.dumps({'message': 'Event processed successfully'})}
-
- except Exception as e:
- print(f"Erro geral no handler: {str(e)}")
- return {'statusCode': 500, 'body': json.dumps({'error': str(e)})}
-
-
-def extract_course_data_with_claude(bedrock_client, content_data, filename, context_from_excel: str):
- """
- Usa o Claude 3 Sonnet para extrair dados estruturados do conteúdo.
- """
- schema = {
- "type": "object",
- "properties": {
- "course": {"type": "string", "description": "Nome completo do curso"},
- "name": {"type": "string", "description": "Nome completo da disciplina"},
- "code": {"type": "string", "description": "Código da disciplina (ex: DSG244)"},
- "period": {"type": "string", "enum": ["A", "S"], "description": "A para Anual, S para Semestral"},
- "examWeight": {"type": "number", "minimum": 0, "maximum": 100, "description": "Peso das provas em %"},
- "assignmentWeight": {"type": "number", "minimum": 0, "maximum": 100, "description": "Peso dos trabalhos em %"},
- "exams": {
- "type": "array",
- "maxItems": 4,
- "items": {
- "type": "object",
- "properties": {
- "name": {"type": "string", "enum": ["P1", "P2", "P3", "P4"]},
- "weight": {"type": "number", "minimum": 0, "maximum": 1}
- }, "required": ["name", "weight"]
- }
- },
- "assignments": {
- "type": "array",
- "maxItems": 10,
- "items": {
- "type": "object",
- "properties": {
- "name": {"type": "string", "enum": ["T1", "T2", "T3", "T4", "T5", "T6", "T7", "T8", "T9", "T10"]},
- "weight": {"type": "number", "minimum": 0, "maximum": 1}
- }, "required": ["name", "weight"]
- }
- },
- "courses": {
- "type": "object",
- "description": "Informações sobre quais cursos possuem esta disciplina e em qual ano",
- "patternProperties": {
- "^(EAL|ECA|ECM|EEN|EET|EMC|EPM|EQM|ETC|ADM|DSG|CIC|SIN|IA|ARQ|RI|ADS)$": {
- "type": "number", "minimum": 1, "maximum": 5, "description": "Ano do curso (1 a 5)"
- }
- }
- }
- },
- "required": ["course", "name", "code", "period", "examWeight", "assignmentWeight", "exams", "assignments"]
- }
-
- schema_prompt = f"""
-Você é um assistente de extração de dados altamente preciso. Sua tarefa é analisar o contexto de um arquivo Excel e o texto de um plano de ensino em PDF para preencher um objeto JSON de acordo com um esquema específico.
-
-Siga estas regras rigorosamente:
-
-1. **Prioridade da Fonte da Verdade:** O conteúdo dentro das tags `` é a fonte da verdade absoluta para os campos `courses` e `period`. Se houver um conflito com o PDF, a informação do Excel SEMPRE vence.
-2. **Extração do PDF:** Para todos os outros campos (`name`, `code`, `examWeight`, `assignmentWeight`, `exams`, `assignments`), use o texto dentro das tags ``.
-3. **Raciocínio Lógico:** Antes de gerar o JSON final, pense passo a passo dentro de tags ``. Descreva como você encontrou cada valor e por que tomou cada decisão.
-4. **Formato de Saída:** Após a tag ``, forneça APENAS o objeto JSON válido, sem comentários, explicações ou formatação de bloco de código.
-
----
-**EXEMPLO DE USO:**
-
-
-[
- {{
- "CODIGO DISCIPLINA": "ECM206",
- "DISCIPLINA": "Física II",
- "CURSO": "ECM",
- "PERIODO": "2º Semestre",
- "SEMESTRALIDADE": "Semestral"
- }},
- {{
- "CODIGO DISCIPLINA": "ECM206",
- "DISCIPLINA": "Física II",
- "CURSO": "EET",
- "PERIODO": "2º Semestre",
- "SEMESTRALIDADE": "Semestral"
- }}
-]
-
-
-
-Disciplina: FISICA 2
-Código da Disciplina: ECM206
-Peso de MP(kp): 7
-Peso de MT(kt): 3
-Critério de aprovação: C1/2007 (2 provas)
-
-
-
-{json.dumps(schema, indent=2)}
-
-
-**SAÍDA ESPERADA:**
-
-
-1. **course**: O Excel e o PDF mencionam "Física II", mas o schema pede o nome completo do curso, não da disciplina. O campo CURSO no Excel indica os cursos que têm a disciplina. O PDF não informa o nome do curso. Vou deixar este campo em branco ou com um valor padrão, pois não há informação suficiente para preenchê-lo com um nome completo de curso como "Engenharia de Computação". [Nota: O schema pode precisar de ajuste aqui, ou o Claude pode precisar de mais instrução. Por enquanto, a extração será literal]. Vou preencher com o nome da disciplina, pois é a informação mais proeminente.
-2. **name**: O PDF diz "FISICA 2". Vou usar isso.
-3. **code**: O PDF e o Excel concordam em "ECM206".
-4. **period**: O Excel é a fonte da verdade. A "SEMESTRALIDADE" é "Semestral", então o valor é "S".
-5. **examWeight**: O PDF diz "Peso de MP(kp): 7". Isso se traduz para 70.
-6. **assignmentWeight**: O PDF diz "Peso de MT(kt): 3". Isso se traduz para 30.
-7. **exams**: O critério "C1/2007" e a menção de "(2 provas)" indicam 2 provas. Com peso 0.5 cada.
-8. **assignments**: Não há menção a trabalhos específicos, então vou deixar o array vazio.
-9. **courses**: O Excel é a fonte da verdade. O código ECM206 está associado aos cursos ECM e EET, ambos no "2º Semestre", que corresponde ao ano 1. O objeto será {{"ECM": 1, "EET": 1}}.
-
-{{
- "course": "Física II",
- "name": "FISICA 2",
- "code": "ECM206",
- "period": "S",
- "examWeight": 70.0,
- "assignmentWeight": 30.0,
- "exams": [
- {{
- "name": "P1",
- "weight": 0.5
- }},
- {{
- "name": "P2",
- "weight": 0.5
- }}
- ],
- "assignments": [],
- "courses": {{
- "ECM": 1,
- "EET": 1
- }}
-}}
-
----
-**AGORA, SUA VEZ. ANALISE OS DADOS A SEGUIR E GERE A SAÍDA NO FORMATO DESCRITO.**
-"""
-
- message_content = [{
- "type": "text",
- "text": (
- f"\n{context_from_excel}\n\n\n"
- f"\n{content_data['content']}\n\n\n"
- f"\n{json.dumps(schema, indent=2)}\n\n\n"
- f"{schema_prompt}"
- )
- }]
-
- try:
- response = bedrock_client.invoke_model(
- modelId='anthropic.claude-3-sonnet-20240229-v1:0',
- contentType='application/json',
- accept='application/json',
- body=json.dumps({
- "anthropic_version": "bedrock-2023-05-31",
- "max_tokens": 4000,
- "messages": [{"role": "user", "content": message_content}],
- "temperature": 0.1
- })
- )
-
- response_body = json.loads(response['body'].read())
- claude_response = response_body['content'][0]['text']
-
- thinking_block_end = ""
- if thinking_block_end in claude_response:
- json_part = claude_response.split(thinking_block_end, 1)[1].strip()
-
- json_part = re.sub(r'^```json\s*', '', json_part)
- json_part = re.sub(r'```$', '', json_part)
+ from .course_extractor import lambda_handler as course_extractor_handler
- structured_data = json.loads(json_part)
- else:
- print("Bloco não encontrado. Tentando parse direto do JSON.")
- structured_data = json.loads(claude_response)
-
- usage = response_body.get('usage', {})
- input_tokens = usage.get('input_tokens', 0)
- output_tokens = usage.get('output_tokens', 0)
-
- estimated_cost = (input_tokens * 0.003 / 1000) + (output_tokens * 0.015 / 1000)
-
- print(f"Claude API Usage - Input: {input_tokens}, Output: {output_tokens}, Total: {input_tokens + output_tokens}")
- print(f"File: {filename} - Estimated cost: ${estimated_cost:.6f}")
-
- try:
- structured_data = json.loads(claude_response)
- except json.JSONDecodeError:
- print("Direct JSON parsing failed. Attempting to extract JSON from response...")
- json_match = re.search(r'\{.*\}', claude_response, re.DOTALL)
- if json_match:
- structured_data = json.loads(json_match.group(0))
- else:
- raise ValueError("Could not extract valid JSON from Claude's response")
-
- structured_data['token_usage'] = {
- 'input_tokens': input_tokens,
- 'output_tokens': output_tokens,
- 'total_tokens': input_tokens + output_tokens,
- 'estimated_cost_usd': estimated_cost
- }
-
- return structured_data
-
- except Exception as e:
- print(f"Error calling Claude: {str(e)}")
- return {"error": f"Failed to process with Claude: {str(e)}"}
\ No newline at end of file
+ return course_extractor_handler(event, context)
diff --git a/src/shared/domain/entities/boletim_ga.py b/src/shared/domain/entities/boletim_ga.py
new file mode 100644
index 0000000..760d8b5
--- /dev/null
+++ b/src/shared/domain/entities/boletim_ga.py
@@ -0,0 +1,168 @@
+from src.shared.helpers.errors.domain_errors import EntityError, EntityParameterError
+from typing import Optional
+
+class Boletim_GA:
+ current_tests: list[float]
+ current_assignments: list[float]
+ num_remaining_tests: int
+ num_remaining_assignments: int
+ test_weight: float
+ assignment_weight: float
+ spec_test_weight: Optional[list[float]]
+ spec_assignment_weight: Optional[list[float]]
+ response: dict
+ target_avg: float
+ max_grade: float
+
+ def __init__(
+ self,
+ current_tests: list[float],
+ current_assignments: list[float],
+ num_remaining_tests: int,
+ num_remaining_assignments: int,
+ test_weight: float,
+ assignment_weight: float,
+ spec_test_weight: Optional[list[float]] = None,
+ spec_assignment_weight: Optional[list[float]] = None,
+ max_grade: float = 10.0
+ ):
+
+
+ # Valida e atribui num_remaining
+ if not self.validate_num_remaining(num_remaining_tests):
+ raise EntityError("num_remaining_tests")
+ self.num_remaining_tests = num_remaining_tests
+
+ if not self.validate_num_remaining(num_remaining_assignments):
+ raise EntityError("num_remaining_assignments")
+ self.num_remaining_assignments = num_remaining_assignments
+
+ # Valida e atribui pesos gerais
+ if not self.validate_sum_weights(test_weight, assignment_weight):
+ raise EntityError("test_weight and/or assignment_weight (devem somar 1.0)")
+
+ if not self.validate_weights(test_weight):
+ raise EntityError("test_weight")
+ self.test_weight = test_weight
+
+ if not self.validate_weights(assignment_weight):
+ raise EntityError("assignment_weight")
+ self.assignment_weight = assignment_weight
+
+ # Valida e atribui listas de notas
+ if not self.validate_tests(current_tests, max_grade):
+ raise EntityError("current_tests")
+ self.current_tests = current_tests
+
+ if not self.validate_tests(current_assignments, max_grade):
+ raise EntityError("current_assignments")
+ self.current_assignments = current_assignments
+
+
+ if spec_test_weight is not None and len(spec_test_weight) > 0:
+ if not self.validate_sum_spec_weights(spec_test_weight, current_tests, num_remaining_tests):
+ raise EntityError("spec_test_weight")
+ if not self.validate_spec_weights(spec_test_weight):
+ raise EntityError("spec_test_weight")
+ self.spec_test_weight = spec_test_weight if spec_test_weight else None
+
+ if spec_assignment_weight is not None and len(spec_assignment_weight) > 0:
+ if not self.validate_sum_spec_weights(spec_assignment_weight, current_assignments, num_remaining_assignments):
+ raise EntityError("spec_assignment_weight")
+ if not self.validate_spec_weights(spec_assignment_weight):
+ raise EntityError("spec_assignment_weight")
+ self.spec_assignment_weight = spec_assignment_weight if spec_assignment_weight else None
+
+ self.response = self.to_dict()
+
+ @staticmethod
+ def validate_num_remaining(num_remaining: int) -> bool:
+ if not isinstance(num_remaining, int):
+ return False
+ if num_remaining < 0:
+ return False
+ return True
+
+ @staticmethod
+ def validate_weights(weight: float) -> bool:
+ if not isinstance(weight, (float, int)):
+ return False
+ if not (0 <= weight <= 1):
+ return False
+ return True
+
+ @staticmethod
+ def validate_tests(current_tests: list[float], max_grade: float) -> bool:
+ if not isinstance(current_tests, list):
+ return False
+ if not all(isinstance(item, (float, int)) for item in current_tests):
+ return False
+ for test in current_tests:
+ if test % 0.5 != 0:
+ return False
+ if test < 0 or test > max_grade:
+ return False
+ return True
+
+ @staticmethod
+ def validate_spec_weights(spec_weight: list[float]) -> bool:
+ if not isinstance(spec_weight, list):
+ return False
+ if not all(isinstance(item, (float, int)) for item in spec_weight):
+ return False
+ for weight in spec_weight:
+ if not (0 <= weight <= 1):
+ return False
+ return True
+
+ @staticmethod
+ def validate_sum_weights(weight1: float, weight2: float) -> bool:
+ return abs((weight1 + weight2) - 1.0) < 0.01 # Tolerância para float
+
+ @staticmethod
+ def validate_sum_spec_weights(
+ spec_weight: list[float],
+ current_tests: list[float],
+ num_remaining_tests: int
+ ) -> bool:
+ if spec_weight is None:
+ return True
+
+ total_items = len(current_tests) + num_remaining_tests
+ if total_items == 0:
+ return len(spec_weight) == 0
+ if len(spec_weight) != total_items:
+ return False
+ if abs(sum(spec_weight) - 1.0) > 0.01:
+ return False
+ return True
+
+ @staticmethod
+ def validate_max_grade(max_grade: float) -> bool:
+ if not isinstance(max_grade, (float, int)):
+ return False
+ if max_grade <= 0:
+ return False
+ return True
+
+ @staticmethod
+ def validate_target_avg(target_avg: float, max_grade: float) -> bool:
+ if not isinstance(target_avg, (float, int)):
+ return False
+ if target_avg < 0 or target_avg > max_grade:
+ return False
+ return True
+
+
+ def to_dict(self) -> dict:
+ """Converte o boletim para dicionário."""
+ return {
+ "current_tests": self.current_tests,
+ "current_assignments": self.current_assignments,
+ "num_remaining_tests": self.num_remaining_tests,
+ "num_remaining_assignments": self.num_remaining_assignments,
+ "test_weight": self.test_weight,
+ "assignment_weight": self.assignment_weight,
+ "spec_test_weight": self.spec_test_weight,
+ "spec_assignment_weight": self.spec_assignment_weight
+ }
\ No newline at end of file
diff --git a/src/shared/domain/entities/curso.py b/src/shared/domain/entities/curso.py
new file mode 100644
index 0000000..20bf057
--- /dev/null
+++ b/src/shared/domain/entities/curso.py
@@ -0,0 +1,8 @@
+from pydantic import BaseModel, Field
+
+
+class Curso(BaseModel):
+
+ código: str = Field(..., description="Identificador do curso")
+ nome: str = Field(..., description="Nome do curso")
+
diff --git a/src/shared/domain/entities/disciplina.py b/src/shared/domain/entities/disciplina.py
new file mode 100644
index 0000000..17eceb6
--- /dev/null
+++ b/src/shared/domain/entities/disciplina.py
@@ -0,0 +1,28 @@
+from pydantic import BaseModel, ConfigDict, Field
+
+
+class ItemAvaliacao(BaseModel):
+ """Componente ponderado de prova ou trabalho (ex.: P1, K1)."""
+
+ model_config = ConfigDict(str_strip_whitespace=True)
+
+ name: str
+ weight: float
+
+
+class Disciplina(BaseModel):
+ """
+ Disciplina com pesos de avaliação e vínculos a cursos (códigos de grade → período).
+ """
+
+ model_config = ConfigDict(str_strip_whitespace=True, populate_by_name=True)
+
+ course: str = Field(..., description="Curso da disciplina")
+ name: str = Field(..., description="Nome da disciplina")
+ code: str = Field(..., description="Código da disciplina")
+ period: str = Field(..., description="Período da disciplina")
+ exam_weight: float = Field(..., alias="examWeight", description="Peso das provas")
+ assignment_weight: float = Field(..., alias="assignmentWeight", description="Peso dos trabalhos")
+ exams: list[ItemAvaliacao] = Field(..., description="Provas")
+ assignments: list[ItemAvaliacao] = Field(..., description="Trabalhos")
+ courses: dict[str, int] = Field(..., description="Cursos e anos")
diff --git a/src/shared/domain/enums/state_enum.py b/src/shared/domain/enums/state_enum.py
deleted file mode 100644
index 2c9e7b6..0000000
--- a/src/shared/domain/enums/state_enum.py
+++ /dev/null
@@ -1,7 +0,0 @@
-from enum import Enum
-
-
-class STATE(Enum):
- APPROVED = "APPROVED"
- PENDING = "PENDING"
- REJECTED = "REJECTED"
diff --git a/src/shared/domain/repositories/curso_repository_interface.py b/src/shared/domain/repositories/curso_repository_interface.py
new file mode 100644
index 0000000..c70f053
--- /dev/null
+++ b/src/shared/domain/repositories/curso_repository_interface.py
@@ -0,0 +1,43 @@
+from abc import ABC, abstractmethod
+from typing import List, Optional
+
+from src.shared.domain.entities.curso import Curso
+
+
+class ICursoRepository(ABC):
+
+ @abstractmethod
+ def create_curso(self, curso: Curso) -> Optional[Curso]:
+ """
+ Persiste o curso e retorna a entidade salva.
+ """
+ pass
+
+ @abstractmethod
+ def get_curso(self, código: str) -> Optional[Curso]:
+ """
+ Retorna o curso pelo código, ou None se não existir.
+ """
+ pass
+
+ @abstractmethod
+ def update_curso(self, curso: Curso) -> Optional[Curso]:
+ """
+ Substitui integralmente o curso identificado por `curso.código` (PUT).
+ Retorna a entidade atualizada, ou None se não existir registro com esse código.
+ """
+ pass
+
+ @abstractmethod
+ def delete_curso(self, código: str) -> Optional[Curso]:
+ """
+ Remove o curso pelo código e retorna a entidade removida, ou None.
+ """
+ pass
+
+ @abstractmethod
+ def get_all_cursos(self) -> List[Curso]:
+ """
+ Retorna todos os cursos persistidos.
+ """
+ pass
diff --git a/src/shared/domain/repositories/disciplina_repository_interface.py b/src/shared/domain/repositories/disciplina_repository_interface.py
new file mode 100644
index 0000000..dea243f
--- /dev/null
+++ b/src/shared/domain/repositories/disciplina_repository_interface.py
@@ -0,0 +1,43 @@
+from abc import ABC, abstractmethod
+from typing import List, Optional
+
+from src.shared.domain.entities.disciplina import Disciplina
+
+
+class IDisciplinaRepository(ABC):
+
+ @abstractmethod
+ def create_disciplina(self, disciplina: Disciplina) -> Optional[Disciplina]:
+ """
+ Persiste a disciplina e retorna a entidade salva.
+ """
+ pass
+
+ @abstractmethod
+ def get_disciplina(self, code: str) -> Optional[Disciplina]:
+ """
+ Retorna a disciplina pelo código, ou None se não existir.
+ """
+ pass
+
+ @abstractmethod
+ def update_disciplina(self, disciplina: Disciplina) -> Optional[Disciplina]:
+ """
+ Substitui integralmente a disciplina identificada por `disciplina.code` (PUT).
+ Retorna a entidade atualizada, ou None se não existir registro com esse código.
+ """
+ pass
+
+ @abstractmethod
+ def delete_disciplina(self, code: str) -> Optional[Disciplina]:
+ """
+ Remove a disciplina pelo código e retorna a entidade removida, ou None.
+ """
+ pass
+
+ @abstractmethod
+ def get_all_disciplinas(self) -> List[Disciplina]:
+ """
+ Retorna todas as disciplinas persistidas.
+ """
+ pass
diff --git a/src/shared/environments.py b/src/shared/environments.py
index a5a9092..44085bd 100644
--- a/src/shared/environments.py
+++ b/src/shared/environments.py
@@ -2,6 +2,8 @@
from enum import Enum
import os
+from src.shared.infra.external.dynamo.academic_catalog.academic_catalog_naming import physical_table_name
+
class STAGE(Enum):
DOTENV = "DOTENV"
@@ -36,7 +38,7 @@ def load_envs(self):
if self.stage == STAGE.TEST:
self.region = "sa-east-1"
- self.endpoint_url = "http://localhost:8000"
+ self.endpoint_url = os.environ.get("ENDPOINT_URL") or "http://localhost:8000"
self.cloud_front_distribution_domain = "https://d3q9q9q9q9q9q9.cloudfront.net"
else:
@@ -44,6 +46,16 @@ def load_envs(self):
self.endpoint_url = os.environ.get("ENDPOINT_URL")
self.cloud_front_distribution_domain = os.environ.get("CLOUD_FRONT_DISTRIBUTION_DOMAIN")
+ self.academic_catalog_table_name = (
+ os.environ.get("ACADEMIC_CATALOG_TABLE_NAME")
+ or os.environ.get("ENTITY_TABLE_NAME")
+ or os.environ.get("DISCIPLINA_TABLE_NAME")
+ or os.environ.get("CURSO_TABLE_NAME")
+ or physical_table_name(self.stage.value)
+ )
+ self.disciplina_table_name = self.academic_catalog_table_name
+ self.curso_table_name = self.academic_catalog_table_name
+
# @staticmethod
# def get_product_repo() -> IProductRepository:
# if Environments.get_envs().stage == STAGE.TEST:
@@ -55,6 +67,32 @@ def load_envs(self):
# else:
# raise Exception("No repository found for this stage")
+ @staticmethod
+ def get_disciplina_repo():
+ stage = os.environ.get("STAGE")
+ running_in_ci = os.environ.get("GITHUB_ACTIONS", "").strip().lower() == "true"
+ if stage == STAGE.TEST.value or running_in_ci:
+ from src.shared.infra.repositories.disciplina_repository_mock import DisciplinaRepositoryMock
+
+ return DisciplinaRepositoryMock()
+
+ from src.shared.infra.repositories.disciplina_repository_dynamo import DisciplinaRepositoryDynamo
+
+ return DisciplinaRepositoryDynamo()
+
+ @staticmethod
+ def get_curso_repo():
+ stage = os.environ.get("STAGE")
+ running_in_ci = os.environ.get("GITHUB_ACTIONS", "").strip().lower() == "true"
+ if stage == STAGE.TEST.value or running_in_ci:
+ from src.shared.infra.repositories.curso_repository_mock import CursoRepositoryMock
+
+ return CursoRepositoryMock()
+
+ from src.shared.infra.repositories.curso_repository_dynamo import CursoRepositoryDynamo
+
+ return CursoRepositoryDynamo()
+
@staticmethod
def get_envs() -> "Environments":
"""
diff --git a/src/shared/genetic_algorithm_solver.py b/src/shared/genetic_algorithm_solver.py
new file mode 100644
index 0000000..0b8a6db
--- /dev/null
+++ b/src/shared/genetic_algorithm_solver.py
@@ -0,0 +1,336 @@
+from src.shared.domain.entities.boletim_ga import Boletim_GA
+import random
+import numpy as np
+from typing import Optional
+class GradeGeneticAlgorithm:
+
+ def __init__(
+ self,
+ boletim: Boletim_GA,
+ target_average: float,
+ max_grade: float = 10.0,
+ population_size: int = 150,
+ generations: int = 200,
+ final_avg: float = 0.0
+ ) -> None:
+
+ # Desempacota atributos do boletim
+ current_tests = boletim.current_tests
+ current_assignments = boletim.current_assignments
+ num_remaining_tests = boletim.num_remaining_tests
+ num_remaining_assignments = boletim.num_remaining_assignments
+ test_weight = boletim.test_weight
+ assignment_weight = boletim.assignment_weight
+ spec_test_weight = boletim.spec_test_weight
+ spec_assignment_weight = boletim.spec_assignment_weight
+
+ # Agora atribui aos self
+ self.current_tests: list[float] = current_tests
+ self.current_assignments: list[float] = current_assignments
+ self.num_remaining_tests: int = num_remaining_tests
+ self.num_remaining_assignments: int = num_remaining_assignments
+ self.test_weight: float = test_weight
+ self.assignment_weight: float = assignment_weight
+ self.spec_test_weight: Optional[list[float]] = spec_test_weight
+ self.spec_assignment_weight: Optional[list[float]] = spec_assignment_weight
+ self.target_avg: float = target_average
+ self.max_grade: float = max_grade
+ self.pop_size: int = population_size
+ self.generations: int = generations
+
+ def create_individual(self):
+ """Cria um indivíduo (notas futuras de testes e trabalhos)"""
+ tests = [random.uniform(0, self.max_grade) for _ in range(self.num_remaining_tests)]
+ assignments = [random.uniform(0, self.max_grade) for _ in range(self.num_remaining_assignments)]
+ return {'tests': tests, 'assignments': assignments}
+
+ def calculate_weighted_average(self, tests, assignments, spec_test_weight=None, spec_assignment_weight=None):
+ """
+ Calcula média ponderada com suporte a pesos específicos opcionais.
+
+ Lógica:
+ 1. Se spec_test_weight fornecido: média ponderada das provas
+ 2. Senão: média simples das provas
+ 3. Se spec_assignment_weight fornecido: média ponderada dos trabalhos
+ 4. Senão: média simples dos trabalhos
+ 5. Combina médias com test_weight e assignment_weight
+ """
+ if not tests and not assignments:
+ return 0
+
+ # ===== CALCULA MÉDIA DAS PROVAS =====
+ if tests:
+ if spec_test_weight is not None:
+ # Média ponderada (NÃO modifica lista original)
+ tests_weighted = [tests[i] * spec_test_weight[i] for i in range(len(tests))]
+ test_avg = sum(tests_weighted) / sum(spec_test_weight)
+ else:
+ # Média simples
+ test_avg = sum(tests) / len(tests)
+ else:
+ test_avg = 0
+
+ # ===== CALCULA MÉDIA DOS TRABALHOS =====
+ if assignments:
+ if spec_assignment_weight is not None:
+ # Média ponderada (NÃO modifica lista original)
+ assignments_weighted = [assignments[i] * spec_assignment_weight[i] for i in range(len(assignments))]
+ assignment_avg = sum(assignments_weighted) / sum(spec_assignment_weight)
+ else:
+ # Média simples
+ assignment_avg = sum(assignments) / len(assignments)
+ else:
+ assignment_avg = 0
+
+ # ===== VERIFICA CASOS ESPECIAIS =====
+ total_tests = len(self.current_tests) + self.num_remaining_tests
+ total_assignments = len(self.current_assignments) + self.num_remaining_assignments
+
+ # Só tem trabalhos
+ if total_tests == 0:
+ return assignment_avg
+
+ # Só tem provas
+ if total_assignments == 0:
+ return test_avg
+
+
+ # ===== MÉDIA PONDERADA ENTRE PROVAS E TRABALHOS =====
+ return (test_avg * self.test_weight) + (assignment_avg * self.assignment_weight)
+
+ def fitness(self, individual):
+ """
+ Função fitness que minimiza:
+ 1. Diferença da média alvo
+ 2. Variância entre as notas (para mantê-las similares)
+ """
+ all_tests = self.current_tests + individual['tests']
+ all_assignments = self.current_assignments + individual['assignments']
+
+ # IMPORTANTE: Passa os 4 parâmetros
+ avg = self.calculate_weighted_average(
+ all_tests,
+ all_assignments,
+ self.spec_test_weight,
+ self.spec_assignment_weight
+ )
+
+ # Penalidade por não atingir a média
+ avg_diff = abs(avg - self.target_avg)
+
+ # Penalidade por variância (queremos notas equilibradas)
+ future_grades = individual['tests'] + individual['assignments']
+ variance_penalty = np.std(future_grades) if len(future_grades) > 1 else 0
+
+ # Penalidade por notas impossíveis
+ impossible_penalty = sum(max(0, g - self.max_grade) for g in future_grades)
+
+ return avg_diff * 10 + variance_penalty * 2 + impossible_penalty * 20
+
+ def selection(self, population, fitnesses):
+ """Seleção por torneio"""
+ tournament_size = 3
+ tournament = random.sample(list(zip(population, fitnesses)), tournament_size)
+ tournament.sort(key=lambda x: x[1])
+ return tournament[0][0], tournament[1][0]
+
+ def crossover(self, parent1, parent2):
+ """Crossover separado para testes e trabalhos"""
+ if random.random() < 0.8:
+ child1 = {'tests': [], 'assignments': []}
+ child2 = {'tests': [], 'assignments': []}
+
+ # Crossover testes
+ if self.num_remaining_tests > 0:
+ point = random.randint(0, len(parent1['tests']))
+ child1['tests'] = parent1['tests'][:point] + parent2['tests'][point:]
+ child2['tests'] = parent2['tests'][:point] + parent1['tests'][point:]
+
+ # Crossover trabalhos
+ if self.num_remaining_assignments > 0:
+ point = random.randint(0, len(parent1['assignments']))
+ child1['assignments'] = parent1['assignments'][:point] + parent2['assignments'][point:]
+ child2['assignments'] = parent2['assignments'][:point] + parent1['assignments'][point:]
+
+ return child1, child2
+
+ return {
+ 'tests': parent1['tests'].copy(),
+ 'assignments': parent1['assignments'].copy()
+ }, {
+ 'tests': parent2['tests'].copy(),
+ 'assignments': parent2['assignments'].copy()
+ }
+
+ def mutate(self, individual):
+ """Mutação gaussiana"""
+ mutated = {
+ 'tests': individual['tests'].copy(),
+ 'assignments': individual['assignments'].copy()
+ }
+
+ for i in range(len(mutated['tests'])):
+ if random.random() < 0.2:
+ mutated['tests'][i] += random.gauss(0, 0.5)
+ mutated['tests'][i] = max(0, min(self.max_grade, mutated['tests'][i]))
+
+ for i in range(len(mutated['assignments'])):
+ if random.random() < 0.2:
+ mutated['assignments'][i] += random.gauss(0, 0.5)
+ mutated['assignments'][i] = max(0, min(self.max_grade, mutated['assignments'][i]))
+
+ return mutated
+
+ def run(self):
+ """Executa o algoritmo genético. Retorna a melhor solução encontrada e seu respectivo fitness."""
+ population = [self.create_individual() for _ in range(self.pop_size)]
+
+ best_ever = None
+ best_fitness_ever = float('inf')
+
+ for gen in range(self.generations):
+ fitnesses = [self.fitness(ind) for ind in population]
+
+ min_idx = fitnesses.index(min(fitnesses))
+ if fitnesses[min_idx] < best_fitness_ever:
+ best_fitness_ever = fitnesses[min_idx]
+ best_ever = {
+ 'tests': population[min_idx]['tests'].copy(),
+ 'assignments': population[min_idx]['assignments'].copy()
+ }
+
+ new_population = []
+
+ # Elitismo
+ sorted_pop = sorted(zip(population, fitnesses), key=lambda x: x[1])
+ new_population.extend([
+ {'tests': ind['tests'].copy(), 'assignments': ind['assignments'].copy()}
+ for ind, _ in sorted_pop[:2]
+ ])
+
+ while len(new_population) < self.pop_size:
+ p1, p2 = self.selection(population, fitnesses)
+ c1, c2 = self.crossover(p1, p2)
+ c1 = self.mutate(c1)
+ c2 = self.mutate(c2)
+ new_population.extend([c1, c2])
+
+ population = new_population[:self.pop_size]
+
+ if gen % 100 == 0:
+ print(f"Geração {gen}: Melhor fitness = {best_fitness_ever:.4f}")
+
+ final_avg = self.calculate_weighted_average(
+ self.current_tests + best_ever['tests'],
+ self.current_assignments + best_ever['assignments'],
+ self.spec_test_weight,
+ self.spec_assignment_weight
+ )
+
+
+ return best_ever, best_fitness_ever, final_avg
+
+ def display_results(self, solution):
+ """Exibe os resultados"""
+ all_tests = self.current_tests + solution['tests']
+ all_assignments = self.current_assignments + solution['assignments']
+
+ # IMPORTANTE: Passa os 4 parâmetros
+ current_avg = self.calculate_weighted_average(
+ self.current_tests,
+ self.current_assignments,
+ self.spec_test_weight,
+ self.spec_assignment_weight
+ )
+
+ final_avg = self.calculate_weighted_average(
+ all_tests,
+ all_assignments,
+ self.spec_test_weight,
+ self.spec_assignment_weight
+ )
+
+ print("\n" + "="*60)
+ print("RESULTADOS")
+ print("="*60)
+ print(f"Pesos: Provas {self.test_weight*100:.0f}% | Trabalhos {self.assignment_weight*100:.0f}%")
+
+ if self.spec_test_weight is not None:
+ print(f"Pesos específicos de provas: {[f'{w*100:.0f}%' for w in self.spec_test_weight]}")
+ if self.spec_assignment_weight is not None:
+ print(f"Pesos específicos de trabalhos: {[f'{w*100:.0f}%' for w in self.spec_assignment_weight]}")
+
+ print(f"\nProvas atuais: {[f'{g:.2f}' for g in self.current_tests]}")
+ print(f"Trabalhos atuais: {[f'{g:.2f}' for g in self.current_assignments]}")
+
+ if solution['tests']:
+ print(f"\nProvas necessárias:")
+ for i, grade in enumerate(solution['tests'], 1):
+ print(f" Prova {i}: {grade:.2f}")
+
+ if solution['assignments']:
+ print(f"\nTrabalhos necessários:")
+ for i, grade in enumerate(solution['assignments'], 1):
+ print(f" Trabalho {i}: {grade:.2f}")
+
+ print(f"\nMédia atual: {current_avg:.2f}")
+ print(f"Média alvo: {self.target_avg:.2f}")
+ print(f"Média final prevista: {final_avg:.2f}")
+
+ future_grades = solution['tests'] + solution['assignments']
+ if len(future_grades) > 1:
+ print(f"Desvio padrão das notas futuras: {np.std(future_grades):.2f}")
+ print("="*60)
+
+ return final_avg
+
+
+
+ def get_results_json(self,solution):
+ all_tests = self.current_tests + solution['tests']
+ all_assignments = self.current_assignments + solution['assignments']
+
+ provas = []
+ for i, grade in enumerate(all_tests):
+ prova = {
+ "nota": round(grade, 2),
+ "peso": round(self.spec_test_weight[i], 2) if self.spec_test_weight else None
+ }
+ provas.append(prova)
+
+
+ trabalhos = []
+ for i, grade in enumerate(all_assignments):
+ trabalho = {
+ "nota": round(grade, 2),
+ "peso": round(self.spec_assignment_weight[i], 2) if self.spec_assignment_weight else None
+ }
+ trabalhos.append(trabalho)
+
+ final_avg = self.calculate_weighted_average(
+ all_tests,
+ all_assignments,
+ self.spec_test_weight,
+ self.spec_assignment_weight
+ )
+
+ diff = abs(final_avg - self.target_avg)
+
+ if diff <= 0.05:
+ message = "O algoritmo retornou uma combinação válida de notas"
+ elif diff <=0.2:
+ message = f"O algoritmo retornou uma solução próxima (diferença: {diff:.2f})"
+ else:
+ message = f"O algoritmo não conseguiu encontrar uma solução próxima (diferença: {diff:.2f})"
+
+ response = {
+ "notas":{
+ "peso provas": round(self.test_weight,2),
+ "provas": provas,
+ "peso trabalhos": round(self.assignment_weight,2),
+ "trabalhos": trabalhos
+ },
+ "final_average": round(final_avg,2),
+ "message": message
+ }
+ return response
\ No newline at end of file
diff --git a/src/shared/helpers/external_interfaces/http_lambda_requests.py b/src/shared/helpers/external_interfaces/http_lambda_requests.py
index 7f66512..806881e 100644
--- a/src/shared/helpers/external_interfaces/http_lambda_requests.py
+++ b/src/shared/helpers/external_interfaces/http_lambda_requests.py
@@ -11,7 +11,6 @@ class LambdaHttpResponse(HttpResponse):
status_code: int = 200
body: any = {"message": "No response"}
headers: dict = {"Content-Type": "application/json"}
-
def __init__(self, body: any = None, status_code: int = None, headers: dict = None, **kwargs) -> None:
"""
Constructor for HttpResponse.
@@ -45,7 +44,7 @@ def toDict(self) -> dict:
"""
return {
"statusCode": self.status_code,
- "body": json.dumps(self.body),
+ "body": json.dumps(self.body, ensure_ascii=False),
"headers": self.headers,
"isBase64Encoded": False
}
diff --git a/src/shared/infra/external/__init__.py b/src/shared/infra/external/__init__.py
new file mode 100644
index 0000000..e69de29
diff --git a/src/shared/infra/external/dynamo/__init__.py b/src/shared/infra/external/dynamo/__init__.py
new file mode 100644
index 0000000..e69de29
diff --git a/src/shared/infra/external/dynamo/academic_catalog/__init__.py b/src/shared/infra/external/dynamo/academic_catalog/__init__.py
new file mode 100644
index 0000000..e69de29
diff --git a/src/shared/infra/external/dynamo/academic_catalog/academic_catalog_naming.py b/src/shared/infra/external/dynamo/academic_catalog/academic_catalog_naming.py
new file mode 100644
index 0000000..a0e8cd9
--- /dev/null
+++ b/src/shared/infra/external/dynamo/academic_catalog/academic_catalog_naming.py
@@ -0,0 +1,15 @@
+"""
+Nome físico da tabela single-table do catálogo acadêmico.
+
+Deve bater com `iac/components/dynamo_construct.py` (CDK). Se mudar o prefixo, atualize os dois.
+"""
+
+ACADEMIC_CATALOG_TABLE_PREFIX = "DevMediasAcademicCatalogTable"
+
+
+def physical_table_name(stage: str) -> str:
+ """
+ Mesmo padrão do CDK: ``{PREFIX}-{stage.lower()}`` (ex.: DevMediasAcademicCatalogTable-dev).
+ """
+ s = (stage or "test").strip().lower()
+ return f"{ACADEMIC_CATALOG_TABLE_PREFIX}-{s}"
diff --git a/src/shared/infra/external/dynamo/academic_catalog/academic_catalog_table_setup.py b/src/shared/infra/external/dynamo/academic_catalog/academic_catalog_table_setup.py
new file mode 100644
index 0000000..8e08adc
--- /dev/null
+++ b/src/shared/infra/external/dynamo/academic_catalog/academic_catalog_table_setup.py
@@ -0,0 +1,60 @@
+"""
+Cria a tabela single-table do catálogo acadêmico (pk + sk), se ainda não existir.
+
+Usado pelo DynamoDB local (Docker) e pode ser importado pelos loaders em `iac/local/docker/dynamo/`.
+
+Requer `Environments` configurado (ex.: `STAGE=TEST`, `ENDPOINT_URL`, opcionalmente `ACADEMIC_CATALOG_TABLE_NAME`).
+"""
+
+from __future__ import annotations
+
+import os
+
+import boto3
+
+from src.shared.environments import Environments
+
+
+def ensure_academic_catalog_table() -> str:
+ """
+ Garante que a tabela em `Environments.academic_catalog_table_name` exista
+ (partition key `pk`, sort key `sk`).
+ Retorna o nome da tabela.
+ """
+ envs = Environments.get_envs()
+ table_name = envs.academic_catalog_table_name
+ endpoint = envs.endpoint_url
+ if not endpoint:
+ raise RuntimeError("endpoint_url não configurado (ex.: ENDPOINT_URL=http://localhost:8000).")
+
+ print(f"DynamoDB: tabela '{table_name}' em '{endpoint}' (região {envs.region})")
+
+ client = boto3.client(
+ "dynamodb",
+ endpoint_url=endpoint,
+ region_name=envs.region,
+ aws_access_key_id=os.environ.get("AWS_ACCESS_KEY_ID", "local"),
+ aws_secret_access_key=os.environ.get("AWS_SECRET_ACCESS_KEY", "local"),
+ )
+
+ existing = client.list_tables().get("TableNames", [])
+ if table_name in existing:
+ print(f"Tabela '{table_name}' já existe.")
+ return table_name
+
+ print(f"Criando tabela '{table_name}'...")
+ client.create_table(
+ TableName=table_name,
+ BillingMode="PAY_PER_REQUEST",
+ KeySchema=[
+ {"AttributeName": "pk", "KeyType": "HASH"},
+ {"AttributeName": "sk", "KeyType": "RANGE"},
+ ],
+ AttributeDefinitions=[
+ {"AttributeName": "pk", "AttributeType": "S"},
+ {"AttributeName": "sk", "AttributeType": "S"},
+ ],
+ )
+ client.get_waiter("table_exists").wait(TableName=table_name)
+ print(f"Tabela '{table_name}' criada.")
+ return table_name
diff --git a/src/shared/infra/external/dynamo/academic_catalog/single_table_keys.py b/src/shared/infra/external/dynamo/academic_catalog/single_table_keys.py
new file mode 100644
index 0000000..5b2dfbb
--- /dev/null
+++ b/src/shared/infra/external/dynamo/academic_catalog/single_table_keys.py
@@ -0,0 +1,30 @@
+"""Chaves single-table: PK = {owner}#{tipo}#{codigo_negocio}, SK fixa METADATA (registro canônico)."""
+
+from enum import Enum
+from typing import Any, Optional
+
+GLOBAL_OWNER = "GLOBAL"
+# SK fixa: um item por entidade; reserva o prefixo da SK para linhas filhas no futuro (ex.: NOTA#..., LOG#...).
+SK_ENTITY_RECORD = "METADATA"
+
+
+class EntityKind(str, Enum):
+ CURSO = "CURSO"
+ DISCIPLINA = "DISCIPLINA"
+
+
+def normalize_owner_id(user_id: Optional[str]) -> str:
+ if user_id is None or not str(user_id).strip():
+ return GLOBAL_OWNER
+ # '#' separa segmentos na PK; remove da id do usuário para não quebrar o formato.
+ return str(user_id).strip().replace("#", "_")
+
+
+def build_partition_key(owner: str, kind: EntityKind, business_code: str) -> str:
+ code = str(business_code).strip()
+ return f"{owner}#{kind.value}#{code}"
+
+
+def strip_dynamo_metadata(item: dict[str, Any]) -> dict[str, Any]:
+ out = {k: v for k, v in item.items() if k not in ("pk", "sk", "entity_type")}
+ return out
diff --git a/src/shared/infra/external/dynamo/dynamo_datasource.py b/src/shared/infra/external/dynamo/dynamo_datasource.py
new file mode 100644
index 0000000..66f9913
--- /dev/null
+++ b/src/shared/infra/external/dynamo/dynamo_datasource.py
@@ -0,0 +1,225 @@
+import json
+from decimal import Decimal
+
+import boto3
+
+
+class DynamoDatasource:
+ """
+ Docs:
+ - https://boto3.amazonaws.com/v1/documentation/api/latest/reference/services/dynamodb.html#DynamoDB.Table
+ """
+ dynamo_table: boto3.resource
+ partition_key: str
+ sort_key: str
+ RESERVED_WORDS = ["ABORT", "ABSOLUTE", "ACTION", "ADD", "AFTER", "AGENT", "AGGREGATE", "ALL", "ALLOCATE", "ALTER", "ANALYZE", "AND", "ANY", "ARCHIVE", "ARE", "ARRAY", "AS", "ASC", "ASCII", "ASENSITIVE", "ASSERTION", "ASYMMETRIC", "AT", "ATOMIC", "ATTACH", "ATTRIBUTE", "AUTH", "AUTHORIZATION", "AUTHORIZE", "AUTO", "AVG", "BACK", "BACKUP", "BASE", "BATCH", "BEFORE", "BEGIN", "BETWEEN", "BIGINT", "BINARY", "BIT", "BLOB", "BLOCK", "BOOLEAN", "BOTH", "BREADTH", "BUCKET", "BULK", "BY", "BYTE", "CALL", "CALLED", "CALLING", "CAPACITY", "CASCADE", "CASCADED", "CASE", "CAST", "CATALOG", "CHAR", "CHARACTER", "CHECK", "CLASS", "CLOB", "CLOSE", "CLUSTER", "CLUSTERED", "CLUSTERING", "CLUSTERS", "COALESCE", "COLLATE", "COLLATION", "COLLECTION", "COLUMN", "COLUMNS", "COMBINE", "COMMENT", "COMMIT", "COMPACT", "COMPILE", "COMPRESS", "CONDITION", "CONFLICT", "CONNECT", "CONNECTION", "CONSISTENCY", "CONSISTENT", "CONSTRAINT", "CONSTRAINTS", "CONSTRUCTOR", "CONSUMED", "CONTINUE", "CONVERT", "COPY", "CORRESPONDING", "COUNT", "COUNTER", "CREATE", "CROSS", "CUBE", "CURRENT", "CURSOR", "CYCLE", "DATA", "DATABASE", "DATE", "DATETIME", "DAY", "DEALLOCATE", "DEC", "DECIMAL", "DECLARE", "DEFAULT", "DEFERRABLE", "DEFERRED", "DEFINE", "DEFINED", "DEFINITION", "DELETE", "DELIMITED", "DEPTH", "DEREF", "DESC", "DESCRIBE", "DESCRIPTOR", "DETACH", "DETERMINISTIC", "DIAGNOSTICS", "DIRECTORIES", "DISABLE", "DISCONNECT", "DISTINCT", "DISTRIBUTE", "DO", "DOMAIN", "DOUBLE", "DROP", "DUMP", "DURATION", "DYNAMIC", "EACH", "ELEMENT", "ELSE", "ELSEIF", "EMPTY", "ENABLE", "END", "EQUAL", "EQUALS", "ERROR", "ESCAPE", "ESCAPED", "EVAL", "EVALUATE", "EXCEEDED", "EXCEPT", "EXCEPTION", "EXCEPTIONS", "EXCLUSIVE", "EXEC", "EXECUTE", "EXISTS", "EXIT", "EXPLAIN", "EXPLODE", "EXPORT", "EXPRESSION", "EXTENDED", "EXTERNAL", "EXTRACT", "FAIL", "FALSE", "FAMILY", "FETCH", "FIELDS", "FILE", "FILTER", "FILTERING", "FINAL", "FINISH", "FIRST", "FIXED", "FLATTERN", "FLOAT", "FOR", "FORCE", "FOREIGN", "FORMAT", "FORWARD", "FOUND", "FREE", "FROM", "FULL", "FUNCTION", "FUNCTIONS", "GENERAL", "GENERATE", "GET", "GLOB", "GLOBAL", "GO", "GOTO", "GRANT", "GREATER", "GROUP", "GROUPING", "HANDLER", "HASH", "HAVE", "HAVING", "HEAP", "HIDDEN", "HOLD", "HOUR", "IDENTIFIED", "IDENTITY", "IF", "IGNORE", "IMMEDIATE", "IMPORT", "IN", "INCLUDING", "INCLUSIVE", "INCREMENT", "INCREMENTAL", "INDEX", "INDEXED", "INDEXES", "INDICATOR", "INFINITE", "INITIALLY", "INLINE", "INNER", "INNTER", "INOUT", "INPUT", "INSENSITIVE", "INSERT", "INSTEAD", "INT", "INTEGER", "INTERSECT", "INTERVAL", "INTO", "INVALIDATE", "IS", "ISOLATION", "ITEM", "ITEMS", "ITERATE", "JOIN", "KEY", "KEYS", "LAG", "LANGUAGE", "LARGE", "LAST", "LATERAL", "LEAD", "LEADING", "LEAVE", "LEFT", "LENGTH", "LESS", "LEVEL", "LIKE", "LIMIT", "LIMITED", "LINES", "LIST", "LOAD", "LOCAL", "LOCALTIME", "LOCALTIMESTAMP", "LOCATION", "LOCATOR", "LOCK", "LOCKS", "LOG", "LOGED", "LONG", "LOOP", "LOWER", "MAP", "MATCH", "MATERIALIZED", "MAX", "MAXLEN", "MEMBER", "MERGE", "METHOD", "METRICS", "MIN", "MINUS", "MINUTE", "MISSING", "MOD", "MODE", "MODIFIES", "MODIFY", "MODULE", "MONTH", "MULTI", "MULTISET", "NAME", "NAMES", "NATIONAL", "NATURAL", "NCHAR", "NCLOB", "NEW", "NEXT", "NO", "NONE", "NOT", "NULL", "NULLIF", "NUMBER", "NUMERIC", "OBJECT", "OF", "OFFLINE", "OFFSET", "OLD", "ON", "ONLINE", "ONLY", "OPAQUE", "OPEN", "OPERATOR", "OPTION", "OR", "ORDER", "ORDINALITY", "OTHER", "OTHERS", "OUT", "OUTER", "OUTPUT", "OVER", "OVERLAPS", "OVERRIDE", "OWNER", "PAD", "PARALLEL", "PARAMETER", "PARAMETERS", "PARTIAL", "PARTITION", "PARTITIONED", "PARTITIONS", "PATH", "PERCENT", "PERCENTILE", "PERMISSION", "PERMISSIONS", "PIPE", "PIPELINED", "PLAN", "POOL", "POSITION", "PRECISION", "PREPARE", "PRESERVE", "PRIMARY", "PRIOR", "PRIVATE", "PRIVILEGES", "PROCEDURE", "PROCESSED", "PROJECT", "PROJECTION", "PROPERTY", "PROVISIONING", "PUBLIC", "PUT", "QUERY", "QUIT", "QUORUM", "RAISE", "RANDOM", "RANGE", "RANK", "RAW", "READ", "READS", "REAL", "REBUILD", "RECORD", "RECURSIVE", "REDUCE", "REF", "REFERENCE", "REFERENCES", "REFERENCING", "REGEXP", "REGION", "REINDEX", "RELATIVE", "RELEASE", "REMAINDER", "RENAME", "REPEAT", "REPLACE", "REQUEST", "RESET", "RESIGNAL", "RESOURCE", "RESPONSE", "RESTORE", "RESTRICT", "RESULT", "RETURN", "RETURNING", "RETURNS", "REVERSE", "REVOKE", "RIGHT", "ROLE", "ROLES", "ROLLBACK", "ROLLUP", "ROUTINE", "ROW", "ROWS", "RULE", "RULES", "SAMPLE", "SATISFIES", "SAVE", "SAVEPOINT", "SCAN", "SCHEMA", "SCOPE", "SCROLL", "SEARCH", "SECOND", "SECTION", "SEGMENT", "SEGMENTS", "SELECT", "SELF", "SEMI", "SENSITIVE", "SEPARATE", "SEQUENCE", "SERIALIZABLE", "SESSION", "SET", "SETS", "SHARD", "SHARE", "SHARED", "SHORT", "SHOW", "SIGNAL", "SIMILAR", "SIZE", "SKEWED", "SMALLINT", "SNAPSHOT", "SOME", "SOURCE", "SPACE", "SPACES", "SPARSE", "SPECIFIC", "SPECIFICTYPE", "SPLIT", "SQL", "SQLCODE", "SQLERROR", "SQLEXCEPTION", "SQLSTATE", "SQLWARNING", "START", "STATE", "STATIC", "STATUS", "STORAGE", "STORE", "STORED", "STREAM", "STRING", "STRUCT", "STYLE", "SUB", "SUBMULTISET", "SUBPARTITION", "SUBSTRING", "SUBTYPE", "SUM", "SUPER", "SYMMETRIC", "SYNONYM", "SYSTEM", "TABLE", "TABLESAMPLE", "TEMP", "TEMPORARY", "TERMINATED", "TEXT", "THAN", "THEN", "THROUGHPUT", "TIME", "TIMESTAMP", "TIMEZONE", "TINYINT", "TO", "TOKEN", "TOTAL", "TOUCH", "TRAILING", "TRANSACTION", "TRANSFORM", "TRANSLATE", "TRANSLATION", "TREAT", "TRIGGER", "TRIM", "TRUE", "TRUNCATE", "TTL", "TUPLE", "TYPE", "UNDER", "UNDO", "UNION", "UNIQUE", "UNIT", "UNKNOWN", "UNLOGGED", "UNNEST", "UNPROCESSED", "UNSIGNED", "UNTIL", "UPDATE", "UPPER", "URL", "USAGE", "USE", "USER", "USERS", "USING", "UUID", "VACUUM", "VALUE", "VALUED", "VALUES", "VARCHAR", "VARIABLE", "VARIANCE", "VARINT", "VARYING", "VIEW", "VIEWS", "VIRTUAL", "VOID", "WAIT", "WHEN", "WHENEVER", "WHERE", "WHILE", "WINDOW", "WITH", "WITHIN", "WITHOUT", "WORK", "WRAPPED", "WRITE", "YEAR", "ZONE"]
+ gsi_partition_key: str
+ gsi_sort_key: str
+
+ def __init__(
+
+ self,
+ dynamo_table_name: str,
+ partition_key: str,
+ region: str,
+ endpoint_url: str = None,
+ sort_key: str = None
+
+ ) -> None:
+
+ s = boto3.Session(region_name=region)
+ dynamo = s.resource('dynamodb', endpoint_url=endpoint_url)
+ self.dynamo_table = dynamo.Table(dynamo_table_name)
+ self.partition_key = partition_key
+ self.sort_key = sort_key
+
+ @staticmethod
+ def _parse_float_to_decimal(item):
+ """
+ Parse float to Decimal
+ @param item: dict with the keys (Partition and Sort) and data to insert
+ """
+ item_parsed = json.loads(json.dumps(item), parse_float=Decimal)
+ return item_parsed
+
+ def put_item(self, item: dict, partition_key: str, sort_key: str = None, **kwargs):
+ """
+ Insert a new item into the table or hard update an existing one.
+ Ref: https://boto3.amazonaws.com/v1/documentation/api/latest/reference/services/dynamodb.html#DynamoDB.Table.put_item
+ @param item: dict with the keys (Partition and Sort) and data to insert
+ @param partition_key: string with the partition key
+ @param sort_key: string with the sort key (optional)
+ @return: dict with the response from DynamoDB
+ """
+
+ item = DynamoDatasource._parse_float_to_decimal(item) if not kwargs.get("is_decimal", False) else item
+
+ item[self.partition_key] = partition_key
+ if sort_key:
+ item[self.sort_key] = sort_key
+
+ return self.dynamo_table.put_item(Item=item)
+
+ def get_item(self, partition_key: str, sort_key: str = None):
+ """
+ Get an item from the table from its keys (Partition and Sort).
+ @param partition_key: string with the partition key
+ @param sort_key: string with the sort key (optional)
+ @return: dict with the response from DynamoDB
+ """
+
+ if sort_key is None and self.sort_key is not None:
+ raise Exception("Table uses composite key (Partition and Sort). Sort key must be provided.")
+
+ resp = self.dynamo_table.get_item(
+ Key={
+ self.partition_key: partition_key,
+ } if self.sort_key is None else {
+ self.partition_key: partition_key,
+ self.sort_key: sort_key
+ }
+ )
+ return resp
+
+ def hard_update_item(self, partition_key: str, sort_key: str, item: dict):
+ """
+ Hard update an item in the table (must have its keys - Partition and Sort).
+ @param partition_key: string with the partition key
+ @param sort_key: string with the sort key (optional)
+ @param item: dict with data to insert
+ @return: dict with the response from DynamoDB
+ """
+
+ item[self.partition_key] = partition_key
+
+ if sort_key:
+ item[self.sort_key] = sort_key
+
+ resp = self.dynamo_table.put_item(Item=DynamoDatasource._parse_float_to_decimal(item))
+ return resp
+
+ def update_item(self, partition_key: str, update_dict: dict, sort_key: str = None):
+ """
+ Update an item in the table with its keys (Partition and Sort) and attributes to update
+ If the attribute does not exist, it will be created. It won't change attributes not mentioned.
+ @param key: dict with the keys (Partition and Sort)
+ @param update_attributes: dict with the attributes to update
+ @return: dict with the response from DynamoDB
+ """
+
+ if sort_key is None and self.sort_key is not None:
+ raise Exception("Table uses composite key (Partition and Sort). Sort key must be provided.")
+
+ data_key_value_pairs = list(update_dict.items())
+
+ update_expression = "SET " + ", ".join([f"#attr{i} = :val{i}" for i in range(len(data_key_value_pairs))]) # SET attribute1=:value1, attribute2=:value2
+ expression_attribute_names = {f"#attr{i}": data_key_value_pairs[i][0] for i in range(len(data_key_value_pairs))} # {"_attribute1": "attribute1", ":_attribute2": "attribute2"}
+ expression_value_names = {f":val{i}": data_key_value_pairs[i][1] for i in range(len(data_key_value_pairs))} # {":value1": "value1", ":value2": "value2"}
+
+ resp = self.dynamo_table.update_item(
+ Key={
+ self.partition_key: partition_key,
+ } if self.sort_key is None else {
+ self.partition_key: partition_key,
+ self.sort_key: sort_key
+ },
+ UpdateExpression=update_expression,
+ ExpressionAttributeNames=expression_attribute_names,
+ ExpressionAttributeValues=expression_value_names,
+ ReturnValues="ALL_NEW"
+ )
+ return resp
+
+ def delete_item(self, partition_key: str, sort_key: str = None):
+ """
+ Delete an item from the table from its keys (Partition and Sort).
+ @param partition_key: string with the partition key
+ @param sort_key: string with the sort key (optional)
+ @return: dict with the response from DynamoDB
+ """
+
+ if sort_key is None and self.sort_key is not None:
+ raise Exception("Table uses composite key (Partition and Sort). Sort key must be provided.")
+
+ resp = self.dynamo_table.delete_item(
+ Key={
+ self.partition_key: partition_key
+ } if self.sort_key is None else {
+ self.partition_key: partition_key,
+ self.sort_key: sort_key
+ },
+ ReturnValues='ALL_OLD'
+ )
+ return resp
+
+ def get_all_items(self):
+ """
+ Get all items from the table.
+ @return: dict with the response from DynamoDB
+ """
+
+ resp = self.dynamo_table.scan(Select='ALL_ATTRIBUTES')
+
+ items = resp['Items']
+
+ while 'LastEvaluatedKey' in resp:
+ response = self.dynamo_table.scan(ExclusiveStartKey=resp['LastEvaluatedKey'])
+ items.extend(response['Items'])
+
+ resp = response
+
+ resp['Items'] = items
+ resp['Count'] = len(items)
+ resp['ScannedCount'] = len(items)
+
+ return resp
+
+ def scan_items(self, filter_expression, **kwargs):
+ """
+ Scan items from the table.
+ @return: dict with the response from DynamoDB
+ """
+
+ resp = self.dynamo_table.scan(
+ FilterExpression=filter_expression,
+ **kwargs
+ )
+ return resp
+
+ def query(self, KeyConditionExpression, **kwargs):
+ """
+ Query the table with the KeyConditionExpression.
+ Example: KeyConditionExpression=Key('Partition').eq('partition') & Key('Sort').gte('sort')
+ Obs: Key de boto3.dynamodb.conditions.Key
+ Ref:https://boto3.amazonaws.com/v1/documentation/api/latest/reference/customizations/dynamodb.html#ref-dynamodb-conditions
+ @param key_condition_expression: string with the KeyConditionExpression
+ @return: dict with the response from DynamoDB
+ """
+
+ resp = self.dynamo_table.query(
+ KeyConditionExpression=KeyConditionExpression,
+
+ **kwargs
+ )
+ return resp
+
+ def batch_write_items(self, items):
+ """
+ Write a list of items to the table. Each item must have the keys (Partition and Sort).
+ @param items: list of dicts with the keys (Partition and Sort) and data to insert
+ """
+
+ with self.dynamo_table.batch_writer() as batch:
+ for i in items:
+ batch.put_item(Item=DynamoDatasource._parse_float_to_decimal(i))
+
+ def batch_delete_items(self, keys):
+ """
+ Delete a list of items from the table. Each item must have only the keys (Partition and Sort).
+ @param keys: list of dicts with the keys (Partition and Sort)
+ Example: keys=[ {'Partition': 'partition1', 'Sort': 'sort2'}, {'Partition': 'partition1', 'Sort': 'sort2'} ]
+ """
+
+ with self.dynamo_table.batch_writer() as batch:
+ for k in keys:
+ batch.delete_item(Key=k)
\ No newline at end of file
diff --git a/src/shared/infra/external/dynamo/dynamo_scan_utils.py b/src/shared/infra/external/dynamo/dynamo_scan_utils.py
new file mode 100644
index 0000000..e2ed716
--- /dev/null
+++ b/src/shared/infra/external/dynamo/dynamo_scan_utils.py
@@ -0,0 +1,12 @@
+from typing import Any, List
+
+
+def scan_all_pages(table: Any, **scan_kwargs: Any) -> List[dict]:
+ kwargs = dict(scan_kwargs)
+ resp = table.scan(**kwargs)
+ items: List[dict] = list(resp.get("Items", []))
+ while "LastEvaluatedKey" in resp:
+ kwargs["ExclusiveStartKey"] = resp["LastEvaluatedKey"]
+ resp = table.scan(**kwargs)
+ items.extend(resp.get("Items", []))
+ return items
diff --git a/src/shared/infra/external/dynamo/notice_table/notice_naming.py b/src/shared/infra/external/dynamo/notice_table/notice_naming.py
new file mode 100644
index 0000000..a0e8cd9
--- /dev/null
+++ b/src/shared/infra/external/dynamo/notice_table/notice_naming.py
@@ -0,0 +1,15 @@
+"""
+Nome físico da tabela single-table do catálogo acadêmico.
+
+Deve bater com `iac/components/dynamo_construct.py` (CDK). Se mudar o prefixo, atualize os dois.
+"""
+
+ACADEMIC_CATALOG_TABLE_PREFIX = "DevMediasAcademicCatalogTable"
+
+
+def physical_table_name(stage: str) -> str:
+ """
+ Mesmo padrão do CDK: ``{PREFIX}-{stage.lower()}`` (ex.: DevMediasAcademicCatalogTable-dev).
+ """
+ s = (stage or "test").strip().lower()
+ return f"{ACADEMIC_CATALOG_TABLE_PREFIX}-{s}"
diff --git a/src/shared/infra/external/dynamo/user_table/user_naming.py b/src/shared/infra/external/dynamo/user_table/user_naming.py
new file mode 100644
index 0000000..a0e8cd9
--- /dev/null
+++ b/src/shared/infra/external/dynamo/user_table/user_naming.py
@@ -0,0 +1,15 @@
+"""
+Nome físico da tabela single-table do catálogo acadêmico.
+
+Deve bater com `iac/components/dynamo_construct.py` (CDK). Se mudar o prefixo, atualize os dois.
+"""
+
+ACADEMIC_CATALOG_TABLE_PREFIX = "DevMediasAcademicCatalogTable"
+
+
+def physical_table_name(stage: str) -> str:
+ """
+ Mesmo padrão do CDK: ``{PREFIX}-{stage.lower()}`` (ex.: DevMediasAcademicCatalogTable-dev).
+ """
+ s = (stage or "test").strip().lower()
+ return f"{ACADEMIC_CATALOG_TABLE_PREFIX}-{s}"
diff --git a/src/shared/infra/repositories/curso_repository_dynamo.py b/src/shared/infra/repositories/curso_repository_dynamo.py
new file mode 100644
index 0000000..8cbd3d9
--- /dev/null
+++ b/src/shared/infra/repositories/curso_repository_dynamo.py
@@ -0,0 +1,122 @@
+import json
+from decimal import Decimal
+from typing import List, Optional
+
+from boto3.dynamodb.conditions import Attr
+
+from src.shared.domain.entities.curso import Curso
+from src.shared.domain.repositories.curso_repository_interface import ICursoRepository
+from src.shared.environments import Environments
+from src.shared.infra.external.dynamo.dynamo_datasource import DynamoDatasource
+from src.shared.infra.external.dynamo.dynamo_scan_utils import scan_all_pages
+from src.shared.infra.external.dynamo.academic_catalog.single_table_keys import (
+ EntityKind,
+ SK_ENTITY_RECORD,
+ build_partition_key,
+ normalize_owner_id,
+ strip_dynamo_metadata,
+)
+
+
+def _dynamo_to_plain(obj):
+ if isinstance(obj, Decimal):
+ if obj == obj.to_integral_value():
+ return int(obj)
+ return float(obj)
+ if isinstance(obj, dict):
+ return {k: _dynamo_to_plain(v) for k, v in obj.items()}
+ if isinstance(obj, list):
+ return [_dynamo_to_plain(v) for v in obj]
+ return obj
+
+
+class CursoRepositoryDynamo(ICursoRepository):
+ """
+ Single-table: pk = {owner}#CURSO#{código}, sk = METADATA.
+ owner = GLOBAL (público / não logado) ou id do usuário (cursos próprios).
+ """
+
+ PARTITION_ATTR = "pk"
+ SORT_ATTR = "sk"
+
+ def __init__(self, user_id: Optional[str] = None) -> None:
+ self._owner = normalize_owner_id(user_id)
+ envs = Environments.get_envs()
+ self.dynamo = DynamoDatasource(
+ endpoint_url=envs.endpoint_url,
+ dynamo_table_name=envs.academic_catalog_table_name,
+ region=envs.region,
+ partition_key=self.PARTITION_ATTR,
+ sort_key=self.SORT_ATTR,
+ )
+
+ def _pk(self, código: str) -> str:
+ return build_partition_key(self._owner, EntityKind.CURSO, código)
+
+ def _item_to_curso(self, item: dict) -> Curso:
+ return Curso.model_validate(_dynamo_to_plain(strip_dynamo_metadata(item)))
+
+ def _curso_to_stored_item(self, curso: Curso) -> dict:
+ body = json.loads(curso.model_dump_json())
+ pk = self._pk(curso.código)
+ return {
+ **body,
+ "pk": pk,
+ "sk": SK_ENTITY_RECORD,
+ "entity_type": EntityKind.CURSO.value,
+ }
+
+ def create_curso(self, curso: Curso) -> Optional[Curso]:
+ item = self._curso_to_stored_item(curso)
+ self.dynamo.put_item(
+ item=item,
+ partition_key=item["pk"],
+ sort_key=SK_ENTITY_RECORD,
+ )
+ return curso
+
+ def get_curso(self, código: str) -> Optional[Curso]:
+ resp = self.dynamo.get_item(
+ partition_key=self._pk(código),
+ sort_key=SK_ENTITY_RECORD,
+ )
+ raw = resp.get("Item")
+ if not raw:
+ return None
+ return self._item_to_curso(raw)
+
+ def update_curso(self, curso: Curso) -> Optional[Curso]:
+ existing = self.dynamo.get_item(
+ partition_key=self._pk(curso.código),
+ sort_key=SK_ENTITY_RECORD,
+ )
+ if not existing.get("Item"):
+ return None
+ item = self._curso_to_stored_item(curso)
+ self.dynamo.put_item(
+ item=item,
+ partition_key=item["pk"],
+ sort_key=SK_ENTITY_RECORD,
+ )
+ return curso
+
+ def delete_curso(self, código: str) -> Optional[Curso]:
+ existing = self.dynamo.get_item(
+ partition_key=self._pk(código),
+ sort_key=SK_ENTITY_RECORD,
+ )
+ raw = existing.get("Item")
+ if not raw:
+ return None
+ removed = self._item_to_curso(raw)
+ self.dynamo.delete_item(
+ partition_key=self._pk(código),
+ sort_key=SK_ENTITY_RECORD,
+ )
+ return removed
+
+ def get_all_cursos(self) -> List[Curso]:
+ prefix = f"{self._owner}#{EntityKind.CURSO.value}#"
+ fe = Attr("entity_type").eq(EntityKind.CURSO.value) & Attr("pk").begins_with(prefix)
+ items = scan_all_pages(self.dynamo.dynamo_table, FilterExpression=fe)
+ return [self._item_to_curso(i) for i in items]
diff --git a/src/shared/infra/repositories/curso_repository_mock.py b/src/shared/infra/repositories/curso_repository_mock.py
new file mode 100644
index 0000000..c69ab2f
--- /dev/null
+++ b/src/shared/infra/repositories/curso_repository_mock.py
@@ -0,0 +1,37 @@
+from typing import List, Optional
+
+from src.shared.domain.entities.curso import Curso
+from src.shared.domain.repositories.curso_repository_interface import ICursoRepository
+
+
+class CursoRepositoryMock(ICursoRepository):
+
+ def __init__(self):
+ self.cursos = [
+ Curso(código="ECM", nome="Engenharia de Computação"),
+ Curso(código="ADM", nome="Administração"),
+ Curso(código="CIC", nome="Ciência da Computação"),
+ ]
+
+ def create_curso(self, curso: Curso) -> Optional[Curso]:
+ self.cursos.append(curso)
+ return curso
+
+ def get_curso(self, código: str) -> Optional[Curso]:
+ return next((c for c in self.cursos if c.código == código), None)
+
+ def update_curso(self, curso: Curso) -> Optional[Curso]:
+ for i, c in enumerate(self.cursos):
+ if c.código == curso.código:
+ self.cursos[i] = curso
+ return curso
+ return None
+
+ def delete_curso(self, código: str) -> Optional[Curso]:
+ for i, c in enumerate(self.cursos):
+ if c.código == código:
+ return self.cursos.pop(i)
+ return None
+
+ def get_all_cursos(self) -> List[Curso]:
+ return list(self.cursos)
diff --git a/src/shared/infra/repositories/disciplina_repository_dynamo.py b/src/shared/infra/repositories/disciplina_repository_dynamo.py
new file mode 100644
index 0000000..006ad8b
--- /dev/null
+++ b/src/shared/infra/repositories/disciplina_repository_dynamo.py
@@ -0,0 +1,122 @@
+import json
+from decimal import Decimal
+from typing import List, Optional
+
+from boto3.dynamodb.conditions import Attr
+
+from src.shared.domain.entities.disciplina import Disciplina
+from src.shared.domain.repositories.disciplina_repository_interface import IDisciplinaRepository
+from src.shared.environments import Environments
+from src.shared.infra.external.dynamo.dynamo_datasource import DynamoDatasource
+from src.shared.infra.external.dynamo.dynamo_scan_utils import scan_all_pages
+from src.shared.infra.external.dynamo.academic_catalog.single_table_keys import (
+ EntityKind,
+ SK_ENTITY_RECORD,
+ build_partition_key,
+ normalize_owner_id,
+ strip_dynamo_metadata,
+)
+
+
+def _dynamo_to_plain(obj):
+ if isinstance(obj, Decimal):
+ if obj == obj.to_integral_value():
+ return int(obj)
+ return float(obj)
+ if isinstance(obj, dict):
+ return {k: _dynamo_to_plain(v) for k, v in obj.items()}
+ if isinstance(obj, list):
+ return [_dynamo_to_plain(v) for v in obj]
+ return obj
+
+
+class DisciplinaRepositoryDynamo(IDisciplinaRepository):
+ """
+ Single-table: pk = {owner}#DISCIPLINA#{code}, sk = METADATA.
+ owner = GLOBAL (catálogo padrão) ou id do usuário (disciplinas próprias).
+ """
+
+ PARTITION_ATTR = "pk"
+ SORT_ATTR = "sk"
+
+ def __init__(self, user_id: Optional[str] = None) -> None:
+ self._owner = normalize_owner_id(user_id)
+ envs = Environments.get_envs()
+ self.dynamo = DynamoDatasource(
+ endpoint_url=envs.endpoint_url,
+ dynamo_table_name=envs.academic_catalog_table_name,
+ region=envs.region,
+ partition_key=self.PARTITION_ATTR,
+ sort_key=self.SORT_ATTR,
+ )
+
+ def _pk(self, code: str) -> str:
+ return build_partition_key(self._owner, EntityKind.DISCIPLINA, code)
+
+ def _item_to_disciplina(self, item: dict) -> Disciplina:
+ return Disciplina.model_validate(_dynamo_to_plain(strip_dynamo_metadata(item)))
+
+ def _disciplina_to_stored_item(self, disciplina: Disciplina) -> dict:
+ body = json.loads(disciplina.model_dump_json())
+ pk = self._pk(disciplina.code)
+ return {
+ **body,
+ "pk": pk,
+ "sk": SK_ENTITY_RECORD,
+ "entity_type": EntityKind.DISCIPLINA.value,
+ }
+
+ def create_disciplina(self, disciplina: Disciplina) -> Optional[Disciplina]:
+ item = self._disciplina_to_stored_item(disciplina)
+ self.dynamo.put_item(
+ item=item,
+ partition_key=item["pk"],
+ sort_key=SK_ENTITY_RECORD,
+ )
+ return disciplina
+
+ def get_disciplina(self, code: str) -> Optional[Disciplina]:
+ resp = self.dynamo.get_item(
+ partition_key=self._pk(code),
+ sort_key=SK_ENTITY_RECORD,
+ )
+ raw = resp.get("Item")
+ if not raw:
+ return None
+ return self._item_to_disciplina(raw)
+
+ def update_disciplina(self, disciplina: Disciplina) -> Optional[Disciplina]:
+ existing = self.dynamo.get_item(
+ partition_key=self._pk(disciplina.code),
+ sort_key=SK_ENTITY_RECORD,
+ )
+ if not existing.get("Item"):
+ return None
+ item = self._disciplina_to_stored_item(disciplina)
+ self.dynamo.put_item(
+ item=item,
+ partition_key=item["pk"],
+ sort_key=SK_ENTITY_RECORD,
+ )
+ return disciplina
+
+ def delete_disciplina(self, code: str) -> Optional[Disciplina]:
+ existing = self.dynamo.get_item(
+ partition_key=self._pk(code),
+ sort_key=SK_ENTITY_RECORD,
+ )
+ raw = existing.get("Item")
+ if not raw:
+ return None
+ removed = self._item_to_disciplina(raw)
+ self.dynamo.delete_item(
+ partition_key=self._pk(code),
+ sort_key=SK_ENTITY_RECORD,
+ )
+ return removed
+
+ def get_all_disciplinas(self) -> List[Disciplina]:
+ prefix = f"{self._owner}#{EntityKind.DISCIPLINA.value}#"
+ fe = Attr("entity_type").eq(EntityKind.DISCIPLINA.value) & Attr("pk").begins_with(prefix)
+ items = scan_all_pages(self.dynamo.dynamo_table, FilterExpression=fe)
+ return [self._item_to_disciplina(i) for i in items]
diff --git a/src/shared/infra/repositories/disciplina_repository_mock.py b/src/shared/infra/repositories/disciplina_repository_mock.py
new file mode 100644
index 0000000..172b064
--- /dev/null
+++ b/src/shared/infra/repositories/disciplina_repository_mock.py
@@ -0,0 +1,79 @@
+from typing import List, Optional
+
+from src.shared.domain.entities.disciplina import Disciplina
+from src.shared.domain.entities.disciplina import ItemAvaliacao
+from src.shared.domain.repositories.disciplina_repository_interface import IDisciplinaRepository
+
+
+class DisciplinaRepositoryMock(IDisciplinaRepository):
+
+ def __init__(self):
+ self.disciplinas = [
+ Disciplina(
+ code="ECM101",
+ name="Engenharia de Computação",
+ course="ECM",
+ period="2024.1",
+ exam_weight=0.6,
+ assignment_weight=0.4,
+ exams=[ItemAvaliacao(name="P1", weight=0.6)],
+ assignments=[ItemAvaliacao(name="T1", weight=0.4)],
+ courses={"ECM": 4},
+ ),
+ Disciplina(
+ code="ECM102",
+ name="Engenharia de Software",
+ course="ECM",
+ period="2024.1",
+ exam_weight=0.6,
+ assignment_weight=0.4,
+ exams=[ItemAvaliacao(name="P1", weight=0.6)],
+ assignments=[ItemAvaliacao(name="T1", weight=0.4)],
+ courses={"ECM": 3},
+ ),
+ Disciplina(
+ code="ECM103",
+ name="Arquitetura de Computadores",
+ course="ECM",
+ period="2024.1",
+ exam_weight=0.6,
+ assignment_weight=0.4,
+ exams=[ItemAvaliacao(name="P1", weight=0.6)],
+ assignments=[ItemAvaliacao(name="T1", weight=0.4)],
+ courses={"ECM": 2},
+ ),
+ Disciplina(
+ code="ECM104",
+ name="Algoritmos e Estruturas de Dados",
+ course="ECM",
+ period="2024.1",
+ exam_weight=0.6,
+ assignment_weight=0.4,
+ exams=[ItemAvaliacao(name="P1", weight=0.6)],
+ assignments=[ItemAvaliacao(name="T1", weight=0.4)],
+ courses={"ECM": 1},
+ ),
+ ]
+
+ def create_disciplina(self, disciplina: Disciplina) -> Optional[Disciplina]:
+ self.disciplinas.append(disciplina)
+ return disciplina
+
+ def get_disciplina(self, code: str) -> Optional[Disciplina]:
+ return next((d for d in self.disciplinas if d.code == code), None)
+
+ def update_disciplina(self, disciplina: Disciplina) -> Optional[Disciplina]:
+ for i, d in enumerate(self.disciplinas):
+ if d.code == disciplina.code:
+ self.disciplinas[i] = disciplina
+ return disciplina
+ return None
+
+ def delete_disciplina(self, code: str) -> Optional[Disciplina]:
+ for i, d in enumerate(self.disciplinas):
+ if d.code == code:
+ return self.disciplinas.pop(i)
+ return None
+
+ def get_all_disciplinas(self) -> List[Disciplina]:
+ return list(self.disciplinas)
diff --git a/tests/modules/curso/create_curso/app/test_create_curso_controller.py b/tests/modules/curso/create_curso/app/test_create_curso_controller.py
new file mode 100644
index 0000000..6c91f98
--- /dev/null
+++ b/tests/modules/curso/create_curso/app/test_create_curso_controller.py
@@ -0,0 +1,79 @@
+from unittest.mock import MagicMock
+
+from src.modules.curso.create_curso.app.create_curso_controller import CreateCursoController
+from src.modules.curso.create_curso.app.create_curso_usecase import CreateCursoUsecase
+from src.shared.helpers.external_interfaces.http_models import HttpRequest
+from src.shared.infra.repositories.curso_repository_mock import CursoRepositoryMock
+
+
+class TestCreateCursoController:
+ def test_create_curso_controller_success(self):
+ request = HttpRequest(body={'código': 'MAT', 'nome': 'Matemática'})
+ usecase = CreateCursoUsecase(repository=CursoRepositoryMock())
+ controller = CreateCursoController(usecase=usecase)
+
+ response = controller(request=request)
+
+ assert response.status_code == 201
+ assert response.body['código'] == 'MAT'
+ assert response.body['nome'] == 'Matemática'
+
+ def test_create_curso_controller_missing_codigo(self):
+ request = HttpRequest(body={'nome': 'Matemática'})
+ usecase = CreateCursoUsecase(repository=CursoRepositoryMock())
+ controller = CreateCursoController(usecase=usecase)
+
+ response = controller(request=request)
+
+ assert response.status_code == 400
+ assert response.body == 'Parâmetro código não existe'
+
+ def test_create_curso_controller_wrong_codigo_type(self):
+ request = HttpRequest(body={'código': 123, 'nome': 'Matemática'})
+ usecase = CreateCursoUsecase(repository=CursoRepositoryMock())
+ controller = CreateCursoController(usecase=usecase)
+
+ response = controller(request=request)
+
+ assert response.status_code == 400
+ assert response.body == 'Parâmetro código não possui tipo correto.\n Recebido: int.\n Esperado: str'
+
+ def test_create_curso_controller_missing_nome(self):
+ request = HttpRequest(body={'código': 'MAT'})
+ usecase = CreateCursoUsecase(repository=CursoRepositoryMock())
+ controller = CreateCursoController(usecase=usecase)
+
+ response = controller(request=request)
+
+ assert response.status_code == 400
+ assert response.body == 'Parâmetro nome não existe'
+
+ def test_create_curso_controller_wrong_nome_type(self):
+ request = HttpRequest(body={'código': 'MAT', 'nome': 123})
+ usecase = CreateCursoUsecase(repository=CursoRepositoryMock())
+ controller = CreateCursoController(usecase=usecase)
+
+ response = controller(request=request)
+
+ assert response.status_code == 400
+ assert response.body == 'Parâmetro nome não possui tipo correto.\n Recebido: int.\n Esperado: str'
+
+ def test_create_curso_controller_conflict(self):
+ request = HttpRequest(body={'código': 'ECM', 'nome': 'Engenharia de Computação'})
+ usecase = CreateCursoUsecase(repository=CursoRepositoryMock())
+ controller = CreateCursoController(usecase=usecase)
+
+ response = controller(request=request)
+
+ assert response.status_code == 409
+ assert 'The item alredy exists for this código' in str(response.body)
+
+ def test_create_curso_controller_internal_server_error(self):
+ request = HttpRequest(body={'código': 'MAT', 'nome': 'Matemática'})
+ usecase = MagicMock(side_effect=Exception('unexpected failure'))
+ controller = CreateCursoController(usecase=usecase)
+
+ response = controller(request=request)
+
+ assert response.status_code == 500
+ assert str(response.body) == 'unexpected failure'
diff --git a/tests/modules/curso/create_curso/app/test_create_curso_presenter.py b/tests/modules/curso/create_curso/app/test_create_curso_presenter.py
new file mode 100644
index 0000000..3c00b88
--- /dev/null
+++ b/tests/modules/curso/create_curso/app/test_create_curso_presenter.py
@@ -0,0 +1,39 @@
+import json
+import os
+
+
+class TestCreateCursoPresenter:
+ def test_lambda_handler_success(self):
+ previous_stage = os.environ.get('STAGE')
+ os.environ['STAGE'] = 'TEST'
+ from src.modules.curso.create_curso.app.create_curso_presenter import lambda_handler
+
+ event = {
+ 'version': '2.0',
+ 'routeKey': '$default',
+ 'rawPath': '/cursos',
+ 'rawQueryString': '',
+ 'headers': {},
+ 'queryStringParameters': None,
+ 'requestContext': {},
+ 'body': {
+ 'código': 'MAT',
+ 'nome': 'Matemática',
+ },
+ 'pathParameters': None,
+ 'isBase64Encoded': False,
+ 'stageVariables': None,
+ }
+
+ try:
+ response = lambda_handler(event=event, context=None)
+ finally:
+ if previous_stage is None:
+ os.environ.pop('STAGE', None)
+ else:
+ os.environ['STAGE'] = previous_stage
+
+ assert response['statusCode'] == 201
+ body = json.loads(response['body'])
+ assert body['código'] == 'MAT'
+ assert body['nome'] == 'Matemática'
diff --git a/tests/modules/curso/create_curso/app/test_create_curso_usecase.py b/tests/modules/curso/create_curso/app/test_create_curso_usecase.py
new file mode 100644
index 0000000..6273829
--- /dev/null
+++ b/tests/modules/curso/create_curso/app/test_create_curso_usecase.py
@@ -0,0 +1,24 @@
+import pytest
+
+from src.modules.curso.create_curso.app.create_curso_usecase import CreateCursoUsecase
+from src.shared.helpers.errors.usecase_errors import DuplicatedItem
+from src.shared.infra.repositories.curso_repository_mock import CursoRepositoryMock
+
+
+class TestCreateCursoUsecase:
+ def test_create_curso_usecase_success(self):
+ repository = CursoRepositoryMock()
+ usecase = CreateCursoUsecase(repository)
+
+ response = usecase(código='MAT', nome='Matemática')
+
+ assert response.código == 'MAT'
+ assert response.nome == 'Matemática'
+ assert len(repository.cursos) == 4
+
+ def test_create_curso_usecase_duplicated_item(self):
+ repository = CursoRepositoryMock()
+ usecase = CreateCursoUsecase(repository)
+
+ with pytest.raises(DuplicatedItem):
+ usecase(código='ECM', nome='Engenharia de Computação')
diff --git a/tests/modules/curso/create_curso/app/test_create_curso_viewmodel.py b/tests/modules/curso/create_curso/app/test_create_curso_viewmodel.py
new file mode 100644
index 0000000..bfb3727
--- /dev/null
+++ b/tests/modules/curso/create_curso/app/test_create_curso_viewmodel.py
@@ -0,0 +1,12 @@
+from src.modules.curso.create_curso.app.create_curso_viewmodel import CreateCursoViewmodel
+from src.shared.domain.entities.curso import Curso
+
+
+class TestCreateCursoViewmodel:
+ def test_to_dict_contains_expected_fields(self):
+ curso = Curso(código='MAT', nome='Matemática')
+
+ response = CreateCursoViewmodel(curso).to_dict()
+
+ assert response['código'] == 'MAT'
+ assert response['nome'] == 'Matemática'
diff --git a/tests/modules/curso/get_all_cursos/app/test_get_all_cursos_controller.py b/tests/modules/curso/get_all_cursos/app/test_get_all_cursos_controller.py
new file mode 100644
index 0000000..9f322dd
--- /dev/null
+++ b/tests/modules/curso/get_all_cursos/app/test_get_all_cursos_controller.py
@@ -0,0 +1,41 @@
+from unittest.mock import MagicMock
+
+from src.modules.curso.get_all_cursos.app.get_all_cursos_controller import GetAllCursosController
+from src.modules.curso.get_all_cursos.app.get_all_cursos_usecase import GetAllCursosUsecase
+from src.shared.helpers.external_interfaces.http_models import HttpRequest
+from src.shared.infra.repositories.curso_repository_mock import CursoRepositoryMock
+
+
+class TestGetAllCursosController:
+ def test_get_all_cursos_controller_success(self):
+ request = HttpRequest()
+ usecase = GetAllCursosUsecase(repository=CursoRepositoryMock())
+ controller = GetAllCursosController(usecase=usecase)
+
+ response = controller(request=request)
+
+ assert response.status_code == 200
+ assert isinstance(response.body, list)
+ assert response.body[0]['código'] == 'ECM'
+
+ def test_get_all_cursos_controller_not_found(self):
+ request = HttpRequest()
+ repository = CursoRepositoryMock()
+ repository.cursos = []
+ usecase = GetAllCursosUsecase(repository=repository)
+ controller = GetAllCursosController(usecase=usecase)
+
+ response = controller(request=request)
+
+ assert response.status_code == 404
+ assert 'No items found for cursos' in str(response.body)
+
+ def test_get_all_cursos_controller_internal_server_error(self):
+ request = HttpRequest()
+ usecase = MagicMock(side_effect=Exception('unexpected failure'))
+ controller = GetAllCursosController(usecase=usecase)
+
+ response = controller(request=request)
+
+ assert response.status_code == 500
+ assert str(response.body) == 'unexpected failure'
diff --git a/tests/modules/curso/get_all_cursos/app/test_get_all_cursos_presenter.py b/tests/modules/curso/get_all_cursos/app/test_get_all_cursos_presenter.py
new file mode 100644
index 0000000..a323a74
--- /dev/null
+++ b/tests/modules/curso/get_all_cursos/app/test_get_all_cursos_presenter.py
@@ -0,0 +1,37 @@
+import json
+import os
+
+
+class TestGetAllCursosPresenter:
+ def test_lambda_handler_success(self):
+ previous_stage = os.environ.get('STAGE')
+ os.environ['STAGE'] = 'TEST'
+ from src.modules.curso.get_all_cursos.app.get_all_cursos_presenter import lambda_handler
+
+ event = {
+ 'version': '2.0',
+ 'routeKey': '$default',
+ 'rawPath': '/cursos',
+ 'rawQueryString': '',
+ 'headers': {},
+ 'queryStringParameters': None,
+ 'requestContext': {},
+ 'body': {},
+ 'pathParameters': None,
+ 'isBase64Encoded': False,
+ 'stageVariables': None,
+ }
+
+ try:
+ response = lambda_handler(event=event, context=None)
+ finally:
+ if previous_stage is None:
+ os.environ.pop('STAGE', None)
+ else:
+ os.environ['STAGE'] = previous_stage
+
+ assert response['statusCode'] == 200
+ body = json.loads(response['body'])
+ assert isinstance(body, list)
+ assert len(body) == 3
+ assert body[0]['código'] == 'ECM'
diff --git a/tests/modules/curso/get_all_cursos/app/test_get_all_cursos_usecase.py b/tests/modules/curso/get_all_cursos/app/test_get_all_cursos_usecase.py
new file mode 100644
index 0000000..b43d1cb
--- /dev/null
+++ b/tests/modules/curso/get_all_cursos/app/test_get_all_cursos_usecase.py
@@ -0,0 +1,24 @@
+import pytest
+
+from src.modules.curso.get_all_cursos.app.get_all_cursos_usecase import GetAllCursosUsecase
+from src.shared.helpers.errors.usecase_errors import NoItemsFound
+from src.shared.infra.repositories.curso_repository_mock import CursoRepositoryMock
+
+
+class TestGetAllCursosUsecase:
+ def test_get_all_cursos_usecase_success(self):
+ repository = CursoRepositoryMock()
+ usecase = GetAllCursosUsecase(repository)
+
+ response = usecase()
+
+ assert len(response) == 3
+ assert response[0].código == 'ECM'
+
+ def test_get_all_cursos_usecase_empty_list(self):
+ repository = CursoRepositoryMock()
+ repository.cursos = []
+ usecase = GetAllCursosUsecase(repository)
+
+ with pytest.raises(NoItemsFound):
+ usecase()
diff --git a/tests/modules/curso/get_all_cursos/app/test_get_all_cursos_viewmodel.py b/tests/modules/curso/get_all_cursos/app/test_get_all_cursos_viewmodel.py
new file mode 100644
index 0000000..422028b
--- /dev/null
+++ b/tests/modules/curso/get_all_cursos/app/test_get_all_cursos_viewmodel.py
@@ -0,0 +1,20 @@
+from src.modules.curso.get_all_cursos.app.get_all_cursos_viewmodel import GetAllCursosViewmodel
+from src.shared.infra.repositories.curso_repository_mock import CursoRepositoryMock
+
+
+class TestGetAllCursosViewmodel:
+ def test_to_dict_returns_list(self):
+ cursos = CursoRepositoryMock().get_all_cursos()
+
+ response = GetAllCursosViewmodel(cursos).to_dict()
+
+ assert isinstance(response, list)
+ assert len(response) == 3
+
+ def test_to_dict_contains_expected_fields(self):
+ cursos = CursoRepositoryMock().get_all_cursos()
+
+ response = GetAllCursosViewmodel(cursos).to_dict()
+
+ assert response[0]['código'] == 'ECM'
+ assert response[0]['nome'] == 'Engenharia de Computação'
diff --git a/tests/modules/disciplina/get_all_disciplinas/app/test_get_all_disciplinas_controller.py b/tests/modules/disciplina/get_all_disciplinas/app/test_get_all_disciplinas_controller.py
new file mode 100644
index 0000000..04c6ccb
--- /dev/null
+++ b/tests/modules/disciplina/get_all_disciplinas/app/test_get_all_disciplinas_controller.py
@@ -0,0 +1,41 @@
+from unittest.mock import MagicMock
+
+from src.modules.disciplina.get_all_disciplinas.app.get_all_disciplinas_controller import GetAllDisciplinasController
+from src.modules.disciplina.get_all_disciplinas.app.get_all_disciplinas_usecase import GetAllDisciplinasUsecase
+from src.shared.helpers.external_interfaces.http_models import HttpRequest
+from src.shared.infra.repositories.disciplina_repository_mock import DisciplinaRepositoryMock
+
+
+class TestGetAllDisciplinasController:
+ def test_get_all_disciplinas_controller_success(self):
+ request = HttpRequest()
+ usecase = GetAllDisciplinasUsecase(repository=DisciplinaRepositoryMock())
+ controller = GetAllDisciplinasController(usecase=usecase)
+
+ response = controller(request=request)
+
+ assert response.status_code == 200
+ assert isinstance(response.body, list)
+ assert response.body[0]["code"] == "ECM101"
+
+ def test_get_all_disciplinas_controller_not_found(self):
+ request = HttpRequest()
+ repository = DisciplinaRepositoryMock()
+ repository.disciplinas = []
+ usecase = GetAllDisciplinasUsecase(repository=repository)
+ controller = GetAllDisciplinasController(usecase=usecase)
+
+ response = controller(request=request)
+
+ assert response.status_code == 404
+ assert "No items found for disciplinas" in str(response.body)
+
+ def test_get_all_disciplinas_controller_internal_server_error(self):
+ request = HttpRequest()
+ usecase = MagicMock(side_effect=Exception("unexpected failure"))
+ controller = GetAllDisciplinasController(usecase=usecase)
+
+ response = controller(request=request)
+
+ assert response.status_code == 500
+ assert str(response.body) == "unexpected failure"
diff --git a/tests/modules/disciplina/get_all_disciplinas/app/test_get_all_disciplinas_presenter.py b/tests/modules/disciplina/get_all_disciplinas/app/test_get_all_disciplinas_presenter.py
new file mode 100644
index 0000000..993dde1
--- /dev/null
+++ b/tests/modules/disciplina/get_all_disciplinas/app/test_get_all_disciplinas_presenter.py
@@ -0,0 +1,37 @@
+import json
+import os
+
+from src.modules.disciplina.get_all_disciplinas.app.get_all_disciplinas_presenter import lambda_handler
+
+
+class TestGetAllDisciplinasPresenter:
+ def test_lambda_handler_success(self):
+ previous_stage = os.environ.get("STAGE")
+ os.environ["STAGE"] = "TEST"
+ event = {
+ "version": "2.0",
+ "routeKey": "$default",
+ "rawPath": "/disciplinas",
+ "rawQueryString": "",
+ "headers": {},
+ "queryStringParameters": None,
+ "requestContext": {},
+ "body": {},
+ "pathParameters": None,
+ "isBase64Encoded": False,
+ "stageVariables": None,
+ }
+
+ try:
+ response = lambda_handler(event=event, context=None)
+ finally:
+ if previous_stage is None:
+ os.environ.pop("STAGE", None)
+ else:
+ os.environ["STAGE"] = previous_stage
+
+ assert response["statusCode"] == 200
+ body = json.loads(response["body"])
+ assert isinstance(body, list)
+ assert len(body) == 4
+ assert body[0]["code"] == "ECM101"
diff --git a/tests/modules/disciplina/get_all_disciplinas/app/test_get_all_disciplinas_usecase.py b/tests/modules/disciplina/get_all_disciplinas/app/test_get_all_disciplinas_usecase.py
new file mode 100644
index 0000000..d5cf98b
--- /dev/null
+++ b/tests/modules/disciplina/get_all_disciplinas/app/test_get_all_disciplinas_usecase.py
@@ -0,0 +1,24 @@
+import pytest
+
+from src.modules.disciplina.get_all_disciplinas.app.get_all_disciplinas_usecase import GetAllDisciplinasUsecase
+from src.shared.helpers.errors.usecase_errors import NoItemsFound
+from src.shared.infra.repositories.disciplina_repository_mock import DisciplinaRepositoryMock
+
+
+class TestGetAllDisciplinasUsecase:
+ def test_get_all_disciplinas_usecase_success(self):
+ repository = DisciplinaRepositoryMock()
+ usecase = GetAllDisciplinasUsecase(repository)
+
+ response = usecase()
+
+ assert len(response) == 4
+ assert response[0].code == "ECM101"
+
+ def test_get_all_disciplinas_usecase_empty_list(self):
+ repository = DisciplinaRepositoryMock()
+ repository.disciplinas = []
+ usecase = GetAllDisciplinasUsecase(repository)
+
+ with pytest.raises(NoItemsFound):
+ usecase()
diff --git a/tests/modules/disciplina/get_all_disciplinas/app/test_get_all_disciplinas_viewmodel.py b/tests/modules/disciplina/get_all_disciplinas/app/test_get_all_disciplinas_viewmodel.py
new file mode 100644
index 0000000..4ce6a8b
--- /dev/null
+++ b/tests/modules/disciplina/get_all_disciplinas/app/test_get_all_disciplinas_viewmodel.py
@@ -0,0 +1,22 @@
+from src.modules.disciplina.get_all_disciplinas.app.get_all_disciplinas_viewmodel import GetAllDisciplinasViewmodel
+from src.shared.infra.repositories.disciplina_repository_mock import DisciplinaRepositoryMock
+
+
+class TestGetAllDisciplinasViewmodel:
+ def test_to_dict_returns_list(self):
+ disciplinas = DisciplinaRepositoryMock().get_all_disciplinas()
+
+ response = GetAllDisciplinasViewmodel(disciplinas).to_dict()
+
+ assert isinstance(response, list)
+ assert len(response) == 4
+
+ def test_to_dict_contains_expected_fields(self):
+ disciplinas = DisciplinaRepositoryMock().get_all_disciplinas()
+
+ response = GetAllDisciplinasViewmodel(disciplinas).to_dict()
+
+ assert response[0]["code"] == "ECM101"
+ assert response[0]["name"] == "Engenharia de Computação"
+ assert "exam_weight" in response[0]
+ assert "assignment_weight" in response[0]
diff --git a/tests/modules/genetic_algorithm/__init__.py b/tests/modules/genetic_algorithm/__init__.py
new file mode 100644
index 0000000..e69de29
diff --git a/tests/modules/genetic_algorithm/app/__init__.py b/tests/modules/genetic_algorithm/app/__init__.py
new file mode 100644
index 0000000..e69de29
diff --git a/tests/modules/genetic_algorithm/app/test_genetic_algorithm_controller.py b/tests/modules/genetic_algorithm/app/test_genetic_algorithm_controller.py
new file mode 100644
index 0000000..3703ef1
--- /dev/null
+++ b/tests/modules/genetic_algorithm/app/test_genetic_algorithm_controller.py
@@ -0,0 +1,293 @@
+import pytest
+from unittest.mock import MagicMock
+from src.modules.genetic_algorithm.app.genetic_algorithm_controller import GeneticAlgorithmController
+from src.modules.genetic_algorithm.app.genetic_algorithm_usecase import GeneticAlgorithmUsecase
+from src.shared.helpers.external_interfaces.http_models import HttpRequest
+
+class TestGeneticAlgorithmController:
+
+ # ==========================================
+ # TESTES DE SUCESSO (STATUS 200)
+ # ==========================================
+
+ def test_genetic_algorithm_controller_only_tests(self):
+ request = HttpRequest(body={
+ 'provas_que_tenho': [{'valor': 5.0, 'peso': 0.5}],
+ 'trabalhos_que_tenho': [],
+ 'provas_que_quero': [{'peso': 0.5}],
+ 'trabalhos_que_quero': [],
+ 'peso_prova': 1.0,
+ 'peso_trabalho': 0.0,
+ 'media_desejada': 7.0
+ })
+
+ usecase = GeneticAlgorithmUsecase()
+ controller = GeneticAlgorithmController(usecase=usecase)
+
+ response = controller(request=request)
+
+ assert response.status_code == 200
+
+ def test_genetic_algorithm_controller_only_assignments(self):
+ request = HttpRequest(body={
+ 'provas_que_tenho': [],
+ 'trabalhos_que_tenho': [{'valor': 8.0, 'peso': 0.2}, {'valor': 9.0, 'peso': 0.2}],
+ 'provas_que_quero': [],
+ 'trabalhos_que_quero': [{'peso': 0.3}, {'peso': 0.3}],
+ 'peso_prova': 0.0,
+ 'peso_trabalho': 1.0,
+ 'media_desejada': 6.0
+ })
+
+ usecase = GeneticAlgorithmUsecase()
+ controller = GeneticAlgorithmController(usecase=usecase)
+
+ response = controller(request=request)
+
+ assert response.status_code == 200
+
+ # ==========================================
+ # TESTES DE VALIDAÇÃO: provas_que_tenho
+ # ==========================================
+
+ def test_genetic_algorithm_controller_provas_que_tenho_missing(self):
+ request = HttpRequest(body={
+ 'trabalhos_que_tenho': [],
+ 'provas_que_quero': [],
+ 'trabalhos_que_quero': [],
+ 'peso_prova': 0.6,
+ 'peso_trabalho': 0.4,
+ 'media_desejada': 7.0
+ })
+
+ usecase = GeneticAlgorithmUsecase()
+ controller = GeneticAlgorithmController(usecase=usecase)
+
+ response = controller(request=request)
+
+ assert response.status_code == 400
+ assert response.body == 'Parâmetro provas_que_tenho não existe'
+
+ def test_genetic_algorithm_controller_provas_que_tenho_wrong_type(self):
+ request = HttpRequest(body={
+ 'provas_que_tenho': 5.0,
+ 'trabalhos_que_tenho': [],
+ 'provas_que_quero': [],
+ 'trabalhos_que_quero': [],
+ 'peso_prova': 0.6,
+ 'peso_trabalho': 0.4,
+ 'media_desejada': 7.0
+ })
+
+ usecase = GeneticAlgorithmUsecase()
+ controller = GeneticAlgorithmController(usecase=usecase)
+
+ response = controller(request=request)
+
+ assert response.status_code == 400
+ assert 'Parâmetro provas_que_tenho não possui tipo correto' in response.body
+
+ def test_genetic_algorithm_controller_provas_que_tenho_item_valor_wrong_type(self):
+ request = HttpRequest(body={
+ 'provas_que_tenho': [{'valor': '6.0', 'peso': 0.5}],
+ 'trabalhos_que_tenho': [],
+ 'provas_que_quero': [],
+ 'trabalhos_que_quero': [],
+ 'peso_prova': 0.6,
+ 'peso_trabalho': 0.4,
+ 'media_desejada': 7.0
+ })
+
+ usecase = GeneticAlgorithmUsecase()
+ controller = GeneticAlgorithmController(usecase=usecase)
+
+ response = controller(request=request)
+
+ assert response.status_code == 400
+ assert 'Parâmetro provas_que_tenho item não possui tipo correto' in response.body
+
+ def test_genetic_algorithm_controller_provas_que_tenho_peso_out_of_range(self):
+ request = HttpRequest(body={
+ 'provas_que_tenho': [{'valor': 6.0, 'peso': 1.5}],
+ 'trabalhos_que_tenho': [],
+ 'provas_que_quero': [],
+ 'trabalhos_que_quero': [],
+ 'peso_prova': 0.6,
+ 'peso_trabalho': 0.4,
+ 'media_desejada': 7.0
+ })
+
+ usecase = GeneticAlgorithmUsecase()
+ controller = GeneticAlgorithmController(usecase=usecase)
+
+ response = controller(request=request)
+
+ assert response.status_code == 400
+ assert 'Must be between 0 and 1' in response.body
+
+ # ==========================================
+ # TESTES DE VALIDAÇÃO: trabalhos_que_tenho
+ # ==========================================
+
+ def test_genetic_algorithm_controller_trabalhos_que_tenho_missing(self):
+ request = HttpRequest(body={
+ 'provas_que_tenho': [],
+ 'provas_que_quero': [],
+ 'trabalhos_que_quero': [],
+ 'peso_prova': 0.6,
+ 'peso_trabalho': 0.4,
+ 'media_desejada': 7.0
+ })
+
+ usecase = GeneticAlgorithmUsecase()
+ controller = GeneticAlgorithmController(usecase=usecase)
+
+ response = controller(request=request)
+
+ assert response.status_code == 400
+ assert response.body == 'Parâmetro trabalhos_que_tenho não existe'
+
+ # ==========================================
+ # TESTES DE VALIDAÇÃO: provas_que_quero
+ # ==========================================
+
+ def test_genetic_algorithm_controller_provas_que_quero_missing(self):
+ request = HttpRequest(body={
+ 'provas_que_tenho': [],
+ 'trabalhos_que_tenho': [],
+ 'trabalhos_que_quero': [],
+ 'peso_prova': 0.6,
+ 'peso_trabalho': 0.4,
+ 'media_desejada': 7.0
+ })
+
+ usecase = GeneticAlgorithmUsecase()
+ controller = GeneticAlgorithmController(usecase=usecase)
+
+ response = controller(request=request)
+
+ assert response.status_code == 400
+ assert response.body == 'Parâmetro provas_que_quero não existe'
+
+ # ==========================================
+ # TESTES DE VALIDAÇÃO: trabalhos_que_quero
+ # ==========================================
+
+ def test_genetic_algorithm_controller_trabalhos_que_quero_missing(self):
+ request = HttpRequest(body={
+ 'provas_que_tenho': [],
+ 'trabalhos_que_tenho': [],
+ 'provas_que_quero': [],
+ 'peso_prova': 0.6,
+ 'peso_trabalho': 0.4,
+ 'media_desejada': 7.0
+ })
+
+ usecase = GeneticAlgorithmUsecase()
+ controller = GeneticAlgorithmController(usecase=usecase)
+
+ response = controller(request=request)
+
+ assert response.status_code == 400
+ assert response.body == 'Parâmetro trabalhos_que_quero não existe'
+
+ # ==========================================
+ # TESTES DE VALIDAÇÃO: peso_prova, peso_trabalho e soma
+ # ==========================================
+
+ def test_genetic_algorithm_controller_peso_prova_missing(self):
+ request = HttpRequest(body={
+ 'provas_que_tenho': [],
+ 'trabalhos_que_tenho': [],
+ 'provas_que_quero': [],
+ 'trabalhos_que_quero': [],
+ 'peso_trabalho': 0.4,
+ 'media_desejada': 7.0
+ })
+
+ usecase = GeneticAlgorithmUsecase()
+ controller = GeneticAlgorithmController(usecase=usecase)
+
+ response = controller(request=request)
+
+ assert response.status_code == 400
+ assert response.body == 'Parâmetro peso_prova não existe'
+
+ def test_genetic_algorithm_controller_peso_prova_wrong_type(self):
+ request = HttpRequest(body={
+ 'provas_que_tenho': [],
+ 'trabalhos_que_tenho': [],
+ 'provas_que_quero': [],
+ 'trabalhos_que_quero': [],
+ 'peso_prova': '0.6',
+ 'peso_trabalho': 0.4,
+ 'media_desejada': 7.0
+ })
+
+ usecase = GeneticAlgorithmUsecase()
+ controller = GeneticAlgorithmController(usecase=usecase)
+
+ response = controller(request=request)
+
+ assert response.status_code == 400
+ assert 'Parâmetro peso_prova não possui tipo correto' in response.body
+
+ def test_genetic_algorithm_controller_pesos_sum_not_one(self):
+ request = HttpRequest(body={
+ 'provas_que_tenho': [],
+ 'trabalhos_que_tenho': [],
+ 'provas_que_quero': [],
+ 'trabalhos_que_quero': [],
+ 'peso_prova': 0.8,
+ 'peso_trabalho': 0.4, # Soma = 1.2
+ 'media_desejada': 7.0
+ })
+
+ usecase = GeneticAlgorithmUsecase()
+ controller = GeneticAlgorithmController(usecase=usecase)
+
+ response = controller(request=request)
+
+ assert response.status_code == 400
+ assert 'Must sum 1.0' in response.body
+
+ # ==========================================
+ # TESTES DE VALIDAÇÃO: media_desejada
+ # ==========================================
+
+ def test_genetic_algorithm_controller_media_desejada_missing(self):
+ request = HttpRequest(body={
+ 'provas_que_tenho': [],
+ 'trabalhos_que_tenho': [],
+ 'provas_que_quero': [],
+ 'trabalhos_que_quero': [],
+ 'peso_prova': 0.6,
+ 'peso_trabalho': 0.4
+ })
+
+ usecase = GeneticAlgorithmUsecase()
+ controller = GeneticAlgorithmController(usecase=usecase)
+
+ response = controller(request=request)
+
+ assert response.status_code == 400
+ assert response.body == 'Parâmetro media_desejada não existe'
+
+ def test_genetic_algorithm_controller_media_desejada_out_of_range(self):
+ request = HttpRequest(body={
+ 'provas_que_tenho': [],
+ 'trabalhos_que_tenho': [],
+ 'provas_que_quero': [],
+ 'trabalhos_que_quero': [],
+ 'peso_prova': 0.6,
+ 'peso_trabalho': 0.4,
+ 'media_desejada': 11.0 # Fora do range 0-10
+ })
+
+ usecase = GeneticAlgorithmUsecase()
+ controller = GeneticAlgorithmController(usecase=usecase)
+
+ response = controller(request=request)
+
+ assert response.status_code == 400
+ assert 'Must be between 0 and 10' in response.body
\ No newline at end of file
diff --git a/tests/modules/genetic_algorithm/app/test_genetic_algorithm_presenter.py b/tests/modules/genetic_algorithm/app/test_genetic_algorithm_presenter.py
new file mode 100644
index 0000000..ddecff6
--- /dev/null
+++ b/tests/modules/genetic_algorithm/app/test_genetic_algorithm_presenter.py
@@ -0,0 +1,206 @@
+# tests/modules/genetic_algorithm/app/test_genetic_algorithm_presenter.py
+
+import json
+from src.modules.genetic_algorithm.app.genetic_algorithm_presenter import lambda_handler
+
+
+class TestGeneticAlgorithmPresenter:
+
+ def _make_event(self, body):
+ return {"body": body}
+
+ def _default_body(self, **kwargs):
+ body = {
+ 'provas_que_tenho': [{'valor': 6.0, 'peso': 0.25}, {'valor': 7.0, 'peso': 0.25}],
+ 'trabalhos_que_tenho': [{'valor': 8.0, 'peso': 0.5}],
+ 'provas_que_quero': [{'peso': 0.25}, {'peso': 0.25}],
+ 'trabalhos_que_quero': [{'peso': 0.5}],
+ 'peso_prova': 0.6,
+ 'peso_trabalho': 0.4,
+ 'media_desejada': 7.0,
+ }
+ body.update(kwargs)
+ return body
+ # ==========================================
+ # Sucesso
+ # ==========================================
+
+ def test_success_basic(self):
+ response = lambda_handler(event=self._make_event(self._default_body()), context=None)
+ assert response["statusCode"] == 200
+
+ def test_success_only_tests(self):
+ body = {
+ 'provas_que_tenho': [{'valor': 5.0, 'peso': 0.5}],
+ 'trabalhos_que_tenho': [],
+ 'provas_que_quero': [{'peso': 0.25}, {'peso': 0.25}], # 0.5 + 0.25 + 0.25 = 1.0
+ 'trabalhos_que_quero': [],
+ 'peso_prova': 1.0,
+ 'peso_trabalho': 0.0,
+ 'media_desejada': 7.0,
+ }
+ response = lambda_handler(event=self._make_event(body), context=None)
+ assert response["statusCode"] == 200
+
+ def test_success_only_assignments(self):
+ body = {
+ 'provas_que_tenho': [],
+ 'trabalhos_que_tenho': [{'valor': 8.0, 'peso': 0.25}, {'valor': 9.0, 'peso': 0.25}],
+ 'provas_que_quero': [],
+ 'trabalhos_que_quero': [{'peso': 0.25}, {'peso': 0.25}], # soma 1.0
+ 'peso_prova': 0.0,
+ 'peso_trabalho': 1.0,
+ 'media_desejada': 6.0,
+ }
+ response = lambda_handler(event=self._make_event(body), context=None)
+ assert response["statusCode"] == 200
+
+ def test_success_high_target(self):
+ body = self._default_body(
+ provas_que_tenho=[{'valor': 10.0, 'peso': 0.5}],
+ trabalhos_que_tenho=[{'valor': 10.0, 'peso': 0.5}],
+ media_desejada=10.0
+ )
+ response = lambda_handler(event=self._make_event(body), context=None)
+ assert response["statusCode"] == 200
+
+ def test_success_low_target(self):
+ response = lambda_handler(event=self._make_event(self._default_body(media_desejada=1.0)), context=None)
+ assert response["statusCode"] == 200
+
+ def test_success_response_has_expected_keys(self):
+ response = lambda_handler(event=self._make_event(self._default_body()), context=None)
+ body = json.loads(response["body"])
+ assert "notas" in body
+ assert "message" in body
+ assert "provas" in body["notas"]
+ assert "trabalhos" in body["notas"]
+ assert isinstance(body["notas"]["provas"], list)
+ assert isinstance(body["notas"]["trabalhos"], list)
+
+ def test_success_response_does_not_expose_legacy_keys(self):
+ response = lambda_handler(event=self._make_event(self._default_body()), context=None)
+ body = json.loads(response["body"])
+
+ for key in ["tests", "assignments", "final_average", "target_average"]:
+ assert key not in body
+
+ def test_success_multiple_calls(self):
+ for _ in range(5):
+ response = lambda_handler(event=self._make_event(self._default_body()), context=None)
+ assert response["statusCode"] == 200
+
+ # ==========================================
+ # Parâmetros faltando
+ # ==========================================
+
+ def test_missing_provas_que_tenho(self):
+ body = self._default_body()
+ del body['provas_que_tenho']
+ response = lambda_handler(event=self._make_event(body), context=None)
+ assert response["statusCode"] == 400
+ assert 'provas_que_tenho' in json.loads(response["body"])
+
+ def test_missing_trabalhos_que_tenho(self):
+ body = self._default_body()
+ del body['trabalhos_que_tenho']
+ response = lambda_handler(event=self._make_event(body), context=None)
+ assert response["statusCode"] == 400
+ assert 'trabalhos_que_tenho' in json.loads(response["body"])
+
+ def test_missing_provas_que_quero(self):
+ body = self._default_body()
+ del body['provas_que_quero']
+ response = lambda_handler(event=self._make_event(body), context=None)
+ assert response["statusCode"] == 400
+ assert 'provas_que_quero' in json.loads(response["body"])
+
+ def test_missing_trabalhos_que_quero(self):
+ body = self._default_body()
+ del body['trabalhos_que_quero']
+ response = lambda_handler(event=self._make_event(body), context=None)
+ assert response["statusCode"] == 400
+ assert 'trabalhos_que_quero' in json.loads(response["body"])
+
+ def test_missing_peso_prova(self):
+ body = self._default_body()
+ del body['peso_prova']
+ response = lambda_handler(event=self._make_event(body), context=None)
+ assert response["statusCode"] == 400
+ assert 'peso_prova' in json.loads(response["body"])
+
+ def test_missing_peso_trabalho(self):
+ body = self._default_body()
+ del body['peso_trabalho']
+ response = lambda_handler(event=self._make_event(body), context=None)
+ assert response["statusCode"] == 400
+ assert 'peso_trabalho' in json.loads(response["body"])
+
+ def test_missing_media_desejada(self):
+ body = self._default_body()
+ del body['media_desejada']
+ response = lambda_handler(event=self._make_event(body), context=None)
+ assert response["statusCode"] == 400
+ assert 'media_desejada' in json.loads(response["body"])
+
+ # ==========================================
+ # Tipos errados
+ # ==========================================
+
+ def test_wrong_type_provas_que_tenho(self):
+ response = lambda_handler(event=self._make_event(self._default_body(provas_que_tenho=6.0)), context=None)
+ assert response["statusCode"] == 400
+ assert 'provas_que_tenho' in json.loads(response["body"])
+
+ def test_wrong_type_peso_prova(self):
+ response = lambda_handler(event=self._make_event(self._default_body(peso_prova='0.6')), context=None)
+ assert response["statusCode"] == 400
+ assert 'peso_prova' in json.loads(response["body"])
+
+ def test_wrong_type_media_desejada(self):
+ response = lambda_handler(event=self._make_event(self._default_body(media_desejada='7.0')), context=None)
+ assert response["statusCode"] == 400
+ assert 'media_desejada' in json.loads(response["body"])
+
+ def test_wrong_type_nota_valor(self):
+ body = self._default_body(provas_que_tenho=[{'valor': 'seis', 'peso': 1.0}])
+ response = lambda_handler(event=self._make_event(body), context=None)
+ assert response["statusCode"] == 400
+
+ def test_wrong_type_nota_peso(self):
+ body = self._default_body(provas_que_tenho=[{'valor': 6.0, 'peso': 'alto'}])
+ response = lambda_handler(event=self._make_event(body), context=None)
+ assert response["statusCode"] == 400
+
+ # ==========================================
+ # Valores fora do range
+ # ==========================================
+
+ def test_peso_prova_out_of_range(self):
+ response = lambda_handler(event=self._make_event(self._default_body(peso_prova=1.5, peso_trabalho=0.4)), context=None)
+ assert response["statusCode"] == 400
+
+ def test_media_desejada_out_of_range(self):
+ response = lambda_handler(event=self._make_event(self._default_body(media_desejada=15.0)), context=None)
+ assert response["statusCode"] == 400
+
+ def test_nota_peso_out_of_range(self):
+ body = self._default_body(provas_que_tenho=[{'valor': 6.0, 'peso': 1.5}])
+ response = lambda_handler(event=self._make_event(body), context=None)
+ assert response["statusCode"] == 400
+
+ def test_pesos_nao_somam_um(self):
+ response = lambda_handler(event=self._make_event(self._default_body(peso_prova=0.5, peso_trabalho=0.3)), context=None)
+ assert response["statusCode"] == 400
+
+ # ==========================================
+ # Formato API Gateway (body como string JSON)
+ # ==========================================
+
+ def test_api_gateway_body_as_string(self):
+ event = {
+ 'body': json.dumps(self._default_body()),
+ 'isBase64Encoded': False
+ }
+ response = lambda_handler(event=event, context=None)
+ assert response["statusCode"] == 200
\ No newline at end of file
diff --git a/tests/modules/genetic_algorithm/app/test_genetic_algorithm_usecase.py b/tests/modules/genetic_algorithm/app/test_genetic_algorithm_usecase.py
new file mode 100644
index 0000000..3b844f8
--- /dev/null
+++ b/tests/modules/genetic_algorithm/app/test_genetic_algorithm_usecase.py
@@ -0,0 +1,164 @@
+import pytest
+from unittest.mock import MagicMock, patch
+from src.modules.genetic_algorithm.app.genetic_algorithm_usecase import (
+ GeneticAlgorithmUsecase,
+ _round_grade_for_front,
+ _round_weight_for_front,
+)
+from src.shared.helpers.errors.usecase_errors import CombinationNotFound
+
+
+class TestGeneticAlgorithmUsecase:
+
+ def setup_method(self):
+ self.usecase = GeneticAlgorithmUsecase()
+
+ def _run(self, **kwargs):
+ defaults = dict(
+ current_tests=[7.0, 8.0],
+ current_assignments=[6.0, 9.0],
+ num_remaining_tests=2,
+ num_remaining_assignments=2,
+ test_weight=0.6,
+ assignment_weight=0.4,
+ target_average=7.0,
+ spec_test_weight=[0.25, 0.25, 0.25, 0.25],
+ spec_assignment_weight=[0.25, 0.25, 0.25, 0.25],
+ max_grade=10.0,
+ population_size=50,
+ generations=50,
+ )
+ defaults.update(kwargs)
+ return self.usecase(**defaults)
+
+ # ==========================================
+ # Casos de sucesso
+ # ==========================================
+
+ def test_returns_boletim(self):
+ boletim = self._run()
+ assert boletim is not None
+
+ def test_boletim_has_provas(self):
+ boletim = self._run()
+ assert hasattr(boletim, 'provas')
+ assert isinstance(boletim.provas, list)
+
+ def test_boletim_has_trabalhos(self):
+ boletim = self._run()
+ assert hasattr(boletim, 'trabalhos')
+ assert isinstance(boletim.trabalhos, list)
+
+ def test_boletim_has_message(self):
+ boletim = self._run()
+ assert hasattr(boletim, 'message')
+ assert isinstance(boletim.message, str)
+
+ def test_provas_total_length(self):
+ boletim = self._run(num_remaining_tests=2)
+ # current(2) + remaining(2)
+ assert len(boletim.provas) == 4
+
+ def test_trabalhos_total_length(self):
+ boletim = self._run(num_remaining_assignments=2)
+ # current(2) + remaining(2)
+ assert len(boletim.trabalhos) == 4
+
+ def test_provas_have_valor_and_peso(self):
+ boletim = self._run()
+ for prova in boletim.provas:
+ assert 'valor' in prova
+ assert 'peso' in prova
+
+ def test_trabalhos_have_valor_and_peso(self):
+ boletim = self._run()
+ for trabalho in boletim.trabalhos:
+ assert 'valor' in trabalho
+ assert 'peso' in trabalho
+
+ def test_final_avg_within_range(self):
+ boletim = self._run()
+ assert 0.0 <= boletim.final_avg <= 10.0
+
+ def test_target_avg_stored(self):
+ boletim = self._run(target_average=8.0)
+ assert boletim.target_avg == 8.0
+
+ def test_grades_displayed_in_half_point_steps(self):
+ boletim = self._run()
+ for prova in boletim.provas:
+ assert (prova['valor'] * 2).is_integer()
+ assert prova['peso'] == round(prova['peso'], 1)
+
+ def test_maua_grade_step_rounding_rule(self):
+ assert _round_grade_for_front(5.6) == 5.5
+ assert _round_grade_for_front(5.7) == 5.5
+ assert _round_grade_for_front(5.8) == 6.0
+
+ def test_maua_weight_rounding_rule(self):
+ assert _round_weight_for_front(0.25) == 0.2
+ assert _round_weight_for_front(0.26) == 0.3
+
+ def test_message_exact_when_diff_lte_005(self):
+ boletim = self._run(target_average=7.0, current_tests=[7.0, 7.0], current_assignments=[7.0, 7.0])
+ if abs(boletim.final_avg - boletim.target_avg) <= 0.05:
+ assert boletim.message == "O algoritmo retornou uma combinação válida de notas"
+
+ def test_message_contains_diff_when_close(self):
+ boletim = self._run()
+ diff = abs(boletim.final_avg - boletim.target_avg)
+ if 0.05 < diff <= 0.2:
+ assert "próxima" in boletim.message
+
+ def test_message_contains_diff_when_far(self):
+ boletim = self._run()
+ diff = abs(boletim.final_avg - boletim.target_avg)
+ if diff > 0.2:
+ assert "não conseguiu" in boletim.message
+
+ # ==========================================
+ # Casos de erro
+ # ==========================================
+
+ def test_raises_combination_not_found_when_impossible(self):
+ with patch('src.modules.genetic_algorithm.app.genetic_algorithm_usecase.GradeGeneticAlgorithm') as mock_ga:
+ mock_instance = MagicMock()
+ mock_instance.run.return_value = (None, None, None)
+ mock_ga.return_value = mock_instance
+ with pytest.raises(CombinationNotFound):
+ self._run()
+
+ def test_raises_entity_error_invalid_weight_sum(self):
+ from src.shared.helpers.errors.domain_errors import EntityError
+ with pytest.raises(EntityError):
+ self._run(test_weight=0.5, assignment_weight=0.3)
+
+ def test_raises_entity_error_negative_num_remaining_tests(self):
+ from src.shared.helpers.errors.domain_errors import EntityError
+ with pytest.raises(EntityError):
+ self._run(num_remaining_tests=-1)
+
+ def test_raises_entity_error_negative_num_remaining_assignments(self):
+ from src.shared.helpers.errors.domain_errors import EntityError
+ with pytest.raises(EntityError):
+ self._run(num_remaining_assignments=-1)
+
+ def test_raises_entity_error_invalid_spec_weight_sum(self):
+ from src.shared.helpers.errors.domain_errors import EntityError
+ with pytest.raises(EntityError):
+ self._run(spec_test_weight=[0.5, 0.5, 0.5, 0.5])
+
+ def test_raises_entity_error_spec_weight_wrong_length(self):
+ from src.shared.helpers.errors.domain_errors import EntityError
+ with pytest.raises(EntityError):
+ self._run(spec_test_weight=[0.5, 0.5])
+
+ def test_raises_entity_error_grade_above_max(self):
+ from src.shared.helpers.errors.domain_errors import EntityError
+ with pytest.raises(EntityError):
+ self._run(current_tests=[11.0, 8.0])
+
+ def test_raises_entity_error_grade_below_zero(self):
+ from src.shared.helpers.errors.domain_errors import EntityError
+ with pytest.raises(EntityError):
+ self._run(current_tests=[-1.0, 8.0])
\ No newline at end of file
diff --git a/tests/modules/genetic_algorithm/app/test_genetic_algorithm_viewmodel.py b/tests/modules/genetic_algorithm/app/test_genetic_algorithm_viewmodel.py
new file mode 100644
index 0000000..332a4ee
--- /dev/null
+++ b/tests/modules/genetic_algorithm/app/test_genetic_algorithm_viewmodel.py
@@ -0,0 +1,70 @@
+import pytest
+from unittest.mock import MagicMock
+from src.shared.domain.entities.boletim_ga import Boletim_GA
+from src.modules.genetic_algorithm.app.genetic_algorithm_viewmodel import GeneticAlgorithmViewmodel
+
+
+def make_boletim(provas=None, trabalhos=None, message="Combinação válida"):
+ boletim = MagicMock(spec=Boletim_GA)
+ boletim.provas = provas if provas is not None else [{"valor": 8.0, "peso": 0.5}, {"valor": 7.0, "peso": 0.5}]
+ boletim.trabalhos = trabalhos if trabalhos is not None else [{"valor": 9.0, "peso": 1.0}]
+ boletim.message = message
+ return boletim
+
+
+class TestGeneticAlgorithmViewmodel:
+
+ def test_to_dict_structure(self):
+ boletim = make_boletim()
+ result = GeneticAlgorithmViewmodel(boletim).to_dict()
+
+ assert "notas" in result
+ assert "provas" in result["notas"]
+ assert "trabalhos" in result["notas"]
+ assert "message" in result
+
+ def test_provas_correct(self):
+ provas = [{"valor": 8.0, "peso": 0.5}]
+ boletim = make_boletim(provas=provas)
+ result = GeneticAlgorithmViewmodel(boletim).to_dict()
+
+ assert result["notas"]["provas"] == provas
+
+ def test_trabalhos_correct(self):
+ trabalhos = [{"valor": 9.0, "peso": 1.0}]
+ boletim = make_boletim(trabalhos=trabalhos)
+ result = GeneticAlgorithmViewmodel(boletim).to_dict()
+
+ assert result["notas"]["trabalhos"] == trabalhos
+
+ def test_provas_and_trabalhos_are_different(self):
+ boletim = make_boletim()
+ result = GeneticAlgorithmViewmodel(boletim).to_dict()
+
+ assert result["notas"]["provas"] != result["notas"]["trabalhos"]
+
+ def test_message_correct(self):
+ boletim = make_boletim(message="O algoritmo retornou uma combinação válida de notas")
+ result = GeneticAlgorithmViewmodel(boletim).to_dict()
+
+ assert result["message"] == "O algoritmo retornou uma combinação válida de notas"
+
+ def test_empty_provas(self):
+ boletim = make_boletim(provas=[])
+ result = GeneticAlgorithmViewmodel(boletim).to_dict()
+
+ assert result["notas"]["provas"] == []
+
+ def test_empty_trabalhos(self):
+ boletim = make_boletim(trabalhos=[])
+ result = GeneticAlgorithmViewmodel(boletim).to_dict()
+
+ assert result["notas"]["trabalhos"] == []
+
+ def test_multiple_provas(self):
+ provas = [{"valor": round(i * 1.5, 2), "peso": 0.25} for i in range(4)]
+ boletim = make_boletim(provas=provas)
+ result = GeneticAlgorithmViewmodel(boletim).to_dict()
+
+ assert len(result["notas"]["provas"]) == 4
+ assert result["notas"]["provas"] == provas
\ No newline at end of file
diff --git a/tests/modules/plans_extractor/app/test_parser.py b/tests/modules/plans_extractor/app/test_parser.py
new file mode 100644
index 0000000..4e74495
--- /dev/null
+++ b/tests/modules/plans_extractor/app/test_parser.py
@@ -0,0 +1,142 @@
+import pytest
+
+from src.modules.plans_extractor.app.parser import build_disciplina
+
+
+def _payload(**overrides):
+ base = {
+ "course": "Análise e Desenvolvimento de Sistemas",
+ "name": "Algoritmos",
+ "code": "ADS1003",
+ "period": "A",
+ "examWeight": 50,
+ "assignmentWeight": 50,
+ "exams": [
+ {"name": "P1", "weight": 0.5},
+ {"name": "P2", "weight": 0.5},
+ ],
+ "assignments": [
+ {"name": "T1", "weight": 0.5},
+ {"name": "T2", "weight": 0.5},
+ ],
+ }
+ base.update(overrides)
+ return base
+
+
+@pytest.mark.parametrize(
+ ("exam_weight", "assignment_weight", "expected_exam", "expected_assignment"),
+ [
+ (50, 50, 0.5, 0.5),
+ (60, 40, 0.6, 0.4),
+ (70, 30, 0.7, 0.3),
+ ],
+)
+def test_normaliza_pesos_globais_para_escala_0_a_1(
+ exam_weight, assignment_weight, expected_exam, expected_assignment
+):
+ disciplina = build_disciplina(
+ _payload(examWeight=exam_weight, assignmentWeight=assignment_weight),
+ courses={"ADS": 1},
+ )
+
+ assert disciplina.exam_weight == pytest.approx(expected_exam)
+ assert disciplina.assignment_weight == pytest.approx(expected_assignment)
+
+
+def test_aplica_guard_rail_em_pesos_internos_de_exams_e_assignments():
+ disciplina = build_disciplina(
+ _payload(
+ period="A",
+ exams=[
+ {"name": "P1", "weight": 0},
+ {"name": "P2", "weight": 0},
+ ],
+ assignments=[
+ {"name": "T1", "weight": 40},
+ {"name": "T2", "weight": 60},
+ ],
+ ),
+ courses={"ADS": 1},
+ )
+
+ assert [exam.weight for exam in disciplina.exams] == pytest.approx([0.4, 0.6])
+ assert [assignment.weight for assignment in disciplina.assignments] == pytest.approx([0.4, 0.6])
+
+
+def test_cenario_ads1003_periodo_a_com_duas_provas_iguais_aplica_correcao():
+ disciplina = build_disciplina(
+ _payload(
+ period="A",
+ exams=[
+ {"name": "P1", "weight": 0.5},
+ {"name": "P2", "weight": 0.5},
+ ],
+ ),
+ courses={"ADS": 1},
+ )
+
+ assert [exam.weight for exam in disciplina.exams] == pytest.approx([0.4, 0.6])
+
+
+def test_resposta_bedrock_incompleta_faz_fallback_para_zero():
+ disciplina = build_disciplina(
+ _payload(
+ examWeight=None,
+ assignmentWeight=None,
+ exams=[{"name": "P1", "weight": 1}],
+ assignments=[{"name": "T1", "weight": 1}],
+ ),
+ courses={"ADS": 1},
+ )
+
+ assert disciplina.exam_weight == 0
+ assert disciplina.assignment_weight == 0
+ assert disciplina.exams == []
+ assert disciplina.assignments == []
+
+
+def test_remove_provas_e_trabalhos_substitutivos():
+ disciplina = build_disciplina(
+ _payload(
+ exams=[
+ {"name": "P1", "weight": 0.4},
+ {"name": "Prova Substitutiva", "weight": 0.6},
+ ],
+ assignments=[
+ {"name": "T1", "weight": 0.5},
+ {"name": "Trabalho Substitutivo", "weight": 0.5},
+ ],
+ ),
+ courses={"ADS": 1},
+ )
+
+ assert [exam.name for exam in disciplina.exams] == ["P1"]
+ assert [exam.weight for exam in disciplina.exams] == pytest.approx([1.0])
+ assert [assignment.name for assignment in disciplina.assignments] == ["T1"]
+ assert [assignment.weight for assignment in disciplina.assignments] == pytest.approx([1.0])
+
+
+def test_trunca_pesos_para_tres_casas_sem_arredondar_para_cima():
+ disciplina = build_disciplina(
+ _payload(
+ exams=[
+ {"name": "P1", "weight": 1},
+ {"name": "P2", "weight": 1},
+ {"name": "P3", "weight": 1},
+ ],
+ assignments=[
+ {"name": "T1", "weight": 1},
+ {"name": "T2", "weight": 1},
+ {"name": "T3", "weight": 1},
+ ],
+ examWeight=33.34,
+ assignmentWeight=66.66,
+ ),
+ courses={"ADS": 1},
+ )
+
+ assert disciplina.exam_weight == pytest.approx(0.333)
+ assert disciplina.assignment_weight == pytest.approx(0.666)
+ assert [exam.weight for exam in disciplina.exams] == pytest.approx([0.333, 0.333, 0.333])
+ assert [assignment.weight for assignment in disciplina.assignments] == pytest.approx([0.333, 0.333, 0.333])
diff --git a/tests/shared/domain/entities/test_boletim_ga.py b/tests/shared/domain/entities/test_boletim_ga.py
new file mode 100644
index 0000000..db97a73
--- /dev/null
+++ b/tests/shared/domain/entities/test_boletim_ga.py
@@ -0,0 +1,391 @@
+import pytest
+from src.shared.domain.entities.boletim_ga import Boletim_GA
+from src.shared.helpers.errors.domain_errors import EntityError
+
+
+class TestBoletimGA:
+
+ def test_boletim_ga_basic_creation(self):
+
+ boletim = Boletim_GA(
+ current_tests=[6.0, 8.0],
+ current_assignments=[7.0],
+ num_remaining_tests=2,
+ num_remaining_assignments=1,
+ test_weight=0.6,
+ assignment_weight=0.4
+ )
+
+ assert boletim.current_tests == [6.0, 8.0]
+ assert boletim.current_assignments == [7.0]
+ assert boletim.num_remaining_tests == 2
+ assert boletim.num_remaining_assignments == 1
+ assert boletim.test_weight == 0.6
+ assert boletim.assignment_weight == 0.4
+ assert boletim.spec_test_weight is None
+ assert boletim.spec_assignment_weight is None
+
+ def test_boletim_ga_with_specific_weights(self):
+
+ boletim = Boletim_GA(
+ current_tests=[6.0],
+ current_assignments=[7.0],
+ num_remaining_tests=2,
+ num_remaining_assignments=2,
+ test_weight=0.6,
+ assignment_weight=0.4,
+ spec_test_weight=[0.2, 0.4, 0.4],
+ spec_assignment_weight=[0.3, 0.3, 0.4]
+ )
+
+ assert boletim.spec_test_weight == [0.2, 0.4, 0.4]
+ assert boletim.spec_assignment_weight == [0.3, 0.3, 0.4]
+
+ def test_boletim_ga_only_tests(self):
+
+ boletim = Boletim_GA(
+ current_tests=[5.0],
+ current_assignments=[],
+ num_remaining_tests=3,
+ num_remaining_assignments=0,
+ test_weight=1.0,
+ assignment_weight=0.0
+ )
+
+ assert len(boletim.current_tests) == 1
+ assert len(boletim.current_assignments) == 0
+ assert boletim.test_weight == 1.0
+ assert boletim.assignment_weight == 0.0
+
+ def test_boletim_ga_only_assignments(self):
+
+ boletim = Boletim_GA(
+ current_tests=[],
+ current_assignments=[8.0, 9.0],
+ num_remaining_tests=0,
+ num_remaining_assignments=2,
+ test_weight=0.0,
+ assignment_weight=1.0
+ )
+
+ assert len(boletim.current_tests) == 0
+ assert len(boletim.current_assignments) == 2
+ assert boletim.test_weight == 0.0
+ assert boletim.assignment_weight == 1.0
+
+ def test_boletim_ga_custom_max_grade(self):
+
+ boletim = Boletim_GA(
+ current_tests=[50.0, 60.0],
+ current_assignments=[70.0],
+ num_remaining_tests=2,
+ num_remaining_assignments=1,
+ test_weight=0.6,
+ assignment_weight=0.4,
+ max_grade=100.0
+ )
+
+ assert boletim.current_tests == [50.0, 60.0]
+ assert boletim.current_assignments == [70.0]
+
+ def test_boletim_ga_to_dict(self):
+
+ boletim = Boletim_GA(
+ current_tests=[6.0],
+ current_assignments=[7.0],
+ num_remaining_tests=2,
+ num_remaining_assignments=1,
+ test_weight=0.6,
+ assignment_weight=0.4
+ )
+
+ result = boletim.to_dict()
+
+ assert isinstance(result, dict)
+ assert result["current_tests"] == [6.0]
+ assert result["current_assignments"] == [7.0]
+ assert result["num_remaining_tests"] == 2
+ assert result["num_remaining_assignments"] == 1
+ assert result["test_weight"] == 0.6
+ assert result["assignment_weight"] == 0.4
+ assert result["spec_test_weight"] is None
+ assert result["spec_assignment_weight"] is None
+
+ def test_boletim_ga_validate_num_remaining_valid(self):
+
+ assert Boletim_GA.validate_num_remaining(0) == True
+ assert Boletim_GA.validate_num_remaining(5) == True
+ assert Boletim_GA.validate_num_remaining(100) == True
+
+ def test_boletim_ga_validate_num_remaining_invalid(self):
+
+ assert Boletim_GA.validate_num_remaining(-1) == False
+ assert Boletim_GA.validate_num_remaining(-10) == False
+ assert Boletim_GA.validate_num_remaining(1.5) == False
+ assert Boletim_GA.validate_num_remaining("5") == False
+
+ def test_boletim_ga_validate_weights_valid(self):
+
+ assert Boletim_GA.validate_weights(0.0) == True
+ assert Boletim_GA.validate_weights(0.5) == True
+ assert Boletim_GA.validate_weights(1.0) == True
+ assert Boletim_GA.validate_weights(0.6) == True
+
+ def test_boletim_ga_validate_weights_invalid(self):
+
+ assert Boletim_GA.validate_weights(-0.1) == False
+ assert Boletim_GA.validate_weights(1.5) == False
+ assert Boletim_GA.validate_weights("0.5") == False
+ assert Boletim_GA.validate_weights(None) == False
+
+ def test_boletim_ga_validate_tests_valid(self):
+
+ assert Boletim_GA.validate_tests([6.0, 8.0], 10.0) == True
+ assert Boletim_GA.validate_tests([0.0, 5.0, 10.0], 10.0) == True
+ assert Boletim_GA.validate_tests([6.5, 7.5], 10.0) == True
+ assert Boletim_GA.validate_tests([], 10.0) == True
+
+ def test_boletim_ga_validate_tests_invalid(self):
+
+ assert Boletim_GA.validate_tests([6.3], 10.0) == False # Não é múltiplo de 0.5
+ assert Boletim_GA.validate_tests([-1.0], 10.0) == False # Negativo
+ assert Boletim_GA.validate_tests([11.0], 10.0) == False # Acima do máximo
+ assert Boletim_GA.validate_tests("not a list", 10.0) == False
+ assert Boletim_GA.validate_tests([6.0, "8.0"], 10.0) == False
+
+ def test_boletim_ga_validate_spec_weights_valid(self):
+
+ assert Boletim_GA.validate_spec_weights([0.2, 0.4, 0.4]) == True
+ assert Boletim_GA.validate_spec_weights([0.5, 0.5]) == True
+ assert Boletim_GA.validate_spec_weights([1.0]) == True
+ assert Boletim_GA.validate_spec_weights([0.0, 0.0, 1.0]) == True
+
+ def test_boletim_ga_validate_spec_weights_invalid(self):
+
+ assert Boletim_GA.validate_spec_weights([0.2, -0.4, 0.4]) == False # Negativo
+ assert Boletim_GA.validate_spec_weights([0.5, 1.5]) == False # Acima de 1
+ assert Boletim_GA.validate_spec_weights("not a list") == False
+ assert Boletim_GA.validate_spec_weights([0.5, "0.5"]) == False
+
+ def test_boletim_ga_validate_sum_weights_valid(self):
+
+ assert Boletim_GA.validate_sum_weights(0.6, 0.4) == True
+ assert Boletim_GA.validate_sum_weights(0.5, 0.5) == True
+ assert Boletim_GA.validate_sum_weights(1.0, 0.0) == True
+ assert Boletim_GA.validate_sum_weights(0.7, 0.3) == True
+
+ def test_boletim_ga_validate_sum_weights_invalid(self):
+
+ assert Boletim_GA.validate_sum_weights(0.5, 0.6) == False
+ assert Boletim_GA.validate_sum_weights(0.3, 0.3) == False
+ assert Boletim_GA.validate_sum_weights(1.0, 1.0) == False
+
+ def test_boletim_ga_validate_sum_spec_weights_valid(self):
+
+ assert Boletim_GA.validate_sum_spec_weights([0.5, 0.5], [6.0], 1) == True
+ assert Boletim_GA.validate_sum_spec_weights([0.2, 0.3, 0.5], [6.0, 7.0], 1) == True
+ assert Boletim_GA.validate_sum_spec_weights(None, [6.0], 1) == True
+
+ def test_boletim_ga_validate_sum_spec_weights_invalid_length(self):
+
+ assert Boletim_GA.validate_sum_spec_weights([0.5, 0.5], [6.0], 2) == False
+ assert Boletim_GA.validate_sum_spec_weights([0.5], [6.0], 1) == False
+
+ def test_boletim_ga_validate_sum_spec_weights_invalid_sum(self):
+
+ assert Boletim_GA.validate_sum_spec_weights([0.3, 0.3], [6.0], 1) == False
+ assert Boletim_GA.validate_sum_spec_weights([0.5, 0.6], [6.0], 1) == False
+
+ def test_boletim_ga_invalid_num_remaining_tests(self):
+
+ with pytest.raises(EntityError):
+ Boletim_GA(
+ current_tests=[6.0],
+ current_assignments=[7.0],
+ num_remaining_tests=-1,
+ num_remaining_assignments=1,
+ test_weight=0.6,
+ assignment_weight=0.4
+ )
+
+ def test_boletim_ga_invalid_num_remaining_assignments(self):
+
+ with pytest.raises(EntityError):
+ Boletim_GA(
+ current_tests=[6.0],
+ current_assignments=[7.0],
+ num_remaining_tests=1,
+ num_remaining_assignments=-1,
+ test_weight=0.6,
+ assignment_weight=0.4
+ )
+
+ def test_boletim_ga_invalid_test_weight(self):
+
+ with pytest.raises(EntityError):
+ Boletim_GA(
+ current_tests=[6.0],
+ current_assignments=[7.0],
+ num_remaining_tests=1,
+ num_remaining_assignments=1,
+ test_weight=1.5,
+ assignment_weight=0.4
+ )
+
+ def test_boletim_ga_invalid_assignment_weight(self):
+
+ with pytest.raises(EntityError):
+ Boletim_GA(
+ current_tests=[6.0],
+ current_assignments=[7.0],
+ num_remaining_tests=1,
+ num_remaining_assignments=1,
+ test_weight=0.6,
+ assignment_weight=-0.1
+ )
+
+ def test_boletim_ga_invalid_weights_sum(self):
+
+ with pytest.raises(EntityError):
+ Boletim_GA(
+ current_tests=[6.0],
+ current_assignments=[7.0],
+ num_remaining_tests=1,
+ num_remaining_assignments=1,
+ test_weight=0.5,
+ assignment_weight=0.6
+ )
+
+ def test_boletim_ga_invalid_current_tests(self):
+
+ with pytest.raises(EntityError):
+ Boletim_GA(
+ current_tests=[6.3], # Não é múltiplo de 0.5
+ current_assignments=[7.0],
+ num_remaining_tests=1,
+ num_remaining_assignments=1,
+ test_weight=0.6,
+ assignment_weight=0.4
+ )
+
+ def test_boletim_ga_invalid_current_assignments(self):
+
+ with pytest.raises(EntityError):
+ Boletim_GA(
+ current_tests=[6.0],
+ current_assignments=[15.0], # Acima do máximo
+ num_remaining_tests=1,
+ num_remaining_assignments=1,
+ test_weight=0.6,
+ assignment_weight=0.4
+ )
+
+ def test_boletim_ga_invalid_spec_test_weight_length(self):
+
+ with pytest.raises(EntityError):
+ Boletim_GA(
+ current_tests=[6.0],
+ current_assignments=[7.0],
+ num_remaining_tests=2,
+ num_remaining_assignments=1,
+ test_weight=0.6,
+ assignment_weight=0.4,
+ spec_test_weight=[0.5, 0.5] # Deveria ter 3 elementos
+ )
+
+ def test_boletim_ga_invalid_spec_test_weight_sum(self):
+
+ with pytest.raises(EntityError):
+ Boletim_GA(
+ current_tests=[6.0],
+ current_assignments=[7.0],
+ num_remaining_tests=2,
+ num_remaining_assignments=1,
+ test_weight=0.6,
+ assignment_weight=0.4,
+ spec_test_weight=[0.3, 0.3, 0.3] # Soma = 0.9, deveria ser 1.0
+ )
+
+ def test_boletim_ga_invalid_spec_test_weight_values(self):
+
+ with pytest.raises(EntityError):
+ Boletim_GA(
+ current_tests=[6.0],
+ current_assignments=[7.0],
+ num_remaining_tests=2,
+ num_remaining_assignments=1,
+ test_weight=0.6,
+ assignment_weight=0.4,
+ spec_test_weight=[0.5, -0.5, 1.0] # Valor negativo
+ )
+
+ def test_boletim_ga_invalid_spec_assignment_weight_length(self):
+
+ with pytest.raises(EntityError):
+ Boletim_GA(
+ current_tests=[6.0],
+ current_assignments=[7.0],
+ num_remaining_tests=1,
+ num_remaining_assignments=2,
+ test_weight=0.6,
+ assignment_weight=0.4,
+ spec_assignment_weight=[0.5, 0.5] # Deveria ter 3 elementos
+ )
+
+ def test_boletim_ga_invalid_spec_assignment_weight_sum(self):
+
+ with pytest.raises(EntityError):
+ Boletim_GA(
+ current_tests=[6.0],
+ current_assignments=[7.0],
+ num_remaining_tests=1,
+ num_remaining_assignments=2,
+ test_weight=0.6,
+ assignment_weight=0.4,
+ spec_assignment_weight=[0.4, 0.4, 0.4] # Soma = 1.2
+ )
+
+ def test_boletim_ga_valid_grades_multiples_of_half(self):
+
+ boletim = Boletim_GA(
+ current_tests=[0.0, 0.5, 1.0, 5.5, 10.0],
+ current_assignments=[6.5, 7.0, 8.5],
+ num_remaining_tests=0,
+ num_remaining_assignments=0,
+ test_weight=0.6,
+ assignment_weight=0.4
+ )
+
+ assert boletim.current_tests == [0.0, 0.5, 1.0, 5.5, 10.0]
+ assert boletim.current_assignments == [6.5, 7.0, 8.5]
+
+ def test_boletim_ga_empty_lists(self):
+
+ boletim = Boletim_GA(
+ current_tests=[],
+ current_assignments=[],
+ num_remaining_tests=3,
+ num_remaining_assignments=2,
+ test_weight=0.5,
+ assignment_weight=0.5
+ )
+
+ assert boletim.current_tests == []
+ assert boletim.current_assignments == []
+ assert boletim.num_remaining_tests == 3
+ assert boletim.num_remaining_assignments == 2
+
+ def test_boletim_ga_response_attribute(self):
+
+ boletim = Boletim_GA(
+ current_tests=[6.0],
+ current_assignments=[7.0],
+ num_remaining_tests=1,
+ num_remaining_assignments=1,
+ test_weight=0.6,
+ assignment_weight=0.4
+ )
+
+ assert hasattr(boletim, 'response')
+ assert isinstance(boletim.response, dict)
+ assert boletim.response == boletim.to_dict()
\ No newline at end of file
diff --git a/tests/shared/domain/entities/test_curso.py b/tests/shared/domain/entities/test_curso.py
new file mode 100644
index 0000000..fb1c7aa
--- /dev/null
+++ b/tests/shared/domain/entities/test_curso.py
@@ -0,0 +1,41 @@
+import pytest
+from pydantic import ValidationError
+
+from src.shared.domain.entities.curso import Curso
+
+
+def _assert_single_error(errors, *, type_, loc, input_):
+ assert len(errors) == 1
+ err = errors[0]
+ assert err["type"] == type_
+ assert err["loc"] == loc
+ assert err["input"] == input_
+
+
+class TestCurso:
+ def test_curso_creation(self):
+ curso = Curso(código="ECM", nome="Engenharia de Computação")
+ assert curso.código == "ECM"
+ assert curso.nome == "Engenharia de Computação"
+
+ def test_curso_código_invalido(self):
+ with pytest.raises(ValidationError) as exc_info:
+ Curso(código=["não é str"], nome="Engenharia de Computação")
+ _assert_single_error(
+ exc_info.value.errors(),
+ type_="string_type",
+ loc=("código",),
+ input_=["não é str"],
+ )
+ assert "string" in exc_info.value.errors()[0]["msg"].lower()
+
+ def test_curso_nome_invalido(self):
+ with pytest.raises(ValidationError) as exc_info:
+ Curso(código="ECM", nome={"invalid": True})
+ _assert_single_error(
+ exc_info.value.errors(),
+ type_="string_type",
+ loc=("nome",),
+ input_={"invalid": True},
+ )
+ assert "string" in exc_info.value.errors()[0]["msg"].lower()
diff --git a/tests/shared/domain/entities/test_disciplina.py b/tests/shared/domain/entities/test_disciplina.py
new file mode 100644
index 0000000..052fc97
--- /dev/null
+++ b/tests/shared/domain/entities/test_disciplina.py
@@ -0,0 +1,226 @@
+import pytest
+from pydantic import ValidationError
+
+from src.shared.domain.entities.disciplina import Disciplina, ItemAvaliacao
+
+
+def _assert_single_error(errors, *, type_, loc, input_):
+ assert len(errors) == 1
+ err = errors[0]
+ assert err["type"] == type_
+ assert err["loc"] == loc
+ assert err["input"] == input_
+
+
+class TestDisciplina:
+ def test_disciplina_creation(self):
+ disciplina = Disciplina(
+ course="ECM",
+ name="Engenharia de Computação",
+ code="ECM101",
+ period="2024.1",
+ exam_weight=0.6,
+ assignment_weight=0.4,
+ exams=[ItemAvaliacao(name="P1", weight=0.6)],
+ assignments=[ItemAvaliacao(name="T1", weight=0.4)],
+ courses={"ECM": 2024},
+ )
+ assert disciplina.course == "ECM"
+ assert disciplina.name == "Engenharia de Computação"
+ assert disciplina.code == "ECM101"
+ assert disciplina.period == "2024.1"
+ assert disciplina.exam_weight == 0.6
+ assert disciplina.assignment_weight == 0.4
+ assert disciplina.exams == [ItemAvaliacao(name="P1", weight=0.6)]
+ assert disciplina.assignments == [ItemAvaliacao(name="T1", weight=0.4)]
+ assert disciplina.courses == {"ECM": 2024}
+
+ def test_disciplina_course_invalido(self):
+ with pytest.raises(ValidationError) as exc_info:
+ Disciplina(
+ course=["não é str"],
+ name="Engenharia de Computação",
+ code="ECM101",
+ period="2024.1",
+ exam_weight=0.6,
+ assignment_weight=0.4,
+ exams=[ItemAvaliacao(name="P1", weight=0.6)],
+ assignments=[ItemAvaliacao(name="T1", weight=0.4)],
+ courses={"ECM": 2024},
+ )
+ _assert_single_error(
+ exc_info.value.errors(),
+ type_="string_type",
+ loc=("course",),
+ input_=["não é str"],
+ )
+ assert "string" in exc_info.value.errors()[0]["msg"].lower()
+
+ def test_disciplina_name_invalido(self):
+ with pytest.raises(ValidationError) as exc_info:
+ Disciplina(
+ course="ECM",
+ name=["não é str"],
+ code="ECM101",
+ period="2024.1",
+ exam_weight=0.6,
+ assignment_weight=0.4,
+ exams=[ItemAvaliacao(name="P1", weight=0.6)],
+ assignments=[ItemAvaliacao(name="T1", weight=0.4)],
+ courses={"ECM": 2024},
+ )
+ _assert_single_error(
+ exc_info.value.errors(),
+ type_="string_type",
+ loc=("name",),
+ input_=["não é str"],
+ )
+ assert "string" in exc_info.value.errors()[0]["msg"].lower()
+
+ def test_disciplina_code_invalido(self):
+ with pytest.raises(ValidationError) as exc_info:
+ Disciplina(
+ course="ECM",
+ name="Engenharia de Computação",
+ code=["não é str"],
+ period="2024.1",
+ exam_weight=0.6,
+ assignment_weight=0.4,
+ exams=[ItemAvaliacao(name="P1", weight=0.6)],
+ assignments=[ItemAvaliacao(name="T1", weight=0.4)],
+ courses={"ECM": 2024},
+ )
+ _assert_single_error(
+ exc_info.value.errors(),
+ type_="string_type",
+ loc=("code",),
+ input_=["não é str"],
+ )
+ assert "string" in exc_info.value.errors()[0]["msg"].lower()
+
+ def test_disciplina_period_invalido(self):
+ with pytest.raises(ValidationError) as exc_info:
+ Disciplina(
+ course="ECM",
+ name="Engenharia de Computação",
+ code="ECM101",
+ period=["não é str"],
+ exam_weight=0.6,
+ assignment_weight=0.4,
+ exams=[ItemAvaliacao(name="P1", weight=0.6)],
+ assignments=[ItemAvaliacao(name="T1", weight=0.4)],
+ courses={"ECM": 2024},
+ )
+ _assert_single_error(
+ exc_info.value.errors(),
+ type_="string_type",
+ loc=("period",),
+ input_=["não é str"],
+ )
+ assert "string" in exc_info.value.errors()[0]["msg"].lower()
+
+ def test_disciplina_exam_weight_invalido(self):
+ with pytest.raises(ValidationError) as exc_info:
+ Disciplina(
+ course="ECM",
+ name="Engenharia de Computação",
+ code="ECM101",
+ period="2024.1",
+ exam_weight=["não é float"],
+ assignment_weight=0.4,
+ exams=[ItemAvaliacao(name="P1", weight=0.6)],
+ assignments=[ItemAvaliacao(name="T1", weight=0.4)],
+ courses={"ECM": 2024},
+ )
+ _assert_single_error(
+ exc_info.value.errors(),
+ type_="float_type",
+ loc=("exam_weight",),
+ input_=["não é float"],
+ )
+ # Pydantic v2 costuma dizer "valid number", não necessariamente "float"
+ assert "number" in exc_info.value.errors()[0]["msg"].lower()
+
+ def test_disciplina_assignment_weight_invalido(self):
+ with pytest.raises(ValidationError) as exc_info:
+ Disciplina(
+ course="ECM",
+ name="Engenharia de Computação",
+ code="ECM101",
+ period="2024.1",
+ exam_weight=0.6,
+ assignment_weight=["não é float"],
+ exams=[ItemAvaliacao(name="P1", weight=0.6)],
+ assignments=[ItemAvaliacao(name="T1", weight=0.4)],
+ courses={"ECM": 2024},
+ )
+ _assert_single_error(
+ exc_info.value.errors(),
+ type_="float_type",
+ loc=("assignment_weight",),
+ input_=["não é float"],
+ )
+ assert "number" in exc_info.value.errors()[0]["msg"].lower()
+
+ def test_disciplina_exams_invalido(self):
+ invalid_item = ["não é ItemAvaliacao"]
+ with pytest.raises(ValidationError) as exc_info:
+ Disciplina(
+ course="ECM",
+ name="Engenharia de Computação",
+ code="ECM101",
+ period="2024.1",
+ exam_weight=0.6,
+ assignment_weight=0.4,
+ exams=[invalid_item],
+ assignments=[ItemAvaliacao(name="T1", weight=0.4)],
+ courses={"ECM": 2024},
+ )
+ _assert_single_error(
+ exc_info.value.errors(),
+ type_="model_type",
+ loc=("exams", 0),
+ input_=invalid_item,
+ )
+
+ def test_disciplina_assignments_invalido(self):
+ invalid_item = ["não é ItemAvaliacao"]
+ with pytest.raises(ValidationError) as exc_info:
+ Disciplina(
+ course="ECM",
+ name="Engenharia de Computação",
+ code="ECM101",
+ period="2024.1",
+ exam_weight=0.6,
+ assignment_weight=0.4,
+ exams=[ItemAvaliacao(name="P1", weight=0.6)],
+ assignments=[invalid_item],
+ courses={"ECM": 2024},
+ )
+ _assert_single_error(
+ exc_info.value.errors(),
+ type_="model_type",
+ loc=("assignments", 0),
+ input_=invalid_item,
+ )
+
+ def test_disciplina_courses_invalido(self):
+ with pytest.raises(ValidationError) as exc_info:
+ Disciplina(
+ course="ECM",
+ name="Engenharia de Computação",
+ code="ECM101",
+ period="2024.1",
+ exam_weight=0.6,
+ assignment_weight=0.4,
+ exams=[ItemAvaliacao(name="P1", weight=0.6)],
+ assignments=[ItemAvaliacao(name="T1", weight=0.4)],
+ courses={"ECM": ["não é int"]},
+ )
+ _assert_single_error(
+ exc_info.value.errors(),
+ type_="int_type",
+ loc=("courses", "ECM"),
+ input_=["não é int"],
+ )
+ assert "integer" in exc_info.value.errors()[0]["msg"].lower()
diff --git a/tests/shared/infra/external/dynamo/test_single_table_keys.py b/tests/shared/infra/external/dynamo/test_single_table_keys.py
new file mode 100644
index 0000000..1ededb5
--- /dev/null
+++ b/tests/shared/infra/external/dynamo/test_single_table_keys.py
@@ -0,0 +1,45 @@
+from src.shared.infra.external.dynamo.academic_catalog.academic_catalog_naming import physical_table_name
+from src.shared.infra.external.dynamo.academic_catalog.single_table_keys import (
+ GLOBAL_OWNER,
+ SK_ENTITY_RECORD,
+ EntityKind,
+ build_partition_key,
+ normalize_owner_id,
+ strip_dynamo_metadata,
+)
+
+
+def test_normalize_owner_id_none_vai_para_global():
+ assert normalize_owner_id(None) == GLOBAL_OWNER
+ assert normalize_owner_id("") == GLOBAL_OWNER
+ assert normalize_owner_id(" ") == GLOBAL_OWNER
+
+
+def test_normalize_owner_id_remove_hash():
+ assert normalize_owner_id("a#b") == "a_b"
+
+
+def test_build_partition_key():
+ assert (
+ build_partition_key(GLOBAL_OWNER, EntityKind.CURSO, "ECM")
+ == "GLOBAL#CURSO#ECM"
+ )
+ assert (
+ build_partition_key("u1", EntityKind.DISCIPLINA, "P1")
+ == "u1#DISCIPLINA#P1"
+ )
+
+
+def test_sk_fixa_metadata():
+ assert SK_ENTITY_RECORD == "METADATA"
+
+
+def test_strip_dynamo_metadata():
+ assert strip_dynamo_metadata(
+ {"pk": "x", "sk": "y", "entity_type": "CURSO", "nome": "N"}
+ ) == {"nome": "N"}
+
+
+def test_physical_table_name_alinha_cdk():
+ assert physical_table_name("TEST") == "DevMediasAcademicCatalogTable-test"
+ assert physical_table_name("Dev") == "DevMediasAcademicCatalogTable-dev"
diff --git a/tests/shared/infra/repositories/test_curso_repository_dynamo.py b/tests/shared/infra/repositories/test_curso_repository_dynamo.py
new file mode 100644
index 0000000..24f8e86
--- /dev/null
+++ b/tests/shared/infra/repositories/test_curso_repository_dynamo.py
@@ -0,0 +1,153 @@
+import os
+import socket
+import uuid
+
+import pytest
+
+pytest.importorskip("boto3")
+
+from src.shared.domain.entities.curso import Curso
+from src.shared.infra.repositories.curso_repository_dynamo import CursoRepositoryDynamo
+from src.shared.infra.repositories.curso_repository_mock import CursoRepositoryMock
+
+
+def _configure_test_env() -> None:
+ os.environ["STAGE"] = "TEST"
+ port = os.environ.get("DYNAMO_HOST_PORT", "8000")
+ os.environ.setdefault("ENDPOINT_URL", f"http://127.0.0.1:{port}")
+ os.environ.setdefault("ACADEMIC_CATALOG_TABLE_NAME", "DevMediasAcademicCatalogTable-test")
+
+
+def _unique_codigo(prefix: str = "DYN") -> str:
+ return f"{prefix}-{uuid.uuid4().hex[:12].upper()}"
+
+
+def _clone_with_codigo(source: Curso, código: str) -> Curso:
+ return Curso(código=código, nome=source.nome)
+
+
+IN_GITHUB_ACTIONS = os.getenv("GITHUB_ACTIONS", "false").lower() == "true"
+
+
+def _dynamo_local_listening(host: str = "127.0.0.1", port: int | None = None) -> bool:
+ port = int(os.environ.get("DYNAMO_HOST_PORT", "8000")) if port is None else port
+ try:
+ with socket.create_connection((host, port), timeout=0.4):
+ return True
+ except OSError:
+ return False
+
+
+_SKIP_DYNAMO = IN_GITHUB_ACTIONS or not _dynamo_local_listening()
+
+
+@pytest.mark.skipif(
+ _SKIP_DYNAMO,
+ reason="GitHub Actions ou DynamoDB Local (127.0.0.1:8000) indisponível",
+)
+class TestCursoRepositoryDynamo:
+ def test_get_all_cursos_matches_mock_after_seed(self):
+ _configure_test_env()
+ dynamo = CursoRepositoryDynamo()
+ mock = CursoRepositoryMock()
+ for c in mock.cursos:
+ dynamo.create_curso(c)
+
+ resp = dynamo.get_all_cursos()
+ mock_resp = mock.get_all_cursos()
+
+ assert resp is not None
+ assert isinstance(resp, list)
+
+ codes_mock = sorted(c.código for c in mock_resp)
+ codes_dynamo = sorted(c.código for c in resp if c.código in set(codes_mock))
+ assert codes_dynamo == codes_mock
+
+ def test_create_curso(self):
+ _configure_test_env()
+ dynamo = CursoRepositoryDynamo()
+ mock = CursoRepositoryMock()
+ sample = _clone_with_codigo(mock.cursos[0], _unique_codigo("CRT"))
+
+ resp = dynamo.create_curso(sample)
+ assert resp is not None
+ assert resp.código == sample.código
+ assert resp.nome == sample.nome
+ assert resp.nome == mock.cursos[0].nome
+
+ def test_create_curso_invalid(self):
+ _configure_test_env()
+ dynamo = CursoRepositoryDynamo()
+ with pytest.raises(Exception) as excinfo:
+ dynamo.create_curso(None) # type: ignore[arg-type]
+ assert "attribute" in str(excinfo.value).lower()
+
+ def test_get_curso(self):
+ _configure_test_env()
+ dynamo = CursoRepositoryDynamo()
+ mock = CursoRepositoryMock()
+ código = _unique_codigo("GET")
+ to_save = _clone_with_codigo(mock.cursos[0], código)
+ dynamo.create_curso(to_save)
+
+ resp = dynamo.get_curso(código)
+ assert resp is not None
+ assert resp.model_dump(mode="json") == to_save.model_dump(mode="json")
+
+ def test_get_curso_not_found(self):
+ _configure_test_env()
+ dynamo = CursoRepositoryDynamo()
+ assert dynamo.get_curso("non-existent-code-xyz") is None
+
+ def test_update_curso(self):
+ _configure_test_env()
+ dynamo = CursoRepositoryDynamo()
+ mock = CursoRepositoryMock()
+ código = _unique_codigo("UPD")
+ base = _clone_with_codigo(mock.cursos[0], código)
+ dynamo.create_curso(base)
+
+ updated = Curso(código=base.código, nome="Nome atualizado pós-PUT")
+ resp = dynamo.update_curso(updated)
+ assert resp is not None
+ assert resp.nome == "Nome atualizado pós-PUT"
+ loaded = dynamo.get_curso(código)
+ assert loaded is not None
+ assert loaded.model_dump(mode="json") == updated.model_dump(mode="json")
+
+ def test_update_curso_not_found(self):
+ _configure_test_env()
+ dynamo = CursoRepositoryDynamo()
+ ghost = Curso(código="no-such-code-999", nome="Y")
+ assert dynamo.update_curso(ghost) is None
+
+ def test_delete_curso(self):
+ _configure_test_env()
+ dynamo = CursoRepositoryDynamo()
+ mock = CursoRepositoryMock()
+ código = _unique_codigo("DEL")
+ to_save = _clone_with_codigo(mock.cursos[1], código)
+ dynamo.create_curso(to_save)
+
+ resp = dynamo.delete_curso(código)
+ assert resp is not None
+ assert resp.código == código
+ assert dynamo.get_curso(código) is None
+
+ def test_delete_curso_not_found(self):
+ _configure_test_env()
+ dynamo = CursoRepositoryDynamo()
+ assert dynamo.delete_curso("non-existent-delete-code") is None
+
+ def test_get_all_cursos_escopo_global_nao_aparece_para_usuario(self):
+ _configure_test_env()
+ código = _unique_codigo("SCOPE")
+ global_repo = CursoRepositoryDynamo(user_id=None)
+ user_repo = CursoRepositoryDynamo(user_id="alice")
+ global_repo.create_curso(Curso(código=código, nome="Só GLOBAL"))
+
+ user_cursos = user_repo.get_all_cursos()
+ assert all(c.código != código for c in user_cursos)
+
+ global_cursos = global_repo.get_all_cursos()
+ assert any(c.código == código for c in global_cursos)
diff --git a/tests/shared/infra/repositories/test_curso_repository_mock.py b/tests/shared/infra/repositories/test_curso_repository_mock.py
new file mode 100644
index 0000000..3404625
--- /dev/null
+++ b/tests/shared/infra/repositories/test_curso_repository_mock.py
@@ -0,0 +1,71 @@
+import pytest
+
+from src.shared.domain.entities.curso import Curso
+from src.shared.infra.repositories.curso_repository_mock import CursoRepositoryMock
+
+
+# Cada teste começa do zero: repositório novo,
+# com os mesmos cursos de exemplo,
+# para não misturar um teste com o outro.
+@pytest.fixture
+def repo() -> CursoRepositoryMock:
+ return CursoRepositoryMock()
+
+
+class TestCursoRepositoryMockGet:
+ def test_get_curso_existente(self, repo: CursoRepositoryMock):
+ c = repo.get_curso("ECM")
+ assert c is not None
+ assert c.código == "ECM"
+ assert c.nome == "Engenharia de Computação"
+
+ def test_get_curso_inexistente(self, repo: CursoRepositoryMock):
+ assert repo.get_curso("NAO_EXISTE") is None
+
+
+class TestCursoRepositoryMockGetAll:
+ def test_get_all_cursos_tamanho_inicial(self, repo: CursoRepositoryMock):
+ all_c = repo.get_all_cursos()
+ assert len(all_c) == 3
+ codigos = {c.código for c in all_c}
+ assert codigos == {"ECM", "ADM", "CIC"}
+
+ def test_get_all_cursos_retorno_nao_aliasing(self, repo: CursoRepositoryMock):
+ first = repo.get_all_cursos()
+ second = repo.get_all_cursos()
+ assert first is not second
+ assert first == second
+
+
+class TestCursoRepositoryMockCreate:
+ def test_create_curso_insere_e_retorna(self, repo: CursoRepositoryMock):
+ novo = Curso(código="DIR", nome="Direito")
+ out = repo.create_curso(novo)
+ assert out is novo
+ assert repo.get_curso("DIR") is novo
+ assert len(repo.get_all_cursos()) == 4
+
+
+class TestCursoRepositoryMockUpdate:
+ def test_update_curso_put_substitui(self, repo: CursoRepositoryMock):
+ atualizado = Curso(código="ECM", nome="Eng. de Computação (atualizado)")
+ out = repo.update_curso(atualizado)
+ assert out is atualizado
+ loaded = repo.get_curso("ECM")
+ assert loaded is not None
+ assert loaded.nome == "Eng. de Computação (atualizado)"
+
+ def test_update_curso_codigo_inexistente(self, repo: CursoRepositoryMock):
+ assert repo.update_curso(Curso(código="X0", nome="Nome")) is None
+
+
+class TestCursoRepositoryMockDelete:
+ def test_delete_curso_remove_e_retorna(self, repo: CursoRepositoryMock):
+ removed = repo.delete_curso("ADM")
+ assert removed is not None
+ assert removed.código == "ADM"
+ assert repo.get_curso("ADM") is None
+ assert len(repo.get_all_cursos()) == 2
+
+ def test_delete_curso_inexistente(self, repo: CursoRepositoryMock):
+ assert repo.delete_curso("NAO_EXISTE") is None
diff --git a/tests/shared/infra/repositories/test_disciplina_repository_dynamo.py b/tests/shared/infra/repositories/test_disciplina_repository_dynamo.py
new file mode 100644
index 0000000..d458ded
--- /dev/null
+++ b/tests/shared/infra/repositories/test_disciplina_repository_dynamo.py
@@ -0,0 +1,185 @@
+import os
+import socket
+import uuid
+
+import pytest
+
+pytest.importorskip("boto3")
+
+from src.shared.domain.entities.disciplina import Disciplina, ItemAvaliacao
+from src.shared.infra.repositories.disciplina_repository_dynamo import DisciplinaRepositoryDynamo
+from src.shared.infra.repositories.disciplina_repository_mock import DisciplinaRepositoryMock
+
+
+def _configure_test_env() -> None:
+ os.environ["STAGE"] = "TEST"
+ port = os.environ.get("DYNAMO_HOST_PORT", "8000")
+ os.environ.setdefault("ENDPOINT_URL", f"http://127.0.0.1:{port}")
+ os.environ.setdefault("ACADEMIC_CATALOG_TABLE_NAME", "DevMediasAcademicCatalogTable-test")
+
+
+def _unique_code(prefix: str = "DYN") -> str:
+ return f"{prefix}-{uuid.uuid4().hex[:12].upper()}"
+
+
+def _clone_with_code(source: Disciplina, code: str) -> Disciplina:
+ return Disciplina(
+ course=source.course,
+ name=source.name,
+ code=code,
+ period=source.period,
+ exam_weight=source.exam_weight,
+ assignment_weight=source.assignment_weight,
+ exams=list(source.exams),
+ assignments=list(source.assignments),
+ courses=dict(source.courses),
+ )
+
+
+IN_GITHUB_ACTIONS = os.getenv("GITHUB_ACTIONS", "false").lower() == "true"
+
+
+def _dynamo_local_listening(host: str = "127.0.0.1", port: int | None = None) -> bool:
+ port = int(os.environ.get("DYNAMO_HOST_PORT", "8000")) if port is None else port
+ try:
+ with socket.create_connection((host, port), timeout=0.4):
+ return True
+ except OSError:
+ return False
+
+
+_SKIP_DYNAMO = IN_GITHUB_ACTIONS or not _dynamo_local_listening()
+
+
+@pytest.mark.skipif(
+ _SKIP_DYNAMO,
+ reason="GitHub Actions ou DynamoDB Local (127.0.0.1:8000) indisponível",
+)
+class TestDisciplinaRepositoryDynamo:
+ def test_get_all_disciplinas_matches_mock_after_seed(self):
+ _configure_test_env()
+ dynamo = DisciplinaRepositoryDynamo()
+ mock = DisciplinaRepositoryMock()
+ for d in mock.disciplinas:
+ dynamo.create_disciplina(d)
+
+ resp = dynamo.get_all_disciplinas()
+ mock_resp = mock.get_all_disciplinas()
+
+ assert resp is not None
+ assert isinstance(resp, list)
+
+ codes_mock = sorted(d.code for d in mock_resp)
+ codes_dynamo = sorted(d.code for d in resp if d.code in set(codes_mock))
+ assert codes_dynamo == codes_mock
+
+ def test_create_disciplina(self):
+ _configure_test_env()
+ dynamo = DisciplinaRepositoryDynamo()
+ mock = DisciplinaRepositoryMock()
+ sample = _clone_with_code(mock.disciplinas[0], _unique_code("CRT"))
+
+ resp = dynamo.create_disciplina(sample)
+ assert resp is not None
+ assert resp.code == sample.code
+ assert resp.name == sample.name
+ assert resp.course == mock.disciplinas[0].course
+
+ def test_create_disciplina_invalid(self):
+ _configure_test_env()
+ dynamo = DisciplinaRepositoryDynamo()
+ with pytest.raises(Exception) as excinfo:
+ dynamo.create_disciplina(None) # type: ignore[arg-type]
+ assert "attribute" in str(excinfo.value).lower()
+
+ def test_get_disciplina(self):
+ _configure_test_env()
+ dynamo = DisciplinaRepositoryDynamo()
+ mock = DisciplinaRepositoryMock()
+ code = _unique_code("GET")
+ to_save = _clone_with_code(mock.disciplinas[0], code)
+ dynamo.create_disciplina(to_save)
+
+ resp = dynamo.get_disciplina(code)
+ assert resp is not None
+ assert resp.model_dump(mode="json") == to_save.model_dump(mode="json")
+
+ def test_get_disciplina_not_found(self):
+ _configure_test_env()
+ dynamo = DisciplinaRepositoryDynamo()
+ assert dynamo.get_disciplina("non-existent-code-xyz") is None
+
+ def test_update_disciplina(self):
+ _configure_test_env()
+ dynamo = DisciplinaRepositoryDynamo()
+ mock = DisciplinaRepositoryMock()
+ code = _unique_code("UPD")
+ base = _clone_with_code(mock.disciplinas[0], code)
+ dynamo.create_disciplina(base)
+
+ updated = Disciplina(
+ course=base.course,
+ name="Nome atualizado pós-PUT",
+ code=base.code,
+ period=base.period,
+ exam_weight=0.55,
+ assignment_weight=0.45,
+ exams=[ItemAvaliacao(name="P1", weight=0.55)],
+ assignments=[ItemAvaliacao(name="T1", weight=0.45)],
+ courses={"ECM": 2},
+ )
+ resp = dynamo.update_disciplina(updated)
+ assert resp is not None
+ assert resp.name == "Nome atualizado pós-PUT"
+ loaded = dynamo.get_disciplina(code)
+ assert loaded is not None
+ assert loaded.model_dump(mode="json") == updated.model_dump(mode="json")
+
+ def test_update_disciplina_not_found(self):
+ _configure_test_env()
+ dynamo = DisciplinaRepositoryDynamo()
+ ghost = Disciplina(
+ course="X",
+ name="Y",
+ code="no-such-code-999",
+ period="2024.1",
+ exam_weight=0.5,
+ assignment_weight=0.5,
+ exams=[ItemAvaliacao(name="P1", weight=0.5)],
+ assignments=[ItemAvaliacao(name="T1", weight=0.5)],
+ courses={"X": 1},
+ )
+ assert dynamo.update_disciplina(ghost) is None
+
+ def test_delete_disciplina(self):
+ _configure_test_env()
+ dynamo = DisciplinaRepositoryDynamo()
+ mock = DisciplinaRepositoryMock()
+ code = _unique_code("DEL")
+ to_save = _clone_with_code(mock.disciplinas[1], code)
+ dynamo.create_disciplina(to_save)
+
+ resp = dynamo.delete_disciplina(code)
+ assert resp is not None
+ assert resp.code == code
+ assert dynamo.get_disciplina(code) is None
+
+ def test_delete_disciplina_not_found(self):
+ _configure_test_env()
+ dynamo = DisciplinaRepositoryDynamo()
+ assert dynamo.delete_disciplina("non-existent-delete-code") is None
+
+ def test_get_all_disciplinas_escopo_global_nao_aparece_para_usuario(self):
+ _configure_test_env()
+ code = _unique_code("SCOPE")
+ global_repo = DisciplinaRepositoryDynamo(user_id=None)
+ user_repo = DisciplinaRepositoryDynamo(user_id="bob")
+ mock = DisciplinaRepositoryMock()
+ to_save = _clone_with_code(mock.disciplinas[0], code)
+ global_repo.create_disciplina(to_save)
+
+ user_list = user_repo.get_all_disciplinas()
+ assert all(d.code != code for d in user_list)
+
+ global_list = global_repo.get_all_disciplinas()
+ assert any(d.code == code for d in global_list)
diff --git a/tests/shared/infra/repositories/test_disciplina_repository_mock.py b/tests/shared/infra/repositories/test_disciplina_repository_mock.py
new file mode 100644
index 0000000..05b8218
--- /dev/null
+++ b/tests/shared/infra/repositories/test_disciplina_repository_mock.py
@@ -0,0 +1,87 @@
+import pytest
+
+from src.shared.domain.entities.disciplina import Disciplina, ItemAvaliacao
+from src.shared.infra.repositories.disciplina_repository_mock import DisciplinaRepositoryMock
+
+# Função auxiliar para criar uma disciplina com valores padrão.
+def _disciplina(code: str, *, name: str = "Nome") -> Disciplina:
+ return Disciplina(
+ course="ECM",
+ name=name,
+ code=code,
+ period="2024.1",
+ exam_weight=0.5,
+ assignment_weight=0.5,
+ exams=[ItemAvaliacao(name="P1", weight=0.5)],
+ assignments=[ItemAvaliacao(name="T1", weight=0.5)],
+ courses={"ECM": 1},
+ )
+
+
+# Cada teste começa do zero: repositório novo,
+# com as mesmas disciplinas de exemplo,
+# para não misturar um teste com outro.
+@pytest.fixture
+def repo() -> DisciplinaRepositoryMock:
+ return DisciplinaRepositoryMock()
+
+
+class TestDisciplinaRepositoryMockGet:
+ def test_get_disciplina_existente(self, repo: DisciplinaRepositoryMock):
+ d = repo.get_disciplina("ECM101")
+ assert d is not None
+ assert d.code == "ECM101"
+ assert d.name == "Engenharia de Computação"
+
+ def test_get_disciplina_inexistente(self, repo: DisciplinaRepositoryMock):
+ assert repo.get_disciplina("NAO_EXISTE") is None
+
+
+class TestDisciplinaRepositoryMockGetAll:
+ def test_get_all_disciplinas_tamanho_inicial(self, repo: DisciplinaRepositoryMock):
+ all_d = repo.get_all_disciplinas()
+ assert len(all_d) == 4
+ codes = {d.code for d in all_d}
+ assert codes == {"ECM101", "ECM102", "ECM103", "ECM104"}
+
+ def test_get_all_disciplinas_retorno_nao_aliasing(
+ self, repo: DisciplinaRepositoryMock
+ ):
+ first = repo.get_all_disciplinas()
+ second = repo.get_all_disciplinas()
+ assert first is not second
+ assert first == second
+
+
+class TestDisciplinaRepositoryMockCreate:
+ def test_create_disciplina_insere_e_retorna(self, repo: DisciplinaRepositoryMock):
+ nova = _disciplina("ECM999", name="Nova")
+ out = repo.create_disciplina(nova)
+ assert out is nova
+ assert repo.get_disciplina("ECM999") is nova
+ assert len(repo.get_all_disciplinas()) == 5
+
+
+class TestDisciplinaRepositoryMockUpdate:
+ def test_update_disciplina_put_substitui(self, repo: DisciplinaRepositoryMock):
+ atualizada = _disciplina("ECM101", name="Nome atualizado")
+ out = repo.update_disciplina(atualizada)
+ assert out is atualizada
+ loaded = repo.get_disciplina("ECM101")
+ assert loaded is not None
+ assert loaded.name == "Nome atualizado"
+
+ def test_update_disciplina_codigo_inexistente(self, repo: DisciplinaRepositoryMock):
+ assert repo.update_disciplina(_disciplina("X0")) is None
+
+
+class TestDisciplinaRepositoryMockDelete:
+ def test_delete_disciplina_remove_e_retorna(self, repo: DisciplinaRepositoryMock):
+ removed = repo.delete_disciplina("ECM102")
+ assert removed is not None
+ assert removed.code == "ECM102"
+ assert repo.get_disciplina("ECM102") is None
+ assert len(repo.get_all_disciplinas()) == 3
+
+ def test_delete_disciplina_inexistente(self, repo: DisciplinaRepositoryMock):
+ assert repo.delete_disciplina("NAO_EXISTE") is None