feat(shop): cart, order, invoice and discounts
Build out the shop domain: Cart/CartItem (one open cart per user per club), Order/OrderLine, Discount/AppliedDiscount, Payment and Invoice. Order and Invoice allocate a per-club, per-year sequential number (ORD-<year>-<seq> / INV-<year>-<seq>) via shared helpers, retrying on collision with the (club, number) unique constraint as the source of truth. Fixes found while testing: - Invoice had no number generator, so a second invoice in a club collided on the empty string and could never be created. - AppliedDiscount printed a "%" suffix even for fixed-amount discounts, and had no (order, discount) uniqueness, so a discount could be applied twice. Every model validates its club-scoped FKs (product/team/discount/order/season/ staff_role) against the owning club, and Member FKs against club membership. Register all models in the admin, with FK dropdowns scoped to the owning club. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
141
shop/admin.py
141
shop/admin.py
@@ -1 +1,140 @@
|
||||
# Register your models here.
|
||||
from django.contrib import admin
|
||||
|
||||
from .models import (
|
||||
AppliedDiscount,
|
||||
Cart,
|
||||
CartItem,
|
||||
Discount,
|
||||
Invoice,
|
||||
Order,
|
||||
OrderLine,
|
||||
Payment,
|
||||
Product,
|
||||
)
|
||||
|
||||
|
||||
class ClubScopedFKMixin:
|
||||
"""Restrict club-scoped FK dropdowns to the edited object's (or parent's) club.
|
||||
|
||||
``scoped_fk_fields`` names FKs whose target is a club-scoped model. The club
|
||||
is taken from the object being edited (or, for inlines, the parent object).
|
||||
Enforcement still lives in each model's ``clean()``; this just keeps invalid
|
||||
options out of the dropdowns.
|
||||
"""
|
||||
|
||||
scoped_fk_fields = ()
|
||||
|
||||
def get_form(self, request, obj=None, **kwargs):
|
||||
request._club_obj = obj
|
||||
return super().get_form(request, obj, **kwargs)
|
||||
|
||||
def get_formset(self, request, obj=None, **kwargs):
|
||||
request._club_obj = obj
|
||||
return super().get_formset(request, obj, **kwargs)
|
||||
|
||||
def formfield_for_foreignkey(self, db_field, request, **kwargs):
|
||||
if db_field.name in self.scoped_fk_fields:
|
||||
club_id = getattr(getattr(request, "_club_obj", None), "club_id", None)
|
||||
if club_id is not None:
|
||||
kwargs["queryset"] = db_field.related_model._default_manager.filter(club_id=club_id)
|
||||
return super().formfield_for_foreignkey(db_field, request, **kwargs)
|
||||
|
||||
|
||||
@admin.register(Product)
|
||||
class ProductAdmin(ClubScopedFKMixin, admin.ModelAdmin):
|
||||
scoped_fk_fields = ("season", "staff_role")
|
||||
list_display = ["name", "product_type", "club", "is_active", "is_public"]
|
||||
list_filter = ["club", "product_type", "is_active", "is_public"]
|
||||
search_fields = ["name", "slug"]
|
||||
prepopulated_fields = {"slug": ["name"]}
|
||||
|
||||
|
||||
class CartItemInline(ClubScopedFKMixin, admin.TabularInline):
|
||||
model = CartItem
|
||||
scoped_fk_fields = ("product", "team")
|
||||
extra = 0
|
||||
raw_id_fields = ["beneficiary"]
|
||||
|
||||
|
||||
@admin.register(Cart)
|
||||
class CartAdmin(admin.ModelAdmin):
|
||||
list_display = ["user", "status", "club"]
|
||||
list_filter = ["club", "status"]
|
||||
search_fields = ["user__email"]
|
||||
raw_id_fields = ["user"]
|
||||
inlines = [CartItemInline]
|
||||
|
||||
|
||||
@admin.register(CartItem)
|
||||
class CartItemAdmin(admin.ModelAdmin):
|
||||
list_display = ["cart", "product", "quantity", "beneficiary"]
|
||||
search_fields = ["product__name"]
|
||||
raw_id_fields = ["cart", "product", "beneficiary", "team"]
|
||||
|
||||
|
||||
class OrderLineInline(ClubScopedFKMixin, admin.TabularInline):
|
||||
model = OrderLine
|
||||
scoped_fk_fields = ("product", "team")
|
||||
extra = 0
|
||||
raw_id_fields = ["beneficiary"]
|
||||
|
||||
|
||||
class AppliedDiscountInline(ClubScopedFKMixin, admin.TabularInline):
|
||||
model = AppliedDiscount
|
||||
scoped_fk_fields = ("discount",)
|
||||
extra = 0
|
||||
raw_id_fields = ["applied_by"]
|
||||
|
||||
|
||||
class PaymentInline(admin.TabularInline):
|
||||
model = Payment
|
||||
extra = 0
|
||||
|
||||
|
||||
@admin.register(Order)
|
||||
class OrderAdmin(admin.ModelAdmin):
|
||||
list_display = ["number", "purchaser", "status", "total", "club", "created"]
|
||||
list_filter = ["club", "status"]
|
||||
search_fields = ["number", "purchaser__first_name", "purchaser__last_name"]
|
||||
raw_id_fields = ["purchaser"]
|
||||
readonly_fields = ["number", "created", "modified"]
|
||||
inlines = [OrderLineInline, AppliedDiscountInline, PaymentInline]
|
||||
|
||||
|
||||
@admin.register(OrderLine)
|
||||
class OrderLineAdmin(admin.ModelAdmin):
|
||||
list_display = ["order", "product", "quantity", "line_total"]
|
||||
search_fields = ["product__name", "order__number"]
|
||||
raw_id_fields = ["order", "product", "beneficiary", "team"]
|
||||
|
||||
|
||||
@admin.register(Discount)
|
||||
class DiscountAdmin(admin.ModelAdmin):
|
||||
list_display = ["name", "discount_type", "discount_amount", "club", "is_active"]
|
||||
list_filter = ["club", "discount_type", "is_active"]
|
||||
search_fields = ["name", "slug"]
|
||||
prepopulated_fields = {"slug": ["name"]}
|
||||
|
||||
|
||||
@admin.register(AppliedDiscount)
|
||||
class AppliedDiscountAdmin(admin.ModelAdmin):
|
||||
list_display = ["order", "discount", "discount_type", "discount_amount"]
|
||||
search_fields = ["order__number", "discount__name"]
|
||||
raw_id_fields = ["order", "discount", "applied_by"]
|
||||
|
||||
|
||||
@admin.register(Payment)
|
||||
class PaymentAdmin(admin.ModelAdmin):
|
||||
list_display = ["order", "amount", "method", "status", "paid_at"]
|
||||
list_filter = ["method", "status"]
|
||||
search_fields = ["order__number", "reference"]
|
||||
raw_id_fields = ["order"]
|
||||
|
||||
|
||||
@admin.register(Invoice)
|
||||
class InvoiceAdmin(ClubScopedFKMixin, admin.ModelAdmin):
|
||||
scoped_fk_fields = ("order",)
|
||||
list_display = ["number", "order", "club", "issued_at", "due_date"]
|
||||
list_filter = ["club"]
|
||||
search_fields = ["number", "order__number"]
|
||||
readonly_fields = ["number", "issued_at"]
|
||||
|
||||
59
shop/migrations/0003_alter_product_options_and_more.py
Normal file
59
shop/migrations/0003_alter_product_options_and_more.py
Normal file
@@ -0,0 +1,59 @@
|
||||
# Generated by Django 6.0.6 on 2026-07-12 19:59
|
||||
|
||||
import django.db.models.deletion
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('club', '0008_alter_clubmembership_unique_together_and_more'),
|
||||
('shop', '0002_alter_product_slug_and_more'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AlterModelOptions(
|
||||
name='product',
|
||||
options={'ordering': ['name'], 'verbose_name': 'product', 'verbose_name_plural': 'products'},
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='product',
|
||||
name='early_bird_discount_amount',
|
||||
field=models.DecimalField(decimal_places=2, default=0, max_digits=10, verbose_name='early bird discount amount'),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='product',
|
||||
name='early_bird_discount_deadline',
|
||||
field=models.DateField(blank=True, null=True, verbose_name='early bird discount deadline'),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='product',
|
||||
name='early_bird_discount_enabled',
|
||||
field=models.BooleanField(default=False, verbose_name='early bird discount enabled?'),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='product',
|
||||
name='early_bird_discount_type',
|
||||
field=models.CharField(choices=[('percentage', 'Percentage'), ('fixed_amount', 'Fixed amount')], default='percentage', max_length=255, verbose_name='early bird discount type'),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='product',
|
||||
name='is_active',
|
||||
field=models.BooleanField(default=True, verbose_name='is active?'),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='product',
|
||||
name='is_public',
|
||||
field=models.BooleanField(default=True, help_text='Non-public products are only visible to staff members for adding on to an order later on.', verbose_name='is public?'),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='product',
|
||||
name='product_type',
|
||||
field=models.CharField(choices=[('membership', 'Membership'), ('event_fee', 'Event fee'), ('merchandise', 'Merchandise'), ('donation', 'Donation')], default='membership', max_length=255, verbose_name='product type'),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='product',
|
||||
name='season',
|
||||
field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.PROTECT, related_name='products', to='club.season', verbose_name='season'),
|
||||
),
|
||||
]
|
||||
32
shop/migrations/0004_cart.py
Normal file
32
shop/migrations/0004_cart.py
Normal file
@@ -0,0 +1,32 @@
|
||||
# Generated by Django 6.0.6 on 2026-07-12 20:02
|
||||
|
||||
import django.db.models.deletion
|
||||
import uuid
|
||||
from django.conf import settings
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('club', '0008_alter_clubmembership_unique_together_and_more'),
|
||||
('shop', '0003_alter_product_options_and_more'),
|
||||
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.CreateModel(
|
||||
name='Cart',
|
||||
fields=[
|
||||
('id', models.UUIDField(default=uuid.uuid4, editable=False, primary_key=True, serialize=False)),
|
||||
('status', models.CharField(choices=[('open', 'Open'), ('checked_out', 'Checked out'), ('abandoned', 'Abandoned')], default='open', max_length=255, verbose_name='status')),
|
||||
('club', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='%(class)ss', to='club.club')),
|
||||
('user', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='carts', to=settings.AUTH_USER_MODEL, verbose_name='user')),
|
||||
],
|
||||
options={
|
||||
'verbose_name': 'cart',
|
||||
'verbose_name_plural': 'carts',
|
||||
'constraints': [models.UniqueConstraint(condition=models.Q(('status', 'open')), fields=('club', 'user'), name='unique_open_cart_per_user_per_club')],
|
||||
},
|
||||
),
|
||||
]
|
||||
59
shop/migrations/0005_product_staff_role_cartitem_order.py
Normal file
59
shop/migrations/0005_product_staff_role_cartitem_order.py
Normal file
@@ -0,0 +1,59 @@
|
||||
# Generated by Django 6.0.6 on 2026-07-12 20:12
|
||||
|
||||
import django.db.models.deletion
|
||||
import uuid
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('club', '0008_alter_clubmembership_unique_together_and_more'),
|
||||
('members', '0002_alter_familymembership_unique_together_and_more'),
|
||||
('shop', '0004_cart'),
|
||||
('teams', '0001_initial'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AddField(
|
||||
model_name='product',
|
||||
name='staff_role',
|
||||
field=models.ForeignKey(blank=True, limit_choices_to={'staff_position': True}, null=True, on_delete=django.db.models.deletion.PROTECT, related_name='staff_products', to='teams.position', verbose_name='staff role'),
|
||||
),
|
||||
migrations.CreateModel(
|
||||
name='CartItem',
|
||||
fields=[
|
||||
('id', models.UUIDField(default=uuid.uuid4, editable=False, primary_key=True, serialize=False)),
|
||||
('quantity', models.PositiveSmallIntegerField(default=1, verbose_name='quantity')),
|
||||
('unit_price', models.DecimalField(decimal_places=2, max_digits=10, verbose_name='unit price')),
|
||||
('beneficiary', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.PROTECT, related_name='cart_items', to='members.member', verbose_name='beneficiary')),
|
||||
('cart', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='items', to='shop.cart', verbose_name='cart')),
|
||||
('product', models.ForeignKey(on_delete=django.db.models.deletion.PROTECT, related_name='cart_items', to='shop.product', verbose_name='product')),
|
||||
('team', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.PROTECT, related_name='cart_items', to='teams.team', verbose_name='team')),
|
||||
],
|
||||
options={
|
||||
'verbose_name': 'cart item',
|
||||
'verbose_name_plural': 'cart items',
|
||||
'constraints': [models.UniqueConstraint(fields=('cart', 'product', 'beneficiary'), name='unique_product_per_cart_per_beneficiary')],
|
||||
},
|
||||
),
|
||||
migrations.CreateModel(
|
||||
name='Order',
|
||||
fields=[
|
||||
('id', models.UUIDField(default=uuid.uuid4, editable=False, primary_key=True, serialize=False)),
|
||||
('number', models.CharField(max_length=255, verbose_name='number')),
|
||||
('status', models.CharField(choices=[('pending', 'Pending'), ('paid', 'Paid'), ('partially_paid', 'Partially paid'), ('cancelled', 'Cancelled'), ('refunded', 'Refunded'), ('delivered', 'Delivered')], default='pending', max_length=255, verbose_name='status')),
|
||||
('total', models.DecimalField(decimal_places=2, max_digits=10, verbose_name='total')),
|
||||
('created', models.DateTimeField(auto_now_add=True, verbose_name='created at')),
|
||||
('modified', models.DateTimeField(auto_now=True, verbose_name='modified')),
|
||||
('club', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='%(class)ss', to='club.club')),
|
||||
('purchaser', models.ForeignKey(on_delete=django.db.models.deletion.PROTECT, related_name='orders', to='members.member', verbose_name='purchaser')),
|
||||
],
|
||||
options={
|
||||
'verbose_name': 'order',
|
||||
'verbose_name_plural': 'orders',
|
||||
'ordering': ['-created'],
|
||||
'constraints': [models.UniqueConstraint(fields=('club', 'number'), name='unique_order_number_per_club')],
|
||||
},
|
||||
),
|
||||
]
|
||||
18
shop/migrations/0006_alter_order_number.py
Normal file
18
shop/migrations/0006_alter_order_number.py
Normal file
@@ -0,0 +1,18 @@
|
||||
# Generated by Django 6.0.6 on 2026-07-12 20:14
|
||||
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('shop', '0005_product_staff_role_cartitem_order'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AlterField(
|
||||
model_name='order',
|
||||
name='number',
|
||||
field=models.CharField(blank=True, max_length=255, verbose_name='number'),
|
||||
),
|
||||
]
|
||||
@@ -0,0 +1,113 @@
|
||||
# Generated by Django 6.0.6 on 2026-07-12 21:25
|
||||
|
||||
import django.db.models.deletion
|
||||
import uuid
|
||||
from django.conf import settings
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('club', '0008_alter_clubmembership_unique_together_and_more'),
|
||||
('members', '0002_alter_familymembership_unique_together_and_more'),
|
||||
('shop', '0006_alter_order_number'),
|
||||
('teams', '0001_initial'),
|
||||
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.CreateModel(
|
||||
name='Discount',
|
||||
fields=[
|
||||
('id', models.UUIDField(default=uuid.uuid4, editable=False, primary_key=True, serialize=False)),
|
||||
('name', models.CharField(max_length=255, verbose_name='name')),
|
||||
('slug', models.SlugField(blank=True, max_length=255, verbose_name='slug')),
|
||||
('description', models.TextField(blank=True, verbose_name='description')),
|
||||
('discount_type', models.CharField(choices=[('percentage', 'Percentage'), ('fixed_amount', 'Fixed amount')], default='percentage', max_length=255, verbose_name='discount type')),
|
||||
('discount_amount', models.DecimalField(decimal_places=2, default=0, max_digits=10, verbose_name='discount amount')),
|
||||
('is_active', models.BooleanField(default=True, verbose_name='is active?')),
|
||||
('club', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='%(class)ss', to='club.club')),
|
||||
],
|
||||
options={
|
||||
'verbose_name': 'order discount type',
|
||||
'verbose_name_plural': 'order discount types',
|
||||
'ordering': ['name'],
|
||||
},
|
||||
),
|
||||
migrations.CreateModel(
|
||||
name='AppliedDiscount',
|
||||
fields=[
|
||||
('id', models.UUIDField(default=uuid.uuid4, editable=False, primary_key=True, serialize=False)),
|
||||
('discount_type', models.CharField(choices=[('percentage', 'Percentage'), ('fixed_amount', 'Fixed amount')], default='percentage', max_length=255, verbose_name='discount type')),
|
||||
('discount_amount', models.DecimalField(decimal_places=2, max_digits=10, verbose_name='discount amount')),
|
||||
('description', models.TextField(blank=True, verbose_name='description')),
|
||||
('applied_by', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.PROTECT, related_name='applied_discounts', to=settings.AUTH_USER_MODEL, verbose_name='applied by')),
|
||||
('order', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='applied_discounts', to='shop.order', verbose_name='order')),
|
||||
('discount', models.ForeignKey(on_delete=django.db.models.deletion.PROTECT, related_name='applied_discounts', to='shop.discount', verbose_name='discount')),
|
||||
],
|
||||
options={
|
||||
'verbose_name': 'applied discount',
|
||||
'verbose_name_plural': 'applied discounts',
|
||||
},
|
||||
),
|
||||
migrations.CreateModel(
|
||||
name='Invoice',
|
||||
fields=[
|
||||
('id', models.UUIDField(default=uuid.uuid4, editable=False, primary_key=True, serialize=False)),
|
||||
('number', models.CharField(blank=True, max_length=255, verbose_name='number')),
|
||||
('issued_at', models.DateTimeField(auto_now_add=True, verbose_name='issued at')),
|
||||
('due_date', models.DateField(blank=True, null=True, verbose_name='due date')),
|
||||
('billing_snapshot', models.JSONField(blank=True, null=True, verbose_name='billing snapshot')),
|
||||
('pdf', models.FileField(blank=True, null=True, upload_to='invoices', verbose_name='PDF')),
|
||||
('club', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='%(class)ss', to='club.club')),
|
||||
('order', models.OneToOneField(on_delete=django.db.models.deletion.CASCADE, related_name='invoice', to='shop.order', verbose_name='order')),
|
||||
],
|
||||
options={
|
||||
'verbose_name': 'invoice',
|
||||
'verbose_name_plural': 'invoices',
|
||||
'ordering': ['-issued_at'],
|
||||
},
|
||||
),
|
||||
migrations.CreateModel(
|
||||
name='OrderLine',
|
||||
fields=[
|
||||
('id', models.UUIDField(default=uuid.uuid4, editable=False, primary_key=True, serialize=False)),
|
||||
('quantity', models.PositiveSmallIntegerField(default=1, verbose_name='quantity')),
|
||||
('unit_price', models.DecimalField(decimal_places=2, max_digits=10, verbose_name='unit price')),
|
||||
('line_total', models.DecimalField(decimal_places=2, max_digits=10, verbose_name='line total')),
|
||||
('beneficiary', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.PROTECT, related_name='order_items', to='members.member', verbose_name='beneficiary')),
|
||||
('order', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='order_items', to='shop.order', verbose_name='order')),
|
||||
('product', models.ForeignKey(on_delete=django.db.models.deletion.PROTECT, related_name='order_items', to='shop.product', verbose_name='product')),
|
||||
('team', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.PROTECT, related_name='order_items', to='teams.team', verbose_name='team')),
|
||||
],
|
||||
options={
|
||||
'verbose_name': 'order line',
|
||||
'verbose_name_plural': 'order lines',
|
||||
},
|
||||
),
|
||||
migrations.CreateModel(
|
||||
name='Payment',
|
||||
fields=[
|
||||
('id', models.UUIDField(default=uuid.uuid4, editable=False, primary_key=True, serialize=False)),
|
||||
('amount', models.DecimalField(decimal_places=2, max_digits=10, verbose_name='amount')),
|
||||
('method', models.CharField(choices=[('credit_card', 'Credit card'), ('bank_transfer', 'Bank transfer'), ('cash', 'Cash')], default='credit_card', max_length=255, verbose_name='method')),
|
||||
('status', models.CharField(choices=[('pending', 'Pending'), ('confirmed', 'Confirmed'), ('failed', 'Failed'), ('refunded', 'Refunded')], default='pending', max_length=255, verbose_name='status')),
|
||||
('reference', models.CharField(blank=True, max_length=255, verbose_name='reference')),
|
||||
('paid_at', models.DateTimeField(blank=True, null=True, verbose_name='paid at')),
|
||||
('order', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='payments', to='shop.order', verbose_name='order')),
|
||||
],
|
||||
options={
|
||||
'verbose_name': 'payment',
|
||||
'verbose_name_plural': 'payments',
|
||||
},
|
||||
),
|
||||
migrations.AddConstraint(
|
||||
model_name='discount',
|
||||
constraint=models.UniqueConstraint(fields=('club', 'slug'), name='unique_order_discount_type_slug_per_club'),
|
||||
),
|
||||
migrations.AddConstraint(
|
||||
model_name='invoice',
|
||||
constraint=models.UniqueConstraint(fields=('club', 'number'), name='unique_invoice_number_per_club'),
|
||||
),
|
||||
]
|
||||
20
shop/migrations/0008_alter_applieddiscount_applied_by.py
Normal file
20
shop/migrations/0008_alter_applieddiscount_applied_by.py
Normal file
@@ -0,0 +1,20 @@
|
||||
# Generated by Django 6.0.6 on 2026-07-12 21:31
|
||||
|
||||
import django.db.models.deletion
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('members', '0002_alter_familymembership_unique_together_and_more'),
|
||||
('shop', '0007_discount_applieddiscount_invoice_orderline_payment_and_more'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AlterField(
|
||||
model_name='applieddiscount',
|
||||
name='applied_by',
|
||||
field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.PROTECT, related_name='applied_discounts', to='members.member', verbose_name='applied by'),
|
||||
),
|
||||
]
|
||||
@@ -0,0 +1,18 @@
|
||||
# Generated by Django 6.0.6 on 2026-07-12 21:41
|
||||
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('members', '0002_alter_familymembership_unique_together_and_more'),
|
||||
('shop', '0008_alter_applieddiscount_applied_by'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AddConstraint(
|
||||
model_name='applieddiscount',
|
||||
constraint=models.UniqueConstraint(fields=('order', 'discount'), name='unique_discount_per_order'),
|
||||
),
|
||||
]
|
||||
296
shop/models.py
296
shop/models.py
@@ -1,17 +1,307 @@
|
||||
from django.db import models
|
||||
from django.db.models import UniqueConstraint
|
||||
from django.db import IntegrityError, models, transaction
|
||||
from django.db.models import Q, UniqueConstraint
|
||||
from django.utils import timezone
|
||||
from django.utils.translation import gettext_lazy as _
|
||||
|
||||
from clubmanager.base import ClubScopedModel
|
||||
from authentication.models import User
|
||||
from club.models import Season
|
||||
from club.tenancy import require_current_club
|
||||
from clubmanager.base import ClubScopedModel, UUIDModel, validate_club_scope
|
||||
from members.models import Member
|
||||
from teams.models import Position, Team
|
||||
|
||||
|
||||
def next_scoped_number(instance, code):
|
||||
"""Next per-club sequential number for the current year: ``<code>-<year>-<seq>``."""
|
||||
prefix = f"{code}-{timezone.now().year}-"
|
||||
model = type(instance)
|
||||
sequences = [int(suffix) for number in model.objects.filter(club=instance.club, number__startswith=prefix).values_list("number", flat=True) if (suffix := number.removeprefix(prefix)).isdigit()]
|
||||
return f"{prefix}{max(sequences, default=0) + 1:05d}"
|
||||
|
||||
|
||||
def save_with_number(instance, save):
|
||||
"""Assign a unique per-club number (retrying on collision) then save.
|
||||
|
||||
Relies on the model's ``generate_number()`` and its ``(club, number)``
|
||||
unique constraint as the source of truth.
|
||||
"""
|
||||
if instance.club_id is None:
|
||||
instance.club = require_current_club()
|
||||
if instance.number:
|
||||
return save()
|
||||
for attempt in range(5):
|
||||
instance.number = instance.generate_number()
|
||||
try:
|
||||
with transaction.atomic():
|
||||
return save()
|
||||
except IntegrityError:
|
||||
if attempt == 4:
|
||||
raise
|
||||
|
||||
|
||||
class Product(ClubScopedModel):
|
||||
class ProductType(models.TextChoices):
|
||||
MEMBERSHIP = "membership", _("Membership")
|
||||
EVENT_FEE = "event_fee", _("Event fee")
|
||||
MERCHANDISE = "merchandise", _("Merchandise")
|
||||
DONATION = "donation", _("Donation")
|
||||
|
||||
class DiscountType(models.TextChoices):
|
||||
PERCENTAGE = "percentage", _("Percentage")
|
||||
FIXED_AMOUNT = "fixed_amount", _("Fixed amount")
|
||||
|
||||
name = models.CharField(_("name"), max_length=255)
|
||||
slug = models.SlugField(_("slug"), max_length=255, blank=True)
|
||||
|
||||
product_type = models.CharField(_("product type"), max_length=255, choices=ProductType.choices, default=ProductType.MEMBERSHIP)
|
||||
season = models.ForeignKey(Season, on_delete=models.PROTECT, related_name="products", verbose_name=_("season"), blank=True, null=True)
|
||||
|
||||
is_active = models.BooleanField(_("is active?"), default=True)
|
||||
is_public = models.BooleanField(_("is public?"), default=True, help_text="Non-public products are only visible to staff members for adding on to an order later on.")
|
||||
staff_role = models.ForeignKey(Position, on_delete=models.PROTECT, related_name="staff_products", verbose_name=_("staff role"), blank=True, null=True, limit_choices_to={"staff_position": True})
|
||||
|
||||
early_bird_discount_enabled = models.BooleanField(_("early bird discount enabled?"), default=False)
|
||||
early_bird_discount_deadline = models.DateField(_("early bird discount deadline"), blank=True, null=True)
|
||||
early_bird_discount_type = models.CharField(_("early bird discount type"), max_length=255, choices=DiscountType.choices, default=DiscountType.PERCENTAGE)
|
||||
early_bird_discount_amount = models.DecimalField(_("early bird discount amount"), max_digits=10, decimal_places=2, default=0)
|
||||
|
||||
slug_source = "name"
|
||||
|
||||
class Meta:
|
||||
verbose_name = _("product")
|
||||
verbose_name_plural = _("products")
|
||||
ordering = ["name"]
|
||||
constraints = [
|
||||
UniqueConstraint(fields=["club", "slug"], name="unique_product_slug_per_club"),
|
||||
]
|
||||
|
||||
def __str__(self):
|
||||
return self.name
|
||||
|
||||
def clean(self):
|
||||
validate_club_scope(self, self.club_id, same_club_fields=("season", "staff_role"))
|
||||
|
||||
|
||||
class Cart(ClubScopedModel):
|
||||
class CartStatus(models.TextChoices):
|
||||
OPEN = "open", _("Open")
|
||||
CHECKED_OUT = "checked_out", _("Checked out")
|
||||
ABANDONED = "abandoned", _("Abandoned")
|
||||
|
||||
user = models.ForeignKey(User, on_delete=models.CASCADE, related_name="carts", verbose_name=_("user"))
|
||||
status = models.CharField(_("status"), max_length=255, choices=CartStatus.choices, default=CartStatus.OPEN)
|
||||
|
||||
class Meta:
|
||||
verbose_name = _("cart")
|
||||
verbose_name_plural = _("carts")
|
||||
constraints = [
|
||||
# At most one open cart per user per club (CartStatus.OPEN == "open").
|
||||
UniqueConstraint(fields=["club", "user"], condition=Q(status="open"), name="unique_open_cart_per_user_per_club"),
|
||||
]
|
||||
|
||||
def __str__(self):
|
||||
return f"{self.user} - {self.status}"
|
||||
|
||||
|
||||
class CartItem(UUIDModel):
|
||||
cart = models.ForeignKey(Cart, on_delete=models.CASCADE, related_name="items", verbose_name=_("cart"))
|
||||
product = models.ForeignKey(Product, on_delete=models.PROTECT, related_name="cart_items", verbose_name=_("product"))
|
||||
|
||||
quantity = models.PositiveSmallIntegerField(_("quantity"), default=1)
|
||||
unit_price = models.DecimalField(_("unit price"), max_digits=10, decimal_places=2)
|
||||
|
||||
beneficiary = models.ForeignKey(Member, on_delete=models.PROTECT, related_name="cart_items", verbose_name=_("beneficiary"), blank=True, null=True)
|
||||
team = models.ForeignKey(Team, on_delete=models.PROTECT, related_name="cart_items", verbose_name=_("team"), blank=True, null=True)
|
||||
|
||||
class Meta:
|
||||
verbose_name = _("cart item")
|
||||
verbose_name_plural = _("cart items")
|
||||
constraints = [
|
||||
UniqueConstraint(fields=["cart", "product", "beneficiary"], name="unique_product_per_cart_per_beneficiary"),
|
||||
]
|
||||
|
||||
def __str__(self):
|
||||
return f"{self.product} - {self.quantity}x"
|
||||
|
||||
def clean(self):
|
||||
club_id = self.cart.club_id if self.cart_id else None
|
||||
validate_club_scope(self, club_id, same_club_fields=("product", "team"), member_fields=("beneficiary",))
|
||||
|
||||
|
||||
class Order(ClubScopedModel):
|
||||
class OrderStatus(models.TextChoices):
|
||||
PENDING = "pending", _("Pending")
|
||||
PAID = "paid", _("Paid")
|
||||
PARTIALLY_PAID = "partially_paid", _("Partially paid")
|
||||
CANCELLED = "cancelled", _("Cancelled")
|
||||
REFUNDED = "refunded", _("Refunded")
|
||||
DELIVERED = "delivered", _("Delivered")
|
||||
|
||||
number = models.CharField(_("number"), max_length=255, blank=True)
|
||||
purchaser = models.ForeignKey(Member, on_delete=models.PROTECT, related_name="orders", verbose_name=_("purchaser"))
|
||||
status = models.CharField(_("status"), max_length=255, choices=OrderStatus.choices, default=OrderStatus.PENDING)
|
||||
total = models.DecimalField(_("total"), max_digits=10, decimal_places=2)
|
||||
|
||||
created = models.DateTimeField(_("created at"), auto_now_add=True)
|
||||
modified = models.DateTimeField(_("modified"), auto_now=True)
|
||||
|
||||
class Meta:
|
||||
verbose_name = _("order")
|
||||
verbose_name_plural = _("orders")
|
||||
ordering = ["-created"]
|
||||
constraints = [
|
||||
UniqueConstraint(fields=["club", "number"], name="unique_order_number_per_club"),
|
||||
]
|
||||
|
||||
def __str__(self):
|
||||
return self.number
|
||||
|
||||
def clean(self):
|
||||
validate_club_scope(self, self.club_id, member_fields=("purchaser",))
|
||||
|
||||
def generate_number(self):
|
||||
"""Next per-club order number for the current year: ``ORD-<year>-<seq>``."""
|
||||
return next_scoped_number(self, "ORD")
|
||||
|
||||
def save(self, *args, **kwargs):
|
||||
return save_with_number(self, lambda: super(Order, self).save(*args, **kwargs))
|
||||
|
||||
|
||||
class OrderLine(UUIDModel):
|
||||
order = models.ForeignKey(Order, on_delete=models.CASCADE, related_name="order_items", verbose_name=_("order"))
|
||||
product = models.ForeignKey(Product, on_delete=models.PROTECT, related_name="order_items", verbose_name=_("product"))
|
||||
|
||||
quantity = models.PositiveSmallIntegerField(_("quantity"), default=1)
|
||||
unit_price = models.DecimalField(_("unit price"), max_digits=10, decimal_places=2)
|
||||
|
||||
beneficiary = models.ForeignKey(Member, on_delete=models.PROTECT, related_name="order_items", verbose_name=_("beneficiary"), blank=True, null=True)
|
||||
team = models.ForeignKey(Team, on_delete=models.PROTECT, related_name="order_items", verbose_name=_("team"), blank=True, null=True)
|
||||
line_total = models.DecimalField(_("line total"), max_digits=10, decimal_places=2)
|
||||
|
||||
class Meta:
|
||||
verbose_name = _("order line")
|
||||
verbose_name_plural = _("order lines")
|
||||
|
||||
def __str__(self):
|
||||
return f"{self.product} - {self.quantity}x"
|
||||
|
||||
def clean(self):
|
||||
club_id = self.order.club_id if self.order_id else None
|
||||
validate_club_scope(self, club_id, same_club_fields=("product", "team"), member_fields=("beneficiary",))
|
||||
|
||||
|
||||
class Discount(ClubScopedModel):
|
||||
class DiscountType(models.TextChoices):
|
||||
PERCENTAGE = "percentage", _("Percentage")
|
||||
FIXED_AMOUNT = "fixed_amount", _("Fixed amount")
|
||||
|
||||
name = models.CharField(_("name"), max_length=255)
|
||||
slug = models.SlugField(_("slug"), max_length=255, blank=True)
|
||||
description = models.TextField(_("description"), blank=True)
|
||||
|
||||
discount_type = models.CharField(_("discount type"), max_length=255, choices=DiscountType.choices, default=DiscountType.PERCENTAGE)
|
||||
discount_amount = models.DecimalField(_("discount amount"), max_digits=10, decimal_places=2, default=0)
|
||||
|
||||
is_active = models.BooleanField(_("is active?"), default=True)
|
||||
|
||||
slug_source = "name"
|
||||
|
||||
class Meta:
|
||||
verbose_name = _("order discount type")
|
||||
verbose_name_plural = _("order discount types")
|
||||
ordering = ["name"]
|
||||
constraints = [
|
||||
UniqueConstraint(fields=["club", "slug"], name="unique_order_discount_type_slug_per_club"),
|
||||
]
|
||||
|
||||
def __str__(self):
|
||||
return self.name
|
||||
|
||||
|
||||
class AppliedDiscount(UUIDModel):
|
||||
class DiscountType(models.TextChoices):
|
||||
PERCENTAGE = "percentage", _("Percentage")
|
||||
FIXED_AMOUNT = "fixed_amount", _("Fixed amount")
|
||||
|
||||
order = models.ForeignKey(Order, on_delete=models.CASCADE, related_name="applied_discounts", verbose_name=_("order"))
|
||||
discount = models.ForeignKey(Discount, on_delete=models.PROTECT, related_name="applied_discounts", verbose_name=_("discount"))
|
||||
|
||||
discount_type = models.CharField(_("discount type"), max_length=255, choices=DiscountType.choices, default=DiscountType.PERCENTAGE)
|
||||
discount_amount = models.DecimalField(_("discount amount"), max_digits=10, decimal_places=2)
|
||||
description = models.TextField(_("description"), blank=True)
|
||||
|
||||
applied_by = models.ForeignKey(Member, on_delete=models.PROTECT, related_name="applied_discounts", verbose_name=_("applied by"), blank=True, null=True)
|
||||
|
||||
class Meta:
|
||||
verbose_name = _("applied discount")
|
||||
verbose_name_plural = _("applied discounts")
|
||||
constraints = [
|
||||
UniqueConstraint(fields=["order", "discount"], name="unique_discount_per_order"),
|
||||
]
|
||||
|
||||
def __str__(self):
|
||||
suffix = "%" if self.discount_type == self.DiscountType.PERCENTAGE else ""
|
||||
return f"{self.discount} - {self.discount_amount}{suffix}"
|
||||
|
||||
def clean(self):
|
||||
club_id = self.order.club_id if self.order_id else None
|
||||
validate_club_scope(self, club_id, same_club_fields=("discount",), member_fields=("applied_by",))
|
||||
|
||||
|
||||
class Payment(UUIDModel):
|
||||
class PaymentMethod(models.TextChoices):
|
||||
CREDIT_CARD = "credit_card", _("Credit card")
|
||||
BANK_TRANSFER = "bank_transfer", _("Bank transfer")
|
||||
CASH = "cash", _("Cash")
|
||||
|
||||
class PaymentStatus(models.TextChoices):
|
||||
PENDING = "pending", _("Pending")
|
||||
CONFIRMED = "confirmed", _("Confirmed")
|
||||
FAILED = "failed", _("Failed")
|
||||
REFUNDED = "refunded", _("Refunded")
|
||||
|
||||
order = models.ForeignKey(Order, on_delete=models.CASCADE, related_name="payments", verbose_name=_("order"))
|
||||
amount = models.DecimalField(_("amount"), max_digits=10, decimal_places=2)
|
||||
method = models.CharField(_("method"), max_length=255, choices=PaymentMethod.choices, default=PaymentMethod.CREDIT_CARD)
|
||||
status = models.CharField(_("status"), max_length=255, choices=PaymentStatus.choices, default=PaymentStatus.PENDING)
|
||||
reference = models.CharField(_("reference"), max_length=255, blank=True)
|
||||
paid_at = models.DateTimeField(_("paid at"), blank=True, null=True)
|
||||
|
||||
class Meta:
|
||||
verbose_name = _("payment")
|
||||
verbose_name_plural = _("payments")
|
||||
|
||||
def __str__(self):
|
||||
return f"{self.order} - {self.status}"
|
||||
|
||||
|
||||
class Invoice(ClubScopedModel):
|
||||
number = models.CharField(_("number"), max_length=255, blank=True)
|
||||
order = models.OneToOneField(Order, on_delete=models.CASCADE, related_name="invoice", verbose_name=_("order"))
|
||||
|
||||
issued_at = models.DateTimeField(_("issued at"), auto_now_add=True)
|
||||
due_date = models.DateField(_("due date"), blank=True, null=True)
|
||||
|
||||
billing_snapshot = models.JSONField(_("billing snapshot"), blank=True, null=True) # Name/address information
|
||||
pdf = models.FileField(_("PDF"), upload_to="invoices", blank=True, null=True)
|
||||
|
||||
class Meta:
|
||||
verbose_name = _("invoice")
|
||||
verbose_name_plural = _("invoices")
|
||||
ordering = ["-issued_at"]
|
||||
constraints = [
|
||||
UniqueConstraint(fields=["club", "number"], name="unique_invoice_number_per_club"),
|
||||
]
|
||||
|
||||
def __str__(self):
|
||||
return self.number
|
||||
|
||||
def clean(self):
|
||||
validate_club_scope(self, self.club_id, same_club_fields=("order",))
|
||||
|
||||
def generate_number(self):
|
||||
"""Next per-club invoice number for the current year: ``INV-<year>-<seq>``."""
|
||||
return next_scoped_number(self, "INV")
|
||||
|
||||
def save(self, *args, **kwargs):
|
||||
return save_with_number(self, lambda: super(Invoice, self).save(*args, **kwargs))
|
||||
|
||||
427
shop/tests.py
427
shop/tests.py
@@ -1,8 +1,32 @@
|
||||
from django.test import TestCase
|
||||
from datetime import timedelta
|
||||
from decimal import Decimal
|
||||
from unittest.mock import patch
|
||||
|
||||
from club.models import Club
|
||||
from django.contrib.admin.sites import AdminSite
|
||||
from django.core.exceptions import ValidationError
|
||||
from django.db import IntegrityError
|
||||
from django.db.models import ProtectedError
|
||||
from django.test import RequestFactory, TestCase
|
||||
from django.utils import timezone
|
||||
|
||||
from .models import Product
|
||||
from authentication.models import User
|
||||
from club.models import Club, ClubMembership, Season
|
||||
from club.tenancy import reset_current_club, set_current_club
|
||||
from members.models import Member
|
||||
from teams.models import Position, Team
|
||||
|
||||
from .admin import ProductAdmin
|
||||
from .models import (
|
||||
AppliedDiscount,
|
||||
Cart,
|
||||
CartItem,
|
||||
Discount,
|
||||
Invoice,
|
||||
Order,
|
||||
OrderLine,
|
||||
Payment,
|
||||
Product,
|
||||
)
|
||||
|
||||
|
||||
class ProductSlugTests(TestCase):
|
||||
@@ -37,3 +61,400 @@ class ProductSlugTests(TestCase):
|
||||
product = Product.objects.create(club=self.club, name="###")
|
||||
|
||||
self.assertEqual(product.slug, "item")
|
||||
|
||||
def test_str_returns_name(self):
|
||||
product = Product.objects.create(club=self.club, name="Home Jersey")
|
||||
|
||||
self.assertEqual(str(product), "Home Jersey")
|
||||
|
||||
|
||||
class OpenCartConstraintTests(TestCase):
|
||||
def setUp(self):
|
||||
self.club = Club.objects.create(name="Ajax United", slug="ajax-united")
|
||||
self.user = User.objects.create_user(email="shopper@example.com", password="pw")
|
||||
|
||||
def test_only_one_open_cart_per_user_per_club(self):
|
||||
Cart.objects.create(club=self.club, user=self.user)
|
||||
|
||||
with self.assertRaises(IntegrityError):
|
||||
Cart.objects.create(club=self.club, user=self.user)
|
||||
|
||||
def test_open_and_closed_carts_coexist(self):
|
||||
Cart.objects.create(club=self.club, user=self.user, status=Cart.CartStatus.CHECKED_OUT)
|
||||
Cart.objects.create(club=self.club, user=self.user, status=Cart.CartStatus.ABANDONED)
|
||||
Cart.objects.create(club=self.club, user=self.user)
|
||||
|
||||
self.assertEqual(self.user.carts.count(), 3)
|
||||
self.assertEqual(self.user.carts.filter(status=Cart.CartStatus.OPEN).count(), 1)
|
||||
|
||||
def test_open_cart_allowed_in_each_club(self):
|
||||
other = Club.objects.create(name="Rival FC", slug="rival-fc")
|
||||
Cart.objects.create(club=self.club, user=self.user)
|
||||
Cart.objects.create(club=other, user=self.user)
|
||||
|
||||
self.assertEqual(self.user.carts.filter(status=Cart.CartStatus.OPEN).count(), 2)
|
||||
|
||||
def test_str(self):
|
||||
cart = Cart.objects.create(club=self.club, user=self.user)
|
||||
|
||||
self.assertEqual(str(cart), f"{self.user} - open")
|
||||
|
||||
|
||||
class CartItemTests(TestCase):
|
||||
def test_str(self):
|
||||
club = Club.objects.create(name="Ajax United", slug="ajax-united")
|
||||
user = User.objects.create_user(email="shopper@example.com", password="pw")
|
||||
cart = Cart.objects.create(club=club, user=user)
|
||||
product = Product.objects.create(club=club, name="Home Jersey")
|
||||
item = CartItem.objects.create(cart=cart, product=product, quantity=2, unit_price=Decimal("25.00"))
|
||||
|
||||
self.assertEqual(str(item), "Home Jersey - 2x")
|
||||
|
||||
|
||||
class OrderNumberTests(TestCase):
|
||||
def setUp(self):
|
||||
self.club = Club.objects.create(name="Ajax United", slug="ajax-united")
|
||||
self.member = Member.objects.create(first_name="Jane", last_name="Doe")
|
||||
self.year = timezone.now().year
|
||||
|
||||
def make_order(self, **kwargs):
|
||||
kwargs.setdefault("club", self.club)
|
||||
kwargs.setdefault("purchaser", self.member)
|
||||
kwargs.setdefault("total", Decimal("10.00"))
|
||||
return Order.objects.create(**kwargs)
|
||||
|
||||
def test_number_is_generated(self):
|
||||
order = self.make_order()
|
||||
|
||||
self.assertEqual(order.number, f"ORD-{self.year}-00001")
|
||||
|
||||
def test_number_increments_within_club_and_year(self):
|
||||
first = self.make_order()
|
||||
second = self.make_order()
|
||||
|
||||
self.assertEqual(first.number, f"ORD-{self.year}-00001")
|
||||
self.assertEqual(second.number, f"ORD-{self.year}-00002")
|
||||
|
||||
def test_number_is_scoped_per_club(self):
|
||||
other = Club.objects.create(name="Rival FC", slug="rival-fc")
|
||||
self.make_order()
|
||||
|
||||
order = self.make_order(club=other)
|
||||
|
||||
self.assertEqual(order.number, f"ORD-{self.year}-00001")
|
||||
|
||||
def test_explicit_number_is_preserved(self):
|
||||
order = self.make_order(number="CUSTOM-1")
|
||||
|
||||
self.assertEqual(order.number, "CUSTOM-1")
|
||||
|
||||
def test_resaving_keeps_the_number(self):
|
||||
order = self.make_order()
|
||||
original = order.number
|
||||
|
||||
order.status = Order.OrderStatus.PAID
|
||||
order.save()
|
||||
|
||||
order.refresh_from_db()
|
||||
self.assertEqual(order.number, original)
|
||||
|
||||
def test_non_numeric_suffix_is_ignored(self):
|
||||
Order.objects.create(club=self.club, purchaser=self.member, total=Decimal("5"), number=f"ORD-{self.year}-oops")
|
||||
|
||||
order = self.make_order()
|
||||
|
||||
self.assertEqual(order.number, f"ORD-{self.year}-00001")
|
||||
|
||||
def test_club_filled_from_tenant_context(self):
|
||||
token = set_current_club(self.club)
|
||||
try:
|
||||
order = Order.objects.create(purchaser=self.member, total=Decimal("5"))
|
||||
finally:
|
||||
reset_current_club(token)
|
||||
|
||||
self.assertEqual(order.club, self.club)
|
||||
|
||||
def test_retries_on_collision(self):
|
||||
taken = self.make_order().number
|
||||
with patch.object(Order, "generate_number", side_effect=[taken, "ORD-2999-00001"]):
|
||||
order = Order(club=self.club, purchaser=self.member, total=Decimal("5"))
|
||||
order.save()
|
||||
|
||||
self.assertEqual(order.number, "ORD-2999-00001")
|
||||
|
||||
def test_gives_up_after_exhausting_retries(self):
|
||||
taken = self.make_order().number
|
||||
with patch.object(Order, "generate_number", return_value=taken), self.assertRaises(IntegrityError):
|
||||
Order(club=self.club, purchaser=self.member, total=Decimal("5")).save()
|
||||
|
||||
def test_str_is_the_number(self):
|
||||
order = self.make_order()
|
||||
|
||||
self.assertEqual(str(order), order.number)
|
||||
|
||||
|
||||
class ShopEntitiesTestBase(TestCase):
|
||||
def setUp(self):
|
||||
self.club = Club.objects.create(name="Ajax United", slug="ajax-united")
|
||||
self.member = Member.objects.create(first_name="Jane", last_name="Doe")
|
||||
self.product = Product.objects.create(club=self.club, name="Home Jersey")
|
||||
self.order = Order.objects.create(club=self.club, purchaser=self.member, total=Decimal("50.00"))
|
||||
self.year = timezone.now().year
|
||||
|
||||
|
||||
class OrderLineTests(ShopEntitiesTestBase):
|
||||
def make_line(self, **kwargs):
|
||||
kwargs.setdefault("order", self.order)
|
||||
kwargs.setdefault("product", self.product)
|
||||
kwargs.setdefault("quantity", 2)
|
||||
kwargs.setdefault("unit_price", Decimal("25.00"))
|
||||
kwargs.setdefault("line_total", Decimal("50.00"))
|
||||
return OrderLine.objects.create(**kwargs)
|
||||
|
||||
def test_str(self):
|
||||
self.assertEqual(str(self.make_line()), "Home Jersey - 2x")
|
||||
|
||||
def test_deleting_order_cascades_to_lines(self):
|
||||
self.make_line()
|
||||
self.order.delete()
|
||||
self.assertFalse(OrderLine.objects.exists())
|
||||
|
||||
def test_product_is_protected_while_referenced(self):
|
||||
self.make_line()
|
||||
with self.assertRaises(ProtectedError):
|
||||
self.product.delete()
|
||||
|
||||
|
||||
class DiscountTests(ShopEntitiesTestBase):
|
||||
def test_slug_and_str(self):
|
||||
discount = Discount.objects.create(club=self.club, name="Sibling discount")
|
||||
|
||||
self.assertEqual(discount.slug, "sibling-discount")
|
||||
self.assertEqual(str(discount), "Sibling discount")
|
||||
|
||||
def test_slug_is_unique_per_club(self):
|
||||
Discount.objects.create(club=self.club, name="Sibling")
|
||||
second = Discount.objects.create(club=self.club, name="Sibling")
|
||||
|
||||
self.assertEqual(second.slug, "sibling-2")
|
||||
|
||||
|
||||
class AppliedDiscountTests(ShopEntitiesTestBase):
|
||||
def setUp(self):
|
||||
super().setUp()
|
||||
self.discount = Discount.objects.create(club=self.club, name="Sibling")
|
||||
|
||||
def apply(self, **kwargs):
|
||||
kwargs.setdefault("order", self.order)
|
||||
kwargs.setdefault("discount", self.discount)
|
||||
kwargs.setdefault("discount_amount", Decimal("10.00"))
|
||||
return AppliedDiscount.objects.create(**kwargs)
|
||||
|
||||
def test_str_percentage_shows_percent(self):
|
||||
applied = self.apply(discount_type=AppliedDiscount.DiscountType.PERCENTAGE)
|
||||
|
||||
self.assertEqual(str(applied), "Sibling - 10.00%")
|
||||
|
||||
def test_str_fixed_amount_has_no_percent(self):
|
||||
applied = self.apply(discount_type=AppliedDiscount.DiscountType.FIXED_AMOUNT)
|
||||
|
||||
self.assertEqual(str(applied), "Sibling - 10.00")
|
||||
|
||||
def test_deleting_order_cascades(self):
|
||||
self.apply()
|
||||
self.order.delete()
|
||||
self.assertFalse(AppliedDiscount.objects.exists())
|
||||
|
||||
def test_discount_is_protected_while_referenced(self):
|
||||
self.apply()
|
||||
with self.assertRaises(ProtectedError):
|
||||
self.discount.delete()
|
||||
|
||||
|
||||
class PaymentTests(ShopEntitiesTestBase):
|
||||
def test_str(self):
|
||||
payment = Payment.objects.create(order=self.order, amount=Decimal("50.00"))
|
||||
|
||||
self.assertEqual(str(payment), f"{self.order} - pending")
|
||||
|
||||
def test_deleting_order_cascades(self):
|
||||
Payment.objects.create(order=self.order, amount=Decimal("50.00"))
|
||||
self.order.delete()
|
||||
self.assertFalse(Payment.objects.exists())
|
||||
|
||||
|
||||
class InvoiceTests(ShopEntitiesTestBase):
|
||||
def test_number_generated_and_str(self):
|
||||
invoice = Invoice.objects.create(club=self.club, order=self.order)
|
||||
|
||||
self.assertEqual(invoice.number, f"INV-{self.year}-00001")
|
||||
self.assertEqual(str(invoice), invoice.number)
|
||||
|
||||
def test_number_increments_per_club(self):
|
||||
second_order = Order.objects.create(club=self.club, purchaser=self.member, total=Decimal("10.00"))
|
||||
Invoice.objects.create(club=self.club, order=self.order)
|
||||
second = Invoice.objects.create(club=self.club, order=second_order)
|
||||
|
||||
self.assertEqual(second.number, f"INV-{self.year}-00002")
|
||||
|
||||
def test_one_invoice_per_order(self):
|
||||
Invoice.objects.create(club=self.club, order=self.order)
|
||||
|
||||
with self.assertRaises(IntegrityError):
|
||||
Invoice.objects.create(club=self.club, order=self.order)
|
||||
|
||||
def test_deleting_order_cascades_to_invoice(self):
|
||||
Invoice.objects.create(club=self.club, order=self.order)
|
||||
self.order.delete()
|
||||
self.assertFalse(Invoice.objects.exists())
|
||||
|
||||
|
||||
class ClubScopeValidationTests(TestCase):
|
||||
def setUp(self):
|
||||
self.club = Club.objects.create(name="Ajax United", slug="ajax-united")
|
||||
self.other = Club.objects.create(name="Rival FC", slug="rival-fc")
|
||||
today = timezone.localdate()
|
||||
self.season = Season.objects.create(club=self.club, start_date=today, end_date=today + timedelta(days=300))
|
||||
self.other_season = Season.objects.create(club=self.other, start_date=today, end_date=today + timedelta(days=300))
|
||||
self.member = Member.objects.create(first_name="Jane", last_name="Doe")
|
||||
ClubMembership.objects.create(club=self.club, member=self.member, season=self.season)
|
||||
self.stranger = Member.objects.create(first_name="Stray", last_name="Ger")
|
||||
|
||||
def make_cart(self, club):
|
||||
user = User.objects.create_user(email=f"u-{club.slug}@example.com", password="pw")
|
||||
return Cart.objects.create(club=club, user=user)
|
||||
|
||||
def make_order(self, club):
|
||||
return Order.objects.create(club=club, purchaser=self.member, total=Decimal("10.00"))
|
||||
|
||||
# --- Product ---
|
||||
def test_product_rejects_cross_club_season(self):
|
||||
product = Product(club=self.club, name="Jersey", season=self.other_season)
|
||||
with self.assertRaises(ValidationError) as ctx:
|
||||
product.full_clean()
|
||||
self.assertIn("season", ctx.exception.error_dict)
|
||||
|
||||
def test_product_rejects_cross_club_staff_role(self):
|
||||
position = Position.objects.create(club=self.other, name="Coach", short_name="C", staff_position=True)
|
||||
product = Product(club=self.club, name="Jersey", staff_role=position)
|
||||
with self.assertRaises(ValidationError) as ctx:
|
||||
product.full_clean()
|
||||
self.assertIn("staff_role", ctx.exception.error_dict)
|
||||
|
||||
def test_product_accepts_same_club_season(self):
|
||||
Product(club=self.club, name="Jersey", season=self.season).full_clean()
|
||||
|
||||
# --- CartItem ---
|
||||
def test_cartitem_rejects_cross_club_product(self):
|
||||
cart = self.make_cart(self.club)
|
||||
product = Product.objects.create(club=self.other, name="Jersey")
|
||||
item = CartItem(cart=cart, product=product, unit_price=Decimal("5"))
|
||||
with self.assertRaises(ValidationError) as ctx:
|
||||
item.full_clean()
|
||||
self.assertIn("product", ctx.exception.error_dict)
|
||||
|
||||
def test_cartitem_rejects_cross_club_team(self):
|
||||
cart = self.make_cart(self.club)
|
||||
product = Product.objects.create(club=self.club, name="Jersey")
|
||||
team = Team.objects.create(club=self.other, name="First", short_name="1")
|
||||
item = CartItem(cart=cart, product=product, team=team, unit_price=Decimal("5"))
|
||||
with self.assertRaises(ValidationError) as ctx:
|
||||
item.full_clean()
|
||||
self.assertIn("team", ctx.exception.error_dict)
|
||||
|
||||
def test_cartitem_rejects_non_member_beneficiary(self):
|
||||
cart = self.make_cart(self.club)
|
||||
product = Product.objects.create(club=self.club, name="Jersey")
|
||||
item = CartItem(cart=cart, product=product, beneficiary=self.stranger, unit_price=Decimal("5"))
|
||||
with self.assertRaises(ValidationError) as ctx:
|
||||
item.full_clean()
|
||||
self.assertIn("beneficiary", ctx.exception.error_dict)
|
||||
|
||||
def test_cartitem_accepts_same_club(self):
|
||||
cart = self.make_cart(self.club)
|
||||
product = Product.objects.create(club=self.club, name="Jersey")
|
||||
CartItem(cart=cart, product=product, beneficiary=self.member, unit_price=Decimal("5")).full_clean()
|
||||
|
||||
def test_cartitem_clean_without_cart_is_noop(self):
|
||||
CartItem().clean()
|
||||
|
||||
# --- OrderLine ---
|
||||
def test_orderline_rejects_cross_club_product(self):
|
||||
order = self.make_order(self.club)
|
||||
product = Product.objects.create(club=self.other, name="Jersey")
|
||||
line = OrderLine(order=order, product=product, unit_price=Decimal("5"), line_total=Decimal("5"))
|
||||
with self.assertRaises(ValidationError) as ctx:
|
||||
line.full_clean()
|
||||
self.assertIn("product", ctx.exception.error_dict)
|
||||
|
||||
def test_orderline_clean_without_order_is_noop(self):
|
||||
OrderLine().clean()
|
||||
|
||||
# --- Order ---
|
||||
def test_order_rejects_non_member_purchaser(self):
|
||||
order = Order(club=self.club, purchaser=self.stranger, total=Decimal("10"))
|
||||
with self.assertRaises(ValidationError) as ctx:
|
||||
order.full_clean()
|
||||
self.assertIn("purchaser", ctx.exception.error_dict)
|
||||
|
||||
def test_order_accepts_member_purchaser(self):
|
||||
Order(club=self.club, purchaser=self.member, total=Decimal("10")).full_clean()
|
||||
|
||||
# --- AppliedDiscount ---
|
||||
def test_applieddiscount_rejects_cross_club_discount(self):
|
||||
order = self.make_order(self.club)
|
||||
discount = Discount.objects.create(club=self.other, name="Sibling")
|
||||
applied = AppliedDiscount(order=order, discount=discount, discount_amount=Decimal("5"))
|
||||
with self.assertRaises(ValidationError) as ctx:
|
||||
applied.full_clean()
|
||||
self.assertIn("discount", ctx.exception.error_dict)
|
||||
|
||||
def test_applieddiscount_rejects_non_member_applied_by(self):
|
||||
order = self.make_order(self.club)
|
||||
discount = Discount.objects.create(club=self.club, name="Sibling")
|
||||
applied = AppliedDiscount(order=order, discount=discount, discount_amount=Decimal("5"), applied_by=self.stranger)
|
||||
with self.assertRaises(ValidationError) as ctx:
|
||||
applied.full_clean()
|
||||
self.assertIn("applied_by", ctx.exception.error_dict)
|
||||
|
||||
def test_applieddiscount_is_unique_per_order(self):
|
||||
order = self.make_order(self.club)
|
||||
discount = Discount.objects.create(club=self.club, name="Sibling")
|
||||
AppliedDiscount.objects.create(order=order, discount=discount, discount_amount=Decimal("5"))
|
||||
with self.assertRaises(IntegrityError):
|
||||
AppliedDiscount.objects.create(order=order, discount=discount, discount_amount=Decimal("5"))
|
||||
|
||||
def test_applieddiscount_clean_without_order_is_noop(self):
|
||||
AppliedDiscount().clean()
|
||||
|
||||
# --- Invoice ---
|
||||
def test_invoice_rejects_cross_club_order(self):
|
||||
order = self.make_order(self.other)
|
||||
invoice = Invoice(club=self.club, order=order)
|
||||
with self.assertRaises(ValidationError) as ctx:
|
||||
invoice.full_clean()
|
||||
self.assertIn("order", ctx.exception.error_dict)
|
||||
|
||||
def test_invoice_accepts_same_club_order(self):
|
||||
order = self.make_order(self.club)
|
||||
Invoice(club=self.club, order=order).full_clean()
|
||||
|
||||
|
||||
class AdminScopingTests(TestCase):
|
||||
def setUp(self):
|
||||
self.club = Club.objects.create(name="Ajax United", slug="ajax-united")
|
||||
self.other = Club.objects.create(name="Rival FC", slug="rival-fc")
|
||||
today = timezone.localdate()
|
||||
self.season = Season.objects.create(club=self.club, start_date=today, end_date=today + timedelta(days=300))
|
||||
self.other_season = Season.objects.create(club=self.other, start_date=today, end_date=today + timedelta(days=300))
|
||||
|
||||
def test_fk_dropdown_scoped_to_object_club(self):
|
||||
product = Product.objects.create(club=self.club, name="Jersey")
|
||||
admin_obj = ProductAdmin(Product, AdminSite())
|
||||
request = RequestFactory().get("/")
|
||||
request._club_obj = product
|
||||
|
||||
field = admin_obj.formfield_for_foreignkey(Product._meta.get_field("season"), request)
|
||||
|
||||
self.assertIn(self.season, field.queryset)
|
||||
self.assertNotIn(self.other_season, field.queryset)
|
||||
|
||||
Reference in New Issue
Block a user