Repository: Proyecto-ISPP/FISIOFIND
Pull request #441: Feat/ratings
Author: login: antoniommff
Labels: name: enhancement
description: Solicitud o incorporación de una nueva funcionalidad
name: hu
description: Historia de usuario que describe una funcionalidad específica
Created: 2025-04-03T14:52:52Z | Merged: 2025-04-05T12:26:32Z

**Descripción del cambio:**
- Se ha añadido la funcionalidad de valoraciónes de la la aplicación acorde a lo pedido en la HU


**Instrucciones**
- Comprobar que los cambios se han realizado de forma correcta y no se produce bugueo estético


Diff:
diff --git a/backend/fisio_find/appointment/exampledata.json b/backend/fisio_find/appointment/exampledata.json
index ddb81a2b..408fffcf 100644
--- a/backend/fisio_find/appointment/exampledata.json
+++ b/backend/fisio_find/appointment/exampledata.json
@@ -3,11 +3,20 @@
         "model": "users.pricing",
         "pk": 1,
         "fields": {
-            "name": "Plan Básico",
-            "price": 50,
+            "name": "blue",
+            "price": 17.99,
             "video_limit": 15
         }
     },
+    {
+        "model": "users.pricing",
+        "pk": 2,
+        "fields": {
+            "name": "gold",
+            "price": 24.99,
+            "video_limit": 30
+        }
+    },
     {
         "model": "users.specialization",
         "pk": 1,
@@ -176,6 +185,7 @@
                 }
             },
             "gender": "M",
