Initial
This commit is contained in:
1
.gitignore
vendored
Normal file
1
.gitignore
vendored
Normal file
@@ -0,0 +1 @@
|
||||
data/db.sqlite3
|
||||
16
Dockerfile
Executable file
16
Dockerfile
Executable file
@@ -0,0 +1,16 @@
|
||||
FROM python:3.8-alpine
|
||||
|
||||
RUN apk add --no-cache gcc libc-dev linux-headers
|
||||
|
||||
# install pypi packages
|
||||
COPY . /django_project
|
||||
RUN pip install --upgrade pip
|
||||
RUN pip install ./django_project
|
||||
RUN pip install uWSGI==2.0.21
|
||||
|
||||
COPY docker/app/uwsgi.ini /uwsgi.ini
|
||||
COPY docker/app/scripts /scripts
|
||||
RUN chmod -R +x /scripts
|
||||
ENV PATH="/scripts:/py/bin:$PATH"
|
||||
|
||||
CMD ["run.sh"]
|
||||
19
Makefile
Normal file
19
Makefile
Normal file
@@ -0,0 +1,19 @@
|
||||
.PHONY: help
|
||||
help:
|
||||
@echo
|
||||
@echo 'Available commands:'
|
||||
@echo ' init: Create initial database and super user'
|
||||
@echo
|
||||
|
||||
.PHONY: init
|
||||
init:
|
||||
python3 manage.py migrate
|
||||
python3 manage.py loaddata traits.json booking_types.json
|
||||
python3 manage.py createsuperuser
|
||||
|
||||
.PHONY: get_columns
|
||||
get_columns:
|
||||
@for x in $$(sqlite3 data/db.sqlite3 '.tables base_%'); do \
|
||||
echo; echo "=== $$x ==="; \
|
||||
sqlite3 data/db.sqlite3 "PRAGMA table_info($$x)" | cut -d'|' -f1-2; \
|
||||
done
|
||||
8
README.md
Normal file
8
README.md
Normal file
@@ -0,0 +1,8 @@
|
||||
# mkspc mgmt
|
||||
|
||||
A Django app for user management for makerspaces.
|
||||
|
||||
|
||||
### Install
|
||||
|
||||
After install, run `make init` to create the initial database and super user.
|
||||
0
app/__init__.py
Executable file
0
app/__init__.py
Executable file
1
app/base/__init__.py
Executable file
1
app/base/__init__.py
Executable file
@@ -0,0 +1 @@
|
||||
default_app_config = 'app.base.apps.AppBaseConfig'
|
||||
20
app/base/admin.py
Executable file
20
app/base/admin.py
Executable file
@@ -0,0 +1,20 @@
|
||||
from django.contrib import admin
|
||||
|
||||
from .models import (
|
||||
Booking, BookingType,
|
||||
Course, CourseVisit,
|
||||
Note,
|
||||
Person,
|
||||
Trait, TraitMapping,
|
||||
Transaction,
|
||||
)
|
||||
|
||||
admin.site.register(Booking)
|
||||
admin.site.register(BookingType)
|
||||
admin.site.register(Course)
|
||||
admin.site.register(CourseVisit)
|
||||
admin.site.register(Note)
|
||||
admin.site.register(Person)
|
||||
admin.site.register(Trait)
|
||||
admin.site.register(TraitMapping)
|
||||
admin.site.register(Transaction)
|
||||
8
app/base/apps.py
Executable file
8
app/base/apps.py
Executable file
@@ -0,0 +1,8 @@
|
||||
from django.apps import AppConfig
|
||||
|
||||
|
||||
class AppBaseConfig(AppConfig):
|
||||
name = 'app.base'
|
||||
|
||||
def ready(self):
|
||||
import app.base.signals
|
||||
0
app/base/fixtures/__init__.py
Executable file
0
app/base/fixtures/__init__.py
Executable file
31
app/base/fixtures/booking_types.json
Normal file
31
app/base/fixtures/booking_types.json
Normal file
@@ -0,0 +1,31 @@
|
||||
[
|
||||
{
|
||||
"model": "base.bookingtype",
|
||||
"fields": {
|
||||
"key": "basic",
|
||||
"label": "Werkstattzeit - BASIC",
|
||||
"price": 2.00,
|
||||
"interval": 60,
|
||||
"is_checkin": true
|
||||
}
|
||||
},
|
||||
{
|
||||
"model": "base.bookingtype",
|
||||
"fields": {
|
||||
"key": "plus",
|
||||
"label": "Werkstattzeit - PLUS",
|
||||
"price": 3.00,
|
||||
"interval": 60,
|
||||
"is_checkin": true
|
||||
}
|
||||
},
|
||||
{
|
||||
"model": "base.bookingtype",
|
||||
"fields": {
|
||||
"key": "machine",
|
||||
"label": "Maschinenzeit",
|
||||
"price": 0.50,
|
||||
"interval": 5
|
||||
}
|
||||
}
|
||||
]
|
||||
34
app/base/fixtures/traits.json
Normal file
34
app/base/fixtures/traits.json
Normal file
@@ -0,0 +1,34 @@
|
||||
[
|
||||
{
|
||||
"model": "base.trait",
|
||||
"fields": {
|
||||
"key": "member",
|
||||
"label": "Mitglied",
|
||||
"description": "Ist Vereinsmitglied der Offenen Werkstatt e.V."
|
||||
}
|
||||
},
|
||||
{
|
||||
"model": "base.trait",
|
||||
"fields": {
|
||||
"key": "abo",
|
||||
"label": "Abo (25€)",
|
||||
"description": "Darf alle elektrischen Geräte kostenfrei nutzen."
|
||||
}
|
||||
},
|
||||
{
|
||||
"model": "base.trait",
|
||||
"fields": {
|
||||
"key": "bckspc",
|
||||
"label": "Backspace",
|
||||
"description": "Ist Backspace Mitglied (Anschlussmitgliedschaft)"
|
||||
}
|
||||
},
|
||||
{
|
||||
"model": "base.trait",
|
||||
"fields": {
|
||||
"key": "gesell",
|
||||
"label": "Gesellenbrief",
|
||||
"description": "Hat Gesellenbrief vorgezeigt und darf die großen Maschinen eigenhändig nutzen."
|
||||
}
|
||||
}
|
||||
]
|
||||
0
app/base/forms/__init__.py
Executable file
0
app/base/forms/__init__.py
Executable file
40
app/base/forms/fields.py
Normal file
40
app/base/forms/fields.py
Normal file
@@ -0,0 +1,40 @@
|
||||
from django.db import models
|
||||
from django import forms
|
||||
|
||||
from app.base.forms.widgets.date_widget import DateWithNow
|
||||
from app.base.forms.widgets.datetime_widget import DateTimeWithNow
|
||||
|
||||
from decimal import Decimal
|
||||
|
||||
|
||||
class AutosizeTextarea(forms.Textarea):
|
||||
template_name = 'forms/widgets/textarea.html'
|
||||
|
||||
|
||||
class TextField(models.TextField):
|
||||
def formfield(self, **kwargs):
|
||||
if 'widget' not in kwargs: # only if no other is set (admin UI)
|
||||
kwargs['widget'] = AutosizeTextarea
|
||||
return super().formfield(**kwargs)
|
||||
|
||||
|
||||
class DateTimeField(models.DateTimeField):
|
||||
def formfield(self, **kwargs):
|
||||
if 'widget' not in kwargs: # only if no other is set (admin UI)
|
||||
kwargs['widget'] = DateTimeWithNow
|
||||
return super().formfield(**kwargs)
|
||||
|
||||
|
||||
class DateField(models.DateField):
|
||||
def formfield(self, **kwargs):
|
||||
if 'widget' not in kwargs: # only if no other is set (admin UI)
|
||||
kwargs['widget'] = DateWithNow
|
||||
return super().formfield(**kwargs)
|
||||
|
||||
|
||||
class CurrencyField(models.DecimalField):
|
||||
def __init__(self, *args, **kwargs) -> None:
|
||||
kwargs['decimal_places'] = kwargs.get('decimal_places', 2)
|
||||
kwargs['max_digits'] = kwargs.get('max_digits', 10)
|
||||
kwargs['default'] = kwargs.get('default', Decimal(0))
|
||||
super().__init__(*args, **kwargs)
|
||||
0
app/base/forms/widgets/__init__.py
Executable file
0
app/base/forms/widgets/__init__.py
Executable file
14
app/base/forms/widgets/date_widget.py
Executable file
14
app/base/forms/widgets/date_widget.py
Executable file
@@ -0,0 +1,14 @@
|
||||
from django import forms
|
||||
|
||||
|
||||
class DateWithNow(forms.DateInput):
|
||||
template_name = 'forms/widgets/date.html'
|
||||
|
||||
def __init__(self, attrs=None, format=None):
|
||||
rv = attrs or {}
|
||||
rv['type'] = 'date'
|
||||
super().__init__(rv, format='%Y-%m-%d')
|
||||
|
||||
# OR: prevent super from converting dateformat.date to str
|
||||
# def format_value(self, value: date) -> date:
|
||||
# return value
|
||||
22
app/base/forms/widgets/datetime_widget.py
Executable file
22
app/base/forms/widgets/datetime_widget.py
Executable file
@@ -0,0 +1,22 @@
|
||||
from django import forms
|
||||
from django.forms.utils import to_current_timezone
|
||||
from django.http.request import QueryDict
|
||||
|
||||
from datetime import datetime
|
||||
|
||||
|
||||
class DateTimeWithNow(forms.DateTimeInput):
|
||||
template_name = 'forms/widgets/datetime.html'
|
||||
|
||||
def format_value(self, value: datetime) -> datetime:
|
||||
return to_current_timezone(value)
|
||||
|
||||
def value_from_datadict(self, data: QueryDict, files, name: str) \
|
||||
-> 'datetime|None':
|
||||
day, time = data.getlist(name)
|
||||
if not day:
|
||||
return None
|
||||
y, m, d = day.split('-')
|
||||
h, i, *s = time.split(':') if time else (0, 0, 0)
|
||||
return datetime(
|
||||
int(y), int(m), int(d), int(h), int(i), int(s[0] if s else 0))
|
||||
BIN
app/base/locale/de/LC_MESSAGES/django.mo
Normal file
BIN
app/base/locale/de/LC_MESSAGES/django.mo
Normal file
Binary file not shown.
11
app/base/locale/de/LC_MESSAGES/django.po
Normal file
11
app/base/locale/de/LC_MESSAGES/django.po
Normal file
@@ -0,0 +1,11 @@
|
||||
msgid ""
|
||||
msgstr ""
|
||||
"Content-Type: text/plain; charset=UTF-8\n"
|
||||
"Content-Transfer-Encoding: 8bit\n"
|
||||
"Language: de\n"
|
||||
|
||||
msgid "user"
|
||||
msgstr "Thekenkraft"
|
||||
|
||||
msgid "users"
|
||||
msgstr "Thekenkräfte"
|
||||
174
app/base/migrations/0001_initial.py
Normal file
174
app/base/migrations/0001_initial.py
Normal file
@@ -0,0 +1,174 @@
|
||||
# Generated by Django 4.2 on 2023-05-29 10:49
|
||||
|
||||
import app.base.forms.fields
|
||||
import datetime
|
||||
from decimal import Decimal
|
||||
from django.db import migrations, models
|
||||
import django.db.models.deletion
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
initial = True
|
||||
|
||||
dependencies = [
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.CreateModel(
|
||||
name='Account',
|
||||
fields=[
|
||||
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
||||
('balance', app.base.forms.fields.CurrencyField(decimal_places=2, default=Decimal('0'), max_digits=10, verbose_name='Guthaben')),
|
||||
('locked', models.BooleanField(default=False, verbose_name='Gesperrt')),
|
||||
],
|
||||
options={
|
||||
'verbose_name': 'Konto',
|
||||
'verbose_name_plural': 'Konten',
|
||||
},
|
||||
),
|
||||
migrations.CreateModel(
|
||||
name='Booking',
|
||||
fields=[
|
||||
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
||||
('begin_time', app.base.forms.fields.DateTimeField(default=datetime.datetime.now, verbose_name='Beginn')),
|
||||
('end_time', app.base.forms.fields.DateTimeField(blank=True, null=True, verbose_name='Ende')),
|
||||
('comment', app.base.forms.fields.TextField(blank=True, verbose_name='Kommentar')),
|
||||
],
|
||||
options={
|
||||
'verbose_name': 'Buchung',
|
||||
'verbose_name_plural': 'Buchungen',
|
||||
},
|
||||
),
|
||||
migrations.CreateModel(
|
||||
name='BookingType',
|
||||
fields=[
|
||||
('key', models.CharField(max_length=20, primary_key=True, serialize=False, verbose_name='UUID')),
|
||||
('label', models.CharField(max_length=200, verbose_name='Bezeichnung')),
|
||||
('price', app.base.forms.fields.CurrencyField(decimal_places=2, default=Decimal('0'), max_digits=10, verbose_name='Preis (€)')),
|
||||
('interval', models.IntegerField(default=60, verbose_name='Intervall (Min)')),
|
||||
('is_checkin', models.BooleanField(default=False, verbose_name='Ist Eincheck-Option')),
|
||||
],
|
||||
options={
|
||||
'verbose_name': 'Buchungsart',
|
||||
'verbose_name_plural': 'Buchungsarten',
|
||||
},
|
||||
),
|
||||
migrations.CreateModel(
|
||||
name='Course',
|
||||
fields=[
|
||||
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
||||
('title', models.CharField(max_length=280, verbose_name='Titel')),
|
||||
('mandatory', models.BooleanField(default=False, verbose_name='Braucht jeder?')),
|
||||
('description', app.base.forms.fields.TextField(blank=True, verbose_name='Beschreibung')),
|
||||
],
|
||||
options={
|
||||
'verbose_name': 'Einweisung',
|
||||
'verbose_name_plural': 'Einweisungen',
|
||||
},
|
||||
),
|
||||
migrations.CreateModel(
|
||||
name='Person',
|
||||
fields=[
|
||||
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
||||
('uuid', models.CharField(blank=True, max_length=200, verbose_name='Karten-ID')),
|
||||
('first_name', models.CharField(max_length=200, verbose_name='Vorname')),
|
||||
('last_name', models.CharField(max_length=200, verbose_name='Nachname')),
|
||||
('email', models.EmailField(blank=True, max_length=254, null=True, verbose_name='Email')),
|
||||
('phone', models.CharField(blank=True, max_length=200, null=True, verbose_name='Telefon')),
|
||||
('birth_date', app.base.forms.fields.DateField(verbose_name='Geburtsdatum')),
|
||||
('zip_code', models.CharField(max_length=10, verbose_name='PLZ')),
|
||||
('city', models.CharField(max_length=200, verbose_name='Stadt')),
|
||||
('street', models.CharField(max_length=200, verbose_name='Straße')),
|
||||
('house_nr', models.CharField(max_length=10, verbose_name='Hausnummer')),
|
||||
('identified', models.BooleanField(default=False, verbose_name='Ausweis vorgezeigt')),
|
||||
('agreed_to_terms_of_service', models.BooleanField(default=False, verbose_name='Nutzungsbedingungen zugestimmt')),
|
||||
],
|
||||
options={
|
||||
'verbose_name': 'Werkstattnutzer:in',
|
||||
'verbose_name_plural': 'Werkstattnutzer:innen',
|
||||
},
|
||||
),
|
||||
migrations.CreateModel(
|
||||
name='Trait',
|
||||
fields=[
|
||||
('key', models.CharField(max_length=20, primary_key=True, serialize=False, verbose_name='UUID')),
|
||||
('label', models.CharField(max_length=200, verbose_name='Label')),
|
||||
('description', app.base.forms.fields.TextField(blank=True, verbose_name='Beschreibung')),
|
||||
],
|
||||
options={
|
||||
'verbose_name': 'Attribut',
|
||||
'verbose_name_plural': 'Attribute',
|
||||
},
|
||||
),
|
||||
migrations.CreateModel(
|
||||
name='Transaction',
|
||||
fields=[
|
||||
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
||||
('amount', app.base.forms.fields.CurrencyField(decimal_places=2, default=Decimal('0'), max_digits=10, verbose_name='Betrag')),
|
||||
('description', models.CharField(max_length=500, verbose_name='Beschreibung')),
|
||||
('time_stamp', app.base.forms.fields.DateTimeField(auto_now_add=True, verbose_name='Datum / Uhrzeit')),
|
||||
('account', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='base.account', verbose_name='Konto')),
|
||||
('booking', models.OneToOneField(blank=True, default=None, null=True, on_delete=django.db.models.deletion.CASCADE, to='base.booking', verbose_name='Zugehörige Zeitbuchung')),
|
||||
],
|
||||
options={
|
||||
'verbose_name': 'Transaktion',
|
||||
'verbose_name_plural': 'Transaktionen',
|
||||
},
|
||||
),
|
||||
migrations.CreateModel(
|
||||
name='TraitMapping',
|
||||
fields=[
|
||||
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
||||
('valid_from', app.base.forms.fields.DateField(default=datetime.date.today, verbose_name='Gültig von')),
|
||||
('valid_until', app.base.forms.fields.DateField(blank=True, null=True, verbose_name='Gültig bis')),
|
||||
('trait', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='base.trait', verbose_name='Attribut')),
|
||||
('user', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='traits', to='base.person', verbose_name='Werkstattnutzer:in')),
|
||||
],
|
||||
options={
|
||||
'verbose_name': 'Attributzuweisung',
|
||||
'verbose_name_plural': 'Attributzuweisungen',
|
||||
},
|
||||
),
|
||||
migrations.CreateModel(
|
||||
name='Note',
|
||||
fields=[
|
||||
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
||||
('text', app.base.forms.fields.TextField(blank=True, verbose_name='Notiz')),
|
||||
('user', models.OneToOneField(on_delete=django.db.models.deletion.CASCADE, to='base.person')),
|
||||
],
|
||||
options={
|
||||
'verbose_name': 'Notiz',
|
||||
'verbose_name_plural': 'Notizen',
|
||||
},
|
||||
),
|
||||
migrations.CreateModel(
|
||||
name='CourseVisit',
|
||||
fields=[
|
||||
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
||||
('date', app.base.forms.fields.DateField(default=datetime.date.today, verbose_name='Datum')),
|
||||
('course', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='visits', to='base.course', verbose_name='Einweisung')),
|
||||
('participant', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='courses', to='base.person', verbose_name='Wer wurde eingewiesen?')),
|
||||
('teacher', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='instructed', to='base.person', verbose_name='Durchgeführt von')),
|
||||
],
|
||||
options={
|
||||
'verbose_name': 'Teilnahme',
|
||||
'verbose_name_plural': 'Teilnahmen',
|
||||
},
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='booking',
|
||||
name='type',
|
||||
field=models.ForeignKey(on_delete=django.db.models.deletion.PROTECT, to='base.bookingtype', verbose_name='Art'),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='booking',
|
||||
name='user',
|
||||
field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='base.person', verbose_name='Nutzer:in'),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='account',
|
||||
name='user',
|
||||
field=models.OneToOneField(on_delete=django.db.models.deletion.CASCADE, to='base.person'),
|
||||
),
|
||||
]
|
||||
0
app/base/migrations/__init__.py
Executable file
0
app/base/migrations/__init__.py
Executable file
10
app/base/models/__init__.py
Executable file
10
app/base/models/__init__.py
Executable file
@@ -0,0 +1,10 @@
|
||||
from app.base.models.account import Account
|
||||
from app.base.models.booking import Booking
|
||||
from app.base.models.booking_type import BookingType
|
||||
from app.base.models.course import Course
|
||||
from app.base.models.course_visit import CourseVisit
|
||||
from app.base.models.note import Note
|
||||
from app.base.models.person import Person
|
||||
from app.base.models.trait import Trait
|
||||
from app.base.models.trait_mapping import TraitMapping
|
||||
from app.base.models.transaction import Transaction
|
||||
32
app/base/models/account.py
Executable file
32
app/base/models/account.py
Executable file
@@ -0,0 +1,32 @@
|
||||
from django.db import models
|
||||
|
||||
from app.base.forms.fields import CurrencyField
|
||||
from app.base.models.person import Person
|
||||
|
||||
|
||||
class Account(models.Model):
|
||||
user = models.OneToOneField(Person, on_delete=models.CASCADE)
|
||||
balance = CurrencyField('Guthaben')
|
||||
locked = models.BooleanField('Gesperrt', default=False)
|
||||
|
||||
class Meta:
|
||||
verbose_name = 'Konto'
|
||||
verbose_name_plural = 'Konten'
|
||||
|
||||
def __str__(self):
|
||||
return f"{self.user}'s Konto"
|
||||
|
||||
# def change_balance(self, amount):
|
||||
# self.balance = self.balance - amount
|
||||
# self.save()
|
||||
# return True
|
||||
|
||||
# def lock(self):
|
||||
# self.locked = True
|
||||
# self.save()
|
||||
# return True
|
||||
|
||||
# def unlock(self):
|
||||
# self.locked = False
|
||||
# self.save()
|
||||
# return True
|
||||
63
app/base/models/booking.py
Executable file
63
app/base/models/booking.py
Executable file
@@ -0,0 +1,63 @@
|
||||
from django.db import models
|
||||
from django.db.models import Q
|
||||
from django.urls import reverse
|
||||
from django.utils import timezone
|
||||
|
||||
from app.base.forms.fields import DateTimeField, TextField
|
||||
|
||||
from datetime import datetime
|
||||
from typing import TYPE_CHECKING
|
||||
if TYPE_CHECKING:
|
||||
from django.db.models import OuterRef
|
||||
from app.base.models.person import Person
|
||||
from app.base.models.booking_type import BookingType
|
||||
|
||||
|
||||
class Booking(models.Model):
|
||||
type: 'models.ForeignKey[BookingType]' = models.ForeignKey(
|
||||
'BookingType', on_delete=models.PROTECT, verbose_name='Art')
|
||||
user: 'models.ForeignKey[Person]' = models.ForeignKey(
|
||||
'Person', on_delete=models.CASCADE, verbose_name='Nutzer:in')
|
||||
begin_time = DateTimeField('Beginn', default=datetime.now)
|
||||
end_time = DateTimeField('Ende', blank=True, null=True)
|
||||
comment = TextField('Kommentar', blank=True)
|
||||
|
||||
class Meta:
|
||||
verbose_name = 'Buchung'
|
||||
verbose_name_plural = 'Buchungen'
|
||||
|
||||
def get_absolute_url(self):
|
||||
return reverse('booking:detail', kwargs={'pk': self.pk})
|
||||
|
||||
def __str__(self):
|
||||
return 'Buchung von {} am {}: {}-{} Uhr'.format(
|
||||
self.user,
|
||||
self.begin_time.strftime('%d.%m.%Y'),
|
||||
self.begin_time.strftime('%H:%M'),
|
||||
self.end_time.strftime('%H:%M') if self.end_time else '')
|
||||
|
||||
@property
|
||||
def duration(self) -> 'int|None':
|
||||
if self.end_time:
|
||||
return round((self.end_time - self.begin_time).seconds / 60)
|
||||
return None
|
||||
|
||||
@property
|
||||
def calculated_price(self):
|
||||
traits = self.user.traits_at_date(self.begin_time).values_list('trait')
|
||||
traits = set(x[0] for x in traits)
|
||||
return self.type.price_with_traits(self.duration or 0, traits)
|
||||
|
||||
@staticmethod
|
||||
def latest_checkin_query(for_user: 'Person|OuterRef'):
|
||||
objects = Booking.objects.filter(user=for_user)
|
||||
return objects.order_by('-begin_time').values('begin_time')[:1]
|
||||
|
||||
@staticmethod
|
||||
def currently_open_checkin(for_user: 'Person|OuterRef') -> 'Booking|None':
|
||||
return Booking.objects.filter(
|
||||
Q(user=for_user),
|
||||
Q(begin_time__lte=timezone.now()),
|
||||
Q(end_time=None),
|
||||
Q(type__is_checkin=True)
|
||||
).first()
|
||||
36
app/base/models/booking_type.py
Executable file
36
app/base/models/booking_type.py
Executable file
@@ -0,0 +1,36 @@
|
||||
from django.db import models
|
||||
|
||||
from app.base.forms.fields import CurrencyField
|
||||
|
||||
import math
|
||||
from decimal import Decimal
|
||||
from typing import Iterable
|
||||
|
||||
|
||||
class BookingType(models.Model):
|
||||
key = models.CharField('UUID', primary_key=True, max_length=20)
|
||||
label = models.CharField('Bezeichnung', max_length=200)
|
||||
price = CurrencyField('Preis (€)')
|
||||
interval = models.IntegerField('Intervall (Min)', default=60)
|
||||
is_checkin = models.BooleanField('Ist Eincheck-Option', default=False)
|
||||
|
||||
class Meta:
|
||||
verbose_name = 'Buchungsart'
|
||||
verbose_name_plural = 'Buchungsarten'
|
||||
|
||||
def __str__(self):
|
||||
return self.label
|
||||
# return f'{self.label} ({self.price}/{self.interval})'
|
||||
|
||||
def price_with_traits(self, duration: int, traits: Iterable[str]) \
|
||||
-> Decimal:
|
||||
# TODO: make this UI-configurable?
|
||||
# people with "Abo" status
|
||||
if 'abo' in traits or 'bckspc' in traits:
|
||||
return Decimal(0)
|
||||
# Members do not pay for basic subscription
|
||||
if self.key == 'basic' and 'member' in traits:
|
||||
return Decimal(0)
|
||||
# TODO: if needed add more rules here
|
||||
intervals_to_pay = math.ceil(duration / self.interval)
|
||||
return Decimal(intervals_to_pay * self.price)
|
||||
22
app/base/models/course.py
Executable file
22
app/base/models/course.py
Executable file
@@ -0,0 +1,22 @@
|
||||
from django.db import models
|
||||
from django.urls import reverse
|
||||
|
||||
from app.base.forms.fields import TextField
|
||||
from app.base.models.course_visit import CourseVisit
|
||||
|
||||
|
||||
class Course(models.Model):
|
||||
instructed: models.QuerySet[CourseVisit]
|
||||
title = models.CharField('Titel', max_length=280)
|
||||
mandatory = models.BooleanField('Braucht jeder?', default=False)
|
||||
description = TextField('Beschreibung', blank=True)
|
||||
|
||||
class Meta:
|
||||
verbose_name = 'Einweisung'
|
||||
verbose_name_plural = 'Einweisungen'
|
||||
|
||||
def get_absolute_url(self):
|
||||
return reverse('course:detail', kwargs={'pk': self.pk})
|
||||
|
||||
def __str__(self):
|
||||
return self.title
|
||||
35
app/base/models/course_visit.py
Executable file
35
app/base/models/course_visit.py
Executable file
@@ -0,0 +1,35 @@
|
||||
from django.db import models
|
||||
from django.urls import reverse
|
||||
|
||||
from app.base.forms.fields import DateField
|
||||
|
||||
from datetime import date
|
||||
from typing import TYPE_CHECKING
|
||||
if TYPE_CHECKING:
|
||||
from app.base.models.course import Course
|
||||
from app.base.models.person import Person
|
||||
|
||||
|
||||
class CourseVisit(models.Model):
|
||||
course: 'models.ForeignKey[Course]' = models.ForeignKey(
|
||||
'Course', on_delete=models.CASCADE,
|
||||
related_name='visits', verbose_name='Einweisung')
|
||||
participant: 'models.ForeignKey[Person]' = models.ForeignKey(
|
||||
'Person', on_delete=models.CASCADE,
|
||||
related_name='courses', verbose_name='Wer wurde eingewiesen?')
|
||||
teacher: 'models.ForeignKey[Person]|models.ForeignKey[None]' =\
|
||||
models.ForeignKey(
|
||||
'Person', on_delete=models.SET_NULL, blank=True, null=True,
|
||||
related_name='instructed', verbose_name='Durchgeführt von')
|
||||
date = DateField('Datum', default=date.today)
|
||||
|
||||
class Meta:
|
||||
verbose_name = 'Teilnahme'
|
||||
verbose_name_plural = 'Teilnahmen'
|
||||
|
||||
def __str__(self):
|
||||
return 'Teilnahme von {} an Einweisung „{}“ am {}'.format(
|
||||
self.participant, self.course, self.date)
|
||||
|
||||
def get_absolute_url(self):
|
||||
return reverse('course-visit:detail', kwargs={'pk': self.pk})
|
||||
15
app/base/models/note.py
Executable file
15
app/base/models/note.py
Executable file
@@ -0,0 +1,15 @@
|
||||
from django.db import models
|
||||
|
||||
from app.base.forms.fields import TextField
|
||||
|
||||
|
||||
class Note(models.Model):
|
||||
user = models.OneToOneField('Person', on_delete=models.CASCADE)
|
||||
text = TextField('Notiz', blank=True)
|
||||
|
||||
class Meta:
|
||||
verbose_name = 'Notiz'
|
||||
verbose_name_plural = 'Notizen'
|
||||
|
||||
def __str__(self):
|
||||
return self.text
|
||||
84
app/base/models/person.py
Executable file
84
app/base/models/person.py
Executable file
@@ -0,0 +1,84 @@
|
||||
from django.db import models
|
||||
from django.db.models import Q
|
||||
from django.urls import reverse
|
||||
|
||||
from app.base.forms.fields import DateField
|
||||
from app.base.models.course import Course
|
||||
from app.base.models.booking import Booking
|
||||
|
||||
from datetime import datetime
|
||||
from typing import TYPE_CHECKING
|
||||
if TYPE_CHECKING:
|
||||
from app.base.models import Account, Note, CourseVisit, TraitMapping
|
||||
|
||||
|
||||
class Person(models.Model):
|
||||
uuid = models.CharField('Karten-ID', max_length=200, blank=True)
|
||||
|
||||
first_name = models.CharField('Vorname', max_length=200)
|
||||
last_name = models.CharField('Nachname', max_length=200)
|
||||
email = models.EmailField('Email', blank=True, null=True)
|
||||
phone = models.CharField('Telefon', max_length=200, blank=True, null=True)
|
||||
birth_date = DateField('Geburtsdatum')
|
||||
zip_code = models.CharField('PLZ', max_length=10)
|
||||
city = models.CharField('Stadt', max_length=200)
|
||||
street = models.CharField('Straße', max_length=200)
|
||||
house_nr = models.CharField('Hausnummer', max_length=10)
|
||||
|
||||
identified = models.BooleanField('Ausweis vorgezeigt', default=False)
|
||||
agreed_to_terms_of_service = models.BooleanField(
|
||||
'Nutzungsbedingungen zugestimmt', default=False)
|
||||
|
||||
# related_name
|
||||
account: 'models.OneToOneField[Account]'
|
||||
note: 'models.OneToOneField[Note]'
|
||||
courses: 'models.QuerySet[CourseVisit]'
|
||||
instructed: 'models.QuerySet[CourseVisit]'
|
||||
traits: 'models.QuerySet[TraitMapping]'
|
||||
|
||||
class Meta:
|
||||
verbose_name = 'Werkstattnutzer:in'
|
||||
verbose_name_plural = 'Werkstattnutzer:innen'
|
||||
# ordering = ('first_name', 'last_name')
|
||||
# indexes = [
|
||||
# models.Index(fields=['first_name', 'last_name']),
|
||||
# models.Index(Latest,fields=["first_name"],name="first_name_idx"),
|
||||
# ]
|
||||
|
||||
def get_absolute_url(self):
|
||||
return reverse('person:detail', kwargs={'pk': self.pk})
|
||||
|
||||
def __str__(self):
|
||||
return f'{self.first_name} {self.last_name}'
|
||||
|
||||
@property
|
||||
def display_name(self):
|
||||
return f'{self.first_name} {self.last_name}'
|
||||
|
||||
@property
|
||||
def address(self):
|
||||
return f'{self.street} {self.house_nr}, {self.zip_code} {self.city}'
|
||||
|
||||
@property
|
||||
def missing_courses(self):
|
||||
mandatory_courses = Course.objects.filter(mandatory=True)
|
||||
completed = set(self.courses.values_list('course', flat=True))
|
||||
return [x for x in mandatory_courses if x.pk not in completed]
|
||||
|
||||
@property
|
||||
def attributes(self):
|
||||
return self.traits_at_date(datetime.now()).values_list(
|
||||
'pk', 'trait__key', 'trait__label')
|
||||
|
||||
def traits_at_date(self, date: datetime):
|
||||
return self.traits.filter(
|
||||
Q(valid_from__lte=date),
|
||||
Q(valid_until__gte=date) | Q(valid_until=None))
|
||||
|
||||
def last_check_in(self) -> 'datetime|None':
|
||||
obj = Booking.latest_checkin_query(self).first()
|
||||
return obj['begin_time'] if obj else None
|
||||
|
||||
@property
|
||||
def current_checkin(self):
|
||||
return Booking.currently_open_checkin(self)
|
||||
16
app/base/models/trait.py
Executable file
16
app/base/models/trait.py
Executable file
@@ -0,0 +1,16 @@
|
||||
from django.db import models
|
||||
|
||||
from app.base.forms.fields import TextField
|
||||
|
||||
|
||||
class Trait(models.Model):
|
||||
key = models.CharField('UUID', primary_key=True, max_length=20)
|
||||
label = models.CharField('Label', max_length=200)
|
||||
description = TextField('Beschreibung', blank=True)
|
||||
|
||||
class Meta:
|
||||
verbose_name = 'Attribut'
|
||||
verbose_name_plural = 'Attribute'
|
||||
|
||||
def __str__(self):
|
||||
return self.label
|
||||
30
app/base/models/trait_mapping.py
Executable file
30
app/base/models/trait_mapping.py
Executable file
@@ -0,0 +1,30 @@
|
||||
from django.db import models
|
||||
from django.urls import reverse
|
||||
|
||||
from app.base.forms.fields import DateField
|
||||
|
||||
from datetime import date
|
||||
from typing import TYPE_CHECKING
|
||||
if TYPE_CHECKING:
|
||||
from app.base.models.person import Person
|
||||
from app.base.models.trait import Trait
|
||||
|
||||
|
||||
class TraitMapping(models.Model):
|
||||
user: 'models.ForeignKey[Person]' = models.ForeignKey(
|
||||
'Person', on_delete=models.CASCADE, related_name='traits',
|
||||
verbose_name='Werkstattnutzer:in')
|
||||
trait: 'models.ForeignKey[Trait]' = models.ForeignKey(
|
||||
'Trait', on_delete=models.CASCADE, verbose_name='Attribut')
|
||||
valid_from = DateField('Gültig von', default=date.today)
|
||||
valid_until = DateField('Gültig bis', blank=True, null=True)
|
||||
|
||||
class Meta:
|
||||
verbose_name = 'Attributzuweisung'
|
||||
verbose_name_plural = 'Attributzuweisungen'
|
||||
|
||||
def __str__(self):
|
||||
return f'Attribut „{self.trait}“ für {self.user}'
|
||||
|
||||
def get_absolute_url(self):
|
||||
return reverse('trait-mapping:detail', kwargs={'pk': self.pk})
|
||||
26
app/base/models/transaction.py
Executable file
26
app/base/models/transaction.py
Executable file
@@ -0,0 +1,26 @@
|
||||
from django.db import models
|
||||
from django.urls import reverse
|
||||
|
||||
from app.base.forms.fields import CurrencyField, DateTimeField
|
||||
from app.base.models.account import Account
|
||||
|
||||
|
||||
class Transaction(models.Model):
|
||||
account = models.ForeignKey(Account, on_delete=models.CASCADE,
|
||||
verbose_name='Konto')
|
||||
amount = CurrencyField('Betrag')
|
||||
booking = models.OneToOneField('Booking', on_delete=models.CASCADE,
|
||||
verbose_name='Zugehörige Zeitbuchung',
|
||||
null=True, blank=True, default=None)
|
||||
description = models.CharField('Beschreibung', max_length=500)
|
||||
time_stamp = DateTimeField('Datum / Uhrzeit', auto_now_add=True)
|
||||
|
||||
class Meta:
|
||||
verbose_name = 'Transaktion'
|
||||
verbose_name_plural = 'Transaktionen'
|
||||
|
||||
def get_absolute_url(self):
|
||||
return reverse('transaction:detail', kwargs={'pk': self.pk})
|
||||
|
||||
def __str__(self):
|
||||
return f'Transaktion über {self.amount}€ von {self.account}'
|
||||
7
app/base/signals/__init__.py
Executable file
7
app/base/signals/__init__.py
Executable file
@@ -0,0 +1,7 @@
|
||||
from app.base.signals.booking import booking_post_save
|
||||
from app.base.signals.person import person_post_save
|
||||
from app.base.signals.transaction import (
|
||||
transaction_pre_save,
|
||||
transaction_post_save,
|
||||
transaction_pre_delete
|
||||
)
|
||||
29
app/base/signals/booking.py
Executable file
29
app/base/signals/booking.py
Executable file
@@ -0,0 +1,29 @@
|
||||
from django.db.models.signals import post_save
|
||||
from django.dispatch import receiver
|
||||
|
||||
from app.base.models import Transaction, Booking
|
||||
|
||||
|
||||
@receiver(post_save, sender=Booking)
|
||||
def booking_post_save(sender, instance: Booking, created: bool, **kwargs):
|
||||
amount = instance.calculated_price
|
||||
with_transaction = instance.end_time and amount
|
||||
description = f'{instance.type.label} ({instance.duration or 0} Min)'
|
||||
|
||||
# Create or update existing Transaction
|
||||
transaction = Transaction.objects.filter(booking=instance).first()
|
||||
if transaction:
|
||||
if with_transaction:
|
||||
transaction.amount = amount
|
||||
transaction.description = description
|
||||
transaction.save()
|
||||
else:
|
||||
transaction.delete()
|
||||
|
||||
elif with_transaction:
|
||||
Transaction.objects.create(
|
||||
account=instance.user.account,
|
||||
amount=amount,
|
||||
description=description,
|
||||
booking=instance,
|
||||
)
|
||||
9
app/base/signals/person.py
Executable file
9
app/base/signals/person.py
Executable file
@@ -0,0 +1,9 @@
|
||||
from django.dispatch import receiver
|
||||
from django.db.models.signals import post_save
|
||||
from app.base.models import Person, Account
|
||||
|
||||
|
||||
@receiver(post_save, sender=Person)
|
||||
def person_post_save(sender, instance: Person, created: bool, **kwargs):
|
||||
if created:
|
||||
Account.objects.create(user=instance)
|
||||
28
app/base/signals/transaction.py
Executable file
28
app/base/signals/transaction.py
Executable file
@@ -0,0 +1,28 @@
|
||||
from django.dispatch import receiver
|
||||
from django.db.models.signals import pre_save, post_save, pre_delete
|
||||
from app.base.models import Transaction
|
||||
|
||||
|
||||
@receiver(pre_save, sender=Transaction)
|
||||
def transaction_pre_save(sender, instance: Transaction, **kwargs):
|
||||
if instance.pk:
|
||||
pre_edit = Transaction.objects.get(pk=instance.pk)
|
||||
|
||||
if pre_edit.amount != instance.amount:
|
||||
delta = pre_edit.amount - instance.amount
|
||||
account = instance.account
|
||||
account.balance = account.balance - delta
|
||||
account.save()
|
||||
|
||||
|
||||
@receiver(post_save, sender=Transaction)
|
||||
def transaction_post_save(sender, instance: Transaction, created, **kwargs):
|
||||
if created:
|
||||
instance.account.balance += instance.amount
|
||||
instance.account.save()
|
||||
|
||||
|
||||
@receiver(pre_delete, sender=Transaction)
|
||||
def transaction_pre_delete(sender, instance: Transaction, **kwargs):
|
||||
instance.account.balance -= instance.amount
|
||||
instance.account.save()
|
||||
7
app/base/static/bootstrap4.6.2/css/bootstrap.min.css
vendored
Normal file
7
app/base/static/bootstrap4.6.2/css/bootstrap.min.css
vendored
Normal file
File diff suppressed because one or more lines are too long
1
app/base/static/bootstrap4.6.2/css/bootstrap.min.css.map
Normal file
1
app/base/static/bootstrap4.6.2/css/bootstrap.min.css.map
Normal file
File diff suppressed because one or more lines are too long
7
app/base/static/bootstrap4.6.2/js/bootstrap.bundle.min.js
vendored
Normal file
7
app/base/static/bootstrap4.6.2/js/bootstrap.bundle.min.js
vendored
Normal file
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
7
app/base/static/bootstrap4.6.2/js/bootstrap.min.js
vendored
Normal file
7
app/base/static/bootstrap4.6.2/js/bootstrap.min.js
vendored
Normal file
File diff suppressed because one or more lines are too long
1
app/base/static/bootstrap4.6.2/js/bootstrap.min.js.map
Normal file
1
app/base/static/bootstrap4.6.2/js/bootstrap.min.js.map
Normal file
File diff suppressed because one or more lines are too long
29
app/base/static/css/login.css
Executable file
29
app/base/static/css/login.css
Executable file
@@ -0,0 +1,29 @@
|
||||
@font-face {
|
||||
font-family: 'Dosis';
|
||||
src: url(/static/fonts/Dosis.ttf);
|
||||
}
|
||||
|
||||
h1 {
|
||||
font-family: 'Dosis', sans-serif;
|
||||
color: #495057;
|
||||
}
|
||||
|
||||
body {
|
||||
background: url("../img/bg.jpg") center bottom / cover;
|
||||
height: 100vh;
|
||||
}
|
||||
|
||||
.login-form {
|
||||
background-color: #fff;
|
||||
display: inline-block;
|
||||
padding: 2em;
|
||||
position: absolute;
|
||||
left: 50%;
|
||||
top: 50%;
|
||||
transform: translate(-50%, -50%);
|
||||
border-radius: 5px;
|
||||
}
|
||||
|
||||
.login-form input {
|
||||
width: 100%;
|
||||
}
|
||||
359
app/base/static/css/style.css
Executable file
359
app/base/static/css/style.css
Executable file
@@ -0,0 +1,359 @@
|
||||
@font-face {
|
||||
font-family: 'Dosis';
|
||||
src: url(/static/fonts/Dosis.ttf);
|
||||
}
|
||||
|
||||
.font-dosis {
|
||||
font-family: 'Dosis', sans-serif;
|
||||
}
|
||||
|
||||
/*
|
||||
* Colors
|
||||
*/
|
||||
|
||||
:root {
|
||||
--bg-subtle: #fff;
|
||||
--bg-subtle-hover: #ededed;
|
||||
--bg-dim: #f8f9fa;
|
||||
--bg-dim-hover: #dae0e5;
|
||||
--bg-owba: #e94190;
|
||||
--bg-owba-hover: #cc287a;
|
||||
--fg-subtle: #495057;
|
||||
}
|
||||
|
||||
.bg-subtle {
|
||||
background-color: var(--bg-subtle);
|
||||
}
|
||||
.bg-subtle:hover {
|
||||
background-color: var(--bg-subtle-hover);
|
||||
}
|
||||
.bg-dim {
|
||||
background-color: var(--bg-dim);
|
||||
}
|
||||
.btn.bg-dim:hover, .list-group-item-action.bg-dim:hover {
|
||||
background-color: var(--bg-dim-hover);
|
||||
}
|
||||
.bg-owba {
|
||||
color: #fff;
|
||||
background-color: var(--bg-owba);
|
||||
}
|
||||
.bg-owba:hover {
|
||||
color: #e8e8e8;
|
||||
background-color: var(--bg-owba-hover);
|
||||
}
|
||||
|
||||
/*
|
||||
* Layout
|
||||
*/
|
||||
h1, h2, h3, h4 {
|
||||
font-family: 'Dosis', sans-serif;
|
||||
color: var(--fg-subtle);
|
||||
}
|
||||
|
||||
header {
|
||||
height: 4rem;
|
||||
position: sticky;
|
||||
top: 0;
|
||||
background-color: #fff;
|
||||
box-shadow: 0 4px 4px -4px rgba(0, 0, 0, .2);
|
||||
z-index: 99;
|
||||
}
|
||||
|
||||
#main-nav {
|
||||
display: flex;
|
||||
}
|
||||
|
||||
#navbar-toggler {
|
||||
display: none;
|
||||
margin-right: 1em;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
#logo {
|
||||
height: 2em
|
||||
}
|
||||
|
||||
#global-search {
|
||||
flex-grow: 1;
|
||||
display: flex;
|
||||
}
|
||||
|
||||
#global-search input {
|
||||
flex-grow: 1;
|
||||
background-color: #ededed;
|
||||
border: none;
|
||||
border-radius: 5px 0 0 5px;
|
||||
padding-left: .8em;
|
||||
}
|
||||
|
||||
#global-search .btn {
|
||||
border-radius: 0 5px 5px 0;
|
||||
}
|
||||
|
||||
#breadcrumbs {
|
||||
font-family: 'Dosis', sans-serif;
|
||||
margin: 1rem 0 1rem 0;
|
||||
}
|
||||
|
||||
#breadcrumbs a, #breadcrumbs span {
|
||||
display: inline-block;
|
||||
text-decoration: none;
|
||||
padding: .3rem .5rem;
|
||||
margin: 4px 0;
|
||||
border-radius: .2em;
|
||||
background-color: #EEE;
|
||||
color: #000;
|
||||
transition: .5s;
|
||||
}
|
||||
|
||||
#breadcrumbs a:hover {
|
||||
background-color: #DDD;
|
||||
color: #000;
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
#sidebar-detail {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 2rem;
|
||||
}
|
||||
#checkin-timer {
|
||||
font: 2rem 'Dosis', sans-serif;
|
||||
}
|
||||
.checkin-title {
|
||||
font: 500 1.5rem 'Dosis', sans-serif;
|
||||
text-align: center;
|
||||
}
|
||||
.account-balance {
|
||||
font: 3em 'Dosis', sans-serif;
|
||||
}
|
||||
|
||||
table th {
|
||||
font-size: .8em;
|
||||
background-color: var(--bg-dim);
|
||||
}
|
||||
|
||||
thead>tr {
|
||||
border: 1px solid #DDD;
|
||||
position: sticky;
|
||||
top: 4rem;
|
||||
z-index: 98;
|
||||
}
|
||||
|
||||
/* .detail-table {
|
||||
width: max-content;
|
||||
max-width: 100%;
|
||||
} */
|
||||
.detail-table th {
|
||||
vertical-align: middle;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.detail-table td {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.raw-text {
|
||||
white-space: pre-line;
|
||||
}
|
||||
|
||||
tbody>tr {
|
||||
border: 1px solid #DDD;
|
||||
}
|
||||
|
||||
td.table-actions {
|
||||
padding: 0;
|
||||
text-align: center;
|
||||
vertical-align: middle;
|
||||
}
|
||||
td.table-actions a {
|
||||
padding: .25rem;
|
||||
transition: .3s;
|
||||
color: #0002;
|
||||
}
|
||||
tbody>tr:hover td.table-actions a {
|
||||
color: #0006;
|
||||
}
|
||||
td.table-actions a:hover {
|
||||
color: #000B !important;
|
||||
}
|
||||
/* .table-actions a:not(:first-child) {
|
||||
margin-left: .4rem;
|
||||
} */
|
||||
|
||||
.table-sm tbody>tr {
|
||||
border: unset !important;
|
||||
}
|
||||
|
||||
table.clickable tbody>tr {
|
||||
cursor: pointer;
|
||||
}
|
||||
table.clickable tbody>tr:hover {
|
||||
background-color: var(--bg-subtle-hover);
|
||||
}
|
||||
|
||||
.h1-w-icon {
|
||||
display: flex;
|
||||
gap: .3em;
|
||||
align-items: baseline;
|
||||
color: var(--fg-subtle);
|
||||
font-size: 2.5rem;
|
||||
}
|
||||
.h1-w-icon+.h1-w-icon {
|
||||
font-size: 2rem;
|
||||
}
|
||||
.h1-w-icon>h1 {
|
||||
font-size: unset;
|
||||
}
|
||||
|
||||
.h-with-action {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 1rem;
|
||||
}
|
||||
|
||||
.nowrap {
|
||||
overflow: hidden;
|
||||
white-space: nowrap;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.toolbar {
|
||||
display: flex;
|
||||
margin-bottom: .8em;
|
||||
align-items: center;
|
||||
gap: .5rem;
|
||||
}
|
||||
|
||||
.border1 {
|
||||
border: 1px solid
|
||||
}
|
||||
.font-sm {
|
||||
font-size: 90%
|
||||
}
|
||||
|
||||
.avatar {
|
||||
height: 3em;
|
||||
width: 3em;
|
||||
margin-left: 2em;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.avatar button {
|
||||
height: 100%;
|
||||
width: 100%;
|
||||
background-color: #e94190;
|
||||
color: #fff;
|
||||
border-radius: 50%;
|
||||
border: none;
|
||||
}
|
||||
|
||||
.avatar button:hover {
|
||||
color: #e8e8e8;
|
||||
}
|
||||
|
||||
.col {
|
||||
hyphens: auto;
|
||||
}
|
||||
|
||||
.text-red {
|
||||
color: #df2752;
|
||||
}
|
||||
.text-green {
|
||||
color: #23be58;
|
||||
}
|
||||
|
||||
#profileMenu {
|
||||
position: absolute;
|
||||
right: 0;
|
||||
top: 3rem;
|
||||
}
|
||||
|
||||
#wrapper {
|
||||
overflow: visible;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
#sidebar-wrapper {
|
||||
font-family: 'Dosis', sans-serif;
|
||||
font-size: 1.2rem;
|
||||
-webkit-transition: margin .25s ease-out;
|
||||
-moz-transition: margin .25s ease-out;
|
||||
-o-transition: margin .25s ease-out;
|
||||
transition: margin .25s ease-out;
|
||||
position: sticky;
|
||||
top: 4rem;
|
||||
height: calc(100vh - 4rem);
|
||||
}
|
||||
|
||||
#sidebar-wrapper .list-group {
|
||||
width: max-content;
|
||||
}
|
||||
|
||||
#sidebar-wrapper i {
|
||||
width: 2rem;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
#page-content-wrapper {
|
||||
margin-bottom: 15px;
|
||||
}
|
||||
|
||||
.table-toolbar {
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
|
||||
.modal {
|
||||
background-color: rgba(0, 0, 0, .6);
|
||||
}
|
||||
|
||||
.create-form {
|
||||
max-width: 600px;
|
||||
}
|
||||
.create-form input, .create-form select, .create-form textarea {
|
||||
width: 100%;
|
||||
}
|
||||
.create-form input[type="checkbox"] {
|
||||
margin: 0 .5em;
|
||||
width: 1.25em;
|
||||
height: 1.25em;
|
||||
vertical-align: text-bottom;
|
||||
}
|
||||
.create-form input[type="date"],
|
||||
.create-form input[type="time"] {
|
||||
width: unset;
|
||||
padding: .3rem .75rem;
|
||||
vertical-align: middle;
|
||||
}
|
||||
.create-form>div {
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
|
||||
@media (min-width: 768px) {
|
||||
#page-content-wrapper {
|
||||
width: 100%;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 900px) {
|
||||
#sidebar-wrapper:not(.show) {
|
||||
display: none;
|
||||
}
|
||||
|
||||
#navbar-toggler {
|
||||
display: block;
|
||||
}
|
||||
}
|
||||
|
||||
.fw-col {
|
||||
flex: unset
|
||||
}
|
||||
|
||||
/* Disable number up-down arrows */
|
||||
input::-webkit-outer-spin-button,
|
||||
input::-webkit-inner-spin-button {
|
||||
appearance: none;
|
||||
}
|
||||
input[type=number] {
|
||||
appearance: textfield;
|
||||
}
|
||||
9
app/base/static/fontawesome6.4.0/css/all.min.css
vendored
Normal file
9
app/base/static/fontawesome6.4.0/css/all.min.css
vendored
Normal file
File diff suppressed because one or more lines are too long
BIN
app/base/static/fontawesome6.4.0/webfonts/fa-brands-400.ttf
Normal file
BIN
app/base/static/fontawesome6.4.0/webfonts/fa-brands-400.ttf
Normal file
Binary file not shown.
BIN
app/base/static/fontawesome6.4.0/webfonts/fa-brands-400.woff2
Normal file
BIN
app/base/static/fontawesome6.4.0/webfonts/fa-brands-400.woff2
Normal file
Binary file not shown.
BIN
app/base/static/fontawesome6.4.0/webfonts/fa-regular-400.ttf
Normal file
BIN
app/base/static/fontawesome6.4.0/webfonts/fa-regular-400.ttf
Normal file
Binary file not shown.
BIN
app/base/static/fontawesome6.4.0/webfonts/fa-regular-400.woff2
Normal file
BIN
app/base/static/fontawesome6.4.0/webfonts/fa-regular-400.woff2
Normal file
Binary file not shown.
BIN
app/base/static/fontawesome6.4.0/webfonts/fa-solid-900.ttf
Normal file
BIN
app/base/static/fontawesome6.4.0/webfonts/fa-solid-900.ttf
Normal file
Binary file not shown.
BIN
app/base/static/fontawesome6.4.0/webfonts/fa-solid-900.woff2
Normal file
BIN
app/base/static/fontawesome6.4.0/webfonts/fa-solid-900.woff2
Normal file
Binary file not shown.
BIN
app/base/static/fontawesome6.4.0/webfonts/fa-v4compatibility.ttf
Normal file
BIN
app/base/static/fontawesome6.4.0/webfonts/fa-v4compatibility.ttf
Normal file
Binary file not shown.
Binary file not shown.
BIN
app/base/static/fonts/Dosis.ttf
Executable file
BIN
app/base/static/fonts/Dosis.ttf
Executable file
Binary file not shown.
BIN
app/base/static/img/bg.jpg
Normal file
BIN
app/base/static/img/bg.jpg
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 322 KiB |
BIN
app/base/static/img/favicon.ico
Executable file
BIN
app/base/static/img/favicon.ico
Executable file
Binary file not shown.
|
After Width: | Height: | Size: 9.4 KiB |
BIN
app/base/static/img/logo.png
Normal file
BIN
app/base/static/img/logo.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 6.0 KiB |
2
app/base/static/jquery3.6.4/js/jquery.min.js
vendored
Normal file
2
app/base/static/jquery3.6.4/js/jquery.min.js
vendored
Normal file
File diff suppressed because one or more lines are too long
34
app/base/static/js/forms.js
Executable file
34
app/base/static/js/forms.js
Executable file
@@ -0,0 +1,34 @@
|
||||
function dateTimeSetNow(theId) {
|
||||
const now = new Date();
|
||||
const localNow = new Date(Date.UTC(
|
||||
now.getFullYear(), now.getMonth(), now.getDate(),
|
||||
now.getHours(), now.getMinutes()));
|
||||
dateFieldSet(theId, localNow);
|
||||
dateFieldSet(theId + '_day', localNow);
|
||||
dateFieldSet(theId + '_time', localNow);
|
||||
}
|
||||
|
||||
function dateTimeReset(theId) {
|
||||
dateFieldSet(theId, null);
|
||||
dateFieldSet(theId + '_day', null);
|
||||
dateFieldSet(theId + '_time', null);
|
||||
}
|
||||
|
||||
function dateFieldSet(theId, value = null) {
|
||||
const el = document.getElementById(theId);
|
||||
if (el) el.valueAsDate = value;
|
||||
}
|
||||
|
||||
$(document).ready(function () {
|
||||
$('select').select2({
|
||||
width: '100%',
|
||||
placeholder: '–– nicht ausgewählt ––',
|
||||
allowClear: true,
|
||||
// minimumResultsForSearch: 7,
|
||||
// minimumInputLength: 1,
|
||||
});
|
||||
});
|
||||
|
||||
$(document).on('select2:open', () => {
|
||||
document.querySelector('.select2-search__field').focus();
|
||||
});
|
||||
22
app/base/static/js/main.js
Executable file
22
app/base/static/js/main.js
Executable file
@@ -0,0 +1,22 @@
|
||||
function toggleMenu(theId) {
|
||||
const menu = document.getElementById(theId);
|
||||
menu.classList.toggle('show');
|
||||
if (menu.classList.contains('show')) {
|
||||
setTimeout(() => {
|
||||
document.addEventListener('click', () => {
|
||||
menu.classList.remove('show');
|
||||
}, { once: true });
|
||||
}, 50); // timeout to not immediately close
|
||||
}
|
||||
}
|
||||
|
||||
function highlight(div) {
|
||||
const prev = div.style;
|
||||
div.style.transition = 'background-color 0.5s';
|
||||
div.style.backgroundColor = 'yellow';
|
||||
setTimeout(() => {
|
||||
div.style.transition = 'background-color 4s';
|
||||
div.style.backgroundColor = 'unset';
|
||||
}, 500);
|
||||
setTimeout(() => div.style = prev, 600);
|
||||
}
|
||||
8
app/base/static/js/onload.js
Executable file
8
app/base/static/js/onload.js
Executable file
@@ -0,0 +1,8 @@
|
||||
document.querySelectorAll('.table[data-onclick]').forEach((tbl) => {
|
||||
tbl.classList.add('clickable');
|
||||
const callback = new Function(tbl.dataset.onclick);
|
||||
tbl.onclick = (event) => {
|
||||
const entry = event.target.closest('tbody>tr');
|
||||
if (entry) { callback.call(tbl, row=entry); }
|
||||
};
|
||||
});
|
||||
27
app/base/static/js/person-detail.js
Executable file
27
app/base/static/js/person-detail.js
Executable file
@@ -0,0 +1,27 @@
|
||||
window.onload = function () {
|
||||
const timer = document.getElementById('checkin-timer');
|
||||
if (timer) {
|
||||
updateTimer(timer);
|
||||
setInterval(function () { updateTimer(timer); }, 1000);
|
||||
}
|
||||
}
|
||||
|
||||
function updateTimer(timer) {
|
||||
let diff = (new Date() - new Date(timer.dataset.since)) / 1000;
|
||||
const hh = Math.floor(diff / 60 / 60);
|
||||
diff -= hh * 60 * 60;
|
||||
const mm = Math.floor(diff / 60);
|
||||
const ss = Math.floor(diff - mm * 60);
|
||||
timer.textContent = `${hh}h ${mm}min ${ss}s`;
|
||||
}
|
||||
|
||||
function showNoteModal(show) {
|
||||
const div = document.getElementById('note-modal');
|
||||
if (show) {
|
||||
div.style.display = 'block';
|
||||
setTimeout(() => div.classList.add('show'), 10);
|
||||
} else {
|
||||
div.classList.remove('show');
|
||||
setTimeout(() => div.style.display = null, 200); // wait for fade
|
||||
}
|
||||
}
|
||||
1
app/base/static/select2-4.0.13/css/select2.min.css
vendored
Executable file
1
app/base/static/select2-4.0.13/css/select2.min.css
vendored
Executable file
File diff suppressed because one or more lines are too long
3
app/base/static/select2-4.0.13/js/i18n_ol/de.js
Executable file
3
app/base/static/select2-4.0.13/js/i18n_ol/de.js
Executable file
@@ -0,0 +1,3 @@
|
||||
/*! Select2 4.0.13 | https://github.com/select2/select2/blob/master/LICENSE.md */
|
||||
|
||||
!function(){if(jQuery&&jQuery.fn&&jQuery.fn.select2&&jQuery.fn.select2.amd)var e=jQuery.fn.select2.amd;e.define("select2/i18n/de",[],function(){return{errorLoading:function(){return"Die Ergebnisse konnten nicht geladen werden."},inputTooLong:function(e){return"Bitte "+(e.input.length-e.maximum)+" Zeichen weniger eingeben"},inputTooShort:function(e){return"Bitte "+(e.minimum-e.input.length)+" Zeichen mehr eingeben"},loadingMore:function(){return"Lade mehr Ergebnisse…"},maximumSelected:function(e){var n="Sie können nur "+e.maximum+" Element";return 1!=e.maximum&&(n+="e"),n+=" auswählen"},noResults:function(){return"Keine Übereinstimmungen gefunden"},searching:function(){return"Suche…"},removeAllItems:function(){return"Entferne alle Elemente"}}}),e.define,e.require}();
|
||||
3
app/base/static/select2-4.0.13/js/i18n_ol/en.js
Executable file
3
app/base/static/select2-4.0.13/js/i18n_ol/en.js
Executable file
@@ -0,0 +1,3 @@
|
||||
/*! Select2 4.0.13 | https://github.com/select2/select2/blob/master/LICENSE.md */
|
||||
|
||||
!function(){if(jQuery&&jQuery.fn&&jQuery.fn.select2&&jQuery.fn.select2.amd)var e=jQuery.fn.select2.amd;e.define("select2/i18n/en",[],function(){return{errorLoading:function(){return"The results could not be loaded."},inputTooLong:function(e){var n=e.input.length-e.maximum,r="Please delete "+n+" character";return 1!=n&&(r+="s"),r},inputTooShort:function(e){return"Please enter "+(e.minimum-e.input.length)+" or more characters"},loadingMore:function(){return"Loading more results…"},maximumSelected:function(e){var n="You can only select "+e.maximum+" item";return 1!=e.maximum&&(n+="s"),n},noResults:function(){return"No results found"},searching:function(){return"Searching…"},removeAllItems:function(){return"Remove all items"}}}),e.define,e.require}();
|
||||
2
app/base/static/select2-4.0.13/js/select2.min.js
vendored
Executable file
2
app/base/static/select2-4.0.13/js/select2.min.js
vendored
Executable file
File diff suppressed because one or more lines are too long
82
app/base/templates/base.html
Executable file
82
app/base/templates/base.html
Executable file
@@ -0,0 +1,82 @@
|
||||
{% load static %}
|
||||
{% load field_utils %}
|
||||
{% load breadcrumbs %}
|
||||
{% load url_utils %}
|
||||
|
||||
<!DOCTYPE html>
|
||||
<html lang="de">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<link rel="shortcut icon" href="{% static 'img/favicon.ico' %}">
|
||||
<link rel="stylesheet" type="text/css" href="{% static 'bootstrap4.6.2/css/bootstrap.min.css' %}">
|
||||
<link rel="stylesheet" type="text/css" href="{% static 'fontawesome6.4.0/css/all.min.css' %}">
|
||||
<link rel="stylesheet" type="text/css" href="{% static 'css/style.css' %}">
|
||||
{% comment %} <script src="{% static 'jquery3.6.4/js/jquery.min.js' %}"></script> {% endcomment %}
|
||||
{% comment %} <script src="{% static 'bootstrap4.6.2/js/bootstrap.min.js' %}"></script> {% endcomment %}
|
||||
<script src="{% static 'js/main.js' %}"></script>
|
||||
<script src="{% static 'js/onload.js' %}" defer></script>
|
||||
<title>{% block title %}{{ title }}{% endblock title %}</title>
|
||||
</head>
|
||||
<body>
|
||||
<header>
|
||||
<nav id="main-nav" class="navbar navbar-expand-lg navbar-light">
|
||||
<a id="navbar-toggler" onclick="toggleMenu('sidebar-wrapper')">
|
||||
<span class="navbar-toggler-icon"></span>
|
||||
</a>
|
||||
<a class="navbar-brand d-none d-md-block" href="{% url 'index' %}">
|
||||
<img id="logo" src="{% static 'img/logo.png' %}" alt="Werkstattmanagement">
|
||||
</a>
|
||||
<form id="global-search" action="{% url 'person:list' %}" method="get">
|
||||
<input name="q" value="{{ request.GET.q }}">
|
||||
<button type="submit" class="btn bg-owba"><i class="fas fa-search"></i></button>
|
||||
</form>
|
||||
<div class="avatar">
|
||||
<button type="button" data-toggle="dropdown" onclick="toggleMenu('profileMenu')">
|
||||
<i class="far fa-user fa-lg"></i>
|
||||
</button>
|
||||
<div id="profileMenu" class="dropdown-menu dropdown-menu-right">
|
||||
<a class="dropdown-item" href="{% url 'logout' %}">Logout</a>
|
||||
</div>
|
||||
</div>
|
||||
</nav>
|
||||
</header>
|
||||
<div class="d-flex" id="wrapper">
|
||||
<div id="sidebar-wrapper" class="border-right bg-dim">
|
||||
<div class="list-group list-group-flush">
|
||||
<a href="{% url 'booking:list' %}" class="list-group-item list-group-item-action bg-dim"><i class="fas fa-clock"></i> Zeitbuchungen</a>
|
||||
<a href="{% url 'person:list' %}" class="list-group-item list-group-item-action bg-dim"><i class="fas fa-users"></i> Werkstattnutzer:innen</a>
|
||||
<a href="{% url 'course:list' %}" class="list-group-item list-group-item-action bg-dim"><i class="fas fa-graduation-cap"></i> Einweisungen</a>
|
||||
<a href="{% url 'transaction:list' %}" class="list-group-item list-group-item-action bg-dim"><i class="fas fa-euro-sign"></i> Transaktionen</a>
|
||||
<a href="{% url 'trait:list' %}" class="list-group-item list-group-item-action bg-dim"><i class="fas fa-tags"></i> Attribute</a>
|
||||
<a href="{% url 'settings' %}" class="list-group-item list-group-item-action bg-dim"><i class="fas fa-sliders-h"></i> Einstellungen</a>
|
||||
</div>
|
||||
</div>
|
||||
<div id="page-content-wrapper" class="container-fluid position-relative">
|
||||
{% breadcrumbs %}
|
||||
{% block page_title %}
|
||||
{% include 'widgets/title-with-icon.html' with icon=icon title=title %}
|
||||
{% endblock page_title %}
|
||||
<div class="toolbar">
|
||||
{% block toolbar_left %}
|
||||
{% endblock toolbar_left %}
|
||||
<span style="flex-grow: 1"></span>
|
||||
{% block toolbar_right %}
|
||||
{% for item in toolbar_buttons %}
|
||||
<a href="{% url_builder item %}" class="btn btn-primary">
|
||||
{% if item.icon %}
|
||||
<i class="fas fa-{{ item.icon }}"></i>
|
||||
{% endif %}
|
||||
{{ item.label }}
|
||||
</a>
|
||||
{% endfor %}
|
||||
{% endblock %}
|
||||
</div>
|
||||
<div id="main-content">
|
||||
{% block content %}{% endblock content %}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{% block scripts %}
|
||||
{% endblock scripts %}
|
||||
</body>
|
||||
</html>
|
||||
11
app/base/templates/booking_list.html
Executable file
11
app/base/templates/booking_list.html
Executable file
@@ -0,0 +1,11 @@
|
||||
{% extends 'generic/list.html' %}
|
||||
{% load tabular_list %}
|
||||
|
||||
{% block content %}
|
||||
{% if open_bookings %}
|
||||
<h2>Offene {{ title }}</h2>
|
||||
{% tabular_list objects=open_bookings views=views columns=list_columns render_options=list_render %}
|
||||
<h2>Alle {{ title }}</h2>
|
||||
{% endif %}
|
||||
{{ block.super }}
|
||||
{% endblock content %}
|
||||
7
app/base/templates/forms/widgets/date.html
Executable file
7
app/base/templates/forms/widgets/date.html
Executable file
@@ -0,0 +1,7 @@
|
||||
<div>
|
||||
{% include "django/forms/widgets/date.html" %}
|
||||
<button type="button" class="btn btn-outline-primary" onclick="dateTimeSetNow('{{ widget.attrs.id }}')" title="Aktuelles Datum setzen">Heute</button>
|
||||
{% if not widget.required %}
|
||||
<button type="button" class="btn btn-outline-secondary" onclick="dateTimeReset('{{ widget.attrs.id }}')" title="Datum zurücksetzen"><i class="far fa-trash-alt"></i></button>
|
||||
{% endif %}
|
||||
</div>
|
||||
8
app/base/templates/forms/widgets/datetime.html
Executable file
8
app/base/templates/forms/widgets/datetime.html
Executable file
@@ -0,0 +1,8 @@
|
||||
<div>
|
||||
<input type="date" id="{{ widget.attrs.id }}_day" name="{{ widget.name }}"{% if widget.value %} value="{{ widget.value|date:'Y-m-d' }}"{% endif %}>
|
||||
<input type="time" id="{{ widget.attrs.id }}_time" name="{{ widget.name }}"{% if widget.value %} value="{{ widget.value|date:'H:i' }}"{% endif %}>
|
||||
<button type="button" class="btn btn-outline-primary" onclick="dateTimeSetNow('{{ widget.attrs.id }}')" title="Aktuelles Datum setzen">Jetzt</button>
|
||||
{% if not widget.required %}
|
||||
<button type="button" class="btn btn-outline-secondary" onclick="dateTimeReset('{{ widget.attrs.id }}')" title="Datum zurücksetzen"><i class="far fa-trash-alt"></i></button>
|
||||
{% endif %}
|
||||
</div>
|
||||
3
app/base/templates/forms/widgets/textarea.html
Executable file
3
app/base/templates/forms/widgets/textarea.html
Executable file
@@ -0,0 +1,3 @@
|
||||
{% load utils %}
|
||||
<textarea name="{{ widget.name }}" rows="{{widget.value|count_lines|min:4|max:10}}"{% include "django/forms/widgets/attrs.html" %}>
|
||||
{% if widget.value %}{{ widget.value }}{% endif %}</textarea>
|
||||
13
app/base/templates/fragment/field_title.html
Normal file
13
app/base/templates/fragment/field_title.html
Normal file
@@ -0,0 +1,13 @@
|
||||
{% spaceless %}
|
||||
{% load utils %}{# model_verbose_name #}
|
||||
|
||||
{% if render_options.verbose_name %}
|
||||
{{ render_options.verbose_name }}
|
||||
{% elif field.verbose_name %}
|
||||
{{ field.verbose_name }}
|
||||
{% elif field.one_to_one or field.many_to_one %}
|
||||
{{ field.remote_field|model_verbose_name }}
|
||||
{% else %}
|
||||
{{ field }}
|
||||
{% endif %}
|
||||
{% endspaceless %}
|
||||
14
app/base/templates/fragment/field_value.html
Normal file
14
app/base/templates/fragment/field_value.html
Normal file
@@ -0,0 +1,14 @@
|
||||
{% spaceless %}
|
||||
{% load utils %}{# format #}
|
||||
{% load field_utils %}{# ref_link #}
|
||||
|
||||
{% if render_options.date_format %}
|
||||
{{ obj|date:render_options.date_format|default:'–' }}
|
||||
{% elif render_options.format %}
|
||||
{{ obj|format:render_options.format }}
|
||||
{% elif clickable_refs %}
|
||||
{{ obj|ref_link:'reflink' }}
|
||||
{% else %}
|
||||
{{ obj }}
|
||||
{% endif %}
|
||||
{% endspaceless %}{% if render_options.is_price %} €{% endif %}
|
||||
18
app/base/templates/generic/create.html
Executable file
18
app/base/templates/generic/create.html
Executable file
@@ -0,0 +1,18 @@
|
||||
{% extends 'base.html' %}
|
||||
{% load static %}
|
||||
{% load url_utils %}{# back_url #}
|
||||
|
||||
{% block content %}
|
||||
<form method="post" class="create-form">{% csrf_token %}
|
||||
{{ form.as_div }}
|
||||
<a class="btn btn-secondary" href="{% back_url %}">Abbrechen</a>
|
||||
<button type="submit" class="btn btn-primary">{{submit_label}}</button>
|
||||
</form>
|
||||
{% endblock %}
|
||||
|
||||
{% block scripts %}
|
||||
<link rel="stylesheet" type="text/css" href="{% static 'select2-4.0.13/css/select2.min.css' %}">
|
||||
<script src="{% static 'jquery3.6.4/js/jquery.min.js' %}"></script>
|
||||
<script src="{% static 'select2-4.0.13/js/select2.min.js' %}"></script>
|
||||
<script src="{% static 'js/forms.js' %}"></script>
|
||||
{% endblock %}
|
||||
10
app/base/templates/generic/delete.html
Executable file
10
app/base/templates/generic/delete.html
Executable file
@@ -0,0 +1,10 @@
|
||||
{% extends 'base.html' %}
|
||||
{% load url_utils %}
|
||||
|
||||
{% block content %}
|
||||
<form method="post">{% csrf_token %}
|
||||
<p>Bist du dir sicher dass du {{ model_verbose_name }} <b>"{{ object }}"</b> löschen möchtest?</p>
|
||||
<a class="btn btn-primary" href="{% back_url %}">Abbrechen</a>
|
||||
<button type="submit" class="btn btn-danger"><i class="far fa-trash-alt"></i> {{submit_label|default:'Weg damit!'}}</button>
|
||||
</form>
|
||||
{% endblock %}
|
||||
17
app/base/templates/generic/detail.html
Executable file
17
app/base/templates/generic/detail.html
Executable file
@@ -0,0 +1,17 @@
|
||||
{% extends 'base.html' %}
|
||||
{% load url_utils %}
|
||||
|
||||
{% block toolbar_left %}
|
||||
{% if views.update %}
|
||||
<a class="btn btn-sm btn-secondary" href="{% url views.update object.pk %}{% query_url prev=request.path %}">
|
||||
<i class="fas fa-edit"></i> Bearbeiten</a>
|
||||
{% endif %}
|
||||
{% if views.delete %}
|
||||
<a class="btn btn-sm btn-secondary" href="{% url views.delete object.pk %}{% query_url prev=request.path %}">
|
||||
<i class="fas fa-trash-alt"></i> Löschen</a>
|
||||
{% endif %}
|
||||
{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
{% include 'templatetags/detail_table.html' %}
|
||||
{% endblock %}
|
||||
44
app/base/templates/generic/list.html
Executable file
44
app/base/templates/generic/list.html
Executable file
@@ -0,0 +1,44 @@
|
||||
{% extends 'base.html' %}
|
||||
{% load tabular_list %}
|
||||
{% load url_utils %}
|
||||
{% load utils %}
|
||||
|
||||
{% block title %}
|
||||
{% if active_search_query %}Suchergebnisse – {% endif %}{{ block.super }}
|
||||
{% endblock %}
|
||||
|
||||
{% block page_title %}
|
||||
{% if active_search_query %}
|
||||
{% include 'widgets/title-with-icon.html' with icon='search' title=active_search_query|format:'Suchergebnisse für "{}"' %}
|
||||
{% endif %}
|
||||
{{ block.super }}
|
||||
{% endblock %}
|
||||
|
||||
{% block toolbar_right %}
|
||||
{% if views.create %}
|
||||
<a class="btn btn-primary" href="{% url views.create %}">
|
||||
<i class="fas fa-plus"></i> {{ model_verbose_name }} hinzufügen</a>
|
||||
{% endif %}
|
||||
{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
{% include 'widgets/table_filter.html' %}
|
||||
{% tabular_list objects=object_list views=views columns=list_columns render_options=list_render %}
|
||||
<div class="pagination">
|
||||
<span class="step-links">
|
||||
{% if page_obj.has_previous %}
|
||||
<a href="{% query_url page=None %}">« Erste Seite</a> |
|
||||
<a href="{% query_url page=page_obj.previous_page_number %}">vorherige Seite</a>
|
||||
{% endif %}
|
||||
|
||||
<span class="current">
|
||||
Seite {{ page_obj.number|default:1 }} von {{ page_obj.paginator.num_pages|default:1 }}.
|
||||
</span>
|
||||
|
||||
{% if page_obj.has_next %}
|
||||
<a href="{% query_url page=page_obj.next_page_number %}">nächste Seite</a> |
|
||||
<a href="{% query_url page=page_obj.paginator.num_pages %}">Letzte Seite »</a>
|
||||
{% endif %}
|
||||
</span>
|
||||
</div>
|
||||
{% endblock %}
|
||||
18
app/base/templates/generic/update.html
Executable file
18
app/base/templates/generic/update.html
Executable file
@@ -0,0 +1,18 @@
|
||||
{% extends 'base.html' %}
|
||||
{% load static %}
|
||||
{% load url_utils %}{# back_url #}
|
||||
|
||||
{% block content %}
|
||||
<form method="post" class="create-form">{% csrf_token %}
|
||||
{{ form.as_div }}
|
||||
<a class="btn btn-secondary" href="{% back_url %}">Abbrechen</a>
|
||||
<button type="submit" class="btn btn-primary">Änderungen speichern</button>
|
||||
</form>
|
||||
{% endblock %}
|
||||
|
||||
{% block scripts %}
|
||||
<link rel="stylesheet" type="text/css" href="{% static 'select2-4.0.13/css/select2.min.css' %}">
|
||||
<script src="{% static 'jquery3.6.4/js/jquery.min.js' %}"></script>
|
||||
<script src="{% static 'select2-4.0.13/js/select2.min.js' %}"></script>
|
||||
<script src="{% static 'js/forms.js' %}"></script>
|
||||
{% endblock %}
|
||||
89
app/base/templates/person_detail.html
Executable file
89
app/base/templates/person_detail.html
Executable file
@@ -0,0 +1,89 @@
|
||||
{% extends 'generic/detail.html' %}
|
||||
{% load static %}
|
||||
{% load tabular_list %}
|
||||
{% load utils %}
|
||||
{% load url_utils %}
|
||||
{% load color_price %}
|
||||
|
||||
{% block toolbar_left %}
|
||||
{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
{% query_url prev=request.path prevname=object|truncatechars:40 as back_query %}
|
||||
<div class="container-fluid">
|
||||
<div class="row">
|
||||
<div class="col-md-8 p-0">
|
||||
<div class="mb-3">
|
||||
<h6 class="d-inline-block">Attribute:</h6>
|
||||
{% for attr in object.attributes|dictsort:2 %}
|
||||
<a class="badge border1" href="{% url 'trait-mapping:detail' attr.0 %}">{{ attr.2 }}</a>
|
||||
{% endfor %}
|
||||
<a class="badge badge-secondary" href="{% url 'trait-mapping:create' %}{{ back_query }}&user={{ object.pk }}" title="Attribut hinzufügen"><i class="fas fa-plus"></i></a>
|
||||
</div>
|
||||
|
||||
{% include 'widgets/alerts.html' %}
|
||||
{% include 'widgets/note-modal.html' %}
|
||||
|
||||
<div class="h-with-action">
|
||||
<h2>Stammdaten:</h2>
|
||||
<div>
|
||||
<a title="{{model_verbose_name}} bearbeiten" class="btn btn-sm btn-outline-primary" href="{% url 'person:update' object.pk %}{{ back_query }}"><i class="fas fa-edit"></i></a>
|
||||
<a title="{{model_verbose_name}} löschen" class="btn btn-sm btn-outline-danger" href="{% url 'person:delete' object.pk %}{{ back_query }}"><i class="far fa-trash-alt"></i></a>
|
||||
<a title="Notiz hinzufügen" class="btn btn-sm btn-outline-warning" onclick="showNoteModal(true)"><i class="far fa-message"></i> Notiz</a>
|
||||
</div>
|
||||
</div>
|
||||
{{ block.super }}
|
||||
|
||||
<div class="h-with-action">
|
||||
<h2>Einweisungen:</h2>
|
||||
<a class="btn btn-sm btn-outline-primary" href="{% url 'course-visit:create' %}{{ back_query }}&user={{ object.pk }}" title="Neue Einweisung hinzufügen"><i class="fas fa-plus"></i></a>
|
||||
</div>
|
||||
{% tabular_list objects=course_list.objects views=course_list.views columns=course_list.columns render_options=course_list.render %}
|
||||
|
||||
<div class="h-with-action">
|
||||
<h2>Buchungen:</h2>
|
||||
<a class="btn btn-sm btn-outline-primary" href="{% url 'booking:create' %}{{ back_query }}&user={{ object.pk }}" title="Neue Buchung hinzufügen"><i class="fas fa-plus"></i></a>
|
||||
</div>
|
||||
{% tabular_list objects=bookings.objects views=bookings.views columns=bookings.columns render_options=bookings.render %}
|
||||
</div>
|
||||
|
||||
<div id="sidebar-detail" class="col-md-4">
|
||||
{% include 'widgets/checkin-status.html' with user=object %}
|
||||
|
||||
<div class="text-center">
|
||||
<span class="font-dosis">Kontostand</span>
|
||||
<div class="account-balance mb-3 {% color_price object.account.balance %}">
|
||||
{{ object.account.balance }}€
|
||||
</div>
|
||||
<form method="get" action="{% url 'transaction:create' %}" onsubmit>
|
||||
<div class="input-group">
|
||||
<input name="amount" class="form-control text-right" type="number" min="0" step="0.5" value="{{ object.account.balance|invert|min:object.account.balance|default:10|floatformat:'2u' }}">
|
||||
<div class="input-group-append">
|
||||
<span class="input-group-text">€</span>
|
||||
</div>
|
||||
</div>
|
||||
<input type="hidden" name="account" value="{{ object.account.pk }}">
|
||||
<div class="btn-group btn-block mt-2">
|
||||
<button name="_type" class="btn btn-outline-success" value="deposit-plus">Einzahlen</button>
|
||||
<button name="_type" class="btn btn-outline-danger" value="deposit-minus">Auszahlen</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<span class="font-dosis text-center">Transaktionen:</span>
|
||||
{% tabular_list objects=transaction_list.objects views=transaction_list.views columns=transaction_list.columns render_options=transaction_list.render is_small=True hide_thead=True %}
|
||||
{% if transaction_list.objects|length > 0 %}
|
||||
<div class="text-center">
|
||||
<a href="{% url 'transaction:list' %}{{ back_query }}&user={{ object.pk }}">Alle anzeigen</a>
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{% endblock content %}
|
||||
|
||||
{% block scripts %}
|
||||
<script src="{% static 'js/person-detail.js' %}"></script>
|
||||
{% endblock scripts %}
|
||||
56
app/base/templates/registration/login.html
Executable file
56
app/base/templates/registration/login.html
Executable file
@@ -0,0 +1,56 @@
|
||||
{% load static %}
|
||||
<!DOCTYPE html>
|
||||
<html lang="de">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<link rel="shortcut icon" href="{% static 'img/favicon.ico' %}">
|
||||
<link rel="stylesheet" type="text/css" href="{% static 'bootstrap4.6.2/css/bootstrap.min.css' %}">
|
||||
<link rel="stylesheet" type="text/css" href="{% static 'css/login.css' %}">
|
||||
<title>Login</title>
|
||||
</head>
|
||||
<body>
|
||||
<div class="login-form w-md-50">
|
||||
{% if form.errors %}
|
||||
<div class="alert alert-warning alert-dismissible fade show" role="alert">
|
||||
Your username and password didn't match. Please try again.
|
||||
<button type="button" class="close" data-dismiss="alert" aria-label="Close">
|
||||
<span aria-hidden="true">×</span>
|
||||
</button>
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
{% if next %}
|
||||
{% if user.is_authenticated %}
|
||||
<div class="alert alert-warning alert-dismissible fade show" role="alert">
|
||||
Your account doesn't have access to this page. To proceed,
|
||||
please login with an account that has access.
|
||||
<button type="button" class="close" data-dismiss="alert" aria-label="Close">
|
||||
<span aria-hidden="true">×</span>
|
||||
</button>
|
||||
</div>
|
||||
{% else %}
|
||||
<p>Log dich ein um auf die Seite zuzugreifen.</p>
|
||||
{% endif %}
|
||||
{% endif %}
|
||||
|
||||
<h1>Bitte log dich ein.</h1>
|
||||
<form method="post" action="{% url 'login' %}">
|
||||
{% csrf_token %}
|
||||
<p>
|
||||
{{ form.username.label_tag }}
|
||||
{{ form.username }}
|
||||
</p>
|
||||
<p>
|
||||
{{ form.password.label_tag }}
|
||||
{{ form.password }}
|
||||
</p>
|
||||
|
||||
<input type="submit" class="btn btn-primary" value="Einloggen">
|
||||
{% if next %}
|
||||
<input type="hidden" name="next" value="{{ next }}">
|
||||
{% else %}
|
||||
<input type="hidden" name="next" value="/">
|
||||
{% endif %}
|
||||
</form>
|
||||
</div>
|
||||
</body>
|
||||
8
app/base/templates/settings.html
Executable file
8
app/base/templates/settings.html
Executable file
@@ -0,0 +1,8 @@
|
||||
{% extends 'base.html' %}
|
||||
{% load tabular_list %}
|
||||
|
||||
{% block content %}
|
||||
<h2>Buchungstypen</h2>
|
||||
{% tabular_list objects=booking_types.objects views=booking_types.views columns=booking_types.columns render_options=booking_types.render %}
|
||||
|
||||
{% endblock content %}
|
||||
12
app/base/templates/templatetags/breadcrumbs.html
Executable file
12
app/base/templates/templatetags/breadcrumbs.html
Executable file
@@ -0,0 +1,12 @@
|
||||
<div id="breadcrumbs">
|
||||
<a href="{% url 'index' %}">Home</a>
|
||||
{% if breadcrumbs %}
|
||||
{% for key, url in breadcrumbs %}
|
||||
{% if url is None %}
|
||||
› <span>{{ key }}</span>
|
||||
{% else %}
|
||||
› <a href="{{ url }}">{{ key }}</a>
|
||||
{% endif %}
|
||||
{% endfor %}
|
||||
{% endif %}
|
||||
</div>
|
||||
15
app/base/templates/templatetags/detail_table.html
Executable file
15
app/base/templates/templatetags/detail_table.html
Executable file
@@ -0,0 +1,15 @@
|
||||
{% load field_utils %}{# get_fields, field_value #}
|
||||
{% load utils %}{# get_item #}
|
||||
|
||||
<table class="table detail-table">
|
||||
<tbody>
|
||||
{% for field in object|get_fields:detail_fields %}
|
||||
<tr>
|
||||
{% with RENDER=detail_render|get_item:field.name %}
|
||||
<th scope="row">{% include 'fragment/field_title.html' with field=field render_options=RENDER %}</th>
|
||||
<td{% if field.get_internal_type == 'TextField' %} class="raw-text"{%endif%}>{% include 'fragment/field_value.html' with obj=object|field_value:field.name render_options=RENDER clickable_refs=True %}</td>
|
||||
{% endwith %}
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
76
app/base/templates/templatetags/table.html
Executable file
76
app/base/templates/templatetags/table.html
Executable file
@@ -0,0 +1,76 @@
|
||||
{% load utils %}{# format #}
|
||||
{% load field_utils %}{# field_value, col_render_width, col_render_class #}
|
||||
{% load url_utils %}{# query_url #}
|
||||
|
||||
{% if objects|length > 0 %}
|
||||
<table class="table{% if is_small %} table-sm{% endif %}" {% if views.detail %}data-onclick="window.location.href='{% url views.detail 0 %}'.replace('0', row.dataset.pk)"{% endif %}>
|
||||
<colgroup>
|
||||
{% for column in columns %}
|
||||
<col {% if column.render_options.width %}style="width: {{ column.render_options|col_render_width }}"{% endif %}>
|
||||
{% endfor %}
|
||||
{% if views.update or views.delete %}
|
||||
<col style="width: 3.5rem">
|
||||
{% endif %}
|
||||
</colgroup>
|
||||
{% if not hide_thead %}
|
||||
<thead>
|
||||
<tr>
|
||||
{% for column in columns %}
|
||||
<th scope="col"{% if column.render_options.class %} class="{{column.render_options.class}}"{% endif %}>
|
||||
{% include 'fragment/field_title.html' with field=column.field render_options=column.render_options %}
|
||||
</th>
|
||||
{% endfor %}
|
||||
{% if views.update or views.delete %}
|
||||
<th scope="col"></th>
|
||||
{% endif %}
|
||||
</tr>
|
||||
</thead>
|
||||
{% endif %}
|
||||
<tbody>
|
||||
{% for object in objects %}
|
||||
<tr data-pk="{{ object.pk }}">
|
||||
{% for column in columns %}
|
||||
{% with X=object|field_value:column.name %}
|
||||
<td class="{{ X|col_render_class:column.render_options }}">
|
||||
{% include 'fragment/field_value.html' with obj=X render_options=column.render_options %}
|
||||
</td>
|
||||
{% endwith %}
|
||||
{% endfor %}
|
||||
{% if views.update or views.delete %}
|
||||
<td class="table-actions">{% spaceless %}
|
||||
{% if views.update %}
|
||||
<a title="Bearbeiten" href="{% url views.update object.pk %}{% query_url prev=request.path prevname=prevname %}">
|
||||
<i class="far fa-edit"></i>
|
||||
</a>
|
||||
{% endif %}
|
||||
{% if views.delete %}
|
||||
<a title="Löschen" href="{% url views.delete object.pk %}{% query_url prev=request.path prevname=prevname %}">
|
||||
<i class="far fa-trash-alt"></i>
|
||||
</a>
|
||||
{% endif %}
|
||||
{% endspaceless %}</td>
|
||||
{% endif %}
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
{% if request.GET.selected %}
|
||||
<script>
|
||||
highlight(document.querySelector('[data-pk="{{request.GET.selected}}"]'));
|
||||
</script>
|
||||
{% endif %}
|
||||
{% else %}
|
||||
<table class="table{% if is_small %} table-sm{% endif %}">
|
||||
<tbody>
|
||||
<tr class="text-center">
|
||||
<td>
|
||||
{% if active_search_query %}
|
||||
Keine Einträge gefunden für "{{active_search_query}}".
|
||||
{% else %}
|
||||
Bisher keine Einträge.
|
||||
{% endif %}
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
{% endif %}
|
||||
27
app/base/templates/widgets/alerts.html
Executable file
27
app/base/templates/widgets/alerts.html
Executable file
@@ -0,0 +1,27 @@
|
||||
{% if object.note %}
|
||||
<div class="alert alert-warning d-flex">
|
||||
<span><i class="far fa-message"></i></span>
|
||||
<span class="raw-text ml-2">{{ object.note }}</span>
|
||||
</div>
|
||||
{% endif %}
|
||||
{% if object.agreed_to_terms_of_service is False %}
|
||||
<div class="alert alert-warning">
|
||||
<span>Hat noch nicht in die Nutzungsbedingungen eingewilligt!</span>
|
||||
</div>
|
||||
{% endif %}
|
||||
{% if object.identified is False %}
|
||||
<div class="alert alert-warning">
|
||||
<span>Hat noch kein Ausweisdokument vorgelegt!</span>
|
||||
</div>
|
||||
{% endif %}
|
||||
{% if object.missing_courses|length > 0 %}
|
||||
<div class="alert alert-warning">
|
||||
<span>Es fehlen noch folgende Einweisungen:</span>
|
||||
<hr>
|
||||
<ul>
|
||||
{% for course in object.missing_courses %}
|
||||
<li>{{ course.title }} <a href="{% url 'course-visit:create' %}?user={{ object.pk }}&course={{ course.pk }}&prev={{request.path}}">(hinzufügen)</a></li>
|
||||
{% endfor %}
|
||||
</ul>
|
||||
</div>
|
||||
{% endif %}
|
||||
22
app/base/templates/widgets/checkin-status.html
Executable file
22
app/base/templates/widgets/checkin-status.html
Executable file
@@ -0,0 +1,22 @@
|
||||
{% load field_utils %}
|
||||
|
||||
<div class="text-center">
|
||||
{% if user.current_checkin %}
|
||||
<div id="checkin-timer" class="text-green mb-3" data-since="{{ user.current_checkin.begin_time|date:'r' }}">0h 0min 0s</div>
|
||||
<div class="checkin-title mb-3"><i class="fas fa-circle text-green"></i> {{ user.first_name }} ist in der Werkstatt.</div>
|
||||
<a class="btn btn-primary btn-block" href="{% url 'toggle-checkin' user.id %}?next={{ request.path }}">{{ user.first_name }} auschecken</a>
|
||||
{% else %}
|
||||
<div class="checkin-title mb-3"><i class="fas fa-circle text-red"></i> {{ user.first_name }} ist zuhause.</div>
|
||||
<form method="GET" action="{% url 'toggle-checkin' user.id %}">
|
||||
<div class="form-group">
|
||||
<label for="plan-select">Tarif:</label>
|
||||
<select class="custom-select mb-2" id="plan-select" name="plan">
|
||||
<option value="basic" selected>BASIC</option>
|
||||
<option value="plus">PLUS</option>
|
||||
</select>
|
||||
<input type="hidden" name="next" value="{{ request.path }}">
|
||||
</div>
|
||||
<button type="submit" class="btn btn-primary btn-block">{{ user.first_name }} einchecken</button>
|
||||
</form>
|
||||
{% endif %}
|
||||
</div>
|
||||
16
app/base/templates/widgets/note-modal.html
Executable file
16
app/base/templates/widgets/note-modal.html
Executable file
@@ -0,0 +1,16 @@
|
||||
<div id="note-modal" class="modal fade">
|
||||
<div class="modal-dialog">
|
||||
<div class="modal-content">
|
||||
<form method="POST" action="{% url 'update-note' object.pk %}">
|
||||
{% csrf_token %}
|
||||
<div class="modal-body">
|
||||
<textarea name="text" rows="4" class="form-control" placeholder="(keine Notiz)" autocomplete="off">{{ object.note }}</textarea>
|
||||
</div>
|
||||
<div class="modal-footer">
|
||||
<button type="button" class="btn btn-secondary" onclick="showNoteModal(false)">Abbrechen</button>
|
||||
<button type="submit" class="btn btn-primary">Notiz speichern</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
13
app/base/templates/widgets/table_filter.html
Normal file
13
app/base/templates/widgets/table_filter.html
Normal file
@@ -0,0 +1,13 @@
|
||||
{% load utils %}
|
||||
{% load url_utils %}
|
||||
|
||||
{% if list_filter.user and request.GET.user %}
|
||||
{% lookup_user request.GET.user as usr %}
|
||||
{% elif list_filter.teacher and request.GET.teacher %}
|
||||
{% lookup_user request.GET.teacher as usr %}
|
||||
{% endif %}
|
||||
|
||||
{% if usr %}
|
||||
<h4>Filter: <a href="{{ usr.get_absolute_url }}">{{ usr }}</a></h4>
|
||||
Filter aufheben: <a href=".">alle anzeigen</a>
|
||||
{% endif %}
|
||||
4
app/base/templates/widgets/title-with-icon.html
Normal file
4
app/base/templates/widgets/title-with-icon.html
Normal file
@@ -0,0 +1,4 @@
|
||||
<div class="h1-w-icon">
|
||||
<i class="fas fa-{{ icon }}"></i>
|
||||
<h1>{{ title }}</h1>
|
||||
</div>
|
||||
0
app/base/templatetags/__init__.py
Executable file
0
app/base/templatetags/__init__.py
Executable file
28
app/base/templatetags/breadcrumbs.py
Executable file
28
app/base/templatetags/breadcrumbs.py
Executable file
@@ -0,0 +1,28 @@
|
||||
from django import template
|
||||
from app.base.templatetags.url_utils import url_with_formatter
|
||||
|
||||
register = template.Library()
|
||||
|
||||
|
||||
@register.inclusion_tag('templatetags/breadcrumbs.html', takes_context=True)
|
||||
def breadcrumbs(context: dict) -> dict:
|
||||
crumbs = context.get('breadcrumbs', [])
|
||||
if isinstance(crumbs, dict):
|
||||
crumbs = crumbs.items()
|
||||
|
||||
rv = []
|
||||
for title, url_obj in crumbs:
|
||||
title = title.format(*[context] * 9)
|
||||
if url_obj:
|
||||
rv.append((title, url_with_formatter(url_obj, context)))
|
||||
else:
|
||||
rv.append((title, None))
|
||||
|
||||
# if previous target is set, insert before last entry
|
||||
request = context['request']
|
||||
prev_url = request.GET.get('prev')
|
||||
prev_title = request.GET.get('prevname')
|
||||
if prev_url and prev_title:
|
||||
rv.insert(max(0, len(rv) - 1), (prev_title, prev_url))
|
||||
|
||||
return {'breadcrumbs': rv}
|
||||
14
app/base/templatetags/color_price.py
Executable file
14
app/base/templatetags/color_price.py
Executable file
@@ -0,0 +1,14 @@
|
||||
from django import template
|
||||
from decimal import Decimal
|
||||
|
||||
register = template.Library()
|
||||
|
||||
|
||||
@register.simple_tag
|
||||
def color_price(value: Decimal) -> str:
|
||||
assert isinstance(value, Decimal), 'Value does not seem to be a Decimal'
|
||||
if value > 0:
|
||||
return 'text-green'
|
||||
elif value < 0:
|
||||
return 'text-red'
|
||||
return ''
|
||||
13
app/base/templatetags/dev.py
Executable file
13
app/base/templatetags/dev.py
Executable file
@@ -0,0 +1,13 @@
|
||||
from django import template
|
||||
|
||||
register = template.Library()
|
||||
|
||||
|
||||
@register.filter(name='dir')
|
||||
def _dir(obj) -> object:
|
||||
return dir(obj)
|
||||
|
||||
|
||||
@register.filter(name='type')
|
||||
def _type(obj) -> object:
|
||||
return type(obj)
|
||||
86
app/base/templatetags/field_utils.py
Executable file
86
app/base/templatetags/field_utils.py
Executable file
@@ -0,0 +1,86 @@
|
||||
from django import template
|
||||
from django.core.exceptions import FieldDoesNotExist
|
||||
from django.utils.safestring import mark_safe
|
||||
from app.base.models.account import Account
|
||||
from .color_price import color_price
|
||||
|
||||
register = template.Library()
|
||||
|
||||
|
||||
@register.filter
|
||||
def get_fields(obj, fields):
|
||||
if fields == '__all__':
|
||||
rv = [x for x in type(obj)._meta.get_fields() if x.name != 'id']
|
||||
else:
|
||||
rv = [type(obj)._meta.get_field(x) for x in fields]
|
||||
# filter ManyToManyRel and ManyToOneRel fields
|
||||
return (x for x in rv if not x.many_to_many and not x.one_to_many)
|
||||
|
||||
|
||||
@register.filter
|
||||
def field_value(obj, field_name):
|
||||
try:
|
||||
value = getattr(obj, field_name)
|
||||
except AttributeError:
|
||||
return None
|
||||
|
||||
# model_field = type(obj)._meta.get_field(field_name)
|
||||
|
||||
if isinstance(value, bool):
|
||||
value = 'Ja' if value else 'Nein'
|
||||
elif value is None:
|
||||
value = '–'
|
||||
|
||||
try:
|
||||
model_field = type(obj)._meta.get_field(field_name)
|
||||
if model_field.choices:
|
||||
# use display value instead of choice-key
|
||||
value = dict(model_field.choices)[value]
|
||||
except (FieldDoesNotExist, AttributeError):
|
||||
pass
|
||||
|
||||
return value
|
||||
|
||||
|
||||
@register.filter
|
||||
def ref_link(cell_value, classname: str):
|
||||
if (isinstance(cell_value, Account)):
|
||||
cell_value = cell_value.user
|
||||
if hasattr(cell_value, 'get_absolute_url'):
|
||||
return mark_safe('<a class="{}" href="{}">{}</a>'.format(
|
||||
classname, cell_value.get_absolute_url(), cell_value))
|
||||
return cell_value
|
||||
|
||||
|
||||
@register.filter
|
||||
def col_render_width(render_options: dict) -> str:
|
||||
if render_options:
|
||||
w = render_options.get('width')
|
||||
if isinstance(w, str):
|
||||
return w
|
||||
if isinstance(w, int):
|
||||
return str(w / 12 * 100) + '%'
|
||||
return '100%'
|
||||
|
||||
|
||||
@register.filter
|
||||
def col_render_class(cell_value: object, render_options: dict) -> str:
|
||||
if not render_options:
|
||||
return ''
|
||||
|
||||
classList = []
|
||||
# special color coding for currency columns
|
||||
if cell_value and bool(render_options.get('is_price', False)):
|
||||
classList.append(color_price(cell_value))
|
||||
|
||||
# append user classes
|
||||
user_classes = render_options.get('class')
|
||||
if user_classes:
|
||||
if isinstance(user_classes, str):
|
||||
classList.append(user_classes)
|
||||
elif isinstance(user_classes, (list, tuple, set)):
|
||||
classList.extend(user_classes)
|
||||
else:
|
||||
raise AttributeError('render_options.class must be str or list')
|
||||
|
||||
return ' '.join(classList)
|
||||
52
app/base/templatetags/tabular_list.py
Executable file
52
app/base/templatetags/tabular_list.py
Executable file
@@ -0,0 +1,52 @@
|
||||
from django import template
|
||||
from django.core.exceptions import FieldDoesNotExist
|
||||
|
||||
register = template.Library()
|
||||
|
||||
|
||||
@register.inclusion_tag('templatetags/table.html', takes_context=True)
|
||||
def tabular_list(
|
||||
context,
|
||||
objects: list,
|
||||
columns: list,
|
||||
render_options: dict,
|
||||
views: dict,
|
||||
**kwargs,
|
||||
) -> dict:
|
||||
if not objects:
|
||||
return {
|
||||
'active_search_query': context.get('active_search_query'),
|
||||
**kwargs, # e.g. `is_small`
|
||||
}
|
||||
|
||||
obj_class = type(objects[0])
|
||||
|
||||
if not columns:
|
||||
columns = [x.name for x in obj_class._meta.get_fields()]
|
||||
|
||||
column_fields = []
|
||||
|
||||
for field in columns:
|
||||
try:
|
||||
field = obj_class._meta.get_field(field)
|
||||
field_dict = {
|
||||
'name': field.name,
|
||||
'field': field,
|
||||
'render_options': render_options.get(field.name, {})
|
||||
}
|
||||
except FieldDoesNotExist:
|
||||
field_dict = {
|
||||
'name': field,
|
||||
'field': None,
|
||||
'render_options': render_options.get(field, {})
|
||||
}
|
||||
column_fields.append(field_dict)
|
||||
|
||||
return {
|
||||
'views': views,
|
||||
'objects': objects,
|
||||
'columns': column_fields,
|
||||
'request': context['request'],
|
||||
'prevname': context.get('prevname'),
|
||||
**kwargs,
|
||||
}
|
||||
84
app/base/templatetags/url_utils.py
Executable file
84
app/base/templatetags/url_utils.py
Executable file
@@ -0,0 +1,84 @@
|
||||
from django import template
|
||||
from django.urls import reverse
|
||||
|
||||
from typing import Any, Union, Tuple
|
||||
|
||||
register = template.Library() # type: ignore[attr-defined]
|
||||
|
||||
# --------------------------------------------
|
||||
# Types
|
||||
# --------------------------------------------
|
||||
|
||||
UrlPkArg = Union[str, int]
|
||||
# URL Format Object can be any of:
|
||||
# - simple reverse lookup: 'user:list'
|
||||
# - lookup with pk: ('user:detail', '{.pk}')
|
||||
# - lookup with pk and query: ('user:detail', '{.pk}', '?args={.ref.mode}')
|
||||
# - lookup with query only: ('user:list', '', '?user={.pk}')
|
||||
UrlFormatObject = Union[str, Tuple[str, UrlPkArg], Tuple[str, UrlPkArg, str]]
|
||||
|
||||
|
||||
# --------------------------------------------
|
||||
# Template tags
|
||||
# --------------------------------------------
|
||||
|
||||
@register.simple_tag(takes_context=True)
|
||||
def query_url(context: dict, **kwargs: dict) -> str:
|
||||
query = context['request'].GET.copy()
|
||||
for k, v in kwargs.items():
|
||||
if v is None:
|
||||
if k in query:
|
||||
del query[k]
|
||||
else:
|
||||
query[k] = v
|
||||
return '?' + query.urlencode() # type: ignore
|
||||
|
||||
|
||||
@register.simple_tag(takes_context=True)
|
||||
def back_url(context: dict) -> str:
|
||||
request = context['request']
|
||||
path = request.GET.get('prev', None)
|
||||
if not path:
|
||||
path = request.headers.get('Referer', None)
|
||||
if not path:
|
||||
path = '/'
|
||||
return path # type: ignore
|
||||
|
||||
|
||||
@register.simple_tag(takes_context=True)
|
||||
def url_builder(context: dict, opts: dict) -> str:
|
||||
# Absolute URL: {'href': '/absolute/path/?user={.pk}'}
|
||||
url = opts.get('href', '') # type: str
|
||||
if url:
|
||||
return url.format(*[context] * 9)
|
||||
# OR reverse lookup with path: {'path': ('user:list', '{.pk}', '?q={.pk}')}
|
||||
path_obj = opts.get('path', '') # type: UrlFormatObject
|
||||
if path_obj:
|
||||
return url_with_formatter(path_obj, context)
|
||||
|
||||
raise AttributeError(f'You must provide either href or path: {opts}')
|
||||
|
||||
|
||||
# --------------------------------------------
|
||||
# Helper methods (not used in templates directly)
|
||||
# --------------------------------------------
|
||||
|
||||
def url_with_formatter(url_obj: UrlFormatObject, format_source: Any) -> str:
|
||||
# just a string, e.g., "user:list"
|
||||
if isinstance(url_obj, str):
|
||||
return reverse(url_obj)
|
||||
# combined object with base path and argument ("user:detail", "{.pk}")
|
||||
if isinstance(url_obj, (list, tuple)):
|
||||
path, pk, *query = url_obj
|
||||
if isinstance(pk, str):
|
||||
pk = pk.format(format_source)
|
||||
else: # dont format int, etc.
|
||||
pk = str(pk) # str(0) allows the next `if` to succeed
|
||||
|
||||
url = reverse(path, kwargs={'pk': pk} if pk else {})
|
||||
# with optional query args: ("user:list", "", "?arg={.param}")
|
||||
for additional in query:
|
||||
# fillup (*9) in case more than one formatting arg was passed
|
||||
url += additional.format(*[format_source] * 9)
|
||||
return url
|
||||
raise AttributeError(f'Can not format URL, unkown structure: {url_obj}')
|
||||
48
app/base/templatetags/utils.py
Executable file
48
app/base/templatetags/utils.py
Executable file
@@ -0,0 +1,48 @@
|
||||
from django import template
|
||||
|
||||
from app.base.models.person import Person
|
||||
|
||||
register = template.Library()
|
||||
|
||||
|
||||
@register.filter
|
||||
def count_lines(text: str) -> int:
|
||||
return text.count('\n') + 1 if text else 0
|
||||
|
||||
|
||||
@register.filter(name='min')
|
||||
def _min(number: int, lower: int) -> int:
|
||||
return max(number, lower)
|
||||
|
||||
|
||||
@register.filter(name='max')
|
||||
def _max(number: int, upper: int) -> int:
|
||||
return min(number, upper)
|
||||
|
||||
|
||||
@register.filter
|
||||
def invert(number):
|
||||
return - number
|
||||
|
||||
|
||||
@register.filter
|
||||
def get_item(dictionary, key):
|
||||
return dictionary and dictionary.get(key)
|
||||
|
||||
|
||||
@register.filter
|
||||
def format(obj, format_str: str) -> str:
|
||||
return format_str.format(obj)
|
||||
|
||||
|
||||
@register.simple_tag
|
||||
def lookup_user(pk):
|
||||
try:
|
||||
return Person.objects.get(pk=pk)
|
||||
except Person.DoesNotExist:
|
||||
return None
|
||||
|
||||
|
||||
@register.filter
|
||||
def model_verbose_name(obj) -> str:
|
||||
return obj.model._meta.verbose_name
|
||||
33
app/base/urls.py
Executable file
33
app/base/urls.py
Executable file
@@ -0,0 +1,33 @@
|
||||
from django.conf import settings
|
||||
from django.conf.urls.static import static
|
||||
from django.contrib.auth.views import LoginView, LogoutView
|
||||
from django.urls import include, path
|
||||
from django.views.generic import RedirectView
|
||||
|
||||
from app.base.views.settings import SettingsView
|
||||
from app.base.views.toggle_checkin import ToggleCheckinView
|
||||
from app.base.views.update_note import UpdateNoteView
|
||||
|
||||
|
||||
urlpatterns = [
|
||||
path('', RedirectView.as_view(url='users/'), name='index'),
|
||||
path('login/', LoginView.as_view(), name='login'),
|
||||
path('logout/', LogoutView.as_view(next_page='login'), name='logout'),
|
||||
path('settings/', SettingsView.as_view(), name='settings'),
|
||||
path('users/<int:user_id>/toggle-checkin/',
|
||||
ToggleCheckinView.as_view(), name='toggle-checkin'),
|
||||
path('users/<int:user_id>/update-note/',
|
||||
UpdateNoteView.as_view(), name='update-note'),
|
||||
|
||||
# Models
|
||||
path('courses/', include('app.base.views.model_views.course')),
|
||||
path('courses/visit/', include('app.base.views.model_views.course_visit')),
|
||||
path('sysusers/', include('app.base.views.model_views.sys_user')),
|
||||
path('bookings/', include('app.base.views.model_views.booking')),
|
||||
path('booking-type/', include('app.base.views.model_views.booking_type')),
|
||||
path('traits/', include('app.base.views.model_views.trait')),
|
||||
path('traits/active/',
|
||||
include('app.base.views.model_views.trait_mapping')),
|
||||
path('transactions/', include('app.base.views.model_views.transaction')),
|
||||
path('users/', include('app.base.views.model_views.person')),
|
||||
] + static(settings.MEDIA_URL, document_root=settings.MEDIA_ROOT)
|
||||
0
app/base/views/__init__.py
Executable file
0
app/base/views/__init__.py
Executable file
5
app/base/views/login.py
Executable file
5
app/base/views/login.py
Executable file
@@ -0,0 +1,5 @@
|
||||
from django.contrib.auth.mixins import LoginRequiredMixin
|
||||
|
||||
|
||||
class LoginRequired(LoginRequiredMixin):
|
||||
login_url = '/login/'
|
||||
0
app/base/views/model_views/__init__.py
Executable file
0
app/base/views/model_views/__init__.py
Executable file
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user