diff --git a/django_walletpass/admin.py b/django_walletpass/admin.py index a720e15..f9a53e9 100644 --- a/django_walletpass/admin.py +++ b/django_walletpass/admin.py @@ -1,6 +1,104 @@ from django.contrib import admin -from django_walletpass.models import Pass, Registration, Log +from django.template import Context, Template +from django.urls import reverse +from django.utils.html import format_html -admin.site.register(Pass) -admin.site.register(Registration) -admin.site.register(Log) +from django_walletpass.models import Log, Pass, Registration + + +@admin.register(Log) +class LogAdmin(admin.ModelAdmin): + list_display = ( + "created_at", + "status", + "task_type", + # "pass_type_identifier", + # "serial_number", + "pass_", + # "web_service_url", + "device_id", + "msg", + ) + list_filter = ("status", "task_type", "pass_type_identifier") + search_fields = ( + "pass_type_identifier", + "serial_number", + "device_id", + "msg", + "message", + ) + readonly_fields = ("created_at", "pass_") + raw_id_fields = ("pazz",) + list_select_related = ("pazz",) + + def pass_(self, obj: Log): + if obj.pazz_id: + url = reverse( + "admin:%s_%s_change" + % (obj.pazz._meta.app_label, obj.pazz._meta.model_name), + args=[obj.pazz_id], + ) + return format_html( + "{title}", + url=url, + title=obj.serial_number, + ) + return obj.serial_number + + pass_.short_description = "Pass" + + +@admin.register(Pass) +class PassAdmin(admin.ModelAdmin): + list_display = ( + "serial_number", + "updated_at", + "pass_type_identifier", + "wallet_pass_", + ) + search_fields = ( + "serial_number", + "pass_type_identifier", + "authentication_token", + "data", + ) + list_filter = ("pass_type_identifier", "updated_at") + date_hierarchy = "updated_at" + readonly_fields = ("wallet_pass_", "updated_at") + + def wallet_pass_(self, obj: Pass): + if obj.data: + return format_html( + Template( + "{% load static %}" + ).render(Context({})), + url=obj.data.url, + title=obj.data.name, + ) + return + + wallet_pass_.short_description = "Pass" + + +@admin.register(Registration) +class RegistrationAdmin(admin.ModelAdmin): + list_display = ("device_library_identifier", "push_token", "pass_") + search_fields = ("device_library_identifier", "push_token", "pazz__serial_number") + raw_id_fields = ("pazz",) + readonly_fields = ("pass_",) + + def pass_(self, obj: Registration): + if obj.pazz_id: + url = reverse( + "admin:%s_%s_change" + % (obj.pazz._meta.app_label, obj.pazz._meta.model_name), + args=[obj.pazz_id], + ) + return format_html( + "{title}", + url=url, + title=obj.pazz.serial_number, + ) + return + + pass_.short_description = "Pass" diff --git a/django_walletpass/apps.py b/django_walletpass/apps.py index a715e0f..10d6127 100644 --- a/django_walletpass/apps.py +++ b/django_walletpass/apps.py @@ -3,6 +3,7 @@ class DjangoWalletpassConfig(AppConfig): name = 'django_walletpass' + verbose_name = 'Django walletpass' def ready(self): from django_walletpass import signals as _signals # pylint: disable=import-outside-toplevel diff --git a/django_walletpass/classviews.py b/django_walletpass/classviews.py index d7813a3..3bffc6b 100644 --- a/django_walletpass/classviews.py +++ b/django_walletpass/classviews.py @@ -142,5 +142,6 @@ class LogViewSet(viewsets.ViewSet): def create(self, request): json_body = json.loads(request.body) for message in json_body['logs']: - Log(message=message).save() + log = Log(message=message) + Log.parse_log(log, message) return Response({}, status=status.HTTP_200_OK) diff --git a/django_walletpass/migrations/0009_auto_20230709_2143.py b/django_walletpass/migrations/0009_auto_20230709_2143.py new file mode 100644 index 0000000..6d21b0e --- /dev/null +++ b/django_walletpass/migrations/0009_auto_20230709_2143.py @@ -0,0 +1,66 @@ +# Generated by Django 3.2.14 on 2023-07-09 18:43 + +from django.db import migrations, models +import django.db.models.deletion +import django.utils.timezone +import django_walletpass.storage + + +class Migration(migrations.Migration): + + dependencies = [ + ('django_walletpass', '0008_alter_pass_data'), + ] + + operations = [ + migrations.AddField( + model_name='log', + name='created_at', + field=models.DateTimeField(default=django.utils.timezone.now), + ), + migrations.AddField( + model_name='log', + name='device_id', + field=models.CharField(blank=True, max_length=255, null=True), + ), + migrations.AddField( + model_name='log', + name='msg', + field=models.TextField(blank=True, null=True), + ), + migrations.AddField( + model_name='log', + name='pass_type_identifier', + field=models.CharField(blank=True, max_length=255, null=True), + ), + migrations.AddField( + model_name='log', + name='pazz', + field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, related_name='logs', to='django_walletpass.pass'), + ), + migrations.AddField( + model_name='log', + name='serial_number', + field=models.CharField(blank=True, max_length=255, null=True), + ), + migrations.AddField( + model_name='log', + name='status', + field=models.CharField(blank=True, max_length=100, null=True), + ), + migrations.AddField( + model_name='log', + name='task_type', + field=models.CharField(blank=True, max_length=255, null=True), + ), + migrations.AddField( + model_name='log', + name='web_service_url', + field=models.URLField(blank=True, null=True), + ), + migrations.AlterField( + model_name='pass', + name='data', + field=models.FileField(storage=django_walletpass.storage.WalletPassStorage(), upload_to='passes'), + ), + ] diff --git a/django_walletpass/migrations/0010_alter_registration_push_token.py b/django_walletpass/migrations/0010_alter_registration_push_token.py new file mode 100644 index 0000000..9862c75 --- /dev/null +++ b/django_walletpass/migrations/0010_alter_registration_push_token.py @@ -0,0 +1,18 @@ +# Generated by Django 3.2.14 on 2023-07-13 10:41 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('django_walletpass', '0009_auto_20230709_2143'), + ] + + operations = [ + migrations.AlterField( + model_name='registration', + name='push_token', + field=models.CharField(max_length=255), + ), + ] diff --git a/django_walletpass/models.py b/django_walletpass/models.py index a96c968..d35a95f 100644 --- a/django_walletpass/models.py +++ b/django_walletpass/models.py @@ -1,4 +1,6 @@ +import datetime import os +import re import uuid import hashlib import json @@ -7,6 +9,7 @@ import zipfile from glob import glob from django.core.exceptions import ValidationError +from django.utils import timezone from django.utils.module_loading import import_string from django.db import models from django.utils.translation import gettext_lazy as _ @@ -289,6 +292,9 @@ def get_pass_builder(self): def __unicode__(self): return self.serial_number + def __str__(self): + return self.serial_number + class Meta: verbose_name_plural = "passes" unique_together = ( @@ -302,7 +308,7 @@ class Registration(models.Model): Registration of a Pass on a device """ device_library_identifier = models.CharField(max_length=150) - push_token = models.CharField(max_length=150) + push_token = models.CharField(max_length=255) pazz = models.ForeignKey( Pass, on_delete=models.CASCADE, @@ -312,12 +318,83 @@ class Registration(models.Model): def __unicode__(self): return self.device_library_identifier + def __str__(self): + return self.device_library_identifier + class Log(models.Model): """ Log message sent by a device """ + created_at = models.DateTimeField(default=timezone.now) + status = models.CharField(max_length=100, null=True, blank=True) + task_type = models.CharField(max_length=255, null=True, blank=True) + pass_type_identifier = models.CharField(max_length=255, null=True, blank=True) + serial_number = models.CharField(max_length=255, null=True, blank=True) + pazz = models.ForeignKey(Pass, null=True, blank=True, on_delete=models.CASCADE, related_name='logs') + web_service_url = models.URLField(null=True, blank=True) + device_id = models.CharField(max_length=255, null=True, blank=True) + msg = models.TextField(null=True, blank=True) message = models.TextField() def __unicode__(self): return self.message + + def __str__(self): + return self.created_at.strftime('%d/%m/%y %H:%M:%S') + + @classmethod + def parse_log(cls, log, message): + pattern_register = r"\[(.*?)\]\s(.*?)\s\(for device (.*?), pass type (.*?), serial number (.*?); with web service url (.*?)\)\s(.*?): (.*$)" + pattern_get = r"\[(.*?)\]\s(.*?)\s\(pass type (.*?), serial number (.*?), if-modified-since \(.*?\); with web service url (.*?)\) (.*?): (.*$)" + pattern_web_service_error = r"\[(.*?)\]\s(.*?)\sfor (.*?)\s\((.*?)\):\s(.*$)" + pattern_get_warning = r"\[(.*?)\]\s(.*?)\s\(pass type (.*?), serial number (.*?), if-modified-since \(.*?\); with web service url (.*?)\) (.*?): (.*\.)\s(.*$)" + + match_register = re.match(pattern_register, message) + match_get = re.match(pattern_get, message) + match_web_service_error = re.match(pattern_web_service_error, message) + match_get_warning = re.match(pattern_get_warning, message) + + if match_register: + timestamp_str, task_type, device_id, pass_type_identifier, serial_number, web_service_url, status, msg = match_register.groups() + elif match_get: + timestamp_str, task_type, pass_type_identifier, serial_number, web_service_url, status, msg = match_get.groups() + device_id = None # 'Get pass task' entries don't include device_id + elif match_web_service_error: + timestamp_str, task_type, pass_type_identifier, web_service_url, msg = match_web_service_error.groups() + serial_number = None + device_id = None + status = "error" + elif match_get_warning: + timestamp_str, task_type, pass_type_identifier, serial_number, web_service_url, status, msg = match_get_warning.groups() + device_id = None + status = "warning" + else: + log.status = 'unknown' + log.message = message + log.save() + return # Log entry didn't match any known pattern + + if 'error' in status: + status = 'error' + elif 'warning' in status: + status = 'warning' + + log.created_at = datetime.datetime.strptime(timestamp_str, "%Y-%m-%d %H:%M:%S %z") + log.status = status + log.task_type = task_type + log.device_id = device_id + log.pass_type_identifier = pass_type_identifier + log.serial_number = serial_number + log.web_service_url = web_service_url + log.msg = msg + log.message = message + + if serial_number: + try: + pazz = Pass.objects.get(serial_number=serial_number) + log.pazz = pazz + except Pass.DoesNotExist: + pass + + log.save() diff --git a/django_walletpass/services.py b/django_walletpass/services.py index b3ea237..7c4985e 100644 --- a/django_walletpass/services.py +++ b/django_walletpass/services.py @@ -12,7 +12,11 @@ class PushBackend: def __init__(self): - self.loop = asyncio.get_event_loop() + try: + self.loop = asyncio.get_event_loop() + except RuntimeError: + self.loop = asyncio.new_event_loop() + asyncio.set_event_loop(self.loop) async def push_notification(self, client, token): diff --git a/django_walletpass/static/admin/passbook_icon.svg b/django_walletpass/static/admin/passbook_icon.svg new file mode 100644 index 0000000..555054e --- /dev/null +++ b/django_walletpass/static/admin/passbook_icon.svg @@ -0,0 +1,3 @@ + + + diff --git a/django_walletpass/tests/main.py b/django_walletpass/tests/main.py index b774f78..d5e659e 100644 --- a/django_walletpass/tests/main.py +++ b/django_walletpass/tests/main.py @@ -1,31 +1,64 @@ from unittest import mock from dateutil.parser import parse +from django.contrib import admin from django.test import TestCase from django.utils import timezone + from django_walletpass import crypto +from django_walletpass.admin import PassAdmin from django_walletpass.classviews import FORMAT from django_walletpass.models import Pass, PassBuilder, Registration from django_walletpass.settings import dwpconfig as WALLETPASS_CONF -class ClassViewsTestCase(TestCase): +class AdminTestCase(TestCase): + def test_wallet_pass(self): + admin_view = PassAdmin(Pass, admin.site) + builder = PassBuilder() + builder.pass_data = { + "formatVersion": 1, + "barcode": { + "message": "123456789", + "format": "PKBarcodeFormatPDF417", + "messageEncoding": "iso-8859-1", + }, + "organizationName": "Organic Produce", + "description": "Organic Produce Loyalty Card", + "logoText": "Organic Produce", + "foregroundColor": "rgb(255, 255, 255)", + "backgroundColor": "rgb(55, 117, 50)", + } + + builder.build() + + instance = builder.write_to_model() + instance.save() + + self.assertEqual( + admin_view.wallet_pass_(instance)[-48:], + "", + ) + +class ClassViewsTestCase(TestCase): def test_format_parse(self): - """ ensure dateutil reads FORMAT properly """ + """ensure dateutil reads FORMAT properly""" now = timezone.now() now_string = now.strftime(FORMAT) - self.assertEqual(parse(now_string), timezone.make_naive(now).replace(microsecond=0)) + self.assertEqual( + parse(now_string), timezone.make_naive(now).replace(microsecond=0) + ) class CryptoTestCase(TestCase): def test_smime_sign(self): crypto.pkcs7_sign( - certcontent=WALLETPASS_CONF['CERT_CONTENT'], - keycontent=WALLETPASS_CONF['KEY_CONTENT'], - wwdr_certificate=WALLETPASS_CONF['WWDRCA_PEM_CONTENT'], - data=b'data to be signed', - key_password=WALLETPASS_CONF['KEY_PASSWORD'], + certcontent=WALLETPASS_CONF["CERT_CONTENT"], + keycontent=WALLETPASS_CONF["KEY_CONTENT"], + wwdr_certificate=WALLETPASS_CONF["WWDRCA_PEM_CONTENT"], + data=b"data to be signed", + key_password=WALLETPASS_CONF["KEY_PASSWORD"], ) @@ -37,7 +70,7 @@ def test_build_pkpass(self): "barcode": { "message": "123456789", "format": "PKBarcodeFormatPDF417", - "messageEncoding": "iso-8859-1" + "messageEncoding": "iso-8859-1", }, "organizationName": "Organic Produce", "description": "Organic Produce Loyalty Card", @@ -57,7 +90,7 @@ def test_build_pkpass(self): self.assertEqual(builder.manifest_dict, builder2.manifest_dict) self.assertEqual(builder.pass_data, builder2.pass_data) - builder2.pass_data.update({"organizationName": 'test'}) + builder2.pass_data.update({"organizationName": "test"}) builder2.build() builder2.write_to_model(instance) instance.save() diff --git a/django_walletpass/urls.py b/django_walletpass/urls.py index 53028e4..883bbfa 100644 --- a/django_walletpass/urls.py +++ b/django_walletpass/urls.py @@ -1,4 +1,4 @@ -from django.urls import re_path +from django.urls import re_path, path from . import classviews urlpatterns = [