generated from westfarn/web_django_template
Add customer accounts, shipment tracking, and purchase reviews.
CI / test (pull_request) Successful in 35s
CI / test (pull_request) Successful in 35s
Shoppers can register, save shipping details, and view order history while cards stay on Stripe. EasyPost tracker updates (including numbers from Pirate Ship) and 1–5 star reviews are limited to buyers. The contact form now only asks for email and a message.
This commit is contained in:
+16
-1
@@ -1,6 +1,13 @@
|
||||
from django.contrib import admin
|
||||
|
||||
from shop.models import Order, OrderItem, Product, ProductColor, ProductImage
|
||||
from shop.models import (
|
||||
Order,
|
||||
OrderItem,
|
||||
Product,
|
||||
ProductColor,
|
||||
ProductImage,
|
||||
ProductReview,
|
||||
)
|
||||
|
||||
|
||||
class ProductColorInline(admin.TabularInline):
|
||||
@@ -22,6 +29,13 @@ class ProductAdmin(admin.ModelAdmin):
|
||||
inlines = [ProductColorInline, ProductImageInline]
|
||||
|
||||
|
||||
@admin.register(ProductReview)
|
||||
class ProductReviewAdmin(admin.ModelAdmin):
|
||||
list_display = ("product", "user", "rating", "created_at")
|
||||
list_filter = ("rating",)
|
||||
search_fields = ("product__name", "user__email", "title")
|
||||
|
||||
|
||||
class OrderItemInline(admin.TabularInline):
|
||||
model = OrderItem
|
||||
extra = 0
|
||||
@@ -34,4 +48,5 @@ class OrderAdmin(admin.ModelAdmin):
|
||||
list_display = ("number", "email", "amount", "status", "created_at")
|
||||
list_filter = ("status",)
|
||||
search_fields = ("number", "email", "customer_name")
|
||||
raw_id_fields = ("user",)
|
||||
inlines = [OrderItemInline]
|
||||
|
||||
@@ -0,0 +1,97 @@
|
||||
# Generated by Django 6.1
|
||||
|
||||
import django.core.validators
|
||||
import django.db.models.deletion
|
||||
import uuid
|
||||
from django.conf import settings
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
("shop", "0004_productimage_and_color_stock"),
|
||||
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AddField(
|
||||
model_name="order",
|
||||
name="user",
|
||||
field=models.ForeignKey(
|
||||
blank=True,
|
||||
null=True,
|
||||
on_delete=django.db.models.deletion.SET_NULL,
|
||||
related_name="shop_orders",
|
||||
to=settings.AUTH_USER_MODEL,
|
||||
),
|
||||
),
|
||||
migrations.CreateModel(
|
||||
name="ProductReview",
|
||||
fields=[
|
||||
(
|
||||
"id",
|
||||
models.UUIDField(
|
||||
default=uuid.uuid4,
|
||||
editable=False,
|
||||
primary_key=True,
|
||||
serialize=False,
|
||||
),
|
||||
),
|
||||
("created_at", models.DateTimeField(auto_now_add=True)),
|
||||
("updated_at", models.DateTimeField(auto_now=True)),
|
||||
(
|
||||
"rating",
|
||||
models.PositiveSmallIntegerField(
|
||||
validators=[
|
||||
django.core.validators.MinValueValidator(1),
|
||||
django.core.validators.MaxValueValidator(5),
|
||||
]
|
||||
),
|
||||
),
|
||||
("title", models.CharField(blank=True, max_length=120)),
|
||||
("body", models.TextField(blank=True)),
|
||||
(
|
||||
"order",
|
||||
models.ForeignKey(
|
||||
on_delete=django.db.models.deletion.CASCADE,
|
||||
related_name="reviews",
|
||||
to="shop.order",
|
||||
),
|
||||
),
|
||||
(
|
||||
"product",
|
||||
models.ForeignKey(
|
||||
on_delete=django.db.models.deletion.CASCADE,
|
||||
related_name="reviews",
|
||||
to="shop.product",
|
||||
),
|
||||
),
|
||||
(
|
||||
"user",
|
||||
models.ForeignKey(
|
||||
on_delete=django.db.models.deletion.CASCADE,
|
||||
related_name="product_reviews",
|
||||
to=settings.AUTH_USER_MODEL,
|
||||
),
|
||||
),
|
||||
],
|
||||
options={
|
||||
"ordering": ["-created_at"],
|
||||
},
|
||||
),
|
||||
migrations.AddConstraint(
|
||||
model_name="productreview",
|
||||
constraint=models.UniqueConstraint(
|
||||
fields=("user", "product"),
|
||||
name="shop_review_user_product",
|
||||
),
|
||||
),
|
||||
migrations.AddConstraint(
|
||||
model_name="productreview",
|
||||
constraint=models.CheckConstraint(
|
||||
condition=models.Q(("rating__gte", 1), ("rating__lte", 5)),
|
||||
name="shop_review_rating_range",
|
||||
),
|
||||
),
|
||||
]
|
||||
@@ -1,5 +1,7 @@
|
||||
from decimal import Decimal
|
||||
|
||||
from django.conf import settings
|
||||
from django.core.validators import MaxValueValidator, MinValueValidator
|
||||
from django.db import models
|
||||
from django.urls import reverse
|
||||
from django.utils.text import slugify
|
||||
@@ -187,6 +189,13 @@ class Order(UUIDPrimaryKeyModel, TimeStampedModel):
|
||||
CANCELLED = "cancelled", "Cancelled"
|
||||
|
||||
number = models.CharField(max_length=32, unique=True)
|
||||
user = models.ForeignKey(
|
||||
settings.AUTH_USER_MODEL,
|
||||
null=True,
|
||||
blank=True,
|
||||
on_delete=models.SET_NULL,
|
||||
related_name="shop_orders",
|
||||
)
|
||||
email = models.EmailField()
|
||||
customer_name = models.CharField(max_length=200, blank=True)
|
||||
status = models.CharField(
|
||||
@@ -243,3 +252,38 @@ class OrderItem(UUIDPrimaryKeyModel, TimeStampedModel):
|
||||
@property
|
||||
def line_total(self) -> Decimal:
|
||||
return self.unit_price * self.quantity
|
||||
|
||||
|
||||
class ProductReview(UUIDPrimaryKeyModel, TimeStampedModel):
|
||||
product = models.ForeignKey(
|
||||
Product, on_delete=models.CASCADE, related_name="reviews"
|
||||
)
|
||||
user = models.ForeignKey(
|
||||
settings.AUTH_USER_MODEL,
|
||||
on_delete=models.CASCADE,
|
||||
related_name="product_reviews",
|
||||
)
|
||||
order = models.ForeignKey(
|
||||
Order, on_delete=models.CASCADE, related_name="reviews"
|
||||
)
|
||||
rating = models.PositiveSmallIntegerField(
|
||||
validators=[MinValueValidator(1), MaxValueValidator(5)]
|
||||
)
|
||||
title = models.CharField(max_length=120, blank=True)
|
||||
body = models.TextField(blank=True)
|
||||
|
||||
class Meta:
|
||||
ordering = ["-created_at"]
|
||||
constraints = [
|
||||
models.UniqueConstraint(
|
||||
fields=["user", "product"],
|
||||
name="shop_review_user_product",
|
||||
),
|
||||
models.CheckConstraint(
|
||||
condition=models.Q(rating__gte=1) & models.Q(rating__lte=5),
|
||||
name="shop_review_rating_range",
|
||||
),
|
||||
]
|
||||
|
||||
def __str__(self) -> str:
|
||||
return f"{self.rating}★ {self.product.name}"
|
||||
|
||||
@@ -12,5 +12,6 @@ urlpatterns = [
|
||||
path("checkout/", views.checkout, name="checkout"),
|
||||
path("checkout/<uuid:pk>/success/", views.checkout_success, name="checkout_success"),
|
||||
path("checkout/<uuid:pk>/cancel/", views.checkout_cancel, name="checkout_cancel"),
|
||||
path("<slug:slug>/review/", views.product_review, name="review"),
|
||||
path("<slug:slug>/", views.product_detail, name="detail"),
|
||||
]
|
||||
|
||||
+90
-11
@@ -10,11 +10,11 @@ from decimal import Decimal
|
||||
|
||||
from django.conf import settings
|
||||
from django.db import transaction
|
||||
from django.db.models import F, Sum
|
||||
from django.db.models import F, Q, Sum
|
||||
from django.utils import timezone
|
||||
|
||||
from core.models import StoredFile
|
||||
from shop.models import Order, OrderItem, Product, ProductColor, ProductImage
|
||||
from shop.models import Order, OrderItem, Product, ProductColor, ProductImage, ProductReview
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
@@ -254,6 +254,7 @@ def create_order_from_cart(
|
||||
customer_name: str = "",
|
||||
shipping_address: dict | None = None,
|
||||
notes: str = "",
|
||||
user=None,
|
||||
) -> Order:
|
||||
lines = cart_lines(session)
|
||||
if not lines:
|
||||
@@ -267,6 +268,7 @@ def create_order_from_cart(
|
||||
with transaction.atomic():
|
||||
order = Order.objects.create(
|
||||
number=next_order_number(),
|
||||
user=user if getattr(user, "is_authenticated", False) else None,
|
||||
email=email,
|
||||
customer_name=(customer_name or "").strip(),
|
||||
status=Order.Status.DRAFT,
|
||||
@@ -295,6 +297,42 @@ def create_order_from_cart(
|
||||
return order
|
||||
|
||||
|
||||
def ensure_stripe_customer(user) -> str:
|
||||
"""Create or reuse a Stripe Customer. Card data never leaves Stripe."""
|
||||
if not user or not getattr(user, "is_authenticated", False):
|
||||
return ""
|
||||
from accounts.services import get_customer_profile
|
||||
|
||||
profile = get_customer_profile(user)
|
||||
if profile.stripe_customer_id:
|
||||
return profile.stripe_customer_id
|
||||
stripe = _stripe()
|
||||
customer = stripe.Customer.create(
|
||||
email=(user.email or user.username or "") or None,
|
||||
name=(user.get_full_name() or "") or None,
|
||||
metadata={"user_id": str(user.pk)},
|
||||
)
|
||||
customer_id = getattr(customer, "id", None) or customer.get("id") or ""
|
||||
if not customer_id:
|
||||
raise ShopError("Stripe did not return a customer id.")
|
||||
profile.stripe_customer_id = customer_id
|
||||
profile.save(update_fields=["stripe_customer_id", "updated_at"])
|
||||
return customer_id
|
||||
|
||||
|
||||
def remember_stripe_customer(order: Order, customer_id: str) -> None:
|
||||
customer_id = (customer_id or "").strip()
|
||||
if not customer_id or not order.user_id:
|
||||
return
|
||||
from accounts.services import get_customer_profile
|
||||
|
||||
profile = get_customer_profile(order.user)
|
||||
if profile.stripe_customer_id:
|
||||
return
|
||||
profile.stripe_customer_id = customer_id
|
||||
profile.save(update_fields=["stripe_customer_id", "updated_at"])
|
||||
|
||||
|
||||
def create_checkout_session(order: Order, *, success_url: str, cancel_url: str) -> str:
|
||||
stripe = _stripe()
|
||||
line_items = [
|
||||
@@ -312,14 +350,27 @@ def create_checkout_session(order: Order, *, success_url: str, cancel_url: str)
|
||||
]
|
||||
if not line_items:
|
||||
raise ShopError("Order has no items.")
|
||||
session = stripe.checkout.Session.create(
|
||||
mode="payment",
|
||||
customer_email=order.email or None,
|
||||
line_items=line_items,
|
||||
metadata={"shop_order_id": str(order.pk), "order_number": order.number},
|
||||
success_url=success_url,
|
||||
cancel_url=cancel_url,
|
||||
)
|
||||
params = {
|
||||
"mode": "payment",
|
||||
"line_items": line_items,
|
||||
"metadata": {"shop_order_id": str(order.pk), "order_number": order.number},
|
||||
"success_url": success_url,
|
||||
"cancel_url": cancel_url,
|
||||
}
|
||||
customer_id = ""
|
||||
if order.user_id:
|
||||
try:
|
||||
customer_id = ensure_stripe_customer(order.user)
|
||||
except ShopError:
|
||||
raise
|
||||
except Exception:
|
||||
logger.exception("stripe customer create failed for order %s", order.number)
|
||||
if customer_id:
|
||||
params["customer"] = customer_id
|
||||
params["payment_intent_data"] = {"setup_future_usage": "on_session"}
|
||||
else:
|
||||
params["customer_email"] = order.email or None
|
||||
session = stripe.checkout.Session.create(**params)
|
||||
order.stripe_checkout_session_id = session.id
|
||||
order.hosted_checkout_url = session.url or ""
|
||||
order.status = Order.Status.OPEN
|
||||
@@ -353,7 +404,9 @@ def _notify_pos(order: Order) -> None:
|
||||
enqueue_online_sale(order)
|
||||
|
||||
|
||||
def mark_paid(order: Order, *, stripe_id: str = "") -> None:
|
||||
def mark_paid(
|
||||
order: Order, *, stripe_id: str = "", stripe_customer_id: str = ""
|
||||
) -> None:
|
||||
if order.status == Order.Status.PAID:
|
||||
return
|
||||
with transaction.atomic():
|
||||
@@ -365,6 +418,7 @@ def mark_paid(order: Order, *, stripe_id: str = "") -> None:
|
||||
locked.paid_at = timezone.now()
|
||||
locked.save(update_fields=["status", "paid_at", "updated_at"])
|
||||
order.refresh_from_db()
|
||||
remember_stripe_customer(order, stripe_customer_id)
|
||||
try:
|
||||
send_order_email(order)
|
||||
except Exception:
|
||||
@@ -614,3 +668,28 @@ def product_media_payload(product: Product, colors: list[ProductColor]) -> dict:
|
||||
"available": available_qty(product, color),
|
||||
}
|
||||
return {"shared": shared, "stl": product.stl_url, "colors": color_data}
|
||||
|
||||
|
||||
_REVIEWABLE_STATUSES = (Order.Status.PAID, Order.Status.FULFILLED)
|
||||
|
||||
|
||||
def qualifying_order_for_review(user, product: Product) -> Order | None:
|
||||
if not user or not getattr(user, "is_authenticated", False):
|
||||
return None
|
||||
email = (user.email or user.username or "").strip()
|
||||
qs = (
|
||||
Order.objects.filter(
|
||||
items__product=product,
|
||||
status__in=_REVIEWABLE_STATUSES,
|
||||
)
|
||||
.filter(Q(user=user) | Q(email__iexact=email))
|
||||
.distinct()
|
||||
.order_by("-paid_at", "-created_at")
|
||||
)
|
||||
return qs.first()
|
||||
|
||||
|
||||
def user_has_reviewed(user, product: Product) -> bool:
|
||||
if not user or not getattr(user, "is_authenticated", False):
|
||||
return False
|
||||
return ProductReview.objects.filter(user=user, product=product).exists()
|
||||
|
||||
@@ -0,0 +1,49 @@
|
||||
{% extends "accounts/account_base.html" %}
|
||||
{% block title %}Order {{ order.number }} · {{ SITE_NAME }}{% endblock %}
|
||||
{% block account_content %}
|
||||
<h3>Order {{ order.number }}</h3>
|
||||
<p>{{ order.get_status_display }} · ${{ order.amount }} {{ order.currency|upper }}</p>
|
||||
{% if order.customer_name %}<p>{{ order.customer_name }}</p>{% endif %}
|
||||
{% if order.shipping_address.line1 %}
|
||||
<p>
|
||||
{{ order.shipping_address.line1 }}{% if order.shipping_address.line2 %}, {{ order.shipping_address.line2 }}{% endif %}<br>
|
||||
{{ order.shipping_address.city }} {{ order.shipping_address.state }} {{ order.shipping_address.zip }}
|
||||
</p>
|
||||
{% endif %}
|
||||
<ul class="list-description">
|
||||
{% for item in order.items.all %}
|
||||
<li>
|
||||
<span>{{ item.quantity }}× {{ item.name }}</span>
|
||||
<span>${{ item.line_total }}</span>
|
||||
</li>
|
||||
{% endfor %}
|
||||
</ul>
|
||||
<h4>Shipments</h4>
|
||||
{% for shipment in order.shipments.all %}
|
||||
<div class="product-review-list">
|
||||
<p>
|
||||
{{ shipment.carrier }} {{ shipment.service }}
|
||||
· {{ shipment.get_status_display }}
|
||||
{% if shipment.tracking_status %} · {{ shipment.get_tracking_status_display }}{% endif %}
|
||||
</p>
|
||||
{% if shipment.tracking_number %}
|
||||
<p>
|
||||
Tracking {{ shipment.tracking_number }}
|
||||
{% if shipment.tracking_url %}
|
||||
· <a href="{{ shipment.tracking_url }}" target="_blank" rel="noopener">Track package</a>
|
||||
{% endif %}
|
||||
</p>
|
||||
{% endif %}
|
||||
{% if shipment.tracking_events %}
|
||||
<ul>
|
||||
{% for event in shipment.tracking_events %}
|
||||
<li>{{ event.datetime }} — {{ event.message|default:event.status }}{% if event.location %} ({{ event.location }}){% endif %}</li>
|
||||
{% endfor %}
|
||||
</ul>
|
||||
{% endif %}
|
||||
</div>
|
||||
{% empty %}
|
||||
<p>Not shipped yet. Tracking appears here once a label is bought or a tracking number is added.</p>
|
||||
{% endfor %}
|
||||
<p><a href="{% url 'account:orders' %}">← All orders</a></p>
|
||||
{% endblock %}
|
||||
@@ -0,0 +1,25 @@
|
||||
{% extends "accounts/account_base.html" %}
|
||||
{% block title %}Orders · {{ SITE_NAME }}{% endblock %}
|
||||
{% block account_content %}
|
||||
<h3>Order history</h3>
|
||||
{% if orders %}
|
||||
<ul class="list-description">
|
||||
{% for order in orders %}
|
||||
<li>
|
||||
<span>
|
||||
<a href="{% url 'account:order_detail' order.pk %}">{{ order.number }}</a>
|
||||
· {{ order.get_status_display }}
|
||||
{% for shipment in order.shipments.all %}
|
||||
{% if forloop.first and shipment.tracking_number %}
|
||||
· {{ shipment.get_tracking_status_display|default:"Shipped" }}
|
||||
{% endif %}
|
||||
{% endfor %}
|
||||
</span>
|
||||
<span>${{ order.amount }}</span>
|
||||
</li>
|
||||
{% endfor %}
|
||||
</ul>
|
||||
{% else %}
|
||||
<p>No orders yet. <a href="{% url 'shop:list' %}">Browse the shop</a>.</p>
|
||||
{% endif %}
|
||||
{% endblock %}
|
||||
@@ -11,49 +11,54 @@
|
||||
<div class="row row-50 justify-content-center">
|
||||
<div class="col-md-10 col-lg-6">
|
||||
<h3 class="font-base text-gray-800 text-uppercase">Shipping</h3>
|
||||
{% if user.is_authenticated %}
|
||||
<p>Signed in as {{ user.email }}. Cards stay on Stripe; we never store card numbers. <a href="{% url 'account:profile' %}">Edit profile</a></p>
|
||||
{% else %}
|
||||
<p>Have an account? <a href="{% url 'account:login' %}?next={{ request.path }}">Sign in</a> to prefill shipping and save cards on Stripe. Or <a href="{% url 'account:register' %}">create one</a>.</p>
|
||||
{% endif %}
|
||||
<form class="rd-form form-checkout" method="post">
|
||||
{% csrf_token %}
|
||||
<div class="row row-20 gutter-20" data-address-autocomplete data-suggest-url="{% url 'address_suggest' %}">
|
||||
<div class="col-12">
|
||||
<div class="form-wrap">
|
||||
<label class="form-label-outside" for="checkout-name">Name</label>
|
||||
<input class="form-input" id="checkout-name" type="text" name="customer_name">
|
||||
<input class="form-input" id="checkout-name" type="text" name="customer_name" value="{{ checkout_initial.customer_name }}">
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-12">
|
||||
<div class="form-wrap">
|
||||
<label class="form-label-outside" for="checkout-email">E-Mail</label>
|
||||
<input class="form-input" id="checkout-email" type="email" name="email" required>
|
||||
<input class="form-input" id="checkout-email" type="email" name="email" required value="{{ checkout_initial.email }}" {% if user.is_authenticated %}readonly{% endif %}>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-12">
|
||||
<div class="form-wrap">
|
||||
<label class="form-label-outside" for="checkout-address">Address</label>
|
||||
<input class="form-input" id="checkout-address" type="text" name="address_line1" data-ac="line1" autocomplete="off">
|
||||
<input class="form-input" id="checkout-address" type="text" name="address_line1" data-ac="line1" autocomplete="off" value="{{ checkout_initial.address.line1|default:'' }}">
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-12">
|
||||
<div class="form-wrap">
|
||||
<label class="form-label-outside" for="checkout-address-2">Apt / suite</label>
|
||||
<input class="form-input" id="checkout-address-2" type="text" name="address_line2" data-ac="line2" autocomplete="address-line2">
|
||||
<input class="form-input" id="checkout-address-2" type="text" name="address_line2" data-ac="line2" autocomplete="address-line2" value="{{ checkout_initial.address.line2|default:'' }}">
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-sm-6">
|
||||
<div class="form-wrap">
|
||||
<label class="form-label-outside" for="checkout-city">City</label>
|
||||
<input class="form-input" id="checkout-city" type="text" name="address_city" data-ac="city" autocomplete="address-level2">
|
||||
<input class="form-input" id="checkout-city" type="text" name="address_city" data-ac="city" autocomplete="address-level2" value="{{ checkout_initial.address.city|default:'' }}">
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-sm-6">
|
||||
<div class="form-wrap">
|
||||
<label class="form-label-outside" for="checkout-state">State</label>
|
||||
<input class="form-input" id="checkout-state" type="text" name="address_state" data-ac="state" autocomplete="address-level1">
|
||||
<input class="form-input" id="checkout-state" type="text" name="address_state" data-ac="state" autocomplete="address-level1" value="{{ checkout_initial.address.state|default:'' }}">
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-sm-6">
|
||||
<div class="form-wrap">
|
||||
<label class="form-label-outside" for="checkout-zip">ZIP</label>
|
||||
<input class="form-input" id="checkout-zip" type="text" name="address_zip" data-ac="zip" autocomplete="postal-code">
|
||||
<input class="form-input" id="checkout-zip" type="text" name="address_zip" data-ac="zip" autocomplete="postal-code" value="{{ checkout_initial.address.zip|default:'' }}">
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-12">
|
||||
|
||||
@@ -94,6 +94,57 @@
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="row row-40">
|
||||
<div class="col-lg-10">
|
||||
<h4>Reviews</h4>
|
||||
{% if review_count %}
|
||||
<p class="product-review-summary">{{ review_avg|floatformat:1 }} / 5 · {{ review_count }} review{{ review_count|pluralize }}</p>
|
||||
{% else %}
|
||||
<p>No reviews yet.</p>
|
||||
{% endif %}
|
||||
{% if can_review %}
|
||||
<form class="rd-form product-review-form" method="post" action="{% url 'shop:review' product.slug %}">
|
||||
{% csrf_token %}
|
||||
<div class="form-wrap">
|
||||
<label class="form-label-outside" for="review-rating">Rating</label>
|
||||
<select class="form-input" id="review-rating" name="rating" required>
|
||||
<option value="">Choose 1–5</option>
|
||||
<option value="5">5 — Excellent</option>
|
||||
<option value="4">4 — Good</option>
|
||||
<option value="3">3 — Okay</option>
|
||||
<option value="2">2 — Fair</option>
|
||||
<option value="1">1 — Poor</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="form-wrap">
|
||||
<label class="form-label-outside" for="review-title">Title (optional)</label>
|
||||
<input class="form-input" id="review-title" type="text" name="title" maxlength="120">
|
||||
</div>
|
||||
<div class="form-wrap">
|
||||
<label class="form-label-outside" for="review-body">Review (optional)</label>
|
||||
<textarea class="form-input" id="review-body" name="body" rows="4"></textarea>
|
||||
</div>
|
||||
<button class="button button-primary" type="submit">Submit review</button>
|
||||
</form>
|
||||
{% elif already_reviewed %}
|
||||
<p>You already reviewed this product.</p>
|
||||
{% elif user.is_authenticated %}
|
||||
<p>Buy this product to leave a review.</p>
|
||||
{% else %}
|
||||
<p><a href="{% url 'account:login' %}?next={{ request.path }}">Sign in</a> after a purchase to leave a review.</p>
|
||||
{% endif %}
|
||||
<ul class="product-review-list">
|
||||
{% for review in reviews %}
|
||||
<li>
|
||||
<strong>{{ review.rating }}/5</strong>
|
||||
{% if review.title %} · {{ review.title }}{% endif %}
|
||||
<span class="text-gray-600"> — {{ review.user.first_name|default:review.user.email }}</span>
|
||||
{% if review.body %}<p>{{ review.body|linebreaks }}</p>{% endif %}
|
||||
</li>
|
||||
{% endfor %}
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
{% endblock %}
|
||||
|
||||
@@ -17,5 +17,26 @@
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
{% if order.shipments.all %}
|
||||
<h3>Shipments</h3>
|
||||
<table class="table">
|
||||
<thead><tr><th>Carrier</th><th>Tracking</th><th>Status</th></tr></thead>
|
||||
<tbody>
|
||||
{% for shipment in order.shipments.all %}
|
||||
<tr>
|
||||
<td>{{ shipment.carrier }} {{ shipment.service }}</td>
|
||||
<td>
|
||||
{% if shipment.tracking_url %}
|
||||
<a href="{{ shipment.tracking_url }}" target="_blank" rel="noopener">{{ shipment.tracking_number }}</a>
|
||||
{% else %}
|
||||
{{ shipment.tracking_number|default:"—" }}
|
||||
{% endif %}
|
||||
</td>
|
||||
<td>{{ shipment.get_tracking_status_display|default:shipment.get_status_display }}</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
{% endif %}
|
||||
<p><a href="{% url 'shop_portal:order_list' %}">← All orders</a></p>
|
||||
{% endblock %}
|
||||
|
||||
@@ -7,7 +7,13 @@
|
||||
<h2>Thank <span class="text-italic font-weight-thin">you</span></h2>
|
||||
<p class="big">Order {{ order.number }} is {{ order.get_status_display|lower }}.</p>
|
||||
<p>A confirmation will go to {{ order.email }}.</p>
|
||||
<a class="button button-lg button-primary" href="{% url 'shop:list' %}">Continue shopping</a>
|
||||
{% if user.is_authenticated %}
|
||||
<p><a class="button button-lg button-primary" href="{% url 'account:order_detail' order.pk %}">View order</a></p>
|
||||
{% else %}
|
||||
<p>Create an account to track shipping and review products you bought. Card details stay with Stripe.</p>
|
||||
<p><a class="button button-lg button-primary" href="{% url 'account:register' %}?email={{ order.email|urlencode }}">Create account</a></p>
|
||||
{% endif %}
|
||||
<a class="button button-lg button-default-outline" href="{% url 'shop:list' %}">Continue shopping</a>
|
||||
</div>
|
||||
</section>
|
||||
{% endblock %}
|
||||
|
||||
+98
-2
@@ -24,6 +24,7 @@ from shop.services import (
|
||||
add_to_cart,
|
||||
adjust_stock,
|
||||
available_qty,
|
||||
create_checkout_session,
|
||||
create_order_from_cart,
|
||||
looks_like_stl,
|
||||
mark_paid,
|
||||
@@ -370,7 +371,7 @@ class ShopInventoryTests(TestCase):
|
||||
class ShopPortalTests(TestCase):
|
||||
def setUp(self):
|
||||
User = get_user_model()
|
||||
self.user = User.objects.create_user("merchant", password="test-pass-123")
|
||||
self.user = User.objects.create_user("merchant", password="test-pass-123", is_staff=True)
|
||||
self.client = Client()
|
||||
self.client.login(username="merchant", password="test-pass-123")
|
||||
|
||||
@@ -663,7 +664,7 @@ def _sold_order(product, *, qty=1, paid_at=None, status=None, number=None):
|
||||
class ShopSalesDashboardTests(TestCase):
|
||||
def setUp(self):
|
||||
User = get_user_model()
|
||||
self.user = User.objects.create_user("merchant", password="test-pass-123")
|
||||
self.user = User.objects.create_user("merchant", password="test-pass-123", is_staff=True)
|
||||
self.client = Client()
|
||||
self.client.login(username="merchant", password="test-pass-123")
|
||||
self.dragon = _product(name="Dragon", sku="DRAGON", price=Decimal("18.00"))
|
||||
@@ -741,3 +742,98 @@ class ShopSalesDashboardTests(TestCase):
|
||||
sales = self.client.get(reverse("shop_portal:sales"))
|
||||
self.assertContains(sales, 'class="active"')
|
||||
|
||||
|
||||
class ShopAccountAndReviewTests(TestCase):
|
||||
def setUp(self):
|
||||
User = get_user_model()
|
||||
self.user = User.objects.create_user(
|
||||
username="buyer@example.com",
|
||||
email="buyer@example.com",
|
||||
password="s3cure-pass-123",
|
||||
)
|
||||
self.product = _product()
|
||||
self.client = Client()
|
||||
self.client.login(username="buyer@example.com", password="s3cure-pass-123")
|
||||
|
||||
def test_checkout_attaches_user_and_uses_stripe_customer(self):
|
||||
session = self.client.session
|
||||
add_to_cart(session, self.product, 1)
|
||||
session.save()
|
||||
captured = {}
|
||||
|
||||
class FakeCustomer:
|
||||
id = "cus_abc"
|
||||
|
||||
class FakeCheckout:
|
||||
id = "cs_abc"
|
||||
url = "https://stripe.test/pay"
|
||||
|
||||
class FakeStripe:
|
||||
class Customer:
|
||||
@staticmethod
|
||||
def create(**kwargs):
|
||||
captured["customer"] = kwargs
|
||||
return FakeCustomer()
|
||||
|
||||
class checkout:
|
||||
class Session:
|
||||
@staticmethod
|
||||
def create(**kwargs):
|
||||
captured["session"] = kwargs
|
||||
return FakeCheckout()
|
||||
|
||||
with patch("shop.services._stripe", return_value=FakeStripe):
|
||||
order = create_order_from_cart(
|
||||
self.client.session,
|
||||
email="buyer@example.com",
|
||||
user=self.user,
|
||||
)
|
||||
url = create_checkout_session(
|
||||
order,
|
||||
success_url="https://example.test/ok",
|
||||
cancel_url="https://example.test/no",
|
||||
)
|
||||
self.assertEqual(url, "https://stripe.test/pay")
|
||||
self.assertEqual(order.user, self.user)
|
||||
self.assertEqual(captured["session"]["customer"], "cus_abc")
|
||||
self.assertNotIn("customer_email", captured["session"])
|
||||
self.assertEqual(
|
||||
captured["session"]["payment_intent_data"]["setup_future_usage"],
|
||||
"on_session",
|
||||
)
|
||||
self.user.customer_profile.refresh_from_db()
|
||||
self.assertEqual(self.user.customer_profile.stripe_customer_id, "cus_abc")
|
||||
|
||||
def test_review_requires_purchase(self):
|
||||
blocked = self.client.post(
|
||||
reverse("shop:review", kwargs={"slug": self.product.slug}),
|
||||
{"rating": "5", "title": "Nope", "body": "Did not buy"},
|
||||
)
|
||||
self.assertEqual(blocked.status_code, 302)
|
||||
self.assertEqual(self.product.reviews.count(), 0)
|
||||
|
||||
order = _sold_order(self.product)
|
||||
order.user = self.user
|
||||
order.email = self.user.email
|
||||
order.save(update_fields=["user", "email"])
|
||||
ok = self.client.post(
|
||||
reverse("shop:review", kwargs={"slug": self.product.slug}),
|
||||
{"rating": "5", "title": "Great", "body": "Loved it"},
|
||||
)
|
||||
self.assertEqual(ok.status_code, 302)
|
||||
review = self.product.reviews.get()
|
||||
self.assertEqual(review.rating, 5)
|
||||
self.assertEqual(review.user, self.user)
|
||||
detail = self.client.get(self.product.get_absolute_url())
|
||||
self.assertContains(detail, "Great")
|
||||
self.assertContains(detail, "You already reviewed")
|
||||
|
||||
def test_order_history_hides_other_users(self):
|
||||
mine = _sold_order(self.product, number="ORD-MINE")
|
||||
mine.user = self.user
|
||||
mine.save(update_fields=["user"])
|
||||
_sold_order(self.product, number="ORD-THEIRS")
|
||||
page = self.client.get(reverse("account:orders"))
|
||||
self.assertContains(page, "ORD-MINE")
|
||||
self.assertNotContains(page, "ORD-THEIRS")
|
||||
|
||||
|
||||
+131
-5
@@ -5,7 +5,7 @@ from django.conf import settings
|
||||
from django.contrib import messages
|
||||
from django.contrib.auth.decorators import login_required
|
||||
from django.db import transaction
|
||||
from django.db.models import Prefetch
|
||||
from django.db.models import Avg, Count, Prefetch
|
||||
from django.http import HttpResponse, HttpResponseBadRequest
|
||||
from django.shortcuts import get_object_or_404, redirect, render
|
||||
from django.urls import reverse
|
||||
@@ -14,7 +14,7 @@ from django.views.decorators.csrf import csrf_exempt
|
||||
from django.views.decorators.http import require_http_methods, require_POST
|
||||
|
||||
from contacts.models import Contact
|
||||
from shop.models import Order, Product, ProductColor, ProductImage
|
||||
from shop.models import Order, Product, ProductColor, ProductImage, ProductReview
|
||||
from shop.services import (
|
||||
ShopError,
|
||||
add_to_cart,
|
||||
@@ -27,12 +27,14 @@ from shop.services import (
|
||||
create_order_from_cart,
|
||||
mark_paid,
|
||||
product_media_payload,
|
||||
qualifying_order_for_review,
|
||||
refresh_listing_image,
|
||||
remove_product_images,
|
||||
save_cart,
|
||||
set_cart_qty,
|
||||
store_product_stl,
|
||||
sync_product_colors,
|
||||
user_has_reviewed,
|
||||
)
|
||||
from shop.stats import sales_dashboard
|
||||
|
||||
@@ -85,6 +87,16 @@ def product_detail(request, slug):
|
||||
)
|
||||
colors = list(product.colors.all())
|
||||
selected = colors[0] if colors else None
|
||||
reviews = list(product.reviews.select_related("user").all()[:50])
|
||||
stats = product.reviews.aggregate(avg=Avg("rating"), n=Count("id"))
|
||||
can_review = False
|
||||
already_reviewed = False
|
||||
if request.user.is_authenticated:
|
||||
already_reviewed = user_has_reviewed(request.user, product)
|
||||
can_review = (
|
||||
not already_reviewed
|
||||
and qualifying_order_for_review(request.user, product) is not None
|
||||
)
|
||||
return render(
|
||||
request,
|
||||
"shop/detail.html",
|
||||
@@ -96,10 +108,47 @@ def product_detail(request, slug):
|
||||
"gallery_items": product.gallery_items(selected),
|
||||
"gallery_photos": product.photos_for(selected),
|
||||
"gallery_data": product_media_payload(product, colors),
|
||||
"reviews": reviews,
|
||||
"review_avg": stats["avg"],
|
||||
"review_count": stats["n"] or 0,
|
||||
"can_review": can_review,
|
||||
"already_reviewed": already_reviewed,
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
@login_required(login_url="account:login")
|
||||
@require_POST
|
||||
def product_review(request, slug):
|
||||
product = get_object_or_404(Product, slug=slug, is_published=True)
|
||||
if user_has_reviewed(request.user, product):
|
||||
messages.info(request, "You already reviewed this product.")
|
||||
return redirect("shop:detail", slug=product.slug)
|
||||
order = qualifying_order_for_review(request.user, product)
|
||||
if order is None:
|
||||
messages.error(request, "Only customers who purchased this product can review it.")
|
||||
return redirect("shop:detail", slug=product.slug)
|
||||
try:
|
||||
rating = int(request.POST.get("rating") or "0")
|
||||
except ValueError:
|
||||
rating = 0
|
||||
if rating < 1 or rating > 5:
|
||||
messages.error(request, "Choose a rating from 1 to 5.")
|
||||
return redirect("shop:detail", slug=product.slug)
|
||||
title = (request.POST.get("title") or "").strip()[:120]
|
||||
body = (request.POST.get("body") or "").strip()
|
||||
ProductReview.objects.create(
|
||||
product=product,
|
||||
user=request.user,
|
||||
order=order,
|
||||
rating=rating,
|
||||
title=title,
|
||||
body=body,
|
||||
)
|
||||
messages.success(request, "Thanks for the review.")
|
||||
return redirect("shop:detail", slug=product.slug)
|
||||
|
||||
|
||||
def cart_view(request):
|
||||
lines = cart_lines(request.session)
|
||||
return render(
|
||||
@@ -169,12 +218,18 @@ def checkout(request):
|
||||
state=request.POST.get("address_state") or "",
|
||||
zip_code=request.POST.get("address_zip") or "",
|
||||
)
|
||||
buyer = request.user if request.user.is_authenticated else None
|
||||
if buyer:
|
||||
email = (buyer.email or buyer.username or email).strip()
|
||||
if not name:
|
||||
name = buyer.get_full_name()
|
||||
try:
|
||||
order = create_order_from_cart(
|
||||
request.session,
|
||||
email=email,
|
||||
customer_name=name,
|
||||
shipping_address=address,
|
||||
user=buyer,
|
||||
)
|
||||
base = _site_base(request)
|
||||
success = base + reverse("shop:checkout_success", kwargs={"pk": order.pk})
|
||||
@@ -192,10 +247,28 @@ def checkout(request):
|
||||
else:
|
||||
save_cart(request.session, {})
|
||||
return redirect(url)
|
||||
checkout_initial = {
|
||||
"email": "",
|
||||
"customer_name": "",
|
||||
"address": {},
|
||||
}
|
||||
if request.user.is_authenticated:
|
||||
from accounts.services import get_customer_profile
|
||||
|
||||
profile = get_customer_profile(request.user)
|
||||
checkout_initial = {
|
||||
"email": request.user.email or request.user.username,
|
||||
"customer_name": request.user.get_full_name(),
|
||||
"address": profile.shipping_address or {},
|
||||
}
|
||||
return render(
|
||||
request,
|
||||
"shop/checkout.html",
|
||||
{"lines": lines, "total": cart_total(lines)},
|
||||
{
|
||||
"lines": lines,
|
||||
"total": cart_total(lines),
|
||||
"checkout_initial": checkout_initial,
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
@@ -361,10 +434,59 @@ def portal_order_list(request):
|
||||
|
||||
@login_required
|
||||
def portal_order_detail(request, pk):
|
||||
order = get_object_or_404(Order.objects.prefetch_related("items"), pk=pk)
|
||||
order = get_object_or_404(
|
||||
Order.objects.prefetch_related("items", "shipments"), pk=pk
|
||||
)
|
||||
return render(request, "shop/portal/order_detail.html", {"order": order})
|
||||
|
||||
|
||||
@login_required(login_url="account:login")
|
||||
def account_order_list(request):
|
||||
from accounts.services import claim_orders_for_user
|
||||
|
||||
claim_orders_for_user(request.user)
|
||||
orders = (
|
||||
Order.objects.filter(user=request.user)
|
||||
.prefetch_related("items", "shipments")
|
||||
.exclude(status=Order.Status.DRAFT)
|
||||
)
|
||||
return render(request, "shop/account/orders.html", {"orders": orders})
|
||||
|
||||
|
||||
@login_required(login_url="account:login")
|
||||
def account_order_detail(request, pk):
|
||||
order = get_object_or_404(
|
||||
Order.objects.prefetch_related("items", "shipments"),
|
||||
pk=pk,
|
||||
user=request.user,
|
||||
)
|
||||
from django.apps import apps as django_apps
|
||||
|
||||
if django_apps.is_installed("shipping"):
|
||||
from datetime import timedelta
|
||||
|
||||
from django.utils import timezone
|
||||
from shipping.models import Shipment
|
||||
from shipping.services import refresh_tracking
|
||||
|
||||
stale_after = timezone.now() - timedelta(minutes=15)
|
||||
for shipment in order.shipments.all():
|
||||
if (
|
||||
shipment.status == Shipment.Status.LABELED
|
||||
and shipment.tracking_number
|
||||
and shipment.tracking_status != Shipment.TrackingStatus.DELIVERED
|
||||
and (
|
||||
shipment.last_tracked_at is None
|
||||
or shipment.last_tracked_at < stale_after
|
||||
)
|
||||
):
|
||||
try:
|
||||
refresh_tracking(shipment)
|
||||
except Exception:
|
||||
logger.exception("order tracking refresh failed for %s", order.number)
|
||||
return render(request, "shop/account/order_detail.html", {"order": order})
|
||||
|
||||
|
||||
@csrf_exempt
|
||||
@require_http_methods(["POST"])
|
||||
def stripe_webhook(request):
|
||||
@@ -394,6 +516,10 @@ def stripe_webhook(request):
|
||||
session_id = obj.get("id") or ""
|
||||
order = Order.objects.filter(stripe_checkout_session_id=session_id).first()
|
||||
if order and order.status != Order.Status.PAID:
|
||||
mark_paid(order, stripe_id=obj.get("id") or "")
|
||||
mark_paid(
|
||||
order,
|
||||
stripe_id=obj.get("id") or "",
|
||||
stripe_customer_id=obj.get("customer") or "",
|
||||
)
|
||||
logger.info("shop order %s marked paid", order.number)
|
||||
return HttpResponse("ok")
|
||||
|
||||
Reference in New Issue
Block a user