Make it possible to restrict an additional option to a specific set of attendees
authorMagnus Hagander <magnus@hagander.net>
Mon, 15 Sep 2025 12:57:12 +0000 (14:57 +0200)
committerMagnus Hagander <magnus@hagander.net>
Mon, 15 Sep 2025 13:05:46 +0000 (15:05 +0200)
If specific attendees are invited to use an option, make it possible to
specify them without creating a separate registration type for them.
When specifying both regtype and attendee requirement, both must be
satisfied.

docs/confreg/registrations.md
postgresqleu/confreg/admin.py
postgresqleu/confreg/backendforms.py
postgresqleu/confreg/forms.py
postgresqleu/confreg/migrations/0119_conferenceadditionaloption_requires_attendee.py [new file with mode: 0644]
postgresqleu/confreg/models.py
postgresqleu/confreg/views.py

index 92119887a6a95bb5cc0516e811f16742386fcd37..295b2beee45fb44893feae98d03c68e1004c2010 100644 (file)
@@ -308,6 +308,13 @@ Requires regtype
 registration types must be picked. If the attendee does not have one
 of these registration types, they may be offered an upsell to a
 different registration type if that is [enabled](#regtypes).
+Note that if both a required regtype and a required
+attendee is specified, both requirements must be fulfilled.
+
+Requires attendee
+: In order to add one of these options, the attendee must be one of
+the specified ones. Note that if both a required regtype and a required
+attendee is specified, both requirements must be fulfilled.
 
 Mutually exclusive
 : This option cannot be picked at the same time as the selected other
index 1875c281c9d14a1c802423e2e3c406ba19de3ad2..523c273713cd4058ba376cc4a938d0a581ee2f74 100644 (file)
@@ -250,6 +250,7 @@ class ConferenceAdditionalOptionAdminForm(ConcurrentProtectedModelForm):
         super(ConferenceAdditionalOptionAdminForm, self).__init__(*args, **kwargs)
         try:
             self.fields['requires_regtype'].queryset = RegistrationType.objects.filter(conference=self.instance.conference)
+            self.fields['requires_attendee'].queryset = ConferenceRegistration.objects.filter(conference=self.instance.conference)
             self.fields['mutually_exclusive'].queryset = ConferenceAdditionalOption.objects.filter(conference=self.instance.conference)
             self.fields['additionaldays'].queryset = RegistrationDay.objects.filter(conference=self.instance.conference)
         except Conference.DoesNotExist:
@@ -263,7 +264,7 @@ class ConferenceAdditionalOptionAdmin(admin.ModelAdmin):
     list_filter = ['conference', ]
     ordering = ['conference', 'name', ]
     search_fields = ['name', ]
-    filter_horizontal = ('requires_regtype', 'mutually_exclusive', )
+    filter_horizontal = ('requires_regtype', 'requires_attendee', 'mutually_exclusive', )
     form = ConferenceAdditionalOptionAdminForm
 
     def get_queryset(self, request):
index 90a8305e515a98f1caa531f0fe70ec43f9b25dca..2751f74da60110968f2e93b2a81fa20f13212fa2 100644 (file)
@@ -597,7 +597,11 @@ class BackendAdditionalOptionForm(BackendForm):
     })
     vat_fields = {'cost': 'reg'}
     auto_cascade_delete_to = ['registrationtype_requires_option', 'conferenceadditionaloption_requires_regtype',
+                              'conferenceadditionaloption_requires_attendee',
                               'conferenceadditionaloption_mutually_exclusive', ]
+    selectize_multiple_fields = {
+        'requires_attendee': RegisteredUsersLookup(None),
+    }
     coltypes = {
         'Maxcount': ['nosearch', ],
     }
@@ -605,10 +609,12 @@ class BackendAdditionalOptionForm(BackendForm):
     class Meta:
         model = ConferenceAdditionalOption
         fields = ['name', 'cost', 'maxcount', 'sortkey', 'public', 'upsellable', 'invoice_autocancel_hours',
-                  'requires_regtype', 'mutually_exclusive', 'additionaldays']
+                  'requires_regtype', 'requires_attendee', 'mutually_exclusive', 'additionaldays']
 
     def fix_fields(self):
         self.fields['requires_regtype'].queryset = RegistrationType.objects.filter(conference=self.conference)
+        self.fields['requires_attendee'].queryset = ConferenceRegistration.objects.filter(conference=self.conference)
+        self.selectize_multiple_fields['requires_attendee'] = RegisteredUsersLookup(self.conference)
         self.fields['mutually_exclusive'].queryset = ConferenceAdditionalOption.objects.filter(conference=self.conference).exclude(pk=self.instance.pk)
         self.fields['additionaldays'].queryset = RegistrationDay.objects.filter(conference=self.conference)
 
