Initial
This commit is contained in:
0
backend/app/__init__.py
Normal file
0
backend/app/__init__.py
Normal file
41
backend/app/admin.py
Normal file
41
backend/app/admin.py
Normal 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
6
backend/app/apps.py
Normal file
@@ -0,0 +1,6 @@
|
||||
from django.apps import AppConfig
|
||||
|
||||
|
||||
class AppConfig(AppConfig):
|
||||
default_auto_field = 'django.db.models.BigAutoField'
|
||||
name = 'app'
|
||||
38
backend/app/form/file_with_img_preview.py
Normal file
38
backend/app/form/file_with_img_preview.py
Normal 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')
|
||||
49
backend/app/migrations/0001_initial.py
Normal file
49
backend/app/migrations/0001_initial.py
Normal 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',
|
||||
},
|
||||
),
|
||||
]
|
||||
0
backend/app/migrations/__init__.py
Normal file
0
backend/app/migrations/__init__.py
Normal file
2
backend/app/models/__init__.py
Normal file
2
backend/app/models/__init__.py
Normal file
@@ -0,0 +1,2 @@
|
||||
from .organization import Organization # noqa: F401
|
||||
from .member import Member # noqa: F401
|
||||
97
backend/app/models/member.py
Normal file
97
backend/app/models/member.py
Normal 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)
|
||||
53
backend/app/models/organization.py
Normal file
53
backend/app/models/organization.py
Normal 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)
|
||||
BIN
backend/app/static/favicon.ico
Normal file
BIN
backend/app/static/favicon.ico
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 1.3 KiB |
21
backend/app/static/favicon.svg
Normal file
21
backend/app/static/favicon.svg
Normal 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 |
14
backend/app/templates/forms/img-file.html
Normal file
14
backend/app/templates/forms/img-file.html
Normal 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
24
backend/app/urls.py
Normal 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
28
backend/app/utils.py
Normal 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
32
backend/app/views.py
Normal 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')
|
||||
Reference in New Issue
Block a user