: An email is generated to the *sponsor*, as well as to the
conference sponsorship address.
+## Additional contracts
+
+Other than the contract for the sponsorship itself, it is also
+possible to send "additional contracts" to the sponsors. This can for
+example be used for training contracts, or any other special activity
+that requires a contract.
+
+In particular this is useful when the sponsor uses digital contracts
+as this process is then also handled by digital contracts using the
+same address and provider. There is no automatic processing for the
+additional contracts, other than updating the status of the contract
+indicating who has signed and when.
+
+Additional contracts can be used for manual contracts as well, in
+which case just like with the digital contracts, the only real benefit
+is that some fields like the sponsor name can be pre-filled in the
+contract using the same template system as for the main sponsor
+contracts.
+
+Each contract is given a subject (used as e-mail subject and the title
+of the contract in the digital signature provider) and message (used
+as the body of the email sent, whether manual or digital).
+
+The status of the contracts can be tracked on the page of each
+individual sponsor (where manual contracts should also be marked when
+they are signed by the different actors) as well as in a global
+overview on the sponsorship dashboard.
+
## Reference
### Sponsorship <a name="sponsor"></a>
def ready(self):
from postgresqleu.digisign.util import register_digisign_handler
- from postgresqleu.confsponsor.invoicehandler import SponsorDigisignHandler
+ from postgresqleu.confsponsor.invoicehandler import SponsorDigisignHandler, SponsorAdditionalDigisignHandler
register_digisign_handler('confsponsor', SponsorDigisignHandler)
+ register_digisign_handler('confsponsoradditional', SponsorAdditionalDigisignHandler)
from django.forms.utils import ErrorList
from django.db.models import Q
from django.core.validators import MaxValueValidator, MinValueValidator
+from django.contrib.auth.models import User
from django.conf import settings
-from .models import Sponsor, SponsorMail, SponsorshipLevel
+from .models import Sponsor, SponsorMail, SponsorshipLevel, SponsorshipContract
from .models import vat_status_choices
from .models import Shipment
from postgresqleu.confreg.models import RegistrationType, DiscountCode
from postgresqleu.confreg.models import ConferenceAdditionalOption
from postgresqleu.confreg.twitter import get_all_conference_social_media
+from postgresqleu.util.fields import UserModelChoiceField
from postgresqleu.util.validators import BeforeValidator, AfterValidator
from postgresqleu.util.validators import Http200Validator
from postgresqleu.util.widgets import Bootstrap4CheckboxSelectMultiple, EmailTextWidget
def __init__(self, *args, **kwargs):
super(ShipmentReceiverForm, self).__init__(*args, **kwargs)
self.fields['arrived_parcels'].choices = [(str(x), str(x)) for x in range(1, 20)]
+
+
+class SponsorAddContractForm(forms.Form):
+ subject = forms.CharField(max_length=100, required=True)
+ contract = forms.ModelChoiceField(SponsorshipContract.objects.all())
+ manager = UserModelChoiceField(User.objects.all(), label="Manager to send to")
+ message = forms.CharField(label="Message to send in signing email", widget=forms.Textarea)
+
+ def __init__(self, sponsor, *args, **kwargs):
+ self.sponsor = sponsor
+ super().__init__(*args, **kwargs)
+
+ self.fields['subject'].help_text = "Subject of contract, for example 'Training contract'. Will be prefixed with '[{}]' in all emails.".format(self.sponsor.conference.conferencename)
+ self.fields['contract'].queryset = SponsorshipContract.objects.filter(conference=self.sponsor.conference, sponsorshiplevel=None)
+ self.fields['manager'].queryset = self.sponsor.managers
"Contract signed for sponsor {}".format(self.sponsor.name),
"The digital contract for sponsor\n{}\n has been signed by\n{}.\n It is now pending signature from {}.\n".format(self.sponsor.name, signedby, self.sponsor.conference.contractsendername),
)
+
+
+class SponsorAdditionalDigisignHandler(DigisignHandlerBase):
+ def __init__(self, *args, **kwargs):
+ super().__init__(*args, **kwargs)
+ if not hasattr(self.doc, 'sponsoradditionalcontract'):
+ raise Exception("No sponsor additional contract found for this document, something got unlinked?")
+ self.acontract = self.doc.sponsoradditionalcontract
+ self.sponsor = self.acontract.sponsor
+
+ def completed(self):
+ super().completed()
+
+ self.acontract.completed = timezone.now()
+ self.acontract.save(update_fields=['completed'])
+ send_conference_sponsor_notification(
+ self.sponsor.conference,
+ "Digital contract signed for {}".format(self.sponsor.conference.conferencename),
+ "A digital contract with the subject '{}' sent to {} has been signed by both the sponsor and {}.".format(self.acontract.subject, self.sponsor.name, self.sponsor.conference.contractsendername),
+ )
+
+ def expired(self):
+ super().expired()
+
+ send_conference_sponsor_notification(
+ self.sponsor.conference,
+ "Digital contract expired for {}".format(self.sponsor.conference.conferencename),
+ "A digital contract with the subject '{}' sent to {} has expired.".format(self.acontract.subject, self.sponsor.name),
+ )
+
+ def declined(self):
+ super().declined()
+
+ send_conference_sponsor_notification(
+ self.sponsor.conference,
+ "Digital contract declined for {}".format(self.sponsor.conference.conferencename),
+ "A digital contract with the subject '{}' sent to {} has been declined.".format(self.acontract.subject, self.sponsor.name),
+ )
+
+ def canceled(self):
+ super().canceled()
+
+ send_conference_sponsor_notification(
+ self.sponsor.conference,
+ "Digital contract canceled for {}".format(self.sponsor.conference.conferencename),
+ "A digital contract with the subject '{}' sent to {} has been canceled.".format(self.acontract.subject, self.sponsor.name),
+ )
+
+ def signed(self, signedby):
+ super().signed(signedby)
+
+ if signedby != self.sponsor.conference.contractsendername:
+ # If it's the other party that signed, send an email to notify the administrators,
+ # for the record. When the organizers sign, the "completed" notification is fired,
+ # and the email is sent from there.
+ send_conference_sponsor_notification(
+ self.sponsor.conference,
+ "Digital contract signed by sponsor {}".format(self.sponsor.name),
+ "A digital contract with the subject '{}' has been signed by {}.\nIt is now pending signature from {}.\n".format(self.acontract.subject, signedby, self.sponsor.conference.contractsendername),
+ )
+ self.acontract.sponsorsigned = timezone.now()
+ self.acontract.save(update_fields=['sponsorsigned'])
--- /dev/null
+# Generated by Django 3.2.14 on 2023-10-27 13:01
+
+from django.conf import settings
+from django.db import migrations, models
+import django.db.models.deletion
+
+
+class Migration(migrations.Migration):
+
+ dependencies = [
+ migrations.swappable_dependency(settings.AUTH_USER_MODEL),
+ ('digisign', '0002_contract_dates'),
+ ('confsponsor', '0026_sponsor_twittername_social'),
+ ]
+
+ operations = [
+ migrations.CreateModel(
+ name='SponsorAdditionalContract',
+ fields=[
+ ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
+ ('subject', models.CharField(max_length=100)),
+ ('sponsorsigned', models.DateTimeField(blank=True, null=True)),
+ ('completed', models.DateTimeField(blank=True, null=True)),
+ ('contract', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='confsponsor.sponsorshipcontract')),
+ ('digitalcontract', models.OneToOneField(blank=True, help_text='Contract, when using digital signatures', null=True, on_delete=django.db.models.deletion.SET_NULL, to='digisign.digisigndocument')),
+ ('sent_to_manager', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, to=settings.AUTH_USER_MODEL)),
+ ('sponsor', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='confsponsor.sponsor')),
+ ],
+ ),
+ ]
if self.sponsor:
return self.sponsor.name
return "{0} organizers".format(self.conference)
+
+
+class SponsorAdditionalContract(models.Model):
+ sponsor = models.ForeignKey(Sponsor, null=False, blank=False, on_delete=models.CASCADE)
+ subject = models.CharField(max_length=100, null=False, blank=False)
+ contract = models.ForeignKey(SponsorshipContract, null=False, blank=False, on_delete=models.CASCADE)
+ sent_to_manager = models.ForeignKey(User, null=True, blank=True, on_delete=models.SET_NULL)
+ digitalcontract = models.OneToOneField(DigisignDocument, null=True, blank=True, help_text="Contract, when using digital signatures", on_delete=models.SET_NULL)
+ sponsorsigned = models.DateTimeField(null=True, blank=True)
+ completed = models.DateTimeField(null=True, blank=True)
url(r'^admin/(\w+)/(\d+)/edit/$', backendviews.edit_sponsor),
url(r'^admin/(\w+)/(\d+)/contractlog/$', views.sponsor_admin_sponsor_contractlog),
url(r'^admin/(\w+)/(\d+)/resendcontract/$', views.sponsor_admin_sponsor_resendcontract),
+ url(r'^admin/(\w+)/(\d+)/addcontract/$', views.sponsor_admin_addcontract),
+ url(r'^admin/(\w+)/(\d+)/markaddcontract/$', views.sponsor_admin_markaddcontract),
url(r'^admin/(\w+)/benefit/(\d+)/$', views.sponsor_admin_benefit),
url(r'^admin/(\w+)/sendmail/$', views.sponsor_admin_send_mail),
url(r'^admin/(\w+)/viewmail/(\d+)/$', views.sponsor_admin_view_mail),
from django.contrib import messages
from django.contrib.auth.models import User
from django.utils import timezone
+from django.template.defaultfilters import slugify
from datetime import timedelta
import io
from postgresqleu.digisign.models import DigisignDocument, DigisignLog
from .models import Sponsor, SponsorshipLevel, SponsorshipBenefit
-from .models import SponsorClaimedBenefit, SponsorMail, SponsorshipContract
+from .models import SponsorClaimedBenefit, SponsorMail, SponsorshipContract, SponsorAdditionalContract
from .models import PurchasedVoucher
from .models import ShipmentAddress, Shipment
from .forms import SponsorSignupForm, SponsorSendEmailForm, SponsorDetailsForm
from .forms import PurchaseVouchersForm, PurchaseDiscountForm
from .forms import SponsorShipmentForm, ShipmentReceiverForm
from .forms import SponsorRefundForm, SponsorReissueForm
+from .forms import SponsorAddContractForm
from .benefits import get_benefit_class
from .invoicehandler import create_sponsor_invoice, confirm_sponsor
from .invoicehandler import get_sponsor_invoice_address, get_sponsor_invoice_rows
from .invoicehandler import create_voucher_invoice
from .vatutil import validate_eu_vat_number
-from .util import send_conference_sponsor_notification, send_sponsor_manager_email
+from .util import send_conference_sponsor_notification, send_sponsor_manager_email, send_sponsor_manager_simple_email
from .util import get_mails_for_sponsor
from .util import get_pdf_fields_for_conference
'benefitmatrix': benefitmatrix,
'has_shipment_tracking': has_shipment_tracking,
'shipments': shipments,
+ 'additionalcontracts': SponsorAdditionalContract.objects.select_related('sponsor').filter(sponsor__conference=conference).order_by('id'),
'helplink': 'sponsors',
})
'claimedbenefits': claimedbenefits,
'unclaimedbenefits': unclaimedbenefits,
'noclaimbenefits': noclaimbenefits,
+ 'conference_has_contracts': SponsorshipContract.objects.filter(conference=conference, sponsorshiplevel=None).exists(),
+ 'additionalcontracts': SponsorAdditionalContract.objects.filter(sponsor=sponsor).order_by('id'),
+ 'addcontractform': SponsorAddContractForm(sponsor),
'breadcrumbs': (('/events/sponsor/admin/{0}/'.format(conference.urlname), 'Sponsors'),),
'euvat': settings.EU_VAT,
'helplink': 'sponsors',
'breadcrumbs': [('../../', 'Sponsors'), ('../', sponsor.name), ],
'helplink': 'sponsors',
})
+
+
+@login_required
+@transaction.atomic
+def sponsor_admin_addcontract(request, confurlname, sponsorid):
+ if request.method != "POST":
+ return HttpResponse("Invalid method.", status=400)
+
+ conference = get_authenticated_conference(request, confurlname)
+ sponsor = get_object_or_404(Sponsor, id=sponsorid, conference=conference)
+
+ if not sponsor.confirmed: # Cannot-happen
+ raise Http404("Page not valid for unconfirmed sponsors")
+
+ form = SponsorAddContractForm(sponsor, data=request.POST)
+ if not form.is_valid():
+ # Should not be possible unless the browser is broken, so we accept a bad error message
+ messages.error(request, "Form does not validate.")
+ return HttpResponseRedirect("../")
+
+ contract = form.cleaned_data['contract']
+ subject = form.cleaned_data['subject']
+
+ # Send the actual contract
+ pdf = fill_pdf_fields(
+ contract.contractpdf,
+ get_pdf_fields_for_conference(conference, sponsor),
+ contract.fieldjson,
+ )
+ if sponsor.signmethod == 1:
+ send_sponsor_manager_simple_email(
+ sponsor,
+ subject,
+ form.cleaned_data['message'],
+ attachments=[
+ ('{}_{}.pdf'.format(conference.urlname, slugify(subject)), 'application/pdf', pdf),
+ ],
+ )
+ SponsorAdditionalContract(
+ sponsor=sponsor,
+ subject=subject,
+ contract=contract,
+ sent_to_manager=None,
+ digitalcontract=None,
+ ).save()
+ messages.info(request, "Manual contract sent.")
+ send_conference_sponsor_notification(
+ conference,
+ "Manual contract sent to {} for {}".format(sponsor.name, conference.conferencename),
+ 'A manual contract with the subject "{}" was sent to {}'.format(subject, sponsor.name),
+ )
+ else:
+ manager = form.cleaned_data['manager']
+
+ acontract = SponsorAdditionalContract(
+ sponsor=sponsor,
+ subject=subject,
+ contract=contract,
+ sent_to_manager=manager,
+ digitalcontract=None,
+ )
+ acontract.save()
+
+ signer = conference.contractprovider.get_implementation()
+ contractid, error = signer.send_contract(
+ conference.contractsendername,
+ conference.contractsenderemail,
+ "{} {}".format(manager.first_name, manager.last_name),
+ manager.email,
+ pdf,
+ '{}_{}.pdf'.format(conference.urlname, slugify(subject)),
+ subject,
+ form.cleaned_data['message'],
+ {
+ 'type': 'sponsoradditional',
+ 'additionalid': str(acontract.id),
+ },
+ contract.fieldjson,
+ conference.contractexpires,
+ test=False,
+ )
+ if error:
+ messages.error(request, "Failed to send digital contract.")
+ return HttpResponseRedirect("../")
+
+ acontract.digitalcontract = DigisignDocument(
+ provider=conference.contractprovider,
+ documentid=contractid,
+ handler='confsponsoradditional',
+ )
+ acontract.digitalcontract.save()
+ acontract.save(update_fields=['digitalcontract', ])
+ messages.info(request, "Digital contract sent.")
+
+ return HttpResponseRedirect("../")
+
+
+@login_required
+@transaction.atomic
+def sponsor_admin_markaddcontract(request, confurlname, sponsorid):
+ if request.method != "POST":
+ return HttpResponse("Invalid method.", status=400)
+
+ conference = get_authenticated_conference(request, confurlname)
+ sponsor = get_object_or_404(Sponsor, id=sponsorid, conference=conference)
+
+ if not sponsor.confirmed: # Cannot-happen
+ raise Http404("Page not valid for unconfirmed sponsors")
+
+ acontract = get_object_or_404(SponsorAdditionalContract, pk=get_int_or_error(request.POST, "id"))
+ if acontract.digitalcontract:
+ raise Http404("Page not valid for digital contracts")
+
+ which = request.POST.get('which', '')
+
+ if which == 'sponsor':
+ if acontract.sponsorsigned:
+ messages.error(request, "Contract already marked as signed by sponsor.")
+ else:
+ acontract.sponsorsigned = timezone.now()
+ acontract.save(update_fields=['sponsorsigned'])
+ send_conference_sponsor_notification(
+ conference,
+ "Manual contract for {} marked as signed by sponsor".format(conference.conferencename),
+ 'A manual contract with the subject "{}" sent to {} has been marked as signed by sponsor.'.format(acontract.subject, sponsor.name),
+ )
+ messages.info(request, "Contract marked as signed by sponsor.")
+ elif which == 'org':
+ if acontract.completed:
+ messages.error(request, "Contract already marked as signed by {}.".format(conference.contractsendername))
+ else:
+ acontract.completed = timezone.now()
+ acontract.save(update_fields=['completed'])
+ send_conference_sponsor_notification(
+ conference,
+ "Manual contract for {} marked as signed by {}".format(conference.conferencename, conference.contractsendername),
+ 'A manual contract with the subject "{}" sent to {} has been marked as signed by {}.'.format(acontract.subject, sponsor.name, conference.contractsendername),
+ )
+ messages.info(request, "Contract marked as signed by {}.".format(conference.contractsendername))
+ else:
+ raise Http404("Invalid value for which")
+
+ return HttpResponseRedirect("../")
<a class="btn btn-primary" href="shipments/new/">Register new shipment</a>
{%endif%}
+{%if additionalcontracts%}
+<h2>Additional contracts</h2>
+<p>
+ Additional contracts are <i>sent</i> (and in the case of manual contracts, marked as signed) on the individual sponsor page.
+</p>
+<table class="table table-striped table-hover">
+ <thead>
+ <tr>
+ <th>Sponsor</th>
+ <th>Subject</th>
+ <th>Contract name</th>
+ <th>Type</th>
+ <th>Sponsor signed</th>
+ <th>{{conference.contractsendername}} signed</th>
+ </tr>
+ </thead>
+ <tbody>
+{% for ac in additionalcontracts%}
+ <tr>
+ <td><a href="{{ac.sponsor.id}}/">{{ac.sponsor.name}}</a></td>
+ <td>{{ac.subject}}</td>
+ <td>{{ac.contract.contractname}}</td>
+ <td>{%if ac.digitalcontract%}Digital{%else%}Manual{%endif%}</td>
+ <td>{{ac.sponsorsigned|default:""}}</td>
+ <td>{{ac.completed|default:""}}</td>
+ </td>
+ </tr>
+{%endfor%}
+ </tbody>
+</table>
+{%endif%}
+
{%if eu_vat and user.is_superuser %}
<h2>VAT testing</h2>
<p>
</div>
{%endif%}
+{%if conference_has_contracts %}
+<h2>Additional contracts</h2>
+<p>
+ You can send additional contracts to this sponsor if one is needed, such as training contracts.
+{%if sponsor.signmethod == 0%}
+ This sponsor uses digital contracts, so the additional contracts will also be sent digitally. No
+ automatic processing happens for these contracts, just the tracking of the status.
+{%else%}
+ This sponsor uses manual contracts, so the additional contracts will be emailed to all sponsor
+ representatives as a PDF, asking them to return it signed.
+{%endif%}
+</p>
+{%if additionalcontracts%}
+<table class="table table-striped table-hover">
+ <thead>
+ <tr>
+ <th>Subject</th>
+ <th>Contract name</th>
+ <th>Type</th>
+ <th>Sponsor signed</th>
+ <th>{{conference.contractsendername}} signed</th>
+ </tr>
+ </thead>
+ <tbody>
+{% for ac in additionalcontracts%}
+ <tr>
+ <td>{{ac.subject}}</td>
+ <td><a href="../contracts/{{ac.contract.id}}/">{{ac.contract.contractname}}</a></td>
+ <td>{%if ac.digitalcontract%}Digital{%else%}Manual{%endif%}</td>
+ <td>{%if ac.sponsorsigned%}{{ac.sponsorsigned}}{%else%}
+ {%if not ac.digitalcontract%}<form method="post" action="markaddcontract/">{%csrf_token%}<input type="hidden" name="id" value="{{ac.id}}"><input type="hidden" name="which" value="sponsor"><input type="submit" class="btn btn-sm confirm-btn" value="Mark as signed" data-confirm="Are you sure you want to mark this (manual) contract as signed by the sponsor?"></form>{%endif%}
+{%endif%}
+ </td>
+ <td>{%if ac.completed%}{{ac.completed}}{%else%}
+ {%if not ac.digitalcontract%}<form method="post" action="markaddcontract/">{%csrf_token%}<input type="hidden" name="id" value="{{ac.id}}"><input type="hidden" name="which" value="org"><input type="submit" class="btn btn-sm confirm-btn" value="Mark as signed" data-confirm="Are you sure you want to mark this (manual) contract as signed by {{conference.contractsendername}}?"></form>{%endif%}
+ {%endif%}
+ </td>
+ </tr>
+{%endfor%}
+ </tbody>
+</table>
+{%endif%}
+
+<h4>Send new contract</h4>
+<form method="post" action="addcontract/" class="form-horizontal">{%csrf_token%}
+{%include "confreg/admin_backend_form_content.html" with form=addcontractform savebutton="Send contract"%}
+</form>
+{%endif%}
+
{%else%}{%comment%}Sponsor confirmed{%endcomment%}
{%if sponsor.invoice%}
<p>