index 66b744b0ba549dc1499a2282ef411ae705e52b15..60e639d0fb873d2cbefc8e51e78b05e7287628d5 100644 (file)
@@ -276,11 +276,8 @@ class ConferenceRegistrationForm(forms.ModelForm):
             regtype = cleaned_data['regtype']
             errs = []
             for ao in cleaned_data['additionaloptions']:
-                if ao.requires_regtype.exists():
-                    if regtype not in ao.requires_regtype.all():
-                        errs.append('Additional option "%s" requires one of the following registration types: %s.' % (ao.name, ", ".join(x.regtype for x in ao.requires_regtype.all())))
-            if len(errs):
-                self._errors['additionaloptions'] = self.error_class(errs)
+                if msg := ao.verify_available_to(regtype, self.instance if self.instance.pk else None):
+                    self.add_error('additionaloptions', msg)
 
         return cleaned_data
 
diff --git a/postgresqleu/confreg/migrations/0119_conferenceadditionaloption_requires_attendee.py b/postgresqleu/confreg/migrations/0119_conferenceadditionaloption_requires_attendee.py
new file mode 100644 (file)
index 0000000..c7f193c
--- /dev/null
@@ -0,0 +1,18 @@
+# Generated by Django 4.2.11 on 2025-09-15 12:11
+
+from django.db import migrations, models
+
+
+class Migration(migrations.Migration):
+
+    dependencies = [
+        ('confreg', '0118_registrationtype_checkinmessage'),
+    ]
+
+    operations = [
+        migrations.AddField(
+            model_name='conferenceadditionaloption',
+            name='requires_attendee',
+            field=models.ManyToManyField(blank=True, help_text='Can only be picked by specific attendees', to='confreg.conferenceregistration', verbose_name='Requires specific attendee'),
+        ),
+    ]
index 2db0ef4a4c297f45204f0260e8a58122b4e1d79b..bfe6acdc9c86dd058df695da9008638fc02537dc 100644 (file)
@@ -540,6 +540,7 @@ class ConferenceAdditionalOption(models.Model):
     upsellable = models.BooleanField(null=False, blank=False, default=True, help_text='Can this option be purchased after the registration is completed')
     invoice_autocancel_hours = models.IntegerField(blank=True, null=True, validators=[MinValueValidator(1), ], verbose_name="Autocancel invoices", help_text="Automatically cancel invoices after this many hours")
     requires_regtype = models.ManyToManyField(RegistrationType, blank=True, verbose_name="Requires registration type", help_text='Can only be picked with selected registration types')
+    requires_attendee = models.ManyToManyField('ConferenceRegistration', blank=True, verbose_name="Requires specific attendee", help_text='Can only be picked by specific attendees')
     mutually_exclusive = models.ManyToManyField('self', blank=True, help_text='Mutually exlusive with these additional options', symmetrical=True)
     additionaldays = models.ManyToManyField(RegistrationDay, blank=True, verbose_name="Adds access to days", help_text='Adds access to additional conference day(s), even if the registration type does not')
     sortkey = models.IntegerField(default=100, null=False, blank=False, verbose_name="Sort key")
@@ -566,6 +567,16 @@ class ConferenceAdditionalOption(models.Model):
                                                   self.maxcount)
         return "%s%s" % (self.name, coststr)
 
+    def verify_available_to(self, regtype, reg):
+        if self.requires_regtype.exists() and not self.requires_regtype.filter(pk=regtype.pk).exists():
+            return 'Option "{}" requires one of the registration types {}'.format(self.name, ", ".join(x.regtype for x in self.requires_regtype.all()))
+        if self.requires_attendee.exists():
+            if reg and not self.requires_attendee.filter(pk=reg.pk).exists():
+                return 'Option "{}" is only available to specific attendees'.format(self.name)
+            elif not reg:
+                return 'Option "{}" is only available to specific attendees'.format(self.name)
+        return None
+
 
 class BulkPayment(models.Model):
     # User that owns this bulk payment
index 4c7b7a9f1f27c8acc174605be3d8424ddb9b9b87..742db51a680ef1c7dfb21306ab8ff79d38b7e0bf 100644 (file)
@@ -1003,6 +1003,12 @@ def reg_add_options(request, confname, whatfor=None):
     else:
         upsell_cost = 0
 
+    # Check that the options is available at all, given the combinations
+    for ao in options:
+        if msg := ao.verify_available_to(new_regtype if new_regtype else reg.regtype, reg):
+            messages.warning(request, msg)
+            return HttpResponseRedirect('../')
+
     # Build our invoice rows
     invoicerows = []
     autocancel_hours = [conference.invoice_autocancel_hours, ]