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',
+ },
+ }