From 1e12dae71bdbcad3e64a8e57c4ab14ef4029d0b9 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Corentin=20S=C3=A9chet?= Date: Thu, 19 Oct 2023 17:44:08 +0200 Subject: [PATCH] qrcode: create qrcode connector & certificate management endpoints (#82648) --- passerelle/apps/qrcode/__init__.py | 15 +++ .../apps/qrcode/migrations/0001_initial.py | 78 ++++++++++++ passerelle/apps/qrcode/migrations/__init__.py | 0 passerelle/apps/qrcode/models.py | 114 ++++++++++++++++++ passerelle/settings.py | 1 + tests/test_qrcode.py | 100 +++++++++++++++ 6 files changed, 308 insertions(+) create mode 100644 passerelle/apps/qrcode/__init__.py create mode 100644 passerelle/apps/qrcode/migrations/0001_initial.py create mode 100644 passerelle/apps/qrcode/migrations/__init__.py create mode 100644 passerelle/apps/qrcode/models.py create mode 100644 tests/test_qrcode.py diff --git a/passerelle/apps/qrcode/__init__.py b/passerelle/apps/qrcode/__init__.py new file mode 100644 index 00000000..86bda729 --- /dev/null +++ b/passerelle/apps/qrcode/__init__.py @@ -0,0 +1,15 @@ +# passerelle - uniform access to multiple data sources and services +# Copyright (C) 2023 Entr'ouvert +# +# This program is free software: you can redistribute it and/or modify it +# under the terms of the GNU Affero General Public License as published +# by the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU Affero General Public License for more details. +# +# You should have received a copy of the GNU Affero General Public License +# along with this program. If not, see .xs diff --git a/passerelle/apps/qrcode/migrations/0001_initial.py b/passerelle/apps/qrcode/migrations/0001_initial.py new file mode 100644 index 00000000..7a163b7e --- /dev/null +++ b/passerelle/apps/qrcode/migrations/0001_initial.py @@ -0,0 +1,78 @@ +# Generated by Django 3.2.18 on 2023-11-02 09:29 + +import uuid + +import django.core.validators +import django.db.models.deletion +from django.db import migrations, models + +import passerelle.apps.qrcode.models + + +class Migration(migrations.Migration): + initial = True + + dependencies = [ + ('base', '0030_resourcelog_base_resour_appname_298cbc_idx'), + ] + + operations = [ + migrations.CreateModel( + name='QRCodeConnector', + fields=[ + ( + 'id', + models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID'), + ), + ('title', models.CharField(max_length=50, verbose_name='Title')), + ('slug', models.SlugField(unique=True, verbose_name='Identifier')), + ('description', models.TextField(verbose_name='Description')), + ( + 'key', + models.CharField( + default=passerelle.apps.qrcode.models.generate_key, + max_length=64, + validators=[ + django.core.validators.RegexValidator( + '[a-z|0-9]{64}', 'Key should be a 32 bytes hexadecimal string' + ) + ], + verbose_name='Private Key', + ), + ), + ( + 'users', + models.ManyToManyField( + blank=True, + related_name='_qrcode_qrcodeconnector_users_+', + related_query_name='+', + to='base.ApiUser', + ), + ), + ], + options={ + 'verbose_name': 'QR Code', + }, + ), + migrations.CreateModel( + name='Certificate', + fields=[ + ( + 'id', + models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID'), + ), + ('uuid', models.UUIDField(default=uuid.uuid4, unique=True, verbose_name='UUID')), + ('validity_start', models.DateTimeField(verbose_name='Validity Start Date')), + ('validity_end', models.DateTimeField(verbose_name='Validity End Date')), + ('data', models.JSONField(null=True, verbose_name='Certificate Data')), + ( + 'resource', + models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, + related_name='certificates', + to='qrcode.qrcodeconnector', + ), + ), + ], + ), + ] diff --git a/passerelle/apps/qrcode/migrations/__init__.py b/passerelle/apps/qrcode/migrations/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/passerelle/apps/qrcode/models.py b/passerelle/apps/qrcode/models.py new file mode 100644 index 00000000..13e4b17d --- /dev/null +++ b/passerelle/apps/qrcode/models.py @@ -0,0 +1,114 @@ +import os +import uuid + +from django.core.validators import RegexValidator +from django.db import models +from django.shortcuts import get_object_or_404 +from django.utils.dateparse import parse_datetime +from django.utils.translation import gettext_lazy as _ + +from passerelle.base.models import BaseResource +from passerelle.utils.api import endpoint + +CERTIFICATE_SCHEMA = { + '$schema': 'http://json-schema.org/draft-04/schema#', + 'type': 'object', + 'unflatten': True, + 'additionalProperties': False, + 'properties': { + 'data': { + 'type': 'object', + 'title': _('Data to encode in the certificate'), + 'additionalProperties': {'type': 'string'}, + }, + 'validity_start': {'type': 'string', 'format': 'date-time'}, + 'validity_end': {'type': 'string', 'format': 'date-time'}, + }, +} + + +def generate_key(): + key = os.urandom(32) + return ''.join(format(x, '02x') for x in key) + + +UUID_PATTERN = '(?P[0-9|a-f]{8}-[0-9|a-f]{4}-[0-9|a-f]{4}-[0-9|a-f]{4}-[0-9a-f]{12})' + + +class QRCodeConnector(BaseResource): + category = _('Misc') + + key = models.CharField( + _('Private Key'), + max_length=64, + default=generate_key, + validators=[RegexValidator(r'[a-z|0-9]{64}', 'Key should be a 32 bytes hexadecimal string')], + ) + + class Meta: + verbose_name = _('QR Code') + + @endpoint( + name='save-certificate', + pattern=f'^{UUID_PATTERN}?$', + example_pattern='{uuid}', + description=_('Create or update a certificate'), + post={'request_body': {'schema': {'application/json': CERTIFICATE_SCHEMA}}}, + parameters={ + 'uuid': { + 'description': _('Certificate identifier'), + 'example_value': '12345678-1234-1234-1234-123456789012', + } + }, + ) + def save_certificate(self, request, uuid=None, post_data=None): + validity_start = parse_datetime(post_data['validity_start']) + validity_end = parse_datetime(post_data['validity_end']) + data = post_data['data'] + + if not uuid: + certificate = self.certificates.create( + data=data, + validity_start=validity_start, + validity_end=validity_end, + ) + else: + certificate = get_object_or_404(self.certificates, uuid=uuid) + certificate.validity_start = validity_start + certificate.validity_end = validity_end + certificate.data = data + certificate.save() + + return {'data': {'uuid': certificate.uuid}} + + @endpoint( + name='get-certificate', + description=_('Retrieve an existing certificate'), + pattern=f'^{UUID_PATTERN}$', + example_pattern='{uuid}', + parameters={ + 'uuid': { + 'description': _('Certificate identifier'), + 'example_value': '12345678-1234-1234-1234-123456789012', + } + }, + ) + def get_certificate(self, request, uuid): + certificate = get_object_or_404(self.certificates, uuid=uuid) + return { + 'err': 0, + 'data': { + 'uuid': certificate.uuid, + 'data': certificate.data, + 'validity_start': certificate.validity_start.isoformat(), + 'validity_end': certificate.validity_end.isoformat(), + }, + } + + +class Certificate(models.Model): + uuid = models.UUIDField(verbose_name=_('UUID'), unique=True, default=uuid.uuid4) + validity_start = models.DateTimeField(verbose_name=_('Validity Start Date')) + validity_end = models.DateTimeField(verbose_name=_('Validity End Date')) + data = models.JSONField(null=True, verbose_name='Certificate Data') + resource = models.ForeignKey(QRCodeConnector, on_delete=models.CASCADE, related_name='certificates') diff --git a/passerelle/settings.py b/passerelle/settings.py index e7eeabb3..05a51139 100644 --- a/passerelle/settings.py +++ b/passerelle/settings.py @@ -189,6 +189,7 @@ INSTALLED_APPS = ( 'passerelle.apps.twilio', 'passerelle.apps.vivaticket', 'passerelle.apps.sendethic', + 'passerelle.apps.qrcode', # backoffice templates and static 'gadjo', ) diff --git a/tests/test_qrcode.py b/tests/test_qrcode.py new file mode 100644 index 00000000..eae6a0ca --- /dev/null +++ b/tests/test_qrcode.py @@ -0,0 +1,100 @@ +# passerelle - uniform access to multiple data sources and services +# Copyright (C) 2022 Entr'ouvert +# +# This program is free software: you can redistribute it and/or modify it +# under the terms of the GNU Affero General Public License as published +# by the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU Affero General Public License for more details. +# +# You should have received a copy of the GNU Affero General Public License +# along with this program. If not, see . +import datetime +from datetime import timezone + +import pytest + +from passerelle.apps.qrcode.models import QRCodeConnector +from tests.utils import generic_endpoint_url, setup_access_rights + + +@pytest.fixture() +def connector(db): + return setup_access_rights( + QRCodeConnector.objects.create( + slug='test', + key='5e8176e50d45b67e9db875d6006edf3ba805ff4ef4d945327012db4c797be1be', + ) + ) + + +def test_save_certificate(app, connector): + endpoint = generic_endpoint_url('qrcode', 'save-certificate', slug=connector.slug) + + result = app.post_json( + endpoint, + params={ + 'data': { + 'first_name': 'Georges', + 'last_name': 'Abitbol', + }, + 'validity_start': '2022-01-01 10:00:00+00:00', + 'validity_end': '2023-01-01 10:00:00+00:00', + }, + ) + + assert result.json['err'] == 0 + + certificate_uuid = result.json['data']['uuid'] + certificate = connector.certificates.get(uuid=certificate_uuid) + + assert certificate.data['first_name'] == 'Georges' + assert certificate.data['last_name'] == 'Abitbol' + assert certificate.validity_start == datetime.datetime(2022, 1, 1, 10, 0, 0, 0, tzinfo=timezone.utc) + assert certificate.validity_end == datetime.datetime(2023, 1, 1, 10, 0, 0, 0, tzinfo=timezone.utc) + + result = app.post_json( + f'{endpoint}/{certificate_uuid}', + params={ + 'data': { + 'first_name': 'Robert', + 'last_name': 'Redford', + }, + 'validity_start': '2024-01-01T10:00:00+00:00', + 'validity_end': '2025-01-01T10:00:00+00:00', + }, + ) + + certificate.refresh_from_db() + assert certificate.data['first_name'] == 'Robert' + assert certificate.data['last_name'] == 'Redford' + assert certificate.validity_start == datetime.datetime(2024, 1, 1, 10, 0, 0, 0, tzinfo=timezone.utc) + assert certificate.validity_end == datetime.datetime(2025, 1, 1, 10, 0, 0, 0, tzinfo=timezone.utc) + + +def test_get_certificate(app, connector): + certificate = connector.certificates.create( + data={ + 'first_name': 'Georges', + 'last_name': 'Abitbol', + }, + validity_start=datetime.datetime(2022, 1, 1, 10, 0, 0, 0, tzinfo=timezone.utc), + validity_end=datetime.datetime(2023, 1, 1, 10, 0, 0, 0, tzinfo=timezone.utc), + ) + + endpoint = generic_endpoint_url('qrcode', 'get-certificate', slug=connector.slug) + result = app.get(f'{endpoint}/{certificate.uuid}') + + assert result.json == { + 'err': 0, + 'data': { + 'uuid': str(certificate.uuid), + 'data': {'first_name': 'Georges', 'last_name': 'Abitbol'}, + 'validity_start': '2022-01-01T10:00:00+00:00', + 'validity_end': '2023-01-01T10:00:00+00:00', + }, + }