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 import IntegrityError, models, transaction
|
||||||
from django.db.models import UniqueConstraint
|
from django.db.models import Q, UniqueConstraint
|
||||||
|
from django.utils import timezone
|
||||||
from django.utils.translation import gettext_lazy as _
|
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 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)
|
name = models.CharField(_("name"), max_length=255)
|
||||||
slug = models.SlugField(_("slug"), max_length=255, blank=True)
|
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"
|
slug_source = "name"
|
||||||
|
|
||||||
class Meta:
|
class Meta:
|
||||||
|
verbose_name = _("product")
|
||||||
|
verbose_name_plural = _("products")
|
||||||
|
ordering = ["name"]
|
||||||
constraints = [
|
constraints = [
|
||||||
UniqueConstraint(fields=["club", "slug"], name="unique_product_slug_per_club"),
|
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):
|
class ProductSlugTests(TestCase):
|
||||||
@@ -37,3 +61,400 @@ class ProductSlugTests(TestCase):
|
|||||||
product = Product.objects.create(club=self.club, name="###")
|
product = Product.objects.create(club=self.club, name="###")
|
||||||
|
|
||||||
self.assertEqual(product.slug, "item")
|
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