This commit is contained in:
relikd
2023-10-02 23:39:20 +02:00
commit 8629b01da3
47 changed files with 1412 additions and 0 deletions

0
backend/app/__init__.py Normal file
View File

41
backend/app/admin.py Normal file
View File

@@ -0,0 +1,41 @@
from django.contrib import admin
from django.contrib.auth.models import Group # , User
from django.utils.safestring import mark_safe
from .models import Organization, Member
admin.site.site_header = 'Ausweis-Verwaltung' # top-most title
admin.site.index_title = 'Ausweis' # title at root
admin.site.site_title = 'Ausweis-Verwaltung' # suffix to <title>
admin.site.unregister(Group)
# admin.site.unregister(User)
@admin.register(Organization)
class OrganizationAdmin(admin.ModelAdmin):
prepopulated_fields = {'slug': ['name']}
list_display = ('name', 'slug', 'member_count')
list_display_links = ('name', 'slug')
search_fields = ('name', 'slug')
@admin.display(description='Mitglieder')
def member_count(self, obj: 'Organization'):
return obj.members.count()
@admin.register(Member)
class MemberAdmin(admin.ModelAdmin):
readonly_fields = ('uuid', 'url_link_full')
list_display = (
'name', 'member_id', 'valid_since', 'valid_until', 'url_link_short')
search_fields = ('name', 'member_id')
@admin.display(description='URL')
def url_link_short(self, obj: 'Member'):
return mark_safe(f'<a target="blank" href="{obj.export_url}">Link</a>')
@admin.display(description='URL')
def url_link_full(self, obj: 'Member'):
return mark_safe('<a target="blank" href="{0}">{0}</a>'.format(
obj.export_url))

6
backend/app/apps.py Normal file
View File

@@ -0,0 +1,6 @@
from django.apps import AppConfig
class AppConfig(AppConfig):
default_auto_field = 'django.db.models.BigAutoField'
name = 'app'

View File

@@ -0,0 +1,38 @@
from django.conf import settings
# from django.contrib.admin.widgets import AdminFileWidget
# from django.core.exceptions import ValidationError
from django.db import models
from django.forms import ValidationError
from django.forms.widgets import ClearableFileInput
# class ImageFileWidget(AdminFileWidget):
class ImageFileWidget(ClearableFileInput):
template_name = 'forms/img-file.html'
def get_context(self, name, value, attrs):
context = super().get_context(name, value, attrs)
context['MEDIA_URL'] = settings.MEDIA_URL
return context
class FileWithImagePreview(models.FileField):
def formfield(self, **kwargs):
# if 'widget' not in kwargs: # only if no other is set (admin UI)
kwargs['widget'] = ImageFileWidget
return super().formfield(**kwargs)
class ImageValidator:
help_text = 'Ideal: 250 x 320 px (JPEG oder PNG)'
@staticmethod
def validate(value: 'models.FieldFile') -> None:
# TODO: make configurable
if value.size > 512 * 1024:
raise ValidationError('Datei darf maximal 512 KB groß sein.')
content_type = getattr(value.file, 'content_type', None)
if content_type not in ['image/png', 'image/jpeg', None]:
raise ValidationError('Nur JPEG und PNG Bilder werden unterstützt')

View File

@@ -0,0 +1,49 @@
# Generated by Django 4.2.5 on 2023-10-02 14:25
import app.form.file_with_img_preview
import app.utils
from django.db import migrations, models
import django.db.models.deletion
import uuid
class Migration(migrations.Migration):
initial = True
dependencies = [
]
operations = [
migrations.CreateModel(
name='Organization',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('name', models.CharField(max_length=100, verbose_name='Name')),
('slug', models.SlugField(unique=True, verbose_name='URL Slug')),
('exportBaseUrl', models.URLField(verbose_name='Export Base-URL')),
],
options={
'verbose_name': 'Organisation',
'verbose_name_plural': 'Organisationen',
},
),
migrations.CreateModel(
name='Member',
fields=[
('uuid', models.UUIDField(default=uuid.uuid4, editable=False, primary_key=True, serialize=False)),
('secret', models.CharField(default=app.utils.random_secret, editable=False, max_length=20)),
('member_id', models.CharField(max_length=20, verbose_name='Mitglieder-Nr.')),
('name', models.CharField(max_length=100, verbose_name='Name')),
('valid_since', models.DateField(verbose_name='Mitglied seit')),
('valid_until', models.DateField(blank=True, null=True, verbose_name='Mitglied bis')),
('image', app.form.file_with_img_preview.FileWithImagePreview(blank=True, help_text='Ideal: 250 x 320 px (JPEG oder PNG)', null=True, upload_to=app.utils.overwrite_upload, validators=[app.form.file_with_img_preview.ImageValidator.validate], verbose_name='Bild')),
('additional', models.JSONField(blank=True, null=True, verbose_name='Zusätzliche Daten (JSON)')),
('organization', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='members', to='app.organization')),
],
options={
'verbose_name': 'Mitglied',
'verbose_name_plural': 'Mitglieder',
},
),
]

