qrcode: add tallying support (#86092)

This commit is contained in:
Corentin Sechet 2024-01-29 08:57:32 +01:00
parent 38d3fbbf4e
commit 4738850fcc
7 changed files with 453 additions and 24 deletions

View File

@ -0,0 +1,46 @@
# Generated by Django 3.2.18 on 2024-02-11 16:30
import uuid
import django.db.models.deletion
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('qrcode', '0005_auto_20231114_1747'),
]
operations = [
migrations.AddField(
model_name='reader',
name='tally',
field=models.BooleanField(default=False, verbose_name='Enable tally for this reader'),
),
migrations.CreateModel(
name='Event',
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')),
('happened', models.DateTimeField()),
('received', models.DateTimeField(auto_now_add=True)),
(
'certificate',
models.ForeignKey(
on_delete=django.db.models.deletion.CASCADE,
related_name='events',
to='qrcode.certificate',
),
),
(
'reader',
models.ForeignKey(
on_delete=django.db.models.deletion.CASCADE, related_name='events', to='qrcode.reader'
),
),
],
),
]

View File

@ -1,9 +1,10 @@
import binascii
import os
import uuid
from datetime import datetime, timezone
from datetime import datetime, timedelta, timezone
from io import BytesIO
from django.core.exceptions import PermissionDenied
from django.core.validators import RegexValidator
from django.db import models
from django.http import HttpResponse
@ -53,6 +54,7 @@ READER_SCHEMA = {
'title': _('Comma-separated list of metadata keys that this reader is allowed to read.'),
'any': [{'type': 'null'}, {'const': ''}, {'type': 'string'}],
},
'enable_tallying': {'title': _('Enable tallying on this reader.'), 'type': 'boolean'},
'validity_start': {
'any': [{'type': 'null'}, {'const': ''}, {'type': 'string', 'format': 'date-time'}],
},
@ -70,6 +72,37 @@ def generate_key():
UUID_PATTERN = '(?P<uuid>[0-9|a-f]{8}-[0-9|a-f]{4}-[0-9|a-f]{4}-[0-9|a-f]{4}-[0-9a-f]{12})'
TALLY_SCHEMA = {
'$schema': 'http://json-schema.org/draft-06/schema#',
'type': 'object',
'unflatten': True,
'additionalProperties': False,
'properties': {
'since': {
'type': 'integer',
'title': _('Get events since this timestamp'),
},
'events': {
'type': 'array',
'title': _('Events to tally'),
'items': {
'type': 'object',
'properties': {
'timestamp': {
'type': 'integer',
'title': _('Timestamp when the QRCode was read'),
},
'certificate': {
'type': 'string',
'title': _('Read certificate'),
'pattern': UUID_PATTERN,
},
},
},
},
},
}
class QRCodeConnector(BaseResource):
category = _('Misc')
@ -213,12 +246,14 @@ class QRCodeConnector(BaseResource):
validity_start=validity_start,
validity_end=validity_end,
readable_metadatas=readable_metadatas,
tally=post_data.get('enable_tallying') or False,
)
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.tally = post_data.get('enable_tallying') or False
reader.save()
return {
@ -278,6 +313,7 @@ class QRCodeConnector(BaseResource):
'verify_key': self.hex_verify_key,
'reader': reader,
'metadata_url': reader.get_metadata_url(request),
'tally_url': reader.get_tally_url(request),
},
)
@ -311,6 +347,55 @@ class QRCodeConnector(BaseResource):
readable_metatadas = reader.readable_metadatas.split(',')
return {'err': 0, 'data': {k: v for k, v in certificate.metadata.items() if k in readable_metatadas}}
@endpoint(
name='tally',
perm='OPEN',
pattern=f'^{UUID_PATTERN}?$',
description=_('Tally and get tallied events'),
post={'request_body': {'schema': {'application/json': TALLY_SCHEMA}}},
)
def tally(self, request, uuid=None, post_data=None):
reader = get_object_or_404(self.readers, uuid=uuid)
if not reader.tally:
raise PermissionDenied('Tallying is not enabled for this reader')
now = datetime.now(timezone.utc)
since_timestamp = post_data.get('since')
if since_timestamp == 0:
since = now - timedelta(days=1)
elif since_timestamp is not None:
since = datetime.fromtimestamp(since_timestamp, timezone.utc) - timedelta(minutes=1)
else:
since = now
stamps = {}
for event in Event.objects.filter(received__gte=since):
stamps[str(event.certificate.uuid)] = 'ok'
for event in post_data.get('events', []):
try:
certificate = self.certificates.get(uuid=event['certificate'])
except Certificate.DoesNotExist:
continue
_, created = Event.objects.get_or_create(
certificate=certificate,
defaults={
'reader': reader,
'happened': datetime.fromtimestamp(event['timestamp'], timezone.utc),
},
)
stamps[str(certificate.uuid)] = 'ok' if created else 'duplicate'
return {
'data': {
'timestamp': int(datetime.timestamp(now)),
'stamps': stamps,
}
}
def encode_mime_like(data):
msg = ''
@ -394,6 +479,7 @@ class Reader(models.Model):
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')
tally = models.BooleanField(verbose_name=_('Enable tally for this reader'), default=False)
def get_url(self, request):
return self._get_endpoint_url(request, 'open-reader')
@ -403,6 +489,9 @@ class Reader(models.Model):
return None
return self._get_endpoint_url(request, 'read-metadata')
def get_tally_url(self, request):
return self._get_endpoint_url(request, 'tally')
def _get_endpoint_url(self, request, endpoint):
relative_url = reverse(
'generic-endpoint',
@ -414,3 +503,11 @@ class Reader(models.Model):
},
)
return request.build_absolute_uri(relative_url)
class Event(models.Model):
uuid = models.UUIDField(verbose_name=_('UUID'), unique=True, default=uuid.uuid4)
certificate = models.ForeignKey(Certificate, on_delete=models.CASCADE, related_name='events')
reader = models.ForeignKey(Reader, on_delete=models.CASCADE, related_name='events')
happened = models.DateTimeField()
received = models.DateTimeField(auto_now_add=True)