+            "plan": 1,
             "specializations": [
                 1,
                 2
@@ -211,6 +221,7 @@
             "collegiate_number": "COL2",
             "services": {},
             "gender": "M",
+            "plan": 1,
             "specializations": [
                 1,
                 3
@@ -245,6 +256,7 @@
             "collegiate_number": "COL3",
             "services": {},
             "gender": "M",
+            "plan": 1,
             "specializations": [
                 1,
                 4
@@ -279,6 +291,7 @@
             "collegiate_number": "COL4",
             "services": {},
             "gender": "M",
+            "plan": 2,
             "specializations": [
                 5
             ]
@@ -312,6 +325,7 @@
             "collegiate_number": "COL5",
             "services": {},
             "gender": "M",
+            "plan": 2,
             "specializations": [
                 6,
                 1
diff --git a/backend/fisio_find/appointment/urls.py b/backend/fisio_find/appointment/urls.py
index 99c6ad04..a25fcecc 100644
--- a/backend/fisio_find/appointment/urls.py
+++ b/backend/fisio_find/appointment/urls.py
@@ -12,16 +12,16 @@
     #Patients
     path('patient/', views.create_appointment_patient, name="create_appointment_patient"),
     path('patient/list/', views.list_appointments_patient),
-    #Physiotherapists
+    # Physiotherapists
     path('physio/', views.create_appointment_physio),
     path('physio/list/', views.list_appointments_physio),
     path('physio/list/finished/', views.list_finished_appointments_physio, name='list_finished_appointments_physio'),
 
-    #Update and delete
+    # Update and delete
     path('update/<int:appointment_id>/', views.update_appointment, name='update_appointment'),
     path('delete/<int:appointment_id>/', views.delete_appointment, name='delete_appointment'),
     path('update/<int:appointment_id>/confirm/', views.confirm_appointment, name='confirm_appointment'),
     path('update/<int:appointment_id>/accept-alternative/', views.accept_alternative),
 
 ]
-urlpatterns = format_suffix_patterns(urlpatterns)
\ No newline at end of file
+urlpatterns = format_suffix_patterns(urlpatterns)
diff --git a/backend/fisio_find/fisio_find/settings.py b/backend/fisio_find/fisio_find/settings.py
index abd97eac..1694217c 100644
--- a/backend/fisio_find/fisio_find/settings.py
+++ b/backend/fisio_find/fisio_find/settings.py
@@ -79,6 +79,7 @@
     'django.contrib.staticfiles',
     'channels',
     'videocall',
+    'ratings',
 ]
 
 # DJANGO REST FRAMEWORK
@@ -106,19 +107,16 @@
 
 MIDDLEWARE = [
     'django.middleware.security.SecurityMiddleware',
-    'corsheaders.middleware.CorsMiddleware',  # Move this up
+    'corsheaders.middleware.CorsMiddleware',
     'django.contrib.sessions.middleware.SessionMiddleware',
     'django.middleware.common.CommonMiddleware',
     'django.middleware.csrf.CsrfViewMiddleware',
     'django.contrib.auth.middleware.AuthenticationMiddleware',
     'django.contrib.messages.middleware.MessageMiddleware',
     'django.middleware.clickjacking.XFrameOptionsMiddleware',
-    # Remove the duplicate corsheaders.middleware.CorsMiddleware from here
     'django.middleware.common.CommonMiddleware',
-    'corsheaders.middleware.CorsMiddleware',
 ]
 
-# Add additional CORS settings
 CORS_ALLOW_CREDENTIALS = True
 CORS_ALLOW_METHODS = [
     'DELETE',
diff --git a/backend/fisio_find/fisio_find/urls.py b/backend/fisio_find/fisio_find/urls.py
index 3c6c4a98..315834c4 100644
--- a/backend/fisio_find/fisio_find/urls.py
+++ b/backend/fisio_find/fisio_find/urls.py
@@ -8,6 +8,7 @@
 def home(request):
     return HttpResponse("¡Bienvenido a FisioFind!")
 
+
 admin.site.index_title = "Panel de administración"
 admin.site.site_title = "Fisio Find"
 admin.site.site_header = "Fisio Find"
@@ -22,6 +23,7 @@ def home(request):
     path('api/treatments/', include('treatments.urls')),
     path('api/guest_session/', include('guest_session.urls')),
     path('api/payments/', include('payment.urls')),
+    path('api/ratings/', include('ratings.urls')),
     path('api/cloud/', include('files.urls')),
 ]
 
diff --git a/backend/fisio_find/ratings/__init__.py b/backend/fisio_find/ratings/__init__.py
new file mode 100644
index 00000000..e69de29b
diff --git a/backend/fisio_find/ratings/admin.py b/backend/fisio_find/ratings/admin.py
new file mode 100644
index 00000000..846b5174
--- /dev/null
+++ b/backend/fisio_find/ratings/admin.py
@@ -0,0 +1,20 @@
+from django.contrib import admin
+from .models import Rating
+
+
+class RatingAdmin(admin.ModelAdmin):
+    search_fields = [
+        'physiotherapist__user__email', 'physiotherapist__user__username',
+        'physiotherapist__user__first_name', 'physiotherapist__user__last_name',
+        'punctuation', 'opinion'
+    ]
+    list_filter = ['punctuation', 'date']
+    list_display = ['id', 'get_physiotherapist_username', 'punctuation', 'date']
+
+    def get_physiotherapist_username(self, obj):
+        return obj.physiotherapist.user.username
+    get_physiotherapist_username.short_description = 'Physiotherapist'
+    get_physiotherapist_username.admin_order_field = 'physiotherapist__user__username'
+
+
+admin.site.register(Rating, RatingAdmin)
diff --git a/backend/fisio_find/ratings/apps.py b/backend/fisio_find/ratings/apps.py
new file mode 100644
index 00000000..ac631aeb
--- /dev/null
+++ b/backend/fisio_find/ratings/apps.py
@@ -0,0 +1,6 @@
+from django.apps import AppConfig
+
+
+class RatingsConfig(AppConfig):
+    default_auto_field = 'django.db.models.BigAutoField'
+    name = 'ratings'
diff --git a/backend/fisio_find/ratings/migrations/__init__.py b/backend/fisio_find/ratings/migrations/__init__.py
new file mode 100644
index 00000000..e69de29b
diff --git a/backend/fisio_find/ratings/models.py b/backend/fisio_find/ratings/models.py
new file mode 100644
index 00000000..3e77629b
--- /dev/null
+++ b/backend/fisio_find/ratings/models.py
@@ -0,0 +1,16 @@
+from django.db import models
+
+
+class Rating(models.Model):
+    physiotherapist = models.ForeignKey(
+        'users.Physiotherapist', on_delete=models.CASCADE, related_name='ratings'
+    )
+    punctuation = models.IntegerField(choices=[(i, i) for i in range(1, 6)])
+    opinion = models.TextField(blank=True, null=True, max_length=140)
+    date = models.DateTimeField(auto_now_add=True)
+
+    class Meta:
+        unique_together = ('physiotherapist',)
+
+    def __str__(self):
+        return f"Rating {self.punctuation} by {self.physiotherapist.user.username}"
\ No newline at end of file
diff --git a/backend/fisio_find/ratings/serializers.py b/backend/fisio_find/ratings/serializers.py
new file mode 100644
index 00000000..e2904359
--- /dev/null
+++ b/backend/fisio_find/ratings/serializers.py
@@ -0,0 +1,48 @@
+from rest_framework import serializers
+from .models import Rating
+from users.models import Physiotherapist, Patient
+
+
+class PhysiotherapistDetailSerializer(serializers.ModelSerializer):
+    full_name = serializers.SerializerMethodField()
+    photo = serializers.SerializerMethodField()
+
+    class Meta:
+        model = Physiotherapist
+        fields = ['id', 'full_name', 'photo']
+
+    def get_full_name(self, obj):
+        return f"{obj.user.first_name} {obj.user.last_name}"
+
+    def get_photo(self, obj):
+        return obj.user.photo.url if obj.user.photo else None
+
+
+
+class RatingSerializer(serializers.ModelSerializer):
+    physiotherapist_details = PhysiotherapistDetailSerializer(source='physiotherapist', read_only=True)
+
+    class Meta:
+        model = Rating
+        fields = ['id', 'physiotherapist', 'punctuation', 'opinion', 'date',
+                  'physiotherapist_details']
+        read_only_fields = ['id', 'date', 'physiotherapist_details']
+        extra_kwargs = {
+            'punctuation': {'required': True, 'error_messages': {'required': 'Punctuation is required.'}},
+            'opinion': {'required': True, 'error_messages': {'required': 'Opinion is required.'}},
+        }
+
+    def validate_punctuation(self, value):
+        if value < 1 or value > 5:
+            raise serializers.ValidationError("Punctuation must be between 1 and 5.")
+        return value
+
+    def validate_opinion(self, value):
+        if not value.strip():
+            raise serializers.ValidationError("Opinion cannot be empty.")
+        return value
+
+    def validate(self, data):
+        if not data.get('physiotherapist'):
+            raise serializers.ValidationError({"physiotherapist": "Physiotherapist is required."})
+        return data
diff --git a/backend/fisio_find/ratings/tests.py b/backend/fisio_find/ratings/tests.py
new file mode 100644
index 00000000..7ce503c2
--- /dev/null
+++ b/backend/fisio_find/ratings/tests.py
@@ -0,0 +1,3 @@
+from django.test import TestCase
+
+# Create your tests here.
diff --git a/backend/fisio_find/ratings/urls.py b/backend/fisio_find/ratings/urls.py
new file mode 100644
index 00000000..0c990938
--- /dev/null
+++ b/backend/fisio_find/ratings/urls.py
@@ -0,0 +1,20 @@
+from django.urls import path
+from .views import (
+    RatingListView,
+    create_rating,
+    update_rating,
+    delete_rating,
+    get_rating_details,
+    get_my_rating,
+    check_if_physio_has_rated
+)
+
+urlpatterns = [
+    path('list/', RatingListView.as_view(), name='ratings_list'),
+    path('create/', create_rating, name='create_rating'),
+    path('update/<int:rating_id>/', update_rating, name='update_rating'),
+    path('delete/<int:rating_id>/', delete_rating, name='delete_rating'),
+    path('<int:rating_id>/', get_rating_details, name='get_rating_details'),
+    path('has-rated/', check_if_physio_has_rated, name='has_rated'),
+    path('my-rating/', get_my_rating, name='my_rating')
+]
diff --git a/backend/fisio_find/ratings/views.py b/backend/fisio_find/ratings/views.py
new file mode 100644
index 00000000..74f2fe54
--- /dev/null
+++ b/backend/fisio_find/ratings/views.py
@@ -0,0 +1,126 @@
+from rest_framework import generics, status
+from rest_framework.decorators import api_view, permission_classes
+from rest_framework.response import Response
+from rest_framework.permissions import IsAuthenticated, AllowAny
+from .models import Rating
+from .serializers import RatingSerializer
+from users.permissions import IsPhysiotherapist
+
+
+class RatingListView(generics.ListAPIView):
+    queryset = Rating.objects.all().order_by('-punctuation')
+    serializer_class = RatingSerializer
+    permission_classes = [AllowAny]
+
+    def get_queryset(self):
+        queryset = super().get_queryset()
+        return queryset
+
+
+@api_view(['POST'])
+@permission_classes([IsAuthenticated, IsPhysiotherapist])
+def create_rating(request):
+    # Log the incoming data for debugging
+    print("Incoming rating data:", request.data)
+
+    # Ensure the physiotherapist field is set to the logged-in user
+    data = request.data.copy()
+    data['physiotherapist'] = request.user.physio.id
+
+    serializer = RatingSerializer(data=data)
+
+    if serializer.is_valid():
+        serializer.save()
+        return Response(serializer.data, status=status.HTTP_201_CREATED)
+
+    # Log validation errors
+    print("Validation errors:", serializer.errors)
+    return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST)
+
+
+@api_view(['PUT', 'PATCH'])
+@permission_classes([IsAuthenticated, IsPhysiotherapist])
+def update_rating(request, rating_id):
+    try:
+        rating = Rating.objects.get(id=rating_id)
+
+        if request.user.physio != rating.physiotherapist:
+            return Response(
+                {'error': 'Solo puedes editar tus valoraciones'}, 
+                status=status.HTTP_403_FORBIDDEN
+            )
+
+        # Automatically associate the physiotherapist with the logged-in user
+        data = request.data.copy()
+        data['physiotherapist'] = request.user.physio.id
+
+        # Log incoming data for debugging
+        print("Incoming data for update:", data)
+
+        serializer = RatingSerializer(rating, data=data, partial=True)
+
+        if serializer.is_valid():
+            serializer.save()
+            return Response(serializer.data, status=status.HTTP_200_OK)
+
+        # Log validation errors
+        print("Validation errors:", serializer.errors)
+        return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST)
+
+    except Exception:
+        return Response({'error': 'Valoración no encontrada'}, status=status.HTTP_404_NOT_FOUND)
+
+
+@api_view(['DELETE'])
+@permission_classes([IsAuthenticated, IsPhysiotherapist])
+def delete_rating(request, rating_id):
+    try:
+        rating = Rating.objects.get(id=rating_id)
+
+        if request.user.physio != rating.physiotherapist:
+            return Response(
+                {'error': 'Solo puedes eliminar tus valoraciones'}, 
+                status=status.HTTP_403_FORBIDDEN
+            )
+
+        rating.delete()
+        return Response({'message': 'Valoración eliminada'}, status=status.HTTP_204_NO_CONTENT)
+
+    except Exception as e:
+        return Response({'error': 'Valoración no encontrada'}, status=status.HTTP_404_NOT_FOUND)
+
+
+@api_view(['GET'])
+@permission_classes([AllowAny])
+def get_rating_details(request, rating_id):
+    try:
+        rating = Rating.objects.get(id=rating_id)
+        serializer = RatingSerializer(rating)
+        return Response(serializer.data, status=status.HTTP_200_OK)
+
+    except Exception as e:
+        return Response({'error': 'Valoración no encontrada'}, status=status.HTTP_404_NOT_FOUND)
+
+
+@api_view(['GET'])
+@permission_classes([IsPhysiotherapist])
+def check_if_physio_has_rated(request):
+    try:
+        has_rated = Rating.objects.filter(physiotherapist=request.user.physio).exists()
+        return Response({'has_rated': has_rated}, status=status.HTTP_200_OK)
+    except Exception as e:
+        return Response({'error': 'Solicitud incorrecta'}, status=status.HTTP_400_BAD_REQUEST)
+
+
+@api_view(['GET'])
+@permission_classes([IsAuthenticated, IsPhysiotherapist])
+def get_my_rating(request):
+    try:
+        rating = Rating.objects.filter(physiotherapist=request.user.physio).first()
+        if rating:
+            serializer = RatingSerializer(rating)
+            return Response(serializer.data, status=status.HTTP_200_OK)
+        else:
+            return Response(None, status=status.HTTP_204_NO_CONTENT)
+    except Exception as e:
+        return Response({'error': 'Solicitud incorrecta'}, status=status.HTTP_400_BAD_REQUEST)
diff --git a/backend/fisio_find/users/emailUtils.py b/backend/fisio_find/users/emailUtils.py
new file mode 100644
index 00000000..65efe582
--- /dev/null
+++ b/backend/fisio_find/users/emailUtils.py
@@ -0,0 +1,101 @@
+from appointment.models import Appointment
+from django.core.mail import EmailMessage
+from django.core import signing
+from email_validator import validate_email, EmailNotValidError
+import logging
+from datetime import timedelta
+from django.utils import timezone
+import uuid
+
+logger = logging.getLogger(__name__)
+
+def is_deliverable_email(email):
+    """
+    Checks if an email is valid and deliverable.
+    """
+    try:
+        validation = validate_email(email, check_deliverability=True)
+        return True, validation.normalized
+    except EmailNotValidError as e:
+        logger.warning(f"Invalid email address: {email}, Error: {str(e)}")
+        return False, None
+
+def send_email(subject, message, recipient_email):
+    """
+    Sends an email after validating the recipient's email address.
+    """
+    try:
+        email = EmailMessage(
+            subject=subject,
+            body=message,
+            from_email="noreply@fisiofind.com",
+            to=[recipient_email],
+        )
+        email.content_subtype = "html"  # Usa HTML para un diseño profesional
+        email.send()
+        logger.info(f"Email sent successfully to {recipient_email}")
+        return True
+    except Exception as e:
+        logger.error(f"Failed to send email to {recipient_email}: {str(e)}")
+        return False
+
+def send_registration_confirmation_email(user_id, email, first_name):
+    """
+    Envía un correo de confirmación de registro al usuario.
+    """
+    try:
+        # Validar el email
+        is_email_valid, normalized_email = is_deliverable_email(email)
+        if not is_email_valid:
+            logger.error(f"Invalid email for user registration: {email}")
+            return False
+
+        # Generar un token único y seguro con expiración
+        token = signing.dumps({
+            'user_id': user_id,
+            'email': email,
+            'timestamp': timezone.now().isoformat()
+        }, salt='registration-confirm', compress=True)
+
+        # Configurar el enlace de confirmación con expiración (por ejemplo, 24 horas)
+        frontend_domain = "http://localhost:3000"
+        confirmation_link = f"{frontend_domain}/register/verified/{token}"
+
+        # Crear el contenido del correo en HTML profesional
+        subject = "✅ Confirma tu registro en FisioFind"
+
+        message = f"""
+        <!DOCTYPE html>
+        <html>
+        <head>
+            <meta charset="UTF-8">
+            <title>Confirma tu registro</title>
+        </head>
+        <body style="font-family: Arial, sans-serif; margin: 0; padding: 0; line-height: 1.6;">
+            <div style="max-width: 600px; margin: 0 auto; padding: 20px; background-color: #f5f5f5; border-radius: 8px;">
+                <div style="text-align: center; padding: 20px 0;">
+                    <img src="https://s2.fisiofind.com/static/images/logo.png" alt="FisioFind Logo" style="max-width: 200px;">
+                </div>
+                <h2 style="color: #1E5AAD; text-align: center;">¡Bienvenido, {first_name}!</h2>
+                <p style="color: #333;">Gracias por registrarte en FisioFind. Para activar tu cuenta, por favor haz clic en el botón de abajo:</p>
+                <div style="text-align: center; margin: 30px 0;">
+                    <a href="{confirmation_link}" 
+                       style="display: inline-block; background-color: #00a896; color: white; padding: 15px 30px; text-decoration: none; border-radius: 5px; font-size: 16px;">
+                        Confirmar mi cuenta
+                    </a>
+                </div>
+                <p style="color: #666;">Este enlace expirará en <strong>24 horas</strong>. Si no solicitaste este registro, por favor ignora este correo.</p>
+                <hr style="border: 0; border-top: 1px solid #eee; margin: 20px 0;">
+                <p style="font-size: 12px; color: #999; text-align: center;">Si tienes problemas para hacer clic en el botón, copia y pega este enlace en tu navegador: {confirmation_link}</p>
+                <p style="font-size: 12px; color: #999; text-align: center;">© 2023 FisioFind. Todos los derechos reservados.</p>
+            </div>
+        </body>
+        </html>
+        """
+
+        # Enviar el correo
+        return send_email(subject, message, normalized_email)
+
+    except Exception as e:
+        logger.error(f"Error sending registration confirmation email: {str(e)}")
+        return False
\ No newline at end of file
diff --git a/backend/fisio_find/users/models.py b/backend/fisio_find/users/models.py
index 3d34e6a1..b831db23 100644
--- a/backend/fisio_find/users/models.py
+++ b/backend/fisio_find/users/models.py
@@ -5,6 +5,7 @@
     ('ACTIVE', 'Active'),
     ('BANNED', 'Banned'),
     ('REMOVED', 'Removed'),
