Add shopper accounts, reviews, tracking, and seed_demo (#9)

Closes #9. Shop-gated buyer accounts, purchase reviews, Stripe customer ids, shipment tracking, slim public contact form, and a template-neutral seed_demo command.
This commit is contained in:
2026-09-07 08:35:55 -05:00
parent 9cdce7a897
commit 5b11cc18c7
66 changed files with 3051 additions and 285 deletions
+9 -1
View File
@@ -1,6 +1,6 @@
from django.contrib import admin
from shop.models import Order, OrderItem, Product
from shop.models import Order, OrderItem, Product, ProductReview
@admin.register(Product)
@@ -11,6 +11,13 @@ class ProductAdmin(admin.ModelAdmin):
prepopulated_fields = {"slug": ("name",)}
@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
@@ -21,4 +28,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", "0001_initial"),
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",
),
),
]
+44
View File
@@ -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
@@ -68,6 +70,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(
@@ -116,3 +125,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}"
+1
View File
@@ -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
View File
@@ -8,10 +8,10 @@ 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 shop.models import Order, OrderItem, Product
from shop.models import Order, OrderItem, Product, ProductReview
logger = logging.getLogger(__name__)
@@ -156,6 +156,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:
@@ -169,6 +170,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,
@@ -191,6 +193,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 = [
@@ -208,14 +246,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
@@ -249,7 +300,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():
@@ -261,6 +314,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:
@@ -303,3 +357,28 @@ def send_order_email(order: Order) -> bool:
mail.attach_alternative(html, "text/html")
mail.send(fail_silently=False)
return True
_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,48 @@
{% 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>
{% for item in order.items.all %}
<li>{{ item.quantity }}× {{ item.name }} — ${{ item.line_total }}</li>
{% endfor %}
</ul>
{% if "shipping" in enabled_features %}
<h4>Shipments</h4>
{% for shipment in order.shipments.all %}
<div>
<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 %}
{% endif %}
<p><a href="{% url 'account:orders' %}">← All orders</a></p>
{% endblock %}
@@ -0,0 +1,18 @@
{% extends "accounts/account_base.html" %}
{% block title %}Orders · {{ SITE_NAME }}{% endblock %}
{% block account_content %}
<h3>Order history</h3>
{% if orders %}
<ul>
{% for order in orders %}
<li>
<a href="{% url 'account:order_detail' order.pk %}">{{ order.number }}</a>
· {{ order.get_status_display }}
· {{ order.amount }}
</li>
{% endfor %}
</ul>
{% else %}
<p>No orders yet. <a href="{% url 'shop:list' %}">Browse the shop</a>.</p>
{% endif %}
{% endblock %}
+21 -7
View File
@@ -1,21 +1,35 @@
{% extends "base.html" %}
{% load static %}
{% block title %}Checkout · {{ SITE_NAME }}{% endblock %}
{% block extra_head %}
<link rel="stylesheet" href="{% static 'css/address-autocomplete.css' %}">
{% endblock %}
{% block content %}
<section class="section section-lg">
<div class="container">
<h1>Checkout</h1>
<p>Total: {{ total }}</p>
{% 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 method="post">
{% csrf_token %}
<p><label>Email <input name="email" type="email" required></label></p>
<p><label>Name <input name="customer_name"></label></p>
<p><label>Address <input name="address_line1"></label></p>
<p><label>Address 2 <input name="address_line2"></label></p>
<p><label>City <input name="address_city"></label></p>
<p><label>State <input name="address_state"></label></p>
<p><label>ZIP <input name="address_zip"></label></p>
<div data-address-autocomplete data-suggest-url="{% url 'address_suggest' %}">
<p><label>Email <input class="form-input" name="email" type="email" required value="{{ checkout_initial.email }}" {% if user.is_authenticated %}readonly{% endif %}></label></p>
<p><label>Name <input class="form-input" name="customer_name" value="{{ checkout_initial.customer_name }}"></label></p>
<p><label>Address <input class="form-input" name="address_line1" data-ac="line1" autocomplete="off" value="{{ checkout_initial.address.line1|default:'' }}"></label></p>
<p><label>Address 2 <input class="form-input" name="address_line2" data-ac="line2" autocomplete="address-line2" value="{{ checkout_initial.address.line2|default:'' }}"></label></p>
<p><label>City <input class="form-input" name="address_city" data-ac="city" autocomplete="address-level2" value="{{ checkout_initial.address.city|default:'' }}"></label></p>
<p><label>State <input class="form-input" name="address_state" data-ac="state" autocomplete="address-level1" value="{{ checkout_initial.address.state|default:'' }}"></label></p>
<p><label>ZIP <input class="form-input" name="address_zip" data-ac="zip" autocomplete="postal-code" value="{{ checkout_initial.address.zip|default:'' }}"></label></p>
</div>
<button class="button button-primary" type="submit">Pay with Stripe</button>
</form>
</div>
</section>
{% endblock %}
{% block extra_js %}
<script src="{% static 'js/address-autocomplete.js' %}"></script>
{% endblock %}
+41
View File
@@ -15,6 +15,47 @@
<label>Qty <input name="quantity" type="number" min="1" value="1"></label>
<button class="button button-primary" type="submit">Add to cart</button>
</form>
<h2>Reviews</h2>
{% if review_count %}
<p>{{ review_avg|floatformat:1 }} / 5 · {{ review_count }} review{{ review_count|pluralize }}</p>
{% else %}
<p>No reviews yet.</p>
{% endif %}
{% if can_review %}
<form method="post" action="{% url 'shop:review' product.slug %}">
{% csrf_token %}
<p>
<label for="review-rating">Rating</label>
<select id="review-rating" name="rating" required>
<option value="">Choose 15</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>
</p>
<p><label>Title (optional) <input name="title" maxlength="120"></label></p>
<p><label>Review (optional) <textarea name="body" rows="4"></textarea></label></p>
<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>
{% for review in reviews %}
<li>
<strong>{{ review.rating }}/5</strong>
{% if review.title %} · {{ review.title }}{% endif %}
<span> — {{ review.user.first_name|default:review.user.email }}</span>
{% if review.body %}<p>{{ review.body|linebreaks }}</p>{% endif %}
</li>
{% endfor %}
</ul>
</div>
</section>
{% endblock %}
@@ -17,5 +17,26 @@
{% endfor %}
</tbody>
</table>
{% if "shipping" in enabled_features and 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
View File
@@ -5,5 +5,12 @@
<h1>Thank you</h1>
<p>Order {{ order.number }} is {{ order.get_status_display|lower }}.</p>
<p>A confirmation will go to {{ order.email }}.</p>
{% if user.is_authenticated %}
<p><a class="button 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-primary" href="{% url 'account:register' %}?email={{ order.email|urlencode }}">Create account</a></p>
{% endif %}
<p><a href="{% url 'shop:list' %}">Continue shopping</a></p>
</div></section>
{% endblock %}
+103 -2
View File
@@ -1,5 +1,6 @@
from datetime import timedelta
from decimal import Decimal
from unittest.mock import patch
from django.contrib.auth import get_user_model
from django.core import mail
@@ -13,6 +14,7 @@ from shop.services import (
add_to_cart,
adjust_stock,
available_qty,
create_checkout_session,
create_order_from_cart,
mark_paid,
next_order_number,
@@ -116,7 +118,9 @@ 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")
@@ -175,7 +179,9 @@ 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"))
@@ -252,3 +258,98 @@ class ShopSalesDashboardTests(TestCase):
self.assertContains(products, "Sales")
sales = self.client.get(reverse("shop_portal:sales"))
self.assertContains(sales, 'class="active"')
class ShopCustomerAccountTests(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")
+138 -5
View File
@@ -4,6 +4,7 @@ from decimal import Decimal, InvalidOperation
from django.conf import settings
from django.contrib import messages
from django.contrib.auth.decorators import login_required
from django.db.models import Avg, Count
from django.http import HttpResponse, HttpResponseBadRequest
from django.shortcuts import get_object_or_404, redirect, render
from django.urls import reverse
@@ -12,7 +13,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
from shop.models import Order, Product, ProductReview
from shop.services import (
ShopError,
add_to_cart,
@@ -23,8 +24,10 @@ from shop.services import (
create_checkout_session,
create_order_from_cart,
mark_paid,
qualifying_order_for_review,
save_cart,
set_cart_qty,
user_has_reviewed,
)
from shop.stats import sales_dashboard
@@ -45,13 +48,63 @@ def product_list(request):
def product_detail(request, slug):
product = get_object_or_404(Product, slug=slug, is_published=True)
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",
{"product": product, "available": available_qty(product)},
{
"product": product,
"available": available_qty(product),
"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(
@@ -104,12 +157,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})
@@ -127,10 +186,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,
},
)
@@ -236,10 +313,62 @@ 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)
qs = Order.objects.prefetch_related("items")
from django.apps import apps as django_apps
if django_apps.is_installed("shipping"):
qs = qs.prefetch_related("shipments")
order = get_object_or_404(qs, 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
from django.apps import apps as django_apps
claim_orders_for_user(request.user)
qs = Order.objects.filter(user=request.user).prefetch_related("items")
if django_apps.is_installed("shipping"):
qs = qs.prefetch_related("shipments")
orders = qs.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):
from django.apps import apps as django_apps
qs = Order.objects.prefetch_related("items")
if django_apps.is_installed("shipping"):
qs = qs.prefetch_related("shipments")
order = get_object_or_404(qs, pk=pk, user=request.user)
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):
@@ -269,6 +398,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")