summaryrefslogtreecommitdiff
path: root/postgresqleu
diff options
context:
space:
mode:
authorMagnus Hagander2023-06-02 15:37:42 +0000
committerMagnus Hagander2023-06-02 15:37:42 +0000
commit01214a785959878f6d7dd835347819dbaa888ac1 (patch)
tree38337993b0bb5f4ad1ecab8587bec1d2af0697b0 /postgresqleu
parent5a8a7681352a19fa81a97ff01f0ad87b1c36231d (diff)
Add support for digital signature providers
This adds a new type of provider to the system for handling digital signatures. Initially the only consumer is conference sponsorships, but it could be added for other parts of the system as well in the future. Regular "old-style" sponsorship contracts are still supported, but will gain the feature to auto-fill sponsor name and VAT number if wanted. The sponsor signup workflow is adjusted to support either one or both of the two methods. Initially the only implementation is Signwell, but the system is made pluggable just like e.g. the payment providers, so other suppliers can be added in the future. This should be considered fairly beta at this point, as several parts of it cannot be fully tested until a production account is in place. But the basics are there...
Diffstat (limited to 'postgresqleu')
-rw-r--r--postgresqleu/confreg/backendforms.py20
-rw-r--r--postgresqleu/confreg/migrations/0097_digisign_contracts.py45
-rw-r--r--postgresqleu/confreg/models.py7
-rw-r--r--postgresqleu/confsponsor/apps.py11
-rw-r--r--postgresqleu/confsponsor/backendforms.py51
-rw-r--r--postgresqleu/confsponsor/backendviews.py164
-rw-r--r--postgresqleu/confsponsor/invoicehandler.py90
-rw-r--r--postgresqleu/confsponsor/migrations/0022_sponsorship_digital_contracts.py38
-rw-r--r--postgresqleu/confsponsor/models.py6
-rw-r--r--postgresqleu/confsponsor/urls.py8
-rw-r--r--postgresqleu/confsponsor/util.py13
-rw-r--r--postgresqleu/confsponsor/views.py152
-rw-r--r--postgresqleu/digisign/__init__.py0
-rw-r--r--postgresqleu/digisign/backendforms.py65
-rw-r--r--postgresqleu/digisign/backendviews.py128
-rw-r--r--postgresqleu/digisign/implementations/__init__.py10
-rw-r--r--postgresqleu/digisign/implementations/signwell.py342
-rw-r--r--postgresqleu/digisign/management/__init__.py0
-rw-r--r--postgresqleu/digisign/management/commands/__init__.py0
-rw-r--r--postgresqleu/digisign/management/commands/digisign_cleanup.py29
-rw-r--r--postgresqleu/digisign/migrations/0001_initial.py62
-rw-r--r--postgresqleu/digisign/migrations/__init__.py0
-rw-r--r--postgresqleu/digisign/models.py49
-rw-r--r--postgresqleu/digisign/pdfutil.py71
-rw-r--r--postgresqleu/digisign/util.py28
-rw-r--r--postgresqleu/digisign/views.py39
-rw-r--r--postgresqleu/settings.py3
-rw-r--r--postgresqleu/urls.py9
-rw-r--r--postgresqleu/util/apps.py5
29 files changed, 1424 insertions, 21 deletions
diff --git a/postgresqleu/confreg/backendforms.py b/postgresqleu/confreg/backendforms.py
index 7d61b5bc..ed11423f 100644
--- a/postgresqleu/confreg/backendforms.py
+++ b/postgresqleu/confreg/backendforms.py
@@ -146,7 +146,8 @@ class BackendSuperConferenceForm(BackendForm):
fields = ['conferencename', 'urlname', 'series', 'startdate', 'enddate', 'location',
'tzname', 'contactaddr', 'sponsoraddr', 'notifyaddr', 'confurl', 'administrators',
'jinjadir', 'accounting_object', 'vat_registrations', 'vat_sponsorship',
- 'paymentmethods', 'web_origins']
+ 'paymentmethods', 'contractprovider', 'contractsendername', 'contractsenderemail',
+ 'contractexpires', 'manualcontracts', 'autocontracts', 'web_origins']
widgets = {
'paymentmethods': django.forms.CheckboxSelectMultiple,
}
@@ -157,6 +158,7 @@ class BackendSuperConferenceForm(BackendForm):
{'id': 'contact', 'legend': 'Contact information', 'fields': ['contactaddr', 'sponsoraddr', 'notifyaddr']},
{'id': 'financial', 'legend': 'Financial information', 'fields': ['accounting_object', 'vat_registrations',
'vat_sponsorship', 'paymentmethods']},
+ {'id': 'contracts', 'legend': 'Sponsorship contracts', 'fields': ['contractprovider', 'contractsendername', 'contractsenderemail', 'contractexpires', 'manualcontracts', 'autocontracts', ]},
{'id': 'api', 'legend': 'API access', 'fields': ['web_origins', ]}
]
@@ -203,6 +205,22 @@ class BackendSuperConferenceForm(BackendForm):
# Re-join string without any spaces
return ",".join(o.strip() for o in self.cleaned_data['web_origins'].split(','))
+ def clean(self):
+ cleaned_data = super().clean()
+ if cleaned_data.get('autocontracts', False) and not cleaned_data['contractprovider']:
+ self.add_error('autocontracts', 'Automatic contract workflow can only be enabled if a digital signature provider is configured')
+ if not (cleaned_data.get('contractprovider', None) or cleaned_data.get('manualcontracts', False)):
+ self.add_error('contractprovider', 'Either a digital signing provider or manual contracts must be enabled')
+ self.add_error('manualcontracts', 'Either a digital signing provider or manual contracts must be enabled')
+
+ if cleaned_data.get('contractprovider', False):
+ if not self.cleaned_data.get('contractsendername', ''):
+ self.add_error('contractsendername', 'This field is required when digital contracts are enabled')
+ if not self.cleaned_data.get('contractsenderemail', ''):
+ self.add_error('contractsenderemail', 'This field is required when digital contracts are enabled')
+
+ return cleaned_data
+
class BackendConferenceSeriesForm(BackendForm):
helplink = "series"
diff --git a/postgresqleu/confreg/migrations/0097_digisign_contracts.py b/postgresqleu/confreg/migrations/0097_digisign_contracts.py
new file mode 100644
index 00000000..1ad23fc9
--- /dev/null
+++ b/postgresqleu/confreg/migrations/0097_digisign_contracts.py
@@ -0,0 +1,45 @@
+# Generated by Django 3.2.14 on 2023-05-14 18:28
+
+from django.db import migrations, models
+import django.db.models.deletion
+
+
+class Migration(migrations.Migration):
+
+ dependencies = [
+ ('digisign', '0001_initial'),
+ ('confreg', '0096_recording_consent'),
+ ]
+
+ operations = [
+ migrations.AddField(
+ model_name='conference',
+ name='contractprovider',
+ field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, to='digisign.digisignprovider', verbose_name='Signing provider'),
+ ),
+ migrations.AddField(
+ model_name='conference',
+ name='manualcontracts',
+ field=models.BooleanField(default=True, help_text='Allow manually signed sponsorship contracts', verbose_name='Manual contracts'),
+ ),
+ migrations.AddField(
+ model_name='conference',
+ name='autocontracts',
+ field=models.BooleanField(default=True, help_text='Default to automatically approving sponsorships when digital signature process completes', verbose_name='Automated contract workflow'),
+ ),
+ migrations.AddField(
+ model_name='conference',
+ name='contractsendername',
+ field=models.CharField(max_length=200, null=False, blank=True, help_text='Name used to send digital contracts for this conference', verbose_name='Contract sender name'),
+ ),
+ migrations.AddField(
+ model_name='conference',
+ name='contractsenderemail',
+ field=models.EmailField(max_length=200, null=False, blank=True, help_text='E-mail address used to send digital contracts for this conference', verbose_name='Contract sender email'),
+ ),
+ migrations.AddField(
+ model_name='conference',
+ name='contractexpires',
+ field=models.IntegerField(null=False, blank=False, default=7, help_text='Digital contracts will expire after this many days', verbose_name='Contract expiry time'),
+ ),
+ ]
diff --git a/postgresqleu/confreg/models.py b/postgresqleu/confreg/models.py
index 38bf7f7e..d8d6d77a 100644
--- a/postgresqleu/confreg/models.py
+++ b/postgresqleu/confreg/models.py
@@ -33,6 +33,7 @@ from decimal import Decimal
from postgresqleu.countries.models import Country
from postgresqleu.invoices.models import Invoice, VatRate, InvoicePaymentMethod
from postgresqleu.newsevents.models import NewsPosterProfile
+from postgresqleu.digisign.models import DigisignProvider
from .regtypes import special_reg_types
@@ -226,6 +227,12 @@ class Conference(models.Model):
series = models.ForeignKey(ConferenceSeries, null=False, blank=False, on_delete=models.CASCADE)
personal_data_purged = models.DateTimeField(null=True, blank=True, help_text="Personal data for registrations for this conference have been purged")
initial_common_countries = models.ManyToManyField(Country, blank=True, help_text="Initial set of common countries")
+ contractprovider = models.ForeignKey(DigisignProvider, null=True, blank=True, verbose_name="Signing provider", on_delete=models.SET_NULL)
+ contractsendername = models.CharField(max_length=200, null=False, blank=True, verbose_name="Contract sender name", help_text="Name used to send digital contracts for this conference")
+ contractsenderemail = models.EmailField(max_length=200, null=False, blank=True, verbose_name="Contract sender email", help_text="E-mail address used to send digital contracts for this conference")
+ contractexpires = models.IntegerField(null=False, blank=False, default=7, verbose_name="Contract expiry time", help_text="Digital contracts will expire after this many days")
+ manualcontracts = models.BooleanField(null=False, blank=False, default=True, verbose_name="Manual contracts", help_text="Allow manually signed sponsorship contracts")
+ autocontracts = models.BooleanField(null=False, blank=False, default=True, verbose_name="Automated contract workflow", help_text="Default to automatically approving sponsorships when digital signature process completes")
key_public = models.TextField(null=False, blank=True, verbose_name="Public RSA key for signatures")
key_private = models.TextField(null=False, blank=True, verbose_name="Private RSA key for signatures")
web_origins = models.CharField(null=False, blank=True, max_length=1000, verbose_name="Allowed web origins for API calls (comma separated list)")
diff --git a/postgresqleu/confsponsor/apps.py b/postgresqleu/confsponsor/apps.py
new file mode 100644
index 00000000..33968b04
--- /dev/null
+++ b/postgresqleu/confsponsor/apps.py
@@ -0,0 +1,11 @@
+from django.apps import AppConfig
+
+
+class ConfsponsorAppConfig(AppConfig):
+ name = 'postgresqleu.confsponsor'
+
+ def ready(self):
+ from postgresqleu.digisign.util import register_digisign_handler
+ from postgresqleu.confsponsor.invoicehandler import SponsorDigisignHandler
+
+ register_digisign_handler('confsponsor', SponsorDigisignHandler)
diff --git a/postgresqleu/confsponsor/backendforms.py b/postgresqleu/confsponsor/backendforms.py
index 31756cbb..2e6a62ad 100644
--- a/postgresqleu/confsponsor/backendforms.py
+++ b/postgresqleu/confsponsor/backendforms.py
@@ -3,6 +3,7 @@ import django.forms
from django.conf import settings
from collections import OrderedDict
+import json
from postgresqleu.util.widgets import StaticTextWidget
from postgresqleu.util.backendforms import BackendForm, BackendBeforeNewForm
@@ -23,6 +24,7 @@ class BackendSponsorForm(BackendForm):
{'id': 'base_info', 'legend': 'Basic information', 'fields': ['name', 'displayname', 'url', 'twittername']},
{'id': 'financial', 'legend': 'Financial information', 'fields': ['invoiceaddr', 'vatstatus', 'vatnumber']},
{'id': 'management', 'legend': 'Management', 'fields': ['extra_cc', 'managers']},
+ {'id': 'contract', 'legend': 'Contract', 'fields': ['autoapprovesigned', ]},
]
selectize_multiple_fields = {
'managers': GeneralAccountLookup(),
@@ -34,7 +36,14 @@ class BackendSponsorForm(BackendForm):
model = Sponsor
fields = ['name', 'displayname', 'url', 'twittername',
'invoiceaddr', 'vatstatus', 'vatnumber',
- 'extra_cc', 'managers', ]
+ 'extra_cc', 'managers', 'autoapprovesigned', ]
+
+ def fix_fields(self):
+ if not self.instance.conference.contractprovider or not self.instance.conference.autocontracts:
+ del self.fields['autoapprovesigned']
+ # For now remove the whole fieldset as there is only one field in it
+ self.fieldsets = [fs for fs in self.fieldsets if fs['id'] != 'contract']
+ self.update_protected_fields()
class BackendSponsorshipNewBenefitForm(BackendBeforeNewForm):
@@ -218,6 +227,16 @@ class BackendSponsorshipContractForm(BackendForm):
model = SponsorshipContract
fields = ['contractname', 'contractpdf', ]
+ @property
+ def extrabuttons(self):
+ yield ('Edit field locations', 'editfields/')
+ yield ('Preview with fields', 'previewfields/')
+ if self.conference.contractprovider:
+ yield ('Edit digital signage fields', 'editdigifields/')
+ if self.conference.contractprovider.get_implementation().can_send_preview:
+ yield ('Send test contract', 'sendtest/')
+ yield ('Copy fields from another contract', 'copyfields/')
+
def fix_fields(self):
# Field must be non-required so we can save things. The widget is still required,
# so things cannot be removed. Yes, that's kind of funky.
@@ -242,3 +261,33 @@ class BackendShipmentAddressForm(BackendForm):
self.fields['available_to'].queryset = SponsorshipLevel.objects.filter(conference=self.conference)
self.fields['address'].help_text = "Full address. %% will be substituted with the unique address number, so don't forget to include it!"
self.initial['receiverlink'] = 'The recipient should use the link <a href="{0}/events/sponsor/shipments/{1}/">{0}/events/sponsor/shipments/{1}/</a> to access the system.'.format(settings.SITEBASE, self.instance.token)
+
+
+class BackendSponsorshipSendTestForm(django.forms.Form):
+ recipientname = django.forms.CharField(max_length=100, label='Recipient name')
+ recipientemail = django.forms.EmailField(max_length=100, label='Recipient email')
+
+ def __init__(self, contract, user, *args, **kwargs):
+ self.contract = contract
+ self.user = user
+ super().__init__(*args, **kwargs)
+ self.initial = {
+ 'recipientname': '{} {}'.format(user.first_name, user.last_name),
+ 'recipientemail': user.email,
+ }
+
+
+class BackendCopyContractFieldsForm(django.forms.Form):
+ currentval = django.forms.CharField(required=False, label="Current fields", widget=StaticTextWidget(monospace=True),
+ help_text="NOTE! This value will be completely overwritten!")
+ copyfrom = django.forms.ChoiceField(choices=[], label="Copy from contract")
+
+ def __init__(self, contract, *args, **kwargs):
+ self.contract = contract
+ super().__init__(*args, **kwargs)
+ self.fields['copyfrom'].choices = [(c.id, c.contractname) for c in SponsorshipContract.objects.filter(conference=contract.conference).exclude(pk=contract.pk).order_by('contractname')]
+
+ if not contract.fieldjson:
+ del self.fields['currentval']
+ else:
+ self.initial['currentval'] = json.dumps(contract.fieldjson, indent=2)
diff --git a/postgresqleu/confsponsor/backendviews.py b/postgresqleu/confsponsor/backendviews.py
index ee73513d..8ce3bada 100644
--- a/postgresqleu/confsponsor/backendviews.py
+++ b/postgresqleu/confsponsor/backendviews.py
@@ -1,11 +1,22 @@
+from django.shortcuts import render, get_object_or_404
+from django.http import HttpResponseRedirect, Http404
+from django.contrib import messages
+from django.db import transaction
+from django.conf import settings
+
from postgresqleu.util.backendviews import backend_list_editor, backend_process_form
from postgresqleu.confreg.util import get_authenticated_conference
+from postgresqleu.digisign.backendviews import pdf_field_editor, pdf_field_preview
+from postgresqleu.digisign.pdfutil import fill_pdf_fields
-from .models import Sponsor
+from .models import Sponsor, SponsorshipContract
from .backendforms import BackendSponsorForm
from .backendforms import BackendSponsorshipLevelForm
from .backendforms import BackendSponsorshipContractForm
from .backendforms import BackendShipmentAddressForm
+from .backendforms import BackendSponsorshipSendTestForm
+from .backendforms import BackendCopyContractFieldsForm
+from .util import get_pdf_fields_for_conference
def edit_sponsor(request, urlname, sponsorid):
@@ -48,3 +59,154 @@ def edit_shipment_addresses(request, urlname, rest):
BackendShipmentAddressForm,
rest,
breadcrumbs=[('/events/sponsor/admin/{0}/'.format(urlname), 'Sponsors'), ])
+
+
+def edit_sponsorship_contract_fields(request, urlname, contractid):
+ conference = get_authenticated_conference(request, urlname)
+ contract = SponsorshipContract.objects.get(conference=conference, pk=contractid)
+
+ def _save(jsondata):
+ contract.fieldjson = jsondata
+ contract.save(update_fields=['fieldjson'])
+
+ return pdf_field_editor(
+ request,
+ conference,
+ contract.contractpdf,
+ available_fields=get_pdf_fields_for_conference(conference),
+ fielddata=contract.fieldjson,
+ savecallback=_save,
+ breadcrumbs=[
+ ('/events/sponsor/admin/{0}/'.format(urlname), 'Sponsors'),
+ ('/events/sponsor/admin/{0}/contracts/'.format(urlname), 'Sponsorship contracts'),
+ ('/events/sponsor/admin/{0}/contracts/{1}'.format(urlname, contract.id), contract.contractname),
+ ],
+ )
+
+
+def edit_sponsorship_digital_contract_fields(request, urlname, contractid):
+ conference = get_authenticated_conference(request, urlname)
+ if not conference.contractprovider:
+ raise Http404("No contract provider for this conference")
+
+ contract = SponsorshipContract.objects.get(conference=conference, pk=contractid)
+
+ def _save(jsondata):
+ contract.fieldjson = jsondata
+ contract.save(update_fields=['fieldjson'])
+
+ signer = conference.contractprovider.get_implementation()
+
+ r = signer.edit_digital_fields(
+ request,
+ conference,
+ "{}_{}".format(conference.urlname, contract.contractname.lower()),
+ contract.contractpdf,
+ contract.fieldjson,
+ _save,
+ breadcrumbs=[
+ ('/events/sponsor/admin/{0}/'.format(urlname), 'Sponsors'),
+ ('/events/sponsor/admin/{0}/contracts/'.format(urlname), 'Sponsorship contracts'),
+ ('/events/sponsor/admin/{0}/contracts/{1}'.format(urlname, contract.id), contract.contractname),
+ ],
+ )
+ if r is None:
+ # indicates we're done
+ return HttpResponseRedirect("../")
+ return r
+
+
+def preview_sponsorship_contract_fields(request, urlname, contractid):
+ conference = get_authenticated_conference(request, urlname)
+ contract = SponsorshipContract.objects.get(conference=conference, pk=contractid)
+
+ return pdf_field_preview(
+ request,
+ conference,
+ contract.contractpdf,
+ available_fields=get_pdf_fields_for_conference(conference),
+ fielddata=contract.fieldjson,
+ )
+
+
+def send_test_sponsorship_contract(request, urlname, contractid):
+ conference = get_authenticated_conference(request, urlname)
+ contract = SponsorshipContract.objects.get(conference=conference, pk=contractid)
+
+ if request.method == 'POST':
+ form = BackendSponsorshipSendTestForm(contract, request.user, data=request.POST)
+ if form.is_valid():
+ signer = conference.contractprovider.get_implementation()
+
+ # Start by filling out the static fields
+ available_fields = get_pdf_fields_for_conference(conference)
+ pdf = fill_pdf_fields(contract.contractpdf, available_fields, contract.fieldjson)
+
+ contractid, error = signer.send_contract(
+ conference.contractsendername,
+ conference.contractsenderemail,
+ form.cleaned_data['recipientname'],
+ form.cleaned_data['recipientemail'],
+ pdf,
+ "{}.pdf".format(contract.contractname),
+ "{}: TEST SPONSORSHIP".format(conference.conferencename),
+ "Hello!\n\nYou have been sent a test sponsorship contract. Please check it out, but remember this is a test only!\n",
+ {
+ 'type': 'sponsor',
+ 'sponsorid': '-1',
+ },
+ contract.fieldjson,
+ 2, # expires_in
+ test=True,
+ )
+ if error:
+ form.add_error(None, 'Failed to send test contract: {}'.format(error))
+ else:
+ messages.info(request, "Test contract successfully sent, with id {}.".format(contractid))
+ return HttpResponseRedirect("../")
+ else:
+ form = BackendSponsorshipSendTestForm(contract, request.user)
+
+ return render(request, 'confreg/admin_backend_form.html', {
+ 'basetemplate': 'confreg/confadmin_base.html',
+ 'conference': conference,
+ 'whatverb': 'Send test contract',
+ 'savebutton': 'Send test',
+ 'form': form,
+ 'helplink': 'sponsors',
+ 'breadcrumbs': [
+ ('/events/sponsor/admin/{0}/'.format(urlname), 'Sponsors'),
+ ('/events/sponsor/admin/{0}/contracts/'.format(urlname), 'Sponsorship contracts'),
+ ('/events/sponsor/admin/{0}/contracts/{1}'.format(urlname, contract.id), contract.contractname),
+ ],
+ })
+
+
+@transaction.atomic
+def copy_sponsorship_contract_fields(request, urlname, contractid):
+ conference = get_authenticated_conference(request, urlname)
+ contract = SponsorshipContract.objects.get(conference=conference, pk=contractid)
+
+ if request.method == 'POST':
+ form = BackendCopyContractFieldsForm(contract, data=request.POST)
+ if form.is_valid():
+ copyfrom = get_object_or_404(SponsorshipContract, conference=conference, pk=form.cleaned_data['copyfrom'])
+ contract.fieldjson = copyfrom.fieldjson
+ contract.save(update_fields=['fieldjson'])
+ messages.info(request, "Fields copied from {} to {}".format(copyfrom.contractname, contract.contractname))
+ return HttpResponseRedirect("../")
+ else:
+ form = BackendCopyContractFieldsForm(contract)
+
+ return render(request, 'confsponsor/copy_contract_fields.html', {
+ 'conference': conference,
+ 'whatverb': 'Copy contract fields',
+ 'savebutton': 'Copy fields',
+ 'form': form,
+ 'helplink': 'sponsors',
+ 'breadcrumbs': [
+ ('/events/sponsor/admin/{0}/'.format(urlname), 'Sponsors'),
+ ('/events/sponsor/admin/{0}/contracts/'.format(urlname), 'Sponsorship contracts'),
+ ('/events/sponsor/admin/{0}/contracts/{1}'.format(urlname, contract.id), contract.contractname),
+ ],
+ })
diff --git a/postgresqleu/confsponsor/invoicehandler.py b/postgresqleu/confsponsor/invoicehandler.py
index 4dfcc34b..41119e3a 100644
--- a/postgresqleu/confsponsor/invoicehandler.py
+++ b/postgresqleu/confsponsor/invoicehandler.py
@@ -5,7 +5,7 @@ from datetime import datetime, timedelta
import base64
import os
-from postgresqleu.invoices.util import InvoiceManager
+from postgresqleu.invoices.util import InvoiceManager, InvoiceWrapper
from postgresqleu.util.time import today_conference
from .models import Sponsor, PurchasedVoucher
@@ -13,6 +13,7 @@ from .util import send_conference_sponsor_notification, send_sponsor_manager_ema
from .util import get_mails_for_sponsor
from postgresqleu.confreg.models import PrepaidBatch, PrepaidVoucher
from postgresqleu.confreg.util import send_conference_mail
+from postgresqleu.digisign.util import DigisignHandlerBase
import postgresqleu.invoices.models as invoicemodels
@@ -315,3 +316,90 @@ def create_voucher_invoice(conference, invoiceaddr, user, rt, num):
paymentmethods=conference.paymentmethods.all(),
)
return i
+
+
+# Handle digital signatures on contracts
+class SponsorDigisignHandler(DigisignHandlerBase):
+ def __init__(self, *args, **kwargs):
+ super().__init__(*args, **kwargs)
+
+ slist = list(self.doc.sponsor_set.all())
+ if len(slist) == 0:
+ raise Exception("No sponsor found for this document, something got unlinked?")
+ if len(slist) > 1:
+ # We have a unique index... But.. In case something weird happened.
+ raise Exception("More than one sponsor found for this document, can't happen!")
+
+ self.sponsor = slist[0]
+
+ def completed(self):
+ if self.sponsor.autoapprovesigned and self.sponsor.conference.autocontracts:
+ if self.sponsor.confirmed:
+ send_conference_sponsor_notification(
+ self.sponsor.conference,
+ "Already confirmed sponsor: %s" % self.sponsor.name,
+ "The sponsor\n%s\nhas signed the digital contract. However, the sponsor was already confirmed!\n" % (self.sponsor.name),
+ )
+
+ confirm_sponsor(self.sponsor, 'Digital contract')
+
+ if not self.sponsor.invoice and self.sponsor.level.levelcost > 0:
+ # Contract signed, time to issue the invoice!
+ manager = self.sponsor.managers.all()[0]
+ self.sponsor.invoice = create_sponsor_invoice(manager, self.sponsor)
+ self.sponsor.invoice.save()
+ self.sponsor.save(update_fields=['invoice'])
+ wrapper = InvoiceWrapper(self.sponsor.invoice)
+ wrapper.email_invoice()
+
+ def expired(self):
+ if self.sponsor.autoapprovesigned and self.sponsor.conference.autocontracts:
+ if self.sponsor.confirmed:
+ send_conference_sponsor_notification(
+ self.sponsor.conference,
+ "Contract expired for already confirmed sponsor: %s" % self.sponsor.name,
+ "The sponsor\n%s\nhas not signed the digital contract before it expired. However, the sponsor was already confirmed!\n" % (self.sponsor.name),
+ )
+ return
+
+ send_conference_sponsor_notification(
+ self.sponsor.conference,
+ "Contract expired for sponsor %s" % self.sponsor.name,
+ "The sponsor\n%s\nhas not signed the digital contract before it expired. The sponsorship has been rejected and the sponsor instructed to start over if they are still interested.\n" % (self.sponsor.name),
+ )
+ send_sponsor_manager_email(
+ self.sponsor,
+ "Sponsorship contract expired",
+ 'confsponsor/mail/sponsor_digisign_expired.txt',
+ {
+ 'sponsor': self.sponsor,
+ 'conference': self.sponsor.conference,
+ },
+ )
+ self.sponsor.delete()
+
+ def declined(self):
+ if self.sponsor.autoapprovesigned and self.sponsor.conference.autocontracts:
+ if self.sponsor.confirmed:
+ send_conference_sponsor_notification(
+ self.sponsor.conference,
+ "Contract declined for already confirmed sponsor: %s" % self.sponsor.name,
+ "The sponsor\n%s\nhas actively declined to sign the digital contract. However, the sponsor was already confirmed!\n" % (self.sponsor.name),
+ )
+ return
+
+ send_conference_sponsor_notification(
+ self.sponsor.conference,
+ "Contract declined for sponsor %s" % self.sponsor.name,
+ "The sponsor\n%s\nhas actively declined to sign the digital contract. The sponsorship has been rejected and the sponsor instructed to start over if they are still interested.\n" % (self.sponsor.name),
+ )
+ send_sponsor_manager_email(
+ self.sponsor,
+ "Sponsorship contract declined",
+ 'confsponsor/mail/sponsor_digisign_declined.txt',
+ {
+ 'sponsor': self.sponsor,
+ 'conference': self.sponsor.conference,
+ },
+ )
+ self.sponsor.delete()
diff --git a/postgresqleu/confsponsor/migrations/0022_sponsorship_digital_contracts.py b/postgresqleu/confsponsor/migrations/0022_sponsorship_digital_contracts.py
new file mode 100644
index 00000000..372bfb73
--- /dev/null
+++ b/postgresqleu/confsponsor/migrations/0022_sponsorship_digital_contracts.py
@@ -0,0 +1,38 @@
+# Generated by Django 3.2.14 on 2023-05-14 17:09
+
+import django.core.serializers.json
+from django.db import migrations, models
+
+
+class Migration(migrations.Migration):
+
+ dependencies = [
+ ('confsponsor', '0021_scannedattendee_firstscan'),
+ ('digisign', '0001_initial'),
+ ]
+
+ operations = [
+ migrations.AddField(
+ model_name='sponsorshipcontract',
+ name='fieldjson',
+ field=models.JSONField(default=dict, encoder=django.core.serializers.json.DjangoJSONEncoder),
+ ),
+ migrations.AddField(
+ model_name='sponsor',
+ name='signmethod',
+ field=models.IntegerField(null=False, blank=False, default=1, choices=((0, 'Digital signatures'), (1, 'Manual signatures')), verbose_name='Signing method'),
+ ),
+ migrations.AddField(
+ model_name='sponsor',
+ name='contract',
+ field=models.OneToOneField(null=True, blank=True, to='digisign.DigisignDocument',
+ on_delete=models.SET_NULL,
+ help_text="Contract, when using digital signatures"),
+ ),
+ migrations.AddField(
+ model_name='sponsor',
+ name='autoapprovesigned',
+ field=models.BooleanField(null=False, blank=False, default=True, verbose_name='Approve on signing',
+ help_text="Automatically approve once digital signatures are completed"),
+ ),
+ ]
diff --git a/postgresqleu/confsponsor/models.py b/postgresqleu/confsponsor/models.py
index 736c6535..7b756c17 100644
--- a/postgresqleu/confsponsor/models.py
+++ b/postgresqleu/confsponsor/models.py
@@ -1,11 +1,13 @@
from django.db import models
from django.utils.functional import cached_property
+from django.core.serializers.json import DjangoJSONEncoder
from django.contrib.auth.models import User
from django.utils import timezone
from postgresqleu.confreg.models import Conference, RegistrationType, PrepaidBatch
from postgresqleu.confreg.models import ConferenceRegistration
from postgresqleu.invoices.models import Invoice, InvoicePaymentMethod
+from postgresqleu.digisign.models import DigisignDocument
from postgresqleu.util.fields import PdfBinaryField
from postgresqleu.util.validators import validate_lowercase, validate_urlname
from postgresqleu.util.random import generate_random_token
@@ -23,6 +25,7 @@ class SponsorshipContract(models.Model):
conference = models.ForeignKey(Conference, null=False, blank=False, on_delete=models.CASCADE)
contractname = models.CharField(max_length=100, null=False, blank=False, verbose_name='Contract name')
contractpdf = PdfBinaryField(null=False, blank=False, max_length=1000000, verbose_name='Contract PDF')
+ fieldjson = models.JSONField(blank=False, null=False, default=dict, encoder=DjangoJSONEncoder)
def __str__(self):
return self.contractname
@@ -108,6 +111,9 @@ class Sponsor(models.Model):
confirmedby = models.CharField(max_length=50, null=False, blank=True)
signupat = models.DateTimeField(null=False, blank=False)
extra_cc = models.EmailField(null=False, blank=True, verbose_name="Extra information address")
+ signmethod = models.IntegerField(null=False, blank=False, default=1, choices=((0, 'Digital signatures'), (1, 'Manual signatures')), verbose_name='Signing method')
+ autoapprovesigned = models.BooleanField(null=False, blank=False, default=True, verbose_name="Approve on signing", help_text="Automatically approve once digital signatures are completed")
+ contract = models.OneToOneField(DigisignDocument, null=True, blank=True, help_text="Contract, when using digital signatures", on_delete=models.SET_NULL)
def __str__(self):
return self.name
diff --git a/postgresqleu/confsponsor/urls.py b/postgresqleu/confsponsor/urls.py
index 47917630..8e427dc2 100644
--- a/postgresqleu/confsponsor/urls.py
+++ b/postgresqleu/confsponsor/urls.py
@@ -23,19 +23,25 @@ urlpatterns = [
url(r'^scanning-test/$', scanning.testcode),
url(r'^signup/(\w+)/$', views.sponsor_signup_dashboard),
url(r'^signup/(\w+)/(\w+)/$', views.sponsor_signup),
- url(r'^viewcontract/(\d+)/$', views.sponsor_contract),
+ url(r'^previewcontract/(\d+)/$', views.sponsor_contract_preview),
url(r'^shipments/([a-z0-9]+)/$', views.sponsor_shipment_receiver),
url(r'^shipments/([a-z0-9]+)/(\d+)/$', views.sponsor_shipment_receiver_shipment),
url(r'^admin/imageview/(\d+)/$', views.sponsor_admin_imageview),
url(r'^admin/(\w+)/$', views.sponsor_admin_dashboard),
url(r'^admin/(\w+)/(\d+)/$', views.sponsor_admin_sponsor),
url(r'^admin/(\w+)/(\d+)/edit/$', backendviews.edit_sponsor),
+ url(r'^admin/(\w+)/(\d+)/contractlog/$', views.sponsor_admin_sponsor_contractlog),
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),
url(r'^admin/(\w+)/testvat/$', views.sponsor_admin_test_vat),
url(r'^admin/(\w+)/benefitreports/$', views.sponsor_admin_benefit_reports),
url(r'^admin/(\w+)/levels/(.*/)?$', backendviews.edit_sponsorship_levels),
+ url(r'^admin/(\w+)/contracts/(\d+)/editfields/$', backendviews.edit_sponsorship_contract_fields),
+ url(r'^admin/(\w+)/contracts/(\d+)/previewfields/$', backendviews.preview_sponsorship_contract_fields),
+ url(r'^admin/(\w+)/contracts/(\d+)/editdigifields/$', backendviews.edit_sponsorship_digital_contract_fields),
+ url(r'^admin/(\w+)/contracts/(\d+)/sendtest/$', backendviews.send_test_sponsorship_contract),
+ url(r'^admin/(\w+)/contracts/(\d+)/copyfields/$', backendviews.copy_sponsorship_contract_fields),
url(r'^admin/(\w+)/contracts/(.*/)?$', backendviews.edit_sponsorship_contracts),
url(r'^admin/(\w+)/addresses/(.*/)?$', backendviews.edit_shipment_addresses),
url(r'^admin/(\w+)/shipments/new/$', views.admin_shipment_new),
diff --git a/postgresqleu/confsponsor/util.py b/postgresqleu/confsponsor/util.py
index 5f466bbc..a2a320bc 100644
--- a/postgresqleu/confsponsor/util.py
+++ b/postgresqleu/confsponsor/util.py
@@ -1,4 +1,5 @@
from django.db.models import Q
+from django.conf import settings
from postgresqleu.util.db import exec_to_list
from postgresqleu.mailqueue.util import send_simple_mail
@@ -42,3 +43,15 @@ def get_mails_for_sponsor(sponsor):
Q(conference=sponsor.conference),
Q(levels=sponsor.level) | Q(sponsors=sponsor)
)
+
+
+def get_pdf_fields_for_conference(conference, sponsor=None):
+ fields = [
+ ('static:sponsor', sponsor.name if sponsor else 'Sponsor company name'),
+ ]
+ if settings.EU_VAT:
+ fields.append(
+ ('static:euvat', sponsor.vatnumber if sponsor else 'Sponsor EU VAT number'),
+ )
+
+ return fields
diff --git a/postgresqleu/confsponsor/views.py b/postgresqleu/confsponsor/views.py
index 98d179b9..6393cc38 100644
--- a/postgresqleu/confsponsor/views.py
+++ b/postgresqleu/confsponsor/views.py
@@ -27,6 +27,8 @@ from postgresqleu.util.decorators import superuser_required
from postgresqleu.util.request import get_int_or_error
from postgresqleu.util.time import today_global
from postgresqleu.invoices.util import InvoiceWrapper, InvoiceManager
+from postgresqleu.digisign.pdfutil import fill_pdf_fields, pdf_watermark_preview
+from postgresqleu.digisign.models import DigisignDocument, DigisignLog
from .models import Sponsor, SponsorshipLevel, SponsorshipBenefit
from .models import SponsorClaimedBenefit, SponsorMail, SponsorshipContract
@@ -44,6 +46,7 @@ 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 get_mails_for_sponsor
+from .util import get_pdf_fields_for_conference
@login_required
@@ -349,7 +352,37 @@ def sponsor_signup(request, confurlname, levelurlname):
if request.method == 'POST':
form = SponsorSignupForm(conference, data=request.POST)
- if not request.POST.get('confirm', '0') == '1':
+ stage = request.POST.get('stage', '0')
+ # Stage 0 = original form. When submitted, show preview address
+ # Stage 1 = preview address. When submitted, show contract choice
+ # 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):
+ stage = '2'
+
+ def _render_contract_choices():
+ contractchoices = []
+ if conference.contractprovider:
+ providerimpl = conference.contractprovider.get_implementation()
+ contractchoices.append(
+ (0, 'Digital signatures', "Digitally sign the contract using {}. {}<br/><strong>NOTE!</strong> The signing process has to complete within {} days or the signup will be automatically canceled.".format(conference.contractprovider.displayname, providerimpl.description_text(request.user.email), conference.contractexpires)),
+ )
+ if conference.manualcontracts:
+ contractchoices.append(
+ (1, 'Manual signing', 'Receive the contract as a PDF sent to {}, print it, sign it, scan it and send it back in to the conference organisers.'.format(request.user.email)),
+ )
+
+ return render(request, 'confsponsor/signupform.html', {
+ 'user_name': user_name,
+ 'conference': conference,
+ 'level': level,
+ 'form': form,
+ 'noform': 1,
+ 'contractchoices': contractchoices,
+ })
+
+ if stage == '0':
if form.is_valid():
# Confirm not set, but form valid: show the address verification.
return render(request, 'confsponsor/signupform.html', {
@@ -357,16 +390,27 @@ def sponsor_signup(request, confurlname, levelurlname):
'conference': conference,
'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'],
form.cleaned_data['address'],
settings.EU_VAT and form.cleaned_data['vatnumber'] or None)
})
- # Else fall through to re-render the full form
# If form not valid, fall through to error below
- elif form.is_valid():
- # Confirm is set, but if the Continue editing button is selected we should go back
+ elif stage == "1":
+ if request.POST.get('submit', '') != 'Continue editing':
+ if form.is_valid():
+ return _render_contract_choices()
+ # If form not valid, fall through to error below
+ elif stage == "2" and form.is_valid():
+ # 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'):
+ return _render_contract_choices()
+
twname = form.cleaned_data.get('twittername', '')
if twname and twname[0] != '@':
twname = '@{0}'.format(twname)
@@ -377,7 +421,10 @@ def sponsor_signup(request, confurlname, levelurlname):
url=form.cleaned_data['url'],
level=level,
twittername=twname,
- invoiceaddr=form.cleaned_data['address'])
+ invoiceaddr=form.cleaned_data['address'],
+ signmethod=1 if request.POST['contractchoice'] == '1' or not conference.contractprovider else 0,
+ autoapprove=conference.autocontracts,
+ )
if settings.EU_VAT:
sponsor.vatstatus = int(form.cleaned_data['vatstatus'])
sponsor.vatnumber = form.cleaned_data['vatnumber']
@@ -387,19 +434,76 @@ def sponsor_signup(request, confurlname, levelurlname):
mailstr = "Sponsor %s signed up for conference\n%s at level %s.\n\n" % (sponsor.name, conference, level.levelname)
+ error = None
+
if level.instantbuy:
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(
settings.SITEBASE, conference.urlname, sponsor.id)
else:
- mailstr += "No invoice has been generated as for this level\na signed contract is required first. The sponsor\nhas been instructed to sign and send the contract."
+ pdf = fill_pdf_fields(
+ level.contract.contractpdf,
+ level.contract.fieldjson,
+ get_pdf_fields_for_conference(conference, sponsor),
+ )
- send_conference_notification(
- conference,
- "Sponsor %s signed up for %s" % (sponsor.name, conference),
- mailstr,
- )
- # Redirect back to edit the actual sponsorship entry
- return HttpResponseRedirect('/events/sponsor/%s/' % sponsor.id)
+ if request.POST['contractchoice'] == '1' or not conference.contractprovider:
+ # Either the user picked manual, or only manual is available
+ mailstr += "No invoice has been generated as for this level\na signed contract is required first. The sponsor\nhas been instructed to sign and send the contract."
+
+ send_sponsor_manager_email(
+ sponsor,
+ 'Your contract for {}'.format(conference.conferencename),
+ 'confsponsor/mail/sponsor_contract_manual.txt',
+ {
+ 'conference': conference,
+ 'sponsor': sponsor,
+ },
+ attachments=[
+ ('{}_sponsorship_contract.pdf'.format(conference.urlname), 'application/pdf', pdf),
+ ],
+ )
+ else:
+ # Send a signing request using the configured provider
+ mailstr += "No invoice has been generated as for this level\na signed contract is required first. The sponsor\nhas been sent a contract for digital signing."
+
+ signer = conference.contractprovider.get_implementation()
+ contractid, error = signer.send_contract(
+ conference.contractsendername,
+ conference.contractsenderemail,
+ "{} {}".format(request.user.first_name, request.user.lasT_name),
+ request.user.name,
+ pdf,
+ "{}_sponsorship_contract.pdf".format(conference.urlname),
+ "{} {} sponsorship contract".format(conference.conferencename, sponsor.level.levelname),
+ "Hello!\n\nYou have signed up as a {} sponsor of {}. Please use the link below to view and sign the sponsorship contract for the event. When you have signed the contract, the organisers will also sign it, and at that point your sponsorship will proceed to the next step.".format(level.levelname, conference.conferencename),
+ {
+ 'type': 'sponsor',
+ 'sponsorid': str(sponsor.id),
+ },
+ level.contract.fieldjson,
+ conference.contractexpires,
+ )
+ if error:
+ form.add_error("Failed to send digital contract.")
+ else:
+ sponsor.contract = DigisignDocument(
+ provider=conference.contractprovider,
+ documentid=contractid,
+ handler='confsponsor',
+ )
+ sponsor.contract.save()
+ sponsor.save(update_fields=['contract', ])
+
+ if not error:
+ send_conference_notification(
+ conference,
+ "Sponsor %s signed up for %s" % (sponsor.name, conference),
+ mailstr,
+ )
+
+ # Redirect back to edit the actual sponsorship entry
+ return HttpResponseRedirect('/events/sponsor/%s/' % sponsor.id)
+ # Else on error we fall through and re-render the form
else:
form = SponsorSignupForm(conference)
@@ -724,7 +828,7 @@ def sponsor_shipment_receiver_shipment(request, token, addresstoken):
@login_required
-def sponsor_contract(request, contractid):
+def sponsor_contract_preview(request, contractid):
# Our contracts are not secret, are they? Anybody can view them, we just require a login
# to keep the load down and to make sure they are not spidered.
@@ -732,7 +836,7 @@ def sponsor_contract(request, contractid):
resp = HttpResponse(content_type='application/pdf')
resp['Content-disposition'] = 'attachment; filename="%s.pdf"' % contract.contractname
- resp.write(bytes(contract.contractpdf))
+ resp.write(pdf_watermark_preview(bytes(contract.contractpdf)))
return resp
@@ -1003,6 +1107,24 @@ def sponsor_admin_sponsor(request, confurlname, sponsorid):
@login_required
+@transaction.atomic
+def sponsor_admin_sponsor_contractlog(request, confurlname, sponsorid):
+ conference = get_authenticated_conference(request, confurlname)
+
+ sponsor = get_object_or_404(Sponsor, id=sponsorid, conference=conference)
+
+ return render(request, 'confsponsor/admin_sponsor_contractlog.html', {
+ 'conference': conference,
+ 'sponsor': sponsor,
+ 'log': DigisignLog.objects.filter(document=sponsor.contract).order_by('-time')[:100],
+ 'breadcrumbs': (
+ ('/events/sponsor/admin/{0}/'.format(conference.urlname), 'Sponsors'),
+ ('/events/sponsor/admin/{0}/{1}/'.format(conference.urlname, sponsor.id), sponsor.name),
+ ),
+ })
+
+
+@login_required
def sponsor_admin_benefit(request, confurlname, benefitid):
conference = get_authenticated_conference(request, confurlname)
diff --git a/postgresqleu/digisign/__init__.py b/postgresqleu/digisign/__init__.py
new file mode 100644
index 00000000..e69de29b
--- /dev/null
+++ b/postgresqleu/digisign/__init__.py
diff --git a/postgresqleu/digisign/backendforms.py b/postgresqleu/digisign/backendforms.py
new file mode 100644
index 00000000..670a4be0
--- /dev/null
+++ b/postgresqleu/digisign/backendforms.py
@@ -0,0 +1,65 @@
+from django import forms
+
+from postgresqleu.util.backendforms import BackendForm, BackendBeforeNewForm
+from postgresqleu.util.forms import SelectSetValueField
+
+from postgresqleu.digisign.models import DigisignProvider
+from postgresqleu.digisign.util import digisign_provider_choices
+
+
+class BackendDigisignProviderNewForm(BackendBeforeNewForm):
+ helplink = 'digisign'
+ classname = forms.ChoiceField(choices=digisign_provider_choices(), label='Implementation class')
+
+ def get_newform_data(self):
+ return self.cleaned_data['classname']
+
+
+class BackendProviderForm(BackendForm):
+ list_fields = ['name', 'displayname', 'active', 'classname_short']
+ helplink = 'digisign'
+ form_before_new = BackendDigisignProviderNewForm
+ verbose_field_names = {
+ 'classname_short': 'Implementation',
+ }
+ queryset_extra_fields = {
+ 'classname_short': r"substring(classname, '[^\.]+$')",
+ }
+ extrabuttons = [
+ ('View log', 'log/'),
+ ]
+
+ config_fields = []
+ config_fieldsets = []
+ config_readonly = []
+
+ class Meta:
+ model = DigisignProvider
+ fields = ['name', 'displayname', 'active', 'classname']
+
+ @property
+ def fieldsets(self):
+ fs = [
+ {'id': 'common', 'legend': 'Common', 'fields': ['name', 'displayname', 'active', 'classname'], }
+ ] + self.config_fieldsets
+
+ return fs
+
+ @property
+ def readonly_fields(self):
+ return ['classname', ] + self.config_readonly
+
+ @property
+ def exclude_fields_from_validation(self):
+ return self.config_readonly
+
+ @property
+ def json_form_fields(self):
+ return {
+ 'config': self.config_fields,
+ }
+
+ def fix_fields(self):
+ if self.newformdata:
+ self.instance.classname = self.newformdata
+ self.initial['classname'] = self.newformdata
diff --git a/postgresqleu/digisign/backendviews.py b/postgresqleu/digisign/backendviews.py
new file mode 100644
index 00000000..c93885d3
--- /dev/null
+++ b/postgresqleu/digisign/backendviews.py
@@ -0,0 +1,128 @@
+from django.core.exceptions import PermissionDenied
+from django.http import HttpResponse
+from django.shortcuts import get_object_or_404, render
+
+import base64
+import io
+import json
+
+from postgresqleu.util.backendviews import backend_list_editor
+from postgresqleu.digisign.models import DigisignProvider, DigisignLog
+from postgresqleu.digisign.backendforms import BackendProviderForm
+from postgresqleu.digisign.util import digisign_providers
+from postgresqleu.digisign.pdfutil import fill_pdf_fields
+
+
+def edit_providers(request, rest):
+ if not request.user.is_superuser:
+ raise PermissionDenied("Access denied")
+
+ def _load_formclass(classname):
+ pieces = classname.split('.')
+ modname = '.'.join(pieces[:-1])
+ classname = pieces[-1]
+ mod = __import__(modname, fromlist=[classname, ])
+ if hasattr(getattr(mod, classname), 'backend_form_class'):
+ return getattr(mod, classname).backend_form_class
+ else:
+ return BackendProviderForm
+
+ u = rest and rest.rstrip('/') or rest
+ if u and u != '' and u.isdigit():
+ p = get_object_or_404(DigisignProvider, pk=u)
+ formclass = _load_formclass(p.classname)
+ elif u == 'new':
+ if '_newformdata' in request.POST or 'classname' in request.POST:
+ c = request.POST['_newformdata' if '_newformdata' in request.POST else 'classname']
+ if c not in digisign_providers:
+ raise PermissionDenied()
+
+ formclass = _load_formclass(c)
+ else:
+ formclass = BackendProviderForm
+ else:
+ formclass = BackendProviderForm
+
+ return backend_list_editor(request,
+ None,
+ formclass,
+ rest,
+ bypass_conference_filter=True,
+ topadmin='Digital signatures',
+ return_url='/admin/',
+ )
+
+
+def view_provider_log(request, providerid):
+ if not request.user.is_superuser:
+ raise PermissionDenied("Access denied")
+
+ provider = get_object_or_404(DigisignProvider, pk=providerid)
+
+ return render(request, 'digisign/digisign_backend_log.html', {
+ 'log': DigisignLog.objects.filter(provider=provider).order_by('-id')[:100],
+ 'breadcrumbs': [
+ ('/admin/digisign/providers/', 'Digital signature providers'),
+ ('/admin/digisign/providers/{}/'.format(provider.id), provider.name),
+ ]
+ })
+
+
+def pdf_field_editor(request, conference, pdf, available_fields, fielddata, savecallback=None, breadcrumbs=[]):
+ import fitz
+
+ if request.method == 'GET' and request.GET.get('current', '0') == '1':
+ return HttpResponse(
+ json.dumps(fielddata),
+ content_type='application/json',
+ status=200,
+ )
+ elif request.method == 'POST' and 'application/json' in request.META['CONTENT_TYPE']:
+ # Postback to save all fields
+ try:
+ postdata = json.loads(request.body.decode())
+ except json.decoder.JSONDecodeError:
+ return HttpResponse("Invalid json", status=400)
+
+ newdata = {
+ 'fields': [],
+ 'fontsize': int(postdata['fontsize']),
+ }
+ fieldnames = [fn for fn, fd in available_fields]
+ for f in postdata['fields']:
+ if f['field'] in fieldnames:
+ newdata['fields'].append({
+ 'field': f['field'],
+ 'page': int(f['page']),
+ 'x': int(f['x']),
+ 'y': int(f['y']),
+ })
+ else:
+ return HttpResponse('Invalid field {}'.format(f['field']), status=400)
+
+ newdata['fields'] = sorted(newdata['fields'], key=lambda f: f['page'])
+ savecallback(fielddata | newdata)
+ return HttpResponse(json.dumps({'status': 'OK'}), content_type="application/json", status=200)
+
+ # Or we render the base page
+
+ # This is inefficient as hell, but we hope not to have huge PDFs :) Turn the PDF into
+ # one PNG for each page.
+ pdf = fitz.open('pdf', bytes(pdf))
+ pages = []
+ pages = [(pagenum, base64.b64encode(page.getPixmap().getPNGData()).decode()) for pagenum, page in enumerate(pdf.pages())]
+
+ return render(request, 'digisign/pdf_field_editor.html', {
+ 'conference': conference,
+ 'breadcrumbs': breadcrumbs,
+ 'pages': pages,
+ 'fields': available_fields,
+ })
+
+
+def pdf_field_preview(request, conference, pdf, available_fields, fielddata):
+ pdf = fill_pdf_fields(pdf, available_fields, fielddata)
+
+ resp = HttpResponse(content_type='application/pdf')
+ resp.write(pdf)
+ return resp
diff --git a/postgresqleu/digisign/implementations/__init__.py b/postgresqleu/digisign/implementations/__init__.py
new file mode 100644
index 00000000..31b67144
--- /dev/null
+++ b/postgresqleu/digisign/implementations/__init__.py
@@ -0,0 +1,10 @@
+class BaseProvider:
+ can_send_preview = False
+ webhookcode = None
+
+ def __init__(self, id, provider):
+ self.id = id
+ self.provider = provider
+
+ def description_text(self, signeremail):
+ return ''
diff --git a/postgresqleu/digisign/implementations/signwell.py b/postgresqleu/digisign/implementations/signwell.py
new file mode 100644
index 00000000..8082d981
--- /dev/null
+++ b/postgresqleu/digisign/implementations/signwell.py
@@ -0,0 +1,342 @@
+from django import forms
+from django.http import HttpResponse, HttpResponseRedirect
+from django.shortcuts import render
+from django.utils import timezone
+from django.conf import settings
+
+from postgresqleu.util.widgets import StaticTextWidget
+from postgresqleu.digisign.backendforms import BackendProviderForm
+from postgresqleu.digisign.models import DigisignDocument, DigisignLog
+from postgresqleu.digisign.util import digisign_handlers
+
+import base64
+import dateutil.parser
+import hashlib
+import hmac
+import json
+import time
+
+import requests
+from datetime import timedelta
+
+from . import BaseProvider
+
+
+class SignwellBackendForm(BackendProviderForm):
+ apikey = forms.CharField(max_length=200, widget=forms.widgets.PasswordInput(render_value=True), label='API Key')
+ applicationid = forms.CharField(max_length=200, label='Application id', required=True)
+ forcetest = forms.BooleanField(label="Force test", required=False, help_text="Check this box to make ALL contracts be sent as test contracts. Test contracts are not legally binding, but free.")
+ webhookurl = forms.CharField(label="Webhook URL", widget=StaticTextWidget, required=False)
+
+ config_fields = ['apikey', 'applicationid', 'forcetest', ]
+ config_readonly = ['webhookurl', ]
+ config_fieldsets = [
+ {
+ 'id': 'signwell',
+ 'legend': 'Signwell',
+ 'fields': ['apikey', 'applicationid', 'forcetest', ],
+ },
+ {
+ 'id': 'webhook',
+ 'legend': 'Webhook',
+ 'fields': ['webhookurl', ],
+ },
+ ]
+
+ def fix_fields(self):
+ super().fix_fields()
+ self.initial['webhookurl'] = """
+On the Signwell account, open up the API application and specify
+<code>{}/wh/sw/{}/</code> as the event callback URL.
+""".format(
+ settings.SITEBASE,
+ self.instance.id,
+ )
+
+ def clean(self):
+ cleaned_data = super().clean()
+ # Fetch the webhook api if we have an application defined
+
+ if self.cleaned_data['applicationid']:
+ impl = self.instance.get_implementation()
+
+ # There's no searching, we have to scan them all...
+ webhooks = impl.get_webhooks_for_application(self.cleaned_data['applicationid'])
+
+ if len(webhooks) == 0:
+ self.add_error('applicationid', 'This application has no webhooks defined')
+ elif len(webhooks) > 1:
+ self.add_error('applicationid', 'This application has more than one webhook defined')
+ else:
+ self.instance.config['webhookid'] = webhooks[0]['id']
+
+ return cleaned_data
+
+
+class Signwell(BaseProvider):
+ backend_form_class = SignwellBackendForm
+ can_send_preview = True
+ webhookcode = "sw"
+
+ def description_text(self, signeremail):
+ return 'Signing instructions will be delivered to {}. If necessary, you will be able to re-route the signing from the provider interface to somebody else in your organisation once the process is started.'.format(signeremail)
+
+ def send_contract(self, sender_name, sender_email, recipient_name, recipient_email, pdf, pdfname, subject, message, metadata, fielddata, expires_in, test=True):
+ if self.provider.config.get('forcetest', False):
+ # Override test to be true if configured for enforcement.
+ test = True
+
+ payload = {
+ "test_mode": "true" if test else "false",
+ "files": [
+ {
+ "name": pdfname,
+ "file_base64": base64.b64encode(pdf),
+ }
+ ],
+ "name": subject,
+ "subject": subject,
+ "message": message,
+ "recipients": [
+ {
+ "id": "1",
+ "name": recipient_name,
+ "email": recipient_email,
+ },
+ {
+ "id": "2",
+ "name": sender_name,
+ "email": sender_email,
+ },
+ ],
+ "apply_signing_order": True,
+ "custom_requester_name": sender_name,
+ "allow_decline": True,
+ "allow_reassign": True,
+ "metadata": metadata,
+ "fields": [fielddata['signwellfields']],
+ "draft": False,
+ "api_application_id": self.provider.config.get('applicationid'),
+ "expires_in": expires_in,
+ }
+
+ # Add fields that only exist in prod
+ if not test:
+ pass
+
+ r = requests.post('https://www.signwell.com/api/v1/documents/', json=payload, headers={
+ 'X-Api-Key': self.provider.config.get('apikey'),
+ }, timeout=15)
+ if r.status_code != 201:
+ DigisignLog(
+ provider=self,
+ document=None,
+ event='internal',
+ text='Could not create signing request: {}'.format(r.text),
+ ).save()
+ return None, "Could not create signing request: {}".format(r.text)
+
+ return r.json()['id'], None
+
+ def edit_digital_fields(self, request, conference, name, pdf, fieldjson, savecallback, breadcrumbs):
+ if request.method == 'GET' and 'finished' in request.GET:
+ if 'signwelledit' not in fieldjson:
+ return HttpResponse("No existing preview data, concurrent edit?)")
+
+ docid = fieldjson['signwelledit']['id']
+ # Fetch back the document
+ r = requests.get('https://www.signwell.com/api/v1/documents/{}'.format(docid), headers={
+ 'X-Api-Key': self.provider.config.get('apikey'),
+ }, timeout=10)
+ if r.status_code != 200:
+ return HttpResponse("Could not re-fetch preview document. Try again?")
+
+ del fieldjson['signwelledit']
+ fieldjson['signwellfields'] = r.json()['fields'][0]
+ for f in fieldjson['signwellfields']:
+ f['type'] = f['type'].lower()
+ savecallback(fieldjson)
+
+ # Delete the temporary document
+ r = requests.delete('https://www.signwell.com/api/v1/documents/{}'.format(docid), headers={
+ 'X-Api-Key': self.provider.config.get('apikey'),
+ }, timeout=10)
+ if r.status_code != 204:
+ DigisignLog(
+ provider=self,
+ document=None,
+ event='internal',
+ text="Failed to delete preview document when complete, code {}, text {}".format(r.status_code, r.text),
+ ).save()
+
+ return None
+ elif request.method == 'GET':
+ return render(request, 'digisign/signwell/field_editor.html', {
+ 'conference': conference,
+ 'breadcrumbs': breadcrumbs,
+ })
+ elif request.method == 'POST':
+ # If we already have a preview document, zap it because we'll need a new one.
+ # But we ignore the error..
+ if 'signwelledit' in fieldjson:
+ r = requests.delete('https://www.signwell.com/api/v1/documents/{}'.format(fieldjson['signwelledit']['id']), headers={
+ 'X-Api-Key': self.provider.config.get('apikey'),
+ }, timeout=10)
+ if r.status_code != 204:
+ DigisignLog(
+ provider=self,
+ document=None,
+ event='internal',
+ text="Failed to delete existing preview document, code {}, text {}".format(r.status_code, r.text),
+ ).save()
+
+ # Create a preview document
+ subject = 'EDITPREVIEW:{}'.format(name)
+ payload = {
+ "test_mode": "true",
+ "files": [
+ {
+ "name": "editpreview_{}.pdf".format(name),
+ "file_base64": base64.b64encode(pdf),
+ }
+ ],
+ "name": subject,
+ "recipients": [
+ {
+ "id": "1",
+ "name": "Sponsor",
+ "email": "test1@example.com",
+ },
+ {
+ "id": "2",
+ "name": "Organisers",
+ "email": "test2@example.com",
+ },
+ ],
+ "allow_decline": False,
+ "allow_reassign": False,
+ "metadata": {"is_edit_preview": "1"},
+ "draft": True,
+ "api_application_id": self.provider.config.get('applicationid'),
+ }
+
+ if 'signwellfields' in fieldjson:
+ payload['fields'] = [fieldjson['signwellfields']]
+ for f in payload['fields'][0]:
+ # Workaround: seems it gets returned mixed case but has to be specified lowercase!
+ f['type'] = f['type'].lower()
+
+ r = requests.post('https://www.signwell.com/api/v1/documents/', json=payload, headers={
+ 'X-Api-Key': self.provider.config.get('apikey'),
+ }, timeout=15)
+ if r.status_code != 201:
+ return HttpResponse("Could not call signwell API, status {}, message {}".format(r.status_code, r.text))
+
+ fieldjson['signwelledit'] = {
+ 'id': r.json()['id'],
+ 'embeddedurl': r.json()['embedded_edit_url'],
+ }
+ savecallback(fieldjson)
+
+ return render(request, 'digisign/signwell/field_editor.html', {
+ 'conference': conference,
+ 'signwelledit': fieldjson['signwelledit'],
+ 'breadcrumbs': breadcrumbs,
+ })
+
+ def cleanup(self):
+ # Get orphaned documents to remove
+ r = requests.get('https://www.signwell.com/api/v1/documents', headers={
+ 'X-Api-Key': self.provider.config.get('apikey'),
+ }, timeout=120)
+
+ for d in r.json()['documents']:
+ if d.get('metadata', {}).get('is_edit_preview', None) == '1':
+ u = dateutil.parser.parse(d['updated_at'])
+ if timezone.now() - u > timedelta(minutes=30):
+ print("Document {} is edit preview and older than 30 minutes, deleting".format(d['id']))
+ r = requests.delete('https://www.signwell.com/api/v1/documents/{}'.format(d['id']), headers={
+ 'X-Api-Key': self.provider.config.get('apikey'),
+ }, timeout=10)
+ time.sleep(10)
+
+ def process_webhook(self, request):
+ if 'application/json' not in request.META['CONTENT_TYPE']:
+ return HttpResponse("Invalid content type", status=400)
+
+ try:
+ j = json.loads(request.body)
+ except json.decoder.JSONDecodeError:
+ return HttpResponse("Invalid json", status=400)
+
+ # Next we verify the signature
+ if 'webhookid' not in self.provider.config:
+ # No webhookid configured, so we just ignore it
+ return HttpResponse("Ignored", status=200)
+
+ data = j['event']['type'] + '@' + str(j['event']['time'])
+ calculated_signature = hmac.new(self.provider.config['webhookid'].encode(), data.encode(), hashlib.sha256).hexdigest()
+ if not hmac.compare_digest(j['event']['hash'], calculated_signature):
+ return HttpResponse("Invalid signature", sstatus=400)
+
+ docid = j.get('data', {}).get('object', {}).get('id', None)
+ if docid:
+ try:
+ doc = DigisignDocument.objects.get(provider=self.provider, documentid=docid)
+ except DigisignDocument.DoesNotExist:
+ doc = None
+ else:
+ doc = None
+
+ event = j['event']['type']
+ if event in ('document_viewed', 'document_declined', 'document_signed'):
+ what = {
+ 'document_viewed': 'Document viewed by',
+ 'document_declined': 'Document declined by',
+ 'document_signed': 'Document signed by',
+ }
+ eventtext = "{} {}".format(
+ what[event],
+ "{} <{}>".format(j['event']['related_signer']['name'], j['event']['related_signer']['email']),
+ )
+ else:
+ eventtext = event
+
+ log = DigisignLog(
+ provider=self.provider,
+ document=doc,
+ event=event,
+ text=eventtext,
+ fulldata=j,
+ )
+ log.save()
+
+ if doc and doc.handler:
+ if doc.handler not in digisign_handlers:
+ DigisignLog(
+ provider=self.provider,
+ document=doc,
+ event='internal',
+ text='Could not find handler {} for document.'.format(doc.handler),
+ fulldata={},
+ ).save()
+ dhandler = digisign_handlers[doc.handler](doc)
+ if event == 'document_completed':
+ doc.completed = True
+ doc.save(update_fields=['completed'])
+ dhandler.completed()
+ elif event == 'document_expired':
+ dhandler.expired()
+ elif event == 'document_declined':
+ dhandler.declined()
+
+ return HttpResponse("OK", status=200)
+
+ def get_webhooks_for_application(self, appid):
+ # Can't search, we have to get all and traverse
+ r = requests.get('https://www.signwell.com/api/v1/hooks/', headers={
+ 'X-Api-Key': self.provider.config.get('apikey'),
+ }, timeout=10)
+ r.raise_for_status()
+
+ return [h for h in r.json() if h.get('api_application_id', None) == appid]
diff --git a/postgresqleu/digisign/management/__init__.py b/postgresqleu/digisign/management/__init__.py
new file mode 100644
index 00000000..e69de29b
--- /dev/null
+++ b/postgresqleu/digisign/management/__init__.py
diff --git a/postgresqleu/digisign/management/commands/__init__.py b/postgresqleu/digisign/management/commands/__init__.py
new file mode 100644
index 00000000..e69de29b
--- /dev/null
+++ b/postgresqleu/digisign/management/commands/__init__.py
diff --git a/postgresqleu/digisign/management/commands/digisign_cleanup.py b/postgresqleu/digisign/management/commands/digisign_cleanup.py
new file mode 100644
index 00000000..6d8408b5
--- /dev/null
+++ b/postgresqleu/digisign/management/commands/digisign_cleanup.py
@@ -0,0 +1,29 @@
+#
+# Run cleanup maintenance for all providers
+#
+# Copyright (C)2023, PostgreSQL Europe
+#
+
+
+from django.core.management.base import BaseCommand
+from django.db import transaction
+
+from datetime import time
+
+from postgresqleu.digisign.models import DigisignProvider
+
+
+class Command(BaseCommand):
+ help = 'Run cleanup commands for all digisign providers'
+
+ class ScheduledJob:
+ scheduled_times = [time(3, 7), ]
+
+ @classmethod
+ def should_run(self):
+ return DigisignProvider.objects.filter(active=True).exists()
+
+ def handle(self, *args, **options):
+ for provider in DigisignProvider.objects.filter(active=True):
+ with transaction.atomic():
+ provider.get_implementation().cleanup()
diff --git a/postgresqleu/digisign/migrations/0001_initial.py b/postgresqleu/digisign/migrations/0001_initial.py
new file mode 100644
index 00000000..b301f00f
--- /dev/null
+++ b/postgresqleu/digisign/migrations/0001_initial.py
@@ -0,0 +1,62 @@
+# Generated by Django 3.2.14 on 2023-05-04 13:16
+
+from django.db import migrations, models
+import django.db.models.deletion
+
+
+class Migration(migrations.Migration):
+
+ initial = True
+
+ dependencies = [
+ ]
+
+ operations = [
+ migrations.CreateModel(
+ name='DigisignProvider',
+ fields=[
+ ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
+ ('name', models.CharField(max_length=100, unique=True)),
+ ('displayname', models.CharField(max_length=100)),
+ ('classname', models.CharField(max_length=200, verbose_name='Implementation class')),
+ ('active', models.BooleanField(default=False)),
+ ('config', models.JSONField(default=dict)),
+ ],
+ options={
+ 'ordering': ('name',),
+ },
+ ),
+ migrations.CreateModel(
+ name='DigisignDocument',
+ fields=[
+ ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
+ ('documentid', models.CharField(blank=True, max_length=100)),
+ ('provider', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='digisign.digisignprovider')),
+ ('handler', models.CharField(blank=True, max_length=32)),
+ ('completed', models.BooleanField(null=False, blank=False, default=False)),
+ ],
+ ),
+ migrations.AlterUniqueTogether(
+ name='digisigndocument',
+ unique_together=set([('documentid', 'provider')]),
+ ),
+ migrations.CreateModel(
+ name='DigisignLog',
+ fields=[
+ ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
+ ('time', models.DateTimeField(auto_now_add=True, db_index=True)),
+ ('event', models.CharField(max_length=200)),
+ ('text', models.CharField(max_length=1000)),
+ ('fulldata', models.JSONField(default=dict)),
+ ('document', models.ForeignKey(null=True, blank=True, on_delete=django.db.models.deletion.CASCADE, to='digisign.digisigndocument')),
+ ('provider', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='digisign.digisignprovider')),
+ ],
+ options={
+ 'ordering': ('time',),
+ },
+ ),
+ migrations.AddIndex(
+ model_name='digisignlog',
+ index=models.Index(fields=['document', '-time'], name='digisign_di_documen_79688b_idx'),
+ ),
+ ]
diff --git a/postgresqleu/digisign/migrations/__init__.py b/postgresqleu/digisign/migrations/__init__.py
new file mode 100644
index 00000000..e69de29b
--- /dev/null
+++ b/postgresqleu/digisign/migrations/__init__.py
diff --git a/postgresqleu/digisign/models.py b/postgresqleu/digisign/models.py
new file mode 100644
index 00000000..913f5460
--- /dev/null
+++ b/postgresqleu/digisign/models.py
@@ -0,0 +1,49 @@
+from django.db import models
+
+
+class DigisignProvider(models.Model):
+ name = models.CharField(max_length=100, null=False, blank=False, unique=True)
+ displayname = models.CharField(max_length=100, null=False, blank=False)
+ classname = models.CharField(max_length=200, null=False, blank=False, verbose_name="Implementation class")
+ active = models.BooleanField(null=False, blank=False, default=False)
+ config = models.JSONField(blank=False, null=False, default=dict)
+
+ def __str__(self):
+ return self.name
+
+ class Meta:
+ ordering = ('name', )
+
+ def get_implementation(self):
+ pieces = self.classname.split('.')
+ modname = '.'.join(pieces[:-1])
+ classname = pieces[-1]
+ mod = __import__(modname, fromlist=[classname, ])
+ return getattr(mod, classname)(self.id, self)
+
+
+class DigisignDocument(models.Model):
+ provider = models.ForeignKey(DigisignProvider, null=False, blank=False, on_delete=models.CASCADE)
+ documentid = models.CharField(max_length=100, null=False, blank=True)
+ handler = models.CharField(max_length=32, null=False, blank=True)
+ completed = models.BooleanField(null=False, blank=False, default=False)
+
+ class Meta:
+ unique_together = (
+ ('documentid', 'provider'),
+ )
+
+
+class DigisignLog(models.Model):
+ provider = models.ForeignKey(DigisignProvider, null=False, blank=False, on_delete=models.CASCADE)
+ document = models.ForeignKey(DigisignDocument, null=True, blank=True, on_delete=models.CASCADE)
+ time = models.DateTimeField(auto_now_add=True, db_index=True)
+ event = models.CharField(max_length=200, null=False, blank=False)
+ text = models.CharField(max_length=1000, null=False, blank=False)
+ fulldata = models.JSONField(null=False, blank=False, default=dict)
+
+ class Meta:
+ ordering = ('time', )
+ indexes = [
+ models.Index(fields=('document', '-time'))
+ ]
diff --git a/postgresqleu/digisign/pdfutil.py b/postgresqleu/digisign/pdfutil.py
new file mode 100644
index 00000000..421838cc
--- /dev/null
+++ b/postgresqleu/digisign/pdfutil.py
@@ -0,0 +1,71 @@
+import io
+import itertools
+
+from reportlab.pdfgen.canvas import Canvas
+
+from postgresqleu.util.reporttools import cm
+
+
+def fill_pdf_fields(pdf, available_fields, fielddata):
+ import fitz
+
+ pagefields = {int(k): list(v) for k, v in itertools.groupby(fielddata['fields'], lambda x: x['page'])}
+
+ pdf = fitz.open('pdf', bytes(pdf))
+ for pagenum, page in enumerate(pdf.pages()):
+ if pagenum in pagefields:
+ for f in pagefields[pagenum]:
+ # Location in the json is top-left corner, but we want bottom-left for the
+ # PDF. So we add the size of the font in points, which is turned into pixels
+ # by multiplying by 96/72.
+ p = fitz.Point(
+ f['x'],
+ f['y'] + fielddata['fontsize'] * 96 / 72,
+ )
+
+ # Preview with the field title
+ txt = None
+ for fieldname, fieldtext in available_fields:
+ if not fieldname.startswith('static:'):
+ break
+ if fieldname == f['field']:
+ txt = fieldtext
+ break
+ else:
+ txt = ""
+
+ if txt:
+ page.insertText(p, txt, fontname='Courier-Bold', fontsize=fielddata['fontsize'])
+
+ return pdf.write()
+
+
+def pdf_watermark_preview(pdfdata):
+ try:
+ import fitz
+ except ImportError:
+ # Just return without watermark
+ return pdfdata
+
+ wmio = io.BytesIO()
+ wmcanvas = Canvas(wmio)
+ wmcanvas.rotate(45)
+ for y in -5, 0, 5, 10, 15:
+ t = wmcanvas.beginText()
+ t.setTextOrigin(cm(6), cm(y))
+ t.setFont("Times-Roman", 100)
+ t.setFillColorRGB(0.9, 0.9, 0.9)
+ t.textLine("PREVIEW PREVIEW")
+ wmcanvas.drawText(t)
+ wmcanvas.rotate(-45)
+ wmcanvas.save()
+
+ wmio.seek(0)
+ wmpdf = fitz.open('pdf', wmio)
+ wmpixmap = next(wmpdf.pages()).getPixmap()
+
+ pdf = fitz.open('pdf', pdfdata)
+ for pagenum, page in enumerate(pdf.pages()):
+ page.insertImage(page.bound(), pixmap=wmpixmap, overlay=False)
+
+ return pdf.write()
diff --git a/postgresqleu/digisign/util.py b/postgresqleu/digisign/util.py
new file mode 100644
index 00000000..10d89476
--- /dev/null
+++ b/postgresqleu/digisign/util.py
@@ -0,0 +1,28 @@
+digisign_providers = {
+ 'postgresqleu.digisign.implementations.signwell.Signwell': (),
+}
+
+
+def digisign_provider_choices():
+ return [(k, k.split('.')[-1]) for k, v in digisign_providers.items()]
+
+
+digisign_handlers = {}
+
+
+def register_digisign_handler(key, handler):
+ digisign_handlers[key] = handler
+
+
+class DigisignHandlerBase:
+ def __init__(self, doc):
+ self.doc = doc
+
+ def completed(self):
+ pass
+
+ def expired(self):
+ pass
+
+ def declined(self):
+ pass
diff --git a/postgresqleu/digisign/views.py b/postgresqleu/digisign/views.py
new file mode 100644
index 00000000..2d174af4
--- /dev/null
+++ b/postgresqleu/digisign/views.py
@@ -0,0 +1,39 @@
+from django.http import Http404, HttpResponse
+from django.shortcuts import get_object_or_404
+from django.views.decorators.csrf import csrf_exempt
+from django.db import transaction
+from django.conf import settings
+
+from postgresqleu.util.decorators import global_login_exempt
+from postgresqleu.digisign.models import DigisignProvider
+from postgresqleu.mailqueue.util import send_simple_mail
+
+
+@global_login_exempt
+@csrf_exempt
+def webhook(request, providershort, id):
+ if request.method != 'POST':
+ raise Http404()
+
+ provider = get_object_or_404(DigisignProvider, pk=id)
+ impl = provider.get_implementation()
+
+ if impl.webhookcode != providershort:
+ raise Http404()
+
+ try:
+ with transaction.atomic():
+ impl.process_webhook(request)
+ return HttpResponse("OK", status=200)
+ except Exception as e:
+ # Bad choice of address to send to, but it's the best we can do at this stage
+ # as we don't have a general notifications address.
+ send_simple_mail(
+ settings.INVOICE_SENDER_EMAIL,
+ settings.INVOICE_SENDER_EMAIL,
+ "Exception processing digital signature webhook",
+ "An exception occurred while processing a digital signature webhook for {}:\n\n{}\n".format(
+ provider.name,
+ e),
+ )
+ return HttpResponse("ERROR", status=500)
diff --git a/postgresqleu/settings.py b/postgresqleu/settings.py
index c548d5c1..22957252 100644
--- a/postgresqleu/settings.py
+++ b/postgresqleu/settings.py
@@ -104,11 +104,12 @@ INSTALLED_APPS = [
'postgresqleu.static',
'postgresqleu.countries',
'postgresqleu.scheduler.apps.SchedulerAppConfig',
+ 'postgresqleu.digisign',
'postgresqleu.paypal',
'postgresqleu.adyen',
'postgresqleu.newsevents',
'postgresqleu.confreg',
- 'postgresqleu.confsponsor',
+ 'postgresqleu.confsponsor.apps.ConfsponsorAppConfig',
'postgresqleu.confwiki',
'postgresqleu.mailqueue',
'postgresqleu.invoices',
diff --git a/postgresqleu/urls.py b/postgresqleu/urls.py
index 2a53a4ec..5997cb81 100644
--- a/postgresqleu/urls.py
+++ b/postgresqleu/urls.py
@@ -34,6 +34,8 @@ import postgresqleu.transferwise.views
import postgresqleu.accountinfo.views
import postgresqleu.util.docsviews
import postgresqleu.mailqueue.backendviews
+import postgresqleu.digisign.backendviews
+import postgresqleu.digisign.views
import postgresqleu.util.monitor
import postgresqleu.util.views
import postgresqleu.util.backendviews
@@ -353,6 +355,10 @@ urlpatterns.extend([
url(r'^admin/jobs/(\d+)/$', postgresqleu.scheduler.views.job),
url(r'^admin/jobs/history/$', postgresqleu.scheduler.views.history),
+ # Digial signatures
+ url(r'^admin/digisign/providers/(\d+)/log/$', postgresqleu.digisign.backendviews.view_provider_log),
+ url(r'^admin/digisign/providers/(.*/)?$', postgresqleu.digisign.backendviews.edit_providers),
+
# Mail queue
url(r'^admin/mailqueue/(\d+)/attachments/(.+)/$', postgresqleu.mailqueue.backendviews.view_attachment),
url(r'^admin/mailqueue/(.*/)?$', postgresqleu.mailqueue.backendviews.edit_mailqueue),
@@ -374,6 +380,9 @@ urlpatterns.extend([
# Transferwise webhooks
url(r'^wh/tw/(\d+)/(\w+)/$', postgresqleu.transferwise.views.webhook),
+ # Digital signatures webhooks
+ url(r'^wh/(sw)/(\d+)/$', postgresqleu.digisign.views.webhook),
+
# Account info callbacks
url(r'^accountinfo/search/$', postgresqleu.accountinfo.views.search),
url(r'^accountinfo/import/$', postgresqleu.accountinfo.views.importuser),
diff --git a/postgresqleu/util/apps.py b/postgresqleu/util/apps.py
index 5576ac04..def15275 100644
--- a/postgresqleu/util/apps.py
+++ b/postgresqleu/util/apps.py
@@ -61,6 +61,11 @@ class UtilAppConfig(AppConfig):
except ImportError:
logging.getLogger(__name__).warning("Could not load qrencode library. QR code based functionality will not be available")
+ try:
+ import fitz
+ except ImportError:
+ logging.getLogger(__name__).warning("Could not load fitz library, PDF editing and digital signatures won't be available")
+
#
# Define our own handling of registering a model for admin without
# it's own class. The default is to set admin_class to ModelAdmin