diff options
Diffstat (limited to 'postgresqleu/digisign')
| -rw-r--r-- | postgresqleu/digisign/__init__.py | 0 | ||||
| -rw-r--r-- | postgresqleu/digisign/backendforms.py | 65 | ||||
| -rw-r--r-- | postgresqleu/digisign/backendviews.py | 128 | ||||
| -rw-r--r-- | postgresqleu/digisign/implementations/__init__.py | 10 | ||||
| -rw-r--r-- | postgresqleu/digisign/implementations/signwell.py | 342 | ||||
| -rw-r--r-- | postgresqleu/digisign/management/__init__.py | 0 | ||||
| -rw-r--r-- | postgresqleu/digisign/management/commands/__init__.py | 0 | ||||
| -rw-r--r-- | postgresqleu/digisign/management/commands/digisign_cleanup.py | 29 | ||||
| -rw-r--r-- | postgresqleu/digisign/migrations/0001_initial.py | 62 | ||||
| -rw-r--r-- | postgresqleu/digisign/migrations/__init__.py | 0 | ||||
| -rw-r--r-- | postgresqleu/digisign/models.py | 49 | ||||
| -rw-r--r-- | postgresqleu/digisign/pdfutil.py | 71 | ||||
| -rw-r--r-- | postgresqleu/digisign/util.py | 28 | ||||
| -rw-r--r-- | postgresqleu/digisign/views.py | 39 |
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) |
