diff --git a/app/migrations/0005_app_user_alter_app_appstore_id_alter_app_code_and_more.py b/app/migrations/0005_app_user_alter_app_appstore_id_alter_app_code_and_more.py new file mode 100644 index 0000000..b912d1a --- /dev/null +++ b/app/migrations/0005_app_user_alter_app_appstore_id_alter_app_code_and_more.py @@ -0,0 +1,58 @@ +# Generated by Django 5.1.7 on 2025-05-23 10:40 + +import django.db.models.deletion +from django.conf import settings +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ("app", "0004_app_icon_url_app_size_in_bytes"), + migrations.swappable_dependency(settings.AUTH_USER_MODEL), + ] + + operations = [ + migrations.AddField( + model_name="app", + name="user", + field=models.ForeignKey( + default=2, + on_delete=django.db.models.deletion.CASCADE, + related_name="apps", + to=settings.AUTH_USER_MODEL, + ), + preserve_default=False, + ), + migrations.AlterField( + model_name="app", + name="appstore_id", + field=models.CharField(max_length=100, null=True), + ), + migrations.AlterField( + model_name="app", + name="code", + field=models.CharField(max_length=100), + ), + migrations.AlterField( + model_name="app", + name="playstore_id", + field=models.CharField(max_length=100, null=True), + ), + migrations.AddConstraint( + model_name="app", + constraint=models.UniqueConstraint(fields=("user", "code"), name="unique_user_code"), + ), + migrations.AddConstraint( + model_name="app", + constraint=models.UniqueConstraint( + fields=("user", "appstore_id"), name="unique_user_appstore_id" + ), + ), + migrations.AddConstraint( + model_name="app", + constraint=models.UniqueConstraint( + fields=("user", "playstore_id"), name="unique_user_playstore_id" + ), + ), + ] diff --git a/app/migrations/0006_alter_app_description.py b/app/migrations/0006_alter_app_description.py new file mode 100644 index 0000000..a675255 --- /dev/null +++ b/app/migrations/0006_alter_app_description.py @@ -0,0 +1,18 @@ +# Generated by Django 5.1.7 on 2025-05-26 17:25 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ("app", "0005_app_user_alter_app_appstore_id_alter_app_code_and_more"), + ] + + operations = [ + migrations.AlterField( + model_name="app", + name="description", + field=models.TextField(blank=True, null=True), + ), + ] diff --git a/app/models.py b/app/models.py index 8c7d45b..afce329 100644 --- a/app/models.py +++ b/app/models.py @@ -1,12 +1,14 @@ +from django.contrib.auth.models import User from django.db import models class App(models.Model): - code = models.CharField(max_length=100, unique=True) + user = models.ForeignKey(User, on_delete=models.CASCADE, related_name="apps") + code = models.CharField(max_length=100) name = models.CharField(max_length=200) - description = models.TextField() - appstore_id = models.CharField(max_length=100, null=True, unique=True) - playstore_id = models.CharField(max_length=100, null=True, unique=True) + description = models.TextField(null=True, blank=True) + appstore_id = models.CharField(max_length=100, null=True) + playstore_id = models.CharField(max_length=100, null=True) developer = models.CharField(max_length=100, null=True) available_on_ios = models.BooleanField(default=False) available_on_android = models.BooleanField(default=False) @@ -16,5 +18,14 @@ class App(models.Model): icon_url = models.URLField(null=True) size_in_bytes = models.BigIntegerField(null=True) + class Meta: + constraints = [ + models.UniqueConstraint(fields=["user", "code"], name="unique_user_code"), + models.UniqueConstraint(fields=["user", "appstore_id"], name="unique_user_appstore_id"), + models.UniqueConstraint( + fields=["user", "playstore_id"], name="unique_user_playstore_id" + ), + ] + def __str__(self): return self.name diff --git a/app/repositories.py b/app/repositories.py index 0a5d2db..b7cf878 100644 --- a/app/repositories.py +++ b/app/repositories.py @@ -2,25 +2,20 @@ class AppRepository: - @staticmethod - def get_all(): - return App.objects.all() + def get_all(self, user): + return App.objects.filter(user=user) - @staticmethod - def get_by_id(app_id): + def get_by_id(self, app_id): return App.objects.get(id=app_id) - @staticmethod - def create(data): - return App.objects.create(**data) + def create(self, data, user): + return App.objects.create(user=user, **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() diff --git a/app/services.py b/app/services.py index 5c395aa..bf44e5a 100644 --- a/app/services.py +++ b/app/services.py @@ -32,8 +32,8 @@ def google_play_adapter(self): self._google_play_adapter = GooglePlayScraperAdapter() return self._google_play_adapter - def list_apps(self): - return self.repo.get_all() + def list_apps(self, user): + return self.repo.get_all(user) def get_app(self, app_id): try: @@ -41,10 +41,10 @@ def get_app(self, app_id): except ObjectDoesNotExist: raise NotFound(f"The app with ID '{app_id}' is not registered.") - def create_app(self, validated_data): + def create_app(self, validated_data, user): self._fetch_appstore_data(validated_data) self._fetch_playstore_data(validated_data) - return self.repo.create(validated_data) + return self.repo.create(validated_data, user) def _fetch_appstore_data(self, validated_data): if not validated_data.get("appstore_id"): diff --git a/app/views.py b/app/views.py index e322e60..f080428 100644 --- a/app/views.py +++ b/app/views.py @@ -1,6 +1,8 @@ from drf_spectacular.utils import OpenApiParameter, extend_schema from rest_framework import status, viewsets from rest_framework.decorators import action +from rest_framework.exceptions import PermissionDenied +from rest_framework.permissions import IsAuthenticated from rest_framework.response import Response from .serializers import AppCreateSerializer, AppSerializer, MetricResponseSerializer @@ -8,14 +10,24 @@ class AppViewSet(viewsets.ViewSet): + permission_classes = [IsAuthenticated] service = AppService() + def _get_user_app(self, pk): + app = self.service.get_app(pk) + if app.user != self.request.user: + raise PermissionDenied("No tens accés a aquesta aplicació.") + return app + @extend_schema( responses=AppSerializer(many=True), tags=["Apps"], ) def list(self, request): - apps = self.service.list_apps() + print("👤 Usuari:", request.user) + print("🔐 Auth:", request.auth) + + apps = self.service.list_apps(user=request.user) serializer = AppSerializer(apps, many=True) return Response(serializer.data) @@ -25,7 +37,7 @@ def list(self, request): tags=["Apps"], ) def retrieve(self, request, pk=None): - app = self.service.get_app(pk) + app = self._get_user_app(pk) serializer = AppSerializer(app) return Response(serializer.data) @@ -43,14 +55,9 @@ def retrieve(self, request, pk=None): tags=["Apps"], ) def create(self, request): - print("REQUEST DATA:", request.data) serializer = AppCreateSerializer(data=request.data) - 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) + serializer.is_valid(raise_exception=True) + app = self.service.create_app(serializer.validated_data, user=request.user) return Response(AppSerializer(app).data, status=status.HTTP_201_CREATED) @extend_schema( @@ -59,7 +66,7 @@ def create(self, request): tags=["Apps"], ) def update(self, request, pk=None): - app = self.service.get_app(pk) + app = self._get_user_app(pk) serializer = AppSerializer(app, data=request.data) serializer.is_valid(raise_exception=True) updated_app = self.service.update_app(app, serializer.validated_data) @@ -71,7 +78,7 @@ def update(self, request, pk=None): tags=["Apps"], ) def destroy(self, request, pk=None): - app = self.service.get_app(pk) + app = self._get_user_app(pk) self.service.delete_app(app) return Response(status=status.HTTP_204_NO_CONTENT) @@ -85,6 +92,7 @@ def destroy(self, request, pk=None): ) @action(detail=True, methods=["get"], url_path="metrics/(?P[^/.]+)") def get_app_metric(self, request, pk=None, metric_id=None): - response_data = self.service.get_metric_dashboard(app_id=pk, metric_id=metric_id) + app = self._get_user_app(pk) + 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/config/settings.py b/config/settings.py index 22c95bc..0003421 100644 --- a/config/settings.py +++ b/config/settings.py @@ -11,6 +11,7 @@ """ import os +from datetime import timedelta from pathlib import Path from dotenv import load_dotenv @@ -53,6 +54,9 @@ "drf_spectacular", "django_celery_beat", "corsheaders", + "rest_framework", + "rest_framework.authtoken", + "users", ] MIDDLEWARE = [ @@ -148,6 +152,17 @@ REST_FRAMEWORK = { "DEFAULT_SCHEMA_CLASS": "drf_spectacular.openapi.AutoSchema", "EXCEPTION_HANDLER": "config.exceptions.custom_exception_handler", + "DEFAULT_AUTHENTICATION_CLASSES": [ + "rest_framework_simplejwt.authentication.JWTAuthentication", + ], + "DEFAULT_PERMISSION_CLASSES": [ + "rest_framework.permissions.IsAuthenticated", + ], +} + +SIMPLE_JWT = { + "ACCESS_TOKEN_LIFETIME": timedelta(minutes=15), + "REFRESH_TOKEN_LIFETIME": timedelta(days=7), } CELERY_BROKER_URL = "redis://redis:6379/0" diff --git a/config/urls.py b/config/urls.py index a47e27e..cce6440 100644 --- a/config/urls.py +++ b/config/urls.py @@ -35,4 +35,5 @@ def home(request): path("api/", include("review.urls")), path("api/", include("metric.urls")), path("api/polling/", include("polling.urls")), + path("api/users/", include("users.urls")), ] diff --git a/metric/migrations/0008_metric_user_metricvalue_user_alter_metric_code_and_more.py b/metric/migrations/0008_metric_user_metricvalue_user_alter_metric_code_and_more.py new file mode 100644 index 0000000..84f584f --- /dev/null +++ b/metric/migrations/0008_metric_user_metricvalue_user_alter_metric_code_and_more.py @@ -0,0 +1,49 @@ +# Generated by Django 5.1.7 on 2025-05-23 10:40 + +import django.db.models.deletion +from django.conf import settings +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ("metric", "0007_alter_metricvalue_retrieved_at"), + migrations.swappable_dependency(settings.AUTH_USER_MODEL), + ] + + operations = [ + migrations.AddField( + model_name="metric", + name="user", + field=models.ForeignKey( + default=2, + on_delete=django.db.models.deletion.CASCADE, + related_name="metrics", + to=settings.AUTH_USER_MODEL, + ), + preserve_default=False, + ), + migrations.AddField( + model_name="metricvalue", + name="user", + field=models.ForeignKey( + default=2, + on_delete=django.db.models.deletion.CASCADE, + related_name="metrics_values", + to=settings.AUTH_USER_MODEL, + ), + preserve_default=False, + ), + migrations.AlterField( + model_name="metric", + name="code", + field=models.CharField(max_length=100), + ), + migrations.AddConstraint( + model_name="metric", + constraint=models.UniqueConstraint( + fields=("user", "code"), name="unique_user_metric_code" + ), + ), + ] diff --git a/metric/models.py b/metric/models.py index 4909d3b..0cb04bf 100644 --- a/metric/models.py +++ b/metric/models.py @@ -1,3 +1,4 @@ +from django.contrib.auth.models import User from django.db import models from django.utils import timezone @@ -6,16 +7,23 @@ class Metric(models.Model): - code = models.CharField(max_length=100, unique=True) + user = models.ForeignKey(User, on_delete=models.CASCADE, related_name="metrics") + code = models.CharField(max_length=100) name = models.CharField(max_length=100) description = models.TextField() value_type = models.CharField(max_length=10, choices=MetricValueType.choices) + class Meta: + constraints = [ + models.UniqueConstraint(fields=["user", "code"], name="unique_user_metric_code") + ] + def __str__(self): return self.name class MetricValue(models.Model): + user = models.ForeignKey(User, on_delete=models.CASCADE, related_name="metrics_values") app = models.ForeignKey(App, on_delete=models.CASCADE) metric = models.ForeignKey(Metric, on_delete=models.CASCADE) source = models.ForeignKey("source.Source", on_delete=models.CASCADE, null=True, blank=True) diff --git a/metric/repositories.py b/metric/repositories.py index af86aa5..988d692 100644 --- a/metric/repositories.py +++ b/metric/repositories.py @@ -3,8 +3,7 @@ class MetricRepository: def get_all(self): - return Metric.objects.filter(id=2) - # return Metric.objects.all() + return Metric.objects.all() def get_by_id(self, pk): return Metric.objects.get(id=pk) diff --git a/polling/migrations/0003_alter_pollingschedule_unique_together_and_more.py b/polling/migrations/0003_alter_pollingschedule_unique_together_and_more.py new file mode 100644 index 0000000..8e00a7b --- /dev/null +++ b/polling/migrations/0003_alter_pollingschedule_unique_together_and_more.py @@ -0,0 +1,39 @@ +# Generated by Django 5.1.7 on 2025-05-23 10:40 + +import django.db.models.deletion +from django.conf import settings +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ("app", "0005_app_user_alter_app_appstore_id_alter_app_code_and_more"), + ("django_celery_beat", "0019_alter_periodictasks_options"), + ("polling", "0002_pollingschedule_periodic_task"), + migrations.swappable_dependency(settings.AUTH_USER_MODEL), + ] + + operations = [ + migrations.AlterUniqueTogether( + name="pollingschedule", + unique_together=set(), + ), + migrations.AddField( + model_name="pollingschedule", + name="user", + field=models.ForeignKey( + default=2, + on_delete=django.db.models.deletion.CASCADE, + related_name="polling_schedules", + to=settings.AUTH_USER_MODEL, + ), + preserve_default=False, + ), + migrations.AddConstraint( + model_name="pollingschedule", + constraint=models.UniqueConstraint( + fields=("user", "app", "poll_type"), name="unique_polling_schedule_per_user" + ), + ), + ] diff --git a/polling/models.py b/polling/models.py index 7bbec20..a1dfa61 100644 --- a/polling/models.py +++ b/polling/models.py @@ -1,3 +1,4 @@ +from django.contrib.auth.models import User from django.db import models from django_celery_beat.models import PeriodicTask @@ -5,6 +6,7 @@ class PollingSchedule(models.Model): + user = models.ForeignKey(User, on_delete=models.CASCADE, related_name="polling_schedules") app = models.ForeignKey(App, on_delete=models.CASCADE, related_name="polling_schedules") interval_hours = models.IntegerField() start_at = models.DateTimeField(auto_now_add=True) @@ -28,7 +30,11 @@ class PollingSchedule(models.Model): ) class Meta: - unique_together = ("app", "poll_type") + constraints = [ + models.UniqueConstraint( + fields=["user", "app", "poll_type"], name="unique_polling_schedule_per_user" + ) + ] def __str__(self): return f"{self.app.name} - {self.poll_type}" diff --git a/requirements.txt b/requirements.txt index 2b4228e..3d8bdef 100644 Binary files a/requirements.txt and b/requirements.txt differ diff --git a/review/migrations/0007_review_user.py b/review/migrations/0007_review_user.py new file mode 100644 index 0000000..4d3051b --- /dev/null +++ b/review/migrations/0007_review_user.py @@ -0,0 +1,27 @@ +# Generated by Django 5.1.7 on 2025-05-23 10:40 + +import django.db.models.deletion +from django.conf import settings +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ("review", "0006_remove_review_app_version_remove_review_replied_at_and_more"), + migrations.swappable_dependency(settings.AUTH_USER_MODEL), + ] + + operations = [ + migrations.AddField( + model_name="review", + name="user", + field=models.ForeignKey( + default=2, + on_delete=django.db.models.deletion.CASCADE, + related_name="reviews", + to=settings.AUTH_USER_MODEL, + ), + preserve_default=False, + ), + ] diff --git a/review/models.py b/review/models.py index 908383d..77176ca 100644 --- a/review/models.py +++ b/review/models.py @@ -1,3 +1,4 @@ +from django.contrib.auth.models import User from django.core.validators import MaxValueValidator, MinValueValidator from django.db import models @@ -19,6 +20,7 @@ class ReviewType(models.TextChoices): class Review(models.Model): + user = models.ForeignKey(User, on_delete=models.CASCADE, related_name="reviews") 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) diff --git a/source/migrations/0004_source_user_alter_source_code_and_more.py b/source/migrations/0004_source_user_alter_source_code_and_more.py new file mode 100644 index 0000000..70c2125 --- /dev/null +++ b/source/migrations/0004_source_user_alter_source_code_and_more.py @@ -0,0 +1,39 @@ +# Generated by Django 5.1.7 on 2025-05-23 10:40 + +import django.db.models.deletion +from django.conf import settings +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ("metric", "0008_metric_user_metricvalue_user_alter_metric_code_and_more"), + ("source", "0003_source_metrics"), + migrations.swappable_dependency(settings.AUTH_USER_MODEL), + ] + + operations = [ + migrations.AddField( + model_name="source", + name="user", + field=models.ForeignKey( + default=2, + on_delete=django.db.models.deletion.CASCADE, + related_name="sources", + to=settings.AUTH_USER_MODEL, + ), + preserve_default=False, + ), + migrations.AlterField( + model_name="source", + name="code", + field=models.CharField(max_length=100), + ), + migrations.AddConstraint( + model_name="source", + constraint=models.UniqueConstraint( + fields=("user", "code"), name="unique_user_source_code" + ), + ), + ] diff --git a/source/models.py b/source/models.py index 1c3a1b5..4b5094d 100644 --- a/source/models.py +++ b/source/models.py @@ -1,14 +1,21 @@ +from django.contrib.auth.models import User from django.db import models from source.constants.source_type import SourceType class Source(models.Model): - code = models.CharField(max_length=100, unique=True) + user = models.ForeignKey(User, on_delete=models.CASCADE, related_name="sources") + code = models.CharField(max_length=100) name = models.CharField(max_length=100) type = models.CharField(max_length=20, choices=SourceType.choices) url = models.URLField(null=True, blank=True) metrics = models.ManyToManyField("metric.Metric", related_name="sources") + class Meta: + constraints = [ + models.UniqueConstraint(fields=["user", "code"], name="unique_user_source_code") + ] + def __str__(self): return self.name diff --git a/users/__init__.py b/users/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/users/admin.py b/users/admin.py new file mode 100644 index 0000000..e69de29 diff --git a/users/apps.py b/users/apps.py new file mode 100644 index 0000000..88f7b17 --- /dev/null +++ b/users/apps.py @@ -0,0 +1,6 @@ +from django.apps import AppConfig + + +class UsersConfig(AppConfig): + default_auto_field = "django.db.models.BigAutoField" + name = "users" diff --git a/users/migrations/__init__.py b/users/migrations/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/users/models.py b/users/models.py new file mode 100644 index 0000000..e69de29 diff --git a/users/repositories.py b/users/repositories.py new file mode 100644 index 0000000..74ac56e --- /dev/null +++ b/users/repositories.py @@ -0,0 +1,9 @@ +from django.contrib.auth.models import User + + +class UserRepository: + def create_user(self, username: str, email: str, password: str) -> User: + return User.objects.create_user(username=username, email=email, password=password) + + def exists_by_username(self, username: str) -> bool: + return User.objects.filter(username=username).exists() diff --git a/users/serializers.py b/users/serializers.py new file mode 100644 index 0000000..6b7a315 --- /dev/null +++ b/users/serializers.py @@ -0,0 +1,15 @@ +from django.contrib.auth.models import User +from rest_framework import serializers + + +class RegisterSerializer(serializers.ModelSerializer): + password = serializers.CharField(write_only=True) + + class Meta: + model = User + fields = ["username", "email", "password"] + + def validate_username(self, value): + if User.objects.filter(username=value).exists(): + raise serializers.ValidationError("Aquest nom d’usuari ja existeix.") + return value diff --git a/users/services.py b/users/services.py new file mode 100644 index 0000000..d1505af --- /dev/null +++ b/users/services.py @@ -0,0 +1,18 @@ +from rest_framework_simplejwt.tokens import RefreshToken + +from .repositories import UserRepository + + +class UserService: + def __init__(self): + self.repo = UserRepository() + + def register_user(self, data: dict): + user = self.repo.create_user( + username=data["username"], email=data.get("email", ""), password=data["password"] + ) + refresh = RefreshToken.for_user(user) + return { + "refresh": str(refresh), + "access": str(refresh.access_token), + } diff --git a/users/tests.py b/users/tests.py new file mode 100644 index 0000000..e69de29 diff --git a/users/urls.py b/users/urls.py new file mode 100644 index 0000000..278e27f --- /dev/null +++ b/users/urls.py @@ -0,0 +1,13 @@ +from django.urls import path +from rest_framework_simplejwt.views import ( + TokenObtainPairView, + TokenRefreshView, +) + +from .views import 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"), +] diff --git a/users/views.py b/users/views.py new file mode 100644 index 0000000..962f2f1 --- /dev/null +++ b/users/views.py @@ -0,0 +1,33 @@ +from drf_spectacular.utils import extend_schema +from rest_framework import status +from rest_framework.permissions import AllowAny +from rest_framework.response import Response +from rest_framework.views import APIView + +from .serializers import RegisterSerializer +from .services import UserService + + +class RegisterView(APIView): + permission_classes = [AllowAny] + service = UserService() + + @extend_schema( + request=RegisterSerializer, + responses={ + 201: { + "type": "object", + "properties": { + "access": {"type": "string"}, + "refresh": {"type": "string"}, + }, + } + }, + description="Endpoint per registrar un nou usuari. Retorna tokens JWT (access + refresh).", + ) + def post(self, request): + serializer = RegisterSerializer(data=request.data) + if serializer.is_valid(): + 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)