From fe44aaa44d591762b81f46a2ea7538d202d143d5 Mon Sep 17 00:00:00 2001 From: magajh <maria.jaimes@edunext.co> Date: Tue, 14 Jan 2025 08:12:33 -0400 Subject: [PATCH 01/60] feat: add API to collect data --- eox_core/api/data/data_collector/__init__.py | 0 eox_core/api/data/data_collector/tasks.py | 72 ++++++++++++++++ eox_core/api/data/data_collector/urls.py | 11 +++ eox_core/api/data/data_collector/utils.py | 82 ++++++++++++++++++ .../api/data/data_collector/v1/__init__.py | 0 .../api/data/data_collector/v1/serializers.py | 50 +++++++++++ eox_core/api/data/data_collector/v1/urls.py | 9 ++ eox_core/api/data/data_collector/v1/views.py | 84 +++++++++++++++++++ eox_core/api/data/v1/urls.py | 1 + eox_core/utils.py | 28 +++++++ 10 files changed, 337 insertions(+) create mode 100644 eox_core/api/data/data_collector/__init__.py create mode 100644 eox_core/api/data/data_collector/tasks.py create mode 100644 eox_core/api/data/data_collector/urls.py create mode 100644 eox_core/api/data/data_collector/utils.py create mode 100644 eox_core/api/data/data_collector/v1/__init__.py create mode 100644 eox_core/api/data/data_collector/v1/serializers.py create mode 100644 eox_core/api/data/data_collector/v1/urls.py create mode 100644 eox_core/api/data/data_collector/v1/views.py diff --git a/eox_core/api/data/data_collector/__init__.py b/eox_core/api/data/data_collector/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/eox_core/api/data/data_collector/tasks.py b/eox_core/api/data/data_collector/tasks.py new file mode 100644 index 000000000..894e687ec --- /dev/null +++ b/eox_core/api/data/data_collector/tasks.py @@ -0,0 +1,72 @@ +""" +Async task for generating reports by executing database queries +and posting the results to the Shipyard API. +""" + +from celery import shared_task, Task +from eox_core.api.data.data_collector.utils import execute_query, post_data_to_api, serialize_data +import yaml +import logging + +logger = logging.getLogger(__name__) + + +class ReportTask(Task): + """ + Custom task class to handle report generation with an on_failure hook. + """ + def on_failure(self, exc, task_id, args, kwargs, einfo): + """ + Called when the task has exhausted all retries. + + Args: + exc (Exception): The exception raised. + task_id (str): The ID of the failed task. + args (tuple): The positional arguments for the task. + kwargs (dict): The keyword arguments for the task. + einfo (ExceptionInfo): Exception information. + """ + logger.error(f"Task {task_id} failed after retries. Exception: {exc}. Could not collect data.") + + +@shared_task(bind=True) +def generate_report(self, destination_url, query_file_content, token_generation_url, current_host): + """ + Async task to generate a report: + 1. Reads queries from the provided query file content. + 2. Executes each query against the database. + 3. Sends the results to the Shipyard API. + + Args: + self (Task): The Celery task instance. + query_file_content (str): The content of the query file in YAML format. + + Raises: + Retry: If an error occurs, the task retries up to 3 times with a 60-second delay. + """ + try: + queries = yaml.safe_load(query_file_content).get("queries", []) + if not queries: + logger.warning("No queries found in the provided file. Task will exit.") + return + + report_data = {} + for query in queries: + query_name = query.get("name") + query_sql = query.get("query") + logger.info(f"Executing query: {query_name}") + try: + result = execute_query(query_sql) + + serialized_result = serialize_data(result) + report_data[query_name] = serialized_result + except Exception as e: + logger.error(f"Failed to execute query '{query_name}': {e}") + continue + + post_data_to_api(destination_url, report_data, token_generation_url, current_host) + + logger.info("Report generation task completed successfully.") + except Exception as e: + logger.error(f"An error occurred in the report generation task: {e}. Retrying") + raise self.retry(exc=e, countdown=60, max_retries=3) diff --git a/eox_core/api/data/data_collector/urls.py b/eox_core/api/data/data_collector/urls.py new file mode 100644 index 000000000..2d71ac519 --- /dev/null +++ b/eox_core/api/data/data_collector/urls.py @@ -0,0 +1,11 @@ +""" +URLs for the Microsite API +""" +from django.urls import include, re_path + +app_name = 'eox_core' # pylint: disable=invalid-name + + +urlpatterns = [ # pylint: disable=invalid-name + re_path(r'^v1/', include('eox_core.api.data.data_collector.v1.urls', namespace='eox-data-api-collector-v1')), +] diff --git a/eox_core/api/data/data_collector/utils.py b/eox_core/api/data/data_collector/utils.py new file mode 100644 index 000000000..d51e81a23 --- /dev/null +++ b/eox_core/api/data/data_collector/utils.py @@ -0,0 +1,82 @@ +""" +Utility functions for report generation, including query execution +and integration with the Shipyard API. +""" + +import yaml +from django.db import connection +import requests +from django.conf import settings +from datetime import datetime +import logging + +from eox_core.utils import get_access_token + +logger = logging.getLogger(__name__) + + +def execute_query(sql_query): + """ + Execute a raw SQL query and return the results in a structured format. + + Args: + sql_query (str): The raw SQL query to execute. + + Returns: + list or dict: Structured query results. + """ + with connection.cursor() as cursor: + cursor.execute(sql_query) + rows = cursor.fetchall() + # If the query returns more than one column, return rows as is. + if cursor.description: + columns = [col[0] for col in cursor.description] + if len(columns) == 1: + return [row[0] for row in rows] # Return single-column results as a list + return [dict(zip(columns, row)) for row in rows] # Multi-column results as a list of dicts + return rows + + +def serialize_data(data): + """ + Recursively serialize data, converting datetime objects to strings. + + Args: + data (dict or list): The data to serialize. + + Returns: + dict or list: The serialized data with datetime objects as strings. + """ + if isinstance(data, dict): + return {key: serialize_data(value) for key, value in data.items()} + elif isinstance(data, list): + return [serialize_data(item) for item in data] + elif isinstance(data, datetime): + return data.isoformat() + return data + + +def post_data_to_api(api_url, report_data, token_generation_url, current_host): + """ + Sends the generated report data to the Shipyard API. + + Args: + report_data (dict): The data to be sent to the Shipyard API. + + Raises: + Exception: If the API request fails. + """ + token = get_access_token( + token_generation_url, + settings.EOX_CORE_SAVE_DATA_API_CLIENT_ID, + settings.EOX_CORE_SAVE_DATA_API_CLIENT_SECRET, + ) + headers = { + "Authorization": f"Bearer {token}", + "Content-Type": "application/json", + } + payload = {"instance_domain":current_host, "data": report_data} + response = requests.post(api_url, json=payload, headers=headers) + + if not response.ok: + raise Exception(f"Failed to post data to Shipyard API: {response.content}") diff --git a/eox_core/api/data/data_collector/v1/__init__.py b/eox_core/api/data/data_collector/v1/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/eox_core/api/data/data_collector/v1/serializers.py b/eox_core/api/data/data_collector/v1/serializers.py new file mode 100644 index 000000000..5109b26e0 --- /dev/null +++ b/eox_core/api/data/data_collector/v1/serializers.py @@ -0,0 +1,50 @@ +from rest_framework import serializers + +class DataCollectorSerializer(serializers.Serializer): + """ + Serializer for the DataCollectorView API. + + Validates the incoming payload for the data collection endpoint. + + Fields: + query_file_content (str): The content of the query file in YAML format. + query_file_url (str): A public URL pointing to the query file. + destination_url (str): The URL where the results should be sent. + """ + query_file_content = serializers.CharField( + required=False, + allow_blank=True, + help_text="Content of the query file in YAML format." + ) + query_file_url = serializers.URLField( + required=False, + allow_blank=True, + help_text="Public URL pointing to the query file." + ) + destination_url = serializers.URLField( + required=True, + help_text="The API endpoint where the results will be sent." + ) + token_generation_url = serializers.URLField( + required=True, + help_text="The API endpoint where the results will be sent." + ) + + def validate(self, data): + """ + Custom validation to ensure either 'query_file_content' or 'query_file_url' is provided. + + Args: + data (dict): The validated data. + + Returns: + dict: The validated data if valid. + + Raises: + serializers.ValidationError: If neither 'query_file_content' nor 'query_file_url' is provided. + """ + if not data.get("query_file_content") and not data.get("query_file_url"): + raise serializers.ValidationError( + "Either 'query_file_content' or 'query_file_url' must be provided." + ) + return data diff --git a/eox_core/api/data/data_collector/v1/urls.py b/eox_core/api/data/data_collector/v1/urls.py new file mode 100644 index 000000000..a815d1d03 --- /dev/null +++ b/eox_core/api/data/data_collector/v1/urls.py @@ -0,0 +1,9 @@ +"""_""" +from django.urls import path +from eox_core.api.data.data_collector.v1.views import DataCollectorView + +app_name = "data_collector" + +urlpatterns = [ + path("collect-data/", DataCollectorView.as_view(), name="collect_data"), +] diff --git a/eox_core/api/data/data_collector/v1/views.py b/eox_core/api/data/data_collector/v1/views.py new file mode 100644 index 000000000..84b0cafe4 --- /dev/null +++ b/eox_core/api/data/data_collector/v1/views.py @@ -0,0 +1,84 @@ +import logging +import requests +import yaml +from rest_framework.views import APIView +from rest_framework.response import Response +from rest_framework import status, permissions +from django.conf import settings +from eox_core.api.data.data_collector.tasks import generate_report + +from rest_framework.permissions import BasePermission +from rest_framework.authentication import get_authorization_header +from django.conf import settings +from eox_core.api.data.data_collector.v1.serializers import DataCollectorSerializer + +logger = logging.getLogger(__name__) + + +class IsGitHubAction(BasePermission): + """ + Permission class to allow access only if the request contains a valid GitHub Action token. + """ + def has_permission(self, request, view): + auth_header = get_authorization_header(request).decode('utf-8') + auth_token = settings.EOX_CORE_DATA_COLLECT_AUTH_TOKEN + if auth_header and auth_header == f"Bearer {auth_token}": + return True + return False + + +class DataCollectorView(APIView): + """ + API view to handle data collection requests. + + This view: + - Validates input using DataCollectorSerializer. + - Triggers an async task to execute queries and send results to a specified destination. + """ + # Allow JWT Auth + permission_classes = [IsGitHubAction] + + def post(self, request): + """ + Handles POST requests to collect data. + + Args: + request (HttpRequest): The incoming request. + + Returns: + Response: A success or error message. + """ + serializer = DataCollectorSerializer(data=request.data) + + if serializer.is_valid(): + validated_data = serializer.validated_data + query_file_content = validated_data.get("query_file_content") + query_file_url = validated_data.get("query_file_url") + destination_url = validated_data.get("destination_url") + token_generation_url = validated_data.get("token_generation_url") + current_host = request.get_host() #Remove trailing slash and http + + # If the query file content is not provided, fetch it from the URL + if not query_file_content and query_file_url: + try: + response = requests.get(query_file_url) + if response.status_code == 200: + query_file_content = response.text + else: + return Response( + {"error": "Failed to fetch query file from the provided URL."}, + status=status.HTTP_400_BAD_REQUEST + ) + except Exception as e: + return Response( + {"error": f"An error occurred while fetching the query file: {str(e)}"}, + status=status.HTTP_400_BAD_REQUEST + ) + + generate_report.delay(destination_url, query_file_content, token_generation_url, current_host) + return Response( + {"message": "Data collection task has been initiated successfully."}, + status=status.HTTP_202_ACCEPTED + ) + + return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST) diff --git a/eox_core/api/data/v1/urls.py b/eox_core/api/data/v1/urls.py index 8ca7a9f2d..292589f20 100644 --- a/eox_core/api/data/v1/urls.py +++ b/eox_core/api/data/v1/urls.py @@ -11,4 +11,5 @@ urlpatterns = [ # pylint: disable=invalid-name re_path(r'^v1/', include((ROUTER.urls, 'eox_core'), namespace='eox-data-api-v1')), re_path(r'^v1/tasks/(?P<task_id>.*)$', CeleryTasksStatus.as_view(), name="celery-data-api-tasks"), + re_path(r'^', include('eox_core.api.data.data_collector.urls', namespace='eox-data-api-collector')), ] diff --git a/eox_core/utils.py b/eox_core/utils.py index 8a3764d0c..0f7b5a346 100644 --- a/eox_core/utils.py +++ b/eox_core/utils.py @@ -5,6 +5,7 @@ import datetime import hashlib import re +import requests from django.conf import settings from django.contrib.sites.models import Site @@ -165,3 +166,30 @@ def get_or_create_site_from_oauth_app_uris(redirect_uris): return sites_qs.first() return Site.objects.create(domain=domain, name=domain) + + +def get_access_token( + token_url, + client_id, + client_secret, + grant_type="client_credentials" + ): + """ + Fetch an access token from a service OAuth2 API. + + Returns: + str: The access token. + Raises: + Exception: If the token request fails. + """ + response = requests.post( + token_url, + data={ + "grant_type": grant_type, + "client_id": client_id, + "client_secret": client_secret, + }, + ) + if response.ok: + return response.json().get("access_token") + raise Exception("Failed to obtain access token for API.") From 53c01c689b06d19e0fe843811a5d673fc5f9f12d Mon Sep 17 00:00:00 2001 From: magajh <maria.jaimes@edunext.co> Date: Wed, 22 Jan 2025 01:42:50 -0400 Subject: [PATCH 02/60] feat: update version and changelog --- CHANGELOG.md | 6 ++++++ eox_core/__init__.py | 2 +- setup.cfg | 2 +- 3 files changed, 8 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index b19d2c505..81d134526 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -11,6 +11,12 @@ Please do not update the unreleased notes. <!-- Content should be placed here --> +## [v11.2.0](https://github.com/eduNEXT/eox-core/compare/v11.1.0...v11.2.0) - (2025-01-20) + +### Added + +- API to collect data and generate reports. + ## [v11.1.0](https://github.com/eduNEXT/eox-core/compare/v11.0.0...v11.1.0) - (2024-11-21) ### Changed diff --git a/eox_core/__init__.py b/eox_core/__init__.py index d3fa81ffd..1ff125f2a 100644 --- a/eox_core/__init__.py +++ b/eox_core/__init__.py @@ -1,4 +1,4 @@ """ Init for main eox-core app """ -__version__ = '11.1.0' +__version__ = '11.2.0' diff --git a/setup.cfg b/setup.cfg index 796ff33d8..82d5ef267 100644 --- a/setup.cfg +++ b/setup.cfg @@ -1,5 +1,5 @@ [bumpversion] -current_version = 11.1.0 +current_version = 11.2.0 commit = False tag = False From 12ed527688eb8b14f786084221bcfaa29c41360c Mon Sep 17 00:00:00 2001 From: magajh <maria.jaimes@edunext.co> Date: Wed, 22 Jan 2025 06:42:49 -0400 Subject: [PATCH 03/60] fix: process results --- eox_core/api/data/data_collector/tasks.py | 5 +++-- eox_core/api/data/data_collector/utils.py | 16 ++++++++++++++++ 2 files changed, 19 insertions(+), 2 deletions(-) diff --git a/eox_core/api/data/data_collector/tasks.py b/eox_core/api/data/data_collector/tasks.py index 894e687ec..00cc7ecc8 100644 --- a/eox_core/api/data/data_collector/tasks.py +++ b/eox_core/api/data/data_collector/tasks.py @@ -4,7 +4,7 @@ """ from celery import shared_task, Task -from eox_core.api.data.data_collector.utils import execute_query, post_data_to_api, serialize_data +from eox_core.api.data.data_collector.utils import execute_query, post_data_to_api, serialize_data, process_query_results import yaml import logging @@ -59,7 +59,8 @@ def generate_report(self, destination_url, query_file_content, token_generation_ result = execute_query(query_sql) serialized_result = serialize_data(result) - report_data[query_name] = serialized_result + processed_result = process_query_results(serialized_result) + report_data[query_name] = processed_result except Exception as e: logger.error(f"Failed to execute query '{query_name}': {e}") continue diff --git a/eox_core/api/data/data_collector/utils.py b/eox_core/api/data/data_collector/utils.py index d51e81a23..e4f7bf9f5 100644 --- a/eox_core/api/data/data_collector/utils.py +++ b/eox_core/api/data/data_collector/utils.py @@ -56,6 +56,22 @@ def serialize_data(data): return data +def process_query_results(raw_result): + """ + Process the raw result of a query. + + Args: + raw_result: The result from the SQL query (list, scalar, or dictionary). + + Returns: + The processed result, extracting scalar values from single-item lists, + or returning the original value for more complex data structures. + """ + if isinstance(raw_result, list) and len(raw_result) == 1: + return raw_result[0] + return raw_result + + def post_data_to_api(api_url, report_data, token_generation_url, current_host): """ Sends the generated report data to the Shipyard API. From 38587d1f353fd38df911a69e2a4b26027680beca Mon Sep 17 00:00:00 2001 From: Luis Felipe Castano <felipe.castano@edunext.co> Date: Mon, 24 Feb 2025 15:08:24 -0500 Subject: [PATCH 04/60] chore: change IsGitHubAction class name --- eox_core/api/data/data_collector/v1/views.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/eox_core/api/data/data_collector/v1/views.py b/eox_core/api/data/data_collector/v1/views.py index 84b0cafe4..f3ec27eb8 100644 --- a/eox_core/api/data/data_collector/v1/views.py +++ b/eox_core/api/data/data_collector/v1/views.py @@ -15,7 +15,7 @@ logger = logging.getLogger(__name__) -class IsGitHubAction(BasePermission): +class DatacollectorPermission(BasePermission): """ Permission class to allow access only if the request contains a valid GitHub Action token. """ @@ -36,7 +36,7 @@ class DataCollectorView(APIView): - Triggers an async task to execute queries and send results to a specified destination. """ # Allow JWT Auth - permission_classes = [IsGitHubAction] + permission_classes = [DatacollectorPermission] def post(self, request): """ From 37ca44ec312d25f617a21a1470b666d1613b8a4a Mon Sep 17 00:00:00 2001 From: Luis Felipe Castano <felipe.castano@edunext.co> Date: Mon, 24 Feb 2025 15:10:12 -0500 Subject: [PATCH 05/60] chore: block non-SELECT queries --- eox_core/api/data/data_collector/utils.py | 11 +++++++++++ 1 file changed, 11 insertions(+) diff --git a/eox_core/api/data/data_collector/utils.py b/eox_core/api/data/data_collector/utils.py index e4f7bf9f5..03e509845 100644 --- a/eox_core/api/data/data_collector/utils.py +++ b/eox_core/api/data/data_collector/utils.py @@ -9,6 +9,7 @@ from django.conf import settings from datetime import datetime import logging +import re from eox_core.utils import get_access_token @@ -24,7 +25,17 @@ def execute_query(sql_query): Returns: list or dict: Structured query results. + + Raises: + ValueError: If the query is not a SELECT statement. """ + # Normalize query (remove whitespace and convert to uppercase) + normalized_query = sql_query.strip().upper() + + # Verify that the query begins with "SELECT" + if not re.match(r"^SELECT\s", normalized_query): + raise ValueError("Only SELECT queries are allowed.") + with connection.cursor() as cursor: cursor.execute(sql_query) rows = cursor.fetchall() From 88081c295bbcf1bc907ffc4f62fa79cc4ace4389 Mon Sep 17 00:00:00 2001 From: Luis Felipe Castano <felipe.castano@edunext.co> Date: Mon, 24 Feb 2025 15:11:24 -0500 Subject: [PATCH 06/60] feat: add setting AGGREGATED_DATA_COLLECTOR_API_ENABLED to disable data collector endpoint --- eox_core/api/data/data_collector/v1/views.py | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/eox_core/api/data/data_collector/v1/views.py b/eox_core/api/data/data_collector/v1/views.py index f3ec27eb8..b2a8f90c5 100644 --- a/eox_core/api/data/data_collector/v1/views.py +++ b/eox_core/api/data/data_collector/v1/views.py @@ -48,6 +48,12 @@ def post(self, request): Returns: Response: A success or error message. """ + if not getattr(settings, "EOX_CORE_DATA_COLLECTOR_ENABLED", False): + return Response( + {"error": "This endpoint is currently disabled."}, + status=status.HTTP_403_FORBIDDEN + ) + serializer = DataCollectorSerializer(data=request.data) if serializer.is_valid(): From cebbc1eec232b1818bb995fd28536ea74e08f8da Mon Sep 17 00:00:00 2001 From: Luis Felipe Castano <felipe.castano@edunext.co> Date: Mon, 24 Feb 2025 15:19:12 -0500 Subject: [PATCH 07/60] refactor: execute all predefined queries and remove query selection --- eox_core/api/data/data_collector/queries.py | 58 +++++++++++++++++++ eox_core/api/data/data_collector/tasks.py | 19 ++---- .../api/data/data_collector/v1/serializers.py | 31 ---------- eox_core/api/data/data_collector/v1/views.py | 22 +------ 4 files changed, 65 insertions(+), 65 deletions(-) create mode 100644 eox_core/api/data/data_collector/queries.py diff --git a/eox_core/api/data/data_collector/queries.py b/eox_core/api/data/data_collector/queries.py new file mode 100644 index 000000000..09034d990 --- /dev/null +++ b/eox_core/api/data/data_collector/queries.py @@ -0,0 +1,58 @@ +PREDEFINED_QUERIES = { + "Usuarios total": """ + SELECT COUNT(*) FROM auth_user as au; + """, + "Usuarios activos mes anterior": """ + SELECT + YEAR(cs.modified) AS 'Year', + MONTH(cs.modified) AS 'Month', + COUNT(DISTINCT cs.student_id) AS 'Active Users' + FROM + courseware_studentmodule AS cs + WHERE + cs.modified >= DATE_FORMAT(DATE_SUB(CURDATE(), INTERVAL 1 MONTH), '%Y-%m-01') + AND cs.modified < DATE_FORMAT(CURDATE(), '%Y-%m-01') + GROUP BY + YEAR(cs.modified), MONTH(cs.modified); + """, + "Usuarios activos 7 dias": """ + SELECT COUNT(DISTINCT cs.student_id) + FROM courseware_studentmodule AS cs + WHERE cs.modified >= DATE_SUB(CURDATE(), INTERVAL 7 DAY); + """, + "Cantidad de cursos creados": """ + SELECT COUNT(*) FROM course_overviews_courseoverview as coc; + """, + "Cursos activos": """ + SELECT COUNT(*) + FROM course_overviews_courseoverview as coc + WHERE coc.start <= NOW() AND (coc.end >= NOW() OR coc.end IS NULL); + """, + "Cursos con certificado activo": """ + SELECT COUNT(DISTINCT coc.id) + FROM course_overviews_courseoverview AS coc + JOIN certificates_generatedcertificate AS cg + ON coc.id = cg.course_id; + """, + "Inscripciones mes anterior": """ + SELECT + YEAR(sc.created) AS 'Year', + MONTH(sc.created) AS 'Month', + COUNT(DISTINCT sc.id) AS 'Enrollments' + FROM + student_courseenrollment AS sc + WHERE + sc.created >= DATE_FORMAT(DATE_SUB(CURDATE(), INTERVAL 1 MONTH), '%Y-%m-01') + AND sc.created < DATE_FORMAT(CURDATE(), '%Y-%m-01') + GROUP BY + YEAR(sc.created), MONTH(sc.created); + """, + "Inscripciones 7 dias": """ + SELECT COUNT(DISTINCT sc.id) + FROM student_courseenrollment AS sc + WHERE sc.created >= DATE_SUB(CURDATE(), INTERVAL 7 DAY); + """, + "Certificados generados": """ + SELECT COUNT(*) FROM certificates_generatedcertificate as cg; + """, +} diff --git a/eox_core/api/data/data_collector/tasks.py b/eox_core/api/data/data_collector/tasks.py index 00cc7ecc8..9ad5d3a1a 100644 --- a/eox_core/api/data/data_collector/tasks.py +++ b/eox_core/api/data/data_collector/tasks.py @@ -5,6 +5,7 @@ from celery import shared_task, Task from eox_core.api.data.data_collector.utils import execute_query, post_data_to_api, serialize_data, process_query_results +from eox_core.api.data.data_collector.queries import PREDEFINED_QUERIES import yaml import logging @@ -30,30 +31,22 @@ def on_failure(self, exc, task_id, args, kwargs, einfo): @shared_task(bind=True) -def generate_report(self, destination_url, query_file_content, token_generation_url, current_host): +def generate_report(self, destination_url, token_generation_url, current_host): """ Async task to generate a report: - 1. Reads queries from the provided query file content. - 2. Executes each query against the database. - 3. Sends the results to the Shipyard API. + 1. Executes all predefined queries. + 2. Sends the results to the Shipyard API. Args: self (Task): The Celery task instance. - query_file_content (str): The content of the query file in YAML format. + destination_url (str): URL to send the results. Raises: Retry: If an error occurs, the task retries up to 3 times with a 60-second delay. """ try: - queries = yaml.safe_load(query_file_content).get("queries", []) - if not queries: - logger.warning("No queries found in the provided file. Task will exit.") - return - report_data = {} - for query in queries: - query_name = query.get("name") - query_sql = query.get("query") + for query_name, query_sql in PREDEFINED_QUERIES.items(): logger.info(f"Executing query: {query_name}") try: result = execute_query(query_sql) diff --git a/eox_core/api/data/data_collector/v1/serializers.py b/eox_core/api/data/data_collector/v1/serializers.py index 5109b26e0..59e24e5c1 100644 --- a/eox_core/api/data/data_collector/v1/serializers.py +++ b/eox_core/api/data/data_collector/v1/serializers.py @@ -7,20 +7,8 @@ class DataCollectorSerializer(serializers.Serializer): Validates the incoming payload for the data collection endpoint. Fields: - query_file_content (str): The content of the query file in YAML format. - query_file_url (str): A public URL pointing to the query file. destination_url (str): The URL where the results should be sent. """ - query_file_content = serializers.CharField( - required=False, - allow_blank=True, - help_text="Content of the query file in YAML format." - ) - query_file_url = serializers.URLField( - required=False, - allow_blank=True, - help_text="Public URL pointing to the query file." - ) destination_url = serializers.URLField( required=True, help_text="The API endpoint where the results will be sent." @@ -29,22 +17,3 @@ class DataCollectorSerializer(serializers.Serializer): required=True, help_text="The API endpoint where the results will be sent." ) - - def validate(self, data): - """ - Custom validation to ensure either 'query_file_content' or 'query_file_url' is provided. - - Args: - data (dict): The validated data. - - Returns: - dict: The validated data if valid. - - Raises: - serializers.ValidationError: If neither 'query_file_content' nor 'query_file_url' is provided. - """ - if not data.get("query_file_content") and not data.get("query_file_url"): - raise serializers.ValidationError( - "Either 'query_file_content' or 'query_file_url' must be provided." - ) - return data diff --git a/eox_core/api/data/data_collector/v1/views.py b/eox_core/api/data/data_collector/v1/views.py index b2a8f90c5..eac4291b8 100644 --- a/eox_core/api/data/data_collector/v1/views.py +++ b/eox_core/api/data/data_collector/v1/views.py @@ -35,7 +35,6 @@ class DataCollectorView(APIView): - Validates input using DataCollectorSerializer. - Triggers an async task to execute queries and send results to a specified destination. """ - # Allow JWT Auth permission_classes = [DatacollectorPermission] def post(self, request): @@ -58,30 +57,11 @@ def post(self, request): if serializer.is_valid(): validated_data = serializer.validated_data - query_file_content = validated_data.get("query_file_content") - query_file_url = validated_data.get("query_file_url") destination_url = validated_data.get("destination_url") token_generation_url = validated_data.get("token_generation_url") current_host = request.get_host() #Remove trailing slash and http - # If the query file content is not provided, fetch it from the URL - if not query_file_content and query_file_url: - try: - response = requests.get(query_file_url) - if response.status_code == 200: - query_file_content = response.text - else: - return Response( - {"error": "Failed to fetch query file from the provided URL."}, - status=status.HTTP_400_BAD_REQUEST - ) - except Exception as e: - return Response( - {"error": f"An error occurred while fetching the query file: {str(e)}"}, - status=status.HTTP_400_BAD_REQUEST - ) - - generate_report.delay(destination_url, query_file_content, token_generation_url, current_host) + generate_report.delay(destination_url, token_generation_url, current_host) return Response( {"message": "Data collection task has been initiated successfully."}, status=status.HTTP_202_ACCEPTED From ad988d1b340e51d96ebdc1d5dbfb582ad744aedd Mon Sep 17 00:00:00 2001 From: Luis Felipe Castano <felipe.castano@edunext.co> Date: Mon, 24 Feb 2025 15:21:21 -0500 Subject: [PATCH 08/60] docs: add docstring --- eox_core/api/data/data_collector/v1/views.py | 3 +++ 1 file changed, 3 insertions(+) diff --git a/eox_core/api/data/data_collector/v1/views.py b/eox_core/api/data/data_collector/v1/views.py index eac4291b8..e3f531113 100644 --- a/eox_core/api/data/data_collector/v1/views.py +++ b/eox_core/api/data/data_collector/v1/views.py @@ -31,6 +31,9 @@ class DataCollectorView(APIView): """ API view to handle data collection requests. + This feature is **100% opt-in**, meaning that it will only work if explicitly enabled + by the system administrator. No data is extracted or sent without consent. + This view: - Validates input using DataCollectorSerializer. - Triggers an async task to execute queries and send results to a specified destination. From 3219c932fe9a580699f8a7b40539136cfe87d207 Mon Sep 17 00:00:00 2001 From: Luis Felipe Castano <felipe.castano@edunext.co> Date: Mon, 24 Feb 2025 18:01:27 -0500 Subject: [PATCH 09/60] chore: rename EOX_CORE_DATA_COLLECTOR_ENABLED setting to AGGREGATED_DATA_COLLECTOR_API_ENABLED --- eox_core/api/data/data_collector/v1/views.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/eox_core/api/data/data_collector/v1/views.py b/eox_core/api/data/data_collector/v1/views.py index e3f531113..8ee192913 100644 --- a/eox_core/api/data/data_collector/v1/views.py +++ b/eox_core/api/data/data_collector/v1/views.py @@ -50,7 +50,7 @@ def post(self, request): Returns: Response: A success or error message. """ - if not getattr(settings, "EOX_CORE_DATA_COLLECTOR_ENABLED", False): + if not getattr(settings, "AGGREGATED_DATA_COLLECTOR_API_ENABLED", False): return Response( {"error": "This endpoint is currently disabled."}, status=status.HTTP_403_FORBIDDEN From b9e0f25d522e0db85e2cd456417f7bdb1fe04685 Mon Sep 17 00:00:00 2001 From: Luis Felipe Castano <felipe.castano@edunext.co> Date: Mon, 24 Feb 2025 18:13:28 -0500 Subject: [PATCH 10/60] chore: fix indentation and formatting issues --- eox_core/api/data/data_collector/utils.py | 8 ++++---- eox_core/api/data/data_collector/v1/serializers.py | 1 + eox_core/api/data/data_collector/v1/views.py | 2 +- eox_core/utils.py | 8 ++------ 4 files changed, 8 insertions(+), 11 deletions(-) diff --git a/eox_core/api/data/data_collector/utils.py b/eox_core/api/data/data_collector/utils.py index 03e509845..9ae2f5a4c 100644 --- a/eox_core/api/data/data_collector/utils.py +++ b/eox_core/api/data/data_collector/utils.py @@ -19,13 +19,13 @@ def execute_query(sql_query): """ Execute a raw SQL query and return the results in a structured format. - + Args: sql_query (str): The raw SQL query to execute. - + Returns: list or dict: Structured query results. - + Raises: ValueError: If the query is not a SELECT statement. """ @@ -102,7 +102,7 @@ def post_data_to_api(api_url, report_data, token_generation_url, current_host): "Authorization": f"Bearer {token}", "Content-Type": "application/json", } - payload = {"instance_domain":current_host, "data": report_data} + payload = {"instance_domain": current_host, "data": report_data} response = requests.post(api_url, json=payload, headers=headers) if not response.ok: diff --git a/eox_core/api/data/data_collector/v1/serializers.py b/eox_core/api/data/data_collector/v1/serializers.py index 59e24e5c1..7df6f29e3 100644 --- a/eox_core/api/data/data_collector/v1/serializers.py +++ b/eox_core/api/data/data_collector/v1/serializers.py @@ -1,5 +1,6 @@ from rest_framework import serializers + class DataCollectorSerializer(serializers.Serializer): """ Serializer for the DataCollectorView API. diff --git a/eox_core/api/data/data_collector/v1/views.py b/eox_core/api/data/data_collector/v1/views.py index 8ee192913..4e6199b4d 100644 --- a/eox_core/api/data/data_collector/v1/views.py +++ b/eox_core/api/data/data_collector/v1/views.py @@ -62,7 +62,7 @@ def post(self, request): validated_data = serializer.validated_data destination_url = validated_data.get("destination_url") token_generation_url = validated_data.get("token_generation_url") - current_host = request.get_host() #Remove trailing slash and http + current_host = request.get_host() # Remove trailing slash and http generate_report.delay(destination_url, token_generation_url, current_host) return Response( diff --git a/eox_core/utils.py b/eox_core/utils.py index 0f7b5a346..1e3a1e42c 100644 --- a/eox_core/utils.py +++ b/eox_core/utils.py @@ -168,12 +168,8 @@ def get_or_create_site_from_oauth_app_uris(redirect_uris): return Site.objects.create(domain=domain, name=domain) -def get_access_token( - token_url, - client_id, - client_secret, - grant_type="client_credentials" - ): +def get_access_token(token_url, client_id, + client_secret, grant_type="client_credentials"): """ Fetch an access token from a service OAuth2 API. From d22fc41d9bb75d3c18c7bf7112163cf9c3aad0f1 Mon Sep 17 00:00:00 2001 From: Luis Felipe Castano <felipe.castano@edunext.co> Date: Mon, 24 Feb 2025 18:23:01 -0500 Subject: [PATCH 11/60] chore: fix indentation and formatting issues --- eox_core/utils.py | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/eox_core/utils.py b/eox_core/utils.py index 1e3a1e42c..aab072ab0 100644 --- a/eox_core/utils.py +++ b/eox_core/utils.py @@ -168,8 +168,9 @@ def get_or_create_site_from_oauth_app_uris(redirect_uris): return Site.objects.create(domain=domain, name=domain) -def get_access_token(token_url, client_id, - client_secret, grant_type="client_credentials"): +def get_access_token( + token_url, client_id, client_secret, grant_type="client_credentials" + ): """ Fetch an access token from a service OAuth2 API. From f71c0b12d6772a9c4c27f9801c2bfdeab0c285f9 Mon Sep 17 00:00:00 2001 From: Luis Felipe Castano <felipe.castano@edunext.co> Date: Mon, 24 Feb 2025 18:26:43 -0500 Subject: [PATCH 12/60] chore: fix indentation and formatting issues --- eox_core/utils.py | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/eox_core/utils.py b/eox_core/utils.py index aab072ab0..0f7b5a346 100644 --- a/eox_core/utils.py +++ b/eox_core/utils.py @@ -169,7 +169,10 @@ def get_or_create_site_from_oauth_app_uris(redirect_uris): def get_access_token( - token_url, client_id, client_secret, grant_type="client_credentials" + token_url, + client_id, + client_secret, + grant_type="client_credentials" ): """ Fetch an access token from a service OAuth2 API. From 9594c5c268bceb9487f9bb0691f0ef39acf010d3 Mon Sep 17 00:00:00 2001 From: Luis Felipe Castano <felipe.castano@edunext.co> Date: Mon, 24 Feb 2025 18:34:33 -0500 Subject: [PATCH 13/60] chore: fix indentation and formatting issues --- eox_core/utils.py | 7 +------ 1 file changed, 1 insertion(+), 6 deletions(-) diff --git a/eox_core/utils.py b/eox_core/utils.py index 0f7b5a346..8befcdb9c 100644 --- a/eox_core/utils.py +++ b/eox_core/utils.py @@ -168,12 +168,7 @@ def get_or_create_site_from_oauth_app_uris(redirect_uris): return Site.objects.create(domain=domain, name=domain) -def get_access_token( - token_url, - client_id, - client_secret, - grant_type="client_credentials" - ): +def get_access_token(token_url, client_id, client_secret, grant_type = "client_credentials"): """ Fetch an access token from a service OAuth2 API. From feffa89924e85c980f5518e32203a8bc351d159d Mon Sep 17 00:00:00 2001 From: Luis Felipe Castano <felipe.castano@edunext.co> Date: Mon, 24 Feb 2025 18:39:22 -0500 Subject: [PATCH 14/60] chore: fix indentation and formatting issues --- eox_core/utils.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/eox_core/utils.py b/eox_core/utils.py index 8befcdb9c..0efa471d7 100644 --- a/eox_core/utils.py +++ b/eox_core/utils.py @@ -168,7 +168,7 @@ def get_or_create_site_from_oauth_app_uris(redirect_uris): return Site.objects.create(domain=domain, name=domain) -def get_access_token(token_url, client_id, client_secret, grant_type = "client_credentials"): +def get_access_token(token_url, client_id, client_secret, grant_type="client_credentials"): """ Fetch an access token from a service OAuth2 API. From f6f676ce31aad9eaa0186850a580ba0ca20cf8f2 Mon Sep 17 00:00:00 2001 From: Luis Felipe Castano <felipe.castano@edunext.co> Date: Tue, 25 Feb 2025 17:43:36 -0500 Subject: [PATCH 15/60] chore: queries file refactor --- eox_core/api/data/data_collector/queries.py | 149 ++++++++++++-------- eox_core/utils.py | 5 +- 2 files changed, 97 insertions(+), 57 deletions(-) diff --git a/eox_core/api/data/data_collector/queries.py b/eox_core/api/data/data_collector/queries.py index 09034d990..8085efea8 100644 --- a/eox_core/api/data/data_collector/queries.py +++ b/eox_core/api/data/data_collector/queries.py @@ -1,58 +1,95 @@ +TOTAL_USERS_QUERY = """ +SELECT + COUNT(*) +FROM + auth_user as au; +""" +ACTIVE_USERS_LAST_MONTH_QUERY = """ +SELECT + YEAR(cs.modified) AS 'Year', + MONTH(cs.modified) AS 'Month', + COUNT(DISTINCT cs.student_id) AS 'Active Users' +FROM + courseware_studentmodule AS cs +WHERE + cs.modified >= DATE_FORMAT(DATE_SUB(CURDATE(), INTERVAL 1 MONTH), '%Y-%m-01') + AND cs.modified < DATE_FORMAT(CURDATE(), '%Y-%m-01') +GROUP BY + YEAR(cs.modified), MONTH(cs.modified); +""" +ACTIVE_USERS_LAST_7_DAYS_QUERY = """ +SELECT + COUNT(DISTINCT cs.student_id) +FROM + courseware_studentmodule AS cs +WHERE + cs.modified >= DATE_SUB(CURDATE(), INTERVAL 7 DAY); +""" +TOTAL_COURSES_CREATED_QUERY = """ +SELECT + COUNT(*) +FROM + course_overviews_courseoverview as coc; +""" +# This query counts the number of CourseOverviews objects that started before +# now and have not yet ended +ACTIVE_COURSES_COUNT_QUERY = """ +SELECT + COUNT(*) +FROM + course_overviews_courseoverview as coc +WHERE + coc.start <= NOW() + AND ( + coc.end >= NOW() + OR coc.end IS NULL + ); +""" +COURSES_WITH_ACTIVE_CERTIFICATES_QUERY = """ +SELECT + COUNT(DISTINCT coc.id) +FROM + course_overviews_courseoverview AS coc +JOIN + certificates_generatedcertificate AS cg +ON + coc.id = cg.course_id; +""" +ENROLLMENTS_LAST_MONTH_QUERY = """ +SELECT + YEAR(sc.created) AS 'Year', + MONTH(sc.created) AS 'Month', + COUNT(DISTINCT sc.id) AS 'Enrollments' +FROM + student_courseenrollment AS sc +WHERE + sc.created >= DATE_FORMAT(DATE_SUB(CURDATE(), INTERVAL 1 MONTH), '%Y-%m-01') + AND sc.created < DATE_FORMAT(CURDATE(), '%Y-%m-01') +GROUP BY + YEAR(sc.created), MONTH(sc.created); +""" +ENROLLMENTS_LAST_7_DAYS_QUERY = """ +SELECT + COUNT(DISTINCT sc.id) +FROM + student_courseenrollment AS sc +WHERE + sc.created >= DATE_SUB(CURDATE(), INTERVAL 7 DAY); +""" +CERTIFICATES_ISSUED_QUERY = """ +SELECT + COUNT(*) +FROM + certificates_generatedcertificate as cg; +""" PREDEFINED_QUERIES = { - "Usuarios total": """ - SELECT COUNT(*) FROM auth_user as au; - """, - "Usuarios activos mes anterior": """ - SELECT - YEAR(cs.modified) AS 'Year', - MONTH(cs.modified) AS 'Month', - COUNT(DISTINCT cs.student_id) AS 'Active Users' - FROM - courseware_studentmodule AS cs - WHERE - cs.modified >= DATE_FORMAT(DATE_SUB(CURDATE(), INTERVAL 1 MONTH), '%Y-%m-01') - AND cs.modified < DATE_FORMAT(CURDATE(), '%Y-%m-01') - GROUP BY - YEAR(cs.modified), MONTH(cs.modified); - """, - "Usuarios activos 7 dias": """ - SELECT COUNT(DISTINCT cs.student_id) - FROM courseware_studentmodule AS cs - WHERE cs.modified >= DATE_SUB(CURDATE(), INTERVAL 7 DAY); - """, - "Cantidad de cursos creados": """ - SELECT COUNT(*) FROM course_overviews_courseoverview as coc; - """, - "Cursos activos": """ - SELECT COUNT(*) - FROM course_overviews_courseoverview as coc - WHERE coc.start <= NOW() AND (coc.end >= NOW() OR coc.end IS NULL); - """, - "Cursos con certificado activo": """ - SELECT COUNT(DISTINCT coc.id) - FROM course_overviews_courseoverview AS coc - JOIN certificates_generatedcertificate AS cg - ON coc.id = cg.course_id; - """, - "Inscripciones mes anterior": """ - SELECT - YEAR(sc.created) AS 'Year', - MONTH(sc.created) AS 'Month', - COUNT(DISTINCT sc.id) AS 'Enrollments' - FROM - student_courseenrollment AS sc - WHERE - sc.created >= DATE_FORMAT(DATE_SUB(CURDATE(), INTERVAL 1 MONTH), '%Y-%m-01') - AND sc.created < DATE_FORMAT(CURDATE(), '%Y-%m-01') - GROUP BY - YEAR(sc.created), MONTH(sc.created); - """, - "Inscripciones 7 dias": """ - SELECT COUNT(DISTINCT sc.id) - FROM student_courseenrollment AS sc - WHERE sc.created >= DATE_SUB(CURDATE(), INTERVAL 7 DAY); - """, - "Certificados generados": """ - SELECT COUNT(*) FROM certificates_generatedcertificate as cg; - """, + "Total Users": TOTAL_USERS_QUERY, + "Active Users Last Month": ACTIVE_USERS_LAST_MONTH_QUERY, + "Active users in the last 7 days": ACTIVE_USERS_LAST_7_DAYS_QUERY, + "Total Courses Created": TOTAL_COURSES_CREATED_QUERY, + "Active Courses Count": ACTIVE_COURSES_COUNT_QUERY, + "Courses With Active Certificates": COURSES_WITH_ACTIVE_CERTIFICATES_QUERY, + "Enrollments Last Month": ENROLLMENTS_LAST_MONTH_QUERY, + "Enrollments Last 7 Days": ENROLLMENTS_LAST_7_DAYS_QUERY, + "Certificates Issued": CERTIFICATES_ISSUED_QUERY, } diff --git a/eox_core/utils.py b/eox_core/utils.py index 0efa471d7..cf9261b4c 100644 --- a/eox_core/utils.py +++ b/eox_core/utils.py @@ -184,7 +184,10 @@ def get_access_token(token_url, client_id, client_secret, grant_type="client_cre "client_id": client_id, "client_secret": client_secret, }, + timeout=10 ) if response.ok: return response.json().get("access_token") - raise Exception("Failed to obtain access token for API.") + raise requests.exceptions.HTTPError( + f"Failed to obtain access token: {response.status_code} - {response.text}" + ) From 37594de5b5c79caf8b94bbd48ecb88dfc10e17e1 Mon Sep 17 00:00:00 2001 From: Luis Felipe Castano <felipe.castano@edunext.co> Date: Tue, 25 Feb 2025 17:55:28 -0500 Subject: [PATCH 16/60] docs: update queries docstring --- eox_core/api/data/data_collector/queries.py | 19 +++++++++++++++++++ 1 file changed, 19 insertions(+) diff --git a/eox_core/api/data/data_collector/queries.py b/eox_core/api/data/data_collector/queries.py index 8085efea8..34cac92c4 100644 --- a/eox_core/api/data/data_collector/queries.py +++ b/eox_core/api/data/data_collector/queries.py @@ -1,9 +1,21 @@ +""" +This module contains SQL queries to retrieve platform-related metrics. + +These queries are used to extract information about user activity, course +engagement, and certificate issuance. The data retrieval is conditioned on +the feature being enabled and the necessary authentication credentials +being set. +""" + +# This query counts the total number of users in the platform. TOTAL_USERS_QUERY = """ SELECT COUNT(*) FROM auth_user as au; """ +# This query counts the number of unique active users in the last month. +# A user is considered active if they have modified a student module within the last month. ACTIVE_USERS_LAST_MONTH_QUERY = """ SELECT YEAR(cs.modified) AS 'Year', @@ -17,6 +29,8 @@ GROUP BY YEAR(cs.modified), MONTH(cs.modified); """ +# This query counts the number of unique active users in the last 7 days. +# A user is considered active if they have modified a student module within the last 7 days. ACTIVE_USERS_LAST_7_DAYS_QUERY = """ SELECT COUNT(DISTINCT cs.student_id) @@ -25,6 +39,7 @@ WHERE cs.modified >= DATE_SUB(CURDATE(), INTERVAL 7 DAY); """ +# This query counts the total number of courses created on the platform. TOTAL_COURSES_CREATED_QUERY = """ SELECT COUNT(*) @@ -45,6 +60,7 @@ OR coc.end IS NULL ); """ +# This query counts the number of courses that have at least one issued certificate. COURSES_WITH_ACTIVE_CERTIFICATES_QUERY = """ SELECT COUNT(DISTINCT coc.id) @@ -55,6 +71,7 @@ ON coc.id = cg.course_id; """ +# This query counts the number of new enrollments in the last month. ENROLLMENTS_LAST_MONTH_QUERY = """ SELECT YEAR(sc.created) AS 'Year', @@ -68,6 +85,7 @@ GROUP BY YEAR(sc.created), MONTH(sc.created); """ +# This query counts the number of new enrollments in the last 7 days. ENROLLMENTS_LAST_7_DAYS_QUERY = """ SELECT COUNT(DISTINCT sc.id) @@ -82,6 +100,7 @@ FROM certificates_generatedcertificate as cg; """ +# This query counts the total number of certificates issued on the platform. PREDEFINED_QUERIES = { "Total Users": TOTAL_USERS_QUERY, "Active Users Last Month": ACTIVE_USERS_LAST_MONTH_QUERY, From 8f75361b3ba00c9cb7937a1cc1a78d487ec78886 Mon Sep 17 00:00:00 2001 From: Luis Felipe Castano <felipe.castano@edunext.co> Date: Tue, 25 Feb 2025 18:00:05 -0500 Subject: [PATCH 17/60] refactor: remove unused class --- eox_core/api/data/data_collector/tasks.py | 20 +------------------- 1 file changed, 1 insertion(+), 19 deletions(-) diff --git a/eox_core/api/data/data_collector/tasks.py b/eox_core/api/data/data_collector/tasks.py index 9ad5d3a1a..9e2f309f4 100644 --- a/eox_core/api/data/data_collector/tasks.py +++ b/eox_core/api/data/data_collector/tasks.py @@ -3,7 +3,7 @@ and posting the results to the Shipyard API. """ -from celery import shared_task, Task +from celery import shared_task from eox_core.api.data.data_collector.utils import execute_query, post_data_to_api, serialize_data, process_query_results from eox_core.api.data.data_collector.queries import PREDEFINED_QUERIES import yaml @@ -12,24 +12,6 @@ logger = logging.getLogger(__name__) -class ReportTask(Task): - """ - Custom task class to handle report generation with an on_failure hook. - """ - def on_failure(self, exc, task_id, args, kwargs, einfo): - """ - Called when the task has exhausted all retries. - - Args: - exc (Exception): The exception raised. - task_id (str): The ID of the failed task. - args (tuple): The positional arguments for the task. - kwargs (dict): The keyword arguments for the task. - einfo (ExceptionInfo): Exception information. - """ - logger.error(f"Task {task_id} failed after retries. Exception: {exc}. Could not collect data.") - - @shared_task(bind=True) def generate_report(self, destination_url, token_generation_url, current_host): """ From f72843219f10fa4a8fa105bb4da6110e965d4a30 Mon Sep 17 00:00:00 2001 From: Luis Felipe Castano <felipe.castano@edunext.co> Date: Tue, 25 Feb 2025 18:03:25 -0500 Subject: [PATCH 18/60] chore: COUNTDOWN and MAX_RETRIES constants --- eox_core/api/data/data_collector/tasks.py | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/eox_core/api/data/data_collector/tasks.py b/eox_core/api/data/data_collector/tasks.py index 9e2f309f4..cf2fd3b52 100644 --- a/eox_core/api/data/data_collector/tasks.py +++ b/eox_core/api/data/data_collector/tasks.py @@ -11,6 +11,8 @@ logger = logging.getLogger(__name__) +COUNTDOWN = 60 +MAX_RETRIES = 3 @shared_task(bind=True) def generate_report(self, destination_url, token_generation_url, current_host): @@ -45,4 +47,4 @@ def generate_report(self, destination_url, token_generation_url, current_host): logger.info("Report generation task completed successfully.") except Exception as e: logger.error(f"An error occurred in the report generation task: {e}. Retrying") - raise self.retry(exc=e, countdown=60, max_retries=3) + raise self.retry(exc=e, countdown=COUNTDOWN, max_retries=MAX_RETRIES) From 36b31045c3c31312047f8f04e9775d07c7361c86 Mon Sep 17 00:00:00 2001 From: Luis Felipe Castano <felipe.castano@edunext.co> Date: Tue, 25 Feb 2025 18:06:38 -0500 Subject: [PATCH 19/60] docs: update docstring urls.py --- eox_core/api/data/data_collector/urls.py | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/eox_core/api/data/data_collector/urls.py b/eox_core/api/data/data_collector/urls.py index 2d71ac519..ee575a587 100644 --- a/eox_core/api/data/data_collector/urls.py +++ b/eox_core/api/data/data_collector/urls.py @@ -1,5 +1,8 @@ """ -URLs for the Microsite API +URL configuration for the Microsite API. + +This module defines the URL patterns for the Microsite API, +including versioned endpoints for data_collector. """ from django.urls import include, re_path From b0a5b5190d1a59b62fa9184c0491e52214981b88 Mon Sep 17 00:00:00 2001 From: Luis Felipe Castano <felipe.castano@edunext.co> Date: Tue, 25 Feb 2025 18:22:35 -0500 Subject: [PATCH 20/60] refactor: merge serialize_data and process_query_results into post_process_query_results --- eox_core/api/data/data_collector/tasks.py | 5 ++-- eox_core/api/data/data_collector/utils.py | 36 ++++++++--------------- 2 files changed, 14 insertions(+), 27 deletions(-) diff --git a/eox_core/api/data/data_collector/tasks.py b/eox_core/api/data/data_collector/tasks.py index cf2fd3b52..549a8672e 100644 --- a/eox_core/api/data/data_collector/tasks.py +++ b/eox_core/api/data/data_collector/tasks.py @@ -4,7 +4,7 @@ """ from celery import shared_task -from eox_core.api.data.data_collector.utils import execute_query, post_data_to_api, serialize_data, process_query_results +from eox_core.api.data.data_collector.utils import execute_query, post_data_to_api, post_process_query_results from eox_core.api.data.data_collector.queries import PREDEFINED_QUERIES import yaml import logging @@ -35,8 +35,7 @@ def generate_report(self, destination_url, token_generation_url, current_host): try: result = execute_query(query_sql) - serialized_result = serialize_data(result) - processed_result = process_query_results(serialized_result) + processed_result = post_process_query_results(result) report_data[query_name] = processed_result except Exception as e: logger.error(f"Failed to execute query '{query_name}': {e}") diff --git a/eox_core/api/data/data_collector/utils.py b/eox_core/api/data/data_collector/utils.py index 9ae2f5a4c..62b35f6cb 100644 --- a/eox_core/api/data/data_collector/utils.py +++ b/eox_core/api/data/data_collector/utils.py @@ -47,42 +47,30 @@ def execute_query(sql_query): return [dict(zip(columns, row)) for row in rows] # Multi-column results as a list of dicts return rows - -def serialize_data(data): +def post_process_query_results(data): """ - Recursively serialize data, converting datetime objects to strings. + Cleans and processes query results by: + - Serializing datetime objects into strings. + - Extracting scalar values from single-item lists. + - Returning structured data for further use. Args: - data (dict or list): The data to serialize. + data (dict, list, datetime, or scalar): The query result data. Returns: - dict or list: The serialized data with datetime objects as strings. + dict, list, or scalar: The processed query result. """ if isinstance(data, dict): - return {key: serialize_data(value) for key, value in data.items()} + return {key: post_process_query_results(value) for key, value in data.items()} elif isinstance(data, list): - return [serialize_data(item) for item in data] + # If it's a list with one item, return just the item + if len(data) == 1: + return post_process_query_results(data[0]) + return [post_process_query_results(item) for item in data] elif isinstance(data, datetime): return data.isoformat() return data - -def process_query_results(raw_result): - """ - Process the raw result of a query. - - Args: - raw_result: The result from the SQL query (list, scalar, or dictionary). - - Returns: - The processed result, extracting scalar values from single-item lists, - or returning the original value for more complex data structures. - """ - if isinstance(raw_result, list) and len(raw_result) == 1: - return raw_result[0] - return raw_result - - def post_data_to_api(api_url, report_data, token_generation_url, current_host): """ Sends the generated report data to the Shipyard API. From 9315b351e90d37ee0f31aadb0036e26f8373e4ba Mon Sep 17 00:00:00 2001 From: Luis Felipe Castano <felipe.castano@edunext.co> Date: Tue, 25 Feb 2025 18:26:49 -0500 Subject: [PATCH 21/60] refactor: remove validate Only SELECT queries are allowed --- eox_core/api/data/data_collector/utils.py | 10 ---------- 1 file changed, 10 deletions(-) diff --git a/eox_core/api/data/data_collector/utils.py b/eox_core/api/data/data_collector/utils.py index 62b35f6cb..e6a251f8f 100644 --- a/eox_core/api/data/data_collector/utils.py +++ b/eox_core/api/data/data_collector/utils.py @@ -25,17 +25,7 @@ def execute_query(sql_query): Returns: list or dict: Structured query results. - - Raises: - ValueError: If the query is not a SELECT statement. """ - # Normalize query (remove whitespace and convert to uppercase) - normalized_query = sql_query.strip().upper() - - # Verify that the query begins with "SELECT" - if not re.match(r"^SELECT\s", normalized_query): - raise ValueError("Only SELECT queries are allowed.") - with connection.cursor() as cursor: cursor.execute(sql_query) rows = cursor.fetchall() From e8bf0a0ea6371154c3b94906535bad10ac4bb0cb Mon Sep 17 00:00:00 2001 From: Luis Felipe Castano <felipe.castano@edunext.co> Date: Tue, 25 Feb 2025 18:32:40 -0500 Subject: [PATCH 22/60] docs: add execute_query example --- eox_core/api/data/data_collector/utils.py | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/eox_core/api/data/data_collector/utils.py b/eox_core/api/data/data_collector/utils.py index e6a251f8f..ddf7d5ca8 100644 --- a/eox_core/api/data/data_collector/utils.py +++ b/eox_core/api/data/data_collector/utils.py @@ -25,6 +25,13 @@ def execute_query(sql_query): Returns: list or dict: Structured query results. + + Example: + >>> execute_query("SELECT id, username FROM auth_user WHERE is_active = 1;") + [ + {"id": 1, "username": "john_doe"}, + {"id": 2, "username": "jane_doe"} + ] """ with connection.cursor() as cursor: cursor.execute(sql_query) From e257d345ac091ade3d1b17ec04e9bf70f951cd49 Mon Sep 17 00:00:00 2001 From: Luis Felipe Castano <felipe.castano@edunext.co> Date: Tue, 25 Feb 2025 18:35:21 -0500 Subject: [PATCH 23/60] chore: rename EOX_CORE_SAVE_DATA_API_CLIENT_ID and EOX_CORE_SAVE_DATA_API_CLIENT_SECRET --- eox_core/api/data/data_collector/utils.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/eox_core/api/data/data_collector/utils.py b/eox_core/api/data/data_collector/utils.py index ddf7d5ca8..e3d7b0e67 100644 --- a/eox_core/api/data/data_collector/utils.py +++ b/eox_core/api/data/data_collector/utils.py @@ -80,8 +80,8 @@ def post_data_to_api(api_url, report_data, token_generation_url, current_host): """ token = get_access_token( token_generation_url, - settings.EOX_CORE_SAVE_DATA_API_CLIENT_ID, - settings.EOX_CORE_SAVE_DATA_API_CLIENT_SECRET, + settings.EOX_CORE_AGGREGATED_DATA_API_CLIENT_ID, + settings.EOX_CORE_AGGREGATED_DATA_API_CLIENT_SECRET, ) headers = { "Authorization": f"Bearer {token}", From 7b8ac8eeb51957ea76097782750c147ea17f0d29 Mon Sep 17 00:00:00 2001 From: Luis Felipe Castano <felipe.castano@edunext.co> Date: Tue, 25 Feb 2025 18:37:48 -0500 Subject: [PATCH 24/60] docs: add serializers docstring --- eox_core/api/data/data_collector/v1/serializers.py | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/eox_core/api/data/data_collector/v1/serializers.py b/eox_core/api/data/data_collector/v1/serializers.py index 7df6f29e3..dcd49c0be 100644 --- a/eox_core/api/data/data_collector/v1/serializers.py +++ b/eox_core/api/data/data_collector/v1/serializers.py @@ -5,10 +5,13 @@ class DataCollectorSerializer(serializers.Serializer): """ Serializer for the DataCollectorView API. - Validates the incoming payload for the data collection endpoint. - + This serializer is used to validate the payload for the data collection endpoint. + It ensures that the required URLs are provided for sending collected data and + generating authentication tokens. + Fields: destination_url (str): The URL where the results should be sent. + token_generation_url (str): The API endpoint used to generate authentication tokens. """ destination_url = serializers.URLField( required=True, From fab57936d55ca7c6d126bc68d82d1195300bd90d Mon Sep 17 00:00:00 2001 From: Luis Felipe Castano <felipe.castano@edunext.co> Date: Tue, 25 Feb 2025 22:36:05 -0500 Subject: [PATCH 25/60] chore: Updated DataCollectorView to retrieve destination_url and token_generation_url from settings. --- .../api/data/data_collector/v1/serializers.py | 23 --------------- eox_core/api/data/data_collector/v1/views.py | 28 +++++++++---------- 2 files changed, 14 insertions(+), 37 deletions(-) delete mode 100644 eox_core/api/data/data_collector/v1/serializers.py diff --git a/eox_core/api/data/data_collector/v1/serializers.py b/eox_core/api/data/data_collector/v1/serializers.py deleted file mode 100644 index dcd49c0be..000000000 --- a/eox_core/api/data/data_collector/v1/serializers.py +++ /dev/null @@ -1,23 +0,0 @@ -from rest_framework import serializers - - -class DataCollectorSerializer(serializers.Serializer): - """ - Serializer for the DataCollectorView API. - - This serializer is used to validate the payload for the data collection endpoint. - It ensures that the required URLs are provided for sending collected data and - generating authentication tokens. - - Fields: - destination_url (str): The URL where the results should be sent. - token_generation_url (str): The API endpoint used to generate authentication tokens. - """ - destination_url = serializers.URLField( - required=True, - help_text="The API endpoint where the results will be sent." - ) - token_generation_url = serializers.URLField( - required=True, - help_text="The API endpoint where the results will be sent." - ) diff --git a/eox_core/api/data/data_collector/v1/views.py b/eox_core/api/data/data_collector/v1/views.py index 4e6199b4d..e61719ac7 100644 --- a/eox_core/api/data/data_collector/v1/views.py +++ b/eox_core/api/data/data_collector/v1/views.py @@ -10,7 +10,6 @@ from rest_framework.permissions import BasePermission from rest_framework.authentication import get_authorization_header from django.conf import settings -from eox_core.api.data.data_collector.v1.serializers import DataCollectorSerializer logger = logging.getLogger(__name__) @@ -35,7 +34,6 @@ class DataCollectorView(APIView): by the system administrator. No data is extracted or sent without consent. This view: - - Validates input using DataCollectorSerializer. - Triggers an async task to execute queries and send results to a specified destination. """ permission_classes = [DatacollectorPermission] @@ -55,19 +53,21 @@ def post(self, request): {"error": "This endpoint is currently disabled."}, status=status.HTTP_403_FORBIDDEN ) + + destination_url = getattr(settings, "EOX_CORE_DATA_COLLECT_DESTINATION_URL", None) + token_generation_url = getattr(settings, "EOX_CORE_DATA_COLLECT_TOKEN_URL", None) - serializer = DataCollectorSerializer(data=request.data) - - if serializer.is_valid(): - validated_data = serializer.validated_data - destination_url = validated_data.get("destination_url") - token_generation_url = validated_data.get("token_generation_url") - current_host = request.get_host() # Remove trailing slash and http - - generate_report.delay(destination_url, token_generation_url, current_host) + if not destination_url or not token_generation_url: + logger.error("Data collection settings are missing.") return Response( - {"message": "Data collection task has been initiated successfully."}, - status=status.HTTP_202_ACCEPTED + {"error": "Data collection settings are not properly configured."}, + status=status.HTTP_500_INTERNAL_SERVER_ERROR ) - return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST) + current_host = request.get_host() # Remove trailing slash and http + + generate_report.delay(destination_url, token_generation_url, current_host) + return Response( + {"message": "Data collection task has been initiated successfully."}, + status=status.HTTP_202_ACCEPTED + ) From 9e1d7cc75d59431334ba3c2fabc7b0b87a2d694d Mon Sep 17 00:00:00 2001 From: Luis Felipe Castano <felipe.castano@edunext.co> Date: Tue, 25 Feb 2025 22:43:57 -0500 Subject: [PATCH 26/60] refactor: move DatacollectorPermission to a dedicated permissions.py file --- .../api/data/data_collector/v1/permissions.py | 18 +++++++++++++++++ eox_core/api/data/data_collector/v1/views.py | 20 ++----------------- 2 files changed, 20 insertions(+), 18 deletions(-) create mode 100644 eox_core/api/data/data_collector/v1/permissions.py diff --git a/eox_core/api/data/data_collector/v1/permissions.py b/eox_core/api/data/data_collector/v1/permissions.py new file mode 100644 index 000000000..ff6398455 --- /dev/null +++ b/eox_core/api/data/data_collector/v1/permissions.py @@ -0,0 +1,18 @@ +import logging +from rest_framework.permissions import BasePermission +from rest_framework.authentication import get_authorization_header +from django.conf import settings + +logger = logging.getLogger(__name__) + +class DatacollectorPermission(BasePermission): + """ + Permission class to allow access only if the request contains a valid GitHub Action token. + """ + + def has_permission(self, request, view): + auth_header = get_authorization_header(request).decode('utf-8') + auth_token = settings.EOX_CORE_DATA_COLLECT_AUTH_TOKEN + if auth_header and auth_header == f"Bearer {auth_token}": + return True + return False diff --git a/eox_core/api/data/data_collector/v1/views.py b/eox_core/api/data/data_collector/v1/views.py index e61719ac7..30fc82e3e 100644 --- a/eox_core/api/data/data_collector/v1/views.py +++ b/eox_core/api/data/data_collector/v1/views.py @@ -1,31 +1,15 @@ import logging -import requests -import yaml from rest_framework.views import APIView from rest_framework.response import Response -from rest_framework import status, permissions +from rest_framework import status from django.conf import settings from eox_core.api.data.data_collector.tasks import generate_report - -from rest_framework.permissions import BasePermission -from rest_framework.authentication import get_authorization_header +from eox_core.api.data.data_collector.v1.permissions import DatacollectorPermission from django.conf import settings logger = logging.getLogger(__name__) -class DatacollectorPermission(BasePermission): - """ - Permission class to allow access only if the request contains a valid GitHub Action token. - """ - def has_permission(self, request, view): - auth_header = get_authorization_header(request).decode('utf-8') - auth_token = settings.EOX_CORE_DATA_COLLECT_AUTH_TOKEN - if auth_header and auth_header == f"Bearer {auth_token}": - return True - return False - - class DataCollectorView(APIView): """ API view to handle data collection requests. From 69ca02af9ca2c61c85dd49e519403b63f5419bd8 Mon Sep 17 00:00:00 2001 From: Luis Felipe Castano <felipe.castano@edunext.co> Date: Wed, 26 Feb 2025 00:18:47 -0500 Subject: [PATCH 27/60] chore: pycodestyle fixes --- eox_core/api/data/data_collector/queries.py | 8 ++++---- eox_core/api/data/data_collector/tasks.py | 1 + eox_core/api/data/data_collector/urls.py | 2 +- eox_core/api/data/data_collector/utils.py | 4 +++- eox_core/api/data/data_collector/v1/permissions.py | 1 + eox_core/api/data/data_collector/v1/views.py | 2 +- 6 files changed, 11 insertions(+), 7 deletions(-) diff --git a/eox_core/api/data/data_collector/queries.py b/eox_core/api/data/data_collector/queries.py index 34cac92c4..91e6f7497 100644 --- a/eox_core/api/data/data_collector/queries.py +++ b/eox_core/api/data/data_collector/queries.py @@ -1,9 +1,9 @@ """ This module contains SQL queries to retrieve platform-related metrics. -These queries are used to extract information about user activity, course -engagement, and certificate issuance. The data retrieval is conditioned on -the feature being enabled and the necessary authentication credentials +These queries are used to extract information about user activity, course +engagement, and certificate issuance. The data retrieval is conditioned on +the feature being enabled and the necessary authentication credentials being set. """ @@ -110,5 +110,5 @@ "Courses With Active Certificates": COURSES_WITH_ACTIVE_CERTIFICATES_QUERY, "Enrollments Last Month": ENROLLMENTS_LAST_MONTH_QUERY, "Enrollments Last 7 Days": ENROLLMENTS_LAST_7_DAYS_QUERY, - "Certificates Issued": CERTIFICATES_ISSUED_QUERY, + "Certificates Issued": CERTIFICATES_ISSUED_QUERY, } diff --git a/eox_core/api/data/data_collector/tasks.py b/eox_core/api/data/data_collector/tasks.py index 549a8672e..86ada98b4 100644 --- a/eox_core/api/data/data_collector/tasks.py +++ b/eox_core/api/data/data_collector/tasks.py @@ -14,6 +14,7 @@ COUNTDOWN = 60 MAX_RETRIES = 3 + @shared_task(bind=True) def generate_report(self, destination_url, token_generation_url, current_host): """ diff --git a/eox_core/api/data/data_collector/urls.py b/eox_core/api/data/data_collector/urls.py index ee575a587..77b523045 100644 --- a/eox_core/api/data/data_collector/urls.py +++ b/eox_core/api/data/data_collector/urls.py @@ -1,7 +1,7 @@ """ URL configuration for the Microsite API. -This module defines the URL patterns for the Microsite API, +This module defines the URL patterns for the Microsite API, including versioned endpoints for data_collector. """ from django.urls import include, re_path diff --git a/eox_core/api/data/data_collector/utils.py b/eox_core/api/data/data_collector/utils.py index e3d7b0e67..703a63e25 100644 --- a/eox_core/api/data/data_collector/utils.py +++ b/eox_core/api/data/data_collector/utils.py @@ -25,7 +25,7 @@ def execute_query(sql_query): Returns: list or dict: Structured query results. - + Example: >>> execute_query("SELECT id, username FROM auth_user WHERE is_active = 1;") [ @@ -44,6 +44,7 @@ def execute_query(sql_query): return [dict(zip(columns, row)) for row in rows] # Multi-column results as a list of dicts return rows + def post_process_query_results(data): """ Cleans and processes query results by: @@ -68,6 +69,7 @@ def post_process_query_results(data): return data.isoformat() return data + def post_data_to_api(api_url, report_data, token_generation_url, current_host): """ Sends the generated report data to the Shipyard API. diff --git a/eox_core/api/data/data_collector/v1/permissions.py b/eox_core/api/data/data_collector/v1/permissions.py index ff6398455..c9ebbbbdb 100644 --- a/eox_core/api/data/data_collector/v1/permissions.py +++ b/eox_core/api/data/data_collector/v1/permissions.py @@ -5,6 +5,7 @@ logger = logging.getLogger(__name__) + class DatacollectorPermission(BasePermission): """ Permission class to allow access only if the request contains a valid GitHub Action token. diff --git a/eox_core/api/data/data_collector/v1/views.py b/eox_core/api/data/data_collector/v1/views.py index 30fc82e3e..ec2125b7b 100644 --- a/eox_core/api/data/data_collector/v1/views.py +++ b/eox_core/api/data/data_collector/v1/views.py @@ -37,7 +37,7 @@ def post(self, request): {"error": "This endpoint is currently disabled."}, status=status.HTTP_403_FORBIDDEN ) - + destination_url = getattr(settings, "EOX_CORE_DATA_COLLECT_DESTINATION_URL", None) token_generation_url = getattr(settings, "EOX_CORE_DATA_COLLECT_TOKEN_URL", None) From 95efdd5a271bc30596e45f6bd30a74342bfb592f Mon Sep 17 00:00:00 2001 From: Luis Felipe Castano <felipe.castano@edunext.co> Date: Wed, 26 Feb 2025 18:20:21 -0500 Subject: [PATCH 28/60] chore: pylint fixes --- eox_core/api/data/data_collector/tasks.py | 14 +++++------ eox_core/api/data/data_collector/utils.py | 24 ++++++++++--------- .../api/data/data_collector/v1/permissions.py | 4 ++++ eox_core/api/data/data_collector/v1/urls.py | 1 + eox_core/api/data/data_collector/v1/views.py | 7 +++++- 5 files changed, 31 insertions(+), 19 deletions(-) diff --git a/eox_core/api/data/data_collector/tasks.py b/eox_core/api/data/data_collector/tasks.py index 86ada98b4..5abc49f34 100644 --- a/eox_core/api/data/data_collector/tasks.py +++ b/eox_core/api/data/data_collector/tasks.py @@ -3,11 +3,11 @@ and posting the results to the Shipyard API. """ +import logging from celery import shared_task -from eox_core.api.data.data_collector.utils import execute_query, post_data_to_api, post_process_query_results +from django.db.utils import DatabaseError, OperationalError from eox_core.api.data.data_collector.queries import PREDEFINED_QUERIES -import yaml -import logging +from eox_core.api.data.data_collector.utils import execute_query, post_data_to_api, post_process_query_results logger = logging.getLogger(__name__) @@ -32,19 +32,19 @@ def generate_report(self, destination_url, token_generation_url, current_host): try: report_data = {} for query_name, query_sql in PREDEFINED_QUERIES.items(): - logger.info(f"Executing query: {query_name}") + logger.info("Executing query: %s", query_name) try: result = execute_query(query_sql) processed_result = post_process_query_results(result) report_data[query_name] = processed_result - except Exception as e: - logger.error(f"Failed to execute query '{query_name}': {e}") + except (DatabaseError, OperationalError) as e: + logger.error("Failed to execute query '%s': %s", query_name, e) continue post_data_to_api(destination_url, report_data, token_generation_url, current_host) logger.info("Report generation task completed successfully.") except Exception as e: - logger.error(f"An error occurred in the report generation task: {e}. Retrying") + logger.error("An error occurred in the report generation task: '%s'. Retrying", e) raise self.retry(exc=e, countdown=COUNTDOWN, max_retries=MAX_RETRIES) diff --git a/eox_core/api/data/data_collector/utils.py b/eox_core/api/data/data_collector/utils.py index 703a63e25..8659d0c83 100644 --- a/eox_core/api/data/data_collector/utils.py +++ b/eox_core/api/data/data_collector/utils.py @@ -3,14 +3,11 @@ and integration with the Shipyard API. """ -import yaml -from django.db import connection +import logging +from datetime import datetime import requests from django.conf import settings -from datetime import datetime -import logging -import re - +from django.db import connection from eox_core.utils import get_access_token logger = logging.getLogger(__name__) @@ -60,7 +57,7 @@ def post_process_query_results(data): """ if isinstance(data, dict): return {key: post_process_query_results(value) for key, value in data.items()} - elif isinstance(data, list): + if isinstance(data, list): # If it's a list with one item, return just the item if len(data) == 1: return post_process_query_results(data[0]) @@ -90,7 +87,12 @@ def post_data_to_api(api_url, report_data, token_generation_url, current_host): "Content-Type": "application/json", } payload = {"instance_domain": current_host, "data": report_data} - response = requests.post(api_url, json=payload, headers=headers) - - if not response.ok: - raise Exception(f"Failed to post data to Shipyard API: {response.content}") + response = requests.post(api_url, json=payload, headers=headers, timeout=10) + + try: + response = requests.post(api_url, json=payload, headers=headers, timeout=10) + response.raise_for_status() + except requests.Timeout: + raise requests.Timeout("The request to Shipyard API timed out.") + except requests.RequestException as e: + raise requests.RequestException(f"Failed to post data to Shipyard API: {e}") \ No newline at end of file diff --git a/eox_core/api/data/data_collector/v1/permissions.py b/eox_core/api/data/data_collector/v1/permissions.py index c9ebbbbdb..89331b109 100644 --- a/eox_core/api/data/data_collector/v1/permissions.py +++ b/eox_core/api/data/data_collector/v1/permissions.py @@ -1,3 +1,7 @@ +""" +Custom permission classes for the Data Collector API. +""" + import logging from rest_framework.permissions import BasePermission from rest_framework.authentication import get_authorization_header diff --git a/eox_core/api/data/data_collector/v1/urls.py b/eox_core/api/data/data_collector/v1/urls.py index a815d1d03..1cf78f1e7 100644 --- a/eox_core/api/data/data_collector/v1/urls.py +++ b/eox_core/api/data/data_collector/v1/urls.py @@ -2,6 +2,7 @@ from django.urls import path from eox_core.api.data.data_collector.v1.views import DataCollectorView +# pylint: disable=invalid-name app_name = "data_collector" urlpatterns = [ diff --git a/eox_core/api/data/data_collector/v1/views.py b/eox_core/api/data/data_collector/v1/views.py index ec2125b7b..f1d6e542a 100644 --- a/eox_core/api/data/data_collector/v1/views.py +++ b/eox_core/api/data/data_collector/v1/views.py @@ -1,3 +1,9 @@ +""" +Views for the Data Collector API (v1). + +This module defines the API views for collecting and processing data. +""" + import logging from rest_framework.views import APIView from rest_framework.response import Response @@ -5,7 +11,6 @@ from django.conf import settings from eox_core.api.data.data_collector.tasks import generate_report from eox_core.api.data.data_collector.v1.permissions import DatacollectorPermission -from django.conf import settings logger = logging.getLogger(__name__) From 94d5aebff342e7ea4b8b4abd3d33929f099d611d Mon Sep 17 00:00:00 2001 From: Luis Felipe Castano <felipe.castano@edunext.co> Date: Wed, 26 Feb 2025 18:23:05 -0500 Subject: [PATCH 29/60] chore: newline --- eox_core/api/data/data_collector/utils.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/eox_core/api/data/data_collector/utils.py b/eox_core/api/data/data_collector/utils.py index 8659d0c83..da1de51c2 100644 --- a/eox_core/api/data/data_collector/utils.py +++ b/eox_core/api/data/data_collector/utils.py @@ -95,4 +95,4 @@ def post_data_to_api(api_url, report_data, token_generation_url, current_host): except requests.Timeout: raise requests.Timeout("The request to Shipyard API timed out.") except requests.RequestException as e: - raise requests.RequestException(f"Failed to post data to Shipyard API: {e}") \ No newline at end of file + raise requests.RequestException(f"Failed to post data to Shipyard API: {e}") From d344e6307f4ff51d59526a8a7794fe5391f4ae8f Mon Sep 17 00:00:00 2001 From: Luis Felipe Castano <felipe.castano@edunext.co> Date: Wed, 26 Feb 2025 18:31:29 -0500 Subject: [PATCH 30/60] chore: newline --- eox_core/api/data/data_collector/utils.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/eox_core/api/data/data_collector/utils.py b/eox_core/api/data/data_collector/utils.py index da1de51c2..1331670c8 100644 --- a/eox_core/api/data/data_collector/utils.py +++ b/eox_core/api/data/data_collector/utils.py @@ -62,7 +62,7 @@ def post_process_query_results(data): if len(data) == 1: return post_process_query_results(data[0]) return [post_process_query_results(item) for item in data] - elif isinstance(data, datetime): + if isinstance(data, datetime): return data.isoformat() return data @@ -92,7 +92,7 @@ def post_data_to_api(api_url, report_data, token_generation_url, current_host): try: response = requests.post(api_url, json=payload, headers=headers, timeout=10) response.raise_for_status() - except requests.Timeout: - raise requests.Timeout("The request to Shipyard API timed out.") + except requests.Timeout as exc: + raise requests.Timeout("The request to Shipyard API timed out.") from exc except requests.RequestException as e: raise requests.RequestException(f"Failed to post data to Shipyard API: {e}") From f4b7063551eb0becbc209b00681ba1733ccf72af Mon Sep 17 00:00:00 2001 From: Luis Felipe Castano <felipe.castano@edunext.co> Date: Wed, 26 Feb 2025 18:41:13 -0500 Subject: [PATCH 31/60] chore: pylint fixes --- eox_core/api/data/data_collector/tasks.py | 2 ++ eox_core/api/data/data_collector/utils.py | 2 ++ eox_core/api/data/data_collector/v1/permissions.py | 5 +++-- eox_core/api/data/data_collector/v1/urls.py | 1 + eox_core/api/data/data_collector/v1/views.py | 8 +++++--- eox_core/utils.py | 2 +- 6 files changed, 14 insertions(+), 6 deletions(-) diff --git a/eox_core/api/data/data_collector/tasks.py b/eox_core/api/data/data_collector/tasks.py index 5abc49f34..a6b2f8c35 100644 --- a/eox_core/api/data/data_collector/tasks.py +++ b/eox_core/api/data/data_collector/tasks.py @@ -4,8 +4,10 @@ """ import logging + from celery import shared_task from django.db.utils import DatabaseError, OperationalError + from eox_core.api.data.data_collector.queries import PREDEFINED_QUERIES from eox_core.api.data.data_collector.utils import execute_query, post_data_to_api, post_process_query_results diff --git a/eox_core/api/data/data_collector/utils.py b/eox_core/api/data/data_collector/utils.py index 1331670c8..0170a43d3 100644 --- a/eox_core/api/data/data_collector/utils.py +++ b/eox_core/api/data/data_collector/utils.py @@ -5,9 +5,11 @@ import logging from datetime import datetime + import requests from django.conf import settings from django.db import connection + from eox_core.utils import get_access_token logger = logging.getLogger(__name__) diff --git a/eox_core/api/data/data_collector/v1/permissions.py b/eox_core/api/data/data_collector/v1/permissions.py index 89331b109..eacf48a02 100644 --- a/eox_core/api/data/data_collector/v1/permissions.py +++ b/eox_core/api/data/data_collector/v1/permissions.py @@ -3,9 +3,10 @@ """ import logging -from rest_framework.permissions import BasePermission -from rest_framework.authentication import get_authorization_header + from django.conf import settings +from rest_framework.authentication import get_authorization_header +from rest_framework.permissions import BasePermission logger = logging.getLogger(__name__) diff --git a/eox_core/api/data/data_collector/v1/urls.py b/eox_core/api/data/data_collector/v1/urls.py index 1cf78f1e7..4a518fdbb 100644 --- a/eox_core/api/data/data_collector/v1/urls.py +++ b/eox_core/api/data/data_collector/v1/urls.py @@ -1,5 +1,6 @@ """_""" from django.urls import path + from eox_core.api.data.data_collector.v1.views import DataCollectorView # pylint: disable=invalid-name diff --git a/eox_core/api/data/data_collector/v1/views.py b/eox_core/api/data/data_collector/v1/views.py index f1d6e542a..788dd6bb9 100644 --- a/eox_core/api/data/data_collector/v1/views.py +++ b/eox_core/api/data/data_collector/v1/views.py @@ -5,10 +5,12 @@ """ import logging -from rest_framework.views import APIView -from rest_framework.response import Response -from rest_framework import status + from django.conf import settings +from rest_framework import status +from rest_framework.response import Response +from rest_framework.views import APIView + from eox_core.api.data.data_collector.tasks import generate_report from eox_core.api.data.data_collector.v1.permissions import DatacollectorPermission diff --git a/eox_core/utils.py b/eox_core/utils.py index cf9261b4c..25f586ff5 100644 --- a/eox_core/utils.py +++ b/eox_core/utils.py @@ -5,8 +5,8 @@ import datetime import hashlib import re -import requests +import requests from django.conf import settings from django.contrib.sites.models import Site from django.core import cache From 33dd1b5bdd399c97789fe2d09a9f54227920c843 Mon Sep 17 00:00:00 2001 From: Luis Felipe Castano <felipe.castano@edunext.co> Date: Wed, 26 Feb 2025 19:18:03 -0500 Subject: [PATCH 32/60] refactor: package rename data_collector to aggregated_collector --- .../__init__.py | 0 .../queries.py | 0 .../tasks.py | 4 ++-- .../urls.py | 4 ++-- .../utils.py | 0 .../v1/__init__.py | 0 .../v1/permissions.py | 6 +++--- eox_core/api/data/aggregated_collector/v1/urls.py | 11 +++++++++++ .../v1/views.py | 14 +++++++------- eox_core/api/data/data_collector/v1/urls.py | 11 ----------- eox_core/api/data/v1/urls.py | 2 +- 11 files changed, 26 insertions(+), 26 deletions(-) rename eox_core/api/data/{data_collector => aggregated_collector}/__init__.py (100%) rename eox_core/api/data/{data_collector => aggregated_collector}/queries.py (100%) rename eox_core/api/data/{data_collector => aggregated_collector}/tasks.py (89%) rename eox_core/api/data/{data_collector => aggregated_collector}/urls.py (59%) rename eox_core/api/data/{data_collector => aggregated_collector}/utils.py (100%) rename eox_core/api/data/{data_collector => aggregated_collector}/v1/__init__.py (100%) rename eox_core/api/data/{data_collector => aggregated_collector}/v1/permissions.py (75%) create mode 100644 eox_core/api/data/aggregated_collector/v1/urls.py rename eox_core/api/data/{data_collector => aggregated_collector}/v1/views.py (77%) delete mode 100644 eox_core/api/data/data_collector/v1/urls.py diff --git a/eox_core/api/data/data_collector/__init__.py b/eox_core/api/data/aggregated_collector/__init__.py similarity index 100% rename from eox_core/api/data/data_collector/__init__.py rename to eox_core/api/data/aggregated_collector/__init__.py diff --git a/eox_core/api/data/data_collector/queries.py b/eox_core/api/data/aggregated_collector/queries.py similarity index 100% rename from eox_core/api/data/data_collector/queries.py rename to eox_core/api/data/aggregated_collector/queries.py diff --git a/eox_core/api/data/data_collector/tasks.py b/eox_core/api/data/aggregated_collector/tasks.py similarity index 89% rename from eox_core/api/data/data_collector/tasks.py rename to eox_core/api/data/aggregated_collector/tasks.py index a6b2f8c35..c35b8d460 100644 --- a/eox_core/api/data/data_collector/tasks.py +++ b/eox_core/api/data/aggregated_collector/tasks.py @@ -8,8 +8,8 @@ from celery import shared_task from django.db.utils import DatabaseError, OperationalError -from eox_core.api.data.data_collector.queries import PREDEFINED_QUERIES -from eox_core.api.data.data_collector.utils import execute_query, post_data_to_api, post_process_query_results +from eox_core.api.data.aggregated_collector.queries import PREDEFINED_QUERIES +from eox_core.api.data.aggregated_collector.utils import execute_query, post_data_to_api, post_process_query_results logger = logging.getLogger(__name__) diff --git a/eox_core/api/data/data_collector/urls.py b/eox_core/api/data/aggregated_collector/urls.py similarity index 59% rename from eox_core/api/data/data_collector/urls.py rename to eox_core/api/data/aggregated_collector/urls.py index 77b523045..cb2ef2c76 100644 --- a/eox_core/api/data/data_collector/urls.py +++ b/eox_core/api/data/aggregated_collector/urls.py @@ -2,7 +2,7 @@ URL configuration for the Microsite API. This module defines the URL patterns for the Microsite API, -including versioned endpoints for data_collector. +including versioned endpoints for aggregated_collector. """ from django.urls import include, re_path @@ -10,5 +10,5 @@ urlpatterns = [ # pylint: disable=invalid-name - re_path(r'^v1/', include('eox_core.api.data.data_collector.v1.urls', namespace='eox-data-api-collector-v1')), + re_path(r'^v1/', include('eox_core.api.data.aggregated_collector.v1.urls', namespace='eox-data-api-collector-v1')), ] diff --git a/eox_core/api/data/data_collector/utils.py b/eox_core/api/data/aggregated_collector/utils.py similarity index 100% rename from eox_core/api/data/data_collector/utils.py rename to eox_core/api/data/aggregated_collector/utils.py diff --git a/eox_core/api/data/data_collector/v1/__init__.py b/eox_core/api/data/aggregated_collector/v1/__init__.py similarity index 100% rename from eox_core/api/data/data_collector/v1/__init__.py rename to eox_core/api/data/aggregated_collector/v1/__init__.py diff --git a/eox_core/api/data/data_collector/v1/permissions.py b/eox_core/api/data/aggregated_collector/v1/permissions.py similarity index 75% rename from eox_core/api/data/data_collector/v1/permissions.py rename to eox_core/api/data/aggregated_collector/v1/permissions.py index eacf48a02..cb25002f2 100644 --- a/eox_core/api/data/data_collector/v1/permissions.py +++ b/eox_core/api/data/aggregated_collector/v1/permissions.py @@ -1,5 +1,5 @@ """ -Custom permission classes for the Data Collector API. +Custom permission classes for the Aggregated Collector API. """ import logging @@ -11,14 +11,14 @@ logger = logging.getLogger(__name__) -class DatacollectorPermission(BasePermission): +class AggregatedCollectorPermission(BasePermission): """ Permission class to allow access only if the request contains a valid GitHub Action token. """ def has_permission(self, request, view): auth_header = get_authorization_header(request).decode('utf-8') - auth_token = settings.EOX_CORE_DATA_COLLECT_AUTH_TOKEN + auth_token = settings.EOX_CORE_AGGREGATED_COLLECT_AUTH_TOKEN if auth_header and auth_header == f"Bearer {auth_token}": return True return False diff --git a/eox_core/api/data/aggregated_collector/v1/urls.py b/eox_core/api/data/aggregated_collector/v1/urls.py new file mode 100644 index 000000000..15d69e396 --- /dev/null +++ b/eox_core/api/data/aggregated_collector/v1/urls.py @@ -0,0 +1,11 @@ +"""_""" +from django.urls import path + +from eox_core.api.data.aggregated_collector.v1.views import AggregatedCollectorView + +# pylint: disable=invalid-name +app_name = "aggregated_collector" + +urlpatterns = [ + path("aggregated-collector/", AggregatedCollectorView.as_view(), name="aggregated_collector"), +] diff --git a/eox_core/api/data/data_collector/v1/views.py b/eox_core/api/data/aggregated_collector/v1/views.py similarity index 77% rename from eox_core/api/data/data_collector/v1/views.py rename to eox_core/api/data/aggregated_collector/v1/views.py index 788dd6bb9..f2a94ed5e 100644 --- a/eox_core/api/data/data_collector/v1/views.py +++ b/eox_core/api/data/aggregated_collector/v1/views.py @@ -1,5 +1,5 @@ """ -Views for the Data Collector API (v1). +Views for the Aggregated Collector API (v1). This module defines the API views for collecting and processing data. """ @@ -11,13 +11,13 @@ from rest_framework.response import Response from rest_framework.views import APIView -from eox_core.api.data.data_collector.tasks import generate_report -from eox_core.api.data.data_collector.v1.permissions import DatacollectorPermission +from eox_core.api.data.aggregated_collector.tasks import generate_report +from eox_core.api.data.aggregated_collector.v1.permissions import AggregatedCollectorPermission logger = logging.getLogger(__name__) -class DataCollectorView(APIView): +class AggregatedCollectorView(APIView): """ API view to handle data collection requests. @@ -27,7 +27,7 @@ class DataCollectorView(APIView): This view: - Triggers an async task to execute queries and send results to a specified destination. """ - permission_classes = [DatacollectorPermission] + permission_classes = [AggregatedCollectorPermission] def post(self, request): """ @@ -45,8 +45,8 @@ def post(self, request): status=status.HTTP_403_FORBIDDEN ) - destination_url = getattr(settings, "EOX_CORE_DATA_COLLECT_DESTINATION_URL", None) - token_generation_url = getattr(settings, "EOX_CORE_DATA_COLLECT_TOKEN_URL", None) + destination_url = getattr(settings, "EOX_CORE_AGGREGATED_COLLECT_DESTINATION_URL", None) + token_generation_url = getattr(settings, "EOX_CORE_AGGREGATED_COLLECT_TOKEN_URL", None) if not destination_url or not token_generation_url: logger.error("Data collection settings are missing.") diff --git a/eox_core/api/data/data_collector/v1/urls.py b/eox_core/api/data/data_collector/v1/urls.py deleted file mode 100644 index 4a518fdbb..000000000 --- a/eox_core/api/data/data_collector/v1/urls.py +++ /dev/null @@ -1,11 +0,0 @@ -"""_""" -from django.urls import path - -from eox_core.api.data.data_collector.v1.views import DataCollectorView - -# pylint: disable=invalid-name -app_name = "data_collector" - -urlpatterns = [ - path("collect-data/", DataCollectorView.as_view(), name="collect_data"), -] diff --git a/eox_core/api/data/v1/urls.py b/eox_core/api/data/v1/urls.py index 292589f20..11cb207ba 100644 --- a/eox_core/api/data/v1/urls.py +++ b/eox_core/api/data/v1/urls.py @@ -11,5 +11,5 @@ urlpatterns = [ # pylint: disable=invalid-name re_path(r'^v1/', include((ROUTER.urls, 'eox_core'), namespace='eox-data-api-v1')), re_path(r'^v1/tasks/(?P<task_id>.*)$', CeleryTasksStatus.as_view(), name="celery-data-api-tasks"), - re_path(r'^', include('eox_core.api.data.data_collector.urls', namespace='eox-data-api-collector')), + re_path(r'^', include('eox_core.api.data.aggregated_collector.urls', namespace='eox-data-api-collector')), ] From eece34523bd510efc7c964b1e6013842d5c25edd Mon Sep 17 00:00:00 2001 From: Luis Felipe Castano <felipe.castano@edunext.co> Date: Thu, 27 Feb 2025 00:08:03 -0500 Subject: [PATCH 33/60] feat: add aggregated collecor test --- .../aggregated_collector/v1/tests/__init__.py | 2 + .../v1/tests/test_views.py | 66 +++++++++++++++++++ 2 files changed, 68 insertions(+) create mode 100644 eox_core/api/data/aggregated_collector/v1/tests/__init__.py create mode 100644 eox_core/api/data/aggregated_collector/v1/tests/test_views.py diff --git a/eox_core/api/data/aggregated_collector/v1/tests/__init__.py b/eox_core/api/data/aggregated_collector/v1/tests/__init__.py new file mode 100644 index 000000000..faa18be5b --- /dev/null +++ b/eox_core/api/data/aggregated_collector/v1/tests/__init__.py @@ -0,0 +1,2 @@ +#!/usr/bin/env python +# -*- coding: utf-8 -*- diff --git a/eox_core/api/data/aggregated_collector/v1/tests/test_views.py b/eox_core/api/data/aggregated_collector/v1/tests/test_views.py new file mode 100644 index 000000000..66f127d36 --- /dev/null +++ b/eox_core/api/data/aggregated_collector/v1/tests/test_views.py @@ -0,0 +1,66 @@ +import pytest +from unittest.mock import patch +from django.conf import settings +from rest_framework.test import APIClient +from rest_framework import status +from eox_core.api.data.aggregated_collector.tasks import generate_report + +@pytest.fixture +def api_client(): + return APIClient() + +@pytest.fixture +def api_url(): + return "/eox-core/data-api/v1/aggregated-collector/" + +@pytest.mark.django_db +class TestAggregatedCollectorView: + + @pytest.fixture(autouse=True) + def setup(self): + """Set default values for settings before each test.""" + self.default_settings = { + "AGGREGATED_DATA_COLLECTOR_API_ENABLED": True, + "EOX_CORE_AGGREGATED_COLLECT_DESTINATION_URL": "https://example.com/destination", + "EOX_CORE_AGGREGATED_COLLECT_TOKEN_URL": "https://example.com/token", + "EOX_CORE_DATA_COLLECT_AUTH_TOKEN": "valid_token" + } + settings.configure(**self.default_settings) + + def test_endpoint_disabled(self, api_client, api_url): + """Should return 403 if the API is disabled in settings.""" + settings.AGGREGATED_DATA_COLLECTOR_API_ENABLED = False + + response = api_client.post(api_url) + + assert response.status_code == status.HTTP_403_FORBIDDEN + assert response.data["error"] == "This endpoint is currently disabled." + + def test_missing_settings(self, api_client, api_url): + """Should return 500 if required settings are missing.""" + settings.EOX_CORE_AGGREGATED_COLLECT_DESTINATION_URL = None + settings.EOX_CORE_AGGREGATED_COLLECT_TOKEN_URL = None + + response = api_client.post(api_url) + + assert response.status_code == status.HTTP_500_INTERNAL_SERVER_ERROR + assert response.data["error"] == "Data collection settings are not properly configured." + + @patch("eox_core.api.data.aggregated_collector.tasks.generate_report.delay") + def test_successful_request(self, mock_generate_report, api_client, api_url): + """Should return 202 and trigger the async task correctly.""" + response = api_client.post(api_url) + + assert response.status_code == status.HTTP_202_ACCEPTED + assert response.data["message"] == "Data collection task has been initiated successfully." + mock_generate_report.assert_called_once_with( + "https://example.com/destination", + "https://example.com/token", + "testserver" + ) + + def test_unauthorized_request(self, api_client, api_url): + """Should return 403 if no authentication token is provided.""" + response = api_client.post(api_url, HTTP_AUTHORIZATION="") + + assert response.status_code == status.HTTP_403_FORBIDDEN From 340bb60f362436ed64c2916781d49eeded3195de Mon Sep 17 00:00:00 2001 From: Luis Felipe Castano <felipe.castano@edunext.co> Date: Thu, 27 Feb 2025 00:13:45 -0500 Subject: [PATCH 34/60] feat: add aggregated collecor test --- .../aggregated_collector/v1/tests/test_views.py | 13 +++++-------- 1 file changed, 5 insertions(+), 8 deletions(-) diff --git a/eox_core/api/data/aggregated_collector/v1/tests/test_views.py b/eox_core/api/data/aggregated_collector/v1/tests/test_views.py index 66f127d36..a49870a34 100644 --- a/eox_core/api/data/aggregated_collector/v1/tests/test_views.py +++ b/eox_core/api/data/aggregated_collector/v1/tests/test_views.py @@ -17,15 +17,12 @@ def api_url(): class TestAggregatedCollectorView: @pytest.fixture(autouse=True) - def setup(self): + def setup(settings): """Set default values for settings before each test.""" - self.default_settings = { - "AGGREGATED_DATA_COLLECTOR_API_ENABLED": True, - "EOX_CORE_AGGREGATED_COLLECT_DESTINATION_URL": "https://example.com/destination", - "EOX_CORE_AGGREGATED_COLLECT_TOKEN_URL": "https://example.com/token", - "EOX_CORE_DATA_COLLECT_AUTH_TOKEN": "valid_token" - } - settings.configure(**self.default_settings) + settings.AGGREGATED_DATA_COLLECTOR_API_ENABLED = True + settings.EOX_CORE_AGGREGATED_COLLECT_DESTINATION_URL = "https://example.com/destination" + settings.EOX_CORE_AGGREGATED_COLLECT_TOKEN_URL = "https://example.com/token" + settings.EOX_CORE_DATA_COLLECT_AUTH_TOKEN = "valid_token" def test_endpoint_disabled(self, api_client, api_url): """Should return 403 if the API is disabled in settings.""" From 77ca34cbdc17c565c8fcbcaee5163739f30ff8f8 Mon Sep 17 00:00:00 2001 From: Luis Felipe Castano <felipe.castano@edunext.co> Date: Thu, 27 Feb 2025 11:16:31 -0500 Subject: [PATCH 35/60] refactor: add type hints --- eox_core/api/data/aggregated_collector/tasks.py | 2 +- eox_core/api/data/aggregated_collector/utils.py | 6 +++--- eox_core/api/data/aggregated_collector/v1/views.py | 3 ++- 3 files changed, 6 insertions(+), 5 deletions(-) diff --git a/eox_core/api/data/aggregated_collector/tasks.py b/eox_core/api/data/aggregated_collector/tasks.py index c35b8d460..ddc314ba5 100644 --- a/eox_core/api/data/aggregated_collector/tasks.py +++ b/eox_core/api/data/aggregated_collector/tasks.py @@ -18,7 +18,7 @@ @shared_task(bind=True) -def generate_report(self, destination_url, token_generation_url, current_host): +def generate_report(self, destination_url: str, token_generation_url: str, current_host: str): """ Async task to generate a report: 1. Executes all predefined queries. diff --git a/eox_core/api/data/aggregated_collector/utils.py b/eox_core/api/data/aggregated_collector/utils.py index 0170a43d3..c3a51289d 100644 --- a/eox_core/api/data/aggregated_collector/utils.py +++ b/eox_core/api/data/aggregated_collector/utils.py @@ -15,7 +15,7 @@ logger = logging.getLogger(__name__) -def execute_query(sql_query): +def execute_query(sql_query: str): """ Execute a raw SQL query and return the results in a structured format. @@ -44,7 +44,7 @@ def execute_query(sql_query): return rows -def post_process_query_results(data): +def post_process_query_results(data: any): """ Cleans and processes query results by: - Serializing datetime objects into strings. @@ -69,7 +69,7 @@ def post_process_query_results(data): return data -def post_data_to_api(api_url, report_data, token_generation_url, current_host): +def post_data_to_api(api_url: str, report_data: dict, token_generation_url: str, current_host: str): """ Sends the generated report data to the Shipyard API. diff --git a/eox_core/api/data/aggregated_collector/v1/views.py b/eox_core/api/data/aggregated_collector/v1/views.py index f2a94ed5e..a680a1b12 100644 --- a/eox_core/api/data/aggregated_collector/v1/views.py +++ b/eox_core/api/data/aggregated_collector/v1/views.py @@ -7,6 +7,7 @@ import logging from django.conf import settings +from django.http import HttpRequest from rest_framework import status from rest_framework.response import Response from rest_framework.views import APIView @@ -29,7 +30,7 @@ class AggregatedCollectorView(APIView): """ permission_classes = [AggregatedCollectorPermission] - def post(self, request): + def post(self, request: HttpRequest) -> Response: """ Handles POST requests to collect data. From 45fcc361d27d8d58ad8dc73d5b4bb5ebe1dcc992 Mon Sep 17 00:00:00 2001 From: Luis Felipe Castano <felipe.castano@edunext.co> Date: Thu, 27 Feb 2025 11:47:45 -0500 Subject: [PATCH 36/60] feat: add aggregated collecor test --- .../api/data/aggregated_collector/v1/tests/test_views.py | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/eox_core/api/data/aggregated_collector/v1/tests/test_views.py b/eox_core/api/data/aggregated_collector/v1/tests/test_views.py index a49870a34..388fc51a3 100644 --- a/eox_core/api/data/aggregated_collector/v1/tests/test_views.py +++ b/eox_core/api/data/aggregated_collector/v1/tests/test_views.py @@ -1,6 +1,7 @@ import pytest from unittest.mock import patch from django.conf import settings +from django.urls import reverse from rest_framework.test import APIClient from rest_framework import status from eox_core.api.data.aggregated_collector.tasks import generate_report @@ -11,13 +12,13 @@ def api_client(): @pytest.fixture def api_url(): - return "/eox-core/data-api/v1/aggregated-collector/" + return reverse('eox-data-api:eox-data-api-collector-v1:aggregated_collector') @pytest.mark.django_db class TestAggregatedCollectorView: @pytest.fixture(autouse=True) - def setup(settings): + def setup(self, settings): """Set default values for settings before each test.""" settings.AGGREGATED_DATA_COLLECTOR_API_ENABLED = True settings.EOX_CORE_AGGREGATED_COLLECT_DESTINATION_URL = "https://example.com/destination" From 9415b1d76f86941608bac2e39b51a6a55c29bb60 Mon Sep 17 00:00:00 2001 From: Luis Felipe Castano <felipe.castano@edunext.co> Date: Thu, 27 Feb 2025 14:53:49 -0500 Subject: [PATCH 37/60] feat: add aggregated collecor test --- .../v1/tests/test_views.py | 88 +++++++------------ 1 file changed, 31 insertions(+), 57 deletions(-) diff --git a/eox_core/api/data/aggregated_collector/v1/tests/test_views.py b/eox_core/api/data/aggregated_collector/v1/tests/test_views.py index 388fc51a3..5e8901acf 100644 --- a/eox_core/api/data/aggregated_collector/v1/tests/test_views.py +++ b/eox_core/api/data/aggregated_collector/v1/tests/test_views.py @@ -1,64 +1,38 @@ -import pytest -from unittest.mock import patch +from unittest.mock import patch, MagicMock +from django.test import TestCase from django.conf import settings -from django.urls import reverse from rest_framework.test import APIClient from rest_framework import status +from eox_core.api.data.aggregated_collector.utils import execute_query from eox_core.api.data.aggregated_collector.tasks import generate_report +from eox_core.api.data.aggregated_collector.v1.views import AggregatedCollectorView -@pytest.fixture -def api_client(): - return APIClient() - -@pytest.fixture -def api_url(): - return reverse('eox-data-api:eox-data-api-collector-v1:aggregated_collector') - -@pytest.mark.django_db -class TestAggregatedCollectorView: - - @pytest.fixture(autouse=True) - def setup(self, settings): - """Set default values for settings before each test.""" +class AggregatedCollectorTests(TestCase): + def setUp(self): + self.client = APIClient() + self.url = "/v1/aggregated-collector/" settings.AGGREGATED_DATA_COLLECTOR_API_ENABLED = True - settings.EOX_CORE_AGGREGATED_COLLECT_DESTINATION_URL = "https://example.com/destination" - settings.EOX_CORE_AGGREGATED_COLLECT_TOKEN_URL = "https://example.com/token" - settings.EOX_CORE_DATA_COLLECT_AUTH_TOKEN = "valid_token" - - def test_endpoint_disabled(self, api_client, api_url): - """Should return 403 if the API is disabled in settings.""" - settings.AGGREGATED_DATA_COLLECTOR_API_ENABLED = False - - response = api_client.post(api_url) + settings.EOX_CORE_AGGREGATED_COLLECT_DESTINATION_URL = "http://mock-api.com" + settings.EOX_CORE_AGGREGATED_COLLECT_TOKEN_URL = "http://mock-token.com" + settings.EOX_CORE_AGGREGATED_COLLECT_AUTH_TOKEN = "test-token" + + @patch("eox_core.api.data.aggregated_collector.utils.connection.cursor") + def test_execute_query(self, mock_cursor): + mock_cursor.return_value.__enter__.return_value.fetchall.return_value = [(1, "test_user")] + mock_cursor.return_value.__enter__.return_value.description = [("id",), ("username",)] - assert response.status_code == status.HTTP_403_FORBIDDEN - assert response.data["error"] == "This endpoint is currently disabled." - - def test_missing_settings(self, api_client, api_url): - """Should return 500 if required settings are missing.""" - settings.EOX_CORE_AGGREGATED_COLLECT_DESTINATION_URL = None - settings.EOX_CORE_AGGREGATED_COLLECT_TOKEN_URL = None - - response = api_client.post(api_url) - - assert response.status_code == status.HTTP_500_INTERNAL_SERVER_ERROR - assert response.data["error"] == "Data collection settings are not properly configured." - - @patch("eox_core.api.data.aggregated_collector.tasks.generate_report.delay") - def test_successful_request(self, mock_generate_report, api_client, api_url): - """Should return 202 and trigger the async task correctly.""" - response = api_client.post(api_url) - - assert response.status_code == status.HTTP_202_ACCEPTED - assert response.data["message"] == "Data collection task has been initiated successfully." - mock_generate_report.assert_called_once_with( - "https://example.com/destination", - "https://example.com/token", - "testserver" - ) - - def test_unauthorized_request(self, api_client, api_url): - """Should return 403 if no authentication token is provided.""" - response = api_client.post(api_url, HTTP_AUTHORIZATION="") - - assert response.status_code == status.HTTP_403_FORBIDDEN + result = execute_query("SELECT id, username FROM auth_user;") + self.assertEqual(result, [{"id": 1, "username": "test_user"}]) + + @patch("eox_core.api.data.aggregated_collector.tasks.execute_query") + @patch("eox_core.api.data.aggregated_collector.tasks.post_data_to_api") + def test_generate_report(self, mock_post, mock_execute): + mock_execute.return_value = [{"id": 1, "data": "sample"}] + generate_report("http://mock-api.com", "http://mock-token.com", "localhost") + mock_post.assert_called_once() + + @patch("eox_core.api.data.aggregated_collector.v1.views.generate_report.delay") + def test_aggregated_collector_view(self, mock_task): + response = self.client.post(self.url, HTTP_AUTHORIZATION=f"Bearer {settings.EOX_CORE_AGGREGATED_COLLECT_AUTH_TOKEN}") + self.assertEqual(response.status_code, status.HTTP_202_ACCEPTED) + mock_task.assert_called_once() From 67144c1ea3becf18df53a4a6013dc3ebf37df11e Mon Sep 17 00:00:00 2001 From: Luis Felipe Castano <felipe.castano@edunext.co> Date: Thu, 27 Feb 2025 15:03:00 -0500 Subject: [PATCH 38/60] feat: add aggregated collecor test --- eox_core/api/data/aggregated_collector/v1/tests/test_views.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/eox_core/api/data/aggregated_collector/v1/tests/test_views.py b/eox_core/api/data/aggregated_collector/v1/tests/test_views.py index 5e8901acf..89dc056dc 100644 --- a/eox_core/api/data/aggregated_collector/v1/tests/test_views.py +++ b/eox_core/api/data/aggregated_collector/v1/tests/test_views.py @@ -10,7 +10,7 @@ class AggregatedCollectorTests(TestCase): def setUp(self): self.client = APIClient() - self.url = "/v1/aggregated-collector/" + self.url = "/eox-core/data-api/v1/aggregated-collector/" settings.AGGREGATED_DATA_COLLECTOR_API_ENABLED = True settings.EOX_CORE_AGGREGATED_COLLECT_DESTINATION_URL = "http://mock-api.com" settings.EOX_CORE_AGGREGATED_COLLECT_TOKEN_URL = "http://mock-token.com" From 562353ea5216d6bd72fe8b8b18df95891b283f0f Mon Sep 17 00:00:00 2001 From: Luis Felipe Castano <felipe.castano@edunext.co> Date: Thu, 27 Feb 2025 15:19:02 -0500 Subject: [PATCH 39/60] feat: fix aggregated collecor test --- eox_core/api/data/aggregated_collector/v1/tests/test_views.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/eox_core/api/data/aggregated_collector/v1/tests/test_views.py b/eox_core/api/data/aggregated_collector/v1/tests/test_views.py index 89dc056dc..dfe91dbb4 100644 --- a/eox_core/api/data/aggregated_collector/v1/tests/test_views.py +++ b/eox_core/api/data/aggregated_collector/v1/tests/test_views.py @@ -1,6 +1,7 @@ from unittest.mock import patch, MagicMock from django.test import TestCase from django.conf import settings +from django.urls import reverse from rest_framework.test import APIClient from rest_framework import status from eox_core.api.data.aggregated_collector.utils import execute_query @@ -10,7 +11,7 @@ class AggregatedCollectorTests(TestCase): def setUp(self): self.client = APIClient() - self.url = "/eox-core/data-api/v1/aggregated-collector/" + self.url = reverse("eox_core.api.data.v1.urls:eox-data-api-collector-v1:aggregated_collector") settings.AGGREGATED_DATA_COLLECTOR_API_ENABLED = True settings.EOX_CORE_AGGREGATED_COLLECT_DESTINATION_URL = "http://mock-api.com" settings.EOX_CORE_AGGREGATED_COLLECT_TOKEN_URL = "http://mock-token.com" From 543c3c62481ca0e45404a5f728ff5bf898c9791d Mon Sep 17 00:00:00 2001 From: Luis Felipe Castano <felipe.castano@edunext.co> Date: Thu, 27 Feb 2025 15:25:22 -0500 Subject: [PATCH 40/60] feat: fix aggregated collecor test --- eox_core/api/data/aggregated_collector/v1/tests/test_views.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/eox_core/api/data/aggregated_collector/v1/tests/test_views.py b/eox_core/api/data/aggregated_collector/v1/tests/test_views.py index dfe91dbb4..a0d1d87d2 100644 --- a/eox_core/api/data/aggregated_collector/v1/tests/test_views.py +++ b/eox_core/api/data/aggregated_collector/v1/tests/test_views.py @@ -11,7 +11,7 @@ class AggregatedCollectorTests(TestCase): def setUp(self): self.client = APIClient() - self.url = reverse("eox_core.api.data.v1.urls:eox-data-api-collector-v1:aggregated_collector") + self.url = reverse("eox-data-api:eox-data-api-collector-v1:aggregated_collector") settings.AGGREGATED_DATA_COLLECTOR_API_ENABLED = True settings.EOX_CORE_AGGREGATED_COLLECT_DESTINATION_URL = "http://mock-api.com" settings.EOX_CORE_AGGREGATED_COLLECT_TOKEN_URL = "http://mock-token.com" From d3068db110c882eca625e6f7f4015fab49ad80a8 Mon Sep 17 00:00:00 2001 From: Luis Felipe Castano <felipe.castano@edunext.co> Date: Thu, 27 Feb 2025 15:40:33 -0500 Subject: [PATCH 41/60] feat: fix aggregated collecor test --- eox_core/api/data/aggregated_collector/v1/tests/test_views.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/eox_core/api/data/aggregated_collector/v1/tests/test_views.py b/eox_core/api/data/aggregated_collector/v1/tests/test_views.py index a0d1d87d2..51ace0ed0 100644 --- a/eox_core/api/data/aggregated_collector/v1/tests/test_views.py +++ b/eox_core/api/data/aggregated_collector/v1/tests/test_views.py @@ -11,7 +11,7 @@ class AggregatedCollectorTests(TestCase): def setUp(self): self.client = APIClient() - self.url = reverse("eox-data-api:eox-data-api-collector-v1:aggregated_collector") + self.url = reverse("eox-data-api-collector-v1:aggregated_collector") settings.AGGREGATED_DATA_COLLECTOR_API_ENABLED = True settings.EOX_CORE_AGGREGATED_COLLECT_DESTINATION_URL = "http://mock-api.com" settings.EOX_CORE_AGGREGATED_COLLECT_TOKEN_URL = "http://mock-token.com" From 855d4407f8fcef4aae2bf5f04989f3764db87516 Mon Sep 17 00:00:00 2001 From: Luis Felipe Castano <felipe.castano@edunext.co> Date: Fri, 28 Feb 2025 09:07:25 -0500 Subject: [PATCH 42/60] chore: add improvements --- CHANGELOG.md | 2 +- .../api/data/aggregated_collector/queries.py | 11 +++- .../api/data/aggregated_collector/utils.py | 11 ++-- .../aggregated_collector/v1/permissions.py | 11 +++- .../aggregated_collector/v1/tests/__init__.py | 2 - .../v1/tests/test_utils.py | 56 +++++++++++++++++++ .../v1/tests/test_views.py | 27 +++++---- .../api/data/aggregated_collector/v1/views.py | 16 ++---- 8 files changed, 103 insertions(+), 33 deletions(-) create mode 100644 eox_core/api/data/aggregated_collector/v1/tests/test_utils.py diff --git a/CHANGELOG.md b/CHANGELOG.md index 81d134526..e2dff07bc 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -15,7 +15,7 @@ Please do not update the unreleased notes. ### Added -- API to collect data and generate reports. +- New API endpoint to support data collection and report generation (if the feature is enabled). ## [v11.1.0](https://github.com/eduNEXT/eox-core/compare/v11.0.0...v11.1.0) - (2024-11-21) diff --git a/eox_core/api/data/aggregated_collector/queries.py b/eox_core/api/data/aggregated_collector/queries.py index 91e6f7497..21a263dc0 100644 --- a/eox_core/api/data/aggregated_collector/queries.py +++ b/eox_core/api/data/aggregated_collector/queries.py @@ -14,6 +14,7 @@ FROM auth_user as au; """ + # This query counts the number of unique active users in the last month. # A user is considered active if they have modified a student module within the last month. ACTIVE_USERS_LAST_MONTH_QUERY = """ @@ -29,6 +30,7 @@ GROUP BY YEAR(cs.modified), MONTH(cs.modified); """ + # This query counts the number of unique active users in the last 7 days. # A user is considered active if they have modified a student module within the last 7 days. ACTIVE_USERS_LAST_7_DAYS_QUERY = """ @@ -39,6 +41,7 @@ WHERE cs.modified >= DATE_SUB(CURDATE(), INTERVAL 7 DAY); """ + # This query counts the total number of courses created on the platform. TOTAL_COURSES_CREATED_QUERY = """ SELECT @@ -46,6 +49,7 @@ FROM course_overviews_courseoverview as coc; """ + # This query counts the number of CourseOverviews objects that started before # now and have not yet ended ACTIVE_COURSES_COUNT_QUERY = """ @@ -60,6 +64,7 @@ OR coc.end IS NULL ); """ + # This query counts the number of courses that have at least one issued certificate. COURSES_WITH_ACTIVE_CERTIFICATES_QUERY = """ SELECT @@ -71,6 +76,7 @@ ON coc.id = cg.course_id; """ + # This query counts the number of new enrollments in the last month. ENROLLMENTS_LAST_MONTH_QUERY = """ SELECT @@ -85,6 +91,7 @@ GROUP BY YEAR(sc.created), MONTH(sc.created); """ + # This query counts the number of new enrollments in the last 7 days. ENROLLMENTS_LAST_7_DAYS_QUERY = """ SELECT @@ -94,13 +101,15 @@ WHERE sc.created >= DATE_SUB(CURDATE(), INTERVAL 7 DAY); """ + +# This query counts the total number of certificates issued on the platform. CERTIFICATES_ISSUED_QUERY = """ SELECT COUNT(*) FROM certificates_generatedcertificate as cg; """ -# This query counts the total number of certificates issued on the platform. + PREDEFINED_QUERIES = { "Total Users": TOTAL_USERS_QUERY, "Active Users Last Month": ACTIVE_USERS_LAST_MONTH_QUERY, diff --git a/eox_core/api/data/aggregated_collector/utils.py b/eox_core/api/data/aggregated_collector/utils.py index c3a51289d..8a2915e72 100644 --- a/eox_core/api/data/aggregated_collector/utils.py +++ b/eox_core/api/data/aggregated_collector/utils.py @@ -1,6 +1,5 @@ """ -Utility functions for report generation, including query execution -and integration with the Shipyard API. +Utility functions for report generation, including query execution and data posting. """ import logging @@ -81,8 +80,8 @@ def post_data_to_api(api_url: str, report_data: dict, token_generation_url: str, """ token = get_access_token( token_generation_url, - settings.EOX_CORE_AGGREGATED_DATA_API_CLIENT_ID, - settings.EOX_CORE_AGGREGATED_DATA_API_CLIENT_SECRET, + settings.EOX_CORE_AGGREGATED_COLLECTOR_TARGET_CLIENT_ID, + settings.EOX_CORE_AGGREGATED_COLLECTOR_TARGET_CLIENT_SECRET, ) headers = { "Authorization": f"Bearer {token}", @@ -95,6 +94,6 @@ def post_data_to_api(api_url: str, report_data: dict, token_generation_url: str, response = requests.post(api_url, json=payload, headers=headers, timeout=10) response.raise_for_status() except requests.Timeout as exc: - raise requests.Timeout("The request to Shipyard API timed out.") from exc + raise requests.Timeout("The request to API timed out.") from exc except requests.RequestException as e: - raise requests.RequestException(f"Failed to post data to Shipyard API: {e}") + raise requests.RequestException(f"Failed to post data to API: {e}") diff --git a/eox_core/api/data/aggregated_collector/v1/permissions.py b/eox_core/api/data/aggregated_collector/v1/permissions.py index cb25002f2..be3fb82e8 100644 --- a/eox_core/api/data/aggregated_collector/v1/permissions.py +++ b/eox_core/api/data/aggregated_collector/v1/permissions.py @@ -13,12 +13,19 @@ class AggregatedCollectorPermission(BasePermission): """ - Permission class to allow access only if the request contains a valid GitHub Action token. + Permission class to allow access only if: + - The AGGREGATED_DATA_COLLECTOR_API_ENABLED setting is True. + - The request contains a valid GitHub Action token. """ def has_permission(self, request, view): + # Check if the API is enabled + if not getattr(settings, "AGGREGATED_DATA_COLLECTOR_API_ENABLED", False): + return False + + # Check if the request contains a valid token auth_header = get_authorization_header(request).decode('utf-8') - auth_token = settings.EOX_CORE_AGGREGATED_COLLECT_AUTH_TOKEN + auth_token = settings.EOX_CORE_AGGREGATED_COLLECTOR_AUTH_TOKEN if auth_header and auth_header == f"Bearer {auth_token}": return True return False diff --git a/eox_core/api/data/aggregated_collector/v1/tests/__init__.py b/eox_core/api/data/aggregated_collector/v1/tests/__init__.py index faa18be5b..e69de29bb 100644 --- a/eox_core/api/data/aggregated_collector/v1/tests/__init__.py +++ b/eox_core/api/data/aggregated_collector/v1/tests/__init__.py @@ -1,2 +0,0 @@ -#!/usr/bin/env python -# -*- coding: utf-8 -*- diff --git a/eox_core/api/data/aggregated_collector/v1/tests/test_utils.py b/eox_core/api/data/aggregated_collector/v1/tests/test_utils.py new file mode 100644 index 000000000..a2db714d3 --- /dev/null +++ b/eox_core/api/data/aggregated_collector/v1/tests/test_utils.py @@ -0,0 +1,56 @@ +""" +Test suite for Aggregated Data Collector API. +""" +from unittest.mock import patch, MagicMock +from django.test import TestCase +from eox_core.api.data.aggregated_collector.utils import execute_query, fetch_access_token + +class UtilsTests(TestCase): + @patch("eox_core.api.data.aggregated_collector.utils.connection.cursor") + def test_execute_query_success(self, mock_cursor): + """ + Test that execute_query returns the expected result when the query executes successfully. + """ + mock_cursor.return_value.__enter__.return_value.fetchall.return_value = [(1, "test_user")] + mock_cursor.return_value.__enter__.return_value.description = [("id",), ("username",)] + + result = execute_query("SELECT id, username FROM auth_user;") + expected_result = [{"id": 1, "username": "test_user"}] + + self.assertEqual(result, expected_result) + + @patch("eox_core.api.data.aggregated_collector.utils.connection.cursor") + def test_execute_query_failure(self, mock_cursor): + """ + Test that execute_query handles exceptions gracefully. + """ + mock_cursor.return_value.__enter__.side_effect = Exception("Database error") + + result = execute_query("SELECT id, username FROM auth_user;") + self.assertEqual(result, []) # Expected to return an empty list on failure + + @patch("eox_core.api.data.aggregated_collector.utils.requests.post") + def test_fetch_access_token_success(self, mock_post): + """ + Test that fetch_access_token correctly retrieves an access token. + """ + mock_response = MagicMock() + mock_response.status_code = 200 + mock_response.json.return_value = {"access_token": "mock_token"} + mock_post.return_value = mock_response + + token = fetch_access_token("http://mock-token.com", "client_id", "client_secret") + self.assertEqual(token, "mock_token") + + @patch("eox_core.api.data.aggregated_collector.utils.requests.post") + def test_fetch_access_token_failure(self, mock_post): + """ + Test that fetch_access_token returns None if the request fails. + """ + mock_response = MagicMock() + mock_response.status_code = 400 + mock_response.json.return_value = {"error": "invalid_request"} + mock_post.return_value = mock_response + + token = fetch_access_token("http://mock-token.com", "client_id", "client_secret") + self.assertIsNone(token) diff --git a/eox_core/api/data/aggregated_collector/v1/tests/test_views.py b/eox_core/api/data/aggregated_collector/v1/tests/test_views.py index 51ace0ed0..d68c8431f 100644 --- a/eox_core/api/data/aggregated_collector/v1/tests/test_views.py +++ b/eox_core/api/data/aggregated_collector/v1/tests/test_views.py @@ -1,3 +1,6 @@ +""" +Test suite for Aggregated Data Collector API. +""" from unittest.mock import patch, MagicMock from django.test import TestCase from django.conf import settings @@ -8,32 +11,34 @@ from eox_core.api.data.aggregated_collector.tasks import generate_report from eox_core.api.data.aggregated_collector.v1.views import AggregatedCollectorView -class AggregatedCollectorTests(TestCase): +class AggregatedCollectorViewTests(TestCase): def setUp(self): self.client = APIClient() - self.url = reverse("eox-data-api-collector-v1:aggregated_collector") settings.AGGREGATED_DATA_COLLECTOR_API_ENABLED = True - settings.EOX_CORE_AGGREGATED_COLLECT_DESTINATION_URL = "http://mock-api.com" - settings.EOX_CORE_AGGREGATED_COLLECT_TOKEN_URL = "http://mock-token.com" - settings.EOX_CORE_AGGREGATED_COLLECT_AUTH_TOKEN = "test-token" + settings.EOX_CORE_AGGREGATED_COLLECTOR_TARGET_URL = "http://mock-api.com" + settings.EOX_CORE_AGGREGATED_COLLECTOR_TARGET_TOKEN_URL = "http://mock-token.com" + settings.EOX_CORE_AGGREGATED_COLLECTOR_AUTH_TOKEN = "test-token" @patch("eox_core.api.data.aggregated_collector.utils.connection.cursor") def test_execute_query(self, mock_cursor): + """ + Test execute_query function to ensure it correctly fetches and formats SQL query results. + """ mock_cursor.return_value.__enter__.return_value.fetchall.return_value = [(1, "test_user")] mock_cursor.return_value.__enter__.return_value.description = [("id",), ("username",)] result = execute_query("SELECT id, username FROM auth_user;") + self.assertEqual(result, [{"id": 1, "username": "test_user"}]) @patch("eox_core.api.data.aggregated_collector.tasks.execute_query") @patch("eox_core.api.data.aggregated_collector.tasks.post_data_to_api") def test_generate_report(self, mock_post, mock_execute): + """ + Test generate_report function to ensure data is retrieved and posted correctly. + """ mock_execute.return_value = [{"id": 1, "data": "sample"}] + generate_report("http://mock-api.com", "http://mock-token.com", "localhost") - mock_post.assert_called_once() - @patch("eox_core.api.data.aggregated_collector.v1.views.generate_report.delay") - def test_aggregated_collector_view(self, mock_task): - response = self.client.post(self.url, HTTP_AUTHORIZATION=f"Bearer {settings.EOX_CORE_AGGREGATED_COLLECT_AUTH_TOKEN}") - self.assertEqual(response.status_code, status.HTTP_202_ACCEPTED) - mock_task.assert_called_once() + mock_post.assert_called_once() diff --git a/eox_core/api/data/aggregated_collector/v1/views.py b/eox_core/api/data/aggregated_collector/v1/views.py index a680a1b12..34fb313c7 100644 --- a/eox_core/api/data/aggregated_collector/v1/views.py +++ b/eox_core/api/data/aggregated_collector/v1/views.py @@ -40,17 +40,13 @@ def post(self, request: HttpRequest) -> Response: Returns: Response: A success or error message. """ - if not getattr(settings, "AGGREGATED_DATA_COLLECTOR_API_ENABLED", False): - return Response( - {"error": "This endpoint is currently disabled."}, - status=status.HTTP_403_FORBIDDEN - ) - - destination_url = getattr(settings, "EOX_CORE_AGGREGATED_COLLECT_DESTINATION_URL", None) - token_generation_url = getattr(settings, "EOX_CORE_AGGREGATED_COLLECT_TOKEN_URL", None) + destination_url = getattr(settings, "EOX_CORE_AGGREGATED_COLLECTOR_TARGET_URL", None) + token_generation_url = getattr(settings, "EOX_CORE_AGGREGATED_COLLECTOR_TARGET_TOKEN_URL", None) + client_id = getattr(settings, "EOX_CORE_AGGREGATED_COLLECTOR_TARGET_CLIENT_ID", None) + client_secret = getattr(settings, "EOX_CORE_AGGREGATED_COLLECTOR_TARGET_CLIENT_SECRET", None) - if not destination_url or not token_generation_url: - logger.error("Data collection settings are missing.") + if not all([destination_url, token_generation_url, client_id, client_secret]): + logger.error("Missing required Aggregated Data Collector settings.") return Response( {"error": "Data collection settings are not properly configured."}, status=status.HTTP_500_INTERNAL_SERVER_ERROR From 8bc6d13a56759445e767ac75b075e25d78607b98 Mon Sep 17 00:00:00 2001 From: Luis Felipe Castano <felipe.castano@edunext.co> Date: Fri, 28 Feb 2025 09:14:15 -0500 Subject: [PATCH 43/60] chore: add improvements --- .../v1/tests/test_utils.py | 28 +------------------ 1 file changed, 1 insertion(+), 27 deletions(-) diff --git a/eox_core/api/data/aggregated_collector/v1/tests/test_utils.py b/eox_core/api/data/aggregated_collector/v1/tests/test_utils.py index a2db714d3..e7c114447 100644 --- a/eox_core/api/data/aggregated_collector/v1/tests/test_utils.py +++ b/eox_core/api/data/aggregated_collector/v1/tests/test_utils.py @@ -3,7 +3,7 @@ """ from unittest.mock import patch, MagicMock from django.test import TestCase -from eox_core.api.data.aggregated_collector.utils import execute_query, fetch_access_token +from eox_core.api.data.aggregated_collector.utils import execute_query class UtilsTests(TestCase): @patch("eox_core.api.data.aggregated_collector.utils.connection.cursor") @@ -28,29 +28,3 @@ def test_execute_query_failure(self, mock_cursor): result = execute_query("SELECT id, username FROM auth_user;") self.assertEqual(result, []) # Expected to return an empty list on failure - - @patch("eox_core.api.data.aggregated_collector.utils.requests.post") - def test_fetch_access_token_success(self, mock_post): - """ - Test that fetch_access_token correctly retrieves an access token. - """ - mock_response = MagicMock() - mock_response.status_code = 200 - mock_response.json.return_value = {"access_token": "mock_token"} - mock_post.return_value = mock_response - - token = fetch_access_token("http://mock-token.com", "client_id", "client_secret") - self.assertEqual(token, "mock_token") - - @patch("eox_core.api.data.aggregated_collector.utils.requests.post") - def test_fetch_access_token_failure(self, mock_post): - """ - Test that fetch_access_token returns None if the request fails. - """ - mock_response = MagicMock() - mock_response.status_code = 400 - mock_response.json.return_value = {"error": "invalid_request"} - mock_post.return_value = mock_response - - token = fetch_access_token("http://mock-token.com", "client_id", "client_secret") - self.assertIsNone(token) From b619c7a79dacf6d0e1744dd35e853f480872c54f Mon Sep 17 00:00:00 2001 From: Luis Felipe Castano <felipe.castano@edunext.co> Date: Fri, 28 Feb 2025 09:22:55 -0500 Subject: [PATCH 44/60] chore: add improvements --- .../aggregated_collector/v1/tests/test_utils.py | 10 ---------- .../aggregated_collector/v1/tests/test_views.py | 13 ------------- 2 files changed, 23 deletions(-) diff --git a/eox_core/api/data/aggregated_collector/v1/tests/test_utils.py b/eox_core/api/data/aggregated_collector/v1/tests/test_utils.py index e7c114447..06bdc212d 100644 --- a/eox_core/api/data/aggregated_collector/v1/tests/test_utils.py +++ b/eox_core/api/data/aggregated_collector/v1/tests/test_utils.py @@ -18,13 +18,3 @@ def test_execute_query_success(self, mock_cursor): expected_result = [{"id": 1, "username": "test_user"}] self.assertEqual(result, expected_result) - - @patch("eox_core.api.data.aggregated_collector.utils.connection.cursor") - def test_execute_query_failure(self, mock_cursor): - """ - Test that execute_query handles exceptions gracefully. - """ - mock_cursor.return_value.__enter__.side_effect = Exception("Database error") - - result = execute_query("SELECT id, username FROM auth_user;") - self.assertEqual(result, []) # Expected to return an empty list on failure diff --git a/eox_core/api/data/aggregated_collector/v1/tests/test_views.py b/eox_core/api/data/aggregated_collector/v1/tests/test_views.py index d68c8431f..fbad281f6 100644 --- a/eox_core/api/data/aggregated_collector/v1/tests/test_views.py +++ b/eox_core/api/data/aggregated_collector/v1/tests/test_views.py @@ -7,7 +7,6 @@ from django.urls import reverse from rest_framework.test import APIClient from rest_framework import status -from eox_core.api.data.aggregated_collector.utils import execute_query from eox_core.api.data.aggregated_collector.tasks import generate_report from eox_core.api.data.aggregated_collector.v1.views import AggregatedCollectorView @@ -19,18 +18,6 @@ def setUp(self): settings.EOX_CORE_AGGREGATED_COLLECTOR_TARGET_TOKEN_URL = "http://mock-token.com" settings.EOX_CORE_AGGREGATED_COLLECTOR_AUTH_TOKEN = "test-token" - @patch("eox_core.api.data.aggregated_collector.utils.connection.cursor") - def test_execute_query(self, mock_cursor): - """ - Test execute_query function to ensure it correctly fetches and formats SQL query results. - """ - mock_cursor.return_value.__enter__.return_value.fetchall.return_value = [(1, "test_user")] - mock_cursor.return_value.__enter__.return_value.description = [("id",), ("username",)] - - result = execute_query("SELECT id, username FROM auth_user;") - - self.assertEqual(result, [{"id": 1, "username": "test_user"}]) - @patch("eox_core.api.data.aggregated_collector.tasks.execute_query") @patch("eox_core.api.data.aggregated_collector.tasks.post_data_to_api") def test_generate_report(self, mock_post, mock_execute): From d7dbe305d23f3c9b5260b788ac5fbf83c5878adf Mon Sep 17 00:00:00 2001 From: Luis Felipe Castano <felipe.castano@edunext.co> Date: Fri, 28 Feb 2025 09:25:09 -0500 Subject: [PATCH 45/60] chore: add improvements --- eox_core/api/data/aggregated_collector/v1/tests/test_utils.py | 3 ++- eox_core/api/data/aggregated_collector/v1/tests/test_views.py | 1 + 2 files changed, 3 insertions(+), 1 deletion(-) diff --git a/eox_core/api/data/aggregated_collector/v1/tests/test_utils.py b/eox_core/api/data/aggregated_collector/v1/tests/test_utils.py index 06bdc212d..bc99f9856 100644 --- a/eox_core/api/data/aggregated_collector/v1/tests/test_utils.py +++ b/eox_core/api/data/aggregated_collector/v1/tests/test_utils.py @@ -5,6 +5,7 @@ from django.test import TestCase from eox_core.api.data.aggregated_collector.utils import execute_query + class UtilsTests(TestCase): @patch("eox_core.api.data.aggregated_collector.utils.connection.cursor") def test_execute_query_success(self, mock_cursor): @@ -13,7 +14,7 @@ def test_execute_query_success(self, mock_cursor): """ mock_cursor.return_value.__enter__.return_value.fetchall.return_value = [(1, "test_user")] mock_cursor.return_value.__enter__.return_value.description = [("id",), ("username",)] - + result = execute_query("SELECT id, username FROM auth_user;") expected_result = [{"id": 1, "username": "test_user"}] diff --git a/eox_core/api/data/aggregated_collector/v1/tests/test_views.py b/eox_core/api/data/aggregated_collector/v1/tests/test_views.py index fbad281f6..948e1d134 100644 --- a/eox_core/api/data/aggregated_collector/v1/tests/test_views.py +++ b/eox_core/api/data/aggregated_collector/v1/tests/test_views.py @@ -10,6 +10,7 @@ from eox_core.api.data.aggregated_collector.tasks import generate_report from eox_core.api.data.aggregated_collector.v1.views import AggregatedCollectorView + class AggregatedCollectorViewTests(TestCase): def setUp(self): self.client = APIClient() From b7e1c98dfddaeb5e02bb9e11dc19837ffa4600cf Mon Sep 17 00:00:00 2001 From: Luis Felipe Castano <felipe.castano@edunext.co> Date: Fri, 28 Feb 2025 09:39:38 -0500 Subject: [PATCH 46/60] chore: add improvements --- .../aggregated_collector/v1/tests/test_utils.py | 5 ++++- .../aggregated_collector/v1/tests/test_views.py | 16 ++++++++++++++-- 2 files changed, 18 insertions(+), 3 deletions(-) diff --git a/eox_core/api/data/aggregated_collector/v1/tests/test_utils.py b/eox_core/api/data/aggregated_collector/v1/tests/test_utils.py index bc99f9856..f131a0a3e 100644 --- a/eox_core/api/data/aggregated_collector/v1/tests/test_utils.py +++ b/eox_core/api/data/aggregated_collector/v1/tests/test_utils.py @@ -1,12 +1,15 @@ """ Test suite for Aggregated Data Collector API. """ -from unittest.mock import patch, MagicMock +from unittest.mock import patch from django.test import TestCase from eox_core.api.data.aggregated_collector.utils import execute_query class UtilsTests(TestCase): + """ + Test suite for utility functions used in the Aggregated Data Collector API. + """ @patch("eox_core.api.data.aggregated_collector.utils.connection.cursor") def test_execute_query_success(self, mock_cursor): """ diff --git a/eox_core/api/data/aggregated_collector/v1/tests/test_views.py b/eox_core/api/data/aggregated_collector/v1/tests/test_views.py index 948e1d134..201662383 100644 --- a/eox_core/api/data/aggregated_collector/v1/tests/test_views.py +++ b/eox_core/api/data/aggregated_collector/v1/tests/test_views.py @@ -7,13 +7,14 @@ from django.urls import reverse from rest_framework.test import APIClient from rest_framework import status +from eox_core.api.data.aggregated_collector.utils import execute_query from eox_core.api.data.aggregated_collector.tasks import generate_report from eox_core.api.data.aggregated_collector.v1.views import AggregatedCollectorView - class AggregatedCollectorViewTests(TestCase): def setUp(self): self.client = APIClient() + self.url = reverse("eox-data-api-collector-v1:aggregated_collector") settings.AGGREGATED_DATA_COLLECTOR_API_ENABLED = True settings.EOX_CORE_AGGREGATED_COLLECTOR_TARGET_URL = "http://mock-api.com" settings.EOX_CORE_AGGREGATED_COLLECTOR_TARGET_TOKEN_URL = "http://mock-token.com" @@ -27,6 +28,17 @@ def test_generate_report(self, mock_post, mock_execute): """ mock_execute.return_value = [{"id": 1, "data": "sample"}] - generate_report("http://mock-api.com", "http://mock-token.com", "localhost") + generate_report(self, "http://mock-api.com", "http://mock-token.com", "localhost") mock_post.assert_called_once() + + @patch("eox_core.api.data.aggregated_collector.v1.views.generate_report.delay") + def test_aggregated_collector_view(self, mock_task): + """ + Test AggregatedCollectorView to ensure it correctly triggers the report generation task. + """ + response = self.client.post(self.url, HTTP_AUTHORIZATION=f"Bearer {settings.EOX_CORE_AGGREGATED_COLLECTOR_AUTH_TOKEN}") + + self.assertEqual(response.status_code, status.HTTP_202_ACCEPTED) + + mock_task.assert_called_once() From de697b9cab3fcc59175667ddc423ab05f07abc6b Mon Sep 17 00:00:00 2001 From: Luis Felipe Castano <felipe.castano@edunext.co> Date: Fri, 28 Feb 2025 16:24:41 -0500 Subject: [PATCH 47/60] chore: delete duplicate code --- eox_core/api/data/aggregated_collector/utils.py | 1 - 1 file changed, 1 deletion(-) diff --git a/eox_core/api/data/aggregated_collector/utils.py b/eox_core/api/data/aggregated_collector/utils.py index 8a2915e72..2965c0c40 100644 --- a/eox_core/api/data/aggregated_collector/utils.py +++ b/eox_core/api/data/aggregated_collector/utils.py @@ -88,7 +88,6 @@ def post_data_to_api(api_url: str, report_data: dict, token_generation_url: str, "Content-Type": "application/json", } payload = {"instance_domain": current_host, "data": report_data} - response = requests.post(api_url, json=payload, headers=headers, timeout=10) try: response = requests.post(api_url, json=payload, headers=headers, timeout=10) From d911b946ef1a713deb6947d95d8111db77e99c1c Mon Sep 17 00:00:00 2001 From: Luis Felipe Castano <felipe.castano@edunext.co> Date: Sun, 2 Mar 2025 23:49:28 -0500 Subject: [PATCH 48/60] feat: fix aggregated collecor test --- eox_core/api/data/aggregated_collector/v1/tests/test_views.py | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/eox_core/api/data/aggregated_collector/v1/tests/test_views.py b/eox_core/api/data/aggregated_collector/v1/tests/test_views.py index 201662383..293603a55 100644 --- a/eox_core/api/data/aggregated_collector/v1/tests/test_views.py +++ b/eox_core/api/data/aggregated_collector/v1/tests/test_views.py @@ -14,11 +14,13 @@ class AggregatedCollectorViewTests(TestCase): def setUp(self): self.client = APIClient() - self.url = reverse("eox-data-api-collector-v1:aggregated_collector") + self.url = "/eox-core/data-api/v1/aggregated-collector/" settings.AGGREGATED_DATA_COLLECTOR_API_ENABLED = True settings.EOX_CORE_AGGREGATED_COLLECTOR_TARGET_URL = "http://mock-api.com" settings.EOX_CORE_AGGREGATED_COLLECTOR_TARGET_TOKEN_URL = "http://mock-token.com" settings.EOX_CORE_AGGREGATED_COLLECTOR_AUTH_TOKEN = "test-token" + settings.EOX_CORE_AGGREGATED_COLLECTOR_TARGET_CLIENT_ID = "test-client-id" + settings.EOX_CORE_AGGREGATED_COLLECTOR_TARGET_CLIENT_SECRET = "test-client-secret" @patch("eox_core.api.data.aggregated_collector.tasks.execute_query") @patch("eox_core.api.data.aggregated_collector.tasks.post_data_to_api") From be5623c19bdafbf467a7ca354c67fad44d0fda81 Mon Sep 17 00:00:00 2001 From: Luis Felipe Castano <felipe.castano@edunext.co> Date: Sun, 2 Mar 2025 23:58:17 -0500 Subject: [PATCH 49/60] feat: fix aggregated collecor test --- eox_core/api/data/aggregated_collector/v1/tests/test_views.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/eox_core/api/data/aggregated_collector/v1/tests/test_views.py b/eox_core/api/data/aggregated_collector/v1/tests/test_views.py index 293603a55..8802abfcb 100644 --- a/eox_core/api/data/aggregated_collector/v1/tests/test_views.py +++ b/eox_core/api/data/aggregated_collector/v1/tests/test_views.py @@ -14,7 +14,7 @@ class AggregatedCollectorViewTests(TestCase): def setUp(self): self.client = APIClient() - self.url = "/eox-core/data-api/v1/aggregated-collector/" + self.url = reverse("aggregated_collector") settings.AGGREGATED_DATA_COLLECTOR_API_ENABLED = True settings.EOX_CORE_AGGREGATED_COLLECTOR_TARGET_URL = "http://mock-api.com" settings.EOX_CORE_AGGREGATED_COLLECTOR_TARGET_TOKEN_URL = "http://mock-token.com" From 6a3395b2b4dd111d7060a4cb6e2be99986bf0da3 Mon Sep 17 00:00:00 2001 From: Luis Felipe Castano <felipe.castano@edunext.co> Date: Mon, 3 Mar 2025 00:08:25 -0500 Subject: [PATCH 50/60] feat: fix aggregated collecor test --- eox_core/api/data/aggregated_collector/v1/tests/test_views.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/eox_core/api/data/aggregated_collector/v1/tests/test_views.py b/eox_core/api/data/aggregated_collector/v1/tests/test_views.py index 8802abfcb..65d33b8cf 100644 --- a/eox_core/api/data/aggregated_collector/v1/tests/test_views.py +++ b/eox_core/api/data/aggregated_collector/v1/tests/test_views.py @@ -14,7 +14,7 @@ class AggregatedCollectorViewTests(TestCase): def setUp(self): self.client = APIClient() - self.url = reverse("aggregated_collector") + self.url = reverse("eox-core:eox-data-api:eox-data-api-collector-v1:aggregated_collector") settings.AGGREGATED_DATA_COLLECTOR_API_ENABLED = True settings.EOX_CORE_AGGREGATED_COLLECTOR_TARGET_URL = "http://mock-api.com" settings.EOX_CORE_AGGREGATED_COLLECTOR_TARGET_TOKEN_URL = "http://mock-token.com" From 7bab3bcf69ad0d4796b6d571deb20a45ea92cd34 Mon Sep 17 00:00:00 2001 From: Luis Felipe Castano <felipe.castano@edunext.co> Date: Mon, 3 Mar 2025 00:43:06 -0500 Subject: [PATCH 51/60] feat: fix aggregated collecor test --- eox_core/api/data/aggregated_collector/v1/tests/test_views.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/eox_core/api/data/aggregated_collector/v1/tests/test_views.py b/eox_core/api/data/aggregated_collector/v1/tests/test_views.py index 65d33b8cf..65bf6cea2 100644 --- a/eox_core/api/data/aggregated_collector/v1/tests/test_views.py +++ b/eox_core/api/data/aggregated_collector/v1/tests/test_views.py @@ -14,7 +14,7 @@ class AggregatedCollectorViewTests(TestCase): def setUp(self): self.client = APIClient() - self.url = reverse("eox-core:eox-data-api:eox-data-api-collector-v1:aggregated_collector") + self.url = reverse("eox-data-api:eox-data-api-collector:eox-data-api-collector-v1:aggregated_collector") settings.AGGREGATED_DATA_COLLECTOR_API_ENABLED = True settings.EOX_CORE_AGGREGATED_COLLECTOR_TARGET_URL = "http://mock-api.com" settings.EOX_CORE_AGGREGATED_COLLECTOR_TARGET_TOKEN_URL = "http://mock-token.com" From 79f33e130950dd541137f6278670a8aa1a486fbf Mon Sep 17 00:00:00 2001 From: Luis Felipe Castano <felipe.castano@edunext.co> Date: Mon, 3 Mar 2025 00:46:30 -0500 Subject: [PATCH 52/60] feat: fix aggregated collecor test --- eox_core/api/data/aggregated_collector/v1/tests/test_views.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/eox_core/api/data/aggregated_collector/v1/tests/test_views.py b/eox_core/api/data/aggregated_collector/v1/tests/test_views.py index 65bf6cea2..b5e2491b0 100644 --- a/eox_core/api/data/aggregated_collector/v1/tests/test_views.py +++ b/eox_core/api/data/aggregated_collector/v1/tests/test_views.py @@ -30,7 +30,7 @@ def test_generate_report(self, mock_post, mock_execute): """ mock_execute.return_value = [{"id": 1, "data": "sample"}] - generate_report(self, "http://mock-api.com", "http://mock-token.com", "localhost") + generate_report("http://mock-api.com", "http://mock-token.com", "localhost") mock_post.assert_called_once() From c444d0065aeb24206948466c6a5ea5daaf7758a0 Mon Sep 17 00:00:00 2001 From: Luis Felipe Castano <felipe.castano@edunext.co> Date: Mon, 3 Mar 2025 00:50:23 -0500 Subject: [PATCH 53/60] feat: fix aggregated collecor test --- eox_core/api/data/aggregated_collector/v1/tests/test_views.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/eox_core/api/data/aggregated_collector/v1/tests/test_views.py b/eox_core/api/data/aggregated_collector/v1/tests/test_views.py index b5e2491b0..11850e3a0 100644 --- a/eox_core/api/data/aggregated_collector/v1/tests/test_views.py +++ b/eox_core/api/data/aggregated_collector/v1/tests/test_views.py @@ -11,6 +11,7 @@ from eox_core.api.data.aggregated_collector.tasks import generate_report from eox_core.api.data.aggregated_collector.v1.views import AggregatedCollectorView + class AggregatedCollectorViewTests(TestCase): def setUp(self): self.client = APIClient() @@ -42,5 +43,5 @@ def test_aggregated_collector_view(self, mock_task): response = self.client.post(self.url, HTTP_AUTHORIZATION=f"Bearer {settings.EOX_CORE_AGGREGATED_COLLECTOR_AUTH_TOKEN}") self.assertEqual(response.status_code, status.HTTP_202_ACCEPTED) - + mock_task.assert_called_once() From 274ab80626c4d477466e5257e8309f01c12f293e Mon Sep 17 00:00:00 2001 From: Luis Felipe Castano <felipe.castano@edunext.co> Date: Mon, 3 Mar 2025 01:01:31 -0500 Subject: [PATCH 54/60] feat: fix aggregated collecor test --- .../api/data/aggregated_collector/v1/tests/test_views.py | 9 +++++---- 1 file changed, 5 insertions(+), 4 deletions(-) diff --git a/eox_core/api/data/aggregated_collector/v1/tests/test_views.py b/eox_core/api/data/aggregated_collector/v1/tests/test_views.py index 11850e3a0..0da15bcc0 100644 --- a/eox_core/api/data/aggregated_collector/v1/tests/test_views.py +++ b/eox_core/api/data/aggregated_collector/v1/tests/test_views.py @@ -1,18 +1,19 @@ """ Test suite for Aggregated Data Collector API. """ -from unittest.mock import patch, MagicMock +from unittest.mock import patch from django.test import TestCase from django.conf import settings from django.urls import reverse from rest_framework.test import APIClient from rest_framework import status -from eox_core.api.data.aggregated_collector.utils import execute_query from eox_core.api.data.aggregated_collector.tasks import generate_report -from eox_core.api.data.aggregated_collector.v1.views import AggregatedCollectorView class AggregatedCollectorViewTests(TestCase): + """ + Test cases for the Aggregated Data Collector API. + """ def setUp(self): self.client = APIClient() self.url = reverse("eox-data-api:eox-data-api-collector:eox-data-api-collector-v1:aggregated_collector") @@ -31,7 +32,7 @@ def test_generate_report(self, mock_post, mock_execute): """ mock_execute.return_value = [{"id": 1, "data": "sample"}] - generate_report("http://mock-api.com", "http://mock-token.com", "localhost") + generate_report(None, "http://mock-api.com", "http://mock-token.com", "localhost") mock_post.assert_called_once() From 8ca5f18e028194c865802f5928b74510ba03678e Mon Sep 17 00:00:00 2001 From: Luis Felipe Castano <felipe.castano@edunext.co> Date: Mon, 3 Mar 2025 01:07:37 -0500 Subject: [PATCH 55/60] feat: fix aggregated collecor test --- eox_core/api/data/aggregated_collector/v1/tests/test_views.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/eox_core/api/data/aggregated_collector/v1/tests/test_views.py b/eox_core/api/data/aggregated_collector/v1/tests/test_views.py index 0da15bcc0..b6a5a34b2 100644 --- a/eox_core/api/data/aggregated_collector/v1/tests/test_views.py +++ b/eox_core/api/data/aggregated_collector/v1/tests/test_views.py @@ -32,7 +32,7 @@ def test_generate_report(self, mock_post, mock_execute): """ mock_execute.return_value = [{"id": 1, "data": "sample"}] - generate_report(None, "http://mock-api.com", "http://mock-token.com", "localhost") + generate_report.run("http://mock-api.com", "http://mock-token.com", "localhost") mock_post.assert_called_once() From c8648770e848c634b2b03f348eb4fc9f76883cf6 Mon Sep 17 00:00:00 2001 From: Luis Felipe Castano <felipe.castano@edunext.co> Date: Mon, 3 Mar 2025 01:13:31 -0500 Subject: [PATCH 56/60] feat: fix aggregated collecor test --- .../api/data/aggregated_collector/v1/tests/test_utils.py | 2 ++ .../api/data/aggregated_collector/v1/tests/test_views.py | 6 ++++-- 2 files changed, 6 insertions(+), 2 deletions(-) diff --git a/eox_core/api/data/aggregated_collector/v1/tests/test_utils.py b/eox_core/api/data/aggregated_collector/v1/tests/test_utils.py index f131a0a3e..3b5966e00 100644 --- a/eox_core/api/data/aggregated_collector/v1/tests/test_utils.py +++ b/eox_core/api/data/aggregated_collector/v1/tests/test_utils.py @@ -2,7 +2,9 @@ Test suite for Aggregated Data Collector API. """ from unittest.mock import patch + from django.test import TestCase + from eox_core.api.data.aggregated_collector.utils import execute_query diff --git a/eox_core/api/data/aggregated_collector/v1/tests/test_views.py b/eox_core/api/data/aggregated_collector/v1/tests/test_views.py index b6a5a34b2..f1cfa4e8c 100644 --- a/eox_core/api/data/aggregated_collector/v1/tests/test_views.py +++ b/eox_core/api/data/aggregated_collector/v1/tests/test_views.py @@ -2,11 +2,13 @@ Test suite for Aggregated Data Collector API. """ from unittest.mock import patch -from django.test import TestCase + from django.conf import settings +from django.test import TestCase from django.urls import reverse -from rest_framework.test import APIClient from rest_framework import status +from rest_framework.test import APIClient + from eox_core.api.data.aggregated_collector.tasks import generate_report From 72dc4e835a1d5fe459bda27d7c0a81eb3887d52a Mon Sep 17 00:00:00 2001 From: Luis Felipe Castano <felipe.castano@edunext.co> Date: Mon, 3 Mar 2025 10:49:23 -0500 Subject: [PATCH 57/60] chore: add test for utils --- .../v1/tests/test_utils.py | 31 +++++++++++++++++++ 1 file changed, 31 insertions(+) diff --git a/eox_core/api/data/aggregated_collector/v1/tests/test_utils.py b/eox_core/api/data/aggregated_collector/v1/tests/test_utils.py index 3b5966e00..3fdc9138a 100644 --- a/eox_core/api/data/aggregated_collector/v1/tests/test_utils.py +++ b/eox_core/api/data/aggregated_collector/v1/tests/test_utils.py @@ -24,3 +24,34 @@ def test_execute_query_success(self, mock_cursor): expected_result = [{"id": 1, "username": "test_user"}] self.assertEqual(result, expected_result) + + @patch("eox_core.api.data.aggregated_collector.utils.connection.cursor") + def test_execute_query_empty_result(self, mock_cursor): + """ + Test that execute_query returns an empty list when there are no results. + """ + mock_cursor.return_value.__enter__.return_value.fetchall.return_value = [] + mock_cursor.return_value.__enter__.return_value.description = [("id",), ("username",)] + + result = execute_query("SELECT id, username FROM auth_user;") + + self.assertEqual(result, []) + + @patch("eox_core.api.data.aggregated_collector.utils.connection.cursor") + def test_execute_query_multiple_rows(self, mock_cursor): + """ + Test that execute_query correctly handles multiple rows in the result. + """ + mock_cursor.return_value.__enter__.return_value.fetchall.return_value = [ + (1, "test_user1"), + (2, "test_user2"), + ] + mock_cursor.return_value.__enter__.return_value.description = [("id",), ("username",)] + + result = execute_query("SELECT id, username FROM auth_user;") + expected_result = [ + {"id": 1, "username": "test_user1"}, + {"id": 2, "username": "test_user2"}, + ] + + self.assertEqual(result, expected_result) From 647113093dc1650d1e0d808ee4f757441d198272 Mon Sep 17 00:00:00 2001 From: Luis Felipe Castano <felipe.castano@edunext.co> Date: Mon, 3 Mar 2025 10:53:54 -0500 Subject: [PATCH 58/60] chore: add test for views --- .../v1/tests/test_views.py | 18 ++++++++++++++++++ 1 file changed, 18 insertions(+) diff --git a/eox_core/api/data/aggregated_collector/v1/tests/test_views.py b/eox_core/api/data/aggregated_collector/v1/tests/test_views.py index f1cfa4e8c..64f4e9504 100644 --- a/eox_core/api/data/aggregated_collector/v1/tests/test_views.py +++ b/eox_core/api/data/aggregated_collector/v1/tests/test_views.py @@ -48,3 +48,21 @@ def test_aggregated_collector_view(self, mock_task): self.assertEqual(response.status_code, status.HTTP_202_ACCEPTED) mock_task.assert_called_once() + + def test_aggregated_collector_view_disabled(self): + """ + Test that the view returns 503 if the API is disabled. + """ + settings.AGGREGATED_DATA_COLLECTOR_API_ENABLED = False + + response = self.client.post(self.url, HTTP_AUTHORIZATION=f"Bearer {settings.EOX_CORE_AGGREGATED_COLLECTOR_AUTH_TOKEN}") + + self.assertEqual(response.status_code, status.HTTP_503_SERVICE_UNAVAILABLE) + + def test_aggregated_collector_view_no_auth(self): + """ + Test that the view returns 401 if no authentication token is provided. + """ + response = self.client.post(self.url) + + self.assertEqual(response.status_code, status.HTTP_401_UNAUTHORIZED) From 54f49141422d7be7601e9bcec3fd8defdd24ac47 Mon Sep 17 00:00:00 2001 From: Luis Felipe Castano <felipe.castano@edunext.co> Date: Mon, 3 Mar 2025 11:06:56 -0500 Subject: [PATCH 59/60] chore: add test for views --- .../data/aggregated_collector/v1/tests/test_views.py | 12 ++---------- 1 file changed, 2 insertions(+), 10 deletions(-) diff --git a/eox_core/api/data/aggregated_collector/v1/tests/test_views.py b/eox_core/api/data/aggregated_collector/v1/tests/test_views.py index 64f4e9504..2eab43aa5 100644 --- a/eox_core/api/data/aggregated_collector/v1/tests/test_views.py +++ b/eox_core/api/data/aggregated_collector/v1/tests/test_views.py @@ -51,18 +51,10 @@ def test_aggregated_collector_view(self, mock_task): def test_aggregated_collector_view_disabled(self): """ - Test that the view returns 503 if the API is disabled. + Test that the view returns 403 if the API is disabled. """ settings.AGGREGATED_DATA_COLLECTOR_API_ENABLED = False response = self.client.post(self.url, HTTP_AUTHORIZATION=f"Bearer {settings.EOX_CORE_AGGREGATED_COLLECTOR_AUTH_TOKEN}") - self.assertEqual(response.status_code, status.HTTP_503_SERVICE_UNAVAILABLE) - - def test_aggregated_collector_view_no_auth(self): - """ - Test that the view returns 401 if no authentication token is provided. - """ - response = self.client.post(self.url) - - self.assertEqual(response.status_code, status.HTTP_401_UNAUTHORIZED) + self.assertEqual(response.status_code, status.HTTP_403_FORBIDDEN) From 0bc33489c61d692d79b53cb386c66b5bebbb536a Mon Sep 17 00:00:00 2001 From: Luis Felipe Castano <felipe.castano@edunext.co> Date: Mon, 3 Mar 2025 11:13:09 -0500 Subject: [PATCH 60/60] chore: add missing docstring --- eox_core/api/data/aggregated_collector/v1/permissions.py | 3 +++ 1 file changed, 3 insertions(+) diff --git a/eox_core/api/data/aggregated_collector/v1/permissions.py b/eox_core/api/data/aggregated_collector/v1/permissions.py index be3fb82e8..df9a9295f 100644 --- a/eox_core/api/data/aggregated_collector/v1/permissions.py +++ b/eox_core/api/data/aggregated_collector/v1/permissions.py @@ -19,6 +19,9 @@ class AggregatedCollectorPermission(BasePermission): """ def has_permission(self, request, view): + """" + Determines if the request has permission to access the Aggregated Collector API. + """ # Check if the API is enabled if not getattr(settings, "AGGREGATED_DATA_COLLECTOR_API_ENABLED", False): return False