summaryrefslogtreecommitdiff
path: root/postgresqleu/digisign
diff options
context:
space:
mode:
Diffstat (limited to 'postgresqleu/digisign')
-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
14 files changed, 823 insertions, 0 deletions
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)