+    ('UNVERIFIED', 'Unverified'),
 ]
 
 GENDER_CHOICES = [
@@ -39,7 +40,7 @@ class AppUser(AbstractUser):
     dni = models.CharField(max_length=9, unique=True)
     phone_number = models.CharField(max_length=9)
     postal_code = models.CharField(max_length=5)
-    account_status = models.CharField(max_length=10, choices=ACCOUNT_STATUS_CHOICES, default='ACTIVE')
+    account_status = models.CharField(max_length=10, choices=ACCOUNT_STATUS_CHOICES, default='UNVERIFIED')
 
     def __str__(self):
         return f"{self.username} - {self.email}"
diff --git a/backend/fisio_find/users/urls.py b/backend/fisio_find/users/urls.py
index 592135d8..9f9365c8 100644
--- a/backend/fisio_find/users/urls.py
+++ b/backend/fisio_find/users/urls.py
@@ -1,4 +1,4 @@
-from django.urls import path
+from django.urls import path, re_path
 from .views import (
     patient_register_view,
     custom_token_obtain_view,
@@ -14,6 +14,7 @@
     PatientProfileView,
     return_user,
     physio_get_services_view,
+    verify_registration,
 )
 from django.conf import settings
 from django.conf.urls.static import static
@@ -24,6 +25,7 @@
     path('logout/', logout_view, name='logout'),
     path('check-role/', check_role_view, name='check_role'),
     path('physio/register/', physio_register_view, name='physio_register'),
+    re_path(r'^register/verified/(?P<token>.*)/$', verify_registration, name='verify_registration'),
     path('physio/validate/', validate_physio_registration, name='validate_physio_registration'),
     path('physio/payment/', process_payment, name='process_payment'),
     path('physio/update/', physio_update_view, name='physio_update'),
diff --git a/backend/fisio_find/users/views.py b/backend/fisio_find/users/views.py
index 10ee3c43..17e4cccd 100644
--- a/backend/fisio_find/users/views.py
+++ b/backend/fisio_find/users/views.py
@@ -3,29 +3,24 @@
 from rest_framework.response import Response
 from rest_framework import status, generics
 from rest_framework.permissions import AllowAny, IsAuthenticated
+from .serializers import PatientRegisterSerializer, PhysioUpdateSerializer, PhysioRegisterSerializer
+from .serializers import PhysioSerializer, PatientSerializer, AppUserSerializer
+from .models import AppUser, Physiotherapist, Patient, Specialization
 from django.conf import settings
 from django.shortcuts import get_object_or_404
+
 import logging
 import stripe
 import json
 
-from .serializers import (
-    PatientRegisterSerializer,
-    PhysioRegisterSerializer,
-    PhysioSerializer,
-    PatientSerializer,
-    AppUserSerializer,
-    PhysioUpdateSerializer
-    )
-
-from .models import Physiotherapist, Patient, Specialization
-
 from .permissions import (
     IsPatient,
     IsPhysiotherapist,
 )
 
 from users.util import check_service_json
+from .emailUtils import send_registration_confirmation_email  
+from django.core import signing
 
 
 class PatientProfileView(generics.RetrieveAPIView):
@@ -67,11 +62,83 @@ def patch(self, request, *args, **kwargs):
 def patient_register_view(request):
     serializer = PatientRegisterSerializer(data=request.data)
     if serializer.is_valid():
-        serializer.save()
-        return Response({"message": "Paciente registrado correctamente"}, status=status.HTTP_201_CREATED)
+        patient = serializer.save()
+
+        user_id = patient.id 
+        email = patient.user.email 
+        first_name = patient.user.first_name
+
+        email_sent = send_registration_confirmation_email(user_id, email, first_name)
+
+        if email_sent:
+            return Response({
+                "message": "Paciente registrado correctamente. Revisa tu correo para confirmar tu cuenta."
+            }, status=status.HTTP_201_CREATED)
+        else:
+            return Response({
+                "message": "Paciente registrado correctamente, pero hubo un problema al enviar el correo de confirmación. Por favor, contacta soporte.",
+                "warning": "No se pudo enviar el correo de confirmación."
+            }, status=status.HTTP_201_CREATED)
+
     return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST)
 
 
+def verify_user_and_update_status(user_id):
+    """
+    Función para cambiar el estado del usuario a verified y devolver una Response.
+    """
+    try:
+        user = AppUser.objects.get(id=user_id)  
+        user.account_status = 'ACTIVE'
+        user.save()
+        return Response({
+            "message": "Usuario verificado exitosamente.",
+            "status": "success"
+        }, status=status.HTTP_200_OK)
+    except Patient.DoesNotExist:
+        return Response({
+            "error": f"No se encontró el usuario con ID {user_id}.",
+            "status": "error"
+        }, status=status.HTTP_404_NOT_FOUND)
+    except Exception as e:
+        return Response({
+            "error": "Ocurrió un error al verificar el usuario.",
+            "status": "error"
+        }, status=status.HTTP_500_INTERNAL_SERVER_ERROR)
+
+
+@api_view(['GET'])
+@permission_classes([AllowAny])
+def verify_registration(request, token):
+    """
+    Vista para verificar el token de registro y actualizar el estado del usuario.
+    Esta vista recibe una solicitud GET con el token en la URL y devuelve una respuesta JSON.
+    """
+    try:
+        data = signing.loads(token, salt='registration-confirm', max_age=86400)
+        user_id = data['user_id']
+        response = verify_user_and_update_status(user_id)
+        return response
+
+    except signing.SignatureExpired:
+        return Response({
+            "error": "El enlace de confirmación ha expirado. Por favor, solicita un nuevo enlace.",
+            "status": "error"
+        }, status=status.HTTP_400_BAD_REQUEST)
+
+    except signing.BadSignature:
+        return Response({
+            "error": "Enlace de confirmación inválido. Por favor, verifica el enlace o solicita uno nuevo.",
+            "status": "error"
+        }, status=status.HTTP_400_BAD_REQUEST)
+
+    except Exception as e:
+        return Response({
+            "error": "Ocurrió un error al procesar tu solicitud. Inténtalo de nuevo más tarde.",
+            "status": "error"
+        }, status=status.HTTP_500_INTERNAL_SERVER_ERROR)
+
+
 @api_view(['POST'])
 @permission_classes([AllowAny])
 def custom_token_obtain_view(request):
@@ -138,8 +205,9 @@ def validate_physio_registration(request):
 def physio_register_view(request):
     serializer = PhysioRegisterSerializer(data=request.data)
     if serializer.is_valid():
-
-        serializer.save()
+        pyshio = serializer.save()
+        user_id = pyshio.id
+        verify_user_and_update_status(user_id)
         return Response({"message": "Fisioteraputa registrado correctamente"}, status=status.HTTP_201_CREATED)
     return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST)
 
diff --git a/frontend/app/page.tsx b/frontend/app/page.tsx
index afbe7c4d..c352abf4 100644
--- a/frontend/app/page.tsx
+++ b/frontend/app/page.tsx
@@ -11,9 +11,9 @@ import { getApiBaseUrl } from "@/utils/api";
 import { useAppointment } from "@/context/appointmentContext";
 import DraftModal from "@/components/ui/draftAppointmentModal";
 import { DemoWindow } from "@/components/demo-window";
-import { WavyBackground } from "@/components/ui/wavy-background";
 import { GradientButton } from "@/components/ui/gradient-button";
 import { CookieConsent } from "@/components/CookieConsent";
