From 40f0ef8ea5656369a210701ae8e33f9fc3d8c074 Mon Sep 17 00:00:00 2001 From: Daniel Date: Fri, 8 May 2026 13:32:33 +0200 Subject: [PATCH 01/10] =?UTF-8?q?feat:=20a=C3=B1adir=20modelo=20Review=20p?= =?UTF-8?q?ara=20valoraciones=20de=20productos?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- ...ntity_alter_orderitem_quantity_and_more.py | 49 +++++++++++++++++++ tienda/models.py | 40 +++++++++++++++ 2 files changed, 89 insertions(+) create mode 100644 tienda/migrations/0009_alter_cartitem_quantity_alter_orderitem_quantity_and_more.py diff --git a/tienda/migrations/0009_alter_cartitem_quantity_alter_orderitem_quantity_and_more.py b/tienda/migrations/0009_alter_cartitem_quantity_alter_orderitem_quantity_and_more.py new file mode 100644 index 0000000..694df1f --- /dev/null +++ b/tienda/migrations/0009_alter_cartitem_quantity_alter_orderitem_quantity_and_more.py @@ -0,0 +1,49 @@ +# Generated by Django 6.0.5 on 2026-05-08 11:32 + +import django.core.validators +import django.db.models.deletion +from django.conf import settings +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('tienda', '0008_alter_product_briefdesc_alter_product_description'), + ] + + operations = [ + migrations.AlterField( + model_name='cartitem', + name='quantity', + field=models.PositiveIntegerField(default=1, validators=[django.core.validators.MaxValueValidator(9999)]), + ), + migrations.AlterField( + model_name='orderitem', + name='quantity', + field=models.PositiveIntegerField(default=1, validators=[django.core.validators.MaxValueValidator(9999)]), + ), + migrations.AlterField( + model_name='stockreservationitem', + name='quantity', + field=models.PositiveIntegerField(default=1, validators=[django.core.validators.MaxValueValidator(9999)]), + ), + migrations.CreateModel( + name='Review', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('rating', models.PositiveIntegerField(validators=[django.core.validators.MaxValueValidator(5)])), + ('title', models.CharField(default='', max_length=200)), + ('content', models.TextField(default='', max_length=2000)), + ('created_at', models.DateTimeField(auto_now_add=True)), + ('updated_at', models.DateTimeField(auto_now=True)), + ('images', models.ManyToManyField(blank=True, related_name='product_reviews', to='tienda.image')), + ('product', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='reviews', to='tienda.product')), + ('user', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='product_reviews', to=settings.AUTH_USER_MODEL)), + ], + options={ + 'ordering': ['-created_at'], + 'unique_together': {('product', 'user')}, + }, + ), + ] diff --git a/tienda/models.py b/tienda/models.py index 25d82be..bd61912 100644 --- a/tienda/models.py +++ b/tienda/models.py @@ -122,6 +122,27 @@ class Product(models.Model): "creator": self.creator.to_dict() if self.creator else None } + def has_user_purchased(self, user): + """Verifica si el usuario ha comprado este producto al menos una vez""" + if not user or not user.is_authenticated: + return False + return OrderItem.objects.filter( + order__buyer=user, + order__status=Order.STATUS_PAID, + product=self + ).exists() + + def get_average_rating(self): + """Retorna la nota media de las valoraciones""" + reviews = self.reviews.all() + if not reviews.exists(): + return 0 + return round(reviews.aggregate(models.Avg('rating'))['rating__avg'], 1) + + def get_reviews_count(self): + """Retorna el número total de valoraciones""" + return self.reviews.count() + class StockReservation(models.Model): STATUS_ACTIVE = "active" @@ -331,6 +352,25 @@ class SavedPaymentMethod(models.Model): super().save(*args, **kwargs) +class Review(models.Model): + """Valoraciones de productos por usuarios que han realizado una compra""" + product = models.ForeignKey(Product, on_delete=models.CASCADE, related_name='reviews') + user = models.ForeignKey(User, on_delete=models.CASCADE, related_name='product_reviews') + rating = models.PositiveIntegerField(validators=[MaxValueValidator(5)]) + title = models.CharField(max_length=200, default="") + content = models.TextField(max_length=2000, default="") + images = models.ManyToManyField(Image, related_name='product_reviews', blank=True) + created_at = models.DateTimeField(auto_now_add=True) + updated_at = models.DateTimeField(auto_now=True) + + class Meta: + unique_together = ('product', 'user') + ordering = ['-created_at'] + + def __str__(self): + return f"Valoración de {self.user.username} en {self.product.name} ({self.rating}★)" + + class ShippingAddress(models.Model): """Direcciones de entrega de los usuarios""" user = models.ForeignKey(User, on_delete=models.CASCADE, related_name='shipping_addresses') From 0438a77149f11f9b4507f2768459cf5e8675a43e Mon Sep 17 00:00:00 2001 From: Daniel Date: Fri, 8 May 2026 13:33:37 +0200 Subject: [PATCH 02/10] =?UTF-8?q?feat:=20a=C3=B1adir=20sistema=20de=20valo?= =?UTF-8?q?raciones=20con=20formulario,=20vistas=20y=20templates?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- tienda/forms.py | 41 ++++++++- tienda/templates/tienda/add_review.html | 112 ++++++++++++++++++++++++ tienda/templates/tienda/producto.html | 79 +++++++++++++++++ tienda/urls.py | 4 +- tienda/views.py | 95 +++++++++++++++++++- 5 files changed, 326 insertions(+), 5 deletions(-) create mode 100644 tienda/templates/tienda/add_review.html diff --git a/tienda/forms.py b/tienda/forms.py index 0234974..a108c4d 100644 --- a/tienda/forms.py +++ b/tienda/forms.py @@ -369,4 +369,43 @@ class ResetPasswordPhase2Form(forms.Form): password = cleaned_data.get("password") verify_password = cleaned_data.get("verify_password") if password and verify_password and password != verify_password: - raise ValidationError("Las contraseñas no coinciden.") \ No newline at end of file + raise ValidationError("Las contraseñas no coinciden.") + + +class ReviewForm(forms.Form): + rating = forms.IntegerField( + label="Puntuación", + required=True, + min_value=1, + max_value=5, + widget=forms.HiddenInput() + ) + title = forms.CharField( + label="Título", + max_length=200, + required=True, + widget=forms.TextInput(attrs={ + 'class': 'form-control', + 'placeholder': 'Título de tu valoración' + }) + ) + content = forms.CharField( + label="Descripción", + max_length=2000, + required=True, + widget=forms.Textarea(attrs={ + 'class': 'form-control', + 'rows': 5, + 'placeholder': 'Comparte tu experiencia con este producto...' + }) + ) + images = forms.ImageField( + label="Imágenes (opcional)", + required=False, + validators=[validate_image_file], + widget=forms.ClearableFileInput(attrs={ + 'class': 'form-control', + 'accept': 'image/*', + 'multiple': True + }) + ) \ No newline at end of file diff --git a/tienda/templates/tienda/add_review.html b/tienda/templates/tienda/add_review.html new file mode 100644 index 0000000..e64635a --- /dev/null +++ b/tienda/templates/tienda/add_review.html @@ -0,0 +1,112 @@ +{% extends "tienda/base.html" %} + +{% block content %} +
+ + +
+
+
+
+

