This commit is contained in:
relikd
2023-05-29 15:20:07 +02:00
commit 1380b156d8
126 changed files with 3612 additions and 0 deletions

1
.gitignore vendored Normal file
View File

@@ -0,0 +1 @@
data/db.sqlite3

16
Dockerfile Executable file
View 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
View 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
View 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
View File

1
app/base/__init__.py Executable file
View File

@@ -0,0 +1 @@
default_app_config = 'app.base.apps.AppBaseConfig'

20
app/base/admin.py Executable file
View 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
View 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
View File

View 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
}
}
]

View 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
View File

40
app/base/forms/fields.py Normal file
View 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)

View File

View 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

View 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))

Binary file not shown.

View 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"

View 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'),
),
]

View File

10
app/base/models/__init__.py Executable file
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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

View 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
View 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
View 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
View 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
View 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
View 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()

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

29
app/base/static/css/login.css Executable file
View 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
View 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;
}

File diff suppressed because one or more lines are too long

BIN
app/base/static/fonts/Dosis.ttf Executable file

Binary file not shown.

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

Binary file not shown.

After

Width:  |  Height:  |  Size: 9.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.0 KiB

File diff suppressed because one or more lines are too long

34
app/base/static/js/forms.js Executable file
View 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
View 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
View 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); }
};
});

View 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
}
}

File diff suppressed because one or more lines are too long

View 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}();

View 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}();

File diff suppressed because one or more lines are too long

82
app/base/templates/base.html Executable file
View 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>

View 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 %}

View 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>

View 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>

View 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>

View 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 %}

View 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 %}&nbsp;€{% endif %}

View 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 %}

View 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 %}

View 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 %}

View 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 %}">&laquo; 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 &raquo;</a>
{% endif %}
</span>
</div>
{% endblock %}

View 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 %}

View 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 %}

View 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">&times;</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">&times;</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>

View 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 %}

View 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 %}
&rsaquo; <span>{{ key }}</span>
{% else %}
&rsaquo; <a href="{{ url }}">{{ key }}</a>
{% endif %}
{% endfor %}
{% endif %}
</div>

View 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>

View 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 %}

View 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 %}

View 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>

View 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>

View 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 %}

View File

@@ -0,0 +1,4 @@
<div class="h1-w-icon">
<i class="fas fa-{{ icon }}"></i>
<h1>{{ title }}</h1>
</div>

View File

View 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}

View 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
View 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)

View 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)

View 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,
}

View 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
View 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
View 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
View File

5
app/base/views/login.py Executable file
View File

@@ -0,0 +1,5 @@
from django.contrib.auth.mixins import LoginRequiredMixin
class LoginRequired(LoginRequiredMixin):
login_url = '/login/'

View File

Some files were not shown because too many files have changed in this diff Show More