View File

@ -147,6 +147,16 @@ qrcode-reader {
font-size: 1.2rem;
}
&--tally-status {
display: grid;
margin-top: 5px;
padding-top: 5px;
border-top: 1px solid var(--gray-light);
&.error {
color: #{$red};
}
}
&--spinner {
grid-area: 1 / 1 / 2 / 3;
justify-self: center;

View File

@ -63,6 +63,7 @@ const validityTemplate = template(`
const dataTemplate = template(`
<div class="qrcode-reader--data-items"></div>
<span class="qrcode-reader--metadata-items"></span>
<span class="qrcode-reader--tally-status"></span>
`)
const dataItemTemplate = template(`
@ -272,6 +273,7 @@ class QRCodeReader extends window.HTMLElement {
const dataItems = dataElement.content.querySelector('.qrcode-reader--data-items')
const metadataItems = dataElement.content.querySelector('.qrcode-reader--metadata-items')
const tallyStatus = dataElement.content.querySelector('.qrcode-reader--tally-status')
for (const [key, value] of Object.entries(data)) {
const dataItem = dataItemTemplate.cloneNode(true)
@ -284,6 +286,10 @@ class QRCodeReader extends window.HTMLElement {
if(this.getAttribute('metadata-url')) {
this.#showMetadata(metadataItems, certificateUUID)
}
if(this.getAttribute('tally-url')) {
this.#showTally(tallyStatus, certificateUUID)
}
}
async #showMetadata(itemsElement, certificateUUID) {
@ -333,6 +339,54 @@ class QRCodeReader extends window.HTMLElement {
return metadata
}
async #showTally(statusElement, certificateUUID) {
const spinner = spinnerTemplate.cloneNode(true)
statusElement.appendChild(spinner.content)
const tallyUrl = this.getAttribute('tally-url')
try {
const response = await fetch(
tallyUrl, {
method: "POST",
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify({
events: [{
certificate: certificateUUID,
timestamp: Math.floor(Date.now() / 1000)}
]}
),
})
statusElement.innerHTML = ''
if(!response.ok) {
statusElement.classList.add('error')
statusElement.innerHTML = `<p>${translate('tally_api_error')}</p>`
}
else {
const json = await response.json()
if(json.data.stamps[certificateUUID] == 'duplicate') {
statusElement.classList.add('error')
statusElement.innerHTML = `<p>${translate('tally_status_already_seen')}</p>`
}
else {
statusElement.innerHTML = `<p>${translate('tally_status_ok')}</p>`
}
}
} catch(err) {
if(err.name == 'NetworkError') {
statusElement.classList.add('error')
statusElement.innerHTML = `<p>${translate('tally_network_error')}</p>`
}
else {
throw err
}
}
}
#showError (message) {
this.#popup.classList.remove('closed')
this.#popup.classList.add('error')

View File

@ -21,6 +21,10 @@
"never": "Jamais",
"not_supported": "{% trans "QR code reader isn\'t supported on your platform. Please update your browser." %}",
"not_yet_valid": "{% trans 'QR code not yet valid' %}",
"tally_api_error": "{% trans 'An api error occured while tallying the QR code.' %}",
"tally_status_already_seen": "{% trans 'This QR code has already been tallied.' %}",
"tally_status_ok": "{% trans 'QR Code tallied.' %}",
"metadata_network_error": "{% trans 'A network error occured while tallying the QR code.' %}",
"to": "{% trans 'To' %}",
"valid": "{% trans 'Valid QR code' %}"
}
@ -36,6 +40,7 @@
{% else %}
<qrcode-reader verify-key="{{ verify_key }}"
{% if metadata_url %}metadata-url="{{metadata_url}}"{% endif %}
{% if tally_url %}tally-url="{{tally_url}}"{% endif %}
></qrcode-reader>
{% endif %}
</body>

View File

@ -346,3 +346,99 @@ qrcodeReaderMetadataTest('qrcode reader show feedback on api error', async ({moc
expect(items.classList.contains('error')).toBe(true)
expect(items.innerText.trim()).toBe('metadata_api_error')
})
const qrcodeReaderTallyTest = qrcodeReaderTest.extend({
loadTally: async ({ task, mock }, use) => {
const loadTally = async (qrCodeData, mockFetch) => {
const { reader, scan } = mock
reader.setAttribute('tally-url', 'https://orson-welles.io')
let resolveLoadingEnded = undefined
const loadingEnded = new Promise((resolve) => resolveLoadingEnded = resolve)
fetch.mockImplementationOnce(async (url, {body}) => {
const observer = new MutationObserver((_, observer) => {
resolveLoadingEnded()
observer.disconnect()
})
observer.observe(reader.querySelector(".qrcode-reader--tally-status"), { childList: true })
return mockFetch(url, JSON.parse(body))
})
await scan(qrCodeData)
await loadingEnded
}
await use(loadTally)
}
})
qrcodeReaderTallyTest('qrcode reader show tally status for unstamped certificates', async ({mock, loadTally}) => {
const { reader, scan } = mock
const nowBackup = Date.now
Date.now = () => 25000
await loadTally(okCodeData, async (url, body) => {
expect(body).toStrictEqual({
events: [{
certificate: '853b9497-6c1a-4ff1-8604-6dc7cca9003c',
timestamp: 25
}]
})
return {
ok: true,
json: async () => ({'err': 0, 'data': {'stamps': {[body.events[0].certificate]: 'ok'}}})
}
})
Date.now = nowBackup
const spinner = reader.querySelector('.qrcode-reader--spinner')
expect(spinner).toBe(null)
const items = reader.querySelector('.qrcode-reader--tally-status')
expect(items.classList.contains('error')).toBe(false)
expect(items.innerText.trim()).toBe('tally_status_ok')
})
qrcodeReaderTallyTest('qrcode reader show error for stamped certificates', async ({mock, loadTally}) => {
const { reader, scan } = mock
await loadTally(okCodeData, async (url, body) => {
return {
ok: true,
json: async () => ({'err': 0, 'data': {'stamps': {[body.events[0].certificate]: 'duplicate'}}})
}
})
const spinner = reader.querySelector('.qrcode-reader--spinner')
expect(spinner).toBe(null)
const items = reader.querySelector('.qrcode-reader--tally-status')
expect(items.classList.contains('error')).toBe(true)
expect(items.innerText.trim()).toBe('tally_status_already_seen')
})
qrcodeReaderTallyTest('qrcode reader show tally when request fails ', async ({mock, loadTally}) => {
const { reader, scan } = mock
await loadTally(okCodeData, async (url, body) => {
throw { name: 'NetworkError' }
})
const spinner = reader.querySelector('.qrcode-reader--spinner')
expect(spinner).toBe(null)
let items = reader.querySelector('.qrcode-reader--tally-status')
expect(items.classList.contains('error')).toBe(true)
expect(items.innerText.trim()).toBe('tally_network_error')
await loadTally(okCodeData, async (url, body) => {
return {
ok: false,
}
})
items = reader.querySelector('.qrcode-reader--tally-status')
expect(items.classList.contains('error')).toBe(true)
expect(items.innerText.trim()).toBe('tally_api_error')
})

View File

@ -13,13 +13,12 @@
#
# You should have received a copy of the GNU Affero General Public License
# along with this program. If not, see <http://www.gnu.org/licenses/>.
import datetime
import uuid
from datetime import timezone
from datetime import datetime, timedelta, timezone
import pytest
from passerelle.apps.qrcode.models import Certificate, QRCodeConnector, Reader
from passerelle.apps.qrcode.models import Certificate, Event, QRCodeConnector, Reader
from tests.utils import generic_endpoint_url, setup_access_rights
@ -57,9 +56,10 @@ 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)
assert certificate.validity_start == datetime(2022, 1, 1, 10, 0, 0, 0, tzinfo=timezone.utc)
assert certificate.validity_end == datetime(2023, 1, 1, 10, 0, 0, 0, tzinfo=timezone.utc)
result = app.post_json(
f'{endpoint}/{certificate_uuid}',
@ -76,8 +76,8 @@ def test_save_certificate(app, connector):
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)
assert certificate.validity_start == datetime(2024, 1, 1, 10, 0, 0, 0, tzinfo=timezone.utc)
assert certificate.validity_end == datetime(2025, 1, 1, 10, 0, 0, 0, tzinfo=timezone.utc)
def test_get_certificate(app, connector):
@ -86,8 +86,8 @@ def test_get_certificate(app, connector):
'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),
validity_start=datetime(2022, 1, 1, 10, 0, 0, 0, tzinfo=timezone.utc),
validity_end=datetime(2023, 1, 1, 10, 0, 0, 0, tzinfo=timezone.utc),
)
endpoint = generic_endpoint_url('qrcode', 'get-certificate', slug=connector.slug)
@ -112,8 +112,8 @@ def test_get_qrcode(app, connector):
'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),
validity_start=datetime(2022, 1, 1, 10, 0, 0, 0, tzinfo=timezone.utc),
validity_end=datetime(2023, 1, 1, 10, 0, 0, 0, tzinfo=timezone.utc),
)
endpoint = generic_endpoint_url('qrcode', 'get-qrcode', slug=connector.slug)
@ -133,6 +133,7 @@ def test_save_reader(app, connector):
'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',
'enable_tallying': True,
},
)
@ -142,9 +143,10 @@ def test_save_reader(app, connector):
assert result.json['data']['url'] == f'http://testserver/qrcode/test/open-reader/{reader_uuid}'
reader = connector.readers.get(uuid=reader_uuid)
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'
assert reader.validity_start == datetime(2022, 1, 1, 10, 0, 0, 0, tzinfo=timezone.utc)
assert reader.validity_end == datetime(2023, 1, 1, 10, 0, 0, 0, tzinfo=timezone.utc)
assert reader.tally
result = app.post_json(
f'{endpoint}/{reader_uuid}',
@ -155,14 +157,15 @@ def test_save_reader(app, connector):
)
reader.refresh_from_db()
assert reader.validity_start == datetime.datetime(2024, 1, 1, 10, 0, 0, 0, tzinfo=timezone.utc)
assert reader.validity_end == datetime.datetime(2025, 1, 1, 10, 0, 0, 0, tzinfo=timezone.utc)
assert reader.validity_start == datetime(2024, 1, 1, 10, 0, 0, 0, tzinfo=timezone.utc)
assert reader.validity_end == datetime(2025, 1, 1, 10, 0, 0, 0, tzinfo=timezone.utc)
assert not reader.tally
def test_get_reader(app, connector):
reader = connector.readers.create(
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),
validity_start=datetime(2022, 1, 1, 10, 0, 0, 0, tzinfo=timezone.utc),
validity_end=datetime(2023, 1, 1, 10, 0, 0, 0, tzinfo=timezone.utc),
)
endpoint = generic_endpoint_url('qrcode', 'get-reader', slug=connector.slug)
@ -181,8 +184,8 @@ def test_get_reader(app, connector):
def test_open_reader(app, connector, freezer):
reader = connector.readers.create(
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),
validity_start=datetime(2022, 1, 1, 10, 0, 0, 0, tzinfo=timezone.utc),
validity_end=datetime(2023, 1, 1, 10, 0, 0, 0, tzinfo=timezone.utc),
)
endpoint = generic_endpoint_url('qrcode', 'open-reader', slug=connector.slug)
@ -196,6 +199,12 @@ def test_open_reader(app, connector, freezer):
assert result.pyquery(f'qrcode-reader[verify-key="{connector.hex_verify_key}"]')
reader.tally = True
reader.save()
result = app.get(f'{endpoint}/{reader.uuid}')
assert result.pyquery(f'qrcode-reader[verify-key="{connector.hex_verify_key}"][tally-url]')
freezer.move_to('2023-01-01T10:00:01')
result = app.get(f'{endpoint}/{reader.uuid}')
@ -205,15 +214,15 @@ def test_open_reader(app, connector, freezer):
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),
validity_start=datetime(2022, 1, 1, 10, 0, 0, 0, tzinfo=timezone.utc),
validity_end=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),
validity_start=datetime(2022, 1, 1, 10, 0, 0, 0, tzinfo=timezone.utc),
validity_end=datetime(2023, 1, 1, 10, 0, 0, 0, tzinfo=timezone.utc),
)
endpoint = generic_endpoint_url('qrcode', 'read-metadata', slug=connector.slug)
@ -236,6 +245,118 @@ def test_read_metadata(app, connector, freezer):
assert result.json['err_desc'] == 'Reader has expired.'
def test_tally_query_events(app, connector, freezer):
first_reader = connector.readers.create(tally=True)
second_reader = connector.readers.create(tally=True)
first_certificate = connector.certificates.create()
second_certificate = connector.certificates.create()
freezer.move_to('2000-01-01T11:59:59')
yesterday = datetime.now(timezone.utc)
Event.objects.create(
reader=first_reader,
certificate=first_certificate,
happened=yesterday - timedelta(minutes=1),
)
freezer.move_to('2000-01-02T11:00:00')
an_hour_ago = datetime.now(timezone.utc)
Event.objects.create(
reader=second_reader,
certificate=second_certificate,
# Far in the past to check that date taken into account is the received
# date, not the happened one.
happened=an_hour_ago - timedelta(days=1),
)
freezer.move_to('2000-01-02T12:00:00')
now = datetime.now(timezone.utc)
endpoint = generic_endpoint_url('qrcode', 'tally', slug=connector.slug)
result = app.post_json(
f'{endpoint}/{first_reader.uuid}',
params={'since': 0, 'events': []},
)
assert result.json['err'] == 0
assert result.json['data'] == {
'timestamp': int(datetime.timestamp(now)),
'stamps': {str(second_certificate.uuid): 'ok'},
}
result = app.post_json(
f'{endpoint}/{first_reader.uuid}',
params={
'since': int(datetime.timestamp(yesterday)),
},
)
assert result.json['err'] == 0
assert result.json['data'] == {
'timestamp': int(datetime.timestamp(now)),
'stamps': {
str(first_certificate.uuid): 'ok',
str(second_certificate.uuid): 'ok',
},
}
def test_tally_save_events(app, connector, freezer):
freezer.move_to('2023-01-01T10:00:01')
reader = connector.readers.create(tally=True)
now = datetime.now(timezone.utc)
a_minute_ago = now - timedelta(minutes=1)
certificate = connector.certificates.create()
stamped_certificate = connector.certificates.create()
Event.objects.create(
reader=reader,
certificate=stamped_certificate,
happened=a_minute_ago,
)
endpoint = generic_endpoint_url('qrcode', 'tally', slug=connector.slug)
result = app.post_json(
f'{endpoint}/{reader.uuid}',
params={
'since': 0,
'events': [
{
'certificate': str(certificate.uuid),
'timestamp': datetime.timestamp(a_minute_ago),
},
{
'certificate': 'deadbeef-0000-0000-0000-000000000000',
'timestamp': datetime.timestamp(a_minute_ago),
},
{
'certificate': str(stamped_certificate.uuid),
'timestamp': datetime.timestamp(now),
},
],
},
)
assert result.json['data'] == {
'timestamp': int(datetime.timestamp(now)),
'stamps': {
str(certificate.uuid): 'ok',
str(stamped_certificate.uuid): 'duplicate',
},
}
events = list(certificate.events.all())
assert len(events) == 1
assert events[0].reader == reader
assert events[0].certificate == certificate
assert events[0].happened == a_minute_ago
assert events[0].received == now
events = list(stamped_certificate.events.all())
assert len(events) == 1
MISSING = object()