+import TopRatings from "@/components/ratings";
 
 interface Physiotherapist {
   id: string;
@@ -38,7 +38,6 @@ const Home = () => {
   const apiBaseurl = getApiBaseUrl();
   const [showDraftModal, setShowDraftModal] = useState(false);
   const [draftData, setDraftData] = useState<any>(null);
-  const [returnUrl, setReturnUrl] = useState<string | null>(null);
   const { dispatch } = useAppointment();
 
   useEffect(() => {
@@ -64,7 +63,6 @@ const Home = () => {
   }, [isClient, token]);
 
   // Efecto para mover imágenes flotantes al hacer scroll
-  // Modify the floating images styles
   useEffect(() => {
     const handleScroll = () => {
       const scrollY = window.scrollY;
@@ -384,10 +382,10 @@ const Home = () => {
 
       {/* Top Physiotherapists Section */}
       <section className="max-w-7xl mx-auto px-4 mb-12">
-        <h2 className="text-3xl text-[#253240] font-bold mb-8 text-center">
+        {/* <h2 className="text-3xl text-[#253240] font-bold mb-8 text-center">
           Top Fisioterapeutas
         </h2>
-        {/* <div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
+        <div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
           {topPhysiotherapists.map((physio, index) => (
             <CardContainer key={index}>
               <CardBody className="bg-gradient-to-bl from-white to-[#65C2C9]/50 relative group/card dark:hover:shadow-2xl dark:hover:shadow-blue-500/[0.1] dark:bg-black dark:border-white/[0.2] border-black/[0.1] w-full h-auto rounded-xl p-6 border">
@@ -434,6 +432,14 @@ const Home = () => {
         </div> */}
       </section>
 
+      {/* Top Ratings Section */}
+      <section className="max-w-7xl mx-auto px-4 mb-12">
+        <h2 className="text-3xl text-[#253240] font-bold mb-8 text-center">
+          La opinión de fisioterapeutas profesionales
+        </h2>
+        <TopRatings />
+      </section>
+
       {/* Footer */}
       <footer className="py-12 px-4">
         <div className="max-w-6xl mx-auto grid grid-cols-1 md:grid-cols-3 pt-8 gap-8 border-t border-gray-700 text-center">
diff --git a/frontend/app/physio-management/profile/page.tsx b/frontend/app/physio-management/profile/page.tsx
index f6e90c0a..98c9160b 100644
--- a/frontend/app/physio-management/profile/page.tsx
+++ b/frontend/app/physio-management/profile/page.tsx
@@ -6,10 +6,12 @@ import { Camera, Plus, Trash2, Edit, Save, StarIcon, Film } from 'lucide-react';
 import ScheduleCalendar from "@/components/ui/ScheduleCalendar";
 import { getApiBaseUrl } from "@/utils/api";
 import { GradientButton } from "@/components/ui/gradient-button";
-import Link from "next/link"; 
+import Link from "next/link";
+import styles from '@/components/ratings.module.css';
 import Alert from "@/components/ui/Alert";
 import ConfirmModal from "@/components/ui/ConfirmModal";
 
+
 const getAuthToken = () => {
     return localStorage.getItem("token"); // Obtiene el token JWT
 };
@@ -125,6 +127,13 @@ const FisioProfile = () => {
         serviceIndex: null
     });
 
+    const [hasRated, setHasRated] = useState<boolean>(false);
+    const [rating, setRating] = useState<{ id: number; punctuation: number; opinion: string } | null>(null);
+    const [showRatingForm, setShowRatingForm] = useState<boolean>(false);
+    const [newRating, setNewRating] = useState<{ punctuation: number; opinion: string }>({ punctuation: 5, opinion: "" });
+
+    const [confirmRatingDelete, setConfirmRatingDelete] = useState<boolean>(false);
+
     useEffect(() => {
         setIsClient(true);
     }, []);
@@ -142,6 +151,7 @@ const FisioProfile = () => {
 
     useEffect(() => {
         fetchFisioProfile();
+        checkIfPhysioHasRated();
         return () => {
             if (profile.user.photo && profile.user.photo.startsWith('blob:')) {
                 URL.revokeObjectURL(profile.user.photo);
@@ -149,6 +159,31 @@ const FisioProfile = () => {
         };
     }, [token, isClient]);
 
+    useEffect(() => {
+        const fetchRating = async () => {
+            if (!token) return;
+
+            try {
+                const response = await axios.get(`${getApiBaseUrl()}/api/ratings/my-rating/`, {
+                    headers: { Authorization: `Bearer ${token}` },
+                });
+
+                if (response.data) {
+                    setHasRated(true);
+                    setRating(response.data);
+                } else {
+                    setHasRated(false);
+                }
+            } catch (error) {
+                console.error("Error fetching rating:", error);
+            }
+        };
+
+        if (isClient && token) {
+            fetchRating();
+        }
+    }, [token, isClient]);
+
     const fetchFisioProfile = async () => {
         if (isClient) {
             try {
@@ -348,8 +383,110 @@ const FisioProfile = () => {
         }
     };
 
+    const handleSubmitRating = async () => {
+        try {
+            if (!newRating.opinion.trim()) {
+                showAlert("error", "Por favor, proporciona una opinión.");
+                return;
+            }
+
+            if (newRating.opinion.trim().length > 140) {
+                showAlert("error", "La opinión no puede exceder los 140 caracteres.");
+                return;
+            }
+
+            const payload = {
+                punctuation: newRating.punctuation,
+                opinion: newRating.opinion,
+                physiotherapist: profile.user.user_id,
+            };
+
+            if (hasRated && rating) {
+                await axios.put(`${getApiBaseUrl()}/api/ratings/update/${rating.id}/`, payload, {
+                    headers: { Authorization: `Bearer ${token}` },
+                });
+            } else {
+                await axios.post(`${getApiBaseUrl()}/api/ratings/create/`, payload, {
+                    headers: { Authorization: `Bearer ${token}` },
+                });
+            }
+
+            setShowRatingForm(false);
+            setHasRated(true);
+        
+            if (newRating.punctuation >= 3) {
+                showAlert("success", "¡Gracias por valorar nuestra app!");
+            } else {
+                showAlert("success", "Valoración enviada correctamente.");
+            }
+                        
+            // Reload the page after successful submission
+            window.location.reload();
+        } catch (error) {
+            console.error("Error submitting rating:", error);
+            if (axios.isAxiosError(error) && error.response) {
+                showAlert("error", `Error: ${JSON.stringify(error.response.data)}`);
+            } else {
+                showAlert("error", "Error al enviar la valoración. Por favor, inténtalo de nuevo más tarde.");
+            }
+        }
+    };
+
+    const handleDeleteRating = async () => {
+        if (!rating) return;
+        setConfirmRatingDelete(true);
+    };
+
+    const handleConfirmRatingDelete = async () => {
+        try {
+            await axios.delete(`${getApiBaseUrl()}/api/ratings/delete/${rating.id}/`, {
+                headers: { Authorization: `Bearer ${token}` },
+            });
+            showAlert("success", "Valoración eliminada correctamente.");
+            setHasRated(false);
+            setRating(null);
+            fetchFisioProfile();
+        } catch (error) {
+            console.error("Error deleting rating:", error);
+            if (axios.isAxiosError(error) && error.response) {
+                showAlert("error", `Error: ${JSON.stringify(error.response.data)}`);
+            } else {
+                showAlert("error", "Error al eliminar la valoración. Por favor, inténtalo de nuevo más tarde.");
+            }
+        } finally {
+            setConfirmRatingDelete(false);
+        }
+    };
 
+    const checkIfPhysioHasRated = async () => {
+      const token = getAuthToken();
+      if (!token) {
+        setHasRated(false);
+        return;
+      }
+
+      try {
+        const response = await axios.get(`${getApiBaseUrl()}/api/ratings/has-rated/`, {
+          headers: {
+            Authorization: `Bearer ${token}`,
+          },
+        });
 
+        if (response.data && response.data.has_rated) {
+          setHasRated(true);
+        } else {
+          setHasRated(false);
+        }
+      } catch (err) {
+        setHasRated(false);
+        if (axios.isAxiosError(err) && err.response) {
+            showAlert("error", `Error: ${JSON.stringify(err.response.data)}`);
+        } else {
+            showAlert("error", "Error al comprobar si ya has valorado la aplicación.");
+        }
+      }
+    };
+    
 
     const getScheduleSummary = () => {
         const daysOfWeek = {
@@ -1003,6 +1140,26 @@ const FisioProfile = () => {
     };
 
 
+    const renderStars = (punctuation: number) => {
+        const stars = [];
+        for (let i = 0; i < 5; i++) {
+          stars.push(
+            <svg 
+              key={i} 
+              className={styles.star}
+              fill={i < punctuation ? "currentColor" : "none"} 
+              stroke={i < punctuation ? "none" : "currentColor"}
+              viewBox="0 0 20 20" 
+              xmlns="http://www.w3.org/2000/svg"
+              width="20"
+              height="20"
+            >
+              <path d="M9.049 2.927c.3-.921 1.603-.921 1.902 0l1.07 3.292a1 1 0 00.95.69h3.462c.969 0 1.371 1.24.588 1.81l-2.8 2.034a1 1 0 00-.364 1.118l1.07 3.292c.3.921-.755 1.688-1.54 1.118l-2.8-2.034a1 1 0 00-1.175 0l-2.8 2.034c-.784.57-1.838-.197-1.539-1.118l1.07-3.292a1 1 0 00-.364-1.118L2.98 8.72c-.783-.57-.38-1.81.588-1.81h3.461a1 1 0 00.951-.69l1.07-3.292z"></path>
+            </svg>
+          );
+        }
+        return stars;
+      };
 
     if (loading) {
         return (
@@ -1071,6 +1228,97 @@ const FisioProfile = () => {
                             Editar Horario
                         </GradientButton>
                     </div>
+
+                    {/* Sección de valoración */}
+                    <div className="space-y-4 mt-8">
+                        <h3 className="text-lg font-semibold">Tu valoración de la App</h3>
+                        {hasRated && rating ? (
+                        <div className="border rounded-2xl p-4">
+                            <div className="flex justify-between items-center">
+                                <div>
+                                    <p className="font-semibold">Tu valoración:</p>
+                                    <div className={styles.stars}>
+                                        {renderStars(rating.punctuation)}
+                                    </div>
+                                    <p className="text-sm text-white">
+                                        {rating.opinion.split(' ')[0].length > 10
+                                            ? rating.opinion.slice(0, 10).concat("...")
+                                            : rating.opinion.split(' ').slice(0, 5).join(' ').concat("...")}
+                                    </p>
+                                </div>
+                                <div className="flex space-x-2">
+                                    <GradientButton
+                                        variant="edit"
+                                        onClick={() => {
+                                            setNewRating({ punctuation: rating.punctuation, opinion: rating.opinion });
+                                            setShowRatingForm(true);
+                                        }}
+                                    >
+                                        <Edit className="w-4 h-4" />
+                                    </GradientButton>
+                                    <GradientButton
+                                        variant="danger"
+                                        onClick={handleDeleteRating}
+                                    >
+                                        <Trash2 className="w-4 h-4" />
+                                    </GradientButton>
+                                </div>
+                            </div>
+                        </div>
+                        ) : (
+                        <div className={styles.rateButtonContainer}>
+                          <GradientButton 
+                            onClick={() => setShowRatingForm(true)}
+                            className="my-4 mx-2"
+                          >
+                             <span className="mx-2">¿Te gusta nuestra app? ¡Valóranos!</span>
+                          </GradientButton>
+                        </div>
+                        )}
+
+                        {showRatingForm && (
+                            <div className="border rounded-2xl p-4">
+                                <h4 className="font-semibold mb-2">{hasRated ? "Editar valoración" : "Nueva valoración"}</h4>
+                                <div className="flex items-center mb-2">
+                                    {[1, 2, 3, 4, 5].map((star) => (
+                                        <svg
+                                            key={star}
+                                            onClick={() => setNewRating((prev) => ({ ...prev, punctuation: star }))}
+                                            className={`w-6 h-6 cursor-pointer ${star <= newRating.punctuation ? "text-[#22C55E]" : "text-gray-300"}`}
+                                            fill="currentColor"
+                                            viewBox="0 0 20 20"
+                                            xmlns="http://www.w3.org/2000/svg"
+                                        >
+                                            <path d="M9.049 2.927c.3-.921 1.603-.921 1.902 0l1.07 3.292a1 1 0 00.95.69h3.462c.969 0 1.371 1.24.588 1.81l-2.8 2.034a1 1 0 00-.364 1.118l1.07 3.292c.3.921-.755 1.688-1.54 1.118l-2.8-2.034a1 1 0 00-1.175 0l-2.8 2.034c-.784.57-1.838-.197-1.539-1.118l1.07-3.292a1 1 0 00-.364-1.118L2.98 8.72c-.783-.57-.38-1.81.588-1.81h3.461a1 1 0 00.951-.69l1.07-3.292z"></path>
+                                        </svg>
+                                    ))}
+                                </div>
+                                <textarea
+                                    className={`w-full border rounded-md p-2 text-gray-800`}
+                                    placeholder="Escribe tu opinión..."
+                                    value={newRating.opinion}
+                                    onChange={(e) => setNewRating((prev) => ({ ...prev, opinion: e.target.value }))}
+                                />
+                                <div className="flex justify-end space-x-2 mt-2">
+                                    <GradientButton
+                                        variant="grey"
+                                        onClick={() => setShowRatingForm(false)}
+                                    >
+                                        Cancelar
+                                    </GradientButton>
+                                    <GradientButton
+                                        variant="edit"
+                                        onClick={async () => {
+                                            await handleSubmitRating();
+                                            fetchFisioProfile(); // Refresh the page data after submitting the form
+                                        }}
+                                    >
+                                        Guardar
+                                    </GradientButton>
+                                </div>
+                            </div>
+                        )}
+                    </div>
                 </div>
 
                 {/* Contenido derecho - Sección de formulario */}
@@ -1237,28 +1485,29 @@ const FisioProfile = () => {
                                 </div>
                             )}
                         </div>
-                        <div>
 
-                        <GradientButton
-                            variant="edit"
-                            className="mt-8 w-full py-4 px-6 bg-gradient-to-r from-[#1E5ACD] to-[#3a6fd8] text-white font-semibold rounded-xl transition-all duration-200 transform hover:-translate-y-0.5 flex items-center justify-center"
-                        >
-                            <Save size={18} className="mr-2" />
-                            Actualizar Perfil
-                        </GradientButton>
-                        <Link href="/physio-management/video" passHref>
+                        <div>
                             <GradientButton
                                 variant="edit"
-                                className="w-full py-2 px-4 bg-gradient-to-r from-[#1E5ACD] to-[#3a6fd8] text-white font-semibold rounded-xl transition-all duration-200 transform hover:-translate-y-0.5 flex items-center justify-center"
+                                className="mt-8 w-full py-4 px-6 bg-gradient-to-r from-[#1E5ACD] to-[#3a6fd8] text-white font-semibold rounded-xl transition-all duration-200 transform hover:-translate-y-0.5 flex items-center justify-center"
                             >
-                                <Film size={22} className="mr-2" />
-                                Subir vídeo
+                                <Save size={18} className="mr-2" />
+                                Actualizar Perfil
                             </GradientButton>
-                        </Link>
+                            <Link href="/physio-management/video" passHref>
+                                <GradientButton
+                                    variant="edit"
+                                    className="w-full py-2 px-4 bg-gradient-to-r from-[#1E5ACD] to-[#3a6fd8] text-white font-semibold rounded-xl transition-all duration-200 transform hover:-translate-y-0.5 flex items-center justify-center"
+                                >
+                                    <Film size={22} className="mr-2" />
+                                    Subir vídeo
+                                </GradientButton>
+                            </Link>
                         </div>
                     </form>
                 </div>
 
+
                 {/* Modal para añadir/editar servicios */}
                 {showServiceModal && (
                     <ServiceModal
@@ -1314,12 +1563,19 @@ const FisioProfile = () => {
                     </div>
                 )}
             </div>
+
             <ConfirmModal
             isOpen={confirmModal.isOpen}
             message="¿Estás seguro de que deseas eliminar este servicio?"
             onConfirm={handleConfirmDelete}
             onCancel={() => setConfirmModal({ isOpen: false, serviceIndex: null })}
             />
+            <ConfirmModal
+                isOpen={confirmRatingDelete}
+                message="¿Estás seguro de que deseas eliminar tu valoración de la app?"
+                onConfirm={handleConfirmRatingDelete}
+                onCancel={() => setConfirmRatingDelete(false)}
+            />
             {alert.show && (
             <Alert
                 type={alert.type}
@@ -1327,8 +1583,9 @@ const FisioProfile = () => {
                 onClose={() => setAlert({ ...alert, show: false })}
                 duration={5000}
             />
-        )}
-        </div>
+            )}
+
+      </div>
     );
 };
 
diff --git a/frontend/app/register/verified/[token]/page.tsx b/frontend/app/register/verified/[token]/page.tsx
new file mode 100644
index 00000000..de6b08d7
--- /dev/null
+++ b/frontend/app/register/verified/[token]/page.tsx
@@ -0,0 +1,123 @@
+"use client";
+
+import React, { useEffect, useState } from "react";
+import { useRouter, useParams } from "next/navigation";
+import axios from "axios";
+import { getApiBaseUrl } from "@/utils/api";
+import Image from "next/image";
+
+const VerifyEmailPage = () => {
+  const router = useRouter();
+  const params = useParams();
+  const token = params.token as string; 
+
+  const [verificationStatus, setVerificationStatus] = useState<string | null>(
+    null
+  );
+  const [error, setError] = useState<string | null>(null);
+
+  useEffect(() => {
+    if (!token) {
+      setError("No se proporcionó un token de verificación.");
+      return;
+    }
+
+    const verifyEmail = async () => {
+      try {
+        const response = await axios.get(
+          `${getApiBaseUrl()}/api/app_user/register/verified/${token}/`
+        );
+
+        if (response.status === 200) {
+          setVerificationStatus(
+            "Se te ha verificado el email correctamente. ¡Ahora puedes iniciar sesión!"
+          );
+
+          // Redirige al login después de 3 segundos
+          setTimeout(() => {
+            router.push("/login");
+          }, 3000);
+        }
+      } catch (error) {
+        if (axios.isAxiosError(error)) {
+          const responseData = error.response?.data;
+          if (responseData?.error) {
+            setError(responseData.error);
+          } else {
+            setError("Ocurrió un error al verificar tu email. Intenta de nuevo.");
+          }
+        } else {
+          setError("Ocurrió un error inesperado. Intenta de nuevo.");
+        }
+      }
+    };
+
+    verifyEmail();
+  }, [token, router]);
+
+  if (verificationStatus) {
+    return (
+      <div className="min-h-screen bg-gradient-to-br from-blue-50 to-white dark:from-neutral-900 dark:to-black py-8 flex items-center justify-center">
+        <div className="bg-white dark:bg-black rounded-xl shadow-xl p-6 max-w-md w-full text-center">
+          <Image
+            src="/static/fisio_find_logo.webp"
+            alt="Fisio Find Logo"
+            width={120}
+            height={120}
+            className="mx-auto mb-4"
+          />
+          <h1 className="text-2xl font-bold text-[#1E5ACD] mb-4">
+            Verificación Exitosa
+          </h1>
+          <p className="text-gray-600 dark:text-gray-400 mb-6">
+            {verificationStatus}
+          </p>
+          <p className="text-gray-500 dark:text-gray-400">
+            Redirigiendo al inicio de sesión en unos segundos...
+          </p>
+        </div>
+      </div>
+    );
+  }
+
+  if (error) {
+    return (
+      <div className="min-h-screen bg-gradient-to-br from-blue-50 to-white dark:from-neutral-900 dark:to-black py-8 flex items-center justify-center">
+        <div className="bg-white dark:bg-black rounded-xl shadow-xl p-6 max-w-md w-full text-center">
+          <Image
+            src="/static/fisio_find_logo.webp"
+            alt="Fisio Find Logo"
+            width={120}
+            height={120}
+            className="mx-auto mb-4"
+          />
+          <h1 className="text-2xl font-bold text-red-600 mb-4">Error</h1>
+          <p className="text-gray-600 dark:text-gray-400 mb-6">{error}</p>
+          <button
+            onClick={() => router.push("/register")}
+            className="px-6 py-2 bg-[#1E5ACD] text-white font-medium rounded-xl hover:bg-[#0A7487] transition-colors"
+          >
+            Volver al registro
+          </button>
+        </div>
+      </div>
+    );
+  }
+
+  return (
+    <div className="min-h-screen bg-gradient-to-br from-blue-50 to-white dark:from-neutral-900 dark:to-black py-8 flex items-center justify-center">
+      <div className="bg-white dark:bg-black rounded-xl shadow-xl p-6 max-w-md w-full text-center">
+        <Image
+          src="/static/fisio_find_logo.webp"
+          alt="Fisio Find Logo"
+          width={120}
+          height={120}
+          className="mx-auto mb-4"
+        />
+        <p className="text-gray-600 dark:text-gray-400">Verificando tu email...</p>
+      </div>
+    </div>
+  );
+};
+
+export default VerifyEmailPage;
\ No newline at end of file
diff --git a/frontend/components/ratings.module.css b/frontend/components/ratings.module.css
new file mode 100644
index 00000000..76caf714
--- /dev/null
+++ b/frontend/components/ratings.module.css
@@ -0,0 +1,210 @@
+.ratingsContainer {
+  padding: 2rem 0;
+  width: 100%;
+}
+
+.ratingsTitle {
+  text-align: center;
+  margin-bottom: 2rem;
+  font-size: 2rem;
+  font-weight: 600;
+  color: #333;
+}
+
+.ratingsGrid {
+  display: grid;
+  grid-template-columns: repeat(auto-fill, minmax(300px, 1fr));
+  gap: 2rem;
+  justify-items: center;
+}
+
+.card {
+  background-color: rgba(243, 244, 246, 1);
+  padding: 2rem;
+  max-width: 320px;
+  width: 100%;
+  border-radius: 10px;
+  box-shadow: 0 20px 30px -20px rgba(5, 5, 5, 0.24);
+  transition: transform 0.3s, box-shadow 0.3s;
+}
+
+.cardHover:hover {
+  transform: scale(1.05);
+  box-shadow: 0 8px 15px rgba(0, 0, 0, 0.2);
+}
+
+.header {
+  display: flex;
+  align-items: center;
+  gap: 1rem;
+}
+
+.image {
+  height: 4rem;
+  width: 4rem;
+  border-radius: 9999px;
+  overflow: hidden;
+  background-color: royalblue;
+  display: flex;
+  align-items: center;
+  justify-content: center;
+}
+
+.profileImage {
+  width: 100%;
+  height: 100%;
+  object-fit: cover;
+}
+
+.stars {
+  display: flex;
+  justify-content: flex-start;
+  gap: 0.125rem;
+  color: rgba(34, 197, 94, 1);
+}
+
+.stars svg {
+  height: 1rem;
+  width: 1rem;
+}
+
+.name {
+  margin-top: 0.25rem;
+  font-size: 1.125rem;
+  line-height: 1.75rem;
+  font-weight: 600;
+  color: rgba(55, 65, 81, 1);
+}
+
+.message {
+  overflow: hidden;
+  display: -webkit-box;
+  -webkit-box-orient: vertical;
+  -webkit-line-clamp: 3;
+  margin-top: 1rem;
+  color: rgba(107, 114, 128, 1);
+}
+
+.ratingDate {
+  margin-top: 0.75rem;
+  font-size: 0.875rem;
+  color: rgba(107, 114, 128, 0.8);
+  text-align: right;
+}
+
+/* New styles for rating button and form */
+.rateButtonContainer {
+  display: flex;
+  justify-content: center;
+  margin-bottom: 2rem;
+}
+
+.rateButton {
+  background-color: #1E5ACD;
+  color: white;
+  padding: 0.75rem 1.5rem;
+  border-radius: 0.5rem;
+  font-weight: 600;
+  border: none;
+  cursor: pointer;
+  transition: background-color 0.2s;
+}
+
+.rateButton:hover {
+  background-color: #1E5ACD;
+}
+
+.ratingForm {
+  max-width: 500px;
+  margin: 0 auto 2rem;
+  padding: 1.5rem;
+  background-color: white;
+  border-radius: 0.5rem;
+  box-shadow: 0 4px 6px -1px 253240, 0 2px 4px -1px rgba(0, 0, 0, 0.06);
+}
+
+.ratingForm h3 {
+  text-align: center;
+  margin-bottom: 1rem;
+  font-size: 1.25rem;
+  font-weight: 600;
+  color: #253240;
+}
+
+.starsContainer {
+  display: flex;
+  justify-content: center;
+  gap: 0.5rem;
+  margin-bottom: 1rem;
+}
+
+.interactiveStar {
+  height: 2rem;
+  width: 2rem;
+  color: #05AC9C;
+  cursor: pointer;
+  transition: transform 0.1s;
+}
+
+.interactiveStar:hover {
+  transform: scale(1.2);
+}
+
+.opinionInput {
+  width: 100%;
+  min-height: 100px;
+  padding: 0.75rem;
+  border: 1px solid #e5e7eb;
+  border-radius: 0.375rem;
+  margin-bottom: 1rem;
+  font-family: inherit;
+  resize: vertical;
+}
+
+.formButtons {
+  display: flex;
+  justify-content: flex-end;
+  gap: 0.75rem;
+}
+
+.cancelButton {
+  padding: 0.5rem 1rem;
+  border-radius: 0.375rem;
+  background-color: #f3f4f6;
+  color: #4b5563;
+  border: 1px solid #e5e7eb;
+  cursor: pointer;
+}
+
+.submitButton {
+  padding: 0.5rem 1rem;
+  border-radius: 0.375rem;
+  background-color: #1E5ACD;
+  color: white;
+  border: none;
+  cursor: pointer;
+}
+
+.submitButton:hover {
+  background-color: #1E5ACD;
+}
+
+.confirmationMessage {
+  background-color: #d4edda;
+  color: #155724;
+  padding: 10px;
+  border-radius: 5px;
+  margin-bottom: 15px;
+  text-align: center;
+  font-weight: bold;
+}
+
+@media (max-width: 768px) {
+  .ratingsGrid {
+    grid-template-columns: 1fr;
+  }
+  
+  .card {
+    max-width: 100%;
+  }
+}
\ No newline at end of file
diff --git a/frontend/components/ratings.tsx b/frontend/components/ratings.tsx
new file mode 100644
index 00000000..2f344b64
--- /dev/null
+++ b/frontend/components/ratings.tsx
@@ -0,0 +1,467 @@
+"use client";
+
+import React, { useEffect, useState } from 'react';
+import axios from 'axios';
+import Image from 'next/image';
+import styles from './ratings.module.css';
+import { getApiBaseUrl } from "@/utils/api";
+import { useRouter } from "next/navigation";
+import { GradientButton } from './ui/gradient-button';
+import Alert from "@/components/ui/Alert";
+
+
+interface PhysiotherapistDetails {
+  id: number;  // ID is included in your serializer
+  full_name: string;
+  photo: string;
+}
+
+interface Rating {
+  id: number;
+  physiotherapist: number;
+  punctuation: number;
+  opinion: string;
+  date: string;
+  physiotherapist_details?: PhysiotherapistDetails;
+}
+
+const getAuthToken = () => {
+  if (typeof window !== 'undefined') {
+    return localStorage.getItem("token");
+  }
+  return null;
+};
+
+const TopRatings: React.FC = () => {
+  const router = useRouter();
+  const [ratings, setRatings] = useState<Rating[]>([]);
+  const [loading, setLoading] = useState<boolean>(true);
+  const [error, setError] = useState<string | null>(null);
+  const [isAuthenticated, setIsAuthenticated] = useState<boolean>(false);
+  const [isAuthChecking, setIsAuthChecking] = useState<boolean>(true);
+  const [isPhysio, setIsPhysio] = useState<boolean>(false);
+  const [hasRated, setHasRated] = useState<boolean>(false);
+  const [showRatingForm, setShowRatingForm] = useState<boolean>(false);
+  const [newRating, setNewRating] = useState<{punctuation: number, opinion: string}>({
+    punctuation: 5,
+    opinion: ''
+  });
+  const [confirmationMessage, setConfirmationMessage] = useState<string | null>(null);
+  
+  const apiBaseUrl = getApiBaseUrl();
+
+  const [alertMessage, setAlertMessage] = useState<string | null>(null);
+  const [alertType, setAlertType] = useState<
+    "success" | "error" | "info" | "warning"
+  >("success");
+
+  // Add this function to show alerts
+  const showAlert = (type: "success" | "error" | "info" | "warning", message: string) => {
+    setAlertType(type);
+    setAlertMessage(message);
+    // Auto-dismiss after 5 seconds
+    setTimeout(() => {
+      setAlertMessage(null);
+    }, 5000);
+  };
+
+  // Check if user is authenticated and is a physiotherapist
+  useEffect(() => {
+    const checkAuth = async () => {
+      const token = getAuthToken();
+      
+      if (!token) {
+        setIsAuthenticated(false);
+        setIsAuthChecking(false);
+        return;
+      }
+
+      try {
+        const response = await axios.get(`${apiBaseUrl}/api/app_user/check-role/`, {
+          headers: {
+            Authorization: `Bearer ${token}`,
+          },
+        });
+
+        if (response.data && response.data.user_role === "physiotherapist") {
+          setIsAuthenticated(true);
+          setIsPhysio(true);
+        } else {
+          setIsAuthenticated(true);
+          setIsPhysio(false);
+        }
+      } catch (error) {
+        setIsAuthenticated(false);
+        if (error.response?.status === 401) {
+          if (typeof window !== 'undefined') {
+            localStorage.removeItem("token");
+          }
+        }
+        if (error.response) {
+            showAlert("error",`Error: ${JSON.stringify(error.response.data)}`);
+        } else {
+            showAlert("error","Error al comprobar el rol del usuario.");
+        }
+      } finally {
+        setIsAuthChecking(false);
+      }
+    };
+
+    checkAuth();
+  }, [apiBaseUrl]);
+
+  // Function to check if the current physiotherapist has already rated
+  const checkIfPhysioHasRated = async () => {
+    const token = getAuthToken();
+    if (!token) {
+      setHasRated(false);
+      return;
+    }
+
+    try {
+      const response = await axios.get(`${apiBaseUrl}/api/ratings/has-rated/`, {
+        headers: {
+          Authorization: `Bearer ${token}`,
+        },
+      });
+
+      if (response.data && response.data.has_rated) {
+        setHasRated(true);
+      } else {
+        setHasRated(false);
+      }
+    } catch (err) {
+      setHasRated(false);
+      
+      // Add alert for error handling
+      if (axios.isAxiosError(err) && err.response) {
+        showAlert("error", `Error: ${JSON.stringify(err.response.data)}`);
+      } else {
+        showAlert("error", "Error al comprobar si ya has valorado la aplicación.");
+      }
+    }
+  };
+
+  // Call the function to check if the physio has rated when the component mounts
+  useEffect(() => {
+    if (isAuthenticated && isPhysio) {
+      checkIfPhysioHasRated();
+    }
+  }, [isAuthenticated, isPhysio, apiBaseUrl]);
+
+  // Fetch ratings
+  useEffect(() => {
+    const fetchTopRatings = async () => {
+      try {
+        setLoading(true);
+        const response = await axios.get(`${apiBaseUrl}/api/ratings/list/`);
+        
+        if (!response.data) {
+          throw new Error('No data received from server');
+        }
+                
+        // Process the response and set the ratings
+        const sortedRatings = response.data
+          .sort((a: Rating, b: Rating) => b.punctuation - a.punctuation)
+          .slice(0, 3); // Show only the first 3 ratings
+        setRatings(sortedRatings);
+        
+      } catch (err) {        
+        if (axios.isAxiosError(err) && err.response) {
+          showAlert("error", `Error: ${JSON.stringify(err.response.data)}`);
+        } else {
+          showAlert("error", "Error al cargar las valoraciones. Por favor, inténtalo de nuevo más tarde.");
+        }
+      } finally {
+        setLoading(false);
+      }
+    };
+
+    fetchTopRatings();
+  }, [apiBaseUrl, hasRated]);
+
+  // Helper function to render stars based on punctuation
+  const renderStars = (punctuation: number) => {
+    const stars = [];
+    for (let i = 0; i < 5; i++) {
+      stars.push(
+        <svg 
+          key={i} 
+          className={styles.star}
+          fill={i < punctuation ? "currentColor" : "none"} 
+          stroke={i < punctuation ? "none" : "currentColor"}
+          viewBox="0 0 20 20" 
+          xmlns="http://www.w3.org/2000/svg"
+          width="20"
+          height="20"
+        >
+          <path d="M9.049 2.927c.3-.921 1.603-.921 1.902 0l1.07 3.292a1 1 0 00.95.69h3.462c.969 0 1.371 1.24.588 1.81l-2.8 2.034a1 1 0 00-.364 1.118l1.07 3.292c.3.921-.755 1.688-1.54 1.118l-2.8-2.034a1 1 0 00-1.175 0l-2.8 2.034c-.784.57-1.838-.197-1.539-1.118l1.07-3.292a1 1 0 00-.364-1.118L2.98 8.72c-.783-.57-.38-1.81.588-1.81h3.461a1 1 0 00.951-.69l1.07-3.292z"></path>
+        </svg>
+      );
+    }
+    return stars;
+  };
+
+  // Interactive stars for the rating form
+  const renderInteractiveStars = () => {
+    const stars = [];
+    for (let i = 1; i <= 5; i++) {
+      stars.push(
+        <svg 
+          key={i} 
+          fill={i <= newRating.punctuation ? "currentColor" : "none"} 
+          stroke={i <= newRating.punctuation ? "none" : "currentColor"}
+          viewBox="0 0 20 20" 
+          xmlns="http://www.w3.org/2000/svg"
+          className={styles.interactiveStar}
+          width="24"
+          height="24"
+          onClick={() => setNewRating({...newRating, punctuation: i})}
+        >
+          <path d="M9.049 2.927c.3-.921 1.603-.921 1.902 0l1.07 3.292a1 1 0 00.95.69h3.462c.969 0 1.371 1.24.588 1.81l-2.8 2.034a1 1 0 00-.364 1.118l1.07 3.292c.3.921-.755 1.688-1.54 1.118l-2.8-2.034a1 1 0 00-1.175 0l-2.8 2.034c-.784.57-1.838-.197-1.539-1.118l1.07-3.292a1 1 0 00-.364-1.118L2.98 8.72c-.783-.57-.38-1.81.588-1.81h3.461a1 1 0 00.951-.69l1.07-3.292z"></path>
+        </svg>
+      );
+    }
+    return stars;
+  };
+
+  const handleSubmitRating = async () => {
+    try {
+      const token = getAuthToken();
+      if (!token) {
+        showAlert("error", "Debes iniciar sesión para enviar una valoración.");
+        return;
+      }
+
+      // Make sure the opinion is not empty
+      if (!newRating.opinion.trim()) {
+        showAlert("error", "Por favor, proporciona una opinión.");
+        return;
+      }
+
+      // Check if opinion exceeds 140 characters
+      if (newRating.opinion.length > 140) {
+        showAlert("error", "La opinión debe tener 140 caracteres o menos.");
+        return;
+      }
+
+      // Include the physiotherapist ID in the payload
+      const payload = {
+        ...newRating,
+        physiotherapist: isPhysio ? 1 : null, // Replace `1` with the actual physiotherapist ID if available
+      };
+
+      // Log the payload for debugging
+
+      await axios.post(
+        `${apiBaseUrl}/api/ratings/create/`, 
+        payload,
+        { headers: { Authorization: `Bearer ${token}` } }
+      );
+
+      setHasRated(true);
+      setShowRatingForm(false);
+      setError(null);
+
+      // Show confirmation message for ratings with 3 stars or more
+      if (newRating.punctuation >= 3) {
+        showAlert("success", "¡Gracias por valorar nuestra app!");
+        setTimeout(() => setConfirmationMessage(null), 3000);
+      }
+
+      // Refresh ratings (use a slight delay to ensure the new rating is included)
+      setTimeout(async () => {
+        try {
+          const response = await axios.get(`${apiBaseUrl}/api/ratings/list/`);
+          const sortedRatings = response.data
+            .sort((a: Rating, b: Rating) => b.punctuation - a.punctuation)
+            .slice(0, 3); // Show only the first 3 ratings
+          setRatings(sortedRatings);
+        } catch (err) {
+          if (axios.isAxiosError(err) && err.response) {
+            showAlert("error", `Error: ${JSON.stringify(err.response.data)}`);
+          } else {
+            showAlert("error", "Error al actualizar las valoraciones. Por favor, recarga la página.");
+          }
+        }
+      });
+      
+    } catch (err) {
+      if (axios.isAxiosError(err) && err.response) {
+        // Handle 400 Bad Request specifically
+        if (err.response.status === 400) {
+          const errorData = err.response.data;
+          if (typeof errorData === 'object') {
+            const errorMessages = Object.values(errorData)
+              .flat()
+              .join(', ');
+            showAlert("error", `Error al enviar la valoración: ${errorMessages}`);
+          } else {
+            showAlert("error", "Error al enviar la valoración. Por favor, revisa tu entrada.");
+          }
+        } else {
+          showAlert("error", `Error: ${err.response.status} - ${err.response.statusText}`);
+        }
+      } else {
+        showAlert("error", "Error al enviar la valoración. Por favor, inténtalo de nuevo más tarde.");
+      }
+    }
+  };
+
+  if (loading) {
+    return <div className={styles.loadingContainer}>Cargando valoraciones...</div>;
+  }
+
+  if (error) {
+    return <div className={styles.errorMessage}>{error}</div>;
+  }
+
+  return (
+    <div className={styles.ratingsContainer}>
+    {alertMessage && (
+      <div className="mb-6">
+        <Alert
+          type={alertType}
+          message={alertMessage}
+          onClose={() => setAlertMessage(null)}
+        />
+      </div>
+      )}
+      {/* Confirmation message */}
+      {confirmationMessage && (
+        <div className={styles.confirmationMessage}>
+          {confirmationMessage}
+        </div>
+      )}
+      {/* Show rating button for authenticated physiotherapists who haven't rated yet */}
+      {isAuthenticated && isPhysio && !hasRated && (
+        <div className={styles.rateButtonContainer}>
+          <GradientButton 
+            onClick={() => setShowRatingForm(true)}
+          >
+            ¿Te gusta nuestra app? ¡Valóranos!
+          </GradientButton>
+        </div>
+      )}
+
+      {/* Rating form */}
+      {showRatingForm && (
+        <div className={styles.ratingForm}>
+          <h3>Valora nuestra aplicación</h3>
+          {hasRated && (
+            <p className={styles.errorMessage}>
+              Ya has enviado una valoración. No puedes enviar otra.
+            </p>
+          )}
+          {!hasRated && (
+            <>
+              <div className={styles.starsContainer}>
+                {renderInteractiveStars()}
+              </div>
+              <textarea
+                className={styles.opinionInput}
+                placeholder="Cuéntanos tu experiencia con la app..."
+                value={newRating.opinion}
+                onChange={(e) => setNewRating({...newRating, opinion: e.target.value})}
+              />
+              <div className={styles.formButtons}>
+                <GradientButton 
+                  variant="grey"
+                  onClick={() => setShowRatingForm(false)}
+                >
+                  Cancelar
+                </GradientButton>
+                <GradientButton 
+                  variant="create"
+                  onClick={handleSubmitRating}
+                >
+                  Enviar valoración
+                </GradientButton>
+              </div>
+            </>
+          )}
+        </div>
+      )}
+
+      {ratings.length === 0 ? (
+        <div className={styles.emptyCardContainer} style={{ display: 'flex', justifyContent: 'center', width: '100%' }}>
+          <div className={`${styles.card} ${styles.cardHover}`} style={{ width: '400px' }}>
+            <div className={styles.header}>
+              <div>
+                <div className={styles.stars}>
+                  {[...Array(5)].map((_, i) => (
+                    <svg key={i} fill="currentColor" viewBox="0 0 20 20" xmlns="http://www.w3.org/2000/svg">
+                      <path d="M9.049 2.927c.3-.921 1.603-.921 1.902 0l1.07 3.292a1 1 0 00.95.69h3.462c.969 0 1.371 1.24.588 1.81l-2.8 2.034a1 1 0 00-.364 1.118l1.07 3.292c.3.921-.755 1.688-1.54 1.118l-2.8-2.034a1 1 0 00-1.175 0l-2.8 2.034c-.784.57-1.838-.197-1.539-1.118l1.07-3.292a1 1 0 00-.364-1.118L2.98 8.72c-.783-.57-.38-1.81.588-1.81h3.461a1 1 0 00.951-.69l1.07-3.292z"></path>
+                    </svg>
+                  ))}
+                </div>
+                <p className={styles.name}>No hay valoraciones disponibles</p>
+              </div>
+            </div>
+            <p className={styles.message}>
+              {isAuthenticated && isPhysio ? (
+                <span 
+                  onClick={() => setShowRatingForm(true)} 
+                  className={styles.loginLink}
+                  style={{ color: 'blue', textDecoration: 'underline', cursor: 'pointer' }}
+                >
+                  ¡Sé el primero en valorar nuestra app!
+                </span>
+              ) : (
+                <span>
+                  <span 
+                    onClick={() => router.push('/login')} 
+                    className={styles.loginLink}
+                    style={{ color: 'blue', textDecoration: 'underline', cursor: 'pointer' }}
+                  >
+                    ¡Inicia sesión
+                  </span> como fisioterapeuta y sé el primero en valorarnos!
+                </span>
+              )}
+            </p>
+          </div>
+        </div>
+      ) : (
+        <div className={styles.ratingsGrid} style={{ justifyContent: 'center' }}>
+          {ratings.map((rating) => (
+            <div 
+              key={rating.id} 
+              className={`${styles.card} ${styles.cardHover}`} 
+              style={{ width: '500px', height: 'auto' }} // Increase card width
+            >
+              <div className={styles.header}>
+                <div className={styles.image}>
+                {rating.physiotherapist_details?.photo && (
+                  <Image 
+                    src={rating.physiotherapist_details.photo} 
+                    alt={rating.physiotherapist_details?.full_name || 'Fisioterapeuta anónimo'}
+                    width={100}
+                    height={100}
+                    className={styles.profileImage}
+                    onError={(e) => {
+                      e.currentTarget.src = '/default-avatar.png';
+                    }}
+                    unoptimized
+                  />
+                )}
+                </div>
+                <div>
+                  <div className={styles.stars}>
+                    {renderStars(rating.punctuation)}
+                  </div>
+                  <p className={styles.name}>{rating.physiotherapist_details?.full_name || 'Fisioterapeuta anónimo'}</p>
+                </div>
+              </div>
+              <p className={styles.message}>{rating.opinion || ''}</p>
+              <p className={styles.ratingDate}>
+                {new Date(rating.date).toLocaleDateString()}
+              </p>
+            </div>
+          ))}
+        </div>
+      )}
+    </div>
+  );
+};
+
+export default TopRatings;
\ No newline at end of file
diff --git a/frontend/components/ui/gradient-button.tsx b/frontend/components/ui/gradient-button.tsx
index 4cdac3bd..7ade9b34 100644
--- a/frontend/components/ui/gradient-button.tsx
+++ b/frontend/components/ui/gradient-button.tsx
@@ -4,17 +4,37 @@ import { cn } from "@/lib/utils";
 interface GradientButtonProps extends React.ButtonHTMLAttributes<HTMLButtonElement> {
   variant?: "danger" | "grey" | "create" | "edit";
   fullWidth?: boolean;
+  margin?: "none" | "sm" | "md" | "lg";
+  padding?: "default" | "wide" | "narrow"; // Added padding prop for text
   children: React.ReactNode;
 }
 
 const GradientButton = ({
   variant = "create",
   fullWidth = false,
+  margin = "none",
+  padding = "default", // Default padding
   className,
   children,
   ...props
 }: GradientButtonProps) => {
-  const baseStyles = "py-4 text-white font-semibold rounded-xl transition-all duration-200 transform hover:-translate-y-0.5";
+  // Base styles with smaller text size but keeping rounded-xl corners
+  const baseStyles = "text-white text-sm font-semibold rounded-xl transition-all duration-200 transform hover:-translate-y-0.5";
+  
+  // Margin styles for the button
+  const marginStyles = {
+    none: "",
+    sm: "my-2",
+    md: "my-4",
+    lg: "my-6"
+  };
+  
+  // Padding styles with smaller values for a more compact button
+  const paddingStyles = {
+    narrow: "py-1.5 px-3",
+    default: "py-2 px-4",
+    wide: "py-2 px-8"
+  };
   
   const variantStyles = {
     // Legacy variants (for backward compatibility)
@@ -36,15 +56,12 @@ const GradientButton = ({
     }
   };
 
-  // Handle legacy variant names
-  const mappedVariant = variant === "create" ? "blue" : 
-                        variant === "edit" ? "green" : 
-                        variant;
-
   return (
     <button
       className={cn(
         baseStyles,
+        paddingStyles[padding], // Apply text padding
+        marginStyles[margin],
         fullWidth ? "w-full" : "",
         className
       )}
diff --git a/next.config.js b/next.config.js
index 201b7fa9..51f44541 100644
--- a/next.config.js
+++ b/next.config.js
@@ -1,5 +1,5 @@
 module.exports = {
     images: {
-      domains: ['ui.aceternity.com'],
+      domains: ['ui.aceternity.com', 'fisiofind-repo.fra1.digitaloceanspaces.com'],
     },
   };
\ No newline at end of file


Reviews:
user: login: copilot-pull-request-reviewer[bot]
body: ## Pull Request Overview

This PR adds a ratings feature to the application, enabling physiotherapists to rate their experience. Key changes include UI updates for rating display and form submission on the frontend, integration of new endpoints and models for rating management on the backend, and corresponding URL and admin configuration updates.

### Reviewed Changes

Copilot reviewed 17 out of 20 changed files in this pull request and generated 2 comments.

<details>
<summary>Show a summary per file</summary>

| File | Description |
| ---- | ----------- |
| frontend/components/ui/gradient-button.tsx | Added new margin and padding props and updated base styles, but the variant prop is no longer applied. |
| frontend/app/physio-management/profile/page.tsx | Integrated ratings state management and UI components for submitting, editing, and deleting ratings. |
| frontend/app/page.tsx | Introduced a new section to display top ratings. |
| backend/fisio_find/ratings/views.py | Implemented endpoints for creating, updating, deleting, and fetching ratings. |
| backend/fisio_find/ratings/urls.py | Configured the URL patterns for the new ratings endpoints. |
| backend/fisio_find/ratings/serializers.py | Added serializers to validate and serialize rating data. |
| backend/fisio_find/ratings/models.py | Defined the Rating model with a unique constraint per physiotherapist. |
| backend/fisio_find/ratings/admin.py | Registered the Rating model with custom admin settings. |
| backend/fisio_find/fisio_find/settings.py | Updated settings to include the ratings app and adjusted middleware ordering. |
</details>


<details>
<summary>Files not reviewed (3)</summary>

* **backend/fisio_find/appointment/exampledata.json**: Language not supported
* **frontend/components/ratings.module.css**: Language not supported
* **frontend/package-lock.json**: Language not supported
</details>

<details>
<summary>Comments suppressed due to low confidence (2)</summary>

**frontend/components/ui/gradient-button.tsx:61**
* The 'variant' prop is defined in the component interface but is no longer applied to the button styles, which may break expected styling. Please integrate the variantStyles object into the final className composition.
```
className={cn(baseStyles, paddingStyles[padding], marginStyles[margin], fullWidth ? "w-full" : "", className)}
```
**backend/fisio_find/ratings/tests.py:1**
* No tests have been provided for the ratings functionality. Consider adding tests to cover creating, updating, and deleting ratings to ensure the new features work as expected.
```
from django.test import TestCase
```
</details>


state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-04-03T14:54:04Z
user: login: github-advanced-security[bot]
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-04-03T15:03:24Z

Review comments:
diff_hunk: @@ -0,0 +1,128 @@
+from rest_framework import generics, status
+from rest_framework.decorators import api_view, permission_classes
+from rest_framework.response import Response
+from rest_framework.permissions import IsAuthenticated, AllowAny
+from .models import Rating
+from .serializers import RatingSerializer
+from users.permissions import IsPhysiotherapist
+
+
+class RatingListView(generics.ListAPIView):
+    queryset = Rating.objects.all().order_by('-punctuation')
+    serializer_class = RatingSerializer
+    permission_classes = [AllowAny]
+
+    def get_queryset(self):
+        queryset = super().get_queryset()
+        return queryset
+
+
+@api_view(['POST'])
+@permission_classes([IsAuthenticated, IsPhysiotherapist])
+def create_rating(request):
+    # Log the incoming data for debugging
+    print("Incoming rating data:", request.data)
+
+    # Ensure the physiotherapist field is set to the logged-in user
+    data = request.data.copy()
+    data['physiotherapist'] = request.user.physio.id
+
+    serializer = RatingSerializer(data=data)
+
+    if serializer.is_valid():
+        serializer.save()
+        return Response(serializer.data, status=status.HTTP_201_CREATED)
+
+    # Log validation errors
+    print("Validation errors:", serializer.errors)
+    return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST)
+
+
+@api_view(['PUT', 'PATCH'])
+@permission_classes([IsAuthenticated, IsPhysiotherapist])
+def update_rating(request, rating_id):
+    try:
+        rating = Rating.objects.get(id=rating_id)
+
+        if request.user.physio != rating.physiotherapist and not request.user.is_staff:
+            return Response(
+                {'error': 'You can only edit your own ratings'}, 
+                status=status.HTTP_403_FORBIDDEN
+            )
+
+        # Automatically associate the physiotherapist with the logged-in user
+        data = request.data.copy()
+        data['physiotherapist'] = request.user.physio.id
+
+        # Log incoming data for debugging
+        print("Incoming data for update:", data)
+
+        serializer = RatingSerializer(rating, data=data, partial=True)
+
+        if serializer.is_valid():
+            serializer.save()
+            return Response(serializer.data, status=status.HTTP_200_OK)
+
+        # Log validation errors
+        print("Validation errors:", serializer.errors)
+        return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST)
+
+    except Rating.DoesNotExist:
+        return Response({'error': 'Rating not found'}, status=status.HTTP_404_NOT_FOUND)
+    except Exception as e:
+        return Response({'error': str(e)}, status=status.HTTP_500_INTERNAL_SERVER_ERROR)
+
+
+@api_view(['DELETE'])
+@permission_classes([IsAuthenticated, IsPhysiotherapist])
+def delete_rating(request, rating_id):
+    try:
+        rating = Rating.objects.get(id=rating_id)
+
+        if request.user.physio != rating.physiotherapist and not request.user.is_staff:
+            return Response(
+                {'error': 'You can only delete your own ratings'}, 
+                status=status.HTTP_403_FORBIDDEN
+            )
+
+        rating.delete()
+        return Response({'message': 'Rating deleted successfully'}, status=status.HTTP_204_NO_CONTENT)
+
+    except Exception as e:
+        return Response({'error': 'Rating not found'}, status=status.HTTP_404_NOT_FOUND)
+
+
+@api_view(['GET'])
+@permission_classes([AllowAny])
+def get_rating_details(request, rating_id):
+    try:
+        rating = Rating.objects.get(id=rating_id)
+        serializer = RatingSerializer(rating)
+        return Response(serializer.data, status=status.HTTP_200_OK)
+
+    except Exception as e:
+        return Response({'error': 'Rating not found'}, status=status.HTTP_404_NOT_FOUND)
+
+
+@api_view(['GET'])
+@permission_classes([AllowAny])
+def check_if_physio_has_rated(request):
path: backend/fisio_find/ratings/views.py
user: login: Copilot
body: The endpoint 'check_if_physio_has_rated' is set to AllowAny but accesses request.user.physio, which will fail for unauthenticated users. Consider requiring authentication or adding error handling for anonymous requests.
```suggestion
def check_if_physio_has_rated(request):
    if not request.user.is_authenticated:
        return Response({'error': 'Authentication required'}, status=status.HTTP_401_UNAUTHORIZED)
```
created_at: 2025-04-03T14:54:04Z
author_association: CONTRIBUTOR
diff_hunk: @@ -105,19 +106,17 @@
 
 MIDDLEWARE = [
     'django.middleware.security.SecurityMiddleware',
-    'corsheaders.middleware.CorsMiddleware',  # Move this up
+    'corsheaders.middleware.CorsMiddleware',
path: backend/fisio_find/fisio_find/settings.py
user: login: Copilot
body: There is a duplicate entry for 'corsheaders.middleware.CorsMiddleware' in the MIDDLEWARE list; please remove the redundant entry to prevent potential middleware conflicts.
created_at: 2025-04-03T14:54:04Z
author_association: CONTRIBUTOR
diff_hunk: @@ -0,0 +1,128 @@
+from rest_framework import generics, status
+from rest_framework.decorators import api_view, permission_classes
+from rest_framework.response import Response
+from rest_framework.permissions import IsAuthenticated, AllowAny
+from .models import Rating
+from .serializers import RatingSerializer
+from users.permissions import IsPhysiotherapist
+
+
+class RatingListView(generics.ListAPIView):
+    queryset = Rating.objects.all().order_by('-punctuation')
+    serializer_class = RatingSerializer
+    permission_classes = [AllowAny]
+
+    def get_queryset(self):
+        queryset = super().get_queryset()
+        return queryset
+
+
+@api_view(['POST'])
+@permission_classes([IsAuthenticated, IsPhysiotherapist])
+def create_rating(request):
+    # Log the incoming data for debugging
+    print("Incoming rating data:", request.data)
+
+    # Ensure the physiotherapist field is set to the logged-in user
+    data = request.data.copy()
+    data['physiotherapist'] = request.user.physio.id
+
+    serializer = RatingSerializer(data=data)
+
+    if serializer.is_valid():
+        serializer.save()
+        return Response(serializer.data, status=status.HTTP_201_CREATED)
+
+    # Log validation errors
+    print("Validation errors:", serializer.errors)
+    return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST)
+
+
+@api_view(['PUT', 'PATCH'])
+@permission_classes([IsAuthenticated, IsPhysiotherapist])
+def update_rating(request, rating_id):
+    try:
+        rating = Rating.objects.get(id=rating_id)
+
+        if request.user.physio != rating.physiotherapist and not request.user.is_staff:
+            return Response(
+                {'error': 'You can only edit your own ratings'}, 
+                status=status.HTTP_403_FORBIDDEN
+            )
+
+        # Automatically associate the physiotherapist with the logged-in user
+        data = request.data.copy()
+        data['physiotherapist'] = request.user.physio.id
+
+        # Log incoming data for debugging
+        print("Incoming data for update:", data)
+
+        serializer = RatingSerializer(rating, data=data, partial=True)
+
+        if serializer.is_valid():
+            serializer.save()
+            return Response(serializer.data, status=status.HTTP_200_OK)
+
+        # Log validation errors
+        print("Validation errors:", serializer.errors)
+        return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST)
+
+    except Rating.DoesNotExist:
+        return Response({'error': 'Rating not found'}, status=status.HTTP_404_NOT_FOUND)
+    except Exception as e:
+        return Response({'error': str(e)}, status=status.HTTP_500_INTERNAL_SERVER_ERROR)
path: backend/fisio_find/ratings/views.py
user: login: github-advanced-security[bot]
body: ## Information exposure through an exception

[Stack trace information](1) flows to this location and may be exposed to an external user.

[Show more details](https://github.com/Proyecto-ISPP/FISIOFIND/security/code-scanning/57)
created_at: 2025-04-03T15:03:24Z
author_association: CONTRIBUTOR

PR comments:
user: login: DelfinSR
created_at: 2025-04-04T16:24:16Z
body: Hola! Gran trabajo Antonio. Recuerda que las respuestas de errores del backend que son del estilo entidad no encontrada y cosas asi deberian de ser en español. Tambien, los errores 500 de django no se deverian de enviar al usuario, eso es un problema de seguridad en general.
author_association: CONTRIBUTOR
user: login: guaridpin
created_at: 2025-04-05T12:25:19Z
body: Guau Antonio, el mejor trabajo de tu vida!

https://github.com/user-attachments/assets/9459a168-b635-484f-9f13-f64fef790cce


author_association: CONTRIBUTOR
user: login: sonarqubecloud[bot]
created_at: 2025-04-05T12:28:19Z
body: ## [![Quality Gate Failed](https://sonarsource.github.io/sonarcloud-github-static-resources/v2/checks/QualityGateBadge/qg-failed-20px.png 'Quality Gate Failed')](https://sonarcloud.io/dashboard?id=Proyecto-ISPP_FISIOFIND&pullRequest=441) **Quality Gate failed**  
Failed conditions  
![](https://sonarsource.github.io/sonarcloud-github-static-resources/v2/common/failed-16px.png '') [6 Security Hotspots](https://sonarcloud.io/project/security_hotspots?id=Proyecto-ISPP_FISIOFIND&pullRequest=441&issueStatuses=OPEN,CONFIRMED&sinceLeakPeriod=true)  
![](https://sonarsource.github.io/sonarcloud-github-static-resources/v2/common/failed-16px.png '') [14.1% Coverage on New Code](https://sonarcloud.io/component_measures?id=Proyecto-ISPP_FISIOFIND&pullRequest=441&metric=new_coverage&view=list) (required ≥ 80%)  
![](https://sonarsource.github.io/sonarcloud-github-static-resources/v2/common/failed-16px.png '') [3.5% Duplication on New Code](https://sonarcloud.io/component_measures?id=Proyecto-ISPP_FISIOFIND&pullRequest=441&metric=new_duplicated_lines_density&view=list) (required ≤ 3%)  
![](https://sonarsource.github.io/sonarcloud-github-static-resources/v2/common/failed-16px.png '') [B Reliability Rating on New Code](https://sonarcloud.io/dashboard?id=Proyecto-ISPP_FISIOFIND&pullRequest=441) (required ≥ A)  
  
[See analysis details on SonarQube Cloud](https://sonarcloud.io/dashboard?id=Proyecto-ISPP_FISIOFIND&pullRequest=441)

##   
![](https://sonarsource.github.io/sonarcloud-github-static-resources/v2/common/light_bulb-16px.png '') Catch issues before they fail your Quality Gate with our IDE extension ![](https://sonarsource.github.io/sonarcloud-github-static-resources/v2/common/sonarlint-16px.png '') [SonarQube for IDE](https://www.sonarsource.com/products/sonarlint/features/connected-mode/?referrer=pull-request)


author_association: NONE