From 80c8c0e4bf20df6d65baca7b96430a7b2349bc4a Mon Sep 17 00:00:00 2001 From: Anyer Date: Thu, 19 Jun 2025 11:40:15 +0200 Subject: [PATCH 1/6] =?UTF-8?q?Refactor=20de=20users=5Fauthenticated=5Fid,?= =?UTF-8?q?=20m=C3=A9s=20tests=20i=20treure=20funcions=20de=20add/remove?= =?UTF-8?q?=20metrics/sources?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- app/serializers.py | 27 ----- app/services.py | 45 +------- app/views.py | 32 +----- conftest.py | 1 - metric/repositories.py | 9 +- metric/serializers.py | 31 +++++- metric/services.py | 41 +++++++- metric/tests/test_create_metrics.py | 41 ++++++++ {app => metric}/tests/test_get_app_metric.py | 8 +- metric/tests/test_update_metrics.py | 41 ++++++++ metric/urls.py | 5 + metric/views.py | 102 ++++++------------- polling/services/polling_schedule_service.py | 11 +- polling/views.py | 29 ++++-- review/models.py | 1 - review/tests/test_models.py | 4 +- source/repositories.py | 33 +++--- source/serializers.py | 12 +-- source/tests/test_create_sources.py | 39 +++++++ source/tests/test_update_sources.py | 39 +++++++ source/views.py | 55 ---------- 21 files changed, 334 insertions(+), 272 deletions(-) rename {app => metric}/tests/test_get_app_metric.py (85%) diff --git a/app/serializers.py b/app/serializers.py index 5bc60e5..fd5a156 100644 --- a/app/serializers.py +++ b/app/serializers.py @@ -1,7 +1,5 @@ from rest_framework import serializers -from metric.serializers import MetricDashboardSerializer - from .models import App @@ -36,28 +34,3 @@ class AppCreateSerializer(serializers.ModelSerializer): class Meta: model = App fields = ["name", "description", "appstore_id", "playstore_id"] - - -class MetricHistorySerializer(serializers.Serializer): - date = serializers.DateField() - value = serializers.SerializerMethodField() - - def get_value(self, obj): - value_type = self.context.get("value_type", "string") - - if value_type == "float": - return float(obj["value"]) - elif value_type == "int": - return int(obj["value"]) - else: - return obj["value"] - - -class SourceHistorySerializer(serializers.Serializer): - source = serializers.CharField() - history = MetricHistorySerializer(many=True) - - -class MetricResponseSerializer(serializers.Serializer): - metric = MetricDashboardSerializer() - sources = SourceHistorySerializer(many=True) diff --git a/app/services.py b/app/services.py index 03c7639..ffa7100 100644 --- a/app/services.py +++ b/app/services.py @@ -3,7 +3,6 @@ from django.core.exceptions import ObjectDoesNotExist from rest_framework.exceptions import NotFound -from metric.services import MetricService, MetricValueService from polling.services.polling_schedule_service import PollingScheduleService from source.adapters.google_play_scraper import GooglePlayScraperAdapter from source.adapters.itunes import ItunesSearchAPIAdapter @@ -12,10 +11,8 @@ class AppService: - def __init__(self, google_play_adapter=None): + def __init__(self): self.repo = AppRepository() - self.metric_service = MetricService() - self.metric_value_service = MetricValueService() self.polling_schedule_service = PollingScheduleService() self.itunes_adapter = ItunesSearchAPIAdapter() self.google_play_adapter = GooglePlayScraperAdapter() @@ -101,43 +98,3 @@ def update_app(self, instance, validated_data): def delete_app(self, instance): return self.repo.delete(instance) - - def get_metric_dashboard(self, app_id: str, metric_id: str) -> dict: - metric = self.metric_service.get_metric(metric_id) - if not metric.is_derived: - metric_values = self.metric_value_service.get_metric_values_by_app_and_metric( - app_id, metric_id - ) - values = [ - { - "retrieved_at": metric_value.retrieved_at.date(), - "value": metric_value.value, - "source": metric_value.source.name if metric_value.source else "Internal", - } - for metric_value in metric_values - ] - else: - values = self.metric_value_service.get_derived_metric_values_by_app_and_metric( - app_id, metric.code - ) - for value in values: - if "source" not in value: - value["source"] = "Internal" - - sources_data = {} - for value in values: - source_name = value["source"] - sources_data.setdefault(source_name, {"source": source_name, "history": []}) - sources_data[source_name]["history"].append( - {"date": value["retrieved_at"], "value": value["value"]} - ) - - return { - "metric": { - "code": metric.code, - "name": metric.name, - "description": metric.description, - "value_type": metric.value_type, - }, - "sources": list(sources_data.values()), - } diff --git a/app/views.py b/app/views.py index 43e83e7..602b250 100644 --- a/app/views.py +++ b/app/views.py @@ -1,6 +1,5 @@ from drf_spectacular.utils import OpenApiParameter, OpenApiResponse, extend_schema from rest_framework import status, viewsets -from rest_framework.decorators import action from rest_framework.response import Response from schemas.responses import ( @@ -9,7 +8,7 @@ not_found_response, ) -from .serializers import AppCreateSerializer, AppSerializer, MetricResponseSerializer +from .serializers import AppCreateSerializer, AppSerializer from .services import AppService @@ -118,32 +117,3 @@ def destroy(self, request, pk=None): app = self.service.get_app_by_user(pk, user=request.user) self.service.delete_app(app) return Response(status=status.HTTP_204_NO_CONTENT) - - @extend_schema( - summary="Get app metric dashboard", - description=( - "Retrieves the historical dashboard for a specific metric of a given app.\n\n" - "If the metric is a direct (raw) metric, this endpoint returns its historical values " - "grouped by data source (e.g. App Store, Google Play, or internal sources).\n" - "If the metric is a derived one, it is computed dynamically " - "and presented similarly.\n\n" - "Each source includes a list of historical records, each with a date and value. " - "Derived metrics are labeled as coming from the 'Internal' source." - ), - parameters=[ - OpenApiParameter(name="id", required=True, type=int, location="path"), - OpenApiParameter(name="metric_id", required=True, type=int, location="path"), - ], - responses={ - 200: MetricResponseSerializer, - 401: UNAUTHORIZED_RESPONSE, - 404: not_found_response("app", True), - }, - tags=["Apps"], - ) - @action(detail=True, methods=["get"], url_path="metrics/(?P[^/.]+)") - def get_app_metric(self, request, pk=None, metric_id=None): - app = self.service.get_app_by_user(pk, user=request.user) - response_data = self.service.get_metric_dashboard(app_id=app.id, metric_id=metric_id) - serializer = MetricResponseSerializer(response_data) - return Response(serializer.data) diff --git a/conftest.py b/conftest.py index a56dbfa..94773e7 100644 --- a/conftest.py +++ b/conftest.py @@ -96,7 +96,6 @@ def dummy_review(db, dummy_app, dummy_source): @pytest.fixture def create_default_sources_and_metrics(db): - """Crea sources i mètriques per defecte disponibles a tots els tests.""" itunes = Source.objects.create( code="itunes", name="App Store", type="api", url="https://itunes.apple.com" ) diff --git a/metric/repositories.py b/metric/repositories.py index f2ea367..747e15f 100644 --- a/metric/repositories.py +++ b/metric/repositories.py @@ -9,12 +9,19 @@ def get_by_id(self, pk): return Metric.objects.get(id=pk) def create(self, data): - return Metric.objects.create(**data) + sources = data.pop("sources", []) + metric = Metric.objects.create(**data) + if sources: + metric.sources.set(sources) + return metric def update(self, instance, data): + sources = data.pop("sources", None) for attr, value in data.items(): setattr(instance, attr, value) instance.save() + if sources is not None: + instance.sources.set(sources) return instance def delete(self, instance): diff --git a/metric/serializers.py b/metric/serializers.py index b156a0e..eb08e2e 100644 --- a/metric/serializers.py +++ b/metric/serializers.py @@ -1,16 +1,20 @@ from rest_framework import serializers +from source.models import Source + from .constants.value_types import MetricValueType from .models import Metric, MetricValue class MetricSerializer(serializers.ModelSerializer): value_type = serializers.ChoiceField(choices=MetricValueType.choices) + sources = serializers.PrimaryKeyRelatedField( + queryset=Source.objects.all(), many=True, required=False + ) class Meta: model = Metric fields = ["id", "code", "name", "description", "value_type", "is_derived", "sources"] - read_only_fields = ["sources"] class MetricValueSerializer(serializers.ModelSerializer): @@ -40,3 +44,28 @@ class MetricDashboardSerializer(serializers.ModelSerializer): class Meta: model = Metric fields = ["code", "name", "description", "value_type"] + + +class MetricHistorySerializer(serializers.Serializer): + date = serializers.DateField() + value = serializers.SerializerMethodField() + + def get_value(self, obj): + value_type = self.context.get("value_type", "string") + + if value_type == "float": + return float(obj["value"]) + elif value_type == "int": + return int(obj["value"]) + else: + return obj["value"] + + +class SourceHistorySerializer(serializers.Serializer): + source = serializers.CharField() + history = MetricHistorySerializer(many=True) + + +class MetricResponseSerializer(serializers.Serializer): + metric = MetricDashboardSerializer() + sources = SourceHistorySerializer(many=True) diff --git a/metric/services.py b/metric/services.py index d7c1a0c..45f2d0e 100644 --- a/metric/services.py +++ b/metric/services.py @@ -1,3 +1,5 @@ +from typing import List + from django.core.exceptions import ObjectDoesNotExist from rest_framework.exceptions import NotFound @@ -66,8 +68,43 @@ def update_metric_value(self, instance, validated_data): def delete_metric_value(self, instance): return self.repo.delete(instance) - def get_metric_values_by_app_and_metric(self, app_id, metric_id): - return self.repo.get_by_app_and_metric(app_id, metric_id) + def get_metric_dashboard(self, app_id: str, metric_id: str, authorized_apps: List[int]) -> dict: + if app_id not in authorized_apps: + raise NotFound(f"The app with ID '{app_id}' is not registered or not authorized.") + metric = self.metric_service.get_metric(metric_id) + if not metric.is_derived: + metric_values = self.repo.get_by_app_and_metric(app_id, metric_id) + values = [ + { + "retrieved_at": metric_value.retrieved_at.date(), + "value": metric_value.value, + "source": metric_value.source.name if metric_value.source else "Internal", + } + for metric_value in metric_values + ] + else: + values = self.get_derived_metric_values_by_app_and_metric(app_id, metric.code) + for value in values: + if "source" not in value: + value["source"] = "Internal" + + sources_data = {} + for value in values: + source_name = value["source"] + sources_data.setdefault(source_name, {"source": source_name, "history": []}) + sources_data[source_name]["history"].append( + {"date": value["retrieved_at"], "value": value["value"]} + ) + + return { + "metric": { + "code": metric.code, + "name": metric.name, + "description": metric.description, + "value_type": metric.value_type, + }, + "sources": list(sources_data.values()), + } def get_derived_metric_values_by_app_and_metric(self, app_id, metric_code): reviews = self.review_service.list_reviews(filters={"app_id": app_id}) diff --git a/metric/tests/test_create_metrics.py b/metric/tests/test_create_metrics.py index e0eaf47..e811ac4 100644 --- a/metric/tests/test_create_metrics.py +++ b/metric/tests/test_create_metrics.py @@ -70,3 +70,44 @@ def test_create_metric_missing_fields(self, dummy_superuser): assert ( "name" in response.json()["errors"][0].lower() or "name" in str(response.json()).lower() ) + + def test_create_metric_with_source( + self, dummy_superuser, dummy_source, create_default_sources_and_metrics + ): + client = APIClient() + client.force_authenticate(user=dummy_superuser) + + payload = { + "code": "test_metric", + "name": "Test Metric", + "value_type": "integer", + "description": "A test metric.", + "is_derived": False, + "sources": [dummy_source.id], + } + + response = client.post("/api/metrics/", data=payload, format="json") + + assert response.status_code == status.HTTP_201_CREATED + assert Metric.objects.filter(name="Test Metric").exists() + + def test_create_metric_with_nonexistent_source( + self, dummy_superuser, create_default_sources_and_metrics + ): + client = APIClient() + client.force_authenticate(user=dummy_superuser) + + payload = { + "code": "test_metric", + "name": "Test Metric", + "value_type": "integer", + "description": "A test metric.", + "is_derived": False, + "sources": [9999], + } + + response = client.post("/api/metrics/", data=payload, format="json") + + assert response.status_code == status.HTTP_400_BAD_REQUEST + assert ("errors") in response.data + assert "Invalid pk" in response.data["errors"][0] diff --git a/app/tests/test_get_app_metric.py b/metric/tests/test_get_app_metric.py similarity index 85% rename from app/tests/test_get_app_metric.py rename to metric/tests/test_get_app_metric.py index d291cf3..488b64d 100644 --- a/app/tests/test_get_app_metric.py +++ b/metric/tests/test_get_app_metric.py @@ -4,7 +4,9 @@ @pytest.mark.django_db -@pytest.mark.usefixtures("dummy_app", "dummy_metric") +@pytest.mark.usefixtures( + "dummy_app", "dummy_metric", "create_default_sources_and_metrics", "dummy_metric_value" +) class TestGetAppMetricDashboard: def test_get_metric_dashboard_success(self, dummy_app, dummy_metric): client = APIClient() @@ -16,7 +18,9 @@ def test_get_metric_dashboard_success(self, dummy_app, dummy_metric): assert response.status_code == status.HTTP_200_OK assert "sources" in response.json() - def test_get_metric_dashboard_unauthenticated(self, dummy_app, dummy_metric): + def test_get_metric_dashboard_unauthenticated( + self, dummy_app, dummy_metric, dummy_metric_value + ): client = APIClient() url = f"/api/apps/{dummy_app.id}/metrics/{dummy_metric.id}/" response = client.get(url) diff --git a/metric/tests/test_update_metrics.py b/metric/tests/test_update_metrics.py index 9d02540..2a73534 100644 --- a/metric/tests/test_update_metrics.py +++ b/metric/tests/test_update_metrics.py @@ -58,3 +58,44 @@ def test_update_metric_invalid_data(self, dummy_metric, dummy_superuser): response = client.put(f"/api/metrics/{dummy_metric.id}/", data=payload, format="json") assert response.status_code == status.HTTP_400_BAD_REQUEST assert "name" in str(response.json()).lower() + + def test_update_metric_with_source( + self, dummy_metric, dummy_superuser, dummy_source, create_default_sources_and_metrics + ): + client = APIClient() + client.force_authenticate(user=dummy_superuser) + + payload = { + "code": dummy_metric.code, + "name": "Updated Name", + "value_type": "float", + "description": "New description", + "is_derived": False, + "sources": [dummy_source.id], + } + + response = client.put(f"/api/metrics/{dummy_metric.id}/", data=payload, format="json") + assert response.status_code == status.HTTP_200_OK, f"Errors: {response.json()}" + + assert response.json()["name"] == "Updated Name" + assert response.json()["description"] == "New description" + + def test_update_metric_with_nonexistent_source( + self, dummy_metric, dummy_superuser, create_default_sources_and_metrics + ): + client = APIClient() + client.force_authenticate(user=dummy_superuser) + + payload = { + "code": dummy_metric.code, + "name": "Updated Name", + "value_type": "float", + "description": "New description", + "is_derived": False, + "sources": [9999], + } + + response = client.put(f"/api/metrics/{dummy_metric.id}/", data=payload, format="json") + assert response.status_code == status.HTTP_400_BAD_REQUEST + assert ("errors") in response.data + assert "Invalid pk" in response.data["errors"][0] diff --git a/metric/urls.py b/metric/urls.py index 9891c6c..6ad0917 100644 --- a/metric/urls.py +++ b/metric/urls.py @@ -9,4 +9,9 @@ urlpatterns = [ path("", include(router.urls)), + path( + "apps//metrics//", + MetricValueViewSet.as_view({"get": "get_app_metric"}), + name="get-app-metric", + ), ] diff --git a/metric/views.py b/metric/views.py index 7891780..c486ccb 100644 --- a/metric/views.py +++ b/metric/views.py @@ -1,6 +1,5 @@ from drf_spectacular.utils import OpenApiParameter, OpenApiResponse, extend_schema from rest_framework import status, viewsets -from rest_framework.decorators import action from rest_framework.response import Response from permissions.decorators import superuser_required @@ -10,9 +9,8 @@ UNAUTHORIZED_RESPONSE, not_found_response, ) -from source.serializers import LinkSourceSerializer -from .serializers import MetricSerializer, MetricValueSerializer +from .serializers import MetricResponseSerializer, MetricSerializer, MetricValueSerializer from .services import MetricService, MetricValueService @@ -136,73 +134,6 @@ def destroy(self, request, pk=None): self.service.delete_metric(metric) return Response(status=status.HTTP_204_NO_CONTENT) - @extend_schema( - summary="Add sources to a metric", - description=( - "Adds a list of source IDs to the metric without removing existing sources.\n\n" - "The input must be a JSON object with a `sources` field containing a list of IDs.\n\n" - "**Example**:\n" - '`{ "sources": [1, 2, 3] }`' - ), - request=LinkSourceSerializer, - responses={ - 200: MetricSerializer, - 400: BAD_REQUEST_RESPONSE, - 401: UNAUTHORIZED_RESPONSE, - 403: FORBIDDEN_RESPONSE, - 404: not_found_response("metrric", False), - }, - tags=["Metrics"], - ) - @superuser_required - @action(detail=True, methods=["post"], url_path="sources") - def add_sources(self, request, pk=None): - sources_ids = request.data.get("sources", []) - - if not isinstance(sources_ids, list): - return Response( - {"error": "sources must be a list of IDs"}, status=status.HTTP_400_BAD_REQUEST - ) - - metric = self.service.get_metric(pk) - metric = self.service.add_sources(metric, sources_ids) - - return Response(MetricSerializer(metric).data, status=status.HTTP_200_OK) - - @extend_schema( - summary="Remove sources from a metric", - description=( - "Removes a list of source IDs from the metric.\n\n" - "The input must be a JSON object with a `sources` field containing a list of IDs.\n\n" - "**Example**:\n" - '`{ "sources": [2, 3] }`' - ), - request=LinkSourceSerializer, - responses={ - 200: MetricSerializer, - 400: BAD_REQUEST_RESPONSE, - 401: UNAUTHORIZED_RESPONSE, - 403: FORBIDDEN_RESPONSE, - 404: not_found_response("metrric", False), - }, - tags=["Metrics"], - methods=["DELETE"], - ) - @superuser_required - @action(detail=True, methods=["delete"], url_path="sources") - def remove_sources(self, request, pk=None): - sources_ids = request.data.get("sources", []) - - if not isinstance(sources_ids, list): - return Response( - {"error": "sources must be a list of IDs"}, status=status.HTTP_400_BAD_REQUEST - ) - - metric = self.service.get_metric(pk) - metric = self.service.remove_sources(metric, sources_ids) - - return Response(MetricSerializer(metric).data, status=status.HTTP_200_OK) - class MetricValueViewSet(viewsets.ViewSet): service = MetricValueService() @@ -251,3 +182,34 @@ def list(self, request): metrics = self.service.list_metric_values(filters) serializer = MetricValueSerializer(metrics, many=True) return Response(serializer.data) + + @extend_schema( + summary="Get app metric dashboard", + description=( + "Retrieves the historical dashboard for a specific metric of a given app.\n\n" + "If the metric is a direct (raw) metric, this endpoint returns its historical values " + "grouped by data source (e.g. App Store, Google Play, or internal sources).\n" + "If the metric is a derived one, it is computed dynamically " + "and presented similarly.\n\n" + "Each source includes a list of historical records, each with a date and value. " + "Derived metrics are labeled as coming from the 'Internal' source." + ), + parameters=[ + OpenApiParameter(name="id", required=True, type=int, location="path"), + OpenApiParameter(name="metric_id", required=True, type=int, location="path"), + ], + responses={ + 200: MetricValueSerializer, + 401: UNAUTHORIZED_RESPONSE, + 404: not_found_response("app", True), + }, + tags=["MetricValues"], + methods=["get"], + ) + def get_app_metric(self, request, id=None, metric_id=None): + user_authorized_app_ids = request.user.apps.values_list("id", flat=True) + response_data = self.service.get_metric_dashboard( + id, metric_id=metric_id, authorized_apps=user_authorized_app_ids + ) + serializer = MetricResponseSerializer(response_data) + return Response(serializer.data) diff --git a/polling/services/polling_schedule_service.py b/polling/services/polling_schedule_service.py index 75f6202..c561c8c 100644 --- a/polling/services/polling_schedule_service.py +++ b/polling/services/polling_schedule_service.py @@ -14,7 +14,9 @@ def __init__(self): def list_polling_schedules(self, poll_type=None): return self.repo.get_all(poll_type) - def get_polling_schedule(self, app_id, poll_type=None): + def get_polling_schedule(self, app_id, poll_type, authorized_app_ids): + if app_id not in authorized_app_ids: + raise NotFound(f"The app with ID '{app_id}' is not registered.") try: return self.repo.get_by_app_id(app_id, poll_type) except ObjectDoesNotExist: @@ -67,10 +69,13 @@ def deactivate_polling_schedule(self, polling_schedule): self.repo.deactivate(polling_schedule) return polling_schedule - def poll_reviews(self, app_id, date_from, date_to): + def manual_poll_reviews(self, app_id, date_from, date_to, authorized_app_ids=None): + if authorized_app_ids is not None and app_id not in authorized_app_ids: + raise NotFound(f"The app with ID '{app_id}' is not registered.") + try: return run_polling_task.delay(app_id, "reviews", date_from, date_to) except (RedisConnectionError, KombuOperationalError): raise APIException( - "No se pudo conectar con el servicio de tareas (Redis). Inténtalo más tarde." + "Could not connect to the task service (Redis). Please try again later." ) diff --git a/polling/views.py b/polling/views.py index 4d724f5..a43cffe 100644 --- a/polling/views.py +++ b/polling/views.py @@ -40,7 +40,8 @@ class PollingViewSet(ViewSet): tags=["Polling"], methods=["GET"], ) - def retrieve_polling(self, request, id=None): + def retrieve_polling(self, request, id: int): + user_authorized_app_ids = request.user.apps.values_list("id", flat=True) poll_type = request.query_params.get("poll_type") if poll_type not in ["metrics", "reviews"]: return Response( @@ -48,7 +49,7 @@ def retrieve_polling(self, request, id=None): status=status.HTTP_400_BAD_REQUEST, ) - schedule = self.service.get_polling_schedule(id, poll_type) + schedule = self.service.get_polling_schedule(id, poll_type, user_authorized_app_ids) serializer = PollingScheduleSerializer(schedule) return Response(serializer.data, status=status.HTTP_200_OK) @@ -86,8 +87,9 @@ def retrieve_polling(self, request, id=None): tags=["Polling"], methods=["POST"], ) - def activate_polling(self, request, id=None): + def activate_polling(self, request, id: int): try: + user_authorized_app_ids = request.user.apps.values_list("id", flat=True) poll_type = request.query_params.get("poll_type") if poll_type not in ["metrics", "reviews"]: return Response( @@ -95,7 +97,9 @@ def activate_polling(self, request, id=None): status=status.HTTP_400_BAD_REQUEST, ) interval_hours = request.query_params.get("interval_hours") - polling_schedule = self.service.get_polling_schedule(id, poll_type) + polling_schedule = self.service.get_polling_schedule( + id, poll_type, user_authorized_app_ids + ) if ( interval_hours is not None @@ -148,15 +152,18 @@ def activate_polling(self, request, id=None): tags=["Polling"], methods=["DELETE"], ) - def deactivate_polling(self, request, id=None): + def deactivate_polling(self, request, id: int): try: + user_authorized_app_ids = request.user.apps.values_list("id", flat=True) poll_type = request.query_params.get("poll_type") if poll_type not in ["metrics", "reviews"]: return Response( {"detail": "Invalid or missing poll_type. Must be 'metrics' or 'reviews'."}, status=status.HTTP_400_BAD_REQUEST, ) - polling_schedule = self.service.get_polling_schedule(id, poll_type) + polling_schedule = self.service.get_polling_schedule( + id, poll_type, user_authorized_app_ids + ) updated_polling_schedule = self.service.deactivate_polling_schedule(polling_schedule) return Response( PollingScheduleSerializer(updated_polling_schedule).data, status=status.HTTP_200_OK @@ -201,8 +208,14 @@ def deactivate_polling(self, request, id=None): tags=["Polling"], methods=["POST"], ) - def manual_review_polling(self, request, id=None): + def manual_review_polling(self, request, id: int): + user_authorized_app_ids = request.user.apps.values_list("id", flat=True) date_from = request.query_params.get("date_from", None) date_to = request.query_params.get("date_to", None) - self.service.poll_reviews(app_id=id, date_from=date_from, date_to=date_to) + self.service.manual_poll_reviews( + app_id=id, + date_from=date_from, + date_to=date_to, + authorized_app_ids=user_authorized_app_ids, + ) return Response({"detail": "Polling triggered successfully."}, status=200) diff --git a/review/models.py b/review/models.py index 908383d..05438e6 100644 --- a/review/models.py +++ b/review/models.py @@ -7,7 +7,6 @@ class ReviewPolarity(models.TextChoices): POSITIVE = "positive", "Positive" - NEUTRAL = "neutral", "Neutral" NEGATIVE = "negative", "Negative" diff --git a/review/tests/test_models.py b/review/tests/test_models.py index c87ad06..e63fb72 100644 --- a/review/tests/test_models.py +++ b/review/tests/test_models.py @@ -51,7 +51,7 @@ def test_create_review_with_values(): content="Content of the test review", date=now, rating=5, - polarity=ReviewPolarity.NEUTRAL, + polarity=ReviewPolarity.POSITIVE, type=ReviewType.FEATURE, ) assert r.pk is not None @@ -60,5 +60,5 @@ def test_create_review_with_values(): assert r.content == "Content of the test review" assert r.date == now assert r.rating == 5 - assert r.polarity == ReviewPolarity.NEUTRAL + assert r.polarity == ReviewPolarity.POSITIVE assert r.type == ReviewType.FEATURE diff --git a/source/repositories.py b/source/repositories.py index 61bae87..530d8f6 100644 --- a/source/repositories.py +++ b/source/repositories.py @@ -3,41 +3,40 @@ class SourceRepository: - @staticmethod - def get_all(): + def get_all(self): return Source.objects.all() - @staticmethod - def get_by_id(source_id): + def get_by_id(self, source_id): return Source.objects.get(id=source_id) - @staticmethod - def create(data): - return Source.objects.create(**data) + def create(self, data): + metrics = data.pop("metrics", []) + source = Source.objects.create(**data) + if metrics: + source.metrics.set(metrics) + return source - @staticmethod - def update(instance, data): + def update(self, instance, data): + metrics = data.pop("metrics", None) for attr, value in data.items(): setattr(instance, attr, value) instance.save() + if metrics is not None: + instance.metrics.set(metrics) return instance - @staticmethod - def delete(instance): + def delete(self, instance): instance.delete() - @staticmethod - def add_metrics(instance, metrics_ids): + def add_metrics(self, instance, metrics_ids): instance.metrics.add(*metrics_ids) instance.save() return instance - @staticmethod - def remove_metrics(instance, metrics_ids): + def remove_metrics(self, instance, metrics_ids): instance.metrics.remove(*metrics_ids) instance.save() return instance - @staticmethod - def get_by_code(code: str): + def get_by_code(self, code: str): return Source.objects.prefetch_related("metrics").get(code=code) diff --git a/source/serializers.py b/source/serializers.py index 10814a2..d1642ab 100644 --- a/source/serializers.py +++ b/source/serializers.py @@ -1,5 +1,7 @@ from rest_framework import serializers +from metric.models import Metric + from .constants.source_type import SourceType from .models import Source @@ -14,6 +16,9 @@ class SourceSerializer(serializers.ModelSerializer): "- 'external_tool': Data provided by an external tool." ), ) + metrics = serializers.PrimaryKeyRelatedField( + queryset=Metric.objects.all(), many=True, required=False + ) class Meta: model = Source @@ -25,10 +30,3 @@ class Meta: "url", "metrics", ] - read_only_fields = ["metrics"] - - -class LinkSourceSerializer(serializers.Serializer): - sources = serializers.ListField( - child=serializers.IntegerField(), help_text="List of source IDs to link to the metric." - ) diff --git a/source/tests/test_create_sources.py b/source/tests/test_create_sources.py index 3e81c13..6fcf99e 100644 --- a/source/tests/test_create_sources.py +++ b/source/tests/test_create_sources.py @@ -68,3 +68,42 @@ def test_create_source_missing_fields(self, dummy_superuser): assert ( "name" in response.json()["errors"][0].lower() or "name" in str(response.json()).lower() ) + + def test_create_source_with_metric( + self, dummy_superuser, dummy_metric, create_default_sources_and_metrics + ): + client = APIClient() + client.force_authenticate(user=dummy_superuser) + + payload = { + "code": "test_source", + "name": "Test Source", + "type": SourceType.API, + "url": "https://example.com", + "metrics": [dummy_metric.id], + } + + response = client.post("/api/sources/", data=payload, format="json") + + assert response.status_code == status.HTTP_201_CREATED + assert Source.objects.filter(name="Test Source").exists() + + def test_create_source_with_nonexistent_metric( + self, dummy_superuser, create_default_sources_and_metrics + ): + client = APIClient() + client.force_authenticate(user=dummy_superuser) + + payload = { + "code": "test_source", + "name": "Test Source", + "type": SourceType.API, + "url": "https://example.com", + "metrics": [9999], + } + + response = client.post("/api/sources/", data=payload, format="json") + + assert response.status_code == status.HTTP_400_BAD_REQUEST + assert ("errors") in response.data + assert "Invalid pk" in response.data["errors"][0] diff --git a/source/tests/test_update_sources.py b/source/tests/test_update_sources.py index ea1e7b5..c639e03 100644 --- a/source/tests/test_update_sources.py +++ b/source/tests/test_update_sources.py @@ -59,3 +59,42 @@ def test_update_source_invalid_data(self, dummy_source, dummy_superuser): response = client.put(f"/api/sources/{dummy_source.id}/", data=payload, format="json") assert response.status_code == status.HTTP_400_BAD_REQUEST assert "name" in str(response.json()).lower() + + def test_update_source_metric( + self, dummy_source, dummy_superuser, dummy_metric, create_default_sources_and_metrics + ): + client = APIClient() + client.force_authenticate(user=dummy_superuser) + + payload = { + "code": dummy_source.code, + "name": "Updated Name", + "type": SourceType.SCRAPER, + "url": "https://exampleupdated.com", + "metrics": [dummy_metric.id], + } + + response = client.put(f"/api/sources/{dummy_source.id}/", data=payload, format="json") + assert response.status_code == status.HTTP_200_OK, f"Errors: {response.json()}" + + assert response.json()["name"] == "Updated Name" + assert response.json()["url"] == "https://exampleupdated.com" + + def test_update_source_nonexistent_metric( + self, dummy_source, dummy_superuser, create_default_sources_and_metrics + ): + client = APIClient() + client.force_authenticate(user=dummy_superuser) + + payload = { + "code": dummy_source.code, + "name": "Updated Name", + "type": SourceType.SCRAPER, + "url": "https://exampleupdated.com", + "metrics": [9999], + } + + response = client.put(f"/api/sources/{dummy_source.id}/", data=payload, format="json") + assert response.status_code == status.HTTP_400_BAD_REQUEST + assert ("errors") in response.data + assert "Invalid pk" in response.data["errors"][0] diff --git a/source/views.py b/source/views.py index 620772f..cc46b20 100644 --- a/source/views.py +++ b/source/views.py @@ -1,9 +1,7 @@ from drf_spectacular.utils import OpenApiParameter, OpenApiResponse, extend_schema from rest_framework import status, viewsets -from rest_framework.decorators import action from rest_framework.response import Response -from metric.serializers import LinkMetricsSerializer from permissions.decorators import superuser_required from schemas.responses import ( BAD_REQUEST_RESPONSE, @@ -119,56 +117,3 @@ def destroy(self, request, pk=None): source = self.service.get_source(pk) self.service.delete_source(source) return Response(status=status.HTTP_204_NO_CONTENT) - - @extend_schema( - summary="Add metrics to a source", - description=( - "Adds a list of metric IDs to the given source," - " without removing existing associations.\n\n" - "The input must include a `metrics` field with a list of IDs." - ), - request=LinkMetricsSerializer, - parameters=[OpenApiParameter(name="id", required=True, type=int, location="path")], - responses=SourceSerializer, - tags=["Sources"], - ) - @superuser_required - @action(detail=True, methods=["post"], url_path="metrics") - def add_metrics(self, request, pk=None): - metrics_ids = request.data.get("metrics", []) - - if not isinstance(metrics_ids, list): - return Response( - {"error": "metrics must be a list of IDs"}, status=status.HTTP_400_BAD_REQUEST - ) - - source = self.service.get_source(pk) - source = self.service.add_metrics(source, metrics_ids) - - return Response(SourceSerializer(source).data, status=status.HTTP_200_OK) - - @extend_schema( - summary="Remove metrics from a source", - description=( - "Removes the specified metric IDs from the given source.\n\n" - "The input must include a `metrics` field with a list of IDs." - ), - request=LinkMetricsSerializer, - parameters=[OpenApiParameter(name="id", required=True, type=int, location="path")], - responses=SourceSerializer, - tags=["Sources"], - ) - @superuser_required - @action(detail=True, methods=["delete"], url_path="metrics") - def remove_metrics(self, request, pk=None): - metrics_ids = request.data.get("metrics", []) - - if not isinstance(metrics_ids, list): - return Response( - {"error": "metrics must be a list of IDs"}, status=status.HTTP_400_BAD_REQUEST - ) - - source = self.service.get_source(pk) - source = self.service.remove_metrics(source, metrics_ids) - - return Response(SourceSerializer(source).data, status=status.HTTP_200_OK) From a5133f9a8223e5422ff9fd22315d2e42a3a481e6 Mon Sep 17 00:00:00 2001 From: Anyer Date: Thu, 19 Jun 2025 16:50:47 +0200 Subject: [PATCH 2/6] Refactor de tests --- app/tests/test_create_apps.py | 37 +++++++-- conftest.py | 3 +- metric/services.py | 51 ++++++++---- metric/tests/test_create_metrics.py | 8 +- metric/tests/test_derived_metric_bug_rate.py | 42 ++++++++++ .../test_derived_metric_positive_rate.py | 42 ++++++++++ .../test_derived_metric_update_changed.py | 82 +++++++++++++++++++ metric/tests/test_get_app_metric.py | 3 - metric/tests/test_update_metrics.py | 8 +- metric/views.py | 2 +- polling/models.py | 1 - polling/tests/test_activate_polling.py | 9 +- polling/tests/test_deactivate_polling.py | 20 ++--- polling/tests/test_manual_review_polling.py | 8 +- polling/tests/test_poll_metrics.py | 4 - polling/tests/test_poll_reviews.py | 7 +- polling/tests/test_retrieve_polling.py | 4 +- polling/tests/test_review_analysis.py | 23 +++--- review/models.py | 8 +- review/repositories.py | 12 ++- review/tests/test_list_reviews.py | 64 +++++++++++++++ source/tests/test_adapter_fetch_behavior.py | 1 - source/tests/test_adapters_contract.py | 1 - source/tests/test_adapters_registry.py | 4 +- source/tests/test_all_adapters.py | 4 +- source/tests/test_create_sources.py | 8 +- source/tests/test_google_play_scraper.py | 4 - source/tests/test_itunes_adapter.py | 1 - source/tests/test_load_sources.py | 2 +- source/tests/test_news.py | 1 - source/tests/test_reddit.py | 1 - source/tests/test_update_sources.py | 8 +- 32 files changed, 340 insertions(+), 133 deletions(-) create mode 100644 metric/tests/test_derived_metric_bug_rate.py create mode 100644 metric/tests/test_derived_metric_positive_rate.py create mode 100644 metric/tests/test_derived_metric_update_changed.py diff --git a/app/tests/test_create_apps.py b/app/tests/test_create_apps.py index 82f8113..ff02339 100644 --- a/app/tests/test_create_apps.py +++ b/app/tests/test_create_apps.py @@ -1,3 +1,5 @@ +import datetime + import pytest from rest_framework import status from rest_framework.test import APIClient @@ -6,15 +8,14 @@ @pytest.mark.django_db -@pytest.mark.usefixtures("dummy_app") +@pytest.mark.usefixtures("dummy_user") class TestAppCreateViewSet: - def test_create_app_success(self, dummy_app, create_default_sources_and_metrics): + def test_create_app_success(self, dummy_user): client = APIClient() - client.force_authenticate(user=dummy_app.user) + client.force_authenticate(user=dummy_user) payload = { "name": "Test App", - "code": "test_app", "appstore_id": "123456789", "playstore_id": "com.example.app", } @@ -22,13 +23,12 @@ def test_create_app_success(self, dummy_app, create_default_sources_and_metrics) response = client.post("/api/apps/", data=payload, format="json") assert response.status_code == status.HTTP_201_CREATED - assert App.objects.filter(name="Test App", user=dummy_app.user).exists() + assert App.objects.filter(name="Test App", user=dummy_user).exists() def test_create_app_unauthenticated(self): client = APIClient() payload = { "name": "Test App", - "code": "test_app", "appstore_id": "123456789", "playstore_id": "com.example.app", } @@ -37,13 +37,13 @@ def test_create_app_unauthenticated(self): assert response.status_code == status.HTTP_401_UNAUTHORIZED - def test_create_app_missing_fields(self, dummy_app): + def test_create_app_missing_fields(self, dummy_user): client = APIClient() - client.force_authenticate(user=dummy_app.user) + client.force_authenticate(user=dummy_user) payload = { # Falta 'name' i altres - "code": "test_app" + "appstore_id": "123456789" } response = client.post("/api/apps/", data=payload, format="json") @@ -52,3 +52,22 @@ def test_create_app_missing_fields(self, dummy_app): assert ( "name" in response.json()["errors"][0].lower() or "name" in str(response.json()).lower() ) + + def test_create_app_with_real_values(self, dummy_user): + client = APIClient() + client.force_authenticate(user=dummy_user) + + payload = { + "name": "Discord", + "appstore_id": "985746746", + "playstore_id": "com.discord", + } + + response = client.post("/api/apps/", data=payload, format="json") + + assert response.status_code == status.HTTP_201_CREATED + app = App.objects.get(name="Discord", user=dummy_user) + assert app.developer == "Discord, Inc." + assert app.release_date == datetime.date(2015, 5, 21) + assert app.available_on_ios is True + assert app.available_on_android is True diff --git a/conftest.py b/conftest.py index 94773e7..19e2148 100644 --- a/conftest.py +++ b/conftest.py @@ -94,7 +94,7 @@ def dummy_review(db, dummy_app, dummy_source): ) -@pytest.fixture +@pytest.fixture(autouse=True) def create_default_sources_and_metrics(db): itunes = Source.objects.create( code="itunes", name="App Store", type="api", url="https://itunes.apple.com" @@ -111,7 +111,6 @@ def create_default_sources_and_metrics(db): MetricCode.AVERAGE_RATING, MetricCode.TOTAL_REVIEWS, MetricCode.TOTAL_DOWNLOADS, - MetricCode.LAST_UPDATE_DATE, MetricCode.DAILY_SOCIAL_NETWORK_MENTIONS, MetricCode.DAILY_NEWS_BLOG_MENTIONS, ]: diff --git a/metric/services.py b/metric/services.py index 45f2d0e..05dc1af 100644 --- a/metric/services.py +++ b/metric/services.py @@ -1,8 +1,10 @@ +from collections import defaultdict from typing import List from django.core.exceptions import ObjectDoesNotExist from rest_framework.exceptions import NotFound +from review.models import ReviewPolarity, ReviewType from review.services import ReviewService from .constants import MetricCode @@ -119,7 +121,7 @@ def get_derived_metric_values_by_app_and_metric(self, app_id, metric_code): key = (date, source) total_reviews[key] = total_reviews.get(key, 0) + 1 - if review.type == "Bug": + if review.type == ReviewType.BUG: bug_reviews[key] = bug_reviews.get(key, 0) + 1 for key in total_reviews: @@ -131,6 +133,8 @@ def get_derived_metric_values_by_app_and_metric(self, app_id, metric_code): elif metric_code == MetricCode.POSITIVE_RATE: polarity_counts = {} + positive = ReviewPolarity.POSITIVE + negative = ReviewPolarity.NEGATIVE for review in reviews: date = review.date.strftime("%Y-%m-%d") @@ -138,42 +142,55 @@ def get_derived_metric_values_by_app_and_metric(self, app_id, metric_code): key = (date, source) if key not in polarity_counts: - polarity_counts[key] = {"positive": 0, "negative": 0} + polarity_counts[key] = {positive: 0, negative: 0} - if review.polarity in ["positive", "negative"]: + if review.polarity in [positive, negative]: polarity_counts[key][review.polarity] += 1 for key, counts in polarity_counts.items(): date, source = key - total = counts["positive"] + counts["negative"] - frequency = counts["positive"] / total if total > 0 else 0 + total = counts[positive] + counts[negative] + frequency = counts[positive] / total if total > 0 else 0 derived_values.append({"retrieved_at": date, "value": frequency, "source": source}) elif metric_code == MetricCode.UPDATE_CHANGED: metric = self.metric_service.get_metric_by_code(MetricCode.LAST_UPDATE_DATE) last_update_values = self.repo.get_by_app_and_metric(app_id, metric.id) grouped_by_source = {} + for value in last_update_values: source_name = value.source.name if value.source else "Internal" grouped_by_source.setdefault(source_name, []).append(value) - # Tractem cada font independentment for source, values in grouped_by_source.items(): - previous_value = None + # Agrupem per data de recollida + grouped_by_date = defaultdict(list) for value in values: + retrieved_date = value.retrieved_at.date() + grouped_by_date[retrieved_date].append(value) + previous_update_date = None + for retrieved_date in sorted(grouped_by_date.keys()): + group = grouped_by_date[retrieved_date] + # Valorem el primer LAST_UPDATE_DATE del dia com a representatiu current_update_date = ( - value.value.date() if hasattr(value.value, "date") else value.value + group[0].value.date() if hasattr(group[0].value, "date") else group[0].value ) + change_detected = 0 - if previous_value is not None and current_update_date != previous_value: + if ( + previous_update_date is not None + and current_update_date != previous_update_date + ): change_detected = 1 - derived_values.append( - { - "retrieved_at": value.retrieved_at.strftime("%Y-%m-%d"), - "value": change_detected, - "source": source, - } - ) - previous_value = current_update_date + + for value in group: + derived_values.append( + { + "retrieved_at": value.retrieved_at.strftime("%Y-%m-%d"), + "value": change_detected, + "source": source, + } + ) + previous_update_date = current_update_date return derived_values diff --git a/metric/tests/test_create_metrics.py b/metric/tests/test_create_metrics.py index e811ac4..a66b79b 100644 --- a/metric/tests/test_create_metrics.py +++ b/metric/tests/test_create_metrics.py @@ -71,9 +71,7 @@ def test_create_metric_missing_fields(self, dummy_superuser): "name" in response.json()["errors"][0].lower() or "name" in str(response.json()).lower() ) - def test_create_metric_with_source( - self, dummy_superuser, dummy_source, create_default_sources_and_metrics - ): + def test_create_metric_with_source(self, dummy_superuser, dummy_source): client = APIClient() client.force_authenticate(user=dummy_superuser) @@ -91,9 +89,7 @@ def test_create_metric_with_source( assert response.status_code == status.HTTP_201_CREATED assert Metric.objects.filter(name="Test Metric").exists() - def test_create_metric_with_nonexistent_source( - self, dummy_superuser, create_default_sources_and_metrics - ): + def test_create_metric_with_nonexistent_source(self, dummy_superuser): client = APIClient() client.force_authenticate(user=dummy_superuser) diff --git a/metric/tests/test_derived_metric_bug_rate.py b/metric/tests/test_derived_metric_bug_rate.py new file mode 100644 index 0000000..20d3177 --- /dev/null +++ b/metric/tests/test_derived_metric_bug_rate.py @@ -0,0 +1,42 @@ +import datetime + +import pytest +from django.utils import timezone + +from metric.constants import MetricCode +from metric.services import MetricValueService +from review.models import Review, ReviewPolarity, ReviewType +from source.models import Source + + +@pytest.mark.django_db +def test_update_changed_multiple_values_same_date(dummy_app): + source = Source.objects.get(code="google_play") + Review.objects.create( + app=dummy_app, + source=source, + content="Test not bug review", + date=timezone.make_aware(datetime.datetime(2023, 6, 1)), + polarity=ReviewPolarity.POSITIVE, + type=ReviewType.FEATURE, + ) + Review.objects.create( + app=dummy_app, + source=source, + content="Test bug review", + date=timezone.make_aware(datetime.datetime(2023, 6, 1)), + polarity=ReviewPolarity.POSITIVE, + type=ReviewType.BUG, + ) + + expected_output = [ + {"retrieved_at": "2023-06-01", "value": 0.5, "source": "Google Play"}, + ] + + service = MetricValueService() + output = service.get_derived_metric_values_by_app_and_metric( + app_id=dummy_app.id, + metric_code=MetricCode.BUG_RATE, + ) + + assert output == expected_output diff --git a/metric/tests/test_derived_metric_positive_rate.py b/metric/tests/test_derived_metric_positive_rate.py new file mode 100644 index 0000000..fefd6fa --- /dev/null +++ b/metric/tests/test_derived_metric_positive_rate.py @@ -0,0 +1,42 @@ +import datetime + +import pytest +from django.utils import timezone + +from metric.constants import MetricCode +from metric.services import MetricValueService +from review.models import Review, ReviewPolarity, ReviewType +from source.models import Source + + +@pytest.mark.django_db +def test_update_changed_multiple_values_same_date(dummy_app): + source = Source.objects.get(code="google_play") + Review.objects.create( + app=dummy_app, + source=source, + content="Test positive review", + date=timezone.make_aware(datetime.datetime(2023, 6, 10)), + polarity=ReviewPolarity.POSITIVE, + type=ReviewType.FEATURE, + ) + Review.objects.create( + app=dummy_app, + source=source, + content="Test negative review", + date=timezone.make_aware(datetime.datetime(2023, 6, 10)), + polarity=ReviewPolarity.NEGATIVE, + type=ReviewType.FEATURE, + ) + + expected_output = [ + {"retrieved_at": "2023-06-10", "value": 0.5, "source": "Google Play"}, + ] + + service = MetricValueService() + output = service.get_derived_metric_values_by_app_and_metric( + app_id=dummy_app.id, + metric_code=MetricCode.POSITIVE_RATE, + ) + + assert output == expected_output diff --git a/metric/tests/test_derived_metric_update_changed.py b/metric/tests/test_derived_metric_update_changed.py new file mode 100644 index 0000000..5a04f1f --- /dev/null +++ b/metric/tests/test_derived_metric_update_changed.py @@ -0,0 +1,82 @@ +import datetime + +import pytest +from django.utils import timezone + +from metric.constants import MetricCode +from metric.models import Metric, MetricValue +from metric.services import MetricValueService +from source.models import Source + + +@pytest.mark.django_db +def test_update_changed_multiple_values_same_date(dummy_app): + # Creem la mètrica base: LAST_UPDATE_DATE + metric = Metric.objects.create( + code=MetricCode.LAST_UPDATE_DATE, + name="Last Update Date", + description="The last date the app was updated.", + value_type="date", + is_derived=False, + ) + + source = Source.objects.get(code="google_play") + + # Afegim múltiples valors de la mateixa data de recollida + MetricValue.objects.bulk_create( + [ + MetricValue( + app=dummy_app, + metric=metric, + value=timezone.make_aware(datetime.datetime(2023, 6, 15)), + source=source, + retrieved_at=timezone.make_aware(datetime.datetime(2023, 6, 1)), + ), + MetricValue( + app=dummy_app, + metric=metric, + value=timezone.make_aware(datetime.datetime(2023, 6, 15)), + source=source, + retrieved_at=timezone.make_aware(datetime.datetime(2023, 6, 1)), + ), + MetricValue( + app=dummy_app, + metric=metric, + value=timezone.make_aware(datetime.datetime(2023, 6, 16)), + source=source, + retrieved_at=timezone.make_aware(datetime.datetime(2023, 6, 2)), + ), + MetricValue( + app=dummy_app, + metric=metric, + value=timezone.make_aware(datetime.datetime(2023, 6, 16)), + source=source, + retrieved_at=timezone.make_aware(datetime.datetime(2023, 6, 2)), + ), + MetricValue( + app=dummy_app, + metric=metric, + value=timezone.make_aware(datetime.datetime(2023, 6, 16)), + source=source, + retrieved_at=timezone.make_aware(datetime.datetime(2023, 6, 3)), + ), + ] + ) + + expected_output = [ + {"retrieved_at": "2023-06-01", "value": 0, "source": "Google Play"}, + {"retrieved_at": "2023-06-01", "value": 0, "source": "Google Play"}, + {"retrieved_at": "2023-06-02", "value": 1, "source": "Google Play"}, + {"retrieved_at": "2023-06-02", "value": 1, "source": "Google Play"}, + {"retrieved_at": "2023-06-03", "value": 0, "source": "Google Play"}, + ] + + # Calculem la mètrica derivada UPDATE_CHANGED + service = MetricValueService() + output = service.get_derived_metric_values_by_app_and_metric( + app_id=dummy_app.id, + metric_code=MetricCode.UPDATE_CHANGED, + ) + + # Comprovem que el comportament sigui l'esperat + assert output == expected_output, f"Expected {expected_output}, but got {output}" diff --git a/metric/tests/test_get_app_metric.py b/metric/tests/test_get_app_metric.py index 488b64d..66439b7 100644 --- a/metric/tests/test_get_app_metric.py +++ b/metric/tests/test_get_app_metric.py @@ -4,9 +4,6 @@ @pytest.mark.django_db -@pytest.mark.usefixtures( - "dummy_app", "dummy_metric", "create_default_sources_and_metrics", "dummy_metric_value" -) class TestGetAppMetricDashboard: def test_get_metric_dashboard_success(self, dummy_app, dummy_metric): client = APIClient() diff --git a/metric/tests/test_update_metrics.py b/metric/tests/test_update_metrics.py index 2a73534..9128dbe 100644 --- a/metric/tests/test_update_metrics.py +++ b/metric/tests/test_update_metrics.py @@ -59,9 +59,7 @@ def test_update_metric_invalid_data(self, dummy_metric, dummy_superuser): assert response.status_code == status.HTTP_400_BAD_REQUEST assert "name" in str(response.json()).lower() - def test_update_metric_with_source( - self, dummy_metric, dummy_superuser, dummy_source, create_default_sources_and_metrics - ): + def test_update_metric_with_source(self, dummy_metric, dummy_superuser, dummy_source): client = APIClient() client.force_authenticate(user=dummy_superuser) @@ -80,9 +78,7 @@ def test_update_metric_with_source( assert response.json()["name"] == "Updated Name" assert response.json()["description"] == "New description" - def test_update_metric_with_nonexistent_source( - self, dummy_metric, dummy_superuser, create_default_sources_and_metrics - ): + def test_update_metric_with_nonexistent_source(self, dummy_metric, dummy_superuser): client = APIClient() client.force_authenticate(user=dummy_superuser) diff --git a/metric/views.py b/metric/views.py index c486ccb..3476fb2 100644 --- a/metric/views.py +++ b/metric/views.py @@ -203,7 +203,7 @@ def list(self, request): 401: UNAUTHORIZED_RESPONSE, 404: not_found_response("app", True), }, - tags=["MetricValues"], + tags=["Metrics"], methods=["get"], ) def get_app_metric(self, request, id=None, metric_id=None): diff --git a/polling/models.py b/polling/models.py index 92a9619..1e73826 100644 --- a/polling/models.py +++ b/polling/models.py @@ -12,7 +12,6 @@ class PollingSchedule(models.Model): POLL_TYPE_CHOICES = [ ("metrics", "Metrics only"), ("reviews", "Reviews only"), - ("both", "Metrics + Reviews"), ] poll_type = models.CharField(max_length=10, choices=POLL_TYPE_CHOICES, default="metrics") diff --git a/polling/tests/test_activate_polling.py b/polling/tests/test_activate_polling.py index 3649d67..391b832 100644 --- a/polling/tests/test_activate_polling.py +++ b/polling/tests/test_activate_polling.py @@ -8,7 +8,6 @@ @patch("polling.services.polling_schedule_service.run_polling_task.delay") def test_activate_polling_schedule_with_valid_interval( mock_run_task, - create_default_sources_and_metrics, create_polling_schedule, ): client = APIClient() @@ -27,9 +26,7 @@ def test_activate_polling_schedule_with_valid_interval( @pytest.mark.django_db -def test_activate_polling_schedule_with_invalid_interval( - create_default_sources_and_metrics, create_polling_schedule -): +def test_activate_polling_schedule_with_invalid_interval(create_polling_schedule): client = APIClient() schedule = create_polling_schedule(poll_type="reviews", interval_hours=6) @@ -43,9 +40,7 @@ def test_activate_polling_schedule_with_invalid_interval( @pytest.mark.django_db -def test_activate_polling_schedule_already_active_returns_conflict( - create_default_sources_and_metrics, create_polling_schedule -): +def test_activate_polling_schedule_already_active_returns_conflict(create_polling_schedule): client = APIClient() schedule = create_polling_schedule(poll_type="metrics", interval_hours=6) diff --git a/polling/tests/test_deactivate_polling.py b/polling/tests/test_deactivate_polling.py index 43da444..0ebecce 100644 --- a/polling/tests/test_deactivate_polling.py +++ b/polling/tests/test_deactivate_polling.py @@ -3,9 +3,7 @@ @pytest.mark.django_db -def test_deactivate_polling_schedule_success( - create_polling_schedule, create_default_sources_and_metrics -): +def test_deactivate_polling_schedule_success(create_polling_schedule): client = APIClient() schedule = create_polling_schedule(poll_type="metrics", interval_hours=6) schedule.is_active = True @@ -21,9 +19,7 @@ def test_deactivate_polling_schedule_success( @pytest.mark.django_db -def test_deactivate_polling_schedule_reviews( - create_polling_schedule, create_default_sources_and_metrics -): +def test_deactivate_polling_schedule_reviews(create_polling_schedule): client = APIClient() schedule = create_polling_schedule(poll_type="reviews", interval_hours=8) schedule.is_active = True @@ -39,9 +35,7 @@ def test_deactivate_polling_schedule_reviews( @pytest.mark.django_db -def test_deactivate_polling_schedule_already_inactive_returns_conflict( - create_polling_schedule, create_default_sources_and_metrics -): +def test_deactivate_polling_schedule_already_inactive_returns_conflict(create_polling_schedule): client = APIClient() schedule = create_polling_schedule(poll_type="metrics", interval_hours=6) schedule.is_active = False @@ -57,9 +51,7 @@ def test_deactivate_polling_schedule_already_inactive_returns_conflict( @pytest.mark.django_db -def test_deactivate_polling_schedule_not_found_returns_404( - dummy_user, create_default_sources_and_metrics -): +def test_deactivate_polling_schedule_not_found_returns_404(dummy_user): client = APIClient() client.force_authenticate(user=dummy_user) @@ -70,9 +62,7 @@ def test_deactivate_polling_schedule_not_found_returns_404( @pytest.mark.django_db -def test_deactivate_polling_schedule_requires_authentication( - create_polling_schedule, create_default_sources_and_metrics -): +def test_deactivate_polling_schedule_requires_authentication(create_polling_schedule): client = APIClient() schedule = create_polling_schedule(poll_type="metrics") schedule.is_active = True diff --git a/polling/tests/test_manual_review_polling.py b/polling/tests/test_manual_review_polling.py index ba2d388..2cd86c6 100644 --- a/polling/tests/test_manual_review_polling.py +++ b/polling/tests/test_manual_review_polling.py @@ -6,9 +6,7 @@ @pytest.mark.django_db @patch("polling.services.polling_schedule_service.run_polling_task.delay") -def test_manual_review_polling_success( - mock_run_task, create_polling_schedule, create_default_sources_and_metrics -): +def test_manual_review_polling_success(mock_run_task, create_polling_schedule): client = APIClient() schedule = create_polling_schedule(poll_type="reviews", interval_hours=6) @@ -27,9 +25,7 @@ def test_manual_review_polling_success( @pytest.mark.django_db -def test_manual_review_polling_unauthenticated_returns_401( - create_polling_schedule, create_default_sources_and_metrics -): +def test_manual_review_polling_unauthenticated_returns_401(create_polling_schedule): client = APIClient() schedule = create_polling_schedule(poll_type="reviews", interval_hours=6) diff --git a/polling/tests/test_poll_metrics.py b/polling/tests/test_poll_metrics.py index de9f7da..667d64f 100644 --- a/polling/tests/test_poll_metrics.py +++ b/polling/tests/test_poll_metrics.py @@ -17,9 +17,7 @@ def test_poll_metrics_calls_fetch_and_saves_values( mock_get_app, mock_create_metric_value, dummy_app, - create_default_sources_and_metrics, ): - # Arrange dummy_app.id = 1 mock_get_app.return_value = dummy_app @@ -36,10 +34,8 @@ def test_poll_metrics_calls_fetch_and_saves_values( polling_service = PollingExecutionService() - # Act polling_service.poll_metrics(app_id=dummy_app.id) - # Assert mock_adapter.fetch.assert_called_once_with(dummy_app, [MetricCode.AVERAGE_RATING]) mock_create_metric_value.assert_called_once() args, kwargs = mock_create_metric_value.call_args diff --git a/polling/tests/test_poll_reviews.py b/polling/tests/test_poll_reviews.py index 3e2ff2c..872abbd 100644 --- a/polling/tests/test_poll_reviews.py +++ b/polling/tests/test_poll_reviews.py @@ -22,12 +22,11 @@ def test_poll_reviews_saves_analyzed_reviews( mock_get_existing_ids, mock_save_reviews, dummy_app, - create_default_sources_and_metrics, ): # Setup mocks mock_get_app.return_value = dummy_app mock_get_existing_ids.return_value = [] - mock_save_reviews.return_value = 1 # 🔧 Aquí és on cal indicar-ho + mock_save_reviews.return_value = 1 mock_adapter = MagicMock() mock_adapter.name = "Reddit" @@ -40,10 +39,8 @@ def test_poll_reviews_saves_analyzed_reviews( polling_execution_service = PollingExecutionService() - # Act results = polling_execution_service.poll_reviews(app_id=dummy_app.id) - # Assert assert len(results) == 1 assert results[0]["saved"] == 1 saved_reviews = mock_save_reviews.call_args[0][2] @@ -68,7 +65,6 @@ def test_poll_reviews_skips_existing_reviews( mock_get_existing_ids, mock_save_reviews, dummy_app, - create_default_sources_and_metrics, ): mock_get_app.return_value = dummy_app mock_get_existing_ids.return_value = ["123"] # 🔁 ja existeix @@ -104,7 +100,6 @@ def test_poll_reviews_handles_failed_analysis_services_gracefully( mock_get_existing_ids, mock_save_reviews, dummy_app, - create_default_sources_and_metrics, ): mock_get_app.return_value = dummy_app mock_get_existing_ids.return_value = [] diff --git a/polling/tests/test_retrieve_polling.py b/polling/tests/test_retrieve_polling.py index d4f560c..625a9ef 100644 --- a/polling/tests/test_retrieve_polling.py +++ b/polling/tests/test_retrieve_polling.py @@ -3,9 +3,7 @@ @pytest.mark.django_db -def test_retrieve_polling_schedule_success( - create_polling_schedule, create_default_sources_and_metrics -): +def test_retrieve_polling_schedule_success(create_polling_schedule): client = APIClient() schedule = create_polling_schedule(poll_type="metrics", interval_hours=6) diff --git a/polling/tests/test_review_analysis.py b/polling/tests/test_review_analysis.py index 7da3941..cd88c02 100644 --- a/polling/tests/test_review_analysis.py +++ b/polling/tests/test_review_analysis.py @@ -3,6 +3,7 @@ import pytest from polling.services.polling_execution_service import PollingExecutionService +from review.models import ReviewPolarity, ReviewType @pytest.fixture @@ -15,14 +16,12 @@ def dummy_reviews(): @pytest.mark.django_db @patch("polling.services.polling_execution_service.requests.post") -def test_analyze_review_polarity_success( - mock_post, dummy_reviews, create_default_sources_and_metrics -): +def test_analyze_review_polarity_success(mock_post, dummy_reviews): mock_post.return_value.status_code = 200 mock_post.return_value.json.return_value = { "reviews": [ - {"reviewId": "1", "polarity": "positive"}, - {"reviewId": "2", "polarity": "negative"}, + {"reviewId": "1", "polarity": ReviewPolarity.POSITIVE}, + {"reviewId": "2", "polarity": ReviewPolarity.NEGATIVE}, ] } @@ -30,18 +29,18 @@ def test_analyze_review_polarity_success( result = service._analyze_review_polarity(dummy_reviews) assert len(result["reviews"]) == 2 - assert result["reviews"][0]["polarity"] == "positive" + assert result["reviews"][0]["polarity"] == ReviewPolarity.POSITIVE assert mock_post.called @pytest.mark.django_db @patch("polling.services.polling_execution_service.requests.post") -def test_analyze_review_type_success(mock_post, dummy_reviews, create_default_sources_and_metrics): +def test_analyze_review_type_success(mock_post, dummy_reviews): mock_post.return_value.status_code = 200 mock_post.return_value.json.return_value = { "reviews": [ - {"reviewId": "1", "type": "feature"}, - {"reviewId": "2", "type": "bug"}, + {"reviewId": "1", "type": ReviewType.FEATURE}, + {"reviewId": "2", "type": ReviewType.BUG}, ] } @@ -49,15 +48,13 @@ def test_analyze_review_type_success(mock_post, dummy_reviews, create_default_so result = service._analyze_review_type(dummy_reviews) assert len(result["reviews"]) == 2 - assert result["reviews"][1]["type"] == "bug" + assert result["reviews"][1]["type"] == ReviewType.BUG assert mock_post.called @pytest.mark.django_db @patch("polling.services.polling_execution_service.requests.post", side_effect=Exception("Timeout")) -def test_analyze_review_handles_exception( - mock_post, dummy_reviews, create_default_sources_and_metrics -): +def test_analyze_review_handles_exception(mock_post, dummy_reviews): service = PollingExecutionService() result = service._analyze_review_polarity(dummy_reviews) diff --git a/review/models.py b/review/models.py index 05438e6..0548ce4 100644 --- a/review/models.py +++ b/review/models.py @@ -11,10 +11,10 @@ class ReviewPolarity(models.TextChoices): class ReviewType(models.TextChoices): - BUG = "bug", "Bug" - RATING = "rating", "Rating" - FEATURE = "feature", "Feature" - USER_EXPERIENCE = "user_experience", "User Experience" + BUG = "Bug", "Bug" + RATING = "Rating", "Rating" + FEATURE = "Feature", "Feature" + USER_EXPERIENCE = "UserExperience", "User Experience" class Review(models.Model): diff --git a/review/repositories.py b/review/repositories.py index 1185ad5..471e31d 100644 --- a/review/repositories.py +++ b/review/repositories.py @@ -22,13 +22,21 @@ def get_all(self, filters=None): if date_from: try: - queryset = queryset.filter(date__gte=datetime.fromisoformat(date_from)) + queryset = queryset.filter( + date__gte=timezone.make_aware( + datetime.strptime(filters["date_from"], "%Y-%m-%d") + ) + ) except ValueError: pass if date_to: try: - queryset = queryset.filter(date__lte=datetime.fromisoformat(date_to)) + queryset = queryset.filter( + date__lte=timezone.make_aware( + datetime.strptime(filters["date_to"], "%Y-%m-%d") + ) + ) except ValueError: pass diff --git a/review/tests/test_list_reviews.py b/review/tests/test_list_reviews.py index 0b2df25..65591df 100644 --- a/review/tests/test_list_reviews.py +++ b/review/tests/test_list_reviews.py @@ -1,4 +1,7 @@ +import datetime + import pytest +from django.utils import timezone from rest_framework import status from rest_framework.test import APIClient @@ -45,3 +48,64 @@ def test_list_reviews_only_returns_user_values(self, dummy_user, dummy_review, d reviews = Review.objects.filter(app__user=dummy_user) expected_data = ReviewSerializer(reviews, many=True).data assert response.json() == expected_data + + def test_list_reviews_filter_by_app(self, dummy_user, dummy_review): + client = APIClient() + client.force_authenticate(user=dummy_user) + + # Afegim una review addicional d'una altra app del mateix usuari + Review.objects.create( + review_id="dummy_review_2", + app=dummy_review.app, + source=dummy_review.source, + author="Another Author", + content="Another review", + rating=3.0, + date="2023-10-05T00:00:00Z", + ) + + response = client.get(f"/api/reviews/?app={dummy_review.app.id}") + + assert response.status_code == status.HTTP_200_OK + reviews = Review.objects.filter(app=dummy_review.app) + expected_data = ReviewSerializer(reviews, many=True).data + assert response.json() == expected_data + + def test_list_reviews_filter_by_date_range(self, dummy_user, dummy_review): + client = APIClient() + client.force_authenticate(user=dummy_user) + + # Review dins del rang + Review.objects.create( + review_id="review_in_range", + app=dummy_review.app, + source=dummy_review.source, + author="Author1", + content="In range review", + rating=4.0, + date=timezone.make_aware(datetime.datetime(2023, 10, 15, 0, 0, 0)), + ) + + # Review fora del rang + Review.objects.create( + review_id="review_out_range", + app=dummy_review.app, + source=dummy_review.source, + author="Author2", + content="Out of range", + rating=2.0, + date=timezone.make_aware(datetime.datetime(2023, 5, 1, 0, 0, 0)), + ) + + response = client.get("/api/reviews/?date_from=2023-10-01&date_to=2023-10-31") + assert response.status_code == status.HTTP_200_OK + + # Definim els límits de data amb zona horària + date_from = timezone.make_aware(datetime.datetime(2023, 10, 1, 0, 0, 0)) + date_to = timezone.make_aware(datetime.datetime(2023, 10, 31, 23, 59, 59)) + + reviews = Review.objects.filter( + app__user=dummy_user, date__gte=date_from, date__lte=date_to + ) + expected_data = ReviewSerializer(reviews, many=True).data + assert response.json() == expected_data diff --git a/source/tests/test_adapter_fetch_behavior.py b/source/tests/test_adapter_fetch_behavior.py index f3288c8..8ffbf02 100644 --- a/source/tests/test_adapter_fetch_behavior.py +++ b/source/tests/test_adapter_fetch_behavior.py @@ -5,7 +5,6 @@ @pytest.mark.django_db -@pytest.mark.usefixtures("create_default_sources_and_metrics") def test_adapter_fetch_returns_dict(dummy_app): adapters = SourceService.load_sources() for adapter in adapters: diff --git a/source/tests/test_adapters_contract.py b/source/tests/test_adapters_contract.py index cf3dd79..1f0565f 100644 --- a/source/tests/test_adapters_contract.py +++ b/source/tests/test_adapters_contract.py @@ -4,7 +4,6 @@ @pytest.mark.django_db -@pytest.mark.usefixtures("create_default_sources_and_metrics") def test_all_adapters_have_required_methods(): adapters = SourceService.load_sources() for adapter in adapters: diff --git a/source/tests/test_adapters_registry.py b/source/tests/test_adapters_registry.py index 6258351..a3515f0 100644 --- a/source/tests/test_adapters_registry.py +++ b/source/tests/test_adapters_registry.py @@ -5,7 +5,6 @@ @pytest.mark.django_db -@pytest.mark.usefixtures("create_default_sources_and_metrics") def test_adapter_codes_are_unique(): adapters = SourceService.load_sources() codes = [adapter.code for adapter in adapters] @@ -13,8 +12,7 @@ def test_adapter_codes_are_unique(): @pytest.mark.django_db -@pytest.mark.usefixtures("create_default_sources_and_metrics") -def test_each_adapter_has_a_matching_source_in_db(create_default_sources_and_metrics): +def test_each_adapter_has_a_matching_source_in_db(): adapters = SourceService.load_sources() for adapter in adapters: assert Source.objects.filter( diff --git a/source/tests/test_all_adapters.py b/source/tests/test_all_adapters.py index 3610b30..22f732b 100644 --- a/source/tests/test_all_adapters.py +++ b/source/tests/test_all_adapters.py @@ -14,9 +14,7 @@ @pytest.mark.django_db @pytest.mark.parametrize("adapter_class", SourceAdapter.__subclasses__()) -def test_fetch_returns_dict_of_strings( - adapter_class, dummy_app, create_default_sources_and_metrics -): +def test_fetch_returns_dict_of_strings(adapter_class, dummy_app): adapter = adapter_class() if not adapter.supported_metrics: diff --git a/source/tests/test_create_sources.py b/source/tests/test_create_sources.py index 6fcf99e..7fca0c6 100644 --- a/source/tests/test_create_sources.py +++ b/source/tests/test_create_sources.py @@ -69,9 +69,7 @@ def test_create_source_missing_fields(self, dummy_superuser): "name" in response.json()["errors"][0].lower() or "name" in str(response.json()).lower() ) - def test_create_source_with_metric( - self, dummy_superuser, dummy_metric, create_default_sources_and_metrics - ): + def test_create_source_with_metric(self, dummy_superuser, dummy_metric): client = APIClient() client.force_authenticate(user=dummy_superuser) @@ -88,9 +86,7 @@ def test_create_source_with_metric( assert response.status_code == status.HTTP_201_CREATED assert Source.objects.filter(name="Test Source").exists() - def test_create_source_with_nonexistent_metric( - self, dummy_superuser, create_default_sources_and_metrics - ): + def test_create_source_with_nonexistent_metric(self, dummy_superuser): client = APIClient() client.force_authenticate(user=dummy_superuser) diff --git a/source/tests/test_google_play_scraper.py b/source/tests/test_google_play_scraper.py index e099dcd..7fabedf 100644 --- a/source/tests/test_google_play_scraper.py +++ b/source/tests/test_google_play_scraper.py @@ -9,7 +9,6 @@ @pytest.mark.django_db -@pytest.mark.usefixtures("create_default_sources_and_metrics") class TestGooglePlayScraperFetch: @patch("source.adapters.google_play_scraper.gp_app") def test_fetch_supported_metrics(self, mock_gp_app, dummy_app): @@ -30,7 +29,6 @@ def test_fetch_supported_metrics(self, mock_gp_app, dummy_app): MetricCode.AVERAGE_RATING, MetricCode.TOTAL_REVIEWS, MetricCode.TOTAL_DOWNLOADS, - MetricCode.LAST_UPDATE_DATE, ], ) @@ -38,7 +36,6 @@ def test_fetch_supported_metrics(self, mock_gp_app, dummy_app): MetricCode.AVERAGE_RATING: "4.6", MetricCode.TOTAL_REVIEWS: "50000", MetricCode.TOTAL_DOWNLOADS: "1000000", - MetricCode.LAST_UPDATE_DATE: "2024-12-10", } @patch("source.adapters.google_play_scraper.gp_app") @@ -53,7 +50,6 @@ def test_fetch_unsupported_metric_returns_empty(self, mock_gp_app, dummy_app): @pytest.mark.django_db -@pytest.mark.usefixtures("create_default_sources_and_metrics") class TestGooglePlayScraperFetchReviews: @patch("source.adapters.google_play_scraper.reviews") def test_fetch_reviews_within_range(self, mock_reviews, dummy_app): diff --git a/source/tests/test_itunes_adapter.py b/source/tests/test_itunes_adapter.py index b868d22..1cf564c 100644 --- a/source/tests/test_itunes_adapter.py +++ b/source/tests/test_itunes_adapter.py @@ -7,7 +7,6 @@ @pytest.mark.django_db -@pytest.mark.usefixtures("create_default_sources_and_metrics") class TestItunesSearchAPIAdapter: @patch("source.adapters.itunes.requests.get") def test_fetch_supported_metrics(self, mock_requests_get, dummy_app): diff --git a/source/tests/test_load_sources.py b/source/tests/test_load_sources.py index 6e5950e..734053b 100644 --- a/source/tests/test_load_sources.py +++ b/source/tests/test_load_sources.py @@ -4,7 +4,7 @@ @pytest.mark.django_db -@pytest.mark.usefixtures("create_default_sources_and_metrics") +@pytest.mark.usefixtures() def test_load_sources_returns_valid_adapters(): adapters = SourceService.load_sources() assert adapters, "No adapters loaded" diff --git a/source/tests/test_news.py b/source/tests/test_news.py index 3ca307a..4b6c93e 100644 --- a/source/tests/test_news.py +++ b/source/tests/test_news.py @@ -7,7 +7,6 @@ @pytest.mark.django_db -@pytest.mark.usefixtures("create_default_sources_and_metrics") class TestNewsAPIAdapter: @patch("source.adapters.news.requests.get") def test_fetch_supported_metric(self, mock_requests_get, dummy_app): diff --git a/source/tests/test_reddit.py b/source/tests/test_reddit.py index 8a96007..a7395c5 100644 --- a/source/tests/test_reddit.py +++ b/source/tests/test_reddit.py @@ -7,7 +7,6 @@ @pytest.mark.django_db -@pytest.mark.usefixtures("create_default_sources_and_metrics") class TestRedditAPIAdapter: @patch("praw.Reddit") def test_fetch_supported_metric(self, mock_reddit_class, dummy_app): diff --git a/source/tests/test_update_sources.py b/source/tests/test_update_sources.py index c639e03..6f454ca 100644 --- a/source/tests/test_update_sources.py +++ b/source/tests/test_update_sources.py @@ -60,9 +60,7 @@ def test_update_source_invalid_data(self, dummy_source, dummy_superuser): assert response.status_code == status.HTTP_400_BAD_REQUEST assert "name" in str(response.json()).lower() - def test_update_source_metric( - self, dummy_source, dummy_superuser, dummy_metric, create_default_sources_and_metrics - ): + def test_update_source_metric(self, dummy_source, dummy_superuser, dummy_metric): client = APIClient() client.force_authenticate(user=dummy_superuser) @@ -80,9 +78,7 @@ def test_update_source_metric( assert response.json()["name"] == "Updated Name" assert response.json()["url"] == "https://exampleupdated.com" - def test_update_source_nonexistent_metric( - self, dummy_source, dummy_superuser, create_default_sources_and_metrics - ): + def test_update_source_nonexistent_metric(self, dummy_source, dummy_superuser): client = APIClient() client.force_authenticate(user=dummy_superuser) From 67d5ccad6f275d53e2e3afdd83c7fc7cbb050910 Mon Sep 17 00:00:00 2001 From: Anyer Date: Thu, 19 Jun 2025 18:32:50 +0200 Subject: [PATCH 3/6] user tests --- .../0003_alter_pollingschedule_poll_type.py | 22 ++++++++++ .../migrations/0002_alter_review_polarity.py | 20 +++++++++ source/constants/source_type.py | 1 - source/serializers.py | 3 +- users/{tests.py => tests/__init__.py} | 0 users/tests/test_api_key.py | 41 +++++++++++++++++ users/tests/test_unauthorized.py | 10 +++++ users/tests/test_user_login.py | 27 ++++++++++++ users/tests/test_user_register.py | 20 +++++++++ users/tests/test_user_roles.py | 44 +++++++++++++++++++ users/urls.py | 3 +- users/views.py | 26 ++++++++++- 12 files changed, 212 insertions(+), 5 deletions(-) create mode 100644 polling/migrations/0003_alter_pollingschedule_poll_type.py create mode 100644 review/migrations/0002_alter_review_polarity.py rename users/{tests.py => tests/__init__.py} (100%) create mode 100644 users/tests/test_api_key.py create mode 100644 users/tests/test_unauthorized.py create mode 100644 users/tests/test_user_login.py create mode 100644 users/tests/test_user_register.py create mode 100644 users/tests/test_user_roles.py diff --git a/polling/migrations/0003_alter_pollingschedule_poll_type.py b/polling/migrations/0003_alter_pollingschedule_poll_type.py new file mode 100644 index 0000000..2bc0454 --- /dev/null +++ b/polling/migrations/0003_alter_pollingschedule_poll_type.py @@ -0,0 +1,22 @@ +# Generated by Django 5.1.7 on 2025-06-19 11:12 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ("polling", "0002_remove_pollingschedule_start_at"), + ] + + operations = [ + migrations.AlterField( + model_name="pollingschedule", + name="poll_type", + field=models.CharField( + choices=[("metrics", "Metrics only"), ("reviews", "Reviews only")], + default="metrics", + max_length=10, + ), + ), + ] diff --git a/review/migrations/0002_alter_review_polarity.py b/review/migrations/0002_alter_review_polarity.py new file mode 100644 index 0000000..14d9e73 --- /dev/null +++ b/review/migrations/0002_alter_review_polarity.py @@ -0,0 +1,20 @@ +# Generated by Django 5.1.7 on 2025-06-19 11:12 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ("review", "0001_initial"), + ] + + operations = [ + migrations.AlterField( + model_name="review", + name="polarity", + field=models.CharField( + choices=[("positive", "Positive"), ("negative", "Negative")], max_length=10 + ), + ), + ] diff --git a/source/constants/source_type.py b/source/constants/source_type.py index acfa338..0434e29 100644 --- a/source/constants/source_type.py +++ b/source/constants/source_type.py @@ -4,4 +4,3 @@ class SourceType(models.TextChoices): API = "api", "API" SCRAPER = "scraper", "Scraper" - EXTERNAL_TOOL = "external_tool", "External Tool" diff --git a/source/serializers.py b/source/serializers.py index d1642ab..309792b 100644 --- a/source/serializers.py +++ b/source/serializers.py @@ -12,8 +12,7 @@ class SourceSerializer(serializers.ModelSerializer): help_text=( "Type of the source. Available options:\n" "- 'api': Data source accessible through a public API.\n" - "- 'scraper': Data source obtained via web scraping.\n" - "- 'external_tool': Data provided by an external tool." + "- 'scraper': Data source obtained via web scraping." ), ) metrics = serializers.PrimaryKeyRelatedField( diff --git a/users/tests.py b/users/tests/__init__.py similarity index 100% rename from users/tests.py rename to users/tests/__init__.py diff --git a/users/tests/test_api_key.py b/users/tests/test_api_key.py new file mode 100644 index 0000000..563a481 --- /dev/null +++ b/users/tests/test_api_key.py @@ -0,0 +1,41 @@ +import pytest +from rest_framework import status +from rest_framework.test import APIClient + + +@pytest.mark.django_db +def test_generate_api_key_success(): + client = APIClient() + + # Registre + client.post( + "/api/users/register/", + data={ + "username": "apikeyuser", + "email": "apikeyuser@example.com", + "password": "securepassword123", + }, + format="json", + ) + + # Login + login_response = client.post( + "/api/users/token/", + data={ + "username": "apikeyuser", + "password": "securepassword123", + }, + format="json", + ) + + assert login_response.status_code == status.HTTP_200_OK + access_token = login_response.json()["access"] + + client.credentials(HTTP_AUTHORIZATION=f"Bearer {access_token}") + + # Crida a l’endpoint d’API Key + response = client.post("/api/users/token/api/", format="json") + + assert response.status_code == status.HTTP_200_OK # abans era 201 + assert "token" in response.json() + assert len(response.json()["token"]) > 10 diff --git a/users/tests/test_unauthorized.py b/users/tests/test_unauthorized.py new file mode 100644 index 0000000..2b2448f --- /dev/null +++ b/users/tests/test_unauthorized.py @@ -0,0 +1,10 @@ +import pytest +from rest_framework import status +from rest_framework.test import APIClient + + +@pytest.mark.django_db +def test_protected_endpoint_requires_authentication(): + client = APIClient() + response = client.get("/api/apps/") + assert response.status_code == status.HTTP_401_UNAUTHORIZED diff --git a/users/tests/test_user_login.py b/users/tests/test_user_login.py new file mode 100644 index 0000000..ec1250f --- /dev/null +++ b/users/tests/test_user_login.py @@ -0,0 +1,27 @@ +import pytest +from rest_framework import status +from rest_framework.test import APIClient + + +@pytest.mark.django_db +def test_login_user_success(): + client = APIClient() + + payload = { + "username": "newuser", + "email": "newuser@example.com", + "password": "securepassword123", + } + + client.post("/api/users/register/", data=payload, format="json") + + payload = { + "username": "newuser", + "password": "securepassword123", # El que uses al fixture + } + + response = client.post("/api/users/token/", data=payload, format="json") + + assert response.status_code == status.HTTP_200_OK + data = response.json() + assert "access" in data and "refresh" in data diff --git a/users/tests/test_user_register.py b/users/tests/test_user_register.py new file mode 100644 index 0000000..b1e85bb --- /dev/null +++ b/users/tests/test_user_register.py @@ -0,0 +1,20 @@ +import pytest +from rest_framework import status +from rest_framework.test import APIClient + + +@pytest.mark.django_db +def test_register_user_success(): + client = APIClient() + + payload = { + "username": "newuser", + "email": "newuser@example.com", + "password": "securepassword123", + } + + response = client.post("/api/users/register/", data=payload, format="json") + + assert response.status_code == status.HTTP_201_CREATED + data = response.json() + assert "access" in data and "refresh" in data diff --git a/users/tests/test_user_roles.py b/users/tests/test_user_roles.py new file mode 100644 index 0000000..2401696 --- /dev/null +++ b/users/tests/test_user_roles.py @@ -0,0 +1,44 @@ +import pytest +from rest_framework import status +from rest_framework.test import APIClient + + +@pytest.mark.django_db +class TestUserRoleDifferentiation: + def test_roles_are_differentiated(self, dummy_user, dummy_superuser): + assert not dummy_user.is_superuser + assert dummy_superuser.is_superuser + + def test_normal_user_has_limited_permissions(self, dummy_user): + client = APIClient() + client.force_authenticate(user=dummy_user) + + response = client.get("/api/apps/") + assert response.status_code == status.HTTP_200_OK + + payload = { + "code": "test_metric", + "name": "Test Metric", + "value_type": "integer", + "description": "A test metric.", + "is_derived": False, + } + response = client.post("/api/metrics/", data=payload, format="json") + assert response.status_code == status.HTTP_403_FORBIDDEN + + def test_superuser_has_extended_permissions(self, dummy_superuser): + client = APIClient() + client.force_authenticate(user=dummy_superuser) + + response = client.get("/api/apps/") + assert response.status_code == status.HTTP_200_OK + + payload = { + "code": "test_metric", + "name": "Test Metric", + "value_type": "integer", + "description": "A test metric.", + "is_derived": False, + } + response = client.post("/api/metrics/", data=payload, format="json") + assert response.status_code == status.HTTP_201_CREATED diff --git a/users/urls.py b/users/urls.py index 278e27f..257bbac 100644 --- a/users/urls.py +++ b/users/urls.py @@ -4,10 +4,11 @@ TokenRefreshView, ) -from .views import RegisterView +from .views import GenerateApiTokenView, RegisterView urlpatterns = [ path("register/", RegisterView.as_view(), name="user-register"), path("token/", TokenObtainPairView.as_view(), name="token_obtain_pair"), path("token/refresh/", TokenRefreshView.as_view(), name="token_refresh"), + path("token/api/", GenerateApiTokenView.as_view(), name="token_api"), ] diff --git a/users/views.py b/users/views.py index b644cf8..1f26947 100644 --- a/users/views.py +++ b/users/views.py @@ -1,6 +1,7 @@ from drf_spectacular.utils import extend_schema from rest_framework import status -from rest_framework.permissions import AllowAny +from rest_framework.authtoken.models import Token +from rest_framework.permissions import AllowAny, IsAuthenticated from rest_framework.response import Response from rest_framework.views import APIView @@ -37,3 +38,26 @@ def post(self, request): tokens = self.service.register_user(serializer.validated_data) return Response(tokens, status=status.HTTP_201_CREATED) return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST) + + +class GenerateApiTokenView(APIView): + permission_classes = [IsAuthenticated] + + @extend_schema( + summary="Obtenir l'API Token", + description=( + "Retorna el token d’autenticació per accés programàtic (API Token).\n\n" + "Aquest token és persistent i no expira, a diferència dels tokens JWT." + ), + responses={ + 200: { + "type": "object", + "properties": { + "token": {"type": "string"}, + }, + }, + }, + ) + def post(self, request): + token, _ = Token.objects.get_or_create(user=request.user) + return Response({"token": token.key}, status=status.HTTP_200_OK) From 763366f2b8af24ed4987841dbdaa0a14188b6da3 Mon Sep 17 00:00:00 2001 From: Anyer Date: Fri, 20 Jun 2025 00:02:22 +0200 Subject: [PATCH 4/6] Canvi noms funcions --- metric/repositories.py | 19 -------------- metric/services.py | 25 +++---------------- metric/tests/test_derived_metric_bug_rate.py | 2 +- .../test_derived_metric_positive_rate.py | 2 +- .../test_derived_metric_update_changed.py | 2 +- polling/repositories.py | 9 ------- polling/services/polling_schedule_service.py | 6 ----- polling/urls.py | 4 +-- polling/views.py | 8 +++--- source/services.py | 6 ----- 10 files changed, 12 insertions(+), 71 deletions(-) diff --git a/metric/repositories.py b/metric/repositories.py index 747e15f..f2b4967 100644 --- a/metric/repositories.py +++ b/metric/repositories.py @@ -27,16 +27,6 @@ def update(self, instance, data): def delete(self, instance): instance.delete() - def add_sources(self, instance, sources_ids): - instance.sources.add(*sources_ids) - instance.save() - return instance - - def remove_sources(self, instance, sources_ids): - instance.sources.remove(*sources_ids) - instance.save() - return instance - def get_by_code(self, code): return Metric.objects.get(code=code) @@ -65,15 +55,6 @@ def get_by_id(self, pk): def create(self, data): return MetricValue.objects.create(**data) - def update(self, instance, data): - for attr, value in data.items(): - setattr(instance, attr, value) - instance.save() - return instance - - def delete(self, instance): - instance.delete() - def get_by_app_and_metric(self, app_id, metric_id): return ( MetricValue.objects.filter(app_id=app_id, metric_id=metric_id) diff --git a/metric/services.py b/metric/services.py index 05dc1af..7121fe2 100644 --- a/metric/services.py +++ b/metric/services.py @@ -33,12 +33,6 @@ def update_metric(self, instance, validated_data): def delete_metric(self, instance): return self.repo.delete(instance) - def add_sources(self, instance, sources_ids): - return self.repo.add_sources(instance, sources_ids) - - def remove_sources(self, instance, sources_ids): - return self.repo.remove_sources(instance, sources_ids) - def get_metric_by_code(self, code): try: return self.repo.get_by_code(code) @@ -55,21 +49,9 @@ def __init__(self): def list_metric_values(self, filters=None): return self.repo.get_all(filters) - def get_metric_value(self, pk): - try: - return self.repo.get_by_id(pk) - except ObjectDoesNotExist: - raise NotFound(f"The metric_value with ID '{pk}' is not registered.") - def create_metric_value(self, validated_data): return self.repo.create(validated_data) - def update_metric_value(self, instance, validated_data): - return self.repo.update(instance, validated_data) - - def delete_metric_value(self, instance): - return self.repo.delete(instance) - def get_metric_dashboard(self, app_id: str, metric_id: str, authorized_apps: List[int]) -> dict: if app_id not in authorized_apps: raise NotFound(f"The app with ID '{app_id}' is not registered or not authorized.") @@ -85,7 +67,7 @@ def get_metric_dashboard(self, app_id: str, metric_id: str, authorized_apps: Lis for metric_value in metric_values ] else: - values = self.get_derived_metric_values_by_app_and_metric(app_id, metric.code) + values = self._get_derived_metric_values_by_app_and_metric(app_id, metric.code) for value in values: if "source" not in value: value["source"] = "Internal" @@ -108,10 +90,10 @@ def get_metric_dashboard(self, app_id: str, metric_id: str, authorized_apps: Lis "sources": list(sources_data.values()), } - def get_derived_metric_values_by_app_and_metric(self, app_id, metric_code): - reviews = self.review_service.list_reviews(filters={"app_id": app_id}) + def _get_derived_metric_values_by_app_and_metric(self, app_id, metric_code): derived_values = [] if metric_code == MetricCode.BUG_RATE: + reviews = self.review_service.list_reviews(filters={"app_id": app_id}) total_reviews = {} bug_reviews = {} @@ -132,6 +114,7 @@ def get_derived_metric_values_by_app_and_metric(self, app_id, metric_code): derived_values.append({"retrieved_at": date, "value": frequency, "source": source}) elif metric_code == MetricCode.POSITIVE_RATE: + reviews = self.review_service.list_reviews(filters={"app_id": app_id}) polarity_counts = {} positive = ReviewPolarity.POSITIVE negative = ReviewPolarity.NEGATIVE diff --git a/metric/tests/test_derived_metric_bug_rate.py b/metric/tests/test_derived_metric_bug_rate.py index 20d3177..09417e2 100644 --- a/metric/tests/test_derived_metric_bug_rate.py +++ b/metric/tests/test_derived_metric_bug_rate.py @@ -34,7 +34,7 @@ def test_update_changed_multiple_values_same_date(dummy_app): ] service = MetricValueService() - output = service.get_derived_metric_values_by_app_and_metric( + output = service._get_derived_metric_values_by_app_and_metric( app_id=dummy_app.id, metric_code=MetricCode.BUG_RATE, ) diff --git a/metric/tests/test_derived_metric_positive_rate.py b/metric/tests/test_derived_metric_positive_rate.py index fefd6fa..1ef7826 100644 --- a/metric/tests/test_derived_metric_positive_rate.py +++ b/metric/tests/test_derived_metric_positive_rate.py @@ -34,7 +34,7 @@ def test_update_changed_multiple_values_same_date(dummy_app): ] service = MetricValueService() - output = service.get_derived_metric_values_by_app_and_metric( + output = service._get_derived_metric_values_by_app_and_metric( app_id=dummy_app.id, metric_code=MetricCode.POSITIVE_RATE, ) diff --git a/metric/tests/test_derived_metric_update_changed.py b/metric/tests/test_derived_metric_update_changed.py index 5a04f1f..b5dfddf 100644 --- a/metric/tests/test_derived_metric_update_changed.py +++ b/metric/tests/test_derived_metric_update_changed.py @@ -73,7 +73,7 @@ def test_update_changed_multiple_values_same_date(dummy_app): # Calculem la mètrica derivada UPDATE_CHANGED service = MetricValueService() - output = service.get_derived_metric_values_by_app_and_metric( + output = service._get_derived_metric_values_by_app_and_metric( app_id=dummy_app.id, metric_code=MetricCode.UPDATE_CHANGED, ) diff --git a/polling/repositories.py b/polling/repositories.py index 37bd042..7039f79 100644 --- a/polling/repositories.py +++ b/polling/repositories.py @@ -6,10 +6,6 @@ class PollingRepository: - def get_all(self, poll_type): - queryset = PollingSchedule.objects.filter(poll_type=poll_type) - return queryset - def get_by_app_id(self, app_id, poll_type): return PollingSchedule.objects.get(app_id=app_id, poll_type=poll_type) @@ -44,11 +40,6 @@ def create_periodic_task(self, schedule, app_id, poll_type, task): ) return task - def delete(self, instance): - if instance.periodic_task: - instance.periodic_task.delete() - instance.delete() - def exists(self, app_id, poll_type): return PollingSchedule.objects.filter(app_id=app_id, poll_type=poll_type).exists() diff --git a/polling/services/polling_schedule_service.py b/polling/services/polling_schedule_service.py index c561c8c..7f390e9 100644 --- a/polling/services/polling_schedule_service.py +++ b/polling/services/polling_schedule_service.py @@ -11,9 +11,6 @@ class PollingScheduleService: def __init__(self): self.repo = PollingRepository() - def list_polling_schedules(self, poll_type=None): - return self.repo.get_all(poll_type) - def get_polling_schedule(self, app_id, poll_type, authorized_app_ids): if app_id not in authorized_app_ids: raise NotFound(f"The app with ID '{app_id}' is not registered.") @@ -36,9 +33,6 @@ def create_polling_schedule(self, app_id, interval_hours=None, poll_type=None): return schedule - def delete_schedule(self, instance): - return self.repo.delete(instance) - def update_polling_schedule(self, polling_schedule, validated_data): if validated_data.get("interval_hours") is not None: interval_schedule = self.repo.get_or_create_interval_schedule( diff --git a/polling/urls.py b/polling/urls.py index 5cc5e44..74039dc 100644 --- a/polling/urls.py +++ b/polling/urls.py @@ -5,9 +5,7 @@ urlpatterns = [ path( "apps//polling/", - PollingViewSet.as_view( - {"get": "retrieve_polling", "post": "activate_polling", "delete": "deactivate_polling"} - ), + PollingViewSet.as_view({"get": "retrieve", "post": "activate", "delete": "deactivate"}), name="manage-polling", ), path( diff --git a/polling/views.py b/polling/views.py index a43cffe..3488321 100644 --- a/polling/views.py +++ b/polling/views.py @@ -40,7 +40,7 @@ class PollingViewSet(ViewSet): tags=["Polling"], methods=["GET"], ) - def retrieve_polling(self, request, id: int): + def retrieve(self, request, id: int): user_authorized_app_ids = request.user.apps.values_list("id", flat=True) poll_type = request.query_params.get("poll_type") if poll_type not in ["metrics", "reviews"]: @@ -87,7 +87,7 @@ def retrieve_polling(self, request, id: int): tags=["Polling"], methods=["POST"], ) - def activate_polling(self, request, id: int): + def activate(self, request, id: int): try: user_authorized_app_ids = request.user.apps.values_list("id", flat=True) poll_type = request.query_params.get("poll_type") @@ -152,7 +152,7 @@ def activate_polling(self, request, id: int): tags=["Polling"], methods=["DELETE"], ) - def deactivate_polling(self, request, id: int): + def deactivate(self, request, id: int): try: user_authorized_app_ids = request.user.apps.values_list("id", flat=True) poll_type = request.query_params.get("poll_type") @@ -212,7 +212,7 @@ def manual_review_polling(self, request, id: int): user_authorized_app_ids = request.user.apps.values_list("id", flat=True) date_from = request.query_params.get("date_from", None) date_to = request.query_params.get("date_to", None) - self.service.manual_poll_reviews( + self.service.manual_p_reviews( app_id=id, date_from=date_from, date_to=date_to, diff --git a/source/services.py b/source/services.py index db2004f..0f83f45 100644 --- a/source/services.py +++ b/source/services.py @@ -28,12 +28,6 @@ def update_source(self, instance, validated_data): def delete_source(self, instance): return self.repo.delete(instance) - def add_metrics(self, instance, metrics_ids): - return self.repo.add_metrics(instance, metrics_ids) - - def remove_metrics(self, instance, metrics_ids): - return self.repo.remove_metrics(instance, metrics_ids) - def get_source_data(self, code: str) -> dict: try: source = self.repo.get_by_code(code) From 584f653bb7e6241da6285440c704d79fcf24f2ca Mon Sep 17 00:00:00 2001 From: Anyer Date: Fri, 20 Jun 2025 13:04:05 +0200 Subject: [PATCH 5/6] CSV --- metric/services.py | 26 +++++++++++++++++++++++++ metric/tests/test_get_metrics_csv.py | 29 ++++++++++++++++++++++++++++ metric/urls.py | 5 +++++ metric/views.py | 23 ++++++++++++++++++++++ polling/views.py | 2 +- 5 files changed, 84 insertions(+), 1 deletion(-) create mode 100644 metric/tests/test_get_metrics_csv.py diff --git a/metric/services.py b/metric/services.py index 7121fe2..05ee57e 100644 --- a/metric/services.py +++ b/metric/services.py @@ -1,4 +1,6 @@ +import csv from collections import defaultdict +from io import StringIO from typing import List from django.core.exceptions import ObjectDoesNotExist @@ -177,3 +179,27 @@ def _get_derived_metric_values_by_app_and_metric(self, app_id, metric_code): previous_update_date = current_update_date return derived_values + + def get_metrics_csv(self, app_id, authorized_apps): + if int(app_id) not in authorized_apps: + raise NotFound(f"The app with ID '{app_id}' is not registered or not authorized.") + + output = StringIO() + writer = csv.writer(output) + writer.writerow(["metric_code", "source", "retrieved_at", "value"]) + + all_metrics = self.metric_service.list_metrics() + for metric in all_metrics: + dashboard = self.get_metric_dashboard(app_id, metric.id, authorized_apps) + for source in dashboard["sources"]: + for history in source["history"]: + writer.writerow( + [ + dashboard["metric"]["code"], + source["source"], + history["date"], + history["value"], + ] + ) + + return output.getvalue() diff --git a/metric/tests/test_get_metrics_csv.py b/metric/tests/test_get_metrics_csv.py new file mode 100644 index 0000000..44b77bc --- /dev/null +++ b/metric/tests/test_get_metrics_csv.py @@ -0,0 +1,29 @@ +import pytest +from rest_framework import status +from rest_framework.test import APIClient + + +@pytest.mark.django_db +class TestExportMetricsCSV: + def test_export_metrics_success(self, dummy_app, dummy_metric_value): + client = APIClient() + client.force_authenticate(user=dummy_app.user) + + response = client.get(f"/api/apps/{dummy_app.id}/metrics/csv/") + + assert response.status_code == status.HTTP_200_OK + assert response["Content-Type"] == "text/csv" + assert "metric_code" in response.content.decode() + + def test_export_metrics_unauthenticated(self, dummy_app): + client = APIClient() + + response = client.get(f"/api/apps/{dummy_app.id}/metrics/csv/") + assert response.status_code == status.HTTP_401_UNAUTHORIZED + + def test_export_metrics_app_not_found(self, dummy_app): + client = APIClient() + client.force_authenticate(user=dummy_app.user) + + response = client.get("/api/apps/999999/metrics/csv/") + assert response.status_code == status.HTTP_404_NOT_FOUND diff --git a/metric/urls.py b/metric/urls.py index 6ad0917..7f3091d 100644 --- a/metric/urls.py +++ b/metric/urls.py @@ -14,4 +14,9 @@ MetricValueViewSet.as_view({"get": "get_app_metric"}), name="get-app-metric", ), + path( + "apps//metrics/csv/", + MetricValueViewSet.as_view({"get": "get_metrics_csv"}), + name="get-metrics-csv", + ), ] diff --git a/metric/views.py b/metric/views.py index 3476fb2..df7600c 100644 --- a/metric/views.py +++ b/metric/views.py @@ -1,3 +1,4 @@ +from django.http import HttpResponse from drf_spectacular.utils import OpenApiParameter, OpenApiResponse, extend_schema from rest_framework import status, viewsets from rest_framework.response import Response @@ -213,3 +214,25 @@ def get_app_metric(self, request, id=None, metric_id=None): ) serializer = MetricResponseSerializer(response_data) return Response(serializer.data) + + @extend_schema( + summary="Export all metric values as CSV", + description=( + "Exports all metric values of an app in CSV format.\n\n" + "The response will contain a CSV file with the metric code," + " source, date of retrieval and value." + ), + parameters=[ + OpenApiParameter(name="id", required=True, type=int, location="path"), + ], + responses={200: {"type": "string", "format": "binary"}}, + tags=["Metrics"], + methods=["get"], + ) + def get_metrics_csv(self, request, id=None): + authorized_apps = request.user.apps.values_list("id", flat=True) + csv_data = self.service.get_metrics_csv(id, authorized_apps) + + response = HttpResponse(csv_data, content_type="text/csv") + response["Content-Disposition"] = "attachment; filename=metrics_export.csv" + return response diff --git a/polling/views.py b/polling/views.py index 3488321..d6d47c4 100644 --- a/polling/views.py +++ b/polling/views.py @@ -212,7 +212,7 @@ def manual_review_polling(self, request, id: int): user_authorized_app_ids = request.user.apps.values_list("id", flat=True) date_from = request.query_params.get("date_from", None) date_to = request.query_params.get("date_to", None) - self.service.manual_p_reviews( + self.service.manual_poll_reviews( app_id=id, date_from=date_from, date_to=date_to, From 7e7bb09508542a329a1f86da77ba7c4d29ba4b90 Mon Sep 17 00:00:00 2001 From: Anyer Date: Wed, 25 Jun 2025 18:46:49 +0200 Subject: [PATCH 6/6] script per a GESSI --- app/management/commands/create_apps.py | 147 +++++++++++++++++++++++++ apps_updated_filtered.xlsx | Bin 0 -> 84151 bytes metric/views.py | 4 +- requirements.txt | Bin 2592 -> 2750 bytes review/views.py | 18 +-- source/adapters/base.py | 4 +- source/adapters/google_play_scraper.py | 6 +- source/adapters/itunes.py | 3 - source/adapters/news.py | 3 - source/adapters/reddit.py | 3 - 10 files changed, 154 insertions(+), 34 deletions(-) create mode 100644 app/management/commands/create_apps.py create mode 100644 apps_updated_filtered.xlsx diff --git a/app/management/commands/create_apps.py b/app/management/commands/create_apps.py new file mode 100644 index 0000000..ba9a98e --- /dev/null +++ b/app/management/commands/create_apps.py @@ -0,0 +1,147 @@ +import time + +import pandas as pd +from django.contrib.auth.models import User +from django.core.management.base import BaseCommand + +from app.services import AppService +from metric.models import Metric +from polling.services.polling_schedule_service import PollingScheduleService +from source.models import Source + + +class Command(BaseCommand): + help = "Crea les Sources i Metric bàsiques" + + def handle(self, *args, **kwargs): + average_rating = Metric.objects.create( + code="average_rating", + name="Average Rating", + value_type="float", + description="Average user rating of the app.", + ) + total_reviews = Metric.objects.create( + code="total_reviews", + name="Total Reviews", + value_type="integer", + description="Total number of user reviews.", + ) + daily_news_blog_mentions = Metric.objects.create( + code="daily_news_blog_mentions", + name="Daily News Blog Mentions", + value_type="integer", + description="Number of daily mentions in news blogs.", + ) + daily_social_network_mentions = Metric.objects.create( + code="daily_social_network_mentions", + name="Daily Social Network Mentions", + value_type="integer", + description="Number of daily mentions on social networks.", + ) + total_downloads = Metric.objects.create( + code="total_downloads", + name="Total Downloads", + value_type="integer", + description="Total number of app downloads.", + ) + last_update_date = Metric.objects.create( + code="last_update_date", + name="Last Update Date", + value_type="date", + description="Date of the app’s last update.", + ) + Metric.objects.create( + code="bug_rate", + name="Bug Rate", + value_type="float", + description="Proportion of reviews that mention errors" + " or bugs relative to the total number of reviews.", + is_derived=True, + ) + Metric.objects.create( + code="positive_rate", + name="Positive Rate", + value_type="float", + description="Proportion of reviews with positive sentiment" + " relative to the total number of reviews.", + is_derived=True, + ) + Metric.objects.create( + code="update_changed", + name="Update Changed", + value_type="integer", + description="Indicates whether the last update date has changed" + " compared to the previous day.\nValue is 1 if it changed, 0 otherwise.", + id_derived=True, + ) + Source.objects.create( + code="itunes", + name="App Store", + type="api", + url="https://itunes.apple.com", + ).metrics.set([average_rating, total_reviews]) + Source.objects.create( + code="google_play", + name="Google Play Scraper", + type="scraper", + url="https://play.google.com", + ).metrics.set([average_rating, total_reviews, total_downloads, last_update_date]) + Source.objects.create( + code="news", + name="News API", + type="api", + url="https://newsapi.org/v2", + ).metrics.set([daily_news_blog_mentions]) + Source.objects.create( + code="reddit", + name="Reddit API", + type="api", + ).metrics.set([daily_social_network_mentions]) + + user, created = User.objects.get_or_create( + username="Anyer", + defaults={ + "email": "anyer@example.com", + "is_superuser": True, + "is_staff": True, + }, + ) + if created: + user.set_password("Anyer123") + user.save() + + service = AppService() + poll_service = PollingScheduleService() + + df_page1 = pd.read_excel("apps_updated_filtered.xlsx", sheet_name="Sheet1") + created_count = 0 + + for index, row in df_page1.iterrows(): + try: + validated_data = { + "name": row["name"], + "appstore_id": ( + str(int(row["apple_store_id"])) + if not pd.isna(row["apple_store_id"]) + else None + ), + "playstore_id": str(row["google_play_id"]), + } + app = service.create_app(validated_data, user) + polling_schedule_metrics = poll_service.get_polling_schedule( + app.id, "metrics", [app.id] + ) + polling_schedule_reviews = poll_service.get_polling_schedule( + app.id, "reviews", [app.id] + ) + poll_service.activate_polling_schedule(polling_schedule_metrics) + poll_service.activate_polling_schedule(polling_schedule_reviews) + + created_count = created_count + 1 + time.sleep(0.1) + except Exception as e: + print(f"❌ Error en la fila {index}: {e}") + + self.stdout.write( + self.style.SUCCESS(f"✔ {created_count} apps creadas y polling activado correctamente.") + ) diff --git a/apps_updated_filtered.xlsx b/apps_updated_filtered.xlsx new file mode 100644 index 0000000000000000000000000000000000000000..799b0c89382e3a74be0b1fd3aec530161f496e88 GIT binary patch literal 84151 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 pU*>E| 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 diff --git a/review/views.py b/review/views.py index 517fac4..cca2ecd 100644 --- a/review/views.py +++ b/review/views.py @@ -1,6 +1,5 @@ -from drf_spectacular.utils import OpenApiParameter, OpenApiResponse, extend_schema -from rest_framework import status, viewsets -from rest_framework.decorators import action +from drf_spectacular.utils import OpenApiParameter, extend_schema +from rest_framework import viewsets from rest_framework.response import Response from review.services import ReviewService @@ -37,16 +36,3 @@ def list(self, request): reviews = self.service.list_reviews(filters) serializer = ReviewSerializer(reviews, many=True) return Response(serializer.data) - - # ELIMINAR - @extend_schema( - summary="Delete all reviews", - description="Deletes all reviews stored in the system. This action is irreversible.", - methods=["delete"], - responses={204: OpenApiResponse(description="All reviews have been deleted")}, - tags=["Reviews"], - ) - @action(detail=False, methods=["delete"], url_path="delete-all") - def delete_all(self, request): - self.service.delete_all_reviews() - return Response(status=status.HTTP_204_NO_CONTENT) diff --git a/source/adapters/base.py b/source/adapters/base.py index 02cfa2b..6cf2453 100644 --- a/source/adapters/base.py +++ b/source/adapters/base.py @@ -17,8 +17,8 @@ def __init__(self): if not hasattr(self, "code") or not self.code: raise NotImplementedError("Adapter must define 'code' and load source config.") - @abstractmethod - def supports_metric(self, metric: str) -> bool: ... + def supports_metric(self, metric: str) -> bool: + return metric in self.supported_metrics @abstractmethod def fetch(self, app, metrics: list[str]) -> dict[str, str]: ... diff --git a/source/adapters/google_play_scraper.py b/source/adapters/google_play_scraper.py index 165cdf1..80944f4 100644 --- a/source/adapters/google_play_scraper.py +++ b/source/adapters/google_play_scraper.py @@ -22,11 +22,7 @@ def __init__(self): self.url = source_data["url"] self.supported_metrics = source_data["supported_metrics"] - def supports_metric(self, metric: str): - return metric in self.supported_metrics - - @staticmethod - def lookup_app(package_name: str) -> dict | None: + def lookup_app(self, package_name: str) -> dict | None: try: result = gp_app(package_name, lang="en", country="uk") return result diff --git a/source/adapters/itunes.py b/source/adapters/itunes.py index 1844833..6622fec 100644 --- a/source/adapters/itunes.py +++ b/source/adapters/itunes.py @@ -17,9 +17,6 @@ def __init__(self): self.url = source_data["url"] self.supported_metrics = source_data["supported_metrics"] - def supports_metric(self, metric: str): - return metric in self.supported_metrics - def lookup_app(self, appstore_id: str): response = requests.get(f"{self.url}/lookup?id={appstore_id}") if not response.ok: diff --git a/source/adapters/news.py b/source/adapters/news.py index a0a95e4..f011990 100644 --- a/source/adapters/news.py +++ b/source/adapters/news.py @@ -21,9 +21,6 @@ def __init__(self, api_key=None): self.supported_metrics = source_data["supported_metrics"] self.api_key = api_key or os.environ.get("NEWSAPI_KEY") - def supports_metric(self, metric: str) -> bool: - return metric in self.supported_metrics - def fetch(self, app, metrics: list[str]): if not app: return {} diff --git a/source/adapters/reddit.py b/source/adapters/reddit.py index 258f242..535b04f 100644 --- a/source/adapters/reddit.py +++ b/source/adapters/reddit.py @@ -32,9 +32,6 @@ def __init__(self): except praw.exceptions.MissingRequiredAttributeException as e: raise APIException(f"Reddit adapter configuration error: {str(e)}") - def supports_metric(self, metric: str) -> bool: - return metric in self.supported_metrics - def fetch(self, app, metrics: list[str]): if not app: return {}