Template
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:
+9
-1
@@ -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",
|
||||
),
|
||||
),
|
||||
]
|
||||
@@ -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}"
|
||||
|
||||
@@ -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
@@ -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 %}
|
||||
@@ -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 %}
|
||||
|
||||
@@ -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 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>
|
||||
</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 %}
|
||||
|
||||
@@ -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
@@ -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
@@ -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")
|
||||
|
||||
Reference in New Issue
Block a user