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 diff --git a/tienda/forms.py b/tienda/forms.py index 0234974..3701a74 100644 --- a/tienda/forms.py +++ b/tienda/forms.py @@ -369,4 +369,33 @@ 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...' + }) + ) \ No newline at end of file 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..052a594 100644 --- a/tienda/models.py +++ b/tienda/models.py @@ -122,6 +122,26 @@ 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, + 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 +351,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') diff --git a/tienda/templates/tienda/add_review.html b/tienda/templates/tienda/add_review.html new file mode 100644 index 0000000..8e09d4e --- /dev/null +++ b/tienda/templates/tienda/add_review.html @@ -0,0 +1,106 @@ +{% 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 %} +
+ +
+ + Cancelar +
+
+
+
+
+
+
+ + +{% endblock %} \ No newline at end of file diff --git a/tienda/templates/tienda/producto.html b/tienda/templates/tienda/producto.html index 117aa9f..cb00214 100644 --- a/tienda/templates/tienda/producto.html +++ b/tienda/templates/tienda/producto.html @@ -62,4 +62,101 @@ {{ product.description }} + +
+
+

Valoraciones

+
+
+
0.0
+
+
+ 0 valoraciones +
+ {% if can_review %} + Valorar este producto + {% elif user_has_review %} +
+ Editar mi valoración +
+ {% csrf_token %} + +
+
+ {% elif user.is_authenticated %} + Solo puedes valorar productos que hayas comprado + {% else %} + Inicia sesión para valorar + {% endif %} +
+
+
+
+
+ + {% endblock %} diff --git a/tienda/urls.py b/tienda/urls.py index 02fd694..30ed75e 100644 --- a/tienda/urls.py +++ b/tienda/urls.py @@ -68,5 +68,8 @@ 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("producto//valorar/eliminar/", views.delete_review, name="delete_review"), + path("api/producto//valoraciones/", views.product_reviews, name="product_reviews"), ] diff --git a/tienda/views.py b/tienda/views.py index 5c16a4a..bc8e04c 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,23 @@ 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 + user_has_review = False + user_review_id = None + + if request.user.is_authenticated: + user_review = Review.objects.filter(product=product, user=request.user).first() + if user_review: + user_has_review = True + user_review_id = user_review.id + can_review = product.has_user_purchased(request.user) and not user_review + + return render(request, "tienda/producto.html", { + "product": product, + "can_review": can_review, + "user_has_review": user_has_review, + "user_review_id": user_review_id + }) def categoria(request: HttpRequest, id: int): page = 1 @@ -2324,3 +2340,97 @@ 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("producto", 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("producto", 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() + }) + + +@login_required +def delete_review(request: HttpRequest, review_id: int): + review = get_object_or_404(Review, id=review_id, user=request.user) + product_id = review.product_id + review.delete() + messages.success(request, "Tu valoración ha sido eliminada.") + return redirect(reverse("producto", args=[product_id]))