diff --git a/.github/workflows/CD.yml b/.github/workflows/CD.yml index bc20464..a382467 100644 --- a/.github/workflows/CD.yml +++ b/.github/workflows/CD.yml @@ -22,17 +22,17 @@ 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: | if [[ "${{ github.ref_name }}" == "dev" ]]; then echo "AWS_ACCOUNT_ID=${{ secrets.AWS_ACCOUNT_ID_DEV }}" >> $GITHUB_ENV elif [[ "${{ github.ref_name }}" == "homolog" ]]; then - echo "AWS_ACCOUNT_ID=${{ secrets.AWS_ACCOUNT_ID_HOMOLOG }}" >> $GITHUB_ENV + echo "AWS_ACCOUNT_ID=${{ secrets.AWS_ACCOUNT_ID_HOML }}" >> $GITHUB_ENV elif [[ "${{ github.ref_name }}" == "prod" ]]; then echo "AWS_ACCOUNT_ID=${{ secrets.AWS_ACCOUNT_ID_PROD }}" >> $GITHUB_ENV else @@ -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 48cc1c4..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.INFO, - data_trace_enabled=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/lambda_function/contact_us/app/send_email_feedback_presenter.py b/src/modules/contact_us/app/contact_us_presenter.py similarity index 100% rename from lambda_function/contact_us/app/send_email_feedback_presenter.py rename to src/modules/contact_us/app/contact_us_presenter.py 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 100% rename from lambda_function/contact_us/app/entities/email.py rename to src/modules/contact_us/app/entities/email.py 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