Implements BILLING.md. The architecture was sound -- snapshot-on-Due, dated prices, asymmetric dry-run commands are all kept -- so this fixes the three hardcoded assumptions rather than rewriting. The real defect: grace ran from period_END, so an annual club used the whole unpaid year plus 45 days (~410 days) before anything switched it off. Grace now runs from the period START, and every clock is per-plan. - Tier -> Plan (+ TierPrice -> PlanPrice, and every FK). Migration 0004 is hand-written: run non-interactively, makemigrations emits DeleteModel+CreateModel and drops every price, subscription and due. Its two RemoveConstraints must come first, or SQLite's table-rebuild tries to render a constraint over a just-renamed column. Verified by round-tripping real rows through it. - Plan gains duration_months / renewal_lead_days / grace_days / is_trial, with CheckConstraints and a matching clean() so the form reports an impossible plan instead of 500ing on IntegrityError. - Existing dues keep their stored grace_until. Re-deriving it would put the date in the past for every open annual period and archive the entire paying customer base on the next --commit run. - Trials take their length from the trial plan's own duration_months; start_trial() loses its trial_months argument. - New BillingNotice service drives a club-facing warning: every level on the dashboard, and on every management page once urgent. - send_billing_reminders emails club admins, once per escalation level so a daily cron is not a daily email. SMTP settings are env-driven and provider-agnostic; the backend defaults to console. - Paying does not auto-restore an archived club -- the control panel surfaces a Reactivate prompt instead, since a club can also be archived by hand.
368 lines
15 KiB
Python
368 lines
15 KiB
Python
from datetime import timedelta
|
|
|
|
from django import forms
|
|
from django.core.exceptions import ValidationError
|
|
from django.db import IntegrityError
|
|
from django.db.models import ProtectedError
|
|
from django.test import TestCase
|
|
from django.utils import timezone
|
|
|
|
from club.models import Club
|
|
from members.models import Member
|
|
|
|
from .models import Answer, Field, Form, Submission
|
|
from .services import (
|
|
FormSubmissionError,
|
|
build_form,
|
|
build_form_class,
|
|
field_choices,
|
|
form_report,
|
|
submit_form,
|
|
)
|
|
|
|
|
|
class FormbuilderTestBase(TestCase):
|
|
def setUp(self):
|
|
self.club = Club.objects.create(name="Ajax United", slug="ajax-united")
|
|
self.form = Form.objects.create(club=self.club, title="Sign-up", slug="sign-up")
|
|
self.name = Field.objects.create(form=self.form, key="name", label="Name", field_type=Field.FieldType.TEXT, required=True, order=1)
|
|
self.size = Field.objects.create(form=self.form, key="size", label="Shirt size", field_type=Field.FieldType.CHOICE, required=False, order=2, options=["S", "M", "L"])
|
|
self.member = Member.objects.create(first_name="Jane", last_name="Doe")
|
|
|
|
|
|
class ModelTests(FormbuilderTestBase):
|
|
def test_str_methods(self):
|
|
submission = Submission.objects.create(form=self.form, member=self.member)
|
|
answer = Answer.objects.create(submission=submission, field=self.name, value="Jane")
|
|
|
|
self.assertEqual(str(self.form), "Sign-up")
|
|
self.assertEqual(str(self.name), "Name")
|
|
self.assertEqual(str(submission), "Sign-up - Jane Doe")
|
|
self.assertEqual(str(answer), "Sign-up - Jane Doe - Name")
|
|
|
|
def test_slug_is_unique_per_club_not_globally(self):
|
|
other_club = Club.objects.create(name="Rival FC", slug="rival-fc")
|
|
Form.objects.create(club=other_club, title="Sign-up", slug="sign-up")
|
|
|
|
with self.assertRaises(IntegrityError):
|
|
Form.objects.create(club=self.club, title="Another", slug="sign-up")
|
|
|
|
def test_key_is_unique_per_form_not_globally(self):
|
|
other_form = Form.objects.create(club=self.club, title="Other", slug="other")
|
|
Field.objects.create(form=other_form, key="name", label="Name", order=1)
|
|
|
|
with self.assertRaises(IntegrityError):
|
|
Field.objects.create(form=self.form, key="name", label="Duplicate", order=9)
|
|
|
|
def test_answer_is_unique_per_field_per_submission(self):
|
|
submission = Submission.objects.create(form=self.form, member=self.member)
|
|
Answer.objects.create(submission=submission, field=self.name, value="Jane")
|
|
|
|
with self.assertRaises(IntegrityError):
|
|
Answer.objects.create(submission=submission, field=self.name, value="Again")
|
|
|
|
def test_deleting_form_cascades_to_fields_and_submissions(self):
|
|
Submission.objects.create(form=self.form, member=self.member)
|
|
|
|
self.form.delete()
|
|
|
|
self.assertFalse(Field.objects.exists())
|
|
self.assertFalse(Submission.objects.exists())
|
|
|
|
def test_field_referenced_by_answer_is_protected(self):
|
|
submission = Submission.objects.create(form=self.form, member=self.member)
|
|
Answer.objects.create(submission=submission, field=self.name, value="Jane")
|
|
|
|
with self.assertRaises(ProtectedError):
|
|
self.name.delete()
|
|
|
|
|
|
class FormSlugTests(FormbuilderTestBase):
|
|
def test_slug_auto_populated_from_title(self):
|
|
form = Form.objects.create(club=self.club, title="Registration Form")
|
|
|
|
self.assertEqual(form.slug, "registration-form")
|
|
|
|
def test_explicit_slug_is_preserved(self):
|
|
form = Form.objects.create(club=self.club, title="Registration", slug="reg")
|
|
|
|
self.assertEqual(form.slug, "reg")
|
|
|
|
def test_slug_is_unique_per_club_with_suffix(self):
|
|
first = Form.objects.create(club=self.club, title="Registration")
|
|
second = Form.objects.create(club=self.club, title="Registration")
|
|
|
|
self.assertEqual(first.slug, "registration")
|
|
self.assertEqual(second.slug, "registration-2")
|
|
|
|
|
|
class FieldKeyTests(FormbuilderTestBase):
|
|
def test_key_auto_populated_from_label(self):
|
|
field = Field.objects.create(form=self.form, label="First Name", order=5)
|
|
|
|
self.assertEqual(field.key, "first-name")
|
|
|
|
def test_explicit_key_is_preserved(self):
|
|
field = Field.objects.create(form=self.form, key="fn", label="First Name", order=5)
|
|
|
|
self.assertEqual(field.key, "fn")
|
|
|
|
def test_key_is_unique_per_form_with_suffix(self):
|
|
first = Field.objects.create(form=self.form, label="First Name", order=5)
|
|
second = Field.objects.create(form=self.form, label="First Name", order=6)
|
|
|
|
self.assertEqual(first.key, "first-name")
|
|
self.assertEqual(second.key, "first-name-2")
|
|
|
|
def test_same_key_allowed_in_a_different_form(self):
|
|
other_form = Form.objects.create(club=self.club, title="Other", slug="other")
|
|
here = Field.objects.create(form=self.form, label="First Name", order=5)
|
|
there = Field.objects.create(form=other_form, label="First Name", order=1)
|
|
|
|
self.assertEqual(here.key, there.key)
|
|
|
|
|
|
class FieldChoicesTests(FormbuilderTestBase):
|
|
def test_string_options(self):
|
|
self.assertEqual(field_choices(self.size), [("S", "S"), ("M", "M"), ("L", "L")])
|
|
|
|
def test_dict_options(self):
|
|
field = Field.objects.create(form=self.form, key="plan", label="Plan", field_type=Field.FieldType.CHOICE, order=3, options=[{"value": "a", "label": "Gold"}, {"value": "b", "label": "Silver"}])
|
|
|
|
self.assertEqual(field_choices(field), [("a", "Gold"), ("b", "Silver")])
|
|
|
|
def test_no_options(self):
|
|
self.assertEqual(field_choices(self.name), [])
|
|
|
|
|
|
class SubmitFormTests(FormbuilderTestBase):
|
|
def test_successful_submission_creates_submission_and_answers(self):
|
|
submission = submit_form(self.form, self.member, {"name": "Jane", "size": "M"})
|
|
|
|
self.assertEqual(submission.form, self.form)
|
|
self.assertEqual(submission.member, self.member)
|
|
self.assertEqual(submission.answers.count(), 2)
|
|
self.assertEqual(submission.answers.get(field=self.name).value, "Jane")
|
|
|
|
def test_optional_field_can_be_omitted(self):
|
|
submission = submit_form(self.form, self.member, {"name": "Jane"})
|
|
|
|
self.assertEqual(submission.answers.count(), 1)
|
|
|
|
def test_missing_required_field_is_rejected(self):
|
|
with self.assertRaises(FormSubmissionError) as ctx:
|
|
submit_form(self.form, self.member, {"size": "M"})
|
|
|
|
self.assertIn("name", ctx.exception.errors)
|
|
self.assertFalse(Submission.objects.exists())
|
|
|
|
def test_inactive_form_is_rejected(self):
|
|
self.form.is_active = False
|
|
self.form.save()
|
|
|
|
with self.assertRaises(FormSubmissionError):
|
|
submit_form(self.form, self.member, {"name": "Jane"})
|
|
|
|
def test_login_required_without_member_is_rejected(self):
|
|
self.form.login_required = True
|
|
self.form.save()
|
|
|
|
with self.assertRaises(FormSubmissionError):
|
|
submit_form(self.form, None, {"name": "Jane"})
|
|
|
|
def test_anonymous_submission_allowed_when_login_not_required(self):
|
|
submission = submit_form(self.form, None, {"name": "Jane"})
|
|
|
|
self.assertIsNone(submission.member)
|
|
|
|
def test_not_open_yet_is_rejected(self):
|
|
self.form.opens_at = timezone.now() + timedelta(days=1)
|
|
self.form.save()
|
|
|
|
with self.assertRaises(FormSubmissionError):
|
|
submit_form(self.form, self.member, {"name": "Jane"})
|
|
|
|
def test_closed_form_is_rejected(self):
|
|
self.form.closes_at = timezone.now() - timedelta(days=1)
|
|
self.form.save()
|
|
|
|
with self.assertRaises(FormSubmissionError):
|
|
submit_form(self.form, self.member, {"name": "Jane"})
|
|
|
|
def test_open_window_respects_when_argument(self):
|
|
self.form.opens_at = timezone.now() + timedelta(days=1)
|
|
self.form.save()
|
|
|
|
submission = submit_form(self.form, self.member, {"name": "Jane"}, when=timezone.now() + timedelta(days=2))
|
|
|
|
self.assertTrue(Submission.objects.filter(pk=submission.pk).exists())
|
|
|
|
def test_max_submissions_per_user_is_enforced(self):
|
|
self.form.max_submissions_per_user = 1
|
|
self.form.save()
|
|
submit_form(self.form, self.member, {"name": "Jane"})
|
|
|
|
with self.assertRaises(FormSubmissionError):
|
|
submit_form(self.form, self.member, {"name": "Jane"})
|
|
|
|
def test_invalid_choice_is_rejected(self):
|
|
with self.assertRaises(FormSubmissionError) as ctx:
|
|
submit_form(self.form, self.member, {"name": "Jane", "size": "XL"})
|
|
|
|
self.assertIn("size", ctx.exception.errors)
|
|
|
|
def test_number_field_rejects_non_numeric_input(self):
|
|
# Validation goes through the same dynamic Django Form the UI renders, so a
|
|
# NUMBER field is checked as a decimal — not merely "present".
|
|
Field.objects.create(form=self.form, key="age", label="Age", field_type=Field.FieldType.NUMBER, required=True, order=3)
|
|
|
|
with self.assertRaises(FormSubmissionError) as ctx:
|
|
submit_form(self.form, self.member, {"name": "Jane", "age": "not-a-number"})
|
|
|
|
self.assertIn("age", ctx.exception.errors)
|
|
|
|
def test_email_field_rejects_an_invalid_address(self):
|
|
Field.objects.create(form=self.form, key="contact", label="Contact", field_type=Field.FieldType.EMAIL, required=True, order=3)
|
|
|
|
with self.assertRaises(FormSubmissionError) as ctx:
|
|
submit_form(self.form, self.member, {"name": "Jane", "contact": "not-an-email"})
|
|
|
|
self.assertIn("contact", ctx.exception.errors)
|
|
|
|
def test_multichoice_validation(self):
|
|
field = Field.objects.create(form=self.form, key="days", label="Days", field_type=Field.FieldType.MULTICHOICE, required=False, order=3, options=["mon", "tue", "wed"])
|
|
|
|
submit_form(self.form, self.member, {"name": "Jane", "days": ["mon", "wed"]})
|
|
with self.assertRaises(FormSubmissionError):
|
|
submit_form(self.form, self.member, {"name": "Joe", "days": ["mon", "sun"]})
|
|
self.assertEqual(Answer.objects.filter(field=field).count(), 1)
|
|
|
|
def test_inactive_field_is_ignored(self):
|
|
Field.objects.create(form=self.form, key="secret", label="Secret", field_type=Field.FieldType.TEXT, required=True, is_active=False, order=4)
|
|
|
|
submission = submit_form(self.form, self.member, {"name": "Jane"})
|
|
|
|
self.assertFalse(submission.answers.filter(field__key="secret").exists())
|
|
|
|
def test_unknown_keys_are_ignored(self):
|
|
submission = submit_form(self.form, self.member, {"name": "Jane", "bogus": "x"})
|
|
|
|
self.assertEqual(submission.answers.count(), 1)
|
|
|
|
|
|
class BuildFormTests(FormbuilderTestBase):
|
|
def make_all_field_types_form(self):
|
|
form = Form.objects.create(club=self.club, title="All", slug="all")
|
|
specs = [
|
|
("f_text", Field.FieldType.TEXT, forms.CharField),
|
|
("f_area", Field.FieldType.TEXTAREA, forms.CharField),
|
|
("f_num", Field.FieldType.NUMBER, forms.DecimalField),
|
|
("f_email", Field.FieldType.EMAIL, forms.EmailField),
|
|
("f_date", Field.FieldType.DATE, forms.DateField),
|
|
("f_choice", Field.FieldType.CHOICE, forms.ChoiceField),
|
|
("f_multi", Field.FieldType.MULTICHOICE, forms.MultipleChoiceField),
|
|
("f_check", Field.FieldType.CHECKBOX, forms.BooleanField),
|
|
("f_file", Field.FieldType.FILE, forms.FileField),
|
|
]
|
|
for i, (key, field_type, _) in enumerate(specs):
|
|
Field.objects.create(form=form, key=key, label=key, field_type=field_type, required=False, order=i, options=["a", "b"])
|
|
return form, specs
|
|
|
|
def test_field_classes_map_from_field_type(self):
|
|
form, specs = self.make_all_field_types_form()
|
|
|
|
instance = build_form_class(form)()
|
|
|
|
for key, _, expected in specs:
|
|
with self.subTest(key=key):
|
|
self.assertIsInstance(instance.fields[key], expected)
|
|
|
|
def test_required_and_labels_propagate(self):
|
|
instance = build_form_class(self.form)()
|
|
|
|
self.assertTrue(instance.fields["name"].required)
|
|
self.assertFalse(instance.fields["size"].required)
|
|
self.assertEqual(instance.fields["name"].label, "Name")
|
|
|
|
def test_choice_field_gets_choices(self):
|
|
instance = build_form(self.form)
|
|
|
|
self.assertEqual(list(instance.fields["size"].choices), [("S", "S"), ("M", "M"), ("L", "L")])
|
|
|
|
def test_bound_form_validates(self):
|
|
bound = build_form(self.form, data={"name": "Jane", "size": "M"})
|
|
|
|
self.assertTrue(bound.is_valid())
|
|
self.assertEqual(bound.cleaned_data["name"], "Jane")
|
|
|
|
def test_bound_form_reports_required_error(self):
|
|
bound = build_form(self.form, data={"size": "M"})
|
|
|
|
self.assertFalse(bound.is_valid())
|
|
self.assertIn("name", bound.errors)
|
|
|
|
def test_inactive_fields_are_excluded(self):
|
|
Field.objects.create(form=self.form, key="hidden", label="Hidden", is_active=False, order=9)
|
|
|
|
instance = build_form(self.form)
|
|
|
|
self.assertNotIn("hidden", instance.fields)
|
|
|
|
|
|
class FormReportTests(FormbuilderTestBase):
|
|
def test_columns_are_fields_in_order(self):
|
|
report = form_report(self.form)
|
|
|
|
self.assertEqual(report.columns, [self.name, self.size])
|
|
|
|
def test_rows_carry_answer_values(self):
|
|
submit_form(self.form, self.member, {"name": "Jane", "size": "M"})
|
|
|
|
report = form_report(self.form)
|
|
|
|
self.assertEqual(report.count, 1)
|
|
row = report.rows[0]
|
|
self.assertEqual(row.values[self.name.id], "Jane")
|
|
self.assertEqual(row.values[self.size.id], "M")
|
|
|
|
def test_choice_field_is_summarised(self):
|
|
submit_form(self.form, self.member, {"name": "Jane", "size": "M"})
|
|
submit_form(self.form, Member.objects.create(first_name="Joe", last_name="Roe"), {"name": "Joe", "size": "M"})
|
|
submit_form(self.form, Member.objects.create(first_name="Kim", last_name="Ash"), {"name": "Kim", "size": "L"})
|
|
|
|
report = form_report(self.form)
|
|
|
|
self.assertEqual(report.summaries[self.size.id], {"M": 2, "L": 1})
|
|
self.assertNotIn(self.name.id, report.summaries)
|
|
|
|
def test_multichoice_values_are_tallied_per_option(self):
|
|
days = Field.objects.create(form=self.form, key="days", label="Days", field_type=Field.FieldType.MULTICHOICE, required=False, order=3, options=["mon", "tue"])
|
|
submit_form(self.form, self.member, {"name": "Jane", "days": ["mon", "tue"]})
|
|
|
|
report = form_report(self.form)
|
|
|
|
self.assertEqual(report.summaries[days.id], {"mon": 1, "tue": 1})
|
|
|
|
def test_empty_form_reports_no_rows(self):
|
|
report = form_report(self.form)
|
|
|
|
self.assertEqual(report.count, 0)
|
|
self.assertEqual(report.rows, [])
|
|
|
|
|
|
class AnswerCleanTests(FormbuilderTestBase):
|
|
def test_rejects_field_from_another_form(self):
|
|
other_form = Form.objects.create(club=self.club, title="Other", slug="other")
|
|
other_field = Field.objects.create(form=other_form, key="x", label="X", order=1)
|
|
submission = Submission.objects.create(form=self.form, member=self.member)
|
|
answer = Answer(submission=submission, field=other_field, value="v")
|
|
|
|
with self.assertRaises(ValidationError) as ctx:
|
|
answer.full_clean()
|
|
self.assertIn("field", ctx.exception.error_dict)
|
|
|
|
def test_accepts_field_from_the_submissions_form(self):
|
|
submission = Submission.objects.create(form=self.form, member=self.member)
|
|
|
|
Answer(submission=submission, field=self.name, value="v").full_clean()
|