+ {% if existing_review %}Actualizar{% else %}Añadir{% endif %} valoración: {{ product.name }} +

+ +
+ {% csrf_token %} + +
+ +
+ {% for i in "12345" %} + + {% endfor %} +
+ + {% if form.rating.errors %} +
{{ form.rating.errors }}
+ {% endif %} +
+ +
+ + {{ form.title }} + {% if form.title.errors %} +
{{ form.title.errors }}
+ {% endif %} +
+ +
+ + {{ form.content }} + {% if form.content.errors %} +
{{ form.content.errors }}
+ {% endif %} +
+ +
+ + {{ form.images }} +
Puedes subir hasta 5 imágenes
+
+ +
+ + Cancelar +
+
+
+
+
+
+
+ + +{% endblock %} \ No newline at end of file diff --git a/tienda/templates/tienda/producto.html b/tienda/templates/tienda/producto.html index 117aa9f..3c0edfb 100644 --- a/tienda/templates/tienda/producto.html +++ b/tienda/templates/tienda/producto.html @@ -62,4 +62,83 @@ {{ product.description }} + +
+
+

Valoraciones

+
+
+
0.0
+
+
+ 0 valoraciones +
+ {% if can_review %} + Valorar este producto + {% elif user.is_authenticated %} + Ya has valorado este producto + {% else %} + Inicia sesión para valorar + {% endif %} +
+
+
+
+
+ + {% endblock %} diff --git a/tienda/urls.py b/tienda/urls.py index 02fd694..cbb924f 100644 --- a/tienda/urls.py +++ b/tienda/urls.py @@ -68,5 +68,7 @@ urlpatterns = [ path("sobre-nosotros", views.sobre_nosotros, name="sobre_nosotros"), path("ayuda", views.ayuda, name="ayuda"), path("reset-password", views.reset_password, name="reset_password"), - path("reset-password-phase2/", views.reset_password_phase2, name="reset_password_phase2") + path("reset-password-phase2/", views.reset_password_phase2, name="reset_password_phase2"), + path("producto//valorar/", views.add_review, name="add_review"), + path("api/producto//valoraciones/", views.product_reviews, name="product_reviews"), ] diff --git a/tienda/views.py b/tienda/views.py index 5c16a4a..34e9afd 100644 --- a/tienda/views.py +++ b/tienda/views.py @@ -6,8 +6,8 @@ from django.contrib.auth.decorators import login_required from django.contrib import messages from tienda.utilities import send_email -from .models import User, Product, Category, Cart, CartItem, Image, Order, OrderItem, OrderMessage, ShippingAddress, StockReservation, StockReservationItem, VerificationCode, SavedPaymentMethod -from .forms import ProductForm, SecondaryImageForm, UserLoginForm, UserRegisterForm, ProductEditForm, EditProfileForm, ChangePasswordForm, ShippingAddressForm, ResetPasswordForm, ResetPasswordPhase2Form +from .models import User, Product, Category, Cart, CartItem, Image, Order, OrderItem, OrderMessage, ShippingAddress, StockReservation, StockReservationItem, VerificationCode, SavedPaymentMethod, Review +from .forms import ProductForm, SecondaryImageForm, UserLoginForm, UserRegisterForm, ProductEditForm, EditProfileForm, ChangePasswordForm, ShippingAddressForm, ResetPasswordForm, ResetPasswordPhase2Form, ReviewForm from . import tasks from .vars import ( PAGE_SIZE, @@ -400,7 +400,11 @@ def producto(request: HttpRequest, id: int): # Cachear el producto por 5 minutos (300 segundos) cache.set(cache_key, product, 300) - return render(request, "tienda/producto.html", {"product": product}) + can_review = False + if request.user.is_authenticated: + can_review = product.has_user_purchased(request.user) and not Review.objects.filter(product=product, user=request.user).exists() + + return render(request, "tienda/producto.html", {"product": product, "can_review": can_review}) def categoria(request: HttpRequest, id: int): page = 1 @@ -2324,3 +2328,88 @@ def reset_password_phase2(request: HttpRequest, code: str): return render(request, "tienda/reset_password_phase2.html", {"form": form, "code": code}) else: raise Http404() + + +@login_required +def add_review(request: HttpRequest, product_id: int): + product = get_object_or_404(Product, id=product_id) + + if not product.has_user_purchased(request.user): + messages.error(request, "Solo puedes valorar productos que hayas comprado.") + return redirect(reverse("product_detail", args=[product_id])) + + existing_review = Review.objects.filter(product=product, user=request.user).first() + + if request.method == "POST": + form = ReviewForm(request.POST, request.FILES) + if form.is_valid(): + rating = form.cleaned_data["rating"] + title = form.cleaned_data["title"] + content = form.cleaned_data["content"] + + if existing_review: + existing_review.rating = rating + existing_review.title = title + existing_review.content = content + existing_review.save() + existing_review.images.clear() + review = existing_review + messages.success(request, "¡Tu valoración ha sido actualizada!") + else: + review = Review.objects.create( + product=product, + user=request.user, + rating=rating, + title=title, + content=content + ) + messages.success(request, "¡Gracias por tu valoración!") + + uploaded_files = request.FILES.getlist("images") + for uploaded_file in uploaded_files: + image = Image.objects.create( + name=f"Review {product.name} - {request.user.username}", + image=uploaded_file + ) + review.images.add(image) + + return redirect(reverse("product_detail", args=[product_id])) + else: + initial_data = {} + if existing_review: + initial_data = { + "rating": existing_review.rating, + "title": existing_review.title, + "content": existing_review.content + } + form = ReviewForm(initial=initial_data) + + return render(request, "tienda/add_review.html", { + "product": product, + "form": form, + "existing_review": existing_review + }) + + +def product_reviews(request: HttpRequest, product_id: int): + product = get_object_or_404(Product, id=product_id) + reviews = product.reviews.select_related("user").prefetch_related("images").all() + + return JsonResponse({ + "reviews": [ + { + "id": r.id, + "user": r.user.username, + "rating": r.rating, + "title": r.title, + "content": r.content, + "images": [img.to_dict() for img in r.images.all()], + "created_at": r.created_at.isoformat(), + "is_owner": request.user.is_authenticated and r.user.id == request.user.id + } + for r in reviews + ], + "average_rating": product.get_average_rating(), + "reviews_count": product.get_reviews_count(), + "can_review": request.user.is_authenticated and product.has_user_purchased(request.user) and not Review.objects.filter(product=product, user=request.user).exists() + }) From 429b531bad537d273c97bec2521ebe6bb06b2fc3 Mon Sep 17 00:00:00 2001 From: Daniel Date: Fri, 8 May 2026 13:33:46 +0200 Subject: [PATCH 03/10] =?UTF-8?q?feat:=20a=C3=B1adir=20Review=20al=20admin?= =?UTF-8?q?=20para=20gestionar=20valoraciones?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- tienda/admin.py | 11 +++++++++-- 1 file changed, 9 insertions(+), 2 deletions(-) diff --git a/tienda/admin.py b/tienda/admin.py index 7b91fbf..577ca0f 100644 --- a/tienda/admin.py +++ b/tienda/admin.py @@ -1,5 +1,5 @@ from django.contrib import admin -from .models import Category, Image, Product, Cart, CartItem, Order, OrderItem, OrderMessage, StockReservation, StockReservationItem, User, VerificationCode, SavedPaymentMethod +from .models import Category, Image, Product, Cart, CartItem, Order, OrderItem, OrderMessage, StockReservation, StockReservationItem, User, VerificationCode, SavedPaymentMethod, Review # Register your models here. from django.shortcuts import redirect from django.urls import path @@ -150,4 +150,11 @@ class StockReservationAdmin(admin.ModelAdmin): class SavedPaymentMethodAdmin(admin.ModelAdmin): list_display = ('id', 'user', 'method_type', 'label', 'is_default', 'created_at') list_filter = ('method_type', 'is_default', 'created_at') - search_fields = ('user__username', 'user__email', 'label', 'paypal_email') \ No newline at end of file + search_fields = ('user__username', 'user__email', 'label', 'paypal_email') + + +@admin.register(Review) +class ReviewAdmin(admin.ModelAdmin): + list_display = ('id', 'product', 'user', 'rating', 'title', 'created_at') + list_filter = ('rating', 'created_at') + search_fields = ('user__username', 'product__name', 'title', 'content') \ No newline at end of file From aa047b3fd85d16917ca8e40733dcc79b37f147e2 Mon Sep 17 00:00:00 2001 From: Daniel Date: Fri, 8 May 2026 13:34:00 +0200 Subject: [PATCH 04/10] fix: eliminar campo images del form (widget no soporta multiple) --- tienda/forms.py | 10 ---------- 1 file changed, 10 deletions(-) diff --git a/tienda/forms.py b/tienda/forms.py index a108c4d..3701a74 100644 --- a/tienda/forms.py +++ b/tienda/forms.py @@ -398,14 +398,4 @@ class ReviewForm(forms.Form): 'rows': 5, 'placeholder': 'Comparte tu experiencia con este producto...' }) - ) - images = forms.ImageField( - label="Imágenes (opcional)", - required=False, - validators=[validate_image_file], - widget=forms.ClearableFileInput(attrs={ - 'class': 'form-control', - 'accept': 'image/*', - 'multiple': True - }) ) \ No newline at end of file From f129b0462a1585a237263b01a2ac2009fa874014 Mon Sep 17 00:00:00 2001 From: Daniel Date: Fri, 8 May 2026 13:53:56 +0200 Subject: [PATCH 05/10] fix: permitir valorar si el usuario tiene cualquier OrderItem del producto --- tienda/models.py | 1 - 1 file changed, 1 deletion(-) diff --git a/tienda/models.py b/tienda/models.py index bd61912..052a594 100644 --- a/tienda/models.py +++ b/tienda/models.py @@ -128,7 +128,6 @@ class Product(models.Model): return False return OrderItem.objects.filter( order__buyer=user, - order__status=Order.STATUS_PAID, product=self ).exists() From 2b2054ace6d3a028ef40d2a44ce4f402a5f3be28 Mon Sep 17 00:00:00 2001 From: Daniel Date: Fri, 8 May 2026 13:57:33 +0200 Subject: [PATCH 06/10] =?UTF-8?q?debug:=20a=C3=B1adir=20variables=20de=20d?= =?UTF-8?q?ebug=20al=20template?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- tienda/templates/tienda/producto.html | 3 +++ 1 file changed, 3 insertions(+) diff --git a/tienda/templates/tienda/producto.html b/tienda/templates/tienda/producto.html index 3c0edfb..050b248 100644 --- a/tienda/templates/tienda/producto.html +++ b/tienda/templates/tienda/producto.html @@ -66,6 +66,9 @@

Valoraciones

+
+ DEBUG: can_review={{can_review}}, user.is_authenticated={{user.is_authenticated}}, user={{user}} +
0.0
From 62bf3fdc08c8a55de5df8bb294dee1f15898aa7b Mon Sep 17 00:00:00 2001 From: Daniel Date: Fri, 8 May 2026 13:58:08 +0200 Subject: [PATCH 07/10] fix: mostrar mensaje correcto cuando no se puede valorar por no haber compra --- tienda/templates/tienda/producto.html | 5 +---- 1 file changed, 1 insertion(+), 4 deletions(-) diff --git a/tienda/templates/tienda/producto.html b/tienda/templates/tienda/producto.html index 050b248..b20c3ce 100644 --- a/tienda/templates/tienda/producto.html +++ b/tienda/templates/tienda/producto.html @@ -66,9 +66,6 @@

Valoraciones

-
- DEBUG: can_review={{can_review}}, user.is_authenticated={{user.is_authenticated}}, user={{user}} -
0.0
@@ -79,7 +76,7 @@ {% if can_review %} Valorar este producto {% elif user.is_authenticated %} - Ya has valorado este producto + Solo puedes valorar productos que hayas comprado {% else %} Inicia sesión para valorar {% endif %} From e0350de530f9083eb6beec7c14414905b82ca5ba Mon Sep 17 00:00:00 2001 From: Daniel Date: Fri, 8 May 2026 14:03:31 +0200 Subject: [PATCH 08/10] fix: usar estrellas Unicode en lugar de Bootstrap Icons --- tienda/templates/tienda/add_review.html | 14 ++++---------- 1 file changed, 4 insertions(+), 10 deletions(-) diff --git a/tienda/templates/tienda/add_review.html b/tienda/templates/tienda/add_review.html index e64635a..8e09d4e 100644 --- a/tienda/templates/tienda/add_review.html +++ b/tienda/templates/tienda/add_review.html @@ -23,9 +23,9 @@
-
+
{% for i in "12345" %} - + {% endfor %}
@@ -50,12 +50,6 @@ {% endif %}
-
- - {{ form.images }} -
Puedes subir hasta 5 imágenes
-
-