View File

View File

@@ -0,0 +1,2 @@
from .organization import Organization # noqa: F401
from .member import Member # noqa: F401

View File

@@ -0,0 +1,97 @@
from pathlib import PosixPath
from django.conf import settings
from django.db import models
from app.form.file_with_img_preview import FileWithImagePreview, ImageValidator
from app.utils import encrypt, overwrite_upload, random_secret
import os
import uuid
import json
import base64
class Member(models.Model):
# auto-generated ids
uuid = models.UUIDField(
primary_key=True, default=uuid.uuid4, editable=False)
secret = models.CharField(
max_length=20, default=random_secret, editable=False)
# member info
organization = models.ForeignKey(
'Organization', on_delete=models.CASCADE, related_name='members')
member_id = models.CharField('Mitglieder-Nr.', max_length=20)
name = models.CharField('Name', max_length=100)
valid_since = models.DateField('Mitglied seit')
valid_until = models.DateField('Mitglied bis', blank=True, null=True)
image = FileWithImagePreview(
'Bild', blank=True, null=True,
upload_to=overwrite_upload, validators=[ImageValidator.validate],
help_text=ImageValidator.help_text)
additional = models.JSONField(
'Zusätzliche Daten (JSON)', blank=True, null=True)
class Meta:
verbose_name = 'Mitglied'
verbose_name_plural = 'Mitglieder'
def __str__(self) -> str:
return self.name
@property
def export_url(self) -> str:
return self.organization.exportBaseUrl + \
f"#{self.organization.slug}/{self.uuid}/{self.secret}"
@property
def export_os_path(self) -> PosixPath:
return settings.EXPORT_PATH / self.organization.slug / str(self.uuid)
@property
def image_save_url(self) -> str:
return f'{self.organization.slug}/{self.uuid}'
@property
def image_os_path(self) -> PosixPath:
return settings.MEDIA_ROOT / self.organization.slug / str(self.uuid)
@property
def json(self):
return json.dumps({
'name': self.name,
'org': self.organization.name,
'id': self.member_id,
'valid_since': str(self.valid_since or ''),
'valid_until': str(self.valid_until or ''),
'data': self.additional or '',
'img': base64.b64encode(self.image.read()).decode(
'utf-8') if self.image else None,
})
@property
def json_encrypted(self) -> bytes:
return encrypt(self.json, self.secret)
def export(self):
os.makedirs(self.export_os_path.parent, exist_ok=True)
with open(self.export_os_path, 'wb') as fp:
fp.write(self.json_encrypted)
def save(self, *args, **kwargs):
if self.pk:
try:
prev = Member.objects.get(pk=self.pk)
if prev.image != self.image:
prev.image.delete(save=False)
except Member.DoesNotExist:
pass
self.export()
return super().save(*args, **kwargs)
def delete(self, *args, **kwargs):
if self.image:
self.image.delete(save=False)
if os.path.isfile(self.export_os_path):
os.remove(self.export_os_path)
return super().delete(*args, **kwargs)

View File

@@ -0,0 +1,53 @@
from django.conf import settings
from django.db import models
import os
import shutil
import typing
if typing.TYPE_CHECKING:
from app.models.member import Member
class Organization(models.Model):
name = models.CharField('Name', max_length=100)
slug = models.SlugField('URL Slug', unique=True, max_length=50)
exportBaseUrl = models.URLField('Export Base-URL', max_length=200)
members: 'models.QuerySet[Member]'
class Meta:
verbose_name = 'Organisation'
verbose_name_plural = 'Organisationen'
def __str__(self) -> str:
return self.name
def save(self, *args, **kwargs):
if self.pk:
try:
prev = Organization.objects.get(pk=self.pk)
if prev.slug != self.slug:
renameSlug(prev.slug, self.slug)
except Organization.DoesNotExist:
pass
return super().save(*args, **kwargs)
def delete(self, *args, **kwargs):
deleteSlug(self.slug)
return super().delete(*args, **kwargs)
def renameSlug(oldSlug: str, newSlug: str):
if newSlug == oldSlug:
return
oldExportPath = settings.EXPORT_PATH / oldSlug
oldMediaPath = settings.MEDIA_ROOT / oldSlug
if os.path.exists(oldExportPath):
os.rename(oldExportPath, settings.EXPORT_PATH / newSlug)
if os.path.exists(oldMediaPath):
os.rename(oldMediaPath, settings.MEDIA_ROOT / newSlug)
def deleteSlug(oldSlug: str):
shutil.rmtree(settings.EXPORT_PATH / oldSlug, ignore_errors=True)
shutil.rmtree(settings.MEDIA_ROOT / oldSlug, ignore_errors=True)

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.3 KiB

View File

@@ -0,0 +1,21 @@
<?xml version="1.0" encoding="utf-8"?>
<svg version="1.1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" viewBox="0 0 1000 1000">
<rect fill="#000" y="112.5" rx="108" width="1000" height="775"/>
<g transform="translate(50,50)scale(.9)">
<rect fill="#fff" y="125" rx="80" width="1000" height="750"/>
<g fill="#43768F">
<rect x="62" y="187" rx="17" width="876" height="63"/>
<rect x="62" y="207" width="876" height="43"/>
<circle cx="282" cy="435" r="94"/>
<path d="M438.2,654.1c0-17.6-1.2-33.9-3.7-49.1c-2.5-15.1-6.4-29.8-11.9-44c-5.5
-14.2-13.8-25.3-24.9-33.4c-11-8.1-24.3-12.2-39.6-12.2c-20.8,20.8-46.2,31.2
-76.2,31.2s-55.4-10.4-76.2-31.2c-15.3,0-28.5,4.1-39.6,12.2c-11.1,8.1-19.4,19.3
-24.9,33.4c-5.5,14.2-9.5,28.8-11.9,44c-2.4,15.2-3.7,31.5-3.7,49.1c0,17.9,5.1,
33.1,15.4,45.6c10.3,12.5,22.5,18.8,36.9,18.8h207.9c14.3,0,26.6-6.2,36.9-18.8
C433.2,687.2,438.2,672,438.2,654.1z"/>
<rect x="500" y="375" width="375" height="63"/>
<rect x="500" y="500" width="190" height="63"/>
<rect x="750" y="500" width="125" height="63"/>
<rect x="500" y="625" width="375" height="63"/>
</g></g>
</svg>

After

Width:  |  Height:  |  Size: 1.1 KiB

View File

@@ -0,0 +1,14 @@
<p>
{% if widget.is_initial %}
<img src="{{MEDIA_URL}}{{widget.value}}" style="max-width: 150px; max-height: 150px; margin: 8px;" />
{% if not widget.required %}
<span class="clearable-file-input">
<input type="checkbox" name="{{ widget.checkbox_name }}" id="{{ widget.checkbox_id }}"{% if widget.attrs.disabled %} disabled{% endif %}>
<label for="{{ widget.checkbox_id }}">{{ widget.clear_checkbox_label }}</label>
</span>
{% endif %}
<br>
<span>{{ widget.input_text }}:</span>
{% endif %}
<input type="{{ widget.type }}" name="{{ widget.name }}"{% include "django/forms/widgets/attrs.html" %}>
</p>

24
backend/app/urls.py Normal file
View File

@@ -0,0 +1,24 @@
from django.conf import settings
from django.core.exceptions import PermissionDenied
from django.http import HttpRequest
from django.urls import path
from django.views.static import serve
from app.views import EncryptedJsonREST
def ensure_authenticated(request: HttpRequest):
if not request.user.is_authenticated:
raise PermissionDenied()
def protected_serve(request, **kwargs):
ensure_authenticated(request)
return serve(request, **kwargs)
urlpatterns = []
if settings.API_ENABLED:
urlpatterns.append(
path('api/json/<str:org>/<uuid:uuid>', EncryptedJsonREST.as_view()))

28
backend/app/utils.py Normal file
View File

@@ -0,0 +1,28 @@
from django.utils.crypto import get_random_string
from cryptography.hazmat.primitives.ciphers.aead import AESGCM
from cryptography.hazmat.primitives.hashes import Hash, SHA256
import os
import typing
if typing.TYPE_CHECKING:
from app.models.member import Member
def random_secret():
return get_random_string(16)
def overwrite_upload(instance: 'Member', filename: str):
if os.path.isfile(instance.image_os_path):
os.remove(instance.image_os_path)
return instance.image_save_url
def encrypt(plaintext: str, key: str):
digest = Hash(SHA256())
digest.update(key.encode('utf8'))
aesgcm = AESGCM(digest.finalize())
iv = os.urandom(12)
ct = aesgcm.encrypt(iv, plaintext.encode('utf8'), iv)
return iv + ct

32
backend/app/views.py Normal file
View File

@@ -0,0 +1,32 @@
from django.conf import settings
from django.http import HttpRequest, HttpResponse
from django.shortcuts import get_object_or_404
from django.views import View
from django.views.static import serve
from app.models import Member
class EncryptedJsonREST(View):
http_method_names = ['get']
def get(self, request: HttpRequest, *args, **kwargs):
return serveStaticJson(request, **kwargs)
# return serveDynamicJson(**kwargs)
def memberOr404(kwargs: 'dict[str, str]'):
org, uuid = kwargs['org'], kwargs['uuid']
return get_object_or_404(Member, uuid=uuid, organization__slug=org)
def serveStaticJson(request: HttpRequest, **kwargs):
org, uuid = kwargs['org'], kwargs['uuid']
return serve(
request, path=f'{org}/{uuid}', document_root=settings.EXPORT_PATH)
def serveDynamicJson(**kwargs):
mem = memberOr404(kwargs)
return HttpResponse(
mem.json_encrypted, content_type='application/octet-stream')