Compare commits

...

11 Commits

8 changed files with 447 additions and 7 deletions
+8 -1
View File
@@ -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
@@ -151,3 +151,10 @@ 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')
@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')
+29
View File
@@ -370,3 +370,32 @@ class ResetPasswordPhase2Form(forms.Form):
verify_password = cleaned_data.get("verify_password")
if password and verify_password and password != verify_password:
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...'
})
)
@@ -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')},
},
),
]
+39
View File
@@ -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')
+106
View File
@@ -0,0 +1,106 @@
{% extends "tienda/base.html" %}
{% block content %}
<div class="container py-4">
<nav aria-label="breadcrumb">
<ol class="breadcrumb">
<li class="breadcrumb-item"><a href="{% url 'index' %}">Inicio</a></li>
<li class="breadcrumb-item"><a href="{% url 'producto' product.id %}">{{ product.name }}</a></li>
<li class="breadcrumb-item active" aria-current="page">Valorar Producto</li>
</ol>
</nav>
<div class="row justify-content-center">
<div class="col-md-8">
<div class="card shadow-sm">
<div class="card-body">
<h4 class="card-title mb-4">
{% if existing_review %}Actualizar{% else %}Añadir{% endif %} valoración: {{ product.name }}
</h4>
<form method="post" enctype="multipart/form-data">
{% csrf_token %}
<div class="mb-4">
<label class="form-label">Puntuación</label>
<div class="star-rating d-flex gap-1" id="star-rating">
{% for i in "12345" %}
<span class="star fs-2 {% if form.initial.rating|default:0 >= i|add:0 %}text-warning text-dark{% else %}text-secondary{% endif %}" data-value="{{ i }}" style="cursor: pointer; font-size: 2rem;"></span>
{% endfor %}
</div>
<input type="hidden" name="rating" id="rating-input" value="{{ form.initial.rating|default:1 }}">
{% if form.rating.errors %}
<div class="text-danger small">{{ form.rating.errors }}</div>
{% endif %}
</div>
<div class="mb-3">
<label for="title" class="form-label">Título</label>
{{ form.title }}
{% if form.title.errors %}
<div class="text-danger small">{{ form.title.errors }}</div>
{% endif %}
</div>
<div class="mb-3">
<label for="content" class="form-label">Descripción</label>
{{ form.content }}
{% if form.content.errors %}
<div class="text-danger small">{{ form.content.errors }}</div>
{% endif %}
</div>
<div class="d-grid gap-2">
<button type="submit" class="btn btn-primary">
{% if existing_review %}Actualizar{% else %}Enviar{% endif %} valoración
</button>
<a href="{% url 'producto' product.id %}" class="btn btn-outline-secondary">Cancelar</a>
</div>
</form>
</div>
</div>
</div>
</div>
</div>
<script>
document.addEventListener('DOMContentLoaded', function() {
const stars = document.querySelectorAll('#star-rating .star');
const ratingInput = document.getElementById('rating-input');
function updateStars(value) {
stars.forEach((star, index) => {
if (index < value) {
star.classList.remove('text-secondary');
star.classList.add('text-warning');
} else {
star.classList.remove('text-warning');
star.classList.add('text-secondary');
}
});
ratingInput.value = value;
}
stars.forEach(star => {
star.addEventListener('click', function() {
const value = parseInt(this.dataset.value);
updateStars(value);
});
star.addEventListener('mouseenter', function() {
const value = parseInt(this.dataset.value);
stars.forEach((s, index) => {
if (index < value) {
s.classList.remove('text-secondary');
s.classList.add('text-warning');
}
});
});
star.addEventListener('mouseleave', function() {
updateStars(parseInt(ratingInput.value) || 1);
});
});
});
</script>
{% endblock %}
+97
View File
@@ -62,4 +62,101 @@
{{ product.description }}
</div>
</div>
<div class="row mt-4">
<div class="col-md-12">
<h4 class="mb-3">Valoraciones</h4>
<div id="reviews-summary" class="mb-4">
<div class="d-flex align-items-center gap-3">
<div class="fs-4" id="average-rating">0.0</div>
<div>
<div id="stars-display"></div>
<small class="text-muted" id="reviews-count">0 valoraciones</small>
</div>
{% if can_review %}
<a href="{% url 'add_review' product.id %}" class="btn btn-sm btn-outline-primary ms-auto">Valorar este producto</a>
{% elif user_has_review %}
<div class="ms-auto">
<a href="{% url 'add_review' product.id %}" class="btn btn-sm btn-outline-primary">Editar mi valoración</a>
<form method="post" action="{% url 'delete_review' product.id %}" style="display:inline;">
{% csrf_token %}
<button type="submit" class="btn btn-sm btn-outline-danger" onclick="return confirm('¿Eliminar esta valoración?');">Eliminar</button>
</form>
</div>
{% elif user.is_authenticated %}
<span class="text-muted ms-auto">Solo puedes valorar productos que hayas comprado</span>
{% else %}
<a href="{% url 'login' %}?next={% url 'producto' product.id %}" class="btn btn-sm btn-outline-primary ms-auto">Inicia sesión para valorar</a>
{% endif %}
</div>
</div>
<div id="reviews-list"></div>
</div>
</div>
<script>
async function loadReviews() {
try {
const response = await fetch("{% url 'product_reviews' product.id %}");
const data = await response.json();
document.getElementById('average-rating').textContent = data.average_rating;
document.getElementById('reviews-count').textContent = data.reviews_count + ' valoraciones';
let starsHtml = '';
for (let i = 1; i <= 5; i++) {
starsHtml += `<span class="${i <= Math.round(data.average_rating) ? 'text-warning' : 'text-secondary'}">★</span>`;
}
document.getElementById('stars-display').innerHTML = starsHtml;
const reviewsList = document.getElementById('reviews-list');
if (data.reviews.length === 0) {
reviewsList.innerHTML = '<p class="text-muted">Aún no hay valoraciones para este producto.</p>';
} else {
let reviewsHtml = '';
data.reviews.forEach(review => {
let imagesHtml = '';
if (review.images && review.images.length > 0) {
imagesHtml = '<div class="mt-2">';
review.images.forEach(img => {
imagesHtml += `<img src="${img.image}" class="img-thumbnail me-1" style="max-width: 80px; max-height: 80px;" alt="">`;
});
imagesHtml += '</div>';
}
const actionsHtml = review.is_owner
? `<div class="mt-2">
<a href="/tienda/producto/${review.id}/valorar/" class="btn btn-sm btn-outline-primary me-1">Editar</a>
<form method="post" action="/tienda/producto/${review.id}/valorar/eliminar/" style="display:inline;">
{% csrf_token %}
<button type="submit" class="btn btn-sm btn-outline-danger" onclick="return confirm('¿Eliminar esta valoración?');">Eliminar</button>
</form>
</div>`
: '';
reviewsHtml += `
<div class="card mb-3">
<div class="card-body">
<div class="d-flex justify-content-between align-items-start">
<div>
<strong>${review.user}</strong>
<span class="text-warning ms-1">${'★'.repeat(review.rating)}</span>
</div>
<small class="text-muted">${new Date(review.created_at).toLocaleDateString('es-ES')}</small>
</div>
<h6 class="mt-2">${review.title}</h6>
<p class="mb-1">${review.content}</p>
${imagesHtml}
${actionsHtml}
</div>
</div>
`;
});
reviewsList.innerHTML = reviewsHtml;
}
} catch (error) {
console.error('Error loading reviews:', error);
}
}
loadReviews();
</script>
{% endblock %}
+4 -1
View File
@@ -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/<str:code>", views.reset_password_phase2, name="reset_password_phase2")
path("reset-password-phase2/<str:code>", views.reset_password_phase2, name="reset_password_phase2"),
path("producto/<int:product_id>/valorar/", views.add_review, name="add_review"),
path("producto/<int:product_id>/valorar/eliminar/", views.delete_review, name="delete_review"),
path("api/producto/<int:product_id>/valoraciones/", views.product_reviews, name="product_reviews"),
]
+113 -3
View File
@@ -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]))