Replace "instantbuy" setting on sponsorship levels with contract levels
authorMagnus Hagander <magnus@hagander.net>
Fri, 28 Feb 2025 15:59:03 +0000 (16:59 +0100)
committerMagnus Hagander <magnus@hagander.net>
Mon, 28 Apr 2025 20:53:23 +0000 (22:53 +0200)
This allows explicit specification of "No contract, Click-through
contract or Full contract", instead of the definitely-hard-to-grok
combination of whether a contract existed and if the checkbox was set or
not.

postgresqleu/confsponsor/backendforms.py
postgresqleu/confsponsor/migrations/0034_rename_instantbuy.py [new file with mode: 0644]
postgresqleu/confsponsor/models.py
postgresqleu/confsponsor/util.py
postgresqleu/confsponsor/views.py
template/confsponsor/admin_dashboard.html
template/confsponsor/admin_sponsor.html
template/confsponsor/admin_sponsor_details.html
template/confsponsor/signupform.html
template/confsponsor/sponsor.html

index 10f945f05d1ee80a3a783e8a95a8f8f9d455145c..ffa9b959a507b813d21635114fe295ac9f509e15 100644 (file)
@@ -304,7 +304,7 @@ class BackendSponsorshipLevelBenefitCopyForm(django.forms.Form):
 
 class BackendSponsorshipLevelForm(BackendForm):
     helplink = 'sponsors#level'
-    list_fields = ['levelname', 'levelcost', 'available', 'public', ]
+    list_fields = ['levelname', 'levelcost', 'available', 'public', 'contractlevel']
     linked_objects = OrderedDict({
         'benefit': BackendSponsorshipLevelBenefitManager(),
     })
@@ -315,7 +315,7 @@ class BackendSponsorshipLevelForm(BackendForm):
 
     class Meta:
         model = SponsorshipLevel
-        fields = ['levelname', 'urlname', 'levelcost', 'available', 'public', 'maxnumber', 'instantbuy',
+        fields = ['levelname', 'urlname', 'levelcost', 'available', 'public', 'maxnumber', 'contractlevel',
                   'paymentdays', 'paymentdueby', 'paymentmethods', 'invoiceextradescription', 'contract', 'canbuyvoucher', 'canbuydiscountcode']
         widgets = {
             'paymentmethods': django.forms.CheckboxSelectMultiple,
@@ -330,7 +330,7 @@ class BackendSponsorshipLevelForm(BackendForm):
         {
             'id': 'contract',
             'legend': 'Contract information',
-            'fields': ['instantbuy', 'contract', 'paymentdays', 'paymentdueby'],
+            'fields': ['contractlevel', 'contract', 'paymentdays', 'paymentdueby'],
         },
         {
             'id': 'payment',
@@ -353,13 +353,19 @@ class BackendSponsorshipLevelForm(BackendForm):
     def clean(self):
         cleaned_data = super(BackendSponsorshipLevelForm, self).clean()
 
-        if not (cleaned_data.get('instantbuy', False) or cleaned_data['contract']):
-            self.add_error('instantbuy', 'Sponsorship level must either be instant signup or have a contract')
-            self.add_error('contract', 'Sponsorship level must either be instant signup or have a contract')
-
-        if int(cleaned_data['levelcost'] == 0) and cleaned_data.get('instantbuy', False):
-            self.add_error('levelcost', 'Sponsorships with zero cost can not be instant signup')
-            self.add_error('instantbuy', 'Sponsorships with zero cost can not be instant signup')
+        if cleaned_data['contractlevel'] == 0:
+            if cleaned_data['contract']:
+                self.add_error('contract', 'Contracts cannot be specified when contract level is No contract')
+            if cleaned_data['levelcost'] == 0:
+                self.add_error('levelcost', 'Cost cannot be zero when contract level is No contract')
+        elif cleaned_data['contractlevel'] == 1:
+            if not cleaned_data['contract']:
+                self.add_error('contract', 'Contract is required when contract level is Click-through')
+            if cleaned_data['levelcost'] == 0:
+                self.add_error('levelcost', 'Cost cannot be zero when contract level is Click-through')
+        else:
+            if not cleaned_data['contract']:
+                self.add_error('contract', 'Contract is required when contract level is Full')
 
         return cleaned_data
 
diff --git a/postgresqleu/confsponsor/migrations/0034_rename_instantbuy.py b/postgresqleu/confsponsor/migrations/0034_rename_instantbuy.py
new file mode 100644 (file)
index 0000000..0debf89
--- /dev/null
@@ -0,0 +1,29 @@
+# Generated by Django 4.2.11 on 2025-02-26 20:22
+
+from django.db import migrations, models
+
+
+class Migration(migrations.Migration):
+
+    dependencies = [
+        ('confsponsor', '0033_payment_terms'),
+    ]
+
+    operations = [
+        migrations.AddField(
+            model_name='sponsorshiplevel',
+            name='contractlevel',
+            field=models.IntegerField(choices=[(0, 'No contract'), (1, 'Click-through contract'), (2, 'Full contract')], default=0, verbose_name='Contract level'),
+        ),
+        migrations.RunSQL("""
+UPDATE confsponsor_sponsorshiplevel SET contractlevel=CASE
+WHEN instantbuy AND contract_id IS NOT NULL THEN 1
+WHEN instantbuy AND contract_id IS NULL THEN 0
+ELSE 2 END""",
+                          """
+UPDATE confsponsor_sponsorshiplevel SET instantbuy=(contractlevel < 2)"""),
+        migrations.RemoveField(
+            model_name='sponsorshiplevel',
+            name='instantbuy',
+        ),
+    ]
index 5536c9b27014a7b0dddaaad196c5710471004ab2..d6d9b404c8a732b8ba55c8ec7b0d2a15fd911ec3 100644 (file)
@@ -23,6 +23,13 @@ vat_status_choices = (
     (2, 'Company is from outside EU'),
 )
 
+CONTRACT_LEVEL_CHOICES = (
+    (0, 'No contract'),
+    (1, 'Click-through contract'),
+    (2, 'Full contract'),
+)
+CONTRACT_LEVEL_MAP = dict(CONTRACT_LEVEL_CHOICES)
+
 
 class SponsorshipContract(models.Model):
     conference = models.ForeignKey(Conference, null=False, blank=False, on_delete=models.CASCADE)
@@ -47,7 +54,8 @@ class SponsorshipLevel(models.Model):
     public = models.BooleanField(null=False, blank=False, default=True, verbose_name="Publicly visible",
                                  help_text="If unchecked the sponsorship level will be treated as internal, for example for testing")
     maxnumber = models.IntegerField(null=False, blank=False, default=0, verbose_name="Maximum number of sponsors")
-    instantbuy = models.BooleanField(null=False, blank=False, default=False, verbose_name="Instant buy available")
+    contractlevel = models.IntegerField(null=False, blank=False, default=0, verbose_name="Contract level",
+                                        choices=CONTRACT_LEVEL_CHOICES)
     paymentmethods = models.ManyToManyField(InvoicePaymentMethod, blank=False, verbose_name="Payment methods for generated invoices")
     invoiceextradescription = models.TextField(
         blank=True, null=False, verbose_name="Invoice extra description",
@@ -90,6 +98,13 @@ class SponsorshipLevel(models.Model):
                 return True
         return False
 
+    @cached_property
+    def contractlevel_name(self):
+        return CONTRACT_LEVEL_MAP[self.contractlevel]
+
+    def _display_contractlevel(self, cache):
+        return self.contractlevel_name
+
 
 class SponsorshipBenefit(models.Model):
     level = models.ForeignKey(SponsorshipLevel, null=False, blank=False, on_delete=models.CASCADE)
index c88d87e1f60f06a309d6dba298f865e623207d66..f379e393171b4bac3c79863811a918abb4082920 100644 (file)
@@ -53,7 +53,8 @@ ORDER BY max(b.sortkey), a.overview_name""", {
                 'cost': format_currency(lvl.levelcost),
                 'available': lvl.available,
                 'maxnumber': lvl.maxnumber,
-                'instantbuy': lvl.instantbuy,
+                'instantbuy': lvl.contractlevel == 1 or (lvl.contractlevel == 0 and cost > 0),  # legacy
+                'contractlevel': lvl.contractlevel_name,
                 'benefits': [dict(_get_benefit_data(b)) for b in lvl.sponsorshipbenefit_set.all()
                 ],
             }
index 8106964805d3e4b2ae05d255755cd9b1d89f1a62..78a0be0355ba24a74a7cf67d97f2743e75e31907 100644 (file)
@@ -222,7 +222,7 @@ def sponsor_contractview(request, sponsorid):
         # Should not happen
         raise Http404("No contract at this level")
 
-    if sponsor.level.instantbuy:
+    if sponsor.level.contractlevel == 1:
         # Click-through contract
 
         resp = HttpResponse(content_type='application/pdf')
@@ -375,6 +375,9 @@ def _generate_and_send_sponsor_contract(sponsor):
     conference = sponsor.conference
     level = sponsor.level
 
+    if level.contractlevel == 0:
+        return
+
     pdf = fill_pdf_fields(
         level.contract.contractpdf,
         get_pdf_fields_for_conference(conference, sponsor),
@@ -386,7 +389,7 @@ def _generate_and_send_sponsor_contract(sponsor):
         send_sponsor_manager_email(
             sponsor,
             'Your contract for {}'.format(conference.conferencename),
-            'confsponsor/mail/{}.txt'.format('sponsor_contract_instant' if level.instantbuy else 'sponsor_contract_manual'),
+            'confsponsor/mail/{}.txt'.format('sponsor_contract_instant' if level.contractlevel == 1 else 'sponsor_contract_manual'),
             {
                 'conference': conference,
                 'sponsor': sponsor,
@@ -452,7 +455,7 @@ def sponsor_signup(request, confurlname, levelurlname):
         # Stage 2 = contract choice. When submitted, sign up.
         # If there is no contract needed on this level, or there is no choice
         # of contract because only one available, we bypass stage 1.
-        if stage == '1' and (level.instantbuy or not conference.contractprovider or not conference.manualcontracts):
+        if stage == '1' and (level.contractlevel != 2 or not conference.contractprovider or not conference.manualcontracts):
             stage = '2'
 
         def _render_contract_choices():
@@ -485,7 +488,6 @@ def sponsor_signup(request, confurlname, levelurlname):
                     'level': level,
                     'form': form,
                     'noform': 1,
-                    'needscontract': not (level.instantbuy or not conference.contractprovider),
                     'sponsorname': form.cleaned_data['name'],
                     'vatnumber': form.cleaned_data['vatnumber'] if settings.EU_VAT else None,
                     'previewaddr': get_sponsor_invoice_address(form.cleaned_data['name'],
@@ -502,7 +504,7 @@ def sponsor_signup(request, confurlname, levelurlname):
             # If the Continue editing button is selected we should go back
             # to just rendering the normal form. Otherwise, go ahead and create the record.
             if request.POST.get('submit', '') != 'Continue editing':
-                if request.POST.get('contractchoice', '') not in ('0', '1') and not level.instantbuy:
+                if request.POST.get('contractchoice', '') not in ('0', '1') and level.contractlevel == 2:
                     return _render_contract_choices()
 
                 social = {
@@ -520,7 +522,7 @@ def sponsor_signup(request, confurlname, levelurlname):
                                   level=level,
                                   social=social,
                                   invoiceaddr=form.cleaned_data['address'],
-                                  signmethod=1 if request.POST.get('contractchoice', '') == '1' or not conference.contractprovider or level.instantbuy else 0,
+                                  signmethod=1 if request.POST.get('contractchoice', '') == '1' or not conference.contractprovider or level.contractlevel < 2 else 0,
                                   autoapprovesigned=conference.autocontracts,
                                   )
                 if settings.EU_VAT:
@@ -534,10 +536,10 @@ def sponsor_signup(request, confurlname, levelurlname):
 
                 error = None
 
-                if level.instantbuy:
-                    if sponsor.level.contract:
-                        # Instantbuy levels that has a contract should get an implicit contract
-                        # attached to an email.
+                if level.contractlevel < 2:
+                    # No contract or click-through contract
+                    if level.contractlevel == 1:
+                        # Click-through contract
                         _generate_and_send_sponsor_contract(sponsor)
 
                     mailstr += "Level does not require a signed contract. Verify the details and approve\nthe sponsorship using:\n\n{0}/events/sponsor/admin/{1}/{2}/".format(
index 3cfbdb844caa9434891df21dead7d58025bc294a..9c24309486bf05c0a00dd588a163c45881526b84 100644 (file)
@@ -86,7 +86,7 @@ sponsor manually, you may want to confirm them manually as well...
         {%else%}
       <span class="label label-success">Invoiced</span>
         {%endif%}
-      {%elif s.level.instantbuy%}
+      {%elif s.level.contractlevel < 2 %}
       <span class="label label-warning" title="Sponsor details for instant buy levels have to be verified before invoice is issued">Pending organizer verification</span>
       {%else%}
         {%if s.signmethod == 0 and s.contract %}
index 21e48f0aa2f536b0e6d05265683e06bba81a3d5d..caaa7b59f1d6433e3934e39fa1cefd679afc66b4 100644 (file)
 <p>
 This sponsorship is awaiting an <a href="/invoiceadmin/{{sponsor.invoice.pk}}/">invoice</a> to be paid.
 </p>
-{%if not sponsor.level.instantbuy%}
-{%comment%}Instant buy sponsorships should never be manually confirmed{%endcomment%}
+{%if sponsor.level.contractlevel == 2 %}
+{%comment%}Only full contract sponsorships should be manually confirmed{%endcomment%}
 <p>
 <b>Iff</b> there is a signed <i>and</i> countersigned contract available
 for this sponsor, it can be confirmed before the invoice is paid.
@@ -161,7 +161,7 @@ for this sponsor, it can be confirmed before the invoice is paid.
 {%else%}
 {%comment%}Sponsor has no invoice{%endcomment%}
 {%if sponsor.level.levelcost %}
-{%if sponsor.level.instantbuy%}
+{%if sponsor.level.contractlevel < 2%}
 <p>
   This sponsorship has not yet been issued an invoice. This is an
   "instant buy" level sponsorship, so as soon as the sponsorship
@@ -191,7 +191,7 @@ This sponsorship has not yet been issued an invoice. Once the contract
 has been received, go ahead and generate the invoice.
 </p>
 {%endif%}{%comment%}Digital contracts{%endcomment%}
-{%endif%}{%comment%}Instant buy{%endcomment%}
+{%endif%}{%comment%}contractlevel < 2{%endcomment%}
 {%else%}{%comment%}levelcost != 0 {%endcomment%}
 <p>
   This sponsorship has zero cost, which means payment is handled manually.
index 371c3148ec91502fdc26910e4f9dcbe0d5367159..dac8b191b88405b748816f990b3137c4a27516fa 100644 (file)
   <tr>
     <th>Contract:</th>
     <td>
-      {%if sponsor.level.instantbuy %}
-      {%if sponsor.level.contract %}
-      Click-through contract completed. <form class="inline-block-form" method="post" action="resendcontract/">{% csrf_token %}<input type="submit" class="btn btn-sm btn-default confirm-btn" value="Re-send contract anyway" data-confirm="Are you sure you want to re-send a new contract to this sponsor?{%if sponsor.signmethod == 0%} {{conference.contractprovider.implementation.resendprompt}}{%endif%}"></form>
-      {%else%}
+      {%if sponsor.level.contractlevel == 0 %}
       No contract needed for this level.
-      {%endif%}
-      {%else%}
+      {%elif sponsor.level.contractlevel == 1 %}
+      Click-through contract completed. {%if not sponsor.confirmed%}<form class="inline-block-form" method="post" action="resendcontract/">{% csrf_token %}<input type="submit" class="btn btn-sm btn-default confirm-btn" value="Re-send contract anyway" data-confirm="Are you sure you want to re-send a new contract to this sponsor?{%if sponsor.signmethod == 0%} {{conference.contractprovider.implementation.resendprompt}}{%endif%}"></form>{%endif%}
+      {%else%}{%comment%}Full contract{%endcomment%}
        {%if sponsor.signmethod == 0%}
        Digital contract.<br/>
         {%if sponsor.contract.completed%}Signed ({{sponsor.contract.firstsigned}}) and countersigned ({{sponsor.contract.completed}}).
@@ -63,7 +61,7 @@
        <form class="inline-block-form" method="post" action="resendcontract/">{% csrf_token %}<input type="submit" class="btn btn-sm btn-default confirm-btn" value="Re-send contract" data-confirm="Are you sure you want to re-send a new contract to this sponsor?{%if sponsor.signmethod == 0%} {{conference.contractprovider.implementation.resendprompt}}{%endif%}"></form>
         {%endif%}
        {%endif%}{# can resend #}
-      {%endif%}{# instant buy #}
+      {%endif%}{# contractlevel #}
     </td>
   </tr>
 {%if sponsor.invoice%}
index 534e558484019088c4f65c22569d3af1f1e7ae2c..689e4d1ba9436a17d80102117bcf287e047fc680 100644 (file)
@@ -7,14 +7,6 @@ Thank you for your interest in sponsoring {{conference}}! Please fill out the
 form below to initiate your sponsorship!
 </p>
 
-{%if level.contract%}
-<p>
-Before you complete the signup form, please <em>make sure</em> you have read the
-<a href="/events/sponsor/previewcontract/{{level.contract.id}}/">contract</a>, and
-agree with the contents in it.
-</p>
-{%endif%}
-
 {%if form.errors%}
 <p>
 <b>NOTE!</b> Your submitted form contained errors and has <b>not</b> been saved!
@@ -46,16 +38,31 @@ the invoice cannot be changed.
 </div>
 
 {%if previewaddr%}
-{%if needscontract%}
+{%if level.contractlevel > 0%}
 <h4>Contract details</h4>
+{% if level.contractlevel == 1 %}
+<p>
+  This sponsorship level uses a <strong>click-through contract</strong>. This
+  means that by signing up, you agree to the
+  <a href="/events/sponsor/previewcontract/{{level.contract.id}}/">contract</a>, and
+  accept that no changes can be made to it.
+</p>
+{% else %}
+<p>
+  This sponsorship level requires a signed contract. Please review the
+  <a href="/events/sponsor/previewcontract/{{level.contract.id}}/">contract</a> before
+  completing the sign-up, and <em>only continue if you agree with it</em>.
+  Once signed up, you will receive a contract or signing.
+</p>
+{% endif %}
 <p>
   The contract for the sponsorship will be issued to the company name
   <strong><em>{{sponsorname}}</em></strong>{%if vatnumber%} with VAT
   number <strong><em>{{vatnumber}}</em></strong>{%endif%}.
-</p>
-<p>
-  It will <strong>not</strong> be possible to change this after this
-  step, so if anything about it is incorrect please click <i>Continue
+
+  It will <strong>not</strong> be possible to change this, or any other
+  details in the contract, after this
+  step. If anything about it is incorrect please click <i>Continue
   editing</i> and correct it <i>before</i> proceeding.
 </p>
 {%endif%}
@@ -92,7 +99,7 @@ the invoice cannot be changed.
 <input type="hidden" name="stage" value="2">
 {%endif%}{# contractchoices #}
 
-{%if not level.instantbuy and not noform %}
+{%if level.contractlevle == 2 and not noform %}
 <p>
 Please note that due to the level of this sponsorship contract, we
 will require a signed contract, apart from the confirmation
index da8f878eab14ef3c25e1b51680af4454745ff469..fd81d3e35a3442ddc12831638c66a1baa186aae0 100644 (file)
     <th>Status:</th>
     <td>{%if sponsor.confirmed%}Confirmed ({{sponsor.confirmedat}}){%else%}<i>Awaiting confirmation</i>{%endif%}</td>
   </tr>
-{% if sponsor.confirmed and sponsor.level.contract %}
+{% if sponsor.confirmed  %}
   <tr>
     <th>Contract:</th>
     <td>
-{% if sponsor.level.instantbuy %}
+{% if sponsor.level.contractlevel == 0 %}
+This level requires no contract.
+{% elif sponsor.level.contractlevel == 1 %}
       Click-through contract agreed to. <a href="contractview/" class="btn btn-outline-dark btn-sm">View copy of contract</a>
 {% else %}
 {%if sponsor.signmethod == 0%}
       Digital contract completed {{sponsor.contract.completed}}.
 {%if sponsor.contract.completed and sponsor.contract.has_completed_pdf %}<a href="contractview/" class="btn btn-outline-dark btn-sm">View signed contract</a>{%endif%}
 {% else %}
-Manual contract.
-{% endif %}
-{% endif %}
+Manual contract signed.
+{% endif %}{%comment%}digital contract/manual contract{%endcomment%}
+{% endif %}{%comment%}contractlevel{%endcomment%}
 </td>
   </tr>
 {% endif %}
@@ -73,8 +75,8 @@ Manual contract.
 {%endwith%}
 {%else%}
 {%comment%}No invoice generated{%endcomment%}
-{%if sponsor.level.instantbuy%}
-{%comment%}No invoice generated but instantbuy active, so awaiting admin approval{%endcomment%}
+{%if sponsor.level.contractlevel < 2 %}
+{%comment%}No invoice generated but clickthrough contract or no contract, so awaiting admin approval{%endcomment%}
 <p>
   Your sponsorship request has been submitted, and is currently awaiting confirmation
   from the conference organizers. As soon as it has been, an invoice will be automatically