diff --git a/shop/admin.py b/shop/admin.py index 846f6b4..2518db7 100644 --- a/shop/admin.py +++ b/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"] diff --git a/shop/migrations/0003_alter_product_options_and_more.py b/shop/migrations/0003_alter_product_options_and_more.py new file mode 100644 index 0000000..524c023 --- /dev/null +++ b/shop/migrations/0003_alter_product_options_and_more.py @@ -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'), + ), + ] diff --git a/shop/migrations/0004_cart.py b/shop/migrations/0004_cart.py new file mode 100644 index 0000000..26305f9 --- /dev/null +++ b/shop/migrations/0004_cart.py @@ -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')], + }, + ), + ] diff --git a/shop/migrations/0005_product_staff_role_cartitem_order.py b/shop/migrations/0005_product_staff_role_cartitem_order.py new file mode 100644 index 0000000..6e067ed --- /dev/null +++ b/shop/migrations/0005_product_staff_role_cartitem_order.py @@ -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')], + }, + ), + ] diff --git a/shop/migrations/0006_alter_order_number.py b/shop/migrations/0006_alter_order_number.py new file mode 100644 index 0000000..5b80f06 --- /dev/null +++ b/shop/migrations/0006_alter_order_number.py @@ -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'), + ), + ] diff --git a/shop/migrations/0007_discount_applieddiscount_invoice_orderline_payment_and_more.py b/shop/migrations/0007_discount_applieddiscount_invoice_orderline_payment_and_more.py new file mode 100644 index 0000000..cbffa6a --- /dev/null +++ b/shop/migrations/0007_discount_applieddiscount_invoice_orderline_payment_and_more.py @@ -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'), + ), + ] diff --git a/shop/migrations/0008_alter_applieddiscount_applied_by.py b/shop/migrations/0008_alter_applieddiscount_applied_by.py new file mode 100644 index 0000000..60c9ce9 --- /dev/null +++ b/shop/migrations/0008_alter_applieddiscount_applied_by.py @@ -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'), + ), + ] diff --git a/shop/migrations/0009_applieddiscount_unique_discount_per_order.py b/shop/migrations/0009_applieddiscount_unique_discount_per_order.py new file mode 100644 index 0000000..5f6fab6 --- /dev/null +++ b/shop/migrations/0009_applieddiscount_unique_discount_per_order.py @@ -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'), + ), + ] diff --git a/shop/models.py b/shop/models.py index ac6877f..7a7a3f2 100644 --- a/shop/models.py +++ b/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: ``--``.""" + 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--``.""" + 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--``.""" + return next_scoped_number(self, "INV") + + def save(self, *args, **kwargs): + return save_with_number(self, lambda: super(Invoice, self).save(*args, **kwargs)) diff --git a/shop/tests.py b/shop/tests.py index bd1b8ec..f0de9f5 100644 --- a/shop/tests.py +++ b/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)