qrcode: add metadata support (#82653)
gitea/passerelle/pipeline/head This commit looks good Details

This commit is contained in:
Corentin Sechet 2023-11-14 18:30:31 +01:00 committed by Corentin Sechet
parent cd0f441d3b
commit 36dfa9508e
7 changed files with 317 additions and 3 deletions

View File

@ -0,0 +1,22 @@
# Generated by Django 3.2.18 on 2023-11-14 16:47
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('qrcode', '0004_validity_is_optional'),
]
operations = [
migrations.AddField(
model_name='certificate',
name='metadata',
field=models.JSONField(null=True, verbose_name='Certificate meta data'),
),
migrations.AddField(
model_name='reader',
name='readable_metadatas',
field=models.CharField(max_length=128, null=True, verbose_name='Readable metadata keys'),
),
]

View File

@ -30,6 +30,11 @@ CERTIFICATE_SCHEMA = {
'title': _('Data to encode in the certificate'),
'additionalProperties': {'type': 'string'},
},
'metadata': {
'type': 'object',
'title': _('Metadata associated to the certificate'),
'additionalProperties': {'type': 'string'},
},
'validity_start': {
'any': [{'type': 'null'}, {'const': ''}, {'type': 'string', 'format': 'date-time'}],
},
@ -44,6 +49,10 @@ READER_SCHEMA = {
'type': 'object',
'additionalProperties': False,
'properties': {
'readable_metadatas': {
'title': _('Comma-separated list of metadata keys that this reader is allowed to read.'),
'any': [{'type': 'null'}, {'const': ''}, {'type': 'string'}],
},
'validity_start': {
'any': [{'type': 'null'}, {'const': ''}, {'type': 'string', 'format': 'date-time'}],
},
@ -108,10 +117,12 @@ class QRCodeConnector(BaseResource):
else:
validity_end = None
data = post_data.get('data') or {}
metadata = post_data.get('metadata') or {}
if not uuid:
certificate = self.certificates.create(
data=data,
metadata=metadata,
validity_start=validity_start,
validity_end=validity_end,
)
@ -120,6 +131,7 @@ class QRCodeConnector(BaseResource):
certificate.validity_start = validity_start
certificate.validity_end = validity_end
certificate.data = data
certificate.metadata = metadata
certificate.save()
return {
@ -194,15 +206,19 @@ class QRCodeConnector(BaseResource):
else:
validity_end = None
readable_metadatas = post_data.get('readable_metadatas', '')
if not uuid:
reader = self.readers.create(
validity_start=validity_start,
validity_end=validity_end,
readable_metadatas=readable_metadatas,
)
else:
reader = get_object_or_404(self.readers, uuid=uuid)
reader.validity_start = validity_start
reader.validity_end = validity_end
reader.readable_metadatas = readable_metadatas
reader.save()
return {
@ -252,6 +268,7 @@ class QRCodeConnector(BaseResource):
def open_reader(self, request, uuid):
reader = get_object_or_404(self.readers, uuid=uuid)
now = datetime.now(timezone.utc)
return TemplateResponse(
request,
'qrcode/qrcode-reader.html',
@ -260,9 +277,40 @@ class QRCodeConnector(BaseResource):
'expired': now >= reader.validity_end if reader.validity_end is not None else False,
'verify_key': self.hex_verify_key,
'reader': reader,
'metadata_url': reader.get_metadata_url(request),
},
)
@endpoint(
name='read-metadata',
perm='OPEN',
description=_('Read certificate metadata'),
pattern=f'^{UUID_PATTERN}$',
example_pattern='{uuid}',
parameters={
'uuid': {
'description': _('QRCode reader identifier'),
'example_value': '12345678-1234-1234-1234-123456789012',
},
'certificate': {
'description': _('Certificate identifier'),
'example_value': '12345678-1234-1234-1234-123456789012',
},
},
)
def read_metadata(self, request, uuid, certificate):
reader = get_object_or_404(self.readers, uuid=uuid)
certificate = get_object_or_404(self.certificates, uuid=certificate)
now = datetime.now(timezone.utc)
if reader.validity_start is not None and now < reader.validity_start:
return {'err': 1, 'err_desc': _("Reader isn't usable yet.")}
if reader.validity_end is not None and now > reader.validity_end:
return {'err': 1, 'err_desc': _('Reader has expired.')}
readable_metatadas = reader.readable_metadatas.split(',')
return {'err': 0, 'data': {k: v for k, v in certificate.metadata.items() if k in readable_metatadas}}
def encode_mime_like(data):
msg = ''
@ -297,6 +345,7 @@ class Certificate(models.Model):
validity_start = models.DateTimeField(verbose_name=_('Validity Start Date'), null=True)
validity_end = models.DateTimeField(verbose_name=_('Validity End Date'), null=True)
data = models.JSONField(null=True, verbose_name='Certificate Data')
metadata = models.JSONField(null=True, verbose_name='Certificate meta data')
resource = models.ForeignKey(QRCodeConnector, on_delete=models.CASCADE, related_name='certificates')
def to_json(self):
@ -343,15 +392,24 @@ class Reader(models.Model):
modified = models.DateTimeField(verbose_name=_('Last modification'), auto_now=True)
validity_start = models.DateTimeField(verbose_name=_('Validity Start Date'), null=True)
validity_end = models.DateTimeField(verbose_name=_('Validity End Date'), null=True)
readable_metadatas = models.CharField(max_length=128, verbose_name=_('Readable metadata keys'), null=True)
resource = models.ForeignKey(QRCodeConnector, on_delete=models.CASCADE, related_name='readers')
def get_url(self, request):
return self._get_endpoint_url(request, 'open-reader')
def get_metadata_url(self, request):
if not self.readable_metadatas:
return None
return self._get_endpoint_url(request, 'read-metadata')
def _get_endpoint_url(self, request, endpoint):
relative_url = reverse(
'generic-endpoint',
kwargs={
'slug': self.resource.slug,
'connector': self.resource.get_connector_slug(),
'endpoint': 'open-reader',
'endpoint': endpoint,
'rest': str(self.uuid),
},
)

View File

@ -119,12 +119,16 @@ qrcode-reader {
align-self: end;
}
&--data-items {
&--data-items, &--metadata-items {
display: grid;
grid-template-columns: auto 1fr;
margin-top: 5px;
padding-top: 5px;
border-top: 1px solid var(--gray-light);
&.error {
color: #{$red};
}
}
&--data-item-label {
@ -142,4 +146,18 @@ qrcode-reader {
padding: 5px;
font-size: 1.2rem;
}
&--spinner {
grid-area: 1 / 1 / 2 / 3;
justify-self: center;
}
&--spinner-animation {
transform-origin:center;
animation:spinner-keyframes .75s infinite linear;
}
}
@keyframes spinner-keyframes {
100% { transform:rotate(360deg); }
}

View File

@ -62,6 +62,7 @@ const validityTemplate = template(`
const dataTemplate = template(`
<div class="qrcode-reader--data-items"></div>
<span class="qrcode-reader--metadata-items"></span>
`)
const dataItemTemplate = template(`
@ -69,6 +70,15 @@ const dataItemTemplate = template(`
<span class="qrcode-reader--data-item-value">{value}</span>
`)
const spinnerTemplate = template(`
<svg width="24" height="24" viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg" class="qrcode-reader--spinner">
<path d="M12,1A11,11,0,1,0,23,12,11,11,0,0,0,12,1Zm0,19a8,8,0,1,1,8-8A8,8,0,0,1,12,20Z" opacity=".25"/>
<path
d="M10.14,1.16a11,11,0,0,0-9,8.92A1.59,1.59,0,0,0,2.46,12,1.52,1.52,0,0,0,4.11,10.7a8,8,0,0,1,6.66-6.61A1.42,1.42,0,0,0,12,2.69h0A1.57,1.57,0,0,0,10.14,1.16Z"
class="qrcode-reader--spinner-animation"/>
</svg>
`)
function decodeMimeLike (value) {
const chunks = value.split('\n')
const data = {}
@ -221,6 +231,7 @@ class QRCodeReader extends window.HTMLElement {
const decoder = new TextDecoder('utf-8')
const decoded = decoder.decode(opened)
const data = decodeMimeLike(decoded)
const certificateUUID = data.uuid
delete data.uuid
@ -260,6 +271,7 @@ class QRCodeReader extends window.HTMLElement {
const dataElement = dataTemplate.cloneNode(true)
const dataItems = dataElement.content.querySelector('.qrcode-reader--data-items')
const metadataItems = dataElement.content.querySelector('.qrcode-reader--metadata-items')
for (const [key, value] of Object.entries(data)) {
const dataItem = dataItemTemplate.cloneNode(true)
@ -268,6 +280,57 @@ class QRCodeReader extends window.HTMLElement {
}
this.#popupContent.append(dataElement.content)
if(this.getAttribute('metadata-url')) {
this.#showMetadata(metadataItems, certificateUUID)
}
}
async #showMetadata(itemsElement, certificateUUID) {
const spinner = spinnerTemplate.cloneNode(true)
itemsElement.appendChild(spinner.content)
const metadata = await this.#fetchMetadata(itemsElement, certificateUUID)
if(!metadata) {
return
}
itemsElement.innerText = ""
for (const [key, value] of Object.entries(metadata)) {
const dataItem = dataItemTemplate.cloneNode(true)
dataItem.innerHTML = dataItem.innerHTML.replace('{label}', key).replace('{value}', value)
itemsElement.append(dataItem.content)
}
}
async #fetchMetadata(itemsElement, certificateUUID) {
const url = `${this.getAttribute('metadata-url')}?certificate=${certificateUUID}`
let metadata = undefined
try {
const response = await fetch(url)
const content = await response.json()
if(response.ok && content.err == 0) {
metadata = content.data
}
else {
itemsElement.classList.add('error')
itemsElement.innerHTML = `
<p>${translate('metadata_api_error')}</p>
`
}
} catch(err) {
if(err.name == 'NetworkError') {
itemsElement.classList.add('error')
itemsElement.innerHTML = `<p>${translate('metadata_network_error')}</p>`
}
else {
throw err
}
}
return metadata
}
#showError (message) {

View File

@ -12,6 +12,8 @@
"always": "Toujours",
"close": "{% trans 'Close' %}",
"expired": "{% trans 'QR code Expired' %}",
"metadata_network_error": "{% trans 'A network error occured while fetching metadata : check network connectivity.' %}",
"metadata_api_error": "{% trans 'An api error occured while fetching metadata.' %}",
"from": "{% trans 'From' %}",
"invalid_content": "{% trans "This QR code isn't supported by this application." %}",
"invalid_signature": "{% trans 'Signature verification failed.' %}",
@ -32,7 +34,9 @@
{% elif expired %}
{% trans "Reader has expired." %}
{% else %}
<qrcode-reader verify-key="{{ verify_key }}"></qrcode-reader>
<qrcode-reader verify-key="{{ verify_key }}"
{% if metadata_url %}metadata-url="{{metadata_url}}"{% endif %}
></qrcode-reader>
{% endif %}
</body>
</html>

View File

@ -63,6 +63,9 @@ const qrcodeReaderTest = test.extend({
reader.setAttribute('verify-key', '15dbdd38a2d8a2db1b4dd985da8da2b4e6b785b28db3fe0b34a10cfb3ba0aeb3')
document.append(reader)
const fetchBackup = global.fetch
global.fetch = vi.fn()
await use({
reader,
scan: async (text) => {
@ -73,6 +76,8 @@ const qrcodeReaderTest = test.extend({
})
vi.useFakeTimers()
global.fetch = fetchBackup
reader.remove()
vi.useRealTimers ()
@ -235,3 +240,109 @@ qrcodeReaderTest('qrcode reader accepts certificate without validity dates', asy
expect(title.innerText).toBe('valid')
expect(validity.innerText.trim().split(/\s+/)).toStrictEqual(["from", ":", "always", "to", ":", "never"])
})
const qrcodeReaderMetadataTest = qrcodeReaderTest.extend({
loadMetadata: async ({ task, mock }, use) => {
const loadMetadata = async (qrCodeData, mockFetch) => {
const { reader, scan } = mock
reader.setAttribute('metadata-url', 'https://orson-welles.io')
let resolveLoadingEnded = undefined
const loadingEnded = new Promise((resolve) => resolveLoadingEnded = resolve)
fetch.mockImplementationOnce(async (url) => {
const observer = new MutationObserver((_, observer) => {
resolveLoadingEnded()
observer.disconnect()
})
observer.observe(reader.querySelector(".qrcode-reader--metadata-items"), { childList: true })
return mockFetch(url)
})
await scan(qrCodeData)
await loadingEnded
}
await use(loadMetadata)
}
})
qrcodeReaderMetadataTest('qrcode reader fetches metadata', async ({mock, loadMetadata}) => {
const {reader} = mock
await loadMetadata(okCodeData, async (url) => {
expect(url).toBe('https://orson-welles.io?certificate=853b9497-6c1a-4ff1-8604-6dc7cca9003c')
const spinner = reader.querySelector('.qrcode-reader--spinner')
expect(spinner).not.toBe(null)
return {
ok: true,
json: () => new Promise((resolve) => {
resolve({'err': 0, 'data': {'Classe': 'Oui', 'Ravioles': 'Non'}})
})
}
})
const spinner = reader.querySelector('.qrcode-reader--spinner')
expect(spinner).toBe(null)
const labels = reader.querySelectorAll('.qrcode-reader--metadata-items .qrcode-reader--data-item-label')
expect(labels.length).toBe(2)
expect(labels[0].innerText).toMatch('Classe')
expect(labels[1].innerText).toMatch('Ravioles')
const values = reader.querySelectorAll('.qrcode-reader--metadata-items .qrcode-reader--data-item-value')
expect(values.length).toBe(2)
expect(values[0].innerText).toMatch('Oui')
expect(values[1].innerText).toMatch('Non')
})
qrcodeReaderMetadataTest('qrcode reader show feedback on metadata network error', async ({mock, loadMetadata}) => {
const { reader, scan } = mock
await loadMetadata(okCodeData, async (url) => {
throw { name: 'NetworkError' }
})
const spinner = reader.querySelector('.qrcode-reader--spinner')
expect(spinner).toBe(null)
const items = reader.querySelector('.qrcode-reader--metadata-items')
expect(items.classList.contains('error')).toBe(true)
expect(items.innerText).toBe('metadata_network_error')
})
qrcodeReaderMetadataTest('qrcode reader show feedback on http error', async ({mock, loadMetadata}) => {
const { reader, scan } = mock
await loadMetadata(okCodeData, async (url) => {
return {
ok: false,
json: () => new Promise((resolve) => resolve({}))
}
})
const spinner = reader.querySelector('.qrcode-reader--spinner')
expect(spinner).toBe(null)
const items = reader.querySelector('.qrcode-reader--metadata-items')
expect(items.classList.contains('error')).toBe(true)
expect(items.innerText.trim()).toBe('metadata_api_error')
})
qrcodeReaderMetadataTest('qrcode reader show feedback on api error', async ({mock, loadMetadata}) => {
const { reader, scan } = mock
await loadMetadata(okCodeData, async (url) => {
return {
ok: true,
json: () => new Promise((resolve) => {
resolve({'err': 1})
})
}
})
const spinner = reader.querySelector('.qrcode-reader--spinner')
expect(spinner).toBe(null)
const items = reader.querySelector('.qrcode-reader--metadata-items')
expect(items.classList.contains('error')).toBe(true)
expect(items.innerText.trim()).toBe('metadata_api_error')
})

View File

@ -43,6 +43,7 @@ def test_save_certificate(app, connector):
'first_name': 'Georges',
'last_name': 'Abitbol',
},
'metadata': {'puissance_intellectuelle': 'BAC +2'},
'validity_start': '2022-01-01 10:00:00+00:00',
'validity_end': '2023-01-01 10:00:00+00:00',
},
@ -56,6 +57,7 @@ def test_save_certificate(app, connector):
assert certificate.data['first_name'] == 'Georges'
assert certificate.data['last_name'] == 'Abitbol'
assert certificate.metadata['puissance_intellectuelle'] == 'BAC +2'
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)
@ -130,6 +132,7 @@ def test_save_reader(app, connector):
params={
'validity_start': '2022-01-01 10:00:00+00:00',
'validity_end': '2023-01-01 10:00:00+00:00',
'readable_metadatas': 'name,last_name',
},
)
@ -141,6 +144,7 @@ def test_save_reader(app, connector):
assert reader.validity_start == datetime.datetime(2022, 1, 1, 10, 0, 0, 0, tzinfo=timezone.utc)
assert reader.validity_end == datetime.datetime(2023, 1, 1, 10, 0, 0, 0, tzinfo=timezone.utc)
assert reader.readable_metadatas == 'name,last_name'
result = app.post_json(
f'{endpoint}/{reader_uuid}',
@ -198,6 +202,40 @@ def test_open_reader(app, connector, freezer):
assert 'Reader has expired.' in result.body.decode('utf-8')
def test_read_metadata(app, connector, freezer):
reader = connector.readers.create(
readable_metadatas='first_name,puissance_intellectuelle',
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),
)
certificate = connector.certificates.create(
uuid=uuid.UUID('12345678-1234-5678-1234-567812345678'),
metadata={'first_name': 'Georges', 'last_name': 'Abitbol', 'puissance_intellectuelle': 'BAC +2'},
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', 'read-metadata', slug=connector.slug)
freezer.move_to('2022-01-01T09:59:59')
result = app.get(f'{endpoint}/{reader.uuid}', params={'certificate': certificate.uuid})
assert result.json['err'] == 1
assert result.json['err_desc'] == 'Reader isn\'t usable yet.'
freezer.move_to('2022-01-01T10:00:00')
result = app.get(f'{endpoint}/{reader.uuid}', params={'certificate': certificate.uuid})
assert result.json['err'] == 0
assert result.json['data'] == {'first_name': 'Georges', 'puissance_intellectuelle': 'BAC +2'}
freezer.move_to('2023-01-01T10:00:01')
result = app.get(f'{endpoint}/{reader.uuid}', params={'certificate': certificate.uuid})
assert result.json['err'] == 1
assert result.json['err_desc'] == 'Reader has expired.'
MISSING = object()