diff --git a/app/migrations/0004_app_icon_url_app_size_in_bytes.py b/app/migrations/0004_app_icon_url_app_size_in_bytes.py new file mode 100644 index 0000000..8dae77a --- /dev/null +++ b/app/migrations/0004_app_icon_url_app_size_in_bytes.py @@ -0,0 +1,23 @@ +# Generated by Django 5.1.7 on 2025-05-15 16:39 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ("app", "0003_alter_app_developer"), + ] + + operations = [ + migrations.AddField( + model_name="app", + name="icon_url", + field=models.URLField(null=True), + ), + migrations.AddField( + model_name="app", + name="size_in_bytes", + field=models.BigIntegerField(null=True), + ), + ] diff --git a/app/models.py b/app/models.py index bf7c3bf..8c7d45b 100644 --- a/app/models.py +++ b/app/models.py @@ -13,6 +13,8 @@ class App(models.Model): pegi_rating = models.CharField(max_length=10, null=True) # PEGI rating release_date = models.DateField(null=True) min_ios_version = models.CharField(max_length=10, null=True) # Minimum iOS version + icon_url = models.URLField(null=True) + size_in_bytes = models.BigIntegerField(null=True) def __str__(self): return self.name diff --git a/app/serializers.py b/app/serializers.py index c8834aa..f1d5193 100644 --- a/app/serializers.py +++ b/app/serializers.py @@ -1,9 +1,13 @@ from rest_framework import serializers +from metric.serializers import MetricDashboardSerializer + from .models import App class AppSerializer(serializers.ModelSerializer): + sizeMB = serializers.SerializerMethodField() + class Meta: model = App fields = [ @@ -19,10 +23,42 @@ class Meta: "pegi_rating", "release_date", "min_ios_version", + "icon_url", + "sizeMB", ] + def get_sizeMB(self, obj): + if obj.size_in_bytes is None: + return None + return round(obj.size_in_bytes / (1024 * 1024), 2) + class AppCreateSerializer(serializers.ModelSerializer): class Meta: model = App fields = ["code", "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 11b684a..ad6b132 100644 --- a/app/services.py +++ b/app/services.py @@ -54,7 +54,15 @@ def _fetch_appstore_data(self, validated_data): validated_data["developer"] = result.get("artistName") validated_data["pegi_rating"] = result.get("contentAdvisoryRating") validated_data["min_ios_version"] = result.get("minimumOsVersion") - + validated_data["icon_url"] = ( + result.get("artworkUrl512") or result.get("artworkUrl100") or result.get("artworkUrl60") + ) + file_size_str = result.get("fileSizeBytes") + if file_size_str: + try: + validated_data["size_in_bytes"] = int(file_size_str) + except ValueError: + print(f"Error al convertir fileSizeBytes: {file_size_str}") released_str = result.get("releaseDate") if released_str: try: @@ -77,6 +85,7 @@ def _fetch_playstore_data(self, validated_data): validated_data["pegi_rating"] = validated_data.get("pegi_rating") or result.get( "contentRating" ) + validated_data["icon_url"] = validated_data.get("icon_url") or result.get("icon") released_str = result.get("released") if released_str and not validated_data.get("release_date"): diff --git a/app/views.py b/app/views.py index 6ce18c7..456e722 100644 --- a/app/views.py +++ b/app/views.py @@ -1,13 +1,18 @@ from drf_spectacular.utils import OpenApiParameter, extend_schema from rest_framework import status, viewsets +from rest_framework.decorators import action from rest_framework.response import Response -from .serializers import AppCreateSerializer, AppSerializer +from metric.services import MetricService, MetricValueService + +from .serializers import AppCreateSerializer, AppSerializer, MetricResponseSerializer from .services import AppService class AppViewSet(viewsets.ViewSet): service = AppService() + metric_service = MetricService() + metric_value_service = MetricValueService() @extend_schema( responses=AppSerializer(many=True), @@ -44,7 +49,11 @@ def retrieve(self, request, pk=None): def create(self, request): print("REQUEST DATA:", request.data) serializer = AppCreateSerializer(data=request.data) - serializer.is_valid(raise_exception=True) + try: + serializer.is_valid(raise_exception=True) + except Exception as e: + print("❌ VALIDATION ERROR:", serializer.errors) + raise e # sigue dejando que DRF devuelva el 400 app = self.service.create_app(serializer.validated_data) return Response(AppSerializer(app).data, status=status.HTTP_201_CREATED) @@ -69,3 +78,40 @@ def destroy(self, request, pk=None): app = self.service.get_app(pk) self.service.delete_app(app) return Response(status=status.HTTP_204_NO_CONTENT) + + @extend_schema( + parameters=[ + OpenApiParameter(name="id", required=True, type=int, location="path"), + OpenApiParameter(name="metric_id", required=True, type=int, location="path"), + ], + responses=MetricResponseSerializer, + tags=["Apps"], + ) + @action(detail=True, methods=["get"], url_path="metrics/(?P[^/.]+)") + def get_app_metric(self, request, pk=None, metric_id=None): + metric = self.metric_service.get_metric(metric_id) + metric_values = self.metric_value_service.get_metric_values_by_app_and_metric( + app_id=pk, metric_id=metric_id + ) + + sources_data = {} + for value in metric_values: + source_name = value.source.name if value.source else "Internal" + if source_name not in sources_data: + sources_data[source_name] = {"source": source_name, "history": []} + sources_data[source_name]["history"].append( + {"date": value.retrieved_at.date(), "value": value.value} + ) + + response_data = { + "metric": { + "code": metric.code, + "name": metric.name, + "description": metric.description, + "value_type": metric.value_type, + }, + "sources": list(sources_data.values()), + } + + serializer = MetricResponseSerializer(response_data) + return Response(serializer.data) diff --git a/config/exceptions.py b/config/exceptions.py new file mode 100644 index 0000000..4398004 --- /dev/null +++ b/config/exceptions.py @@ -0,0 +1,15 @@ +from rest_framework.views import exception_handler + + +def custom_exception_handler(exc, context): + response = exception_handler(exc, context) + + if response is not None and isinstance(response.data, dict): + formatted_errors = [] + for field, errors in response.data.items(): + for error in errors: + formatted_errors.append(f"{field.capitalize()}: {error}") + + response.data = {"errors": formatted_errors} + + return response diff --git a/config/settings.py b/config/settings.py index 2a83d36..22c95bc 100644 --- a/config/settings.py +++ b/config/settings.py @@ -147,6 +147,7 @@ REST_FRAMEWORK = { "DEFAULT_SCHEMA_CLASS": "drf_spectacular.openapi.AutoSchema", + "EXCEPTION_HANDLER": "config.exceptions.custom_exception_handler", } CELERY_BROKER_URL = "redis://redis:6379/0" diff --git a/metric/migrations/0006_remove_metric_is_internal.py b/metric/migrations/0006_remove_metric_is_internal.py new file mode 100644 index 0000000..095927a --- /dev/null +++ b/metric/migrations/0006_remove_metric_is_internal.py @@ -0,0 +1,17 @@ +# Generated by Django 5.1.7 on 2025-05-15 16:44 + +from django.db import migrations + + +class Migration(migrations.Migration): + + dependencies = [ + ("metric", "0005_add_is_internal"), + ] + + operations = [ + migrations.RemoveField( + model_name="metric", + name="is_internal", + ), + ] diff --git a/metric/models.py b/metric/models.py index 2c6bb8d..5529f14 100644 --- a/metric/models.py +++ b/metric/models.py @@ -9,7 +9,6 @@ class Metric(models.Model): name = models.CharField(max_length=100) description = models.TextField() value_type = models.CharField(max_length=10, choices=MetricValueType.choices) - is_internal = models.BooleanField(default=False) def __str__(self): return self.name diff --git a/metric/repositories.py b/metric/repositories.py index 60e31c7..7f07d2a 100644 --- a/metric/repositories.py +++ b/metric/repositories.py @@ -2,62 +2,55 @@ class MetricRepository: - @staticmethod - def get_all(): + def get_all(self): return Metric.objects.all() - @staticmethod - def get_by_id(pk): + def get_by_id(self, pk): return Metric.objects.get(id=pk) - @staticmethod - def create(data): + def create(self, data): return Metric.objects.create(**data) - @staticmethod - def update(instance, data): + def update(self, instance, data): for attr, value in data.items(): setattr(instance, attr, value) instance.save() return instance - @staticmethod - def delete(instance): + def delete(self, instance): instance.delete() - @staticmethod - def add_sources(instance, sources_ids): + def add_sources(self, instance, sources_ids): instance.sources.add(*sources_ids) instance.save() return instance - @staticmethod - def remove_sources(instance, sources_ids): + def remove_sources(self, instance, sources_ids): instance.sources.remove(*sources_ids) instance.save() return instance class MetricValueRepository: - @staticmethod - def get_all(): + def get_all(self): return MetricValue.objects.all() - @staticmethod - def get_by_id(pk): + def get_by_id(self, pk): return MetricValue.objects.get(id=pk) - @staticmethod - def create(data): + def create(self, data): return MetricValue.objects.create(**data) - @staticmethod - def update(instance, data): + def update(self, instance, data): for attr, value in data.items(): setattr(instance, attr, value) instance.save() return instance - @staticmethod - def delete(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).select_related( + "source" + ) diff --git a/metric/serializers.py b/metric/serializers.py index 5e21823..25a7269 100644 --- a/metric/serializers.py +++ b/metric/serializers.py @@ -32,3 +32,11 @@ class LinkMetricsSerializer(serializers.Serializer): metrics = serializers.ListField( child=serializers.IntegerField(), help_text="List of metric IDs to link to the source." ) + + +class MetricDashboardSerializer(serializers.ModelSerializer): + value_type = serializers.ChoiceField(choices=MetricValueType.choices) + + class Meta: + model = Metric + fields = ["code", "name", "description", "value_type"] diff --git a/metric/services.py b/metric/services.py index 80616df..ea54f89 100644 --- a/metric/services.py +++ b/metric/services.py @@ -54,3 +54,6 @@ 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) diff --git a/polling/services.py b/polling/services.py index 51e320c..94c6c7c 100644 --- a/polling/services.py +++ b/polling/services.py @@ -32,12 +32,15 @@ def _analyze_reviews(self, reviews, service_url: str, label: str = "RE-Miner"): for idx, r in enumerate(reviews) ] } + print(f"📤 Sending {len(payload['reviews'])} reviews to {service_url}") response = requests.post(service_url, json=payload, timeout=10) + print("📥 Received response from", label) response.raise_for_status() return response.json() except Exception as e: print(f"[{label} ❌] Error analitzant ressenyes: {e}") - return [] + # ⚠️ Devolvemos estructura esperada vacía + return {"reviews": []} def _analyze_review_polarity(self, reviews): return self._analyze_reviews( @@ -76,12 +79,20 @@ def create_polling_schedule(self, app_id, interval_hours=None, poll_type=None): def delete_schedule(self, instance): return self.repo.delete(instance) + def log(self, msg: str): + print(f"[{datetime.now().isoformat()}] {msg}") + def poll_reviews(self, app_id, date_from=None, date_to=None): + self.log("🔌 Loading adapters...") adapters = self.source_service.load_sources() + self.log("✅ Adapters loaded.") results = [] for adapter in adapters: + self.log(f"🔄 Fetching reviews from {adapter.name}...") reviews = adapter.fetch_reviews(app_id=app_id, date_from=date_from, date_to=date_to) + self.log(f"✅ {len(reviews)} reviews fetched.") + if not reviews: results.append( { @@ -93,33 +104,61 @@ def poll_reviews(self, app_id, date_from=None, date_to=None): ) continue - # 🧪 Preparem les dades per RE-Miner (agafa 'content' com a 'text') + # Asigna reviewId si falta for idx, r in enumerate(reviews): - r["reviewId"] = r.get("reviewId", str(idx)) # assegura que sempre hi ha un id + r["reviewId"] = r.get("reviewId", str(idx)) - prepared_reviews = [{"reviewId": r["reviewId"], "text": r["content"]} for r in reviews] + # ❌ Comprobación: eliminar las reviews ya guardadas para esa app y adapter + existing_ids = ReviewRepository().get_existing_review_ids(app_id, adapter.id) + new_reviews = [r for r in reviews if r["reviewId"] not in existing_ids] + self.log( + f"🧹 {len(reviews) - len(new_reviews)} reviews ya existen.{len(new_reviews)} nuevas." + ) + + if not new_reviews: + results.append( + { + "adapter": adapter.name, + "fetched": len(reviews), + "saved": 0, + "reviews": [], + } + ) + continue + + # Preparamos para análisis + prepared_reviews = [ + {"reviewId": r["reviewId"], "text": r["content"]} for r in new_reviews + ] + + self.log("🔍 Analyzing polarity...") polarity_result = self._analyze_review_polarity(prepared_reviews) + self.log("✅ Polarity analyzed.") + + self.log("🔍 Analyzing type...") type_result = self._analyze_review_type(prepared_reviews) + self.log("✅ Type analyzed.") - # 🔁 Mapegem la polaritat a cada review original polarity_by_id = { r["reviewId"]: r["polarity"] for r in polarity_result.get("reviews", []) } type_by_id = {r["reviewId"]: r["type"] for r in type_result.get("reviews", [])} - for review in reviews: - review["polarity"] = polarity_by_id.get(review["reviewId"]) - review["type"] = type_by_id.get(review["reviewId"]) + for review in new_reviews: + review["polarity"] = polarity_by_id.get(review["reviewId"], "") + review["type"] = type_by_id.get(review["reviewId"], "") - saved_count = ReviewRepository().save_reviews(app_id, reviews) + self.log("💾 Saving reviews...") + saved_count = ReviewRepository().save_reviews(app_id, adapter.id, new_reviews) + self.log(f"✅ {saved_count} reviews saved.") results.append( { "adapter": adapter.name, "fetched": len(reviews), "saved": saved_count, - "reviews": reviews, + "reviews": new_reviews, } ) @@ -132,8 +171,7 @@ def poll_metrics(self, app_id): metrics = list(Metric.objects.prefetch_related("sources").all()) # 🔀 Separa mètriques - external_metrics = [m for m in metrics if not m.is_internal] - internal_metrics = [m for m in metrics if m.is_internal] + metrics = [m for m in metrics] # ⚡ Caches metric_cache = {m.code: m for m in metrics} @@ -144,9 +182,7 @@ def poll_metrics(self, app_id): print("adapter actual: ", adapter.code) source_code = adapter.code available_metrics = [ - m.code - for m in external_metrics - if source_code in m.sources.values_list("code", flat=True) + m.code for m in metrics if source_code in m.sources.values_list("code", flat=True) ] if not available_metrics: continue @@ -182,8 +218,4 @@ def poll_metrics(self, app_id): ) print(f" ✅ Guardat {metric_code}: {value}") - # 🔁 INTERNAL: opcional (implementa si tens estratègies internes) - if internal_metrics: - print("\n📦 Tractament de mètriques internes no implementat (pendents).") - print("\n✅ Procés de recollida completat.") diff --git a/polling/urls.py b/polling/urls.py index 0a05ddd..9254a23 100644 --- a/polling/urls.py +++ b/polling/urls.py @@ -14,5 +14,5 @@ path("reviews/", polling_list_view, {"poll_type": "reviews"}), path("metrics/apps//", polling_detail_view, {"poll_type": "metrics"}), path("reviews/apps//", polling_detail_view, {"poll_type": "reviews"}), - path("maunal/reviews/apps//", polling_review_view), + path("manual/reviews/apps//", polling_review_view), ] diff --git a/polling/views.py b/polling/views.py index a5b6bba..5e05245 100644 --- a/polling/views.py +++ b/polling/views.py @@ -99,7 +99,7 @@ def destroy(self, request, id=None, poll_type=None): def poll_reviews(self, request, id=None): date_from = request.query_params.get("date_from") date_to = request.query_params.get("date_to") - + print("hola") result = self.service.poll_reviews(app_id=id, date_from=date_from, date_to=date_to) return Response(result) diff --git a/review/migrations/0003_alter_review_topic_alter_review_type.py b/review/migrations/0003_alter_review_topic_alter_review_type.py new file mode 100644 index 0000000..6d2cd93 --- /dev/null +++ b/review/migrations/0003_alter_review_topic_alter_review_type.py @@ -0,0 +1,50 @@ +# Generated by Django 5.1.7 on 2025-05-15 16:39 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ("review", "0002_review_app_version_review_replied_at_and_more"), + ] + + operations = [ + migrations.AlterField( + model_name="review", + name="topic", + field=models.CharField( + blank=True, + choices=[ + ("aesthetics", "Aesthetics"), + ("compatibility", "Compatibility"), + ("cost", "Cost"), + ("effectiveness", "Effectiveness"), + ("efficiency", "Efficiency"), + ("enjoyability", "Enjoyability"), + ("general", "General"), + ("learnability", "Learnability"), + ("n/a", "N/A"), + ("reliability", "Reliability"), + ("safety", "Safety"), + ("security", "Security"), + ("usability", "Usability"), + ], + max_length=30, + null=True, + ), + ), + migrations.AlterField( + model_name="review", + name="type", + field=models.CharField( + choices=[ + ("bug", "Bug"), + ("rating", "Rating"), + ("feature", "Feature"), + ("user_experience", "User Experience"), + ], + max_length=20, + ), + ), + ] diff --git a/review/migrations/0004_remove_review_topic.py b/review/migrations/0004_remove_review_topic.py new file mode 100644 index 0000000..5cfa4fe --- /dev/null +++ b/review/migrations/0004_remove_review_topic.py @@ -0,0 +1,17 @@ +# Generated by Django 5.1.7 on 2025-05-15 16:41 + +from django.db import migrations + + +class Migration(migrations.Migration): + + dependencies = [ + ("review", "0003_alter_review_topic_alter_review_type"), + ] + + operations = [ + migrations.RemoveField( + model_name="review", + name="topic", + ), + ] diff --git a/review/migrations/0005_review_source.py b/review/migrations/0005_review_source.py new file mode 100644 index 0000000..f394f0b --- /dev/null +++ b/review/migrations/0005_review_source.py @@ -0,0 +1,23 @@ +# Generated by Django 5.1.7 on 2025-05-15 16:51 + +import django.db.models.deletion +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ("review", "0004_remove_review_topic"), + ("source", "0003_source_metrics"), + ] + + operations = [ + migrations.AddField( + model_name="review", + name="source", + field=models.ForeignKey( + default=4, on_delete=django.db.models.deletion.CASCADE, to="source.source" + ), + preserve_default=False, + ), + ] diff --git a/review/migrations/0006_remove_review_app_version_remove_review_replied_at_and_more.py b/review/migrations/0006_remove_review_app_version_remove_review_replied_at_and_more.py new file mode 100644 index 0000000..8023618 --- /dev/null +++ b/review/migrations/0006_remove_review_app_version_remove_review_replied_at_and_more.py @@ -0,0 +1,25 @@ +# Generated by Django 5.1.7 on 2025-05-15 17:27 + +from django.db import migrations + + +class Migration(migrations.Migration): + + dependencies = [ + ("review", "0005_review_source"), + ] + + operations = [ + migrations.RemoveField( + model_name="review", + name="app_version", + ), + migrations.RemoveField( + model_name="review", + name="replied_at", + ), + migrations.RemoveField( + model_name="review", + name="reply_content", + ), + ] diff --git a/review/models.py b/review/models.py index 6fef8be..908383d 100644 --- a/review/models.py +++ b/review/models.py @@ -2,6 +2,7 @@ from django.db import models from app.models import App +from source.models import Source class ReviewPolarity(models.TextChoices): @@ -17,25 +18,10 @@ class ReviewType(models.TextChoices): USER_EXPERIENCE = "user_experience", "User Experience" -class ReviewTopic(models.TextChoices): - AESTHETICS = "aesthetics", "Aesthetics" - COMPATIBILITY = "compatibility", "Compatibility" - COST = "cost", "Cost" - EFFECTIVENESS = "effectiveness", "Effectiveness" - EFFICIENCY = "efficiency", "Efficiency" - ENJOYABILITY = "enjoyability", "Enjoyability" - GENERAL = "general", "General" - LEARNABILITY = "learnability", "Learnability" - N_A = "n/a", "N/A" - RELIABILITY = "reliability", "Reliability" - SAFETY = "safety", "Safety" - SECURITY = "security", "Security" - USABILITY = "usability", "Usability" - - class Review(models.Model): review_id = models.CharField(max_length=255, null=True, blank=True) app = models.ForeignKey(App, on_delete=models.CASCADE) + source = models.ForeignKey(Source, on_delete=models.CASCADE) author = models.CharField(max_length=100, null=True, blank=True) content = models.TextField() rating = models.FloatField( @@ -43,11 +29,7 @@ class Review(models.Model): ) polarity = models.CharField(max_length=10, choices=ReviewPolarity.choices) type = models.CharField(max_length=20, choices=ReviewType.choices) - topic = models.CharField(max_length=30, choices=ReviewTopic.choices, null=True, blank=True) date = models.DateTimeField() - reply_content = models.TextField(null=True, blank=True) - replied_at = models.DateTimeField(null=True, blank=True) - app_version = models.CharField(max_length=100, null=True, blank=True) def __str__(self): return f"{self.app.name} - {self.date.date()} - {self.author or 'Anon'}" diff --git a/review/repositories.py b/review/repositories.py index 560cce6..bdf48e4 100644 --- a/review/repositories.py +++ b/review/repositories.py @@ -34,45 +34,29 @@ def get_all(self, filters=None): def get_by_id(self, review_id): return Review.objects.get(id=review_id) - def save_reviews(self, app_id, reviews_list): + def save_reviews(self, app_id, source_id, reviews_list): if not reviews_list: return 0 - # Agafar tots els review_ids existents d'aquesta app - existing_review_ids = set( - Review.objects.filter( - review_id__in=[r.get("reviewId") for r in reviews_list] - ).values_list("review_id", flat=True) - ) - created_reviews = [] for r in reviews_list: review_id = r.get("reviewId") - if not review_id or review_id in existing_review_ids: - continue created_at = r.get("at") if created_at and timezone.is_naive(created_at): created_at = timezone.make_aware(created_at) - replied_at = r.get("repliedAt") - if replied_at and timezone.is_naive(replied_at): - replied_at = timezone.make_aware(replied_at) - review = Review( app_id=app_id, + source_id=source_id, review_id=review_id, author=r.get("userName"), content=r.get("content"), rating=r.get("score"), - # polarity=self._infer_polarity(r.get('score')), - # type="general", - # topic=None, + polarity=r.get("polarity"), + type=r.get("type"), date=created_at, - reply_content=r.get("replyContent"), - replied_at=replied_at, - app_version=r.get("appVersion"), ) created_reviews.append(review) @@ -80,3 +64,13 @@ def save_reviews(self, app_id, reviews_list): print(f"Saved {len(created_reviews)} new reviews.") return len(created_reviews) + + def get_existing_review_ids(self, app_id: int, source_id: int) -> set: + return set( + Review.objects.filter(app_id=app_id, source_id=source_id).values_list( + "review_id", flat=True + ) + ) + + def delete_all_reviews(self): + Review.objects.all().delete() diff --git a/review/serializers.py b/review/serializers.py index 0a84f20..e948cb8 100644 --- a/review/serializers.py +++ b/review/serializers.py @@ -1,12 +1,11 @@ from rest_framework import serializers -from .models import Review, ReviewPolarity, ReviewTopic, ReviewType +from .models import Review, ReviewPolarity, ReviewType class ReviewSerializer(serializers.ModelSerializer): polarity = serializers.ChoiceField(choices=ReviewPolarity.choices) type = serializers.ChoiceField(choices=ReviewType.choices) - topic = serializers.ChoiceField(choices=ReviewTopic.choices) class Meta: model = Review @@ -14,29 +13,23 @@ class Meta: "id", "review_id", "app", + "source", "author", "content", "rating", "polarity", "type", - "topic", "date", - "reply_content", - "replied_at", - "app_version", ] read_only_fields = [ "id", "review_id", "app", + "source", "author", "content", "rating", "polarity", "type", - "topic", "date", - "reply_content", - "replied_at", - "app_version", ] diff --git a/review/services.py b/review/services.py index 7c23b28..05ae8df 100644 --- a/review/services.py +++ b/review/services.py @@ -16,3 +16,6 @@ def get_review(self, review_id): return self.repo.get_by_id(review_id) except ObjectDoesNotExist: raise NotFound(f"The review with ID '{review_id}' is not registered.") + + def delete_all_reviews(self): + self.repo.delete_all_reviews() diff --git a/review/tests.py b/review/tests.py index 9f81184..022ffa5 100644 --- a/review/tests.py +++ b/review/tests.py @@ -2,7 +2,7 @@ from django.utils import timezone from app.models import App -from review.models import Review, ReviewPolarity, ReviewTopic, ReviewType +from review.models import Review, ReviewPolarity, ReviewType, Source @pytest.mark.django_db @@ -14,13 +14,16 @@ def test_create_review_no_values(): description="Descripction of the test app", appstore_id="123456", ) + source = Source.objects.create(name="Test Source") # Crear un objeto Source r = Review.objects.create( app=a, + source=source, # Asignar el source content="Content of the test review", date=now, ) assert r.pk is not None assert r.app == a + assert r.source == source assert r.content == "Content of the test review" assert r.date == now @@ -34,20 +37,21 @@ def test_create_review_with_values(): description="Descripction of the test app", appstore_id="123456", ) + source = Source.objects.create(name="Test Source") # Crear un objeto Source r = Review.objects.create( app=a, + source=source, # Asignar el source content="Content of the test review", date=now, rating=5, polarity=ReviewPolarity.NEUTRAL, type=ReviewType.FEATURE, - topic=ReviewTopic.COMPATIBILITY, ) assert r.pk is not None assert r.app == a + assert r.source == source assert r.content == "Content of the test review" assert r.date == now assert r.rating == 5 assert r.polarity == ReviewPolarity.NEUTRAL assert r.type == ReviewType.FEATURE - assert r.topic == ReviewTopic.COMPATIBILITY diff --git a/review/views.py b/review/views.py index 006e335..c71af10 100644 --- a/review/views.py +++ b/review/views.py @@ -1,5 +1,6 @@ -from drf_spectacular.utils import OpenApiParameter, extend_schema -from rest_framework import viewsets +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 .serializers import ReviewSerializer @@ -37,3 +38,14 @@ def retrieve(self, request, pk=None): source = self.service.get_review(pk) serializer = ReviewSerializer(source) return Response(serializer.data) + + @extend_schema( + methods=["delete"], + description="Esborra totes les reviews de la base de dades.", + responses={204: OpenApiResponse(description="Totes les reviews han estat esborrades")}, + 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 524dbba..e7458ba 100644 --- a/source/adapters/base.py +++ b/source/adapters/base.py @@ -5,6 +5,7 @@ class SourceAdapter(ABC): + id: int code: str name: str type: SourceType diff --git a/source/adapters/google_play_scraper.py b/source/adapters/google_play_scraper.py index ce0ba85..bf20674 100644 --- a/source/adapters/google_play_scraper.py +++ b/source/adapters/google_play_scraper.py @@ -1,5 +1,5 @@ import time -from datetime import date, datetime, timedelta +from datetime import date, datetime, timedelta, timezone from google_play_scraper import app as gp_app from google_play_scraper import reviews @@ -17,6 +17,7 @@ class GooglePlayScraperAdapter(SourceAdapter): def __init__(self): super().__init__() source_data = SourceService().get_source_data(code=self.code) + self.id = source_data["id"] self.name = source_data["name"] self.type = source_data["type"] self.url = source_data["url"] @@ -52,12 +53,15 @@ def fetch(self, app_id: int, metrics: list[str]): metric: metric_map.get(metric, "") for metric in metrics if self.supports_metric(metric) } + def _log(self, msg): + print(f"[{datetime.now(timezone.utc).isoformat()}] {msg}") + def fetch_reviews(self, app_id: int, date_from=None, date_to=None): app = AppService().get_app(app_id=app_id) if not app: + self._log("❌ App no trobada.") return [] - # Dates per defecte: ahir yesterday = date.today() - timedelta(days=1) try: date_from = datetime.fromisoformat(date_from).date() if date_from else yesterday @@ -65,43 +69,54 @@ def fetch_reviews(self, app_id: int, date_from=None, date_to=None): except ValueError: raise ParseError("Dates must be in ISO format: YYYY-MM-DD.") - days_range = (date_to - date_from).days + days_range = (date_to - date_from).days + 1 if days_range < 0: raise ValidationError("date_from must be before or equal to date_to.") - count_per_request = min(max(1, 180 * days_range), 4500) + count_per_request = min(180 * days_range, 4500) + + self._log( + f"📅 Fetching reviews from {date_from} to {date_to} (max {count_per_request} perrequest)" + ) all_reviews = [] next_token = None count = 0 while True: - batch, next_token = reviews( - app.playstore_id, - lang="en", - country="us", - count=count_per_request, - continuation_token=next_token, - ) + try: + self._log(f"🔄 Petició #{count + 1} amb token: {next_token}") + batch, next_token = reviews( + app.playstore_id, + lang="en", + country="us", + count=count_per_request, + continuation_token=next_token, + ) + except Exception as e: + self._log(f"❌ Error durant la crida a reviews(): {e}") + break + count += 1 if not batch: + self._log("⚠️ Batch buit. Finalitzant.") break for r in batch: review_date = r["at"].date() - if review_date < date_from: - print("⛔ Hem arribat a reviews més antigues del rang. Sortint.") + self._log("⛔ Reviews més antigues del rang. Finalitzant.") return all_reviews if review_date <= date_to: all_reviews.append(r) if not next_token: + self._log("✅ No hi ha més tokens. Finalitzant.") break time.sleep(1) - print(f"✅ Total reviews: {len(all_reviews)} recollides en {count} peticions.") + self._log(f"✅ Total reviews: {len(all_reviews)} recollides en {count} peticions.") return all_reviews diff --git a/source/adapters/itunes.py b/source/adapters/itunes.py index 7226016..0fec35e 100644 --- a/source/adapters/itunes.py +++ b/source/adapters/itunes.py @@ -12,6 +12,7 @@ class ItunesSearchAPIAdapter(SourceAdapter): def __init__(self): super().__init__() source_data = SourceService().get_source_data(code=self.code) + self.id = source_data["id"] self.name = source_data["name"] self.type = source_data["type"] self.url = source_data["url"] diff --git a/source/adapters/news.py b/source/adapters/news.py index 1bdea50..b9a8bbd 100644 --- a/source/adapters/news.py +++ b/source/adapters/news.py @@ -15,6 +15,7 @@ class NewsAPIAdapter(SourceAdapter): def __init__(self, api_key=None): super().__init__() source_data = SourceService().get_source_data(code=self.code) + self.id = source_data["id"] self.name = source_data["name"] self.type = source_data["type"] self.url = source_data["url"] diff --git a/source/adapters/reddit.py b/source/adapters/reddit.py index 52613dd..7e564e4 100644 --- a/source/adapters/reddit.py +++ b/source/adapters/reddit.py @@ -15,6 +15,7 @@ class RedditAPIAdapter(SourceAdapter): def __init__(self): super().__init__() source_data = SourceService().get_source_data(code=self.code) + self.id = source_data["id"] self.name = source_data["name"] self.type = source_data["type"] self.url = source_data["url"] diff --git a/source/services.py b/source/services.py index daad8cb..db2004f 100644 --- a/source/services.py +++ b/source/services.py @@ -41,6 +41,7 @@ def get_source_data(self, code: str) -> dict: raise NotFound(f"The source with code '{code}' is not registered.") return { + "id": source.id, "code": source.code, "name": source.name, "type": source.type,