43 Commits

Author SHA1 Message Date
3bd36fe8dc optimize file path 2026-01-06 17:57:57 +01:00
2558c81860 update logging settings 2026-01-06 17:56:59 +01:00
d99df85e26 update python packages 2026-01-06 17:51:51 +01:00
6eeb7769a4 ruff check; update ValidationError params 2026-01-06 17:45:06 +01:00
d2c4a6ef6a optimize generating link to fet meeting 2026-01-06 11:31:01 +01:00
90dc566511 add sending mail for returned items 2026-01-06 11:28:51 +01:00
0f616f51e1 update mail text 2026-01-06 11:27:37 +01:00
c9f6b2d163 delete unused files 2026-01-02 12:30:59 +01:00
c71f2ae0f1 Merge branch 'master' of https://git.fet.at/bofh/fet2020 2026-01-01 17:29:09 +01:00
root
3b4e42c119 Removed the fucking file volume 2025-12-16 15:02:14 +01:00
root
a1cbd66da6 Merge branch 'master' of https://git.fet.at/bofh/fet2020 2025-12-16 14:40:34 +01:00
sebivh
4d8a7a68f4 Add default env file 2025-12-16 14:40:23 +01:00
root
acc8375d2b Merge branch 'master' of https://git.fet.at/bofh/fet2020 2025-12-16 14:37:53 +01:00
sebivh
dac53d2d06 Change compose layout to use env file 2025-11-25 15:42:47 +01:00
sebivh
7613f3a547 Remove uneccesary gitignore subdir 2025-11-25 15:41:24 +01:00
2d6a06e1b2 fix the missing of checking empty queryset; add bills ordering by date 2025-11-19 14:15:34 +01:00
root
bba8ac4703 Merge branch 'master' of https://git.fet.at/bofh/fet2020 2025-11-19 00:38:37 +01:00
28d3d99754 update account holder in wiref file 2025-11-17 12:17:46 +01:00
78c2860cca update etherpad to 2.5.2; fix charset and add auth method 2025-11-04 22:02:07 +01:00
2024466a48 Fix the help text 2025-11-04 18:49:03 +01:00
72570e25c2 add firstname and surname to search field 2025-11-03 18:46:24 +01:00
root
54c344d262 Merge branch 'master' of https://git.fet.at/bofh/fet2020 2025-10-31 15:13:00 +01:00
root
047dfcb147 Renamed Asset 2025-10-31 15:05:34 +01:00
a3b252c9be Fix: Find the current last day; Use the correct get now time function 2025-10-31 13:30:43 +01:00
b0e686245a Merge branch 'master' of https://git.fet.at/bofh/fet2020 2025-10-31 13:04:28 +01:00
sebivh
b50c010b3b Add persistant Storage to Container Databases 2025-10-30 23:21:25 +01:00
be581675cd update homepage version to 2.2.1 2025-10-30 15:42:48 +01:00
c1519bab0f add migrations 2025-10-30 15:40:49 +01:00
585bc60676 update assets 2025-10-30 15:39:58 +01:00
b9943b7d41 add help text for status 2025-10-30 15:20:14 +01:00
65ac5ae18e fix: sending mail; add email host user and pwd for authentication 2025-10-30 15:05:36 +01:00
8ff3905657 Add total deposit calculation to Rental model 2025-10-30 15:00:20 +01:00
5d2a052c1e add internal rentals with zero deposit 2025-10-30 14:05:53 +01:00
4a7076b120 fix: third line in products in pdf file 2025-10-30 13:59:00 +01:00
2204c07deb fix: path to pdf file 2025-10-30 13:58:11 +01:00
07449db128 add migrations 2025-10-30 13:06:28 +01:00
5d9ad679de Fix: auto-split rentals with >5 items; update create_done text 2025-10-30 12:59:11 +01:00
0e1a61cefc sorted all rentalitems alphabetically 2025-10-30 12:45:01 +01:00
4d35e498c5 fix: wrong binding for checked box 2025-10-30 10:32:46 +01:00
370577493a optimize function 2025-10-29 22:36:11 +01:00
e10fa77c3a add week view 2025-10-29 22:29:10 +01:00
root
1e71779c6d Merge branch 'master' of https://git.fet.at/bofh/fet2020 2025-10-23 19:21:40 +02:00
root
d01dde658f change mode to +x 2025-10-21 17:56:33 +02:00
55 changed files with 3165 additions and 2972 deletions

14
.env.default Normal file
View File

@@ -0,0 +1,14 @@
HOSTNAME="fet.at" # Hostname that will be used to filter requests
DEBUG="false" # Debug flag ( disables ldap integration )
LDAP="True" # Ldap flag ( enables connection with fet ldap )
#SECRET_KEY="aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa" # Secret key for the Website encryption
ETHERPAD_GROUP="g.snlbqn7S6ksRbom3" # The Etherpad Group to use
EMAIL_HOST_USER="verleih@fet.at" # The user to be used for rental Emails
#EMAIL_HOST_PASSWORD="password" # The password for the smtp account for the rental Email
#ETHERPAD_DB_USER="user" # User for the Eterpad MySql Database
#ETHERPAD_DB_PASSWORD="password" # Password for the Etherpad MySql Database
#DJANGO_MYSQL_USER="user" # The MySql User used for Database
#DJANGO_MYSQL_PASSWORD"="password" # The Password for the MySql Database

3
.gitignore vendored
View File

@@ -1,4 +1,4 @@
.env/*
.env*
*.pyc
*_design1
fet2020/.env/*
@@ -19,3 +19,4 @@ flowbite
gallery/*
tailwind
whoosh_index
databases

View File

@@ -1,61 +0,0 @@
FROM tiangolo/uwsgi-nginx:python3.8-alpine AS builder
RUN python -m venv /opt/venv
# Make sure we use the virtualenv:
ENV PATH="/opt/venv/bin:$PATH"
RUN apk add --no-cache --virtual .build-deps ca-certificates gcc linux-headers \
musl-dev \
jpeg-dev \
zlib-dev \
libffi-dev \
mysql \
mariadb-dev \
freetype-dev \
# install for pymupdf
# && apk add --no-cache \
# libffi-dev \
# zlib-dev \
# freetype-dev \
# make \
# build-base \
# gcc \
# jbig2dec \
# jpeg-dev \
# harfbuzz-dev \
# libc-dev \
# mupdf-dev \
# musl-dev \
# openjpeg-dev \
# swig \
# && ln -s /usr/lib/libjbig2dec.so.0 /usr/lib/libjbig2dec.so \
# && pip install pymupdf==1.23.18 \
# upgrade pip
&& pip install --upgrade pip
# setting for pymupdf
# ENV PYMUPDF_SETUP_MUPDF_TESSERACT="0"
COPY ./fet2020/requirements.txt /app/requirements.txt
RUN pip install -r requirements.txt
# Remove virtual environment dependencies
# RUN apk del .builddeps
FROM tiangolo/uwsgi-nginx:python3.8-alpine
RUN apk add ghostscript-dev mariadb-connector-c
COPY --from=builder /opt/venv /opt/venv
# Make sure we use the virtualenv:
ENV PATH="/opt/venv/bin:$PATH"
#COPY ./fet2020 /app
COPY ./fet2020 /app
COPY ./assets /app/assets
COPY ./deployment/nginx.conf /etc/nginx/conf.d/fet2020.conf
#RUN python manage.py makemigrations && python manage.py makemigrations posts members

50
Jenkinsfile vendored
View File

@@ -1,50 +0,0 @@
pipeline {
agent any
stages {
stage('Checkout'){
steps {
git credentialsId: '3e336704-7d58-4dcb-88b6-1ef7b5392576', url: 'https://git.fet.at/bofh/fet2020.git'
}
}
stage('build Test') {
steps{
script {
app = docker.build("django2020test", "-f Dockerfile .")
}
}
}
stage('Test Inside') {
steps{
script {
app.inside("-u root:root") {
sh 'ls && cd fet2020 && ls && ./manage.py test'
}
}
}
}
stage('build') {
steps{
script {
app = docker.build("fet2020django", "-f Dockerfile .")
}
}
}
stage('Deploy Image') {
steps{
script {
docker.withRegistry( 'https://docker.fet.at/', '3e336704-7d58-4dcb-88b6-1ef7b5392576') {
app.push("1.$BUILD_NUMBER")
app.push('latest')
}
}
}
}
}
}

View File

@@ -96,6 +96,8 @@ docker build -t django-nginx-image -f nginx/Dockerfile ./nginx
### Start docker container
Add email password for 'Verleih' account to EMAIL_HOST_PASSWORD in the docker compose file!
Build the docker containers:
```bash
@@ -134,6 +136,10 @@ ckeditor -> django-prose-editor
## Version History
2.2.1
* Fix rental (view, pdf file, sending mail)
2.2.0
* Add rental

Binary file not shown.

Binary file not shown.

View File

@@ -1 +0,0 @@
docker build -t fet2020django .

View File

@@ -1,102 +0,0 @@
version: "2"
services:
mysql:
image: mariadb:10.7
container_name: test_mysql
networks:
- testfet
environment:
MYSQL_DATABASE: fet2020db
MYSQL_USER: user
MYSQL_PASSWORD: hgu
MYSQL_COLLATION: utf8_general_ci
MYSQL_CHARSET: utf8
MYSQL_ALLOW_EMPTY_PASSWORD: "yes"
volumes:
#- mysql-volume:/var/lib/mysql
- /srv/initdb/mysql:/docker-entrypoint-initdb.d/
healthcheck:
test: "mysqladmin ping -h localhost && echo 'show tables;' | mysql 'fet2020db' >/dev/null "
timeout: 20s
retries: 20
etherpadsql:
image: mariadb:10.7
container_name: test_mysql_etherpad
networks:
- testfet
environment:
MYSQL_DATABASE: etherpaddb
MYSQL_USER: user
MYSQL_PASSWORD: "hgu"
MYSQL_COLLATION: utf8_general_ci
MYSQL_CHARSET: utf8
MYSQL_ALLOW_EMPTY_PASSWORD: "yes"
healthcheck:
test: "mysqladmin ping -h localhost && echo 'show tables;' | mysql --port 3306 --protocol tcp 'etherpaddb' > /dev/null"
timeout: 20s
retries: 20
volumes:
#- ep-mysql-volume:/var/lib/mysql
- /srv/initdb/etherpad:/docker-entrypoint-initdb.d/
etherpad:
image: etherpad/etherpad:1.8.17
container_name: test_etherpad
networks:
- testfet
environment:
DB_TYPE: mysql
DB_HOST: etherpadsql
DB_PORT: 3306
DB_NAME: etherpaddb
DB_USER: root
DB_PASS: ""
DB_CHARSET: utf8
TRUST_PROXY: "true"
REQUIRE_SESSION: "true"
depends_on:
etherpadsql:
condition: "service_healthy"
volumes:
- /srv/APIKEY.txt:/opt/etherpad-lite/APIKEY.txt
restart: unless-stopped
# - ./deployment/mysql.cnf:/etc/mysql/conf.d
healthcheck:
test: "curl --fail localhost:9001 >/dev/null "
timeout: 10s
interval: 15s
retries: 30
fet2020:
image: docker.fet.at/fet2020django
container_name: test_fet2020
networks:
- testfet
environment:
HOST_NAME: "test.fet.at"
DEBUG: "False"
SECRET_KEY: "sadfreigjopi4qgjpjrp"
MYSQL_USER: "user"
MYSQL_PASSWORD: "hgu"
ETHERPAD_GROUP: "g.snlbqn7S6ksRbom3"
depends_on:
mysql:
condition: service_healthy
etherpad:
condition: service_healthy
ports:
- "8005:8080"
healthcheck:
test: "python3 manage.py check --database default"
timeout: 20s
retries: 20
volumes:
- /srv/APIKEY.txt:/app/etherpad/APIKEY.txt
- /srv/files:/app/files
restart: unless-stopped
volumes:
ep-mysql-volume:
driver: local
mysql-volume:
driver: local
networks:
testfet:
name: testfet

View File

@@ -9,7 +9,7 @@ services:
depends_on:
- django-homepage
volumes:
- files-volume:/usr/src/app/files
- ./fet2020/files:/usr/src/app/files
- ./gallery:/usr/src/app/files/uploads/gallery
- ./assets:/usr/src/app/assets:ro
networks:
@@ -18,13 +18,15 @@ services:
container_name: django-container
image: django-image:latest
environment:
HOST_NAME: "fet.at"
DEBUG: "False"
LDAP: "True"
SECRET_KEY: "sae34sADfrFr89E!Gl#f!34hdjGR#!jopi4qFEr#4R56rT56zT2#wE1!feGp"
MYSQL_USER: "user"
MYSQL_PASSWORD: "hgu"
ETHERPAD_GROUP: "g.snlbqn7S6ksRbom3"
HOST_NAME: ${HOSTNAME}
DEBUG: ${DEBUG}
LDAP: ${LDAP}
SECRET_KEY: ${SECRET_KEY}
MYSQL_USER: ${DJANGO_MYSQL_USER}
MYSQL_PASSWORD: ${DJANGO_MYSQL_PASSWORD}
ETHERPAD_GROUP: ${ETHERPAD_GROUP}
EMAIL_HOST_USER: ${EMAIL_HOST_USER}
EMAIL_HOST_PASSWORD: ${EMAIL_HOST_PASSWORD}
depends_on:
mysql:
condition: service_healthy
@@ -33,7 +35,7 @@ services:
volumes:
- ./fet2020:/usr/src/app
- ./gallery:/usr/src/app/files/uploads/gallery:shared
- files-volume:/usr/src/app/files
- ./fet2020/files:/usr/src/app/files
- ./assets:/usr/src/app/assets:ro
networks:
- fet-network
@@ -45,7 +47,7 @@ services:
retries: 20
etherpad:
container_name: etherpad-container
image: etherpad/etherpad:1.8.17
image: etherpad/etherpad:2.5.2
# ports:
# - 9001:9001
environment:
@@ -53,10 +55,11 @@ services:
DB_HOST: etherpadsql
DB_PORT: 3306
DB_NAME: etherpaddb
DB_USER: user
DB_PASS: "hgu"
DB_CHARSET: utf8
DB_USER: ${ETHERPAD_DB_USER}
DB_PASS: ${ETHERPAD_DB_PASSWORD}
DB_CHARSET: "utf8mb4"
TRUST_PROXY: false
AUTHENTICATION_METHOD: "apikey"
depends_on:
etherpadsql:
condition: "service_healthy"
@@ -76,13 +79,14 @@ services:
image: mariadb:10.7
environment:
MYSQL_DATABASE: fet2020db
MYSQL_USER: user
MYSQL_PASSWORD: hgu
MYSQL_USER: ${DJANGO_MYSQL_USER}
MYSQL_PASSWORD: ${DJANGO_MYSQL_PASSWORD}
MYSQL_COLLATION: utf8_general_ci
MYSQL_CHARSET: utf8
MYSQL_ALLOW_EMPTY_PASSWORD: "yes"
volumes:
- mysql-volume:/docker-entrypoint-initdb.d/
- ./inits/django:/docker-entrypoint-initdb.d/
- ./databases/django:/var/lib/mysql:Z
networks:
- django-db-network
healthcheck:
@@ -94,13 +98,14 @@ services:
image: mariadb:10.7
environment:
MYSQL_DATABASE: etherpaddb
MYSQL_USER: user
MYSQL_PASSWORD: "hgu"
MYSQL_USER: ${ETHERPAD_DB_USER}
MYSQL_PASSWORD: ${ETHERPAD_DB_PASSWORD}
MYSQL_COLLATION: utf8_general_ci
MYSQL_CHARSET: utf8
MYSQL_ALLOW_EMPTY_PASSWORD: "yes"
volumes:
- etherpad-mysql-volume:/docker-entrypoint-initdb.d/
- ./init/etherpad:/docker-entrypoint-initdb.d/
- ./databases/etherpad:/var/lib/mysql:Z
networks:
- etherpad-db-network
healthcheck:

View File

@@ -1,6 +1,6 @@
from django.utils.version import get_version
VERSION = (2, 2, 0, "final", 0)
VERSION = (2, 2, 1, "final", 0)
BUILD = 0
__version__ = get_version(VERSION)

View File

@@ -18,6 +18,8 @@ env = environ.Env(
ETHERPAD_GROUP=(str, ""),
GALLERY_PATH=(str, "uploads/gallery"),
MC_MASTERPASSWORD=(str, ""),
EMAIL_HOST_USER=(str, ""),
EMAIL_HOST_PASSWORD=(str, ""),
)
# Build paths inside the project like this: os.path.join(BASE_DIR, ...)
@@ -103,6 +105,8 @@ else:
EMAIL_BACKEND = "django.core.mail.backends.smtp.EmailBackend"
EMAIL_HOST = "buran.htu.tuwien.ac.at"
EMAIL_PORT = 587
EMAIL_HOST_USER = env("EMAIL_HOST_USER")
EMAIL_HOST_PASSWORD = env("EMAIL_HOST_PASSWORD")
EMAIL_USE_TLS = True
@@ -165,14 +169,32 @@ WSGI_APPLICATION = "fet2020.wsgi.application"
LOGGING = {
"version": 1,
"disable_existing_loggers": False,
"handlers": {
"console": {
"class": "logging.StreamHandler",
"formatters": {
"logger": {
"()": "logging.Formatter",
"format": "[%(asctime)s] %(levelname)8s | %(message)s",
"style": "%",
"datefmt": "%Y-%m-%d %H:%M:%S",
},
},
"root": {
"handlers": ["console"],
"level": "DEBUG",
"handlers": {
"console": {
"level": "INFO",
"class": "logging.StreamHandler",
"formatter": "logger",
},
},
"loggers": {
"root": {
"handlers": ["console"],
"level": "INFO",
"propagate": False,
},
"django": {
"handlers": ["console"],
"level": "INFO",
"propagate": True,
},
},
}

View File

@@ -1,39 +1,39 @@
# util functions for all apps
import uuid
from django.contrib.admin.utils import construct_change_message
def add_log_action(request, form, app_label, model, add=True):
from django.contrib.admin.models import ADDITION, CHANGE, LogEntry
from django.contrib.contenttypes.models import ContentType
obj = form.save()
content_type = ContentType.objects.get(app_label=app_label, model=model)
change_message = construct_change_message(form, None, add)
action_flag = ADDITION if add else CHANGE
LogEntry.objects.log_action(
user_id=request.user.pk,
content_type_id=content_type.pk,
object_id=obj.pk,
object_repr=str(obj),
action_flag=action_flag,
change_message=change_message,
)
def create_perms(sender, **kwargs):
from django.contrib.auth.models import Group, Permission
from django.contrib.contenttypes.models import ContentType
group, created = Group.objects.get_or_create(name=sender.label)
content_types = ContentType.objects.filter(app_label=sender.label)
for content_type in content_types:
permissions = Permission.objects.filter(content_type=content_type)
[group.permissions.add(permission) for permission in permissions]
def create_random_id():
return str(uuid.uuid4())[:8]
# util functions for all apps
import uuid
from django.contrib.admin.utils import construct_change_message
def add_log_action(request, form, app_label, model, add=True):
from django.contrib.admin.models import ADDITION, CHANGE, LogEntry
from django.contrib.contenttypes.models import ContentType
obj = form.save()
content_type = ContentType.objects.get(app_label=app_label, model=model)
change_message = construct_change_message(form, None, add)
action_flag = ADDITION if add else CHANGE
LogEntry.objects.log_action(
user_id=request.user.pk,
content_type_id=content_type.pk,
object_id=obj.pk,
object_repr=str(obj),
action_flag=action_flag,
change_message=change_message,
)
def create_perms(sender, **kwargs):
from django.contrib.auth.models import Group, Permission
from django.contrib.contenttypes.models import ContentType
group, _ = Group.objects.get_or_create(name=sender.label)
content_types = ContentType.objects.filter(app_label=sender.label)
for content_type in content_types:
permissions = Permission.objects.filter(content_type=content_type)
[group.permissions.add(permission) for permission in permissions]
def create_random_id():
return str(uuid.uuid4())[:8]

View File

@@ -1,9 +1,11 @@
import logging
from datetime import date, datetime
from urllib.parse import urljoin
from django.conf import settings
from django.contrib import admin, messages
from django.contrib.sites.models import Site
from django.http import HttpResponseRedirect
from django.urls import reverse
from django.utils.html import format_html
from django.utils.translation import gettext_lazy as _
from django.utils.translation import ngettext
@@ -103,8 +105,6 @@ class BillPeriodeFilter(admin.SimpleListFilter):
return self.__lst
def queryset(self, request, queryset):
qs = queryset
if self.value():
try:
period = datetime.strptime(self.value()[:4], "%Y") # noqa: DTZ007
@@ -120,14 +120,15 @@ class BillPeriodeFilter(admin.SimpleListFilter):
# Return bills from current period.
else:
tmp_qs = queryset.order_by("-date")
if not queryset.exists():
return queryset
# Get first period.
start_year = tmp_qs.first().date.year
start_year = queryset.first().date.year
# Check if date of first bill is in first half of year. If yes, start of period is the
# year before.
if tmp_qs.first().date < date(start_year, 7, 1):
if queryset.first().date < date(start_year, 7, 1):
start_year -= 1
start_date = date(start_year, 7, 1)
@@ -204,7 +205,12 @@ class BillAdmin(admin.ModelAdmin):
actions = ["make_cleared", "make_finished"]
autocomplete_fields = ["resolution"]
list_filter = ["status", "affiliation", "payer", BillPeriodeFilter]
search_fields = ["purpose", "bankdata__name"]
search_fields = [
"purpose",
"bankdata__name",
"bill_creator__firstname",
"bill_creator__surname",
]
show_facets = admin.ShowFacets.ALWAYS
ordering = ["-id"]
@@ -469,7 +475,9 @@ class ResolutionAdmin(admin.ModelAdmin):
fetmeeting = FetMeeting.objects.get_queryset().filter(date=obj.date).first()
if fetmeeting is not None:
link = f"https://{settings.HOST_NAME}/posts/{fetmeeting.slug}/"
site = Site.objects.get_current()
path = reverse("show", kwargs={"id": fetmeeting.slug})
link = urljoin(f"https://{site.domain}", path)
return format_html('<a href="{}" target="_blank">Link zur Fachschaftssitzung</a>', link)
return format_html("-")

View File

@@ -1,11 +1,11 @@
import datetime
import decimal
from dateutil.relativedelta import relativedelta
from django import forms
from django.core.validators import ValidationError
from django.core.exceptions import ValidationError
from django.db.models import Count, Q
from django.forms import DateInput
from django.utils import timezone
from members.models import Member
@@ -412,7 +412,7 @@ class BillAdminForm(forms.ModelForm):
self.fields["resolution"].queryset = self.fields["resolution"].queryset.filter(
(
Q(option=Resolution.Option.FINANCE)
& Q(date__gt=datetime.datetime.now(tz=datetime.UTC).date() - relativedelta(years=2))
& Q(date__gt=timezone.now().date() - relativedelta(years=2))
)
| Q(option=Resolution.Option.PERMANENT)
)
@@ -450,8 +450,8 @@ class ResolutionAdminForm(forms.ModelForm):
def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs) # to get the self.fields set
budget = decimal.Decimal(0.0)
total = decimal.Decimal(0.0)
budget = decimal.Decimal("0.0")
total = decimal.Decimal("0.0")
if resolution := kwargs.get("instance"):
for elem in Bill.objects.filter(resolution=resolution):
total += elem.amount

View File

@@ -1,6 +1,8 @@
import logging
from pathlib import Path
from django.core.validators import FileExtensionValidator, ValidationError
from django.core.exceptions import ValidationError
from django.core.validators import FileExtensionValidator
from django.db import models
from django.urls import reverse
@@ -8,6 +10,8 @@ from members.models import Member
from .validators import validate_bill_file_extension
logger = logging.getLogger(__name__)
class BankData(models.Model):
# members can be deleted but never their bank datas
@@ -86,8 +90,18 @@ class Resolution(models.Model):
if not Resolution.objects.filter(id=_id).exists():
break
else:
msg = (
"Es wurden zu viele Beschlüsse in dieser Woche %(week)s vom Jahr %(year)s "
"erstellt."
)
logger.error(
"Too many resolutions created in week %(week)s of year %(year)s.",
extra={"week": week, "year": year},
)
raise ValidationError(
f"Es wurden zu viele Beschlüsse in dieser Woche angelegt. (ID: {_id})"
msg,
code="too_many_resolutions",
params={"week": week, "year": year},
)
self.id = _id
@@ -217,6 +231,8 @@ class Bill(models.Model):
verbose_name = "Rechnung"
verbose_name_plural = "Rechnungen"
ordering = ["-date"]
def __str__(self):
return f"{self.purpose}"

View File

@@ -1,82 +1,83 @@
import datetime
import io
from pathlib import Path
from django.core.files import File
from pypdf import PdfReader, PdfWriter
from pypdf.constants import FieldDictionaryAttributes as FA # noqa: N814
from .models import Bill, Wiref
def generate_pdf(wiref):
if wiref is None or wiref.status != Wiref.Status.OPENED:
return False
bills = Bill.objects.filter(wiref=wiref).order_by("date")
# Get data for pdf
data = {}
data_invoice = {} # Own dict for fixing text to multiline
for count, elem in enumerate(bills):
data.update(
{
f"DatumRow{count + 1}": str(elem.date.strftime("%d.%m.%Y")),
f"VerwendungszweckRow{count + 1}": elem.purpose,
# Replace decimal separator from '.' to ','
f"BetragRow{count + 1}": str(elem.amount).replace(".", ","),
},
)
data_invoice.update(
{
f"RechnungsaustellerinRow{count + 1}": elem.invoice,
},
)
# Get budget year
today = datetime.datetime.now(tz=datetime.UTC).date()
if today.month < 7:
budget_year = f"{today.year - 1}-{today.year}"
else:
budget_year = f"{today.year}-{today.year + 1}"
# Get total of all bills of wiref form
total = 0
for elem in bills:
total += elem.amount
data.update(
{
"Rechnungsnummer": str(wiref.wiref_id),
"Budgetjahr": budget_year,
# Replace decimal separator from '.' to ','
"Summe": str(total).replace(".", ","),
},
)
# Write data in pdf
pdf_path = Path(Path(__file__).parent) / "static/wiref/Vorlage.pdf"
reader = PdfReader(pdf_path)
writer = PdfWriter()
writer.append(reader)
writer.update_page_form_field_values(
writer.pages[0],
data,
)
# Add invoices and fix text to multiline
writer.update_page_form_field_values(
writer.pages[0],
data_invoice,
flags=FA.FfBits.Multiline,
)
with io.BytesIO() as bytes_stream:
writer.write(bytes_stream)
# Save pdf in wiref
wiref_name = f"Abrechnungsformular-{wiref.wiref_id}.pdf"
wiref.file_field.save(wiref_name, File(bytes_stream, wiref_name))
return True
import io
from django.contrib.staticfiles import finders
from django.core.files import File
from django.utils import timezone
from pypdf import PdfReader, PdfWriter
from pypdf.constants import FieldDictionaryAttributes as FA # noqa: N814
from .models import Bill, Wiref
def generate_pdf(wiref):
if wiref is None or wiref.status != Wiref.Status.OPENED:
return False
bills = Bill.objects.filter(wiref=wiref).order_by("date")
# Get data for pdf
data = {}
data_invoice = {} # Own dict for fixing text to multiline
for count, elem in enumerate(bills):
data.update(
{
f"DatumRow{count + 1}": str(elem.date.strftime("%d.%m.%Y")),
f"VerwendungszweckRow{count + 1}": elem.purpose,
# Replace decimal separator from '.' to ','
f"BetragRow{count + 1}": str(elem.amount).replace(".", ","),
},
)
data_invoice.update(
{
f"RechnungsaustellerinRow{count + 1}": elem.invoice,
},
)
# Get budget year
today = timezone.now().date()
if today.month < 7:
budget_year = f"{today.year - 1}-{today.year}"
else:
budget_year = f"{today.year}-{today.year + 1}"
# Get total of all bills of wiref form
total = 0
for elem in bills:
total += elem.amount
data.update(
{
"Rechnungsnummer": str(wiref.wiref_id),
"Budgetjahr": budget_year,
# Replace decimal separator from '.' to ','
"Summe": str(total).replace(".", ","),
},
)
# Write data in pdf
pdf_path_str = finders.find("wiref/Vorlage.pdf")
reader = PdfReader(pdf_path_str)
writer = PdfWriter()
writer.append(reader)
writer.update_page_form_field_values(
writer.pages[0],
data,
)
# Add invoices and fix text to multiline
writer.update_page_form_field_values(
writer.pages[0],
data_invoice,
flags=FA.FfBits.Multiline,
)
with io.BytesIO() as bytes_stream:
writer.write(bytes_stream)
bytes_stream.seek(0)
# Save pdf in wiref
wiref_name = f"Abrechnungsformular-{wiref.wiref_id}.pdf"
wiref.file_field.save(wiref_name, File(bytes_stream, wiref_name))
return True

View File

@@ -27,11 +27,11 @@ def get_image_list(folder_name: str) -> list:
Path(thumb_path).mkdir(exist_ok=True)
for _file in os.listdir(image_path):
if Path(_file).suffix.lower()[1:] not in get_available_image_extensions():
for _file in image_path.iterdir():
if _file.suffix.lower()[1:] not in get_available_image_extensions():
continue
thumb_file_path = Path(thumb_path) / f"thumb_{_file}"
thumb_file_path = Path(thumb_path) / f"thumb_{_file.name}"
if not Path(thumb_file_path).exists():
with Image.open(Path(image_path) / _file, "r") as im:
if im._getexif() is not None:

View File

@@ -1,65 +1,67 @@
import logging
from collections import deque
from django.contrib.auth.mixins import LoginRequiredMixin
from django.http import Http404
from django.shortcuts import render
from django.utils.text import slugify
from django.views.generic.detail import DetailView
from .models import Album
from .utils import get_folder_list
logger = logging.getLogger(__name__)
def index(request):
if request.user.is_authenticated:
albums = deque(Album.objects.all())
# Get albums that are in the server but not in the db.
for folder in get_folder_list():
if not Album.objects.filter(folder_name=folder):
albums.append(
Album(title=folder, slug=slugify(folder), folder_name=folder, event_date=None)
)
else:
# Show only PUBLIC albums.
albums = Album.objects.public()
context = {"albums": albums}
return render(request, "gallery/index.html", context)
class AlbumDetailView(DetailView):
model = Album
template_name = "gallery/album.html"
def get_queryset(self):
return (
Album.objects.public()
if not self.request.user.is_authenticated
else Album.objects.all()
)
class DraftAlbumDetailView(LoginRequiredMixin, DetailView):
model = Album
template_name = "gallery/album.html"
def get_object(self, queryset=None):
slug = self.kwargs.get(self.slug_url_kwarg)
if not (album := Album.objects.filter(slug=slug).first()):
for folder in get_folder_list():
if slug == slugify(folder):
album = Album(
title=folder, slug=slugify(folder), folder_name=folder, event_date=None
)
break
else:
raise Http404("Album slug not found.")
return album
import logging
from collections import deque
from django.contrib.auth.mixins import LoginRequiredMixin
from django.http import Http404
from django.shortcuts import render
from django.utils.text import slugify
from django.views.generic.detail import DetailView
from .models import Album
from .utils import get_folder_list
logger = logging.getLogger(__name__)
def index(request):
if request.user.is_authenticated:
albums = deque(Album.objects.all())
# Get albums that are in the server but not in the db.
for folder in get_folder_list():
if not Album.objects.filter(folder_name=folder):
albums.append(
Album(title=folder, slug=slugify(folder), folder_name=folder, event_date=None)
)
else:
# Show only PUBLIC albums.
albums = Album.objects.public()
context = {"albums": albums}
return render(request, "gallery/index.html", context)
class AlbumDetailView(DetailView):
model = Album
template_name = "gallery/album.html"
def get_queryset(self):
return (
Album.objects.public()
if not self.request.user.is_authenticated
else Album.objects.all()
)
class DraftAlbumDetailView(LoginRequiredMixin, DetailView):
model = Album
template_name = "gallery/album.html"
def get_object(self, queryset=None):
slug = self.kwargs.get(self.slug_url_kwarg)
if not (album := Album.objects.filter(slug=slug).first()):
for folder in get_folder_list():
if slug == slugify(folder):
album = Album(
title=folder, slug=slugify(folder), folder_name=folder, event_date=None
)
break
else:
msg = f"Album mit dem Slug '{slug}' nicht gefunden."
logger.error("Album with slug '%s' not found.", slug)
raise Http404(msg)
return album

View File

@@ -1,6 +0,0 @@
#!/bin/sh
pip install -r requirements.txt
python3 manage.py makemigrations blackboard core gallery members posts tasks
python3 manage.py makemigrations intern
python3 manage.py migrate

View File

@@ -1,205 +1,219 @@
import logging
from datetime import date
from pathlib import Path
from django.core.validators import ValidationError
from django.db import models
from django.db.models.constraints import UniqueConstraint
from django.urls import reverse
from django.utils.text import slugify
from documents.api import ep_create_new_pad, ep_get_html, ep_get_url
from fet2020.utils import create_random_id
logger = logging.getLogger(__name__)
class TopicGroup(models.Model):
title = models.CharField(verbose_name="Titel", max_length=128)
shortterm = models.CharField(max_length=128, unique=True, blank=True)
slug = models.SlugField(unique=True)
short_description = models.TextField(blank=True, default="")
order = models.PositiveSmallIntegerField(
verbose_name="Reihenfolge",
unique=True,
null=True,
blank=True,
)
objects = models.Manager()
class Meta:
verbose_name = "Themenbereich"
verbose_name_plural = "Themenbereiche"
def __str__(self):
return self.title
def get_absolute_url(self):
return reverse("intern:index") + "#" + self.slug
def clean(self, *args, **kwargs):
if not self.shortterm:
self.shortterm = self.title
self.slug = slugify(self.shortterm)
class Topic(models.Model):
title = models.CharField(max_length=128, verbose_name="Titel")
slug = models.SlugField()
archive = models.BooleanField(default=False, verbose_name="Archiv")
description = models.TextField(blank=True, default="")
topic_group = models.ForeignKey(
TopicGroup,
on_delete=models.CASCADE,
verbose_name="Themenbereich",
)
objects = models.Manager()
class Meta:
verbose_name = "Thema"
verbose_name_plural = "Themen"
constraints = [
UniqueConstraint(fields=["slug", "topic_group"], name="unique_intern_slug_topic_group"),
UniqueConstraint(
fields=["title", "topic_group"],
name="unique_intern_title_topic_group",
),
]
def __str__(self):
return self.title
def get_absolute_url(self):
kwargs = {
"topic_group_slug": self.topic_group.slug,
"slug": self.slug,
}
return reverse("intern:topic", kwargs=kwargs)
def clean(self, *args, **kwargs):
self.slug = slugify(self.title)
class Attachment(models.Model):
title = models.CharField(max_length=128, verbose_name="Titel")
slug = models.SlugField()
description = models.TextField(blank=True, default="")
topic = models.ForeignKey(Topic, on_delete=models.CASCADE, verbose_name="Thema")
objects = models.Manager()
class Meta:
verbose_name = "Anhang Ordner"
verbose_name_plural = "Anhang Ordner"
constraints = [
UniqueConstraint(fields=["slug", "topic"], name="unique_intern_slug_topic"),
UniqueConstraint(fields=["title", "topic"], name="unique_intern_title_topic"),
]
def __str__(self):
return self.topic.title + " / " + self.title
def get_absolute_url(self):
kwargs = {
"topic_group_slug": self.topic.topic_group.slug,
"topic_slug": self.topic.slug,
"slug": self.slug,
}
return reverse("intern:attachment", kwargs=kwargs)
def clean(self, *args, **kwargs):
self.slug = slugify(self.title)
class Etherpad(models.Model):
title = models.CharField(max_length=128, verbose_name="Titel")
slug_id = models.CharField(default=create_random_id, editable=False, max_length=8, unique=True)
etherpad_key = models.CharField(blank=True, max_length=50)
date = models.DateField(default=date.today, verbose_name="Datum")
attachment = models.ForeignKey(
Attachment,
on_delete=models.CASCADE,
verbose_name="Anhang Ordner",
)
objects = models.Manager()
class Meta:
verbose_name = "Etherpad"
verbose_name_plural = "Etherpads"
constraints = [
UniqueConstraint(fields=["title", "date", "attachment"], name="unique_intern_etherpad"),
]
def __str__(self):
return self.title
def get_absolute_url(self):
return ep_get_url(self.etherpad_key)
def clean(self):
pad_name = slugify(str(self.slug_id) + "-" + self.title[:40])
if len(pad_name) > 50:
raise ValidationError(
(
"Name zum Erstellen des Etherpads ist zu lange - max. 50 Zeichen. ("
"Länge: %(length)s, Name: %(pad_name)s)"
),
params={"length": len(pad_name), "pad_name": pad_name},
)
if self.etherpad_key == "":
if ep_create_new_pad(pad_name):
self.etherpad_key = pad_name
else:
raise ValidationError(
f"Etherpad '{pad_name}' konnte nicht erstellt werden. This should never happen!"
)
@property
def etherpad_html(self):
return ep_get_html(self.etherpad_key)
def get_model_name(self):
return self._meta.model_name
class FileUpload(models.Model):
title = models.CharField(blank=True, max_length=128, verbose_name="Titel")
file_field = models.FileField(upload_to="uploads/intern/files/", verbose_name="Dokument")
date = models.DateField(default=date.today, verbose_name="Datum")
attachment = models.ForeignKey(
Attachment,
on_delete=models.CASCADE,
verbose_name="Anhang Ordner",
)
objects = models.Manager()
class Meta:
verbose_name = "Datei"
verbose_name_plural = "Dateien"
def __str__(self):
return self.title
def clean(self):
if not self.title:
self.title = Path(self.file_field.name).stem
import logging
from datetime import date
from pathlib import Path
from django.core.exceptions import ValidationError
from django.db import models
from django.db.models.constraints import UniqueConstraint
from django.urls import reverse
from django.utils.text import slugify
from documents.api import ep_create_new_pad, ep_get_html, ep_get_url
from fet2020.utils import create_random_id
logger = logging.getLogger(__name__)
class TopicGroup(models.Model):
title = models.CharField(verbose_name="Titel", max_length=128)
shortterm = models.CharField(max_length=128, unique=True, blank=True)
slug = models.SlugField(unique=True)
short_description = models.TextField(blank=True, default="")
order = models.PositiveSmallIntegerField(
verbose_name="Reihenfolge",
unique=True,
null=True,
blank=True,
)
objects = models.Manager()
class Meta:
verbose_name = "Themenbereich"
verbose_name_plural = "Themenbereiche"
def __str__(self):
return self.title
def get_absolute_url(self):
return reverse("intern:index") + "#" + self.slug
def clean(self, *args, **kwargs):
if not self.shortterm:
self.shortterm = self.title
self.slug = slugify(self.shortterm)
class Topic(models.Model):
title = models.CharField(max_length=128, verbose_name="Titel")
slug = models.SlugField()
archive = models.BooleanField(default=False, verbose_name="Archiv")
description = models.TextField(blank=True, default="")
topic_group = models.ForeignKey(
TopicGroup,
on_delete=models.CASCADE,
verbose_name="Themenbereich",
)
objects = models.Manager()
class Meta:
verbose_name = "Thema"
verbose_name_plural = "Themen"
constraints = [
UniqueConstraint(fields=["slug", "topic_group"], name="unique_intern_slug_topic_group"),
UniqueConstraint(
fields=["title", "topic_group"],
name="unique_intern_title_topic_group",
),
]
def __str__(self):
return self.title
def get_absolute_url(self):
kwargs = {
"topic_group_slug": self.topic_group.slug,
"slug": self.slug,
}
return reverse("intern:topic", kwargs=kwargs)
def clean(self, *args, **kwargs):
self.slug = slugify(self.title)
class Attachment(models.Model):
title = models.CharField(max_length=128, verbose_name="Titel")
slug = models.SlugField()
description = models.TextField(blank=True, default="")
topic = models.ForeignKey(Topic, on_delete=models.CASCADE, verbose_name="Thema")
objects = models.Manager()
class Meta:
verbose_name = "Anhang Ordner"
verbose_name_plural = "Anhang Ordner"
constraints = [
UniqueConstraint(fields=["slug", "topic"], name="unique_intern_slug_topic"),
UniqueConstraint(fields=["title", "topic"], name="unique_intern_title_topic"),
]
def __str__(self):
return self.topic.title + " / " + self.title
def get_absolute_url(self):
kwargs = {
"topic_group_slug": self.topic.topic_group.slug,
"topic_slug": self.topic.slug,
"slug": self.slug,
}
return reverse("intern:attachment", kwargs=kwargs)
def clean(self, *args, **kwargs):
self.slug = slugify(self.title)
class Etherpad(models.Model):
title = models.CharField(max_length=128, verbose_name="Titel")
slug_id = models.CharField(default=create_random_id, editable=False, max_length=8, unique=True)
etherpad_key = models.CharField(blank=True, max_length=50)
date = models.DateField(default=date.today, verbose_name="Datum")
attachment = models.ForeignKey(
Attachment,
on_delete=models.CASCADE,
verbose_name="Anhang Ordner",
)
objects = models.Manager()
class Meta:
verbose_name = "Etherpad"
verbose_name_plural = "Etherpads"
constraints = [
UniqueConstraint(fields=["title", "date", "attachment"], name="unique_intern_etherpad"),
]
def __str__(self):
return self.title
def get_absolute_url(self):
return ep_get_url(self.etherpad_key)
def clean(self):
pad_name = slugify(str(self.slug_id) + "-" + self.title[:40])
if len(pad_name) > 50:
msg = (
"Name ist zum Erstellen des Etherpads zu lange - max. 50 Zeichen. (Länge: "
"%(length)s, Name: %(pad_name)s)"
)
logger.error(
(
"Name '%(pad_name)s' is too long for creating a new etherpad - max. 50 "
"characters. (Length: %(length)s)",
),
extra={"length": len(pad_name), "pad_name": pad_name},
)
raise ValidationError(
msg,
code="pad_name_too_long",
params={"length": len(pad_name), "pad_name": pad_name},
)
if self.etherpad_key == "":
if ep_create_new_pad(pad_name):
self.etherpad_key = pad_name
else:
msg = "Etherpad '%(pad_name)s' konnte nicht erstellt werden."
logger.error(
"Etherpad '%(pad_name)s' could not be created. This should never happen!",
extra={"pad_name": pad_name},
)
raise ValidationError(
msg, code="pad_creation_failed", params={"pad_name": pad_name}
)
@property
def etherpad_html(self):
return ep_get_html(self.etherpad_key)
def get_model_name(self):
return self._meta.model_name
class FileUpload(models.Model):
title = models.CharField(blank=True, max_length=128, verbose_name="Titel")
file_field = models.FileField(upload_to="uploads/intern/files/", verbose_name="Dokument")
date = models.DateField(default=date.today, verbose_name="Datum")
attachment = models.ForeignKey(
Attachment,
on_delete=models.CASCADE,
verbose_name="Anhang Ordner",
)
objects = models.Manager()
class Meta:
verbose_name = "Datei"
verbose_name_plural = "Dateien"
def __str__(self):
return self.title
def clean(self):
if not self.title:
self.title = Path(self.file_field.name).stem

View File

@@ -1,230 +1,232 @@
from django.contrib import admin
from .forms import (
ActiveMemberForm,
InactiveMemberForm,
JobForm,
JobGroupForm,
JobInlineForm,
MemberForm,
)
from .models import Job, JobGroup, JobMember, Member
class MemberRoleFilter(admin.SimpleListFilter):
title = "Rolle"
parameter_name = "role"
def lookups(self, request, model_admin):
return (
("A", "Aktiv"),
("P", "Pension"),
)
def queryset(self, request, queryset):
if self.value() in Member.MemberRole:
return queryset.filter(role=self.value())
class JobMemberInline(admin.TabularInline):
model = JobMember
extra = 0
class JobOverviewInline(JobMemberInline):
verbose_name = "Tätigkeit"
verbose_name_plural = "Tätigkeitsübersicht"
def get_queryset(self, request):
return JobMember.members.get_all_jobs()
class ActiveMemberInline(JobMemberInline):
form = ActiveMemberForm
verbose_name = "Mitglied"
verbose_name_plural = "Aktive Mitglieder Liste"
def get_queryset(self, request):
return JobMember.active_member.get_queryset()
class InactiveMemberInline(JobMemberInline):
form = InactiveMemberForm
verbose_name = "Mitglied"
verbose_name_plural = "Inaktive Mitglieder Liste"
def get_queryset(self, request):
return JobMember.inactive_member.get_queryset()
class JobInline(admin.TabularInline):
form = JobInlineForm
model = Job
extra = 0
show_change_link = True
@admin.register(Member)
class MemberAdmin(admin.ModelAdmin):
form = MemberForm
model = Member
fieldsets = (
(
None,
{
"fields": (
(
"firstname",
"surname",
),
"nickname",
"mailaccount",
"role",
"image",
"description",
),
},
),
)
inlines = (JobOverviewInline,)
list_display = ["nickname", "firstname", "surname", "mailaccount", "role"]
list_filter = [MemberRoleFilter]
ordering = ["firstname"]
search_fields = ["firstname", "surname", "nickname", "mailaccount"]
def add_view(self, request, form_url="", extra_context=None):
extra_context = extra_context or {}
extra_context["help_text"] = "Fette Schriften sind Pflichtfelder."
return super().add_view(
request,
form_url,
extra_context=extra_context,
)
def change_view(self, request, object_id, form_url="", extra_context=None):
extra_context = extra_context or {}
extra_context["help_text"] = "Fette Schriften sind Pflichtfelder."
return super().change_view(
request,
object_id,
form_url,
extra_context=extra_context,
)
def save_model(self, request, obj, form, change):
obj.author = request.user
super().save_model(request, obj, form, change)
@admin.register(Job)
class JobAdmin(admin.ModelAdmin):
form = JobForm
model = Job
list_display = ["name", "job_group"]
ordering = ["name"]
search_fields = ["name"]
fieldsets = (
(
None,
{
"fields": (
"name",
"job_group",
),
},
),
(
"Permalink",
{
"fields": (
"shortterm",
"slug",
),
},
),
)
inlines = (ActiveMemberInline, InactiveMemberInline)
readonly_fields = ["slug"]
def add_view(self, request, form_url="", extra_context=None):
extra_context = extra_context or {}
extra_context["help_text"] = "Fette Schriften sind Pflichtfelder."
return super().add_view(
request,
form_url,
extra_context=extra_context,
)
def change_view(self, request, object_id, form_url="", extra_context=None):
extra_context = extra_context or {}
extra_context["help_text"] = "Fette Schriften sind Pflichfelder."
return super().change_view(
request,
object_id,
form_url,
extra_context=extra_context,
)
def save_model(self, request, obj, form, change):
obj.author = request.user
super().save_model(request, obj, form, change)
@admin.register(JobGroup)
class JobGroupAdmin(admin.ModelAdmin):
form = JobGroupForm
model = JobGroup
list_display = ["name"]
ordering = ["name"]
search_fields = ["name"]
fieldsets = (
(
None,
{
"fields": (
"name",
"description",
),
},
),
(
"Permalink",
{
"fields": (
"shortterm",
"slug",
),
},
),
)
inlines = (JobInline,)
readonly_fields = ["slug"]
def add_view(self, request, form_url="", extra_context=None):
extra_context = extra_context or {}
extra_context["help_text"] = "Fette Schriften sind Pflichtfelder."
return super().add_view(
request,
form_url,
extra_context=extra_context,
)
def change_view(self, request, object_id, form_url="", extra_context=None):
extra_context = extra_context or {}
extra_context["help_text"] = "Fette Schriften sind Pflichfelder."
return super().change_view(
request,
object_id,
form_url,
extra_context=extra_context,
)
def save_model(self, request, obj, form, change):
obj.author = request.user
super().save_model(request, obj, form, change)
from django.contrib import admin
from .forms import (
ActiveMemberForm,
InactiveMemberForm,
JobForm,
JobGroupForm,
JobInlineForm,
MemberForm,
)
from .models import Job, JobGroup, JobMember, Member
class MemberRoleFilter(admin.SimpleListFilter):
title = "Rolle"
parameter_name = "role"
def lookups(self, request, model_admin):
return (
("A", "Aktiv"),
("P", "Pension"),
)
def queryset(self, request, queryset):
if self.value() in Member.MemberRole:
return queryset.filter(role=self.value())
return queryset
class JobMemberInline(admin.TabularInline):
model = JobMember
extra = 0
class JobOverviewInline(JobMemberInline):
verbose_name = "Tätigkeit"
verbose_name_plural = "Tätigkeitsübersicht"
def get_queryset(self, request):
return JobMember.members.get_all_jobs()
class ActiveMemberInline(JobMemberInline):
form = ActiveMemberForm
verbose_name = "Mitglied"
verbose_name_plural = "Aktive Mitglieder Liste"
def get_queryset(self, request):
return JobMember.active_member.get_queryset()
class InactiveMemberInline(JobMemberInline):
form = InactiveMemberForm
verbose_name = "Mitglied"
verbose_name_plural = "Inaktive Mitglieder Liste"
def get_queryset(self, request):
return JobMember.inactive_member.get_queryset()
class JobInline(admin.TabularInline):
form = JobInlineForm
model = Job
extra = 0
show_change_link = True
@admin.register(Member)
class MemberAdmin(admin.ModelAdmin):
form = MemberForm
model = Member
fieldsets = (
(
None,
{
"fields": (
(
"firstname",
"surname",
),
"nickname",
"mailaccount",
"role",
"image",
"description",
),
},
),
)
inlines = (JobOverviewInline,)
list_display = ["nickname", "firstname", "surname", "mailaccount", "role"]
list_filter = [MemberRoleFilter]
ordering = ["firstname"]
search_fields = ["firstname", "surname", "nickname", "mailaccount"]
def add_view(self, request, form_url="", extra_context=None):
extra_context = extra_context or {}
extra_context["help_text"] = "Fette Schriften sind Pflichtfelder."
return super().add_view(
request,
form_url,
extra_context=extra_context,
)
def change_view(self, request, object_id, form_url="", extra_context=None):
extra_context = extra_context or {}
extra_context["help_text"] = "Fette Schriften sind Pflichtfelder."
return super().change_view(
request,
object_id,
form_url,
extra_context=extra_context,
)
def save_model(self, request, obj, form, change):
obj.author = request.user
super().save_model(request, obj, form, change)
@admin.register(Job)
class JobAdmin(admin.ModelAdmin):
form = JobForm
model = Job
list_display = ["name", "job_group"]
ordering = ["name"]
search_fields = ["name"]
fieldsets = (
(
None,
{
"fields": (
"name",
"job_group",
),
},
),
(
"Permalink",
{
"fields": (
"shortterm",
"slug",
),
},
),
)
inlines = (ActiveMemberInline, InactiveMemberInline)
readonly_fields = ["slug"]
def add_view(self, request, form_url="", extra_context=None):
extra_context = extra_context or {}
extra_context["help_text"] = "Fette Schriften sind Pflichtfelder."
return super().add_view(
request,
form_url,
extra_context=extra_context,
)
def change_view(self, request, object_id, form_url="", extra_context=None):
extra_context = extra_context or {}
extra_context["help_text"] = "Fette Schriften sind Pflichfelder."
return super().change_view(
request,
object_id,
form_url,
extra_context=extra_context,
)
def save_model(self, request, obj, form, change):
obj.author = request.user
super().save_model(request, obj, form, change)
@admin.register(JobGroup)
class JobGroupAdmin(admin.ModelAdmin):
form = JobGroupForm
model = JobGroup
list_display = ["name"]
ordering = ["name"]
search_fields = ["name"]
fieldsets = (
(
None,
{
"fields": (
"name",
"description",
),
},
),
(
"Permalink",
{
"fields": (
"shortterm",
"slug",
),
},
),
)
inlines = (JobInline,)
readonly_fields = ["slug"]
def add_view(self, request, form_url="", extra_context=None):
extra_context = extra_context or {}
extra_context["help_text"] = "Fette Schriften sind Pflichtfelder."
return super().add_view(
request,
form_url,
extra_context=extra_context,
)
def change_view(self, request, object_id, form_url="", extra_context=None):
extra_context = extra_context or {}
extra_context["help_text"] = "Fette Schriften sind Pflichfelder."
return super().change_view(
request,
object_id,
form_url,
extra_context=extra_context,
)
def save_model(self, request, obj, form, change):
obj.author = request.user
super().save_model(request, obj, form, change)

View File

@@ -1,210 +1,207 @@
import logging
from django.conf import settings
from django.contrib.auth.models import User
from django.core.validators import ValidationError, validate_email
from django.db import models
from django.db.models import F
from django.urls import reverse
from django.utils.text import slugify
from easy_thumbnails.fields import ThumbnailerImageField
from .managers import (
ActiveJobMemberManager,
InactiveJobMemberManager,
JobMemberManager,
MemberManager,
)
from .validators import (
validate_domainonly_email,
validate_file_size,
validate_image_dimension,
)
fet_logo_url = settings.STATIC_URL + "img/FET-Logo-2014-quadrat.png"
logger = logging.getLogger(__name__)
class Member(models.Model):
firstname = models.CharField("Vorname", max_length=128)
surname = models.CharField("Nachname", max_length=128)
nickname = models.CharField("Spitzname", max_length=128)
# LDAP Username
username = models.CharField(max_length=128, blank=True)
# fet mail account
mailaccount = models.CharField(
"Mailadresse",
unique=True,
max_length=128,
validators=[validate_email, validate_domainonly_email],
error_messages={
"unique": "Diese Mailadresse existiert schon.",
},
)
class MemberRole(models.TextChoices):
ACTIVE = "A", "Active"
PENSION = "P", "Pension"
role = models.CharField(
"Rolle",
max_length=1,
choices=MemberRole.choices,
default=MemberRole.ACTIVE,
)
description = models.TextField(blank=True, default="")
image = ThumbnailerImageField(
upload_to="uploads/members/image/",
validators=[validate_file_size, validate_image_dimension],
)
date_modified = models.DateTimeField(auto_now=True)
date_created = models.DateTimeField(auto_now_add=True)
# Managers
objects = models.Manager()
all_members = MemberManager()
class Meta:
verbose_name = "Mitglied"
verbose_name_plural = "Mitglieder"
def __str__(self):
return self.firstname + " " + self.surname
# need to have 'View on site' link in admin app
def get_absolute_url(self):
return reverse("members:member", kwargs={"pk": self.pk})
def clean(self):
if not self.image:
raise ValidationError("Es fehlt das Profilbild.")
if self.username:
try:
user = User.objects.get(username=self.username.lower())
except User.DoesNotExist as e:
logger.info("Username not found. Error: %s", e)
else:
user.first_name = self.firstname
user.save()
def get_model_name(self):
return self._meta.model_name
@property
def image_url(self):
return self.image.url if self.image else fet_logo_url
@property
def avatar_url(self):
return self.image["avatar"].url if self.image else fet_logo_url
@property
def portrait_url(self):
return self.image["portrait"].url if self.image else fet_logo_url
@property
def thumb_url(self):
return self.image["thumb"].url if self.image else fet_logo_url
class JobGroup(models.Model):
name = models.CharField(max_length=128)
shortterm = models.CharField(max_length=128, unique=True, blank=True)
slug = models.SlugField(unique=True, null=True, blank=True)
description = models.TextField(blank=True, default="")
# Managers
objects = models.Manager()
class Meta:
verbose_name = "Tätigkeitsbereich"
verbose_name_plural = "Tätigkeitsbereiche"
def __str__(self):
return self.name
# need to have 'View on site' link in admin app
def get_absolute_url(self):
return reverse("members:jobs", kwargs={"slug": self.slug})
def clean(self):
if not self.shortterm:
self.shortterm = slugify(self.name)
self.slug = slugify(self.shortterm)
class Job(models.Model):
name = models.CharField(max_length=128)
shortterm = models.CharField(max_length=128, unique=True, blank=True)
slug = models.SlugField(unique=True, null=True, blank=True)
order = models.PositiveSmallIntegerField(null=True, blank=True)
job_group = models.ForeignKey(
JobGroup,
on_delete=models.CASCADE,
verbose_name="Job Gruppe",
)
# Managers
objects = models.Manager()
class Meta:
ordering = (F("order").asc(nulls_last=True), "name")
verbose_name = "Tätigkeit"
verbose_name_plural = "Tätigkeiten"
def __str__(self):
return self.name
def get_absolute_url(self):
return reverse("members:jobs", kwargs={"slug": self.job_group.slug}) + "#" + self.slug
def clean(self):
if not self.shortterm:
self.shortterm = slugify(self.name)
self.slug = slugify(self.shortterm)
class JobMember(models.Model):
member = models.ForeignKey(
Member,
on_delete=models.CASCADE,
verbose_name="Mitglied",
)
job = models.ForeignKey(
Job,
on_delete=models.CASCADE,
verbose_name="Tätigkeit",
)
job_start = models.DateField("Job Start")
job_end = models.DateField("Job Ende", null=True, blank=True)
class JobRole(models.TextChoices):
PRESIDENT = ("10", "Vorsitz")
VICE_PRESIDENT = ("20", "Stv. Vorsitz")
SECOND_VICE_PRESIDENT = ("30", "2. Stv. Vorsitz")
PERSON_RESPONSIBLE = ("40", "Verantwortliche_r")
MEMBER = ("50", "Mitglied")
SUBSTITUTE_MEMBER = ("60", "Ersatzmitglied")
job_role = models.CharField(max_length=2, choices=JobRole.choices, default=JobRole.MEMBER)
objects = models.Manager()
members = JobMemberManager()
active_member = ActiveJobMemberManager()
inactive_member = InactiveJobMemberManager()
def __str__(self):
return self.job.name
import logging
from django.conf import settings
from django.contrib.auth.models import User
from django.core.validators import validate_email
from django.db import models
from django.db.models import F
from django.urls import reverse
from django.utils.text import slugify
from easy_thumbnails.fields import ThumbnailerImageField
from .managers import (
ActiveJobMemberManager,
InactiveJobMemberManager,
JobMemberManager,
MemberManager,
)
from .validators import (
validate_domainonly_email,
validate_file_size,
validate_image_dimension,
)
fet_logo_url = settings.STATIC_URL + "img/FET-Logo-2014-quadrat.png"
logger = logging.getLogger(__name__)
class Member(models.Model):
firstname = models.CharField("Vorname", max_length=128)
surname = models.CharField("Nachname", max_length=128)
nickname = models.CharField("Spitzname", max_length=128)
# LDAP Username
username = models.CharField(max_length=128, blank=True)
# fet mail account
mailaccount = models.CharField(
"Mailadresse",
unique=True,
max_length=128,
validators=[validate_email, validate_domainonly_email],
error_messages={
"unique": "Diese Mailadresse existiert schon.",
},
)
class MemberRole(models.TextChoices):
ACTIVE = "A", "Active"
PENSION = "P", "Pension"
role = models.CharField(
"Rolle",
max_length=1,
choices=MemberRole.choices,
default=MemberRole.ACTIVE,
)
description = models.TextField(blank=True, default="")
image = ThumbnailerImageField(
upload_to="uploads/members/image/",
validators=[validate_file_size, validate_image_dimension],
)
date_modified = models.DateTimeField(auto_now=True)
date_created = models.DateTimeField(auto_now_add=True)
# Managers
objects = models.Manager()
all_members = MemberManager()
class Meta:
verbose_name = "Mitglied"
verbose_name_plural = "Mitglieder"
def __str__(self):
return self.firstname + " " + self.surname
# need to have 'View on site' link in admin app
def get_absolute_url(self):
return reverse("members:member", kwargs={"pk": self.pk})
def clean(self):
if self.username:
try:
user = User.objects.get(username=self.username.lower())
except User.DoesNotExist as e:
logger.info("Username not found. Error: %s", e)
else:
user.first_name = self.firstname
user.save()
def get_model_name(self):
return self._meta.model_name
@property
def image_url(self):
return self.image.url if self.image else fet_logo_url
@property
def avatar_url(self):
return self.image["avatar"].url if self.image else fet_logo_url
@property
def portrait_url(self):
return self.image["portrait"].url if self.image else fet_logo_url
@property
def thumb_url(self):
return self.image["thumb"].url if self.image else fet_logo_url
class JobGroup(models.Model):
name = models.CharField(max_length=128)
shortterm = models.CharField(max_length=128, unique=True, blank=True)
slug = models.SlugField(unique=True, null=True, blank=True)
description = models.TextField(blank=True, default="")
# Managers
objects = models.Manager()
class Meta:
verbose_name = "Tätigkeitsbereich"
verbose_name_plural = "Tätigkeitsbereiche"
def __str__(self):
return self.name
# need to have 'View on site' link in admin app
def get_absolute_url(self):
return reverse("members:jobs", kwargs={"slug": self.slug})
def clean(self):
if not self.shortterm:
self.shortterm = slugify(self.name)
self.slug = slugify(self.shortterm)
class Job(models.Model):
name = models.CharField(max_length=128)
shortterm = models.CharField(max_length=128, unique=True, blank=True)
slug = models.SlugField(unique=True, null=True, blank=True)
order = models.PositiveSmallIntegerField(null=True, blank=True)
job_group = models.ForeignKey(
JobGroup,
on_delete=models.CASCADE,
verbose_name="Job Gruppe",
)
# Managers
objects = models.Manager()
class Meta:
ordering = (F("order").asc(nulls_last=True), "name")
verbose_name = "Tätigkeit"
verbose_name_plural = "Tätigkeiten"
def __str__(self):
return self.name
def get_absolute_url(self):
return reverse("members:jobs", kwargs={"slug": self.job_group.slug}) + "#" + self.slug
def clean(self):
if not self.shortterm:
self.shortterm = slugify(self.name)
self.slug = slugify(self.shortterm)
class JobMember(models.Model):
member = models.ForeignKey(
Member,
on_delete=models.CASCADE,
verbose_name="Mitglied",
)
job = models.ForeignKey(
Job,
on_delete=models.CASCADE,
verbose_name="Tätigkeit",
)
job_start = models.DateField("Job Start")
job_end = models.DateField("Job Ende", null=True, blank=True)
class JobRole(models.TextChoices):
PRESIDENT = ("10", "Vorsitz")
VICE_PRESIDENT = ("20", "Stv. Vorsitz")
SECOND_VICE_PRESIDENT = ("30", "2. Stv. Vorsitz")
PERSON_RESPONSIBLE = ("40", "Verantwortliche_r")
MEMBER = ("50", "Mitglied")
SUBSTITUTE_MEMBER = ("60", "Ersatzmitglied")
job_role = models.CharField(max_length=2, choices=JobRole.choices, default=JobRole.MEMBER)
objects = models.Manager()
members = JobMemberManager()
active_member = ActiveJobMemberManager()
inactive_member = InactiveJobMemberManager()
def __str__(self):
return self.job.name

View File

@@ -1,33 +1,31 @@
from collections import deque
from django import template
from django.db.models import F
from members.models import JobGroup, JobMember
register = template.Library()
@register.inclusion_tag("members/partials/jobs_sidebar.html")
def get_jobs_sidebar(slug):
job_groups = deque(JobGroup.objects.all())
# remove job group if there is no longer an active member
for elem in job_groups.copy():
job_members = JobMember.active_member.get_all(slug=elem.slug)
if not job_members:
job_groups.remove(elem)
job_members = JobMember.active_member.get_all(slug=slug).order_by(
F("job__order").asc(nulls_last=True),
"job__name",
)
active_job_group = JobGroup.objects.filter(slug=slug).first()
context = {
"job_groups": job_groups,
"job_members": job_members,
"active_job_group": active_job_group,
}
return context
from collections import deque
from django import template
from django.db.models import F
from members.models import JobGroup, JobMember
register = template.Library()
@register.inclusion_tag("members/partials/jobs_sidebar.html")
def get_jobs_sidebar(slug):
job_groups = deque(JobGroup.objects.all())
# remove job group if there is no longer an active member
for elem in job_groups.copy():
job_members = JobMember.active_member.get_all(slug=elem.slug)
if not job_members:
job_groups.remove(elem)
job_members = JobMember.active_member.get_all(slug=slug).order_by(
F("job__order").asc(nulls_last=True),
"job__name",
)
active_job_group = JobGroup.objects.filter(slug=slug).first()
return {
"job_groups": job_groups,
"job_members": job_members,
"active_job_group": active_job_group,
}

View File

@@ -1,22 +1,26 @@
from django.core.validators import ValidationError
def validate_domainonly_email(value):
if "fet.at" not in value:
raise ValidationError("In der Mailadresse fehlt die richtige Domäne.")
def validate_file_size(value):
if value.size > 10 * 1024 * 1024:
raise ValidationError("Die maximale Dateigröße ist 10MB.")
def validate_image_dimension(value):
if value.height < 150 or value.width < 150:
raise ValidationError(
"Das Bild ist zu klein. (Höhe: %(height)s, Breite: %(width)s)",
params={
"height": value.height,
"width": value.width,
},
)
from django.core.exceptions import ValidationError
def validate_domainonly_email(value):
if "fet.at" not in value:
msg = "In der Mailadresse fehlt die richtige Domäne."
raise ValidationError(msg, code="invalid_domain")
def validate_file_size(value):
if value.size > 10 * 1024 * 1024:
msg = "Die maximale Dateigröße ist 10MB."
raise ValidationError(msg, code="file_too_large")
def validate_image_dimension(value):
if value.height < 150 or value.width < 150:
msg = "Das Bild ist zu klein. (Höhe: %(height)s, Breite: %(width)s)"
raise ValidationError(
msg,
code="image_too_small",
params={
"height": value.height,
"width": value.width,
},
)

View File

@@ -23,10 +23,7 @@ def create_token(username, masterpassword):
@authenticated_user
def index(request):
context = {
"mctoken": "",
"valid_master_pwd": True
}
context = {"mctoken": "", "valid_master_pwd": True}
masterpassword = settings.MC_MASTERPASSWORD

View File

@@ -1,275 +1,275 @@
from ckeditor_uploader.widgets import CKEditorUploadingWidget
from django import forms
from django.forms.widgets import CheckboxInput
from django.utils.dates import MONTHS
from taggit.models import Tag
from .models import Event, FetMeeting, News, Post
class PostForm(forms.ModelForm):
class Meta:
model = Post
fields = [
"title",
"subtitle",
"tags",
"image",
"body",
"slug",
"author",
"public_date",
]
widgets = {"body": CKEditorUploadingWidget(config_name="default")}
class Media:
js = (
"js/auto_slug.js", # automatic slag completion via ajax
"js/tag_completion.js", # to get a list for tag autocompletion via ajax
)
class NewsForm(PostForm):
class Meta:
model = News
fields = "__all__"
help_texts = {
"tags": (
"Die Hashtags ohne '#' eintragen, und mit Komma kann man mehrere Tags anfügen."
),
"image": "Verwendbare Formate: ...",
"is_pinned": (
"Der Post soll als erster auf der Startseite angeheftet werden und sich "
"automatisch einen Monat nach der Veröffentlichung wieder lösen."
),
}
labels = {
"title": "Titel",
"subtitle": "Untertitel",
"image": "Hintergrundbild",
"body": "Text",
"slug": "Permalink",
"author": "Autor",
"public_date": "Veröffentlichung",
"is_pinned": "Post anheften",
}
widgets = {"body": CKEditorUploadingWidget(config_name="default")}
def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs) # to get the self.fields set
author_qs = self.fields["author"].queryset.order_by("username")
self.fields["author"].queryset = author_qs
class EventForm(PostForm):
class Meta:
model = Event
fields = "__all__"
help_texts = {
"tags": (
"Die Hashtags ohne '#' eintragen, und mit Komma kann man mehrere Tags anfügen."
),
"image": "Verwendbare Formate: Bildformate",
"is_pinned": (
"Dieses Event soll als erstes auf der Startseite angeheftet werden und sich "
"automatisch einen Monat nach der Veröffentlichung wieder lösen."
),
}
labels = {
"title": "Titel",
"subtitle": "Untertitel",
"image": "Hintergrundbild",
"body": "Text",
"event_start": "Start des Events",
"event_end": "Ende des Events",
"event_place": "Ort des Events",
"slug": "Permalink",
"author": "Autor",
"public_date": "Veröffentlichung",
"is_pinned": "Event anheften",
}
widgets = {"body": CKEditorUploadingWidget(config_name="default")}
def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs) # to get the self.fields set
author_qs = self.fields["author"].queryset.order_by("username")
self.fields["author"].queryset = author_qs
self.fields["event_start"].required = True
self.fields["event_end"].required = False
if "event_place" in self.fields:
self.fields["event_place"].required = True
class FetMeetingForm(PostForm):
class Meta:
model = FetMeeting
fields = ["event_start", "event_end", "event_place", "tags"]
labels = {
"event_start": "Start der Sitzung",
"event_end": "Ende der Sitzung",
"event_place": "Ort der Sitzung",
}
help_texts = {
"event_end": "Bei einer leeren Eingabe werden 2 Stunden zur Startzeit dazugezählt.",
"tags": (
"Die Hashtags ohne '#' eintragen, und mit Komma kann man mehrere Tags anfügen."
),
}
def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs) # to get the self.fields set
self.fields["event_start"].required = True
self.fields["event_end"].required = False
self.fields["event_place"].initial = "FET"
tags = []
tags.append(Tag())
tags[0].name = "fachschaft"
self.fields["tags"].initial = tags
class PostSearchForm(forms.Form):
year_choices = [("", "Alle")]
month_choices = [("", "Alle")] + list(MONTHS.items())
year = forms.ChoiceField(label="Jahr", choices=year_choices, required=False)
month = forms.ChoiceField(label="Monat", choices=month_choices, required=False)
compact_view = forms.BooleanField(
label="Kompakte Ansicht",
required=False,
widget=CheckboxInput,
)
fet_meeting_only = forms.BooleanField(
label="nur FET Sitzungen",
required=False,
widget=CheckboxInput,
)
def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs) # to get the self.fields set
try:
first_post = Post.objects.get_queryset().last()
last_post = Post.objects.get_queryset().first()
if first_post and last_post:
years = range(last_post.date.year, first_post.date.year - 1, -1)
year_choices = [("", "Alle")] + [(i, i) for i in years]
self.fields["year"].choices = year_choices
except Exception:
pass
class NewsUpdateForm(forms.ModelForm):
class Meta:
model = News
fields = [
"title",
"status",
"body",
]
labels = {
"title": "Titel",
"image": "Hintergrundbild",
"body": "Text",
}
widgets = {"body": CKEditorUploadingWidget(config_name="default")}
def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs) # to get the self.fields set
self.fields["title"].autofocus = True
class EventUpdateForm(forms.ModelForm):
class Meta:
model = Event
fields = [
"title",
"status",
"event_start",
"event_end",
"event_place",
]
labels = {
"title": "Titel",
"event_start": "Start des Events",
"event_end": "Ende des Events",
"event_place": "Ort des Events",
}
def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs) # to get the self.fields set
self.fields["event_start"].required = True
self.fields["event_start"].autofocus = True
self.fields["event_end"].required = False
if "event_place" in self.fields:
self.fields["event_place"].required = True
class FetMeetingCreateForm(forms.ModelForm):
class Meta:
model = FetMeeting
fields = ["event_start", "event_end", "event_place"]
help_texts = {
"event_end": "Bei einer leeren Eingabe werden 2 Stunden zur Startzeit dazugezählt.",
}
labels = {
"event_start": "Start der Sitzung",
"event_end": "Ende der Sitzung",
"event_place": "Ort der Sitzung",
}
def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs) # to get the self.fields set
self.fields["event_start"].required = True
self.fields["event_start"].autofocus = True
self.fields["event_end"].required = False
self.fields["event_place"].initial = "FET"
class FetMeetingUpdateForm(forms.ModelForm):
class Meta:
model = FetMeeting
fields = ["event_start", "event_end", "event_place"]
labels = {
"event_start": "Start der Sitzung",
"event_end": "Ende der Sitzung",
"event_place": "Ort der Sitzung",
}
def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs) # to get the self.fields set
self.fields["event_start"].required = True
self.fields["event_start"].autofocus = True
self.fields["event_end"].required = False
self.fields["event_place"].initial = "FET"
from ckeditor_uploader.widgets import CKEditorUploadingWidget
from django import forms
from django.forms.widgets import CheckboxInput
from django.utils.dates import MONTHS
from taggit.models import Tag
from .models import Event, FetMeeting, News, Post
class PostForm(forms.ModelForm):
class Meta:
model = Post
fields = [
"title",
"subtitle",
"tags",
"image",
"body",
"slug",
"author",
"public_date",
]
widgets = {"body": CKEditorUploadingWidget(config_name="default")}
class Media:
js = (
"js/auto_slug.js", # automatic slag completion via ajax
"js/tag_completion.js", # to get a list for tag autocompletion via ajax
)
class NewsForm(PostForm):
class Meta:
model = News
fields = "__all__"
help_texts = {
"tags": (
"Die Hashtags ohne '#' eintragen, und mit Komma kann man mehrere Tags anfügen."
),
"image": "Verwendbare Formate: ...",
"is_pinned": (
"Der Post soll als erster auf der Startseite angeheftet werden und sich "
"automatisch einen Monat nach der Veröffentlichung wieder lösen."
),
}
labels = {
"title": "Titel",
"subtitle": "Untertitel",
"image": "Hintergrundbild",
"body": "Text",
"slug": "Permalink",
"author": "Autor",
"public_date": "Veröffentlichung",
"is_pinned": "Post anheften",
}
widgets = {"body": CKEditorUploadingWidget(config_name="default")}
def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs) # to get the self.fields set
author_qs = self.fields["author"].queryset.order_by("username")
self.fields["author"].queryset = author_qs
class EventForm(PostForm):
class Meta:
model = Event
fields = "__all__"
help_texts = {
"tags": (
"Die Hashtags ohne '#' eintragen, und mit Komma kann man mehrere Tags anfügen."
),
"image": "Verwendbare Formate: Bildformate",
"is_pinned": (
"Dieses Event soll als erstes auf der Startseite angeheftet werden und sich "
"automatisch ein Tag nach dem Eventende wieder lösen."
),
}
labels = {
"title": "Titel",
"subtitle": "Untertitel",
"image": "Hintergrundbild",
"body": "Text",
"event_start": "Start des Events",
"event_end": "Ende des Events",
"event_place": "Ort des Events",
"slug": "Permalink",
"author": "Autor",
"public_date": "Veröffentlichung",
"is_pinned": "Event anheften",
}
widgets = {"body": CKEditorUploadingWidget(config_name="default")}
def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs) # to get the self.fields set
author_qs = self.fields["author"].queryset.order_by("username")
self.fields["author"].queryset = author_qs
self.fields["event_start"].required = True
self.fields["event_end"].required = False
if "event_place" in self.fields:
self.fields["event_place"].required = True
class FetMeetingForm(PostForm):
class Meta:
model = FetMeeting
fields = ["event_start", "event_end", "event_place", "tags"]
labels = {
"event_start": "Start der Sitzung",
"event_end": "Ende der Sitzung",
"event_place": "Ort der Sitzung",
}
help_texts = {
"event_end": "Bei einer leeren Eingabe werden 2 Stunden zur Startzeit dazugezählt.",
"tags": (
"Die Hashtags ohne '#' eintragen, und mit Komma kann man mehrere Tags anfügen."
),
}
def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs) # to get the self.fields set
self.fields["event_start"].required = True
self.fields["event_end"].required = False
self.fields["event_place"].initial = "FET"
tags = []
tags.append(Tag())
tags[0].name = "fachschaft"
self.fields["tags"].initial = tags
class PostSearchForm(forms.Form):
year_choices = [("", "Alle")]
month_choices = [("", "Alle")] + list(MONTHS.items())
year = forms.ChoiceField(label="Jahr", choices=year_choices, required=False)
month = forms.ChoiceField(label="Monat", choices=month_choices, required=False)
compact_view = forms.BooleanField(
label="Kompakte Ansicht",
required=False,
widget=CheckboxInput,
)
fet_meeting_only = forms.BooleanField(
label="nur FET Sitzungen",
required=False,
widget=CheckboxInput,
)
def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs) # to get the self.fields set
try:
first_post = Post.objects.get_queryset().last()
last_post = Post.objects.get_queryset().first()
if first_post and last_post:
years = range(last_post.date.year, first_post.date.year - 1, -1)
year_choices = [("", "Alle")] + [(i, i) for i in years]
self.fields["year"].choices = year_choices
except Exception:
pass
class NewsUpdateForm(forms.ModelForm):
class Meta:
model = News
fields = [
"title",
"status",
"body",
]
labels = {
"title": "Titel",
"image": "Hintergrundbild",
"body": "Text",
}
widgets = {"body": CKEditorUploadingWidget(config_name="default")}
def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs) # to get the self.fields set
self.fields["title"].autofocus = True
class EventUpdateForm(forms.ModelForm):
class Meta:
model = Event
fields = [
"title",
"status",
"event_start",
"event_end",
"event_place",
]
labels = {
"title": "Titel",
"event_start": "Start des Events",
"event_end": "Ende des Events",
"event_place": "Ort des Events",
}
def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs) # to get the self.fields set
self.fields["event_start"].required = True
self.fields["event_start"].autofocus = True
self.fields["event_end"].required = False
if "event_place" in self.fields:
self.fields["event_place"].required = True
class FetMeetingCreateForm(forms.ModelForm):
class Meta:
model = FetMeeting
fields = ["event_start", "event_end", "event_place"]
help_texts = {
"event_end": "Bei einer leeren Eingabe werden 2 Stunden zur Startzeit dazugezählt.",
}
labels = {
"event_start": "Start der Sitzung",
"event_end": "Ende der Sitzung",
"event_place": "Ort der Sitzung",
}
def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs) # to get the self.fields set
self.fields["event_start"].required = True
self.fields["event_start"].autofocus = True
self.fields["event_end"].required = False
self.fields["event_place"].initial = "FET"
class FetMeetingUpdateForm(forms.ModelForm):
class Meta:
model = FetMeeting
fields = ["event_start", "event_end", "event_place"]
labels = {
"event_start": "Start der Sitzung",
"event_end": "Ende der Sitzung",
"event_place": "Ort der Sitzung",
}
def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs) # to get the self.fields set
self.fields["event_start"].required = True
self.fields["event_start"].autofocus = True
self.fields["event_end"].required = False
self.fields["event_place"].initial = "FET"

View File

@@ -1,201 +1,206 @@
import datetime
from django.db import models
from django.db.models import Case, Q, When
from .choices import PostType, Status
class PublishedManager(models.Manager):
def published(self, public=True):
"""
publish all posts with status 'PUBLIC'
"""
return self.get_queryset().filter(status=Status.PUBLIC) if public else self.get_queryset()
def published_all(self, public=True):
"""
publish all posts with status 'PUBLIC' and 'ONLY_INTERN'
"""
return (
self.get_queryset().filter(~Q(status=Status.DRAFT)) if public else self.get_queryset()
)
class PostManager(PublishedManager, models.Manager):
def get_queryset(self):
qs = (
super()
.get_queryset()
.annotate(
date=Case(
When(post_type=PostType.NEWS, then="public_date"),
When(post_type=PostType.EVENT, then="event_start__date"),
When(post_type=PostType.FETMEETING, then="event_start__date"),
),
)
)
return qs.order_by("-date", "-id")
def date_sorted(self, public=True):
return self.published(public)
def date_filter(
self,
public=True,
year=None,
month=None,
fet_meeting_only=None,
):
qs_filter = Q()
if fet_meeting_only:
qs_filter &= Q(post_type=PostType.FETMEETING)
if year:
qs_filter &= Q(date__year=year)
if month:
qs_filter &= Q(date__month=month)
return self.published(public).filter(qs_filter)
class ArticleManager(PublishedManager, models.Manager):
"""
Provide a query set only for "Article"
regular fet meetings should not be contained in the news stream
"""
def get_queryset(self):
qs = super().get_queryset().filter(Q(post_type=PostType.NEWS) | Q(post_type=PostType.EVENT))
qs = qs.annotate(
date=Case(
When(post_type=PostType.NEWS, then="public_date"),
When(post_type=PostType.EVENT, then="event_start__date"),
),
)
return qs.order_by("-date", "-id")
def date_sorted(self, public=True):
return self.published(public)
def pinned(self, public=True):
# Get date for pinned news that is max 1 month old.
post_date = datetime.datetime.now(tz=datetime.UTC).date()
__month = post_date.month
__year = post_date.year
if __month != 1:
__month -= 1
else:
# If the current month is January, you get the date from December of previous year.
__month = 12
__year -= 1
post_date = post_date.replace(year=__year, month=__month)
# Get date for event posts that is max 1 day old.
event_date = datetime.datetime.now(tz=datetime.UTC).date() - datetime.timedelta(1)
return (
self.published(public)
.filter(
Q(is_pinned=True)
& (
(Q(post_type=PostType.NEWS) & Q(public_date__gt=post_date))
| (Q(post_type=PostType.EVENT) & Q(event_end__date__gt=event_date))
)
)
.first()
)
class NewsManager(PublishedManager, models.Manager):
"""
Provide a query set only for "News"
"""
def get_queryset(self):
qs = super().get_queryset().filter(post_type=PostType.NEWS)
qs = qs.annotate(
date=Case(
When(post_type=PostType.NEWS, then="public_date"),
),
)
return qs.order_by("-date")
class AllEventManager(PublishedManager, models.Manager):
"""
Provide a query set for all events ("Event" and "Fet Meeting")
"""
def get_queryset(self):
qs = (
super()
.get_queryset()
.filter(Q(post_type=PostType.EVENT) | Q(post_type=PostType.FETMEETING))
)
qs = qs.annotate(
date=Case(
When(post_type=PostType.EVENT, then="event_start__date"),
When(post_type=PostType.FETMEETING, then="event_start__date"),
),
)
return qs.order_by("-date")
def future_events(self, public=True):
date_today = datetime.datetime.now(tz=datetime.UTC).date()
qs = self.published(public).filter(event_start__gt=date_today)
return qs.reverse()
class EventManager(PublishedManager, models.Manager):
"""
Provide a query set only for "Events"
regular fet meetings should not be contained in the news stream
"""
def get_queryset(self):
qs = super().get_queryset().filter(post_type=PostType.EVENT)
qs = qs.annotate(
date=Case(
When(post_type=PostType.EVENT, then="event_start__date"),
),
)
return qs.order_by("-date")
def future_events(self, public=True):
date_today = datetime.datetime.now(tz=datetime.UTC).date()
qs = self.published(public).filter(event_start__gt=date_today)
return qs.reverse()
def past_events(self, public=True):
date_today = datetime.datetime.now(tz=datetime.UTC).date()
qs = self.published(public).filter(event_start__lt=date_today)
return qs
class FetMeetingManager(PublishedManager, models.Manager):
"""
Provide a query set only for "Fet Meeting"
"""
def get_queryset(self):
qs = super().get_queryset().filter(post_type=PostType.FETMEETING)
qs = qs.annotate(
date=Case(
When(post_type=PostType.FETMEETING, then="event_start__date"),
),
)
return qs.order_by("-date")
def future_events(self):
date_today = datetime.datetime.now(tz=datetime.UTC).date()
qs = self.published().filter(event_start__gt=date_today)
return qs.reverse()
def past_events(self):
date_today = datetime.datetime.now(tz=datetime.UTC).date()
qs = self.published().filter(event_start__lt=date_today)
return qs
import calendar
import datetime
from django.db import models
from django.db.models import Case, Q, When
from django.utils import timezone
from .choices import PostType, Status
class PublishedManager(models.Manager):
def published(self, public=True):
"""
publish all posts with status 'PUBLIC'
"""
return self.get_queryset().filter(status=Status.PUBLIC) if public else self.get_queryset()
def published_all(self, public=True):
"""
publish all posts with status 'PUBLIC' and 'ONLY_INTERN'
"""
return (
self.get_queryset().filter(~Q(status=Status.DRAFT)) if public else self.get_queryset()
)
class PostManager(PublishedManager, models.Manager):
def get_queryset(self):
qs = (
super()
.get_queryset()
.annotate(
date=Case(
When(post_type=PostType.NEWS, then="public_date"),
When(post_type=PostType.EVENT, then="event_start__date"),
When(post_type=PostType.FETMEETING, then="event_start__date"),
),
)
)
return qs.order_by("-date", "-id")
def date_sorted(self, public=True):
return self.published(public)
def date_filter(
self,
public=True,
year=None,
month=None,
fet_meeting_only=None,
):
qs_filter = Q()
if fet_meeting_only:
qs_filter &= Q(post_type=PostType.FETMEETING)
if year:
qs_filter &= Q(date__year=year)
if month:
qs_filter &= Q(date__month=month)
return self.published(public).filter(qs_filter)
class ArticleManager(PublishedManager, models.Manager):
"""
Provide a query set only for "Article"
regular fet meetings should not be contained in the news stream
"""
def get_queryset(self):
qs = super().get_queryset().filter(Q(post_type=PostType.NEWS) | Q(post_type=PostType.EVENT))
qs = qs.annotate(
date=Case(
When(post_type=PostType.NEWS, then="public_date"),
When(post_type=PostType.EVENT, then="event_start__date"),
),
)
return qs.order_by("-date", "-id")
def date_sorted(self, public=True):
return self.published(public)
def pinned(self, public=True):
# Get date for pinned news that is max 1 month old.
post_date = timezone.now().date()
_day = post_date.day
_month = post_date.month
_year = post_date.year
if _month != 1:
_month -= 1
else:
# If the current month is January, you get the date from December of previous year.
_month = 12
_year -= 1
# Clamp day to last day of target month (handles 30/31 and Feb)
last_day = calendar.monthrange(_year, _month)[1]
safe_day = min(_day, last_day)
post_date = post_date.replace(year=_year, month=_month, day=safe_day)
# Get date for event posts that is max 1 day old.
event_date = timezone.now().date() - datetime.timedelta(1)
return (
self.published(public)
.filter(
Q(is_pinned=True)
& (
(Q(post_type=PostType.NEWS) & Q(public_date__gt=post_date))
| (Q(post_type=PostType.EVENT) & Q(event_end__date__gt=event_date))
)
)
.first()
)
class NewsManager(PublishedManager, models.Manager):
"""
Provide a query set only for "News"
"""
def get_queryset(self):
qs = super().get_queryset().filter(post_type=PostType.NEWS)
qs = qs.annotate(
date=Case(
When(post_type=PostType.NEWS, then="public_date"),
),
)
return qs.order_by("-date")
class AllEventManager(PublishedManager, models.Manager):
"""
Provide a query set for all events ("Event" and "Fet Meeting")
"""
def get_queryset(self):
qs = (
super()
.get_queryset()
.filter(Q(post_type=PostType.EVENT) | Q(post_type=PostType.FETMEETING))
)
qs = qs.annotate(
date=Case(
When(post_type=PostType.EVENT, then="event_start__date"),
When(post_type=PostType.FETMEETING, then="event_start__date"),
),
)
return qs.order_by("-date")
def future_events(self, public=True):
date_today = timezone.now().date()
qs = self.published(public).filter(event_start__gt=date_today)
return qs.reverse()
class EventManager(PublishedManager, models.Manager):
"""
Provide a query set only for "Events"
regular fet meetings should not be contained in the news stream
"""
def get_queryset(self):
qs = super().get_queryset().filter(post_type=PostType.EVENT)
qs = qs.annotate(
date=Case(
When(post_type=PostType.EVENT, then="event_start__date"),
),
)
return qs.order_by("-date")
def future_events(self, public=True):
date_today = timezone.now().date()
qs = self.published(public).filter(event_start__gt=date_today)
return qs.reverse()
def past_events(self, public=True):
date_today = timezone.now().date()
return self.published(public).filter(event_start__lt=date_today)
class FetMeetingManager(PublishedManager, models.Manager):
"""
Provide a query set only for "Fet Meeting"
"""
def get_queryset(self):
qs = super().get_queryset().filter(post_type=PostType.FETMEETING)
qs = qs.annotate(
date=Case(
When(post_type=PostType.FETMEETING, then="event_start__date"),
),
)
return qs.order_by("-date")
def future_events(self):
date_today = timezone.now().date()
qs = self.published().filter(event_start__gt=date_today)
return qs.reverse()
def past_events(self):
date_today = timezone.now().date()
return self.published().filter(event_start__lt=date_today)

View File

@@ -1,376 +1,384 @@
import logging
from datetime import timedelta
from django.conf import settings
from django.contrib.auth.models import User
from django.core.validators import ValidationError
from django.db import models
from django.urls import reverse
from django.utils import timezone
from django.utils.text import slugify
from taggit.managers import TaggableManager
from core.models import CustomFlatPage
from documents.api import ep_create_new_pad, ep_get_html, ep_get_url, ep_pad_exists, ep_set_html
from .choices import PostType, Status
from .managers import (
AllEventManager,
ArticleManager,
EventManager,
FetMeetingManager,
NewsManager,
PostManager,
)
logger = logging.getLogger(__name__)
def create_pad_for_post(slug, item="agenda"):
logger.info("Pad-Type: %s", item)
pad_id = slug + "-" + item
if not ep_create_new_pad(pad_id):
return ""
# Set template into the newly created pad if it exists.
if page := CustomFlatPage.objects.filter(title__iexact=item).first():
ep_set_html(pad_id, page.content)
logger.info("Template '%s' is set.", page.title)
return pad_id
class Post(models.Model):
# legacy id is for the posts from the old website
legacy_id = models.IntegerField(null=True, blank=True)
title = models.CharField(verbose_name="Titel", max_length=200)
subtitle = models.CharField(max_length=500, blank=True, default="")
tags = TaggableManager(blank=True)
slug = models.SlugField(unique=True, blank=True)
body = models.TextField(blank=True, default="")
image = models.ImageField(null=True, blank=True)
author = models.ForeignKey(User, on_delete=models.SET_NULL, null=True, blank=True)
public_date = models.DateField(
verbose_name="Veröffentlichung",
null=True,
blank=True,
default=timezone.now,
)
post_type = models.CharField(max_length=1, choices=PostType.choices, editable=True)
status = models.CharField(
verbose_name="Status", max_length=2, choices=Status.choices, default=Status.DRAFT
)
# post is pinned at main page
is_pinned = models.BooleanField(verbose_name="ANGEHEFTET", default=False)
# addional infos for events
event_start = models.DateTimeField(verbose_name="Event Start", null=True, blank=True)
event_end = models.DateTimeField(verbose_name="Event Ende", null=True, blank=True)
event_place = models.CharField(max_length=200, blank=True, default="")
# protocol for fet meeting
has_protocol = models.BooleanField(default=False)
has_agenda = models.BooleanField(default=False)
protocol_key = models.CharField(max_length=200, blank=True, default="")
agenda_key = models.CharField(max_length=200, blank=True, default="")
# TimeStamps
date_modified = models.DateTimeField(auto_now=True)
date_created = models.DateTimeField(auto_now_add=True)
# Managers
objects = PostManager()
articles = ArticleManager()
def __str__(self):
return "Post ({}, {}): {}".format(
self.slug,
self.public_date.strftime("%d.%m.%Y"),
self.title,
)
def save(self, *args, **kwargs):
# save the post with some defaults
if not self.public_date:
self.public_date = timezone.now().date()
if not self.slug:
self.slug = slugify(self.public_date) + "-" + slugify(self.title)
super().save(*args, **kwargs)
def get_absolute_url(self):
return reverse("posts:post", kwargs={"slug": self.slug})
# "#" for backward compatibility
_possible_empty_key_value = ["#", "", None]
@property
def agenda_html(self) -> str | None:
if self.agenda_key in self._possible_empty_key_value:
return None
return ep_get_html(self.agenda_key)
@agenda_html.setter
def agenda_html(self, value: str) -> str | None:
if self.agenda_key in self._possible_empty_key_value:
self.create_agenda_key()
if not value or not self.agenda_key:
return None
ep_set_html(self.agenda_key, value)
logger.info("Set agenda etherpad '%s' for post '%s'.", self.agenda_key, self.slug)
return value
@property
def protocol_html(self) -> str | None:
if self.protocol_key in self._possible_empty_key_value:
return None
return ep_get_html(self.protocol_key)
@protocol_html.setter
def protocol_html(self, value: str) -> str | None:
if self.protocol_key in self._possible_empty_key_value:
self.create_protocol_key()
if not value or not self.protocol_key:
return None
ep_set_html(self.protocol_key, value)
logger.info("Set protocol etherpad '%s' for post '%s'.", self.protocol_key, self.slug)
return value
_agenda_filename = None
_agenda_url = None
@property
def agenda_url(self) -> str | None:
if not self.has_agenda:
self._agenda_url = None
self._agenda_filename = None
return self._agenda_url
if self._agenda_url:
return self._agenda_url
if url := ep_get_url(self.agenda_key):
self._agenda_url = url
self._agenda_filename = self.slug + "-agenda.pdf"
else:
self._agenda_url = None
self._agenda_filename = None
return self._agenda_url
@property
def agenda_filename(self) -> str | None:
# TODO: fix pdf render
# if self._agenda_filename:
# return self._agenda_filename
# if self.has_agenda and self.agenda_url:
# return self.slug + "-agenda.pdf"
return None
_protocol_filename = None
_protocol_url = None
@property
def protocol_url(self) -> str | None:
if not self.has_protocol:
self._protocol_url = None
self._protocol_filename = None
return self._protocol_url
if self._protocol_url:
return self._protocol_url
if url := ep_get_url(self.protocol_key):
self._protocol_url = url
self._protocol_filename = self.slug + "-protokoll.pdf"
else:
self._protocol_url = None
self._protocol_filename = None
return self._protocol_url
@property
def protocol_filename(self) -> str | None:
# TODO: fix pdf render
# if self._protocol_filename:
# return self._protocol_filename
# if self.has_protocol and self.protocol_url:
# return self.slug + "-protokoll.pdf"
return None
def create_agenda_key(self) -> None:
"""
Create a Etherpad Id for the Pad associated to this post.
Create the pad if it doesn't exist.
"""
if self.slug:
self.agenda_key = create_pad_for_post(self.slug, "agenda")
def create_protocol_key(self) -> None:
"""
Create a Etherpad Id for the Pad associated to this post.
Create the pad if it doesn't exist.
"""
if self.slug:
self.protocol_key = create_pad_for_post(self.slug, "protocol")
def get_model_name(self):
return self._meta.model_name
@property
def three_tag_names(self):
return self.tags.names()[:3]
@property
def tag_names(self):
return [t for t in self.tags.names()]
@property
def imageurl(self) -> str:
return (
self.image.url if self.image else settings.STATIC_URL + "img/FET-Logo-2014-quadrat.png"
)
def clean(self):
if self.event_end and self.event_end < self.event_start:
raise ValidationError("Das Ende des Events liegt vor dem Beginn.")
if self.event_start and self.post_type not in ["E", "F"]:
raise ValidationError("Für diesen Post Typ ist kein Event Start zulässig.")
@property
def published(self):
return self.status == Status.PUBLIC
class News(Post):
objects = NewsManager()
class Meta:
proxy = True
verbose_name = "News"
verbose_name_plural = "News"
def save(self, *args, **kwargs):
if not self.post_type:
self.post_type = "N"
super().save(*args, **kwargs)
class Event(Post):
only_events = EventManager()
all_events = AllEventManager()
class Meta:
proxy = True
verbose_name = "Event"
verbose_name_plural = "Events"
def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
self.post_type = "E"
def save(self, *args, **kwargs):
if not self.post_type:
self.post_type = "E"
if not self.event_end:
self.event_end = self.event_start + timedelta(hours=2)
super().save(*args, **kwargs)
def clean(self):
super().clean()
if not self.event_start:
raise ValidationError("Das Datum des Events fehlt.")
class FetMeeting(Event):
objects = FetMeetingManager()
class Meta:
proxy = True
verbose_name = "Fet Sitzung"
verbose_name_plural = "Fet Sitzungen"
def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
self.post_type = "F"
def save(self, *args, **kwargs):
self.title = "Fachschaftssitzung"
if not self.slug:
self.slug = self.__get_slug()
if not ep_pad_exists(self.agenda_key) or self.agenda_key in self._possible_empty_key_value:
self.create_agenda_key()
if self.agenda_key:
self.has_agenda = True
if (
not ep_pad_exists(self.protocol_key)
or self.protocol_key in self._possible_empty_key_value
):
self.create_protocol_key()
if self.protocol_key:
self.has_protocol = True
if not self.post_type:
self.post_type = "F"
if not self.event_place:
self.event_place = "FET"
# make duration 2 hours if not specified otherwise
if not self.event_end:
self.event_end = self.event_start + timedelta(hours=2)
# set FET Meeting always public
self.status = Status.PUBLIC
super().save(*args, **kwargs)
def __get_slug(self) -> str:
slug = slugify(self.event_start.date()) + "-" + slugify("Fachschaftssitzung")
if Post.objects.filter(slug=slug).exists() and Post.objects.get(slug=slug).id != self.id:
raise ValidationError("Es existiert bereits eine Sitzung mit demselben Datum.")
return slug
def clean(self):
super().clean()
if not self.slug:
self.slug = self.__get_slug()
class FileUpload(models.Model):
title = models.CharField(verbose_name="Titel", max_length=200)
file_field = models.FileField(verbose_name="Dokument", upload_to="uploads/posts/files/")
post = models.ForeignKey(Post, on_delete=models.CASCADE)
objects = models.Manager()
def __str__(self):
return self.title
import logging
from datetime import timedelta
from django.conf import settings
from django.contrib.auth.models import User
from django.core.exceptions import ValidationError
from django.db import models
from django.urls import reverse
from django.utils import timezone
from django.utils.text import slugify
from taggit.managers import TaggableManager
from core.models import CustomFlatPage
from documents.api import ep_create_new_pad, ep_get_html, ep_get_url, ep_pad_exists, ep_set_html
from .choices import PostType, Status
from .managers import (
AllEventManager,
ArticleManager,
EventManager,
FetMeetingManager,
NewsManager,
PostManager,
)
logger = logging.getLogger(__name__)
def create_pad_for_post(slug, item="agenda"):
logger.info("Pad-Type: %s", item)
pad_id = slug + "-" + item
if not ep_create_new_pad(pad_id):
return ""
# Set template into the newly created pad if it exists.
if page := CustomFlatPage.objects.filter(title__iexact=item).first():
ep_set_html(pad_id, page.content)
logger.info("Template '%s' is set.", page.title)
return pad_id
class Post(models.Model):
# legacy id is for the posts from the old website
legacy_id = models.IntegerField(null=True, blank=True)
title = models.CharField(verbose_name="Titel", max_length=200)
subtitle = models.CharField(max_length=500, blank=True, default="")
tags = TaggableManager(blank=True)
slug = models.SlugField(unique=True, blank=True)
body = models.TextField(blank=True, default="")
image = models.ImageField(null=True, blank=True)
author = models.ForeignKey(User, on_delete=models.SET_NULL, null=True, blank=True)
public_date = models.DateField(
verbose_name="Veröffentlichung",
null=True,
blank=True,
default=timezone.now,
)
post_type = models.CharField(max_length=1, choices=PostType.choices, editable=True)
status = models.CharField(
verbose_name="Status", max_length=2, choices=Status.choices, default=Status.DRAFT
)
# post is pinned at main page
is_pinned = models.BooleanField(verbose_name="ANGEHEFTET", default=False)
# addional infos for events
event_start = models.DateTimeField(verbose_name="Event Start", null=True, blank=True)
event_end = models.DateTimeField(verbose_name="Event Ende", null=True, blank=True)
event_place = models.CharField(max_length=200, blank=True, default="")
# protocol for fet meeting
has_protocol = models.BooleanField(default=False)
has_agenda = models.BooleanField(default=False)
protocol_key = models.CharField(max_length=200, blank=True, default="")
agenda_key = models.CharField(max_length=200, blank=True, default="")
# TimeStamps
date_modified = models.DateTimeField(auto_now=True)
date_created = models.DateTimeField(auto_now_add=True)
# Managers
objects = PostManager()
articles = ArticleManager()
def __str__(self):
return "Post ({}, {}): {}".format(
self.slug,
self.public_date.strftime("%d.%m.%Y"),
self.title,
)
def save(self, *args, **kwargs):
# save the post with some defaults
if not self.public_date:
self.public_date = timezone.now().date()
if not self.slug:
self.slug = slugify(self.public_date) + "-" + slugify(self.title)
super().save(*args, **kwargs)
def get_absolute_url(self):
return reverse("posts:post", kwargs={"slug": self.slug})
# "#" for backward compatibility
_possible_empty_key_value = ["#", "", None]
@property
def agenda_html(self) -> str | None:
if self.agenda_key in self._possible_empty_key_value:
return None
return ep_get_html(self.agenda_key)
@agenda_html.setter
def agenda_html(self, value: str) -> str | None:
if self.agenda_key in self._possible_empty_key_value:
self.create_agenda_key()
if not value or not self.agenda_key:
return None
ep_set_html(self.agenda_key, value)
logger.info("Set agenda etherpad '%s' for post '%s'.", self.agenda_key, self.slug)
return value
@property
def protocol_html(self) -> str | None:
if self.protocol_key in self._possible_empty_key_value:
return None
return ep_get_html(self.protocol_key)
@protocol_html.setter
def protocol_html(self, value: str) -> str | None:
if self.protocol_key in self._possible_empty_key_value:
self.create_protocol_key()
if not value or not self.protocol_key:
return None
ep_set_html(self.protocol_key, value)
logger.info("Set protocol etherpad '%s' for post '%s'.", self.protocol_key, self.slug)
return value
_agenda_filename = None
_agenda_url = None
@property
def agenda_url(self) -> str | None:
if not self.has_agenda:
self._agenda_url = None
self._agenda_filename = None
return self._agenda_url
if self._agenda_url:
return self._agenda_url
if url := ep_get_url(self.agenda_key):
self._agenda_url = url
self._agenda_filename = self.slug + "-agenda.pdf"
else:
self._agenda_url = None
self._agenda_filename = None
return self._agenda_url
@property
def agenda_filename(self) -> str | None:
# TODO: fix pdf render
# if self._agenda_filename:
# return self._agenda_filename
# if self.has_agenda and self.agenda_url:
# return self.slug + "-agenda.pdf"
return None
_protocol_filename = None
_protocol_url = None
@property
def protocol_url(self) -> str | None:
if not self.has_protocol:
self._protocol_url = None
self._protocol_filename = None
return self._protocol_url
if self._protocol_url:
return self._protocol_url
if url := ep_get_url(self.protocol_key):
self._protocol_url = url
self._protocol_filename = self.slug + "-protokoll.pdf"
else:
self._protocol_url = None
self._protocol_filename = None
return self._protocol_url
@property
def protocol_filename(self) -> str | None:
# TODO: fix pdf render
# if self._protocol_filename:
# return self._protocol_filename
# if self.has_protocol and self.protocol_url:
# return self.slug + "-protokoll.pdf"
return None
def create_agenda_key(self) -> None:
"""
Create a Etherpad Id for the Pad associated to this post.
Create the pad if it doesn't exist.
"""
if self.slug:
self.agenda_key = create_pad_for_post(self.slug, "agenda")
def create_protocol_key(self) -> None:
"""
Create a Etherpad Id for the Pad associated to this post.
Create the pad if it doesn't exist.
"""
if self.slug:
self.protocol_key = create_pad_for_post(self.slug, "protocol")
def get_model_name(self):
return self._meta.model_name
@property
def three_tag_names(self):
return self.tags.names()[:3]
@property
def tag_names(self):
return [t for t in self.tags.names()]
@property
def imageurl(self) -> str:
return (
self.image.url if self.image else settings.STATIC_URL + "img/FET-Logo-2014-quadrat.png"
)
def clean(self):
if self.event_end and self.event_end < self.event_start:
msg = "Das Ende des Events liegt vor dem Beginn."
raise ValidationError(msg, code="invalid_event_end")
if self.event_start and self.post_type not in ["E", "F"]:
msg = "Für diesen Post Typ ist kein Event Start zulässig."
raise ValidationError(msg, code="invalid_event_start")
@property
def published(self):
return self.status == Status.PUBLIC
class News(Post):
objects = NewsManager()
class Meta:
proxy = True
verbose_name = "News"
verbose_name_plural = "News"
def save(self, *args, **kwargs):
if not self.post_type:
self.post_type = "N"
super().save(*args, **kwargs)
class Event(Post):
only_events = EventManager()
all_events = AllEventManager()
class Meta:
proxy = True
verbose_name = "Event"
verbose_name_plural = "Events"
def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
self.post_type = "E"
def save(self, *args, **kwargs):
if not self.post_type:
self.post_type = "E"
if not self.event_end:
self.event_end = self.event_start + timedelta(hours=2)
super().save(*args, **kwargs)
def clean(self):
super().clean()
if not self.event_start:
msg = "Das Datum des Events fehlt."
raise ValidationError(msg, code="missing_event_start")
class FetMeeting(Event):
objects = FetMeetingManager()
class Meta:
proxy = True
verbose_name = "Fet Sitzung"
verbose_name_plural = "Fet Sitzungen"
def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
self.post_type = "F"
def save(self, *args, **kwargs):
self.title = "Fachschaftssitzung"
if not self.slug:
self.slug = self.__get_slug()
if not ep_pad_exists(self.agenda_key) or self.agenda_key in self._possible_empty_key_value:
self.create_agenda_key()
if self.agenda_key:
self.has_agenda = True
if (
not ep_pad_exists(self.protocol_key)
or self.protocol_key in self._possible_empty_key_value
):
self.create_protocol_key()
if self.protocol_key:
self.has_protocol = True
if not self.post_type:
self.post_type = "F"
if not self.event_place:
self.event_place = "FET"
# make duration 2 hours if not specified otherwise
if not self.event_end:
self.event_end = self.event_start + timedelta(hours=2)
# set FET Meeting always public
self.status = Status.PUBLIC
super().save(*args, **kwargs)
def __get_slug(self) -> str:
slug = slugify(self.event_start.date()) + "-" + slugify("Fachschaftssitzung")
if Post.objects.filter(slug=slug).exists() and Post.objects.get(slug=slug).id != self.id:
msg = "Es existiert bereits eine Sitzung mit demselben Datum."
logger.error(
"A fet meeting with same date (slug: %(slug)s) is already existing.",
extra={"slug": slug},
)
raise ValidationError(msg, code="duplicate_fet_meeting")
return slug
def clean(self):
super().clean()
if not self.slug:
self.slug = self.__get_slug()
class FileUpload(models.Model):
title = models.CharField(verbose_name="Titel", max_length=200)
file_field = models.FileField(verbose_name="Dokument", upload_to="uploads/posts/files/")
post = models.ForeignKey(Post, on_delete=models.CASCADE)
objects = models.Manager()
def __str__(self):
return self.title

View File

@@ -1,36 +1,36 @@
from haystack import indexes
from html2text import html2text
from .models import Post
class PostIndex(indexes.SearchIndex, indexes.Indexable):
text = indexes.CharField(document=True)
title = indexes.EdgeNgramField(model_attr="title")
body = indexes.EdgeNgramField(model_attr="body", null=True)
status = indexes.CharField(model_attr="status")
date = indexes.DateField()
agenda = indexes.EdgeNgramField()
protocol = indexes.EdgeNgramField()
def get_model(self):
return Post
def index_queryset(self, using=None):
return self.get_model().objects.date_sorted(public=False)
def prepare_date(self, obj):
if obj.post_type == "N":
return obj.public_date
elif obj.post_type == "E" or obj.post_type == "F":
return obj.event_start.date()
def prepare_agenda(self, obj):
if obj.has_agenda and obj.agenda_html:
return html2text(obj.agenda_html)
return None
def prepare_protocol(self, obj):
if obj.has_protocol and obj.protocol_html:
return html2text(obj.protocol_html)
return None
from haystack import indexes
from html2text import html2text
from .models import Post
class PostIndex(indexes.SearchIndex, indexes.Indexable):
text = indexes.CharField(document=True)
title = indexes.EdgeNgramField(model_attr="title")
body = indexes.EdgeNgramField(model_attr="body", null=True)
status = indexes.CharField(model_attr="status")
date = indexes.DateField()
agenda = indexes.EdgeNgramField()
protocol = indexes.EdgeNgramField()
def get_model(self):
return Post
def index_queryset(self, using=None):
return self.get_model().objects.date_sorted(public=False)
def prepare_date(self, obj):
if obj.post_type == "E" or obj.post_type == "F":
return obj.event_start.date()
return obj.public_date # obj.post_type == "N"
def prepare_agenda(self, obj):
if obj.has_agenda and obj.agenda_html:
return html2text(obj.agenda_html)
return None
def prepare_protocol(self, obj):
if obj.has_protocol and obj.protocol_html:
return html2text(obj.protocol_html)
return None

View File

@@ -117,15 +117,17 @@ class PostDetailView(DetailView):
if not obj.published:
related_posts.remove(obj)
context = {
"post": self.object,
"files": files,
"author": author,
"author_image": author_image,
"next": self.post_next(),
"prev": self.post_prev(),
"related_posts": related_posts[:4],
}
context.update(
{
"post": self.object,
"files": files,
"author": author,
"author_image": author_image,
"next": self.post_next(),
"prev": self.post_prev(),
"related_posts": related_posts[:4],
}
)
return context
@@ -309,7 +311,8 @@ def show_pdf(request, html, filename):
html = html[:idx] + rendered + html[idx:]
if not (pdf := render_to_pdf(html)):
raise Http404("can't create pdf file.")
msg = "PDF konnte nicht erstellt werden."
raise Http404(msg)
response = HttpResponse(pdf, content_type="application/pdf")

View File

@@ -1,10 +1,10 @@
[project]
name = "fet-homepage"
version = "2.1.0"
version = "2.1.1"
description = "Homepage for Fachschaft Elektrotechnik"
requires-python = ">=3.13"
dependencies = [
"django",
"django<6.0", # django 6 has to be tested
"django-ckeditor<6.7.3",
"django-environ",
"django-filter",
@@ -38,7 +38,7 @@ dev = [
line-length = 100
indent-width = 4
target-version = "py313"
exclude = ["tests", "migrations", "search", "tests.py"]
exclude = ["tests", "migrations", "search", "manage.py", "tests.py"]
[tool.ruff.format]
docstring-code-format = true

View File

@@ -1,124 +1,121 @@
from django.contrib import admin, messages
from django.http import HttpResponseRedirect
from .forms import RentalAdminForm, RentalItemAdminForm
from .models import Rental, RentalItem
from .utils import generate_rental_pdf
@admin.register(Rental)
class RentalAdmin(admin.ModelAdmin):
form = RentalAdminForm
model = Rental
list_display = [
"id",
"firstname",
"surname",
"status",
"total_disposit",
"date_start",
"date_end",
]
ordering = ["-id"]
readonly_fields = ["total_disposit"]
fieldsets = (
(
"Persönliche Daten",
{
"fields": (
("firstname", "surname"),
("organization", "matriculation_number"),
("email", "phone"),
),
},
),
(
"Verleih",
{
"fields": (
("date_start", "date_end"),
"reason",
"rentalitems",
"total_disposit",
),
},
),
(
"Sonstiges",
{
"fields": (
"comment",
"file_field",
"status",
),
},
),
)
def add_view(self, request, form_url="", extra_context=None):
extra_context = extra_context or {}
extra_context["help_text"] = "Fette Schriften sind Pflichtfelder."
return super().add_view(request, form_url, extra_context=extra_context)
def change_view(self, request, object_id, form_url="", extra_context=None):
extra_context = extra_context or {}
extra_context["help_text"] = "Fette Schriften sind Pflichtfelder."
extra_context["generate_rental_pdf"] = True
return super().change_view(request, object_id, form_url, extra_context=extra_context)
def response_change(self, request, obj):
if "_generate_rental_pdf" in request.POST:
if generate_rental_pdf(obj):
self.message_user(
request,
"Neues Verleihformular wurde generiert.",
messages.SUCCESS,
)
else:
self.message_user(
request,
(
"Das PDF-Dokument konnte nicht generiert werden, da der Status nicht auf "
"'Verleih genehmigt' gesetzt ist."
),
messages.WARNING,
)
return HttpResponseRedirect(".")
return super().response_change(request, obj)
def save_model(self, request, obj, form, change):
obj.author = request.user
super().save_model(request, obj, form, change)
@admin.display(description="Kaution (EUR)")
def total_disposit(self, obj):
total_disposit = 0
for elem in obj.rentalitems.all():
total_disposit += elem.deposit
return f"{total_disposit}"
@admin.register(RentalItem)
class RentalItemAdmin(admin.ModelAdmin):
form = RentalItemAdminForm
model = RentalItem
ordering = ["name"]
def add_view(self, request, form_url="", extra_context=None):
extra_context = extra_context or {}
extra_context["help_text"] = "Fette Schriften sind Pflichtfelder."
return super().add_view(request, form_url, extra_context=extra_context)
def change_view(self, request, object_id, form_url="", extra_context=None):
extra_context = extra_context or {}
extra_context["help_text"] = "Fette Schriften sind Pflichtfelder."
return super().change_view(request, object_id, form_url, extra_context=extra_context)
def save_model(self, request, obj, form, change):
obj.author = request.user
super().save_model(request, obj, form, change)
from django.contrib import admin, messages
from django.http import HttpResponseRedirect
from .forms import RentalAdminForm, RentalItemAdminForm
from .models import Rental, RentalItem
from .utils import generate_rental_pdf
@admin.register(Rental)
class RentalAdmin(admin.ModelAdmin):
form = RentalAdminForm
model = Rental
list_display = [
"id",
"firstname",
"surname",
"status",
"total_disposit",
"date_start",
"date_end",
]
ordering = ["-id"]
readonly_fields = ["total_disposit"]
fieldsets = (
(
"Persönliche Daten",
{
"fields": (
("firstname", "surname"),
("organization", "matriculation_number"),
("email", "phone"),
),
},
),
(
"Verleih",
{
"fields": (
("date_start", "date_end"),
"reason",
"rentalitems",
("total_disposit", "intern"),
),
},
),
(
"Sonstiges",
{
"fields": (
"comment",
"file_field",
"status",
),
},
),
)
def add_view(self, request, form_url="", extra_context=None):
extra_context = extra_context or {}
extra_context["help_text"] = "Fette Schriften sind Pflichtfelder."
return super().add_view(request, form_url, extra_context=extra_context)
def change_view(self, request, object_id, form_url="", extra_context=None):
extra_context = extra_context or {}
extra_context["help_text"] = "Fette Schriften sind Pflichtfelder."
extra_context["generate_rental_pdf"] = True
return super().change_view(request, object_id, form_url, extra_context=extra_context)
def response_change(self, request, obj):
if "_generate_rental_pdf" in request.POST:
if generate_rental_pdf(obj):
self.message_user(
request,
"Neues Verleihformular wurde generiert.",
messages.SUCCESS,
)
else:
self.message_user(
request,
(
"Das PDF-Dokument konnte nicht generiert werden, da der Status nicht auf "
"'Verleih genehmigt' gesetzt ist."
),
messages.WARNING,
)
return HttpResponseRedirect(".")
return super().response_change(request, obj)
def save_model(self, request, obj, form, change):
obj.author = request.user
super().save_model(request, obj, form, change)
@admin.display(description="Kaution insgesamt")
def total_disposit(self, obj):
total_disposit = obj.calc_total_deposit()
return f"{total_disposit}"
@admin.register(RentalItem)
class RentalItemAdmin(admin.ModelAdmin):
form = RentalItemAdminForm
model = RentalItem
ordering = ["name"]
def add_view(self, request, form_url="", extra_context=None):
extra_context = extra_context or {}
extra_context["help_text"] = "Fette Schriften sind Pflichtfelder."
return super().add_view(request, form_url, extra_context=extra_context)
def change_view(self, request, object_id, form_url="", extra_context=None):
extra_context = extra_context or {}
extra_context["help_text"] = "Fette Schriften sind Pflichtfelder."
return super().change_view(request, object_id, form_url, extra_context=extra_context)
def save_model(self, request, obj, form, change):
obj.author = request.user
super().save_model(request, obj, form, change)

View File

@@ -1,58 +1,65 @@
from django import forms
from django.forms import DateInput
from .models import Rental, RentalItem
class DateInput(DateInput):
input_type = "date"
class RentalCreateForm(forms.ModelForm):
# Conformation
conformation = forms.BooleanField(
required=True,
label=("Ich habe die Verleihregeln gelesen und akzeptiere sie."),
initial=False,
)
class Meta:
model = Rental
fields = [
"firstname",
"surname",
"matriculation_number",
"email",
"phone",
"organization",
"date_start",
"date_end",
"reason",
"comment",
"rentalitems",
]
widgets = {
"date_start": DateInput(format=("%Y-%m-%d")),
"date_end": DateInput(format=("%Y-%m-%d")),
}
def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs) # to get the self.fields set
self.fields["firstname"].autofocus = True
class RentalAdminForm(forms.ModelForm):
class Meta:
model = Rental
fields = "__all__"
widgets = {"rentalitems": forms.CheckboxSelectMultiple()}
class RentalItemAdminForm(forms.ModelForm):
class Meta:
model = RentalItem
fields = "__all__"
from django import forms
from django.forms import DateInput
from .models import Rental, RentalItem
class DateInput(DateInput):
input_type = "date"
class RentalCreateForm(forms.ModelForm):
# Conformation
conformation = forms.BooleanField(
required=True,
label=("Ich habe die Verleihregeln gelesen und akzeptiere sie."),
initial=False,
)
class Meta:
model = Rental
fields = [
"firstname",
"surname",
"matriculation_number",
"email",
"phone",
"organization",
"date_start",
"date_end",
"reason",
"comment",
"rentalitems",
]
widgets = {
"date_start": DateInput(format=("%Y-%m-%d")),
"date_end": DateInput(format=("%Y-%m-%d")),
}
def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs) # to get the self.fields set
self.fields["firstname"].autofocus = True
class RentalAdminForm(forms.ModelForm):
class Meta:
model = Rental
fields = "__all__"
widgets = {"rentalitems": forms.CheckboxSelectMultiple()}
help_texts = {
"status": (
"Wird der Status auf 'Verleih genehmigt' oder 'Verleih abgelehnt' gesetzt, wird "
"eine E-Mail gesendet."
),
}
class RentalItemAdminForm(forms.ModelForm):
class Meta:
model = RentalItem
fields = "__all__"

View File

@@ -1,53 +1,102 @@
import logging
from django.core.mail import EmailMessage
RENTAL_EMAIL = "verleih@fet.at"
logger = logging.getLogger(__name__)
def send_mail_approved(obj):
subject = f"FET-Verleih #{obj.id}: {obj.get_status_display()}"
total_deposit = 0
for rentalitem in obj.rentalitems.all():
total_deposit += rentalitem.deposit
message = (
f"Hallo {obj.firstname},\n"
f"deine Verleihanfrage mit der Nummer #{obj.id} wurde erfolgreich genehmigt. Die "
f"Gegenstände können am {obj.date_start.strftime('%d.%m.%Y')} während der Beratungszeit "
"(Montag - Donnerstag: 09:00 - 14:00, Freitag: 09:00 - 12:00) abgeholt werden. Bitte bring "
f"den Gesamtpfand in Höhe von {total_deposit} € in bar mit.\n"
"Liebe Grüße,\n"
"das Verleih-Team"
)
email = EmailMessage(
subject, message, from_email=RENTAL_EMAIL, to=[obj.email], cc=[RENTAL_EMAIL]
)
try:
email.send()
except Exception as exc:
logger.error("Failed to send approval email for rental #%s. Error: %s", obj.id, exc)
def send_mail_rejected(obj):
subject = f"FET-Verleih #{obj.id}: {obj.get_status_display()}"
message = (
f"Hallo {obj.firstname},\n"
f"deine Verleihanfrage mit der Nummer #{obj.id} wurde abgelehnt.\n"
"Liebe Grüße,\n"
"das Verleih-Team"
)
email = EmailMessage(
subject, message, from_email=RENTAL_EMAIL, to=[obj.email], cc=[RENTAL_EMAIL]
)
try:
email.send()
except Exception as exc:
logger.error("Failed to send rejection email for rental #%s. Error: %s", obj.id, exc)
import logging
from urllib.parse import urljoin
from django.conf import settings
from django.contrib.sites.models import Site
from django.core.mail import EmailMessage
RENTAL_EMAIL = settings.EMAIL_HOST_USER
logger = logging.getLogger(__name__)
def send_mail_approved(obj):
subject = f"FET-Verleih: {obj.get_status_display()}"
total_deposit = obj.calc_total_deposit()
# Build URL to contact page
site = Site.objects.get_current()
contact_link = urljoin(f"https://{site.domain}", "/kontakt/")
# Build email message
message = (
f"Hallo {obj.firstname},\n\ndeine Verleihanfrage wurde erfolgreich genehmigt. Für den "
f"Zeitraum vom {obj.date_start.strftime('%d.%m.%Y')} bis einschließlich "
f"{obj.date_end.strftime('%d.%m.%Y')} stehen dir folgende Gegenstände zur Verfügung:\n"
)
for item in obj.rentalitems.all():
message += f"- {item.name}\n"
message += (
f"\nDie Abholung ist am {obj.date_start.strftime('%d.%m.%Y')} während der Beratungszeit "
f"möglich: {contact_link}\n"
)
if total_deposit > 0:
message += (
f"Bitte bring zur Abholung den Gesamtpfand in Höhe von {total_deposit} € in bar mit.\n"
)
message += "\nLiebe Grüße,\ndas Verleih-Team"
# Create email
email = EmailMessage(
subject, message, from_email=RENTAL_EMAIL, to=[obj.email], cc=[RENTAL_EMAIL]
)
try:
email.send()
except Exception as exc:
logger.info("Failed to send approval email for rental #%s. Error: %s", obj.id, exc)
def send_mail_rejected(obj):
subject = f"FET-Verleih: {obj.get_status_display()}"
# Build email message
message = (
f"Hallo {obj.firstname},\n\nleider müssen wir dir mitteilen, dass deine Verleihanfrage für "
f"den Zeitraum vom {obj.date_start.strftime('%d.%m.%Y')} bis einschließlich "
f"{obj.date_end.strftime('%d.%m.%Y')} nicht genehmigt werden konnte. Für diesen Zeitraum "
"waren folgende Gegenstände angefragt:\n"
)
for item in obj.rentalitems.all():
message += f"- {item.name}\n"
message += (
"\nVielen Dank für dein Verständnis. Bei Fragen oder für alternative Termine kannst du "
"dich gerne bei uns melden.\n\nLiebe Grüße,\ndas Verleih-Team"
)
# Create email
email = EmailMessage(
subject, message, from_email=RENTAL_EMAIL, to=[obj.email], cc=[RENTAL_EMAIL]
)
try:
email.send()
except Exception as exc:
logger.info("Failed to send rejection email for rental #%s. Error: %s", obj.id, exc)
def send_mail_returned(obj):
subject = f"FET-Verleih: {obj.get_status_display()}"
# Build email message
message = (
f"Hallo {obj.firstname},\n\n"
"deine Verleihgegenstände wurden erfolgreich zurückgegeben.\n\n"
"Liebe Grüße,\n"
"das Verleih-Team"
)
# Create email
email = EmailMessage(
subject, message, from_email=RENTAL_EMAIL, to=[obj.email], cc=[RENTAL_EMAIL]
)
try:
email.send()
except Exception as exc:
logger.info("Failed to send returned email for rental #%s. Error: %s", obj.id, exc)

View File

@@ -0,0 +1,6 @@
from django.db import models
class RentalItemsManager(models.Manager):
def get_queryset(self):
return super().get_queryset().order_by("name")

View File

@@ -0,0 +1,55 @@
# Generated by Django 5.2.7 on 2025-10-30 12:05
import django.core.validators
import rental.validators
from django.db import migrations, models
class Migration(migrations.Migration):
initial = True
dependencies = [
]
operations = [
migrations.CreateModel(
name='RentalItem',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('name', models.CharField(max_length=128, verbose_name='Name')),
('description', models.CharField(blank=True, default='', max_length=128, verbose_name='Beschreibung')),
('deposit', models.DecimalField(decimal_places=2, default=0, max_digits=6, verbose_name='Kaution (EUR)')),
('image', models.ImageField(blank=True, null=True, upload_to='rentalitems', verbose_name='Bild')),
('induction', models.BooleanField(default=False, verbose_name='Einschulung notwendig')),
('location', models.CharField(blank=True, default='', max_length=128, verbose_name='Standort')),
],
options={
'verbose_name': 'Verleihgegenstand',
'verbose_name_plural': 'Verleihgegenstände',
},
),
migrations.CreateModel(
name='Rental',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('firstname', models.CharField(max_length=128, verbose_name='Vorname')),
('surname', models.CharField(max_length=128, verbose_name='Nachname')),
('matriculation_number', models.CharField(max_length=8, verbose_name='Matrikelnummer')),
('email', models.EmailField(max_length=254, verbose_name='E-Mail')),
('phone', models.CharField(max_length=32, validators=[rental.validators.PhoneNumberValidator()], verbose_name='Telefonnummer')),
('organization', models.CharField(max_length=128, verbose_name='Organisation')),
('date_start', models.DateField(verbose_name='Abholdatum')),
('date_end', models.DateField(verbose_name='Rückgabedatum')),
('reason', models.TextField(max_length=500, verbose_name='Grund der Ausleihe')),
('comment', models.TextField(blank=True, default='', max_length=500, verbose_name='Kommentar')),
('file_field', models.FileField(blank=True, null=True, upload_to='uploads/rental/rental/', validators=[django.core.validators.FileExtensionValidator(['pdf'])], verbose_name='Verleihformular')),
('status', models.CharField(choices=[('S', 'Eingereicht'), ('A', 'Verleih genehmigt'), ('J', 'Verleih abgelehnt'), ('I', 'Verleihgegenstände ausgegeben'), ('R', 'Verleihgegenstände zurückgegeben')], default='S', max_length=1, verbose_name='Status')),
('rentalitems', models.ManyToManyField(to='rental.rentalitem', verbose_name='Verleihgegenstände')),
],
options={
'verbose_name': 'Verleih',
'verbose_name_plural': 'Verleih',
},
),
]

View File

@@ -0,0 +1,18 @@
# Generated by Django 5.2.7 on 2025-10-30 14:40
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('rental', '0001_initial'),
]
operations = [
migrations.AddField(
model_name='rental',
name='intern',
field=models.BooleanField(default=False, verbose_name='Interner Verleih'),
),
]

View File

View File

@@ -1,8 +1,9 @@
from django.core.exceptions import ValidationError
from django.core.validators import FileExtensionValidator
from django.db import models
from django.forms import ValidationError
from .mails import send_mail_approved, send_mail_rejected
from .mails import send_mail_approved, send_mail_rejected, send_mail_returned
from .managers import RentalItemsManager
from .validators import PhoneNumberValidator
@@ -22,6 +23,8 @@ class RentalItem(models.Model):
location = models.CharField(verbose_name="Standort", max_length=128, blank=True, default="")
objects = RentalItemsManager()
class Meta:
verbose_name = "Verleihgegenstand"
verbose_name_plural = "Verleihgegenstände"
@@ -41,6 +44,7 @@ class Rental(models.Model):
)
organization = models.CharField(verbose_name="Organisation", max_length=128)
intern = models.BooleanField(verbose_name="Interner Verleih", default=False)
date_start = models.DateField(verbose_name="Abholdatum")
date_end = models.DateField(verbose_name="Rückgabedatum")
@@ -100,10 +104,26 @@ class Rental(models.Model):
and self.status == self.Status.REJECTED
):
send_mail_rejected(self)
elif (
pre_obj
and pre_obj.status != self.Status.RETURNED
and self.status == self.Status.RETURNED
):
send_mail_returned(self)
def clean(self):
if not self.date_end:
self.date_end = self.date_start
if self.date_start > self.date_end:
raise ValidationError("Das Abholdatum muss vor dem Rückgabedatum liegen.")
msg = "Das Abholdatum muss vor dem Rückgabedatum liegen."
raise ValidationError(msg, code="invalid_date_range")
def calc_total_deposit(self) -> int:
total_deposit = 0
if not self.intern:
for item in self.rentalitems.all():
total_deposit += item.deposit
return total_deposit

View File

@@ -10,7 +10,7 @@ urlpatterns = [
path("overview/", RentalListView.as_view(), name="index"),
path("request-rental/", RentalCreateView.as_view(), name="rental_create"),
path(
"request-rental/<int:pk>/done/",
"request-rental/done/",
RentalCreateDoneView.as_view(),
name="rental_create_done",
),

View File

@@ -1,66 +1,61 @@
import io
from pathlib import Path
from django.core.files import File
from pypdf import PdfReader, PdfWriter
from .models import Rental
def generate_rental_pdf(rental: Rental) -> bool:
if not rental or rental.status != Rental.Status.APPROVED:
return False
# Get data for pdf
data = {}
data.update(
{
"Vorname": rental.firstname,
"Nachname": rental.surname,
"Orga": rental.organization,
"Matrikelnummer": rental.matriculation_number,
"E-Mail": rental.email,
"Telefonnummer": rental.phone,
# Change to the correct date format
"Abholdatum": str(rental.date_start.strftime("%d.%m.%Y")),
"Rückgabedatum": str(rental.date_end.strftime("%d.%m.%Y")),
},
)
total_deposit = 0
for i, item in enumerate(rental.rentalitems.all(), start=1):
total_deposit += item.deposit
data.update(
{
f"Produkt Row{i}": item.name,
f"Menge Row{i}": "1",
f"Kaution Row{i}": item.deposit,
},
)
data.update(
{
"Gesamtkaution": total_deposit,
},
)
# Write data in pdf
pdf_path = Path(Path(__file__).parent) / "static/rental/Verleihformular.pdf"
reader = PdfReader(pdf_path)
writer = PdfWriter()
writer.append(reader)
writer.update_page_form_field_values(
writer.pages[0],
data,
)
with io.BytesIO() as bytes_stream:
writer.write(bytes_stream)
# Save pdf in rental
rental_name = f"Verleihformular-{str(rental.pk).zfill(4)}.pdf"
rental.file_field.save(rental_name, File(bytes_stream, rental_name))
return True
import io
from django.contrib.staticfiles import finders
from django.core.files import File
from pypdf import PdfReader, PdfWriter
from .models import Rental
def generate_rental_pdf(rental: Rental) -> bool:
if not rental or rental.status != Rental.Status.APPROVED:
return False
# Get data for pdf
data = {}
data.update(
{
"Vorname": rental.firstname,
"Nachname": rental.surname,
"Orga": rental.organization,
"Matrikelnummer": rental.matriculation_number,
"E-Mail": rental.email,
"Telefonnummer": rental.phone,
# Change to the correct date format
"Abholdatum": str(rental.date_start.strftime("%d.%m.%Y")),
"Rückgabedatum": str(rental.date_end.strftime("%d.%m.%Y")),
},
)
for i, item in enumerate(rental.rentalitems.all(), start=1):
data.update(
{
f"Produkt Row{i}": item.name,
f"Menge Row{i}": "1",
f"Kaution Row{i}": (str(item.deposit) if not rental.intern else "0"),
},
)
total_deposit = rental.calc_total_deposit()
data.update({"Gesamtkaution": str(total_deposit)})
# Write data in pdf
pdf_path_str = finders.find("rental/Verleihformular.pdf")
reader = PdfReader(pdf_path_str)
writer = PdfWriter()
writer.append(reader)
writer.update_page_form_field_values(
writer.pages[0],
data,
)
with io.BytesIO() as bytes_stream:
writer.write(bytes_stream)
bytes_stream.seek(0)
# Save pdf in rental
rental_name = f"Verleihformular-{str(rental.pk).zfill(4)}.pdf"
rental.file_field.save(rental_name, File(bytes_stream, rental_name))
return True

View File

@@ -1,9 +1,12 @@
import calendar
import datetime
from datetime import date
from django.db.models import Q
from django.http import HttpResponseRedirect
from django.shortcuts import render
from django.urls import reverse
from django.utils import timezone
from django.views.generic import ListView, TemplateView
from django.views.generic.detail import DetailView
from django.views.generic.edit import CreateView
@@ -11,91 +14,164 @@ from django.views.generic.edit import CreateView
from .forms import RentalCreateForm
from .models import Rental, RentalItem
# Maximum number of rental items per rental entry because of table size limitations in PDF file
RENTAL_ITEMS_MAX = 5
def _calc_days_from_current_month(month: date) -> list:
last_day_of_month = month.replace(day=calendar.monthrange(month.year, month.month)[1])
return [month + datetime.timedelta(days=i) for i in range((last_day_of_month - month).days + 1)]
def _calc_days_from_prev_month(month: date) -> list:
days_of_prev_period = []
if month.weekday() != calendar.MONDAY:
for i in range(1, 7):
day = month + datetime.timedelta(days=-i)
days_of_prev_period.append(day)
if day.weekday() == calendar.MONDAY:
break
return sorted(days_of_prev_period)
def _calc_days_from_next_month(month: date) -> list:
last_day_of_month = month.replace(day=calendar.monthrange(month.year, month.month)[1])
days_of_next_period = []
if last_day_of_month.weekday() != calendar.SUNDAY:
for i in range(1, 7):
day = last_day_of_month + datetime.timedelta(days=i)
days_of_next_period.append(day)
if day.weekday() == calendar.SUNDAY:
break
return days_of_next_period
def _get_display_period(view_type: str, period: str) -> date:
display_date: date = None
# Handle week view
if view_type == "week":
try:
# Parse the requested calendar week
display_date = (
datetime.datetime.strptime(f"{period}-1", "%G-KW%V-%u")
.replace(tzinfo=datetime.UTC)
.date()
)
except Exception:
# Get first day of the current week
today = timezone.now().date()
display_date = today - datetime.timedelta(days=today.weekday())
# Handle month view
else:
try:
# Parse the requested month
display_date = (
datetime.datetime.strptime(period, "%Y-%m").replace(tzinfo=datetime.UTC).date()
)
except Exception:
# Get the first day of the current month
display_date = timezone.now().date().replace(day=1)
return display_date
class RentalListView(ListView):
model = Rental
template_name = "rental/calendar.html"
# Month is the month displayed in the calendar (and should be the first day of the month)
month = None
# Rental items to filter
rentalitem_filters = []
def __init__(self):
super().__init__()
# Current display period and view settings
self.display_period = None
self.view_type = "month" # Default view
self.rentalitem_filters = []
def get(self, request, *args, **kwargs):
# Get the rental items from the filter (max. 4)
self.rentalitem_filters = request.GET.getlist("rentalitems", [])[:4]
if not self.rentalitem_filters:
for rentalitem in RentalItem.objects.all()[:4]:
self.rentalitem_filters.append(rentalitem.name)
# Get view parameters from request
_view_type = request.GET.get("view_type", "week")
_period = request.GET.get("period_value", "")
_prev_period = request.GET.get("prev_period", "")
_next_period = request.GET.get("next_period", "")
# Get the displayed month from the request
_date_str = request.GET.get("month", "")
if _date_str:
self.month = (
datetime.datetime.strptime(_date_str, "%Y-%m").replace(tzinfo=datetime.UTC).date()
)
else:
self.month = datetime.datetime.now(tz=datetime.UTC).date().replace(day=1)
if _prev_period:
_period = _prev_period
elif _next_period:
_period = _next_period
self.view_type = _view_type
self.display_period = _get_display_period(_view_type, _period)
self.rentalitem_filters = request.GET.getlist("rentalitems", [])
if not self.rentalitem_filters:
items = RentalItem.objects.all()
self.rentalitem_filters = [item.name for item in items]
# Update request.GET
_request_get_list = request.GET.copy()
_request_get_list.pop("prev_period", None)
_request_get_list.pop("next_period", None)
_request_get_list["period_value"] = _period
request.GET = _request_get_list
return super().get(request, *args, **kwargs)
def get_context_data(self, **kwargs):
context = super().get_context_data(**kwargs)
# Calculate the day of the previous month from Monday
days_of_prev_month = []
if self.view_type != "week":
# Add the displayed, previous and next month
context["view_period"] = self.display_period
context["prev_period"] = self.display_period + datetime.timedelta(days=-1)
context["next_period"] = self.display_period + datetime.timedelta(
days=calendar.monthrange(self.display_period.year, self.display_period.month)[1] + 1
)
if self.month.weekday() != calendar.MONDAY:
for i in range(1, 7):
day = self.month + datetime.timedelta(days=-i)
days_of_prev_month.append(day)
if day.weekday() == calendar.MONDAY:
break
# Add the days of the displayed, previous and next month
days_of_view_period = _calc_days_from_current_month(self.display_period)
context["days_of_view_period"] = days_of_view_period
context["days_of_prev_period"] = _calc_days_from_prev_month(self.display_period)
context["days_of_next_period"] = _calc_days_from_next_month(self.display_period)
# Calculate the days of the next month until Sunday
last_day_of_month = self.month.replace(
day=calendar.monthrange(self.month.year, self.month.month)[1]
)
days_of_next_month = []
context["view_type"] = "month"
if last_day_of_month.weekday() != calendar.SUNDAY:
for i in range(1, 7):
day = last_day_of_month + datetime.timedelta(days=i)
days_of_next_month.append(day)
if day.weekday() == calendar.SUNDAY:
break
context["week_num"] = None
# Calculate the days of the displayed month
days_of_month = [
self.month + datetime.timedelta(days=i)
for i in range((last_day_of_month - self.month).days + 1)
]
else:
# Current week
year, week_num, _ = self.display_period.isocalendar()
context["view_period"] = f"{year}-KW{week_num:02d}" # formats as "2025-KW02"
# Create a dictionary with the rental items for each day
rental_dict = {}
for rental in self.get_queryset():
for day in days_of_month:
if rental["date_start"] <= day and rental["date_end"] >= day:
if day not in rental_dict:
rental_dict[day] = []
# Calculate previous week
prev_week = self.display_period - datetime.timedelta(days=7)
prev_year, prev_week_num, _ = prev_week.isocalendar()
context["prev_period"] = f"{prev_year}-KW{prev_week_num:02d}"
if rental["rentalitems__name"] not in rental_dict[day]:
rental_dict[day].append(rental["rentalitems__name"])
# Calculate next week
next_week = self.display_period + datetime.timedelta(days=7)
next_year, next_week_num, _ = next_week.isocalendar()
context["next_period"] = f"{next_year}-KW{next_week_num:02d}"
# Add the displayed, previous and next month
context["month"] = self.month
context["prev_month"] = self.month + datetime.timedelta(days=-1)
context["next_month"] = self.month + datetime.timedelta(
days=calendar.monthrange(self.month.year, self.month.month)[1] + 1
)
# Add days of week (7 days starting from first_day_of_week)
days_of_view_period = [
self.display_period + datetime.timedelta(days=i) for i in range(7)
]
context["days_of_view_period"] = days_of_view_period
context["days_of_prev_period"] = []
context["days_of_next_period"] = []
# Add the days of the displayed, previous and next month
context["days_of_month"] = days_of_month
context["days_of_prev_month"] = sorted(days_of_prev_month)
context["days_of_next_month"] = days_of_next_month
context["view_type"] = "week"
context["week_num"] = week_num
# Get the current date for the calendar
context["today"] = datetime.datetime.now(tz=datetime.UTC).date()
context["today"] = timezone.now().date()
# Add rental items to the context for the filter
context["rentalitems"] = RentalItem.objects.all()
@@ -103,7 +179,18 @@ class RentalListView(ListView):
# Add the selected rental items to the context for the filter
context["rentalitem_filters"] = {"rentalitems": self.rentalitem_filters}
context["rental_dict"] = rental_dict
# Create a dictionary with the rental items for each day
rental_dict = {}
for rental in self.get_queryset():
for day in days_of_view_period:
if rental["date_start"] <= day and rental["date_end"] >= day:
if day not in rental_dict:
rental_dict[day] = []
if rental["rentalitems__name"] not in rental_dict[day]:
rental_dict[day].append(rental["rentalitems__name"])
context["rental_dict"] = {k: sorted(v, key=str.casefold) for k, v in rental_dict.items()}
return context
@@ -118,20 +205,22 @@ class RentalListView(ListView):
)
)
last_day_of_month = self.month.replace(
day=calendar.monthrange(self.month.year, self.month.month)[1]
)
if self.view_type == "week":
qs_date_end = self.display_period + datetime.timedelta(days=6)
else:
qs_date_end = self.display_period.replace(
day=calendar.monthrange(self.display_period.year, self.display_period.month)[1]
)
# Filter by date
qs_new = qs.filter(date_start__gte=self.month, date_start__lte=last_day_of_month)
qs_new |= qs.filter(date_end__gte=self.month, date_end__lte=last_day_of_month)
if self.display_period and qs_date_end:
qs_new = qs.filter(date_start__gte=self.display_period, date_start__lte=qs_date_end)
qs_new |= qs.filter(date_end__gte=self.display_period, date_end__lte=qs_date_end)
# Filter by rental items
qs = qs.filter(rentalitems__name__in=self.rentalitem_filters).distinct()
qs = qs.values("id", "date_start", "date_end", "rentalitems__name").distinct()
return qs
return qs.values("id", "date_start", "date_end", "rentalitems__name").distinct()
class RentalCreateView(CreateView):
@@ -139,6 +228,35 @@ class RentalCreateView(CreateView):
model = Rental
template_name = "rental/create.html"
def form_valid(self, form):
# Get unsaved base Rental with all form data
base_rental = form.save(commit=False)
items = list(form.cleaned_data["rentalitems"])
selected_items = sorted(items, key=lambda x: x.name.lower())
for i in range(0, len(selected_items), RENTAL_ITEMS_MAX):
batch = selected_items[i : i + RENTAL_ITEMS_MAX]
# Clone base_rental — copying all its field values
new_rental = Rental.objects.create(
firstname=base_rental.firstname,
surname=base_rental.surname,
matriculation_number=base_rental.matriculation_number,
email=base_rental.email,
phone=base_rental.phone,
organization=base_rental.organization,
date_start=base_rental.date_start,
date_end=base_rental.date_end,
reason=base_rental.reason,
comment=base_rental.comment,
status=base_rental.status,
)
# Important: Add M2M relations after the object has been saved
new_rental.rentalitems.add(*batch)
return HttpResponseRedirect(self.get_success_url())
def get_context_data(self, **kwargs):
context = super().get_context_data(**kwargs)
@@ -147,26 +265,12 @@ class RentalCreateView(CreateView):
return context
def get_success_url(self):
return reverse("rental:rental_create_done", kwargs={"pk": self.object.pk})
return reverse("rental:rental_create_done")
class RentalCreateDoneView(TemplateView):
template_name = "rental/create_done.html"
def get_context_data(self, **kwargs):
context = super().get_context_data(**kwargs)
obj = Rental.objects.get(pk=self.kwargs["pk"])
total_deposit = 0
for elem in obj.rentalitems.all():
total_deposit += elem.deposit
context["object"] = obj
context["total_deposit"] = total_deposit
return context
class RentalItemDetailView(DetailView):
model = RentalItem
@@ -175,7 +279,7 @@ class RentalItemDetailView(DetailView):
def rental_calendar(request):
"""
ICS-calendar for outlook, google calender, ...
ICS-calendar for Outlook, Google Calendar, etc.
"""
rentals = Rental.objects.all()

View File

@@ -1,192 +1,281 @@
{% extends 'base.html' %}
{% load static %}
{% block title %}Verleih{% endblock %}
{% block content %}
<main class="container mx-auto w-full px-4 my-8 flex-1">
<h1 class="page-title">Verleih</h1>
<section class="text-center my-6">
<p class="my-6 text-gray-900 dark:text-gray-100">
Willkommen bei unserem Verleih!
</p>
{% if user.is_authenticated %}
<a href="{% url 'rental:calendar' %}" class="block btn btn-secondary max-w-xs mx-auto sm:w-max sm:mr-0 sm:ml-auto my-6"><i class="fa-solid fa-calendar-days mr-2"></i>Verleih-Kalender abonnieren</a>
{% endif %}
<a href="{% url 'rental:rental_create' %}" class="page-subtitle block btn-small btn-primary max-w-xs mx-auto sm:w-max sm:mr-0 sm:ml-auto">
<i class="fa-solid fa-plus mr-1"></i> Verleih anfragen
</a>
</section>
<form action="" method="GET">
<section>
<div class="grid grid-cols-1 gap-x-6 gap-y-6 lg:grid-cols-6 sm:grid-cols-3 pb-6">
<button
id="filterDropdownButton"
data-dropdown-toggle="filterDropdown"
class="w-full md:w-auto flex items-center justify-center py-2 px-4 text-sm font-medium text-gray-900 focus:outline-none bg-white rounded-lg border border-gray-200 hover:bg-gray-100 hover:text-primary-700 focus:z-10 focus:ring-4 focus:ring-gray-200 dark:focus:ring-gray-700 dark:bg-gray-800 dark:text-gray-400 dark:border-gray-600 dark:hover:text-white dark:hover:bg-gray-700"
type="button"
>
<i class="h-4 w-4 mr-2 fa-solid fa-filter"></i>
Filter
<i class="-mr-1 ml-1.5 mt-1 w-5 h-5 fa-solid fa-chevron-down"></i>
</button>
<div id="filterDropdown" class="z-10 hidden w-56 p-3 bg-white rounded-lg shadow dark:bg-gray-700">
<h6 class="mb-3 text-sm font-medium text-gray-900 dark:text-white">Verleihgegenstände</h6>
<ul class="space-y-2 text-sm" aria-labelledby="filterDropdownButton">
{% for item in rentalitems %}
<li class="flex items-center">
<input
id="item_{{ item.id }}"
type="checkbox"
name="rentalitems"
value="{{ item.name }}"
{% for key, value in rentalitem_filters.items %}
{% if key == "rentalitems" and item.name in value %}
checked
{% endif %}
{% endfor %}
class="w-4 h-4 bg-gray-100 border-gray-300 rounded text-primary-600 focus:ring-primary-500 dark:focus:ring-primary-600 dark:ring-offset-gray-700 focus:ring-2 dark:bg-gray-600 dark:border-gray-500"
>
<label for="item_{{ item.id }}" class="ml-2 text-sm font-medium text-gray-900 dark:text-gray-100">{{ item.name }}</label>
</li>
{% endfor %}
</ul>
</div>
<button type="submit" class="block btn btn-primary" name="" value="Submit">Filter anwenden</button>
</div>
</section>
<section class="text-center">
<div class="wrapper mx-auto bg-white rounded shadow max-w-full">
<div class="header flex justify-between border-b p-2">
<span class="text-lg font-bold">{{ month|date:'F Y' }}</span>
<div class="buttons">
<button type="submit" class="p-1" name="month" value="{{ prev_month|date:'Y-m' }}">
<i class="fa-regular fa-circle-left"></i>
</button>
<button type="submit" class="p-1" name="month" value="{{ next_month|date:'Y-m' }}">
<i class="fa-regular fa-circle-right"></i>
</button>
</div>
</div>
<table class="table-fixed w-full mx-auto">
<thead>
<tr>
<th class="p-2 border-r h-10 xl:text-sm text-xs">
<span class="xl:block lg:block md:block sm:block hidden">Monday</span>
<span class="xl:hidden lg:hidden md:hidden sm:hidden block">Mon</span>
</th>
<th class="p-2 border-r h-10 xl:text-sm text-xs">
<span class="xl:block lg:block md:block sm:block hidden">Tuesday</span>
<span class="xl:hidden lg:hidden md:hidden sm:hidden block">Tue</span>
</th>
<th class="p-2 border-r h-10 xl:text-sm text-xs">
<span class="xl:block lg:block md:block sm:block hidden">Wednesday</span>
<span class="xl:hidden lg:hidden md:hidden sm:hidden block">Wed</span>
</th>
<th class="p-2 border-r h-10 xl:text-sm text-xs">
<span class="xl:block lg:block md:block sm:block hidden">Thursday</span>
<span class="xl:hidden lg:hidden md:hidden sm:hidden block">Thu</span>
</th>
<th class="p-2 border-r h-10 xl:text-sm text-xs">
<span class="xl:block lg:block md:block sm:block hidden">Friday</span>
<span class="xl:hidden lg:hidden md:hidden sm:hidden block">Fri</span>
</th>
<th class="p-2 border-r h-10 xl:text-sm text-xs">
<span class="xl:block lg:block md:block sm:block hidden">Saturday</span>
<span class="xl:hidden lg:hidden md:hidden sm:hidden block">Sat</span>
</th>
<th class="p-2 border-r h-10 xl:text-sm text-xs">
<span class="xl:block lg:block md:block sm:block hidden">Sunday</span>
<span class="xl:hidden lg:hidden md:hidden sm:hidden block">Sun</span>
</th>
</tr>
</thead>
<tbody>
{% for day in days_of_prev_month %}
{% if day.weekday == 0 %}
<tr class="text-center h-20">
{% endif %}
<td class="border p-1 h-40">
<div class="flex flex-col h-40">
<div class="top h-5 w-full">
<span class="text-gray-500 text-sm">{{ day.day }}</span>
</div>
<div class="bottom flex-grow h-30 py-1 w-full"></div>
</div>
</td>
{% endfor %}
{% for day in days_of_month %}
{% if day.weekday == 0 %}
<tr class="text-center h-20">
{% endif %}
<td class="border p-1 h-40">
<div class="flex flex-col h-40">
<div class="top h-5 w-full">
{% if day == today %}
<span class="text-gray-100 dark:text-gray-900 border-2 border-blue-900 dark:border-blue-100 rounded-full bg-blue-900 dark:bg-blue-100">{{ day.day }}</span>
{% else %}
<span class="text-gray-900 dark:text-gray-100">{{ day.day }}</span>
{% endif %}
</div>
{% for key, names in rental_dict.items %}
{% if key == day %}
{% for name in names %}
<div class="bottom h-30 py-1 w-full">
<div class="event bg-purple-400 text-white rounded p-1 text-sm">
<span class="event-name whitespace-nowrap 2xl:block xl:hidden lg:hidden md:hidden sm:hidden hidden">{{ name|truncatechars:26 }}</span>
<span class="event-name whitespace-nowrap 2xl:hidden xl:block lg:hidden md:hidden sm:hidden hidden">{{ name|truncatechars:20 }}</span>
<span class="event-name whitespace-nowrap 2xl:hidden xl:hidden lg:block md:hidden sm:hidden hidden">{{ name|truncatechars:15 }}</span>
<span class="event-name whitespace-nowrap 2xl:hidden xl:hidden lg:hidden md:block sm:hidden hidden">{{ name|truncatechars:10 }}</span>
<span class="event-name whitespace-nowrap 2xl:hidden xl:hidden lg:hidden md:hidden sm:block hidden">{{ name|truncatechars:6 }}</span>
<span class="event-name whitespace-nowrap 2xl:hidden xl:hidden lg:hidden md:hidden sm:hidden block">{{ name|truncatechars:3 }}</span>
</div>
</div>
{% empty %}
<div class="bottom flex-grow h-30 py-1 w-full"></div>
{% endfor %}
{% endif %}
{% endfor %}
</div>
</td>
{% if day.weekday == 6 %}
</tr>
{% endif %}
{% endfor %}
{% for day in days_of_next_month %}
<td class="border p-1 h-40">
<div class="flex flex-col h-40">
<div class="top h-5 w-full">
<span class="text-gray-500 text-sm">{{ day.day }}</span>
</div>
<div class="bottom flex-grow h-30 py-1 w-full"></div>
</div>
</td>
{% if day.weekday == 6 %}
</tr>
{% endif %}
{% endfor %}
</tbody>
</table>
</div>
</section>
</form>
</main>
{% endblock content %}
{% extends 'base.html' %}
{% load static %}
{% block title %}Verleih{% endblock %}
{% block content %}
<main class="container mx-auto w-full px-4 my-8 flex-1">
<h1 class="page-title">Verleih</h1>
<section class="text-center my-6">
<p class="my-6 text-gray-900 dark:text-gray-100">
Willkommen bei unserem Verleih!
</p>
{% if user.is_authenticated %}
<a
href="{% url 'rental:calendar' %}"
class="block btn btn-secondary max-w-xs mx-auto sm:w-max sm:mr-0 sm:ml-auto my-6"
title="Kalender im iCal-Format abonnieren"
>
<i class="fa-solid fa-calendar-days mr-2"></i>Verleih-Kalender abonnieren
</a>
{% endif %}
<a
href="{% url 'rental:rental_create' %}"
class="page-subtitle block btn-small btn-primary max-w-xs mx-auto sm:w-max sm:mr-0 sm:ml-auto"
title="Neue Verleihanfrage erstellen"
>
<i class="fa-solid fa-plus mr-1"></i> Verleih anfragen
</a>
</section>
<form action="" method="GET">
<section>
<div class="grid grid-cols-1 gap-x-6 gap-y-6 xl:grid-cols-6 sm:grid-cols-3 pb-6">
<button
id="filterDropdownButton"
data-dropdown-toggle="filterDropdown"
class="w-full md:w-auto flex items-center justify-center py-2 px-4 text-sm font-medium text-gray-900 focus:outline-none bg-white rounded-lg border border-gray-200 hover:bg-gray-100 hover:text-primary-700 focus:z-10 focus:ring-4 focus:ring-gray-200 dark:focus:ring-gray-700 dark:bg-gray-800 dark:text-gray-400 dark:border-gray-600 dark:hover:text-white dark:hover:bg-gray-700"
type="button"
>
<i class="h-4 w-4 mr-2 fa-solid fa-filter"></i>
Filter
<i class="-mr-1 ml-1.5 mt-1 w-5 h-5 fa-solid fa-chevron-down"></i>
</button>
<div id="filterDropdown" class="z-10 hidden w-56 p-3 bg-white rounded-lg shadow dark:bg-gray-700">
<h6 class="mb-3 text-sm font-medium text-gray-900 dark:text-white">Verleihgegenstände</h6>
<ul class="space-y-2 text-sm" aria-labelledby="filterDropdownButton">
{% for item in rentalitems %}
<li class="flex items-center">
<input
id="item_{{ item.id }}"
type="checkbox"
name="rentalitems"
value="{{ item.name }}"
{% for key, value in rentalitem_filters.items %}
{% if key == "rentalitems" and item.name in value %}
checked
{% endif %}
{% endfor %}
class="w-4 h-4 bg-gray-100 border-gray-300 rounded text-primary-600 focus:ring-primary-500 dark:focus:ring-primary-600 dark:ring-offset-gray-700 focus:ring-2 dark:bg-gray-600 dark:border-gray-500"
>
<label for="item_{{ item.id }}" class="ml-2 text-sm font-medium text-gray-900 dark:text-gray-100">{{ item.name }}</label>
</li>
{% endfor %}
</ul>
</div>
<div class="flex w-full md:w-auto items-center justify-center py-2 px-4 border border-gray-200 rounded-sm dark:border-gray-700">
<input
{% if view_type == 'month' %}checked{% endif %}
id="bordered-radio-1"
type="radio"
value="month"
name="view_type"
title="Zur Monatsansicht wechseln"
class="ml-2 mr-2 w-4 h-4 text-blue-600 bg-gray-100 border-gray-300 focus:ring-blue-500 dark:focus:ring-blue-600 dark:ring-offset-gray-800 focus:ring-2 dark:bg-gray-700 dark:border-gray-600"
>
<label for="bordered-radio-1" class="w-full ms-2 text-sm font-medium text-gray-900 dark:text-gray-300">Monatsansicht</label>
</div>
<div class="flex w-full md:w-auto items-center justify-center py-2 px-4 border border-gray-200 rounded-sm dark:border-gray-700">
<input
{% if view_type == 'week' %}checked{% endif %}
id="bordered-radio-2"
type="radio"
value="week"
name="view_type"
title="Zur Wochenansicht wechseln"
class="ml-2 mr-2 w-4 h-4 text-blue-600 bg-gray-100 border-gray-300 focus:ring-blue-500 dark:focus:ring-blue-600 dark:ring-offset-gray-800 focus:ring-2 dark:bg-gray-700 dark:border-gray-600"
>
<label for="bordered-radio-2" class="w-full ms-2 text-sm font-medium text-gray-900 dark:text-gray-300">Wochenansicht</label>
</div>
<input
type="hidden" name="period_value"
value="{% if view_type == 'month' %}{{ view_period|date:'Y-m' }}{% else %}{{ view_period }}{% endif %}"
>
<button
type="submit"
class="block btn btn-primary"
title="Filter anwenden und Kalender aktualisieren"
>
Filter anwenden
</button>
</div>
</section>
<section class="text-center">
<div class="wrapper mx-auto bg-white rounded shadow max-w-full">
<div class="header flex justify-between border-b p-2">
<span class="text-lg font-bold">
{% if view_type == 'month' %}
{{ view_period|date:'F Y' }}
{% else %}
{% if days_of_view_period.0.month != days_of_view_period.6.month %}
KW{{ week_num|stringformat:"02d" }} - {{ days_of_view_period.0|date:'F' }} / {{ days_of_view_period.6|date:'F Y' }}
{% else %}
KW{{ week_num|stringformat:"02d" }} - {{ days_of_view_period.0|date:'F Y' }}
{% endif %}
{% endif %}
</span>
<div class="buttons">
<button
type="submit" class="p-1" name="prev_period"
title="{% if view_type == 'month' %}Zum vorherigen Monat{% else %}Zur vorherigen Woche{% endif %}"
value="{% if view_type == 'month' %}{{ prev_period|date:'Y-m' }}{% else %}{{ prev_period }}{% endif %}"
>
<i class="fa-regular fa-circle-left"></i>
</button>
<button
type="submit" class="p-1" name="next_period"
title="{% if view_type == 'month' %}Zum nächsten Monat{% else %}Zur nächsten Woche{% endif %}"
value="{% if view_type == 'month' %}{{ next_period|date:'Y-m' }}{% else %}{{ next_period }}{% endif %}"
>
<i class="fa-regular fa-circle-right"></i>
</button>
</div>
</div>
<table class="table-fixed w-full mx-auto">
<thead>
<tr>
<th class="p-2 border-r h-10 xl:text-sm text-xs">
<span class="xl:block lg:block md:block sm:block hidden">Montag</span>
<span class="xl:hidden lg:hidden md:hidden sm:hidden block">Mo</span>
</th>
<th class="p-2 border-r h-10 xl:text-sm text-xs">
<span class="xl:block lg:block md:block sm:block hidden">Dienstag</span>
<span class="xl:hidden lg:hidden md:hidden sm:hidden block">Di</span>
</th>
<th class="p-2 border-r h-10 xl:text-sm text-xs">
<span class="xl:block lg:block md:block sm:block hidden">Mittwoch</span>
<span class="xl:hidden lg:hidden md:hidden sm:hidden block">Mi</span>
</th>
<th class="p-2 border-r h-10 xl:text-sm text-xs">
<span class="xl:block lg:block md:block sm:block hidden">Donnerstag</span>
<span class="xl:hidden lg:hidden md:hidden sm:hidden block">Do</span>
</th>
<th class="p-2 border-r h-10 xl:text-sm text-xs">
<span class="xl:block lg:block md:block sm:block hidden">Freitag</span>
<span class="xl:hidden lg:hidden md:hidden sm:hidden block">Fr</span>
</th>
<th class="p-2 border-r h-10 xl:text-sm text-xs">
<span class="xl:block lg:block md:block sm:block hidden">Samstag</span>
<span class="xl:hidden lg:hidden md:hidden sm:hidden block">Sa</span>
</th>
<th class="p-2 border-r h-10 xl:text-sm text-xs">
<span class="xl:block lg:block md:block sm:block hidden">Sonntag</span>
<span class="xl:hidden lg:hidden md:hidden sm:hidden block">So</span>
</th>
</tr>
</thead>
<tbody>
{% for day in days_of_prev_period %}
{% if day.weekday == 0 %}
<tr class="text-center h-20">
{% endif %}
<td class="border p-1 h-40">
<div class="flex flex-col h-40">
<div class="top h-5 w-full">
<span class="text-gray-500 text-sm">{{ day.day }}</span>
</div>
<div class="bottom flex-grow h-30 py-1 w-full"></div>
</div>
</td>
{% endfor %}
{% for day in days_of_view_period %}
{% if day.weekday == 0 %}
<tr class="text-center h-40">
{% endif %}
<td class="border p-1 h-full">
<div
class="flex flex-col{% if view_type == 'month' %} h-40{% else %} h-full{% endif %}"
>
<div class="top h-5 w-full">
{% if day == today %}
<span class="text-gray-100 dark:text-gray-900 border-2 border-blue-900 dark:border-blue-100 rounded-full bg-blue-900 dark:bg-blue-100">{{ day.day }}</span>
{% else %}
<span class="text-gray-900 dark:text-gray-100">{{ day.day }}</span>
{% endif %}
</div>
{% for key, names in rental_dict.items %}
{% if key == day %}
{% if view_type == 'month' %}
{% for name in names|slice:":3" %}
<div class="bottom h-30 py-1 w-full">
<div class="event bg-purple-400 text-white rounded p-1 text-sm" title="{{ name }}">
<span class="event-name whitespace-nowrap 2xl:block xl:hidden lg:hidden md:hidden sm:hidden hidden">{{ name|truncatechars:26 }}</span>
<span class="event-name whitespace-nowrap 2xl:hidden xl:block lg:hidden md:hidden sm:hidden hidden">{{ name|truncatechars:20 }}</span>
<span class="event-name whitespace-nowrap 2xl:hidden xl:hidden lg:block md:hidden sm:hidden hidden">{{ name|truncatechars:15 }}</span>
<span class="event-name whitespace-nowrap 2xl:hidden xl:hidden lg:hidden md:block sm:hidden hidden">{{ name|truncatechars:10 }}</span>
<span class="event-name whitespace-nowrap 2xl:hidden xl:hidden lg:hidden md:hidden sm:block hidden">{{ name|truncatechars:6 }}</span>
<span class="event-name whitespace-nowrap 2xl:hidden xl:hidden lg:hidden md:hidden sm:hidden block">{{ name|truncatechars:3 }}</span>
</div>
</div>
{% endfor %}
{% if names|length > 3 %}
<div class="bottom h-30 py-1 w-full">
<a href="?view_type=week&period_value={{ day|date:'o-\K\WW' }}"
class="event bg-gray-300 hover:bg-gray-400 text-gray-700 rounded p-1 text-sm hover:underline block cursor-pointer"
title="Alle Verleihgegenstände in der Wochenansicht anzeigen">
<span class="event-name whitespace-nowrap sm:block hidden">+{{ names|length|add:"-3" }} weitere</span>
<span class="event-name whitespace-nowrap sm:hidden block">+{{ names|length|add:"-3" }}</span>
</a>
</div>
{% endif %}
{% else %}
{% for name in names %}
<div class="bottom h-30 py-1 w-full">
<div class="event bg-purple-400 text-white rounded p-1 text-sm" title="{{ name }}">
<span class="event-name whitespace-nowrap 2xl:block xl:hidden lg:hidden md:hidden sm:hidden hidden">{{ name|truncatechars:26 }}</span>
<span class="event-name whitespace-nowrap 2xl:hidden xl:block lg:hidden md:hidden sm:hidden hidden">{{ name|truncatechars:20 }}</span>
<span class="event-name whitespace-nowrap 2xl:hidden xl:hidden lg:block md:hidden sm:hidden hidden">{{ name|truncatechars:15 }}</span>
<span class="event-name whitespace-nowrap 2xl:hidden xl:hidden lg:hidden md:block sm:hidden hidden">{{ name|truncatechars:10 }}</span>
<span class="event-name whitespace-nowrap 2xl:hidden xl:hidden lg:hidden md:hidden sm:block hidden">{{ name|truncatechars:6 }}</span>
<span class="event-name whitespace-nowrap 2xl:hidden xl:hidden lg:hidden md:hidden sm:hidden block">{{ name|truncatechars:3 }}</span>
</div>
</div>
{% endfor %}
{% endif %}
{% endif %}
{% endfor %}
</div>
</td>
{% if day.weekday == 6 %}
</tr>
{% endif %}
{% endfor %}
{% for day in days_of_next_period %}
<td class="border p-1 h-40">
<div class="flex flex-col h-40">
<div class="top h-5 w-full">
<span class="text-gray-500 text-sm">{{ day.day }}</span>
</div>
<div class="bottom flex-grow h-30 py-1 w-full"></div>
</div>
</td>
{% if day.weekday == 6 %}
</tr>
{% endif %}
{% endfor %}
</tbody>
</table>
</div>
</section>
</form>
</main>
{% endblock content %}

View File

@@ -1,121 +1,123 @@
{% extends 'base.html' %}
{% load static %}
{% block title %}Verleih Anfrage{% endblock %}
{% block content %}
<!-- Main Content -->
<main class="container mx-auto w-full px-4 my-8 flex-1">
<h1 class="page-title">Verleih Anfrage</h1>
<form action="" enctype="multipart/form-data" method="POST" class="multiSectionForm max-w-2xl">
{% csrf_token %}
{% include "baseform/non_field_errors.html" %}
<section>
<h2>Persönliche Daten</h2>
<small>Bitte gib deine persönlichen Daten ein.</small>
<div class="grid grid-cols-1 gap-x-6 gap-y-6 sm:grid-cols-6">
<div class="sm:col-span-3">
{% include "baseform/text.html" with field=form.firstname %}
</div>
<div class="sm:col-span-3">
{% include "baseform/text.html" with field=form.surname %}
</div>
<div class="sm:col-span-3">
{% include "baseform/text.html" with field=form.organization %}
</div>
<div class="sm:col-span-3">
{% include "baseform/text.html" with field=form.matriculation_number %}
</div>
<div class="sm:col-span-3">
{% include "baseform/email.html" with field=form.email %}
</div>
<div class="sm:col-span-3">
{% include "baseform/text.html" with field=form.phone %}
</div>
</div>
</section>
<section>
<h2>Verleihgegenstände</h2>
<small>Wähl deine gewünschten Verleihgegenstände aus.</small>
<div class="grid grid-cols-1 gap-x-6 gap-y-6 sm:grid-cols-6 pb-6 col-span-full">
{% if form.rentalitems.errors %}
<div class="col-span-full alert alert-danger">
<div class="alert-body">{{ form.rentalitems.errors }}</div>
</div>
{% endif %}
<div id="{{ form.rentalitems.auto_id }}" class="col-span-full">
{% for elem in form.rentalitems %}
<div class="col-span-2 mb-2">
<label for="{{ elem.id_for_label }}">
<input
type="checkbox"
id="{{ elem.id_for_label }}"
name="{{ form.rentalitems.html_name }}"
value="{{ elem.data.value }}"
class="rounded border-gray-300 dark:border-none text-proprietary shadow-sm focus:border-blue-300 focus:ring focus:ring-offset-0 focus:ring-blue-200 dark:focus:ring-sky-700 focus:ring-opacity-50"
>
<a
href="{% url 'rental:rentalitem_detail' elem.data.value %}"
class="text-gray-700 dark:text-gray-200 underline hover:text-blue-700 dark:hover:text-blue-300"
>{{ elem.choice_label }}</a>
</label>
{% for item in rentalitems_addinfo %}
{% if item.name == elem.choice_label and item.induction %}
<p class="text-xs text-gray-700 dark:text-gray-200">Einschulung erforderlich!</p>
{% endif %}
{% endfor %}
</div>
{% endfor %}
</div>
</div>
<div class="grid grid-cols-1 gap-x-6 gap-y-6 sm:grid-cols-6">
<div class="sm:col-span-3">
{% include "baseform/date.html" with field=form.date_start %}
</div>
<div class="sm:col-span-3">
{% include "baseform/date.html" with field=form.date_end %}
</div>
<div class="col-span-full">
{% include "baseform/textarea.html" with field=form.reason %}
</div>
</div>
</section>
<section>
<h2>Zusätzliche Informationen</h2>
<small>Hier kannst du zusätzliche Informationen, Anliegen und Sonstiges angeben.</small>
<div class="grid grid-cols-1 gap-x-6 gap-y-6 sm:grid-cols-6">
<div class="col-span-full">
{% include "baseform/textarea.html" with field=form.comment %}
</div>
</div>
</section>
<section>
<div class="grid grid-cols-1 gap-x-6 gap-y-6 sm:grid-cols-6">
<div class="col-span-full text-gray-700 dark:text-gray-200">
{% include "baseform/checkbox.html" with field=form.conformation %}
</div>
<a href="{% static 'rental/verleihregeln.pdf' %}" target='_blank' class="inline-flex items-center px-2 py-1">
<i class="fa-solid fa-file-pdf fa-fw text-red-800 dark:text-red-500 md:text-inherit group-hover:text-red-800 dark:group-hover:text-red-500"></i>
<span class="ml-2 sm:ml-1 text-gray-700 dark:text-gray-200">Verleihregeln</span>
</a>
</div>
</section>
<section class="flex justify-end">
<button type="submit" class="btn btn-primary w-full sm:w-auto" value="Einreichen">Anfrage abschicken</button>
</section>
</form>
</main>
{% endblock content %}
{% extends 'base.html' %}
{% load static %}
{% block title %}Verleih Anfrage{% endblock %}
{% block content %}
<!-- Main Content -->
<main class="container mx-auto w-full px-4 my-8 flex-1">
<h1 class="page-title">Verleih Anfrage</h1>
<form action="" enctype="multipart/form-data" method="POST" class="multiSectionForm max-w-2xl">
{% csrf_token %}
{% include "baseform/non_field_errors.html" %}
<section>
<h2>Persönliche Daten</h2>
<small>Bitte gib deine persönlichen Daten ein.</small>
<div class="grid grid-cols-1 gap-x-6 gap-y-6 sm:grid-cols-6">
<div class="sm:col-span-3">
{% include "baseform/text.html" with field=form.firstname %}
</div>
<div class="sm:col-span-3">
{% include "baseform/text.html" with field=form.surname %}
</div>
<div class="sm:col-span-3">
{% include "baseform/text.html" with field=form.organization %}
</div>
<div class="sm:col-span-3">
{% include "baseform/text.html" with field=form.matriculation_number %}
</div>
<div class="sm:col-span-3">
{% include "baseform/email.html" with field=form.email %}
</div>
<div class="sm:col-span-3">
{% include "baseform/text.html" with field=form.phone %}
</div>
</div>
</section>
<section>
<h2>Verleihgegenstände</h2>
<small>Wähl deine gewünschten Verleihgegenstände aus.</small>
<div class="grid grid-cols-1 gap-x-6 gap-y-6 sm:grid-cols-6 pb-6 col-span-full">
{% if form.rentalitems.errors %}
<div class="col-span-full alert alert-danger">
<div class="alert-body">{{ form.rentalitems.errors }}</div>
</div>
{% endif %}
<div id="{{ form.rentalitems.auto_id }}" class="col-span-full">
{% for elem in form.rentalitems %}
<div class="col-span-2 mb-2">
<label for="{{ elem.data.value }}">
<input
type="checkbox"
id="{{ elem.data.value }}"
name="{{ form.rentalitems.html_name }}"
value="{{ elem.data.value }}"
{% if elem.data.selected %}checked{% endif %}
class="rounded border-gray-300 dark:border-none text-proprietary shadow-sm focus:border-blue-300 focus:ring focus:ring-offset-0 focus:ring-blue-200 dark:focus:ring-sky-700 focus:ring-opacity-50"
>
<a
href="{% url 'rental:rentalitem_detail' elem.data.value %}"
class="text-gray-700 dark:text-gray-200 underline hover:text-blue-700 dark:hover:text-blue-300"
title="Details zu {{ elem.choice_label }}"
aria-label="Details zu {{ elem.choice_label }}"
>{{ elem.choice_label }}</a>
</label>
{% for item in rentalitems_addinfo %}
{% if item.name == elem.choice_label and item.induction %}
<p class="text-xs text-gray-700 dark:text-gray-200">Einschulung erforderlich!</p>
{% endif %}
{% endfor %}
</div>
{% endfor %}
</div>
</div>
<div class="grid grid-cols-1 gap-x-6 gap-y-6 sm:grid-cols-6">
<div class="sm:col-span-3">
{% include "baseform/date.html" with field=form.date_start %}
</div>
<div class="sm:col-span-3">
{% include "baseform/date.html" with field=form.date_end %}
</div>
<div class="col-span-full">
{% include "baseform/textarea.html" with field=form.reason %}
</div>
</div>
</section>
<section>
<h2>Zusätzliche Informationen</h2>
<small>Hier kannst du zusätzliche Informationen, Anliegen und Sonstiges angeben.</small>
<div class="grid grid-cols-1 gap-x-6 gap-y-6 sm:grid-cols-6">
<div class="col-span-full">
{% include "baseform/textarea.html" with field=form.comment %}
</div>
</div>
</section>
<section>
<div class="grid grid-cols-1 gap-x-6 gap-y-6 sm:grid-cols-6">
<div class="col-span-full text-gray-700 dark:text-gray-200">
{% include "baseform/checkbox.html" with field=form.conformation %}
</div>
<a href="{% static 'rental/verleihregeln.pdf' %}" target='_blank' class="inline-flex items-center px-2 py-1">
<i class="fa-solid fa-file-pdf fa-fw text-red-800 dark:text-red-500 md:text-inherit group-hover:text-red-800 dark:group-hover:text-red-500"></i>
<span class="ml-2 sm:ml-1 text-gray-700 dark:text-gray-200">Verleihregeln</span>
</a>
</div>
</section>
<section class="flex justify-end">
<button type="submit" class="btn btn-primary w-full sm:w-auto" value="Einreichen">Anfrage abschicken</button>
</section>
</form>
</main>
{% endblock content %}

View File

@@ -1,17 +1,22 @@
{% extends 'base.html' %}
{% block title %}Verleihanfrage erfolgreich eingereicht{% endblock %}
{% block content %}
<!-- Main Content -->
<main class="container mx-auto w-full px-4 my-8 flex-1 max-w-2xl">
<section class="block w-full">
<p class="mt-6 text-gray-900 dark:text-gray-100 hyphens-auto" lang="de">
Deine Verleihanfrage mit der Nummer #{{ pk }} wurde erfolgreich eingereicht. Die Gegenstände können am {{ object.date_start }} während der Beratungszeit (Montag - Donnerstag: 09:00 - 14:00, Freitag: 09:00 - 12:00) abgeholt werden. Bitte bring den Gesamtpfand in Höhe von {{ total_deposit }} € in bar mit.
</p>
<div class="mt-10 flex items-center justify-center">
<a href="{% url 'home' %}" type="submit" class="block btn btn-primary">Zur Startseite</a>
</div>
</section>
</main>
{% endblock content %}
{% extends 'base.html' %}
{% block title %}Verleihanfrage erfolgreich eingereicht{% endblock %}
{% block content %}
<!-- Main Content -->
<main class="container mx-auto w-full px-4 my-8 flex-1 max-w-2xl">
<section class="block w-full">
<p class="mt-6 text-gray-900 dark:text-gray-100 hyphens-auto" lang="de">
Deine Verleihanfrage ist eingegangen - danke dir! 🎉
Wir kümmern uns jetzt darum und melden uns per E-Mail mit den nächsten Schritten.
</p>
<p class="mt-6 text-gray-900 dark:text-gray-100 hyphens-auto" lang="de">
Kleiner Hinweis: Bevor du die Sachen abholen kannst, wird deine Anfrage kurz geprüft und freigegeben.
Sobald alles genehmigt ist, bekommst du von uns eine Mail.
</p>
<div class="mt-10 flex items-center justify-center">
<a href="{% url 'home' %}" type="submit" class="block btn btn-primary">Zur Startseite</a>
</div>
</section>
</main>
{% endblock content %}

283
fet2020/uv.lock generated
View File

@@ -3,54 +3,66 @@ revision = 3
requires-python = ">=3.13"
[[package]]
name = "asgiref"
version = "3.10.0"
name = "anyio"
version = "4.12.1"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/46/08/4dfec9b90758a59acc6be32ac82e98d1fbfc321cb5cfa410436dbacf821c/asgiref-3.10.0.tar.gz", hash = "sha256:d89f2d8cd8b56dada7d52fa7dc8075baa08fb836560710d38c292a7a3f78c04e", size = 37483, upload-time = "2025-10-05T09:15:06.557Z" }
dependencies = [
{ name = "idna" },
]
sdist = { url = "https://files.pythonhosted.org/packages/96/f0/5eb65b2bb0d09ac6776f2eb54adee6abe8228ea05b20a5ad0e4945de8aac/anyio-4.12.1.tar.gz", hash = "sha256:41cfcc3a4c85d3f05c932da7c26d0201ac36f72abd4435ba90d0464a3ffed703", size = 228685, upload-time = "2026-01-06T11:45:21.246Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/17/9c/fc2331f538fbf7eedba64b2052e99ccf9ba9d6888e2f41441ee28847004b/asgiref-3.10.0-py3-none-any.whl", hash = "sha256:aef8a81283a34d0ab31630c9b7dfe70c812c95eba78171367ca8745e88124734", size = 24050, upload-time = "2025-10-05T09:15:05.11Z" },
{ url = "https://files.pythonhosted.org/packages/38/0e/27be9fdef66e72d64c0cdc3cc2823101b80585f8119b5c112c2e8f5f7dab/anyio-4.12.1-py3-none-any.whl", hash = "sha256:d405828884fc140aa80a3c667b8beed277f1dfedec42ba031bd6ac3db606ab6c", size = 113592, upload-time = "2026-01-06T11:45:19.497Z" },
]
[[package]]
name = "asgiref"
version = "3.11.0"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/76/b9/4db2509eabd14b4a8c71d1b24c8d5734c52b8560a7b1e1a8b56c8d25568b/asgiref-3.11.0.tar.gz", hash = "sha256:13acff32519542a1736223fb79a715acdebe24286d98e8b164a73085f40da2c4", size = 37969, upload-time = "2025-11-19T15:32:20.106Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/91/be/317c2c55b8bbec407257d45f5c8d1b6867abc76d12043f2d3d58c538a4ea/asgiref-3.11.0-py3-none-any.whl", hash = "sha256:1db9021efadb0d9512ce8ffaf72fcef601c7b73a8807a1bb2ef143dc6b14846d", size = 24096, upload-time = "2025-11-19T15:32:19.004Z" },
]
[[package]]
name = "certifi"
version = "2025.10.5"
version = "2026.1.4"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/4c/5b/b6ce21586237c77ce67d01dc5507039d444b630dd76611bbca2d8e5dcd91/certifi-2025.10.5.tar.gz", hash = "sha256:47c09d31ccf2acf0be3f701ea53595ee7e0b8fa08801c6624be771df09ae7b43", size = 164519, upload-time = "2025-10-05T04:12:15.808Z" }
sdist = { url = "https://files.pythonhosted.org/packages/e0/2d/a891ca51311197f6ad14a7ef42e2399f36cf2f9bd44752b3dc4eab60fdc5/certifi-2026.1.4.tar.gz", hash = "sha256:ac726dd470482006e014ad384921ed6438c457018f4b3d204aea4281258b2120", size = 154268, upload-time = "2026-01-04T02:42:41.825Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/e4/37/af0d2ef3967ac0d6113837b44a4f0bfe1328c2b9763bd5b1744520e5cfed/certifi-2025.10.5-py3-none-any.whl", hash = "sha256:0f212c2744a9bb6de0c56639a6f68afe01ecd92d91f14ae897c4fe7bbeeef0de", size = 163286, upload-time = "2025-10-05T04:12:14.03Z" },
{ url = "https://files.pythonhosted.org/packages/e6/ad/3cc14f097111b4de0040c83a525973216457bbeeb63739ef1ed275c1c021/certifi-2026.1.4-py3-none-any.whl", hash = "sha256:9943707519e4add1115f44c2bc244f782c0249876bf51b6599fee1ffbedd685c", size = 152900, upload-time = "2026-01-04T02:42:40.15Z" },
]
[[package]]
name = "djade"
version = "1.6.0"
version = "1.7.0"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/a9/9c/637184b2595d38d9ddcd31076c7ac6b57cd259821481cc8f029616be2b25/djade-1.6.0.tar.gz", hash = "sha256:a7f173d6949ce248ee538ab85676e3277b4b4ae956c3383f96348355ab8983b7", size = 40150, upload-time = "2025-09-08T15:35:20.097Z" }
sdist = { url = "https://files.pythonhosted.org/packages/d5/31/05baa6b0419c6cd13e4ecae275aadfb6d5c0b332f1b06d4fccb4cb4a2b29/djade-1.7.0.tar.gz", hash = "sha256:b49e7ad42050ecac10ae990a6adbecec08c62b0a32a33de748d63d948bced815", size = 39433, upload-time = "2025-12-10T16:03:53.618Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/dd/e6/ddfcf10d010a3905232ba2962d2ef00533052913966d06237356871fa48f/djade-1.6.0-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:e43eb2227a02599df038ba64b7a0c8599d6cef9d0de978ac635592d58299d477", size = 1126877, upload-time = "2025-09-08T15:35:02.534Z" },
{ url = "https://files.pythonhosted.org/packages/37/78/ce1cc79f9311f101ea6721498b1027b7698cd40702af6b50a7842442644d/djade-1.6.0-py3-none-macosx_11_0_arm64.whl", hash = "sha256:fe820c5be3d08952ad42ea9ee5475e1c60d32326fae57ee98a4887e99d2d1765", size = 1054501, upload-time = "2025-09-08T15:35:04.115Z" },
{ url = "https://files.pythonhosted.org/packages/71/db/2b7d51041c47b65ad0f483379958ac44fb12db7a761a390e0f3423932e6e/djade-1.6.0-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:4dd8a7919f1fa953e332548aeb3b793dea0563fc5e9f89017da6fe7318b603a2", size = 1098200, upload-time = "2025-09-08T15:35:05.794Z" },
{ url = "https://files.pythonhosted.org/packages/5c/82/8d829ba658337ee9138f8850d91cab4ea9ccad2a6d23a60fb7c3cbb7ae9b/djade-1.6.0-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:113a8db4657bd488ecf383c3db0953530012afbccb596302a294c7b629573269", size = 1041283, upload-time = "2025-09-08T15:35:07.125Z" },
{ url = "https://files.pythonhosted.org/packages/79/ac/7f3fbedf3f229bed3c55fe0e6db4b669d2b5d995c07918739dd4713997f7/djade-1.6.0-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:53511e1d3d55ff408fe11edc7bc982b0af53ab6225870d031804e209ec0515d8", size = 1222400, upload-time = "2025-09-08T15:35:08.826Z" },
{ url = "https://files.pythonhosted.org/packages/57/2a/3f3053cc7fba84faee5cd8307cb2d548bc26f05e9c4d28bb48b47bf25d0a/djade-1.6.0-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:e9ed89688c9baa733cbf4ec08b8cc0102bdd1b539bd0775f5ad5be98e20ab934", size = 1195877, upload-time = "2025-09-08T15:35:10.464Z" },
{ url = "https://files.pythonhosted.org/packages/ac/35/9324569b8cfa714c1992dba661deb9bf9001c0471986c71c331172c10388/djade-1.6.0-py3-none-musllinux_1_1_aarch64.whl", hash = "sha256:4c4c42d86f317742ad1d33f4ca85d4d507530b7cfbfc9b2dc015dda4d06199ad", size = 1086171, upload-time = "2025-09-08T15:35:11.923Z" },
{ url = "https://files.pythonhosted.org/packages/64/39/e924089cddb183295f7c51ed1587c2f6c956e0efe8b5b594494104c93037/djade-1.6.0-py3-none-musllinux_1_1_x86_64.whl", hash = "sha256:a99661ad8b155c3aa62ac2fdb3879df0e5a5bcb1c66f8a142f3964cd2a0bb1cb", size = 1240677, upload-time = "2025-09-08T15:35:13.761Z" },
{ url = "https://files.pythonhosted.org/packages/ec/2e/5d942c51cd7c5bb868e942fc3fc9d7cd97617d94a56db0bb40da18d0d0ef/djade-1.6.0-py3-none-win32.whl", hash = "sha256:4555387841d0ef6290ff8221ebea48321262ff1011874debd30010e5deba0ff3", size = 963337, upload-time = "2025-09-08T15:35:15.878Z" },
{ url = "https://files.pythonhosted.org/packages/73/ff/3b4a9ff45c0f8584931031d511721c27f5f4d09852611be2164b42eb74e0/djade-1.6.0-py3-none-win_amd64.whl", hash = "sha256:d6111763d133a7a85db0f6f0e59586fdbbd08e6c22238e84de730bb1a21cbab4", size = 1056597, upload-time = "2025-09-08T15:35:17.562Z" },
{ url = "https://files.pythonhosted.org/packages/32/a7/d3f58feeb1169a41c569b63cb8f23886694f0d72290cbc7eb691b083e0aa/djade-1.6.0-py3-none-win_arm64.whl", hash = "sha256:c52efaa5d64572ddc4a986d74202195c8574f969877fcfbdbea3a41718334804", size = 985920, upload-time = "2025-09-08T15:35:18.856Z" },
{ url = "https://files.pythonhosted.org/packages/e9/41/d25b88a4ff05a495aaf992793eae116b867e6a4ebda64701f737f22abf18/djade-1.7.0-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:084f505338862542343d4cabbd52a8e58a8b05cc1a0eb37d78824073485d4c8d", size = 1156096, upload-time = "2025-12-10T16:03:35.626Z" },
{ url = "https://files.pythonhosted.org/packages/bb/1f/8ca54f7d4ab2c26095f938cedb13d2508032d9eadcfd07757a46a02cc38a/djade-1.7.0-py3-none-macosx_11_0_arm64.whl", hash = "sha256:9676b72b9e9e494d977f887cda825e333e699ae7184384795d5a9e2e5d2c7f69", size = 1097385, upload-time = "2025-12-10T16:03:38.21Z" },
{ url = "https://files.pythonhosted.org/packages/f5/5d/781d7204973cd12138805d4a6b67ec7666771b66709242f6b7b6834f862b/djade-1.7.0-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:0102aaf6f1a259f2ca78839c796b130fc53868965b01fb0b8d5768c5fe142b99", size = 1142211, upload-time = "2025-12-10T16:03:39.72Z" },
{ url = "https://files.pythonhosted.org/packages/e9/aa/ba674b4a78be552f9bba496369f0a436b0eebc0bfc7c41bd250776428967/djade-1.7.0-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:db11d4563b73c3eacd2433005e9467d67c3a58c61ce6b5d821b89b124d1dd168", size = 1077205, upload-time = "2025-12-10T16:03:41.001Z" },
{ url = "https://files.pythonhosted.org/packages/0d/2d/30f9ce233e02e9e29793b8eb2784a09d5d18b8aa6bbb53374403cee39ac8/djade-1.7.0-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:6aa4659a2c3c172cf0ed35d19cb2d396248e6889b47b4ae52263be1d89f0a532", size = 1264698, upload-time = "2025-12-10T16:03:43.481Z" },
{ url = "https://files.pythonhosted.org/packages/6a/88/a5a79f2e57e13c0403a238173744d3eb08f03616f0e399a6fe88a806bf66/djade-1.7.0-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:9596896b44e25fbd897e0e8d134a108184f56c32feff8607028daa2315506f47", size = 1217375, upload-time = "2025-12-10T16:03:44.908Z" },
{ url = "https://files.pythonhosted.org/packages/dd/a2/73839c8d4f9e96ed3f4a4e6e51cc2934fdbc1f1f4ff8be1effbdb7574fe5/djade-1.7.0-py3-none-musllinux_1_1_aarch64.whl", hash = "sha256:7fbdfece645f5816adae816eed847db1905d1e14cba3da5384706adce1eefb2a", size = 1139307, upload-time = "2025-12-10T16:03:46.599Z" },
{ url = "https://files.pythonhosted.org/packages/d0/a1/462b4e004c5fc9ce9d4f93968e8ee5ba646c033f95e05fc70d8864ac3cb8/djade-1.7.0-py3-none-musllinux_1_1_x86_64.whl", hash = "sha256:51221cff697e1eaa709e375ecac331366ec68cd90567de36f4bd3daca94e8327", size = 1277128, upload-time = "2025-12-10T16:03:47.997Z" },
{ url = "https://files.pythonhosted.org/packages/0c/54/8b2fc833d1fdeb08d9e87a1db7dd8d4b64854b9d62c7867b69519c357f21/djade-1.7.0-py3-none-win32.whl", hash = "sha256:20308b03c51bd2551137f5852afb663ac5f86015321cc4f45363427f7dbd9534", size = 988134, upload-time = "2025-12-10T16:03:49.264Z" },
{ url = "https://files.pythonhosted.org/packages/f9/dd/fea876ed43f13182a6381c56dbe8daf4ed4d745228292c38ea44e85ea56d/djade-1.7.0-py3-none-win_amd64.whl", hash = "sha256:a1d41819a0409c74e0e521e856799cbce38c50a1bd5b020fa209a40f9441b499", size = 1098723, upload-time = "2025-12-10T16:03:50.788Z" },
{ url = "https://files.pythonhosted.org/packages/2c/51/438fcae17c2afa62966f0fbda3799f3bada7110d92cc3c58ebc47b636925/djade-1.7.0-py3-none-win_arm64.whl", hash = "sha256:fef26be22c5b3c15e6b8be3888ae0672aab5067d219cf2be145af22860757c08", size = 1028482, upload-time = "2025-12-10T16:03:52.098Z" },
]
[[package]]
name = "django"
version = "5.2.7"
version = "5.2.9"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "asgiref" },
{ name = "sqlparse" },
{ name = "tzdata", marker = "sys_platform == 'win32'" },
]
sdist = { url = "https://files.pythonhosted.org/packages/b1/96/bd84e2bb997994de8bcda47ae4560991084e86536541d7214393880f01a8/django-5.2.7.tar.gz", hash = "sha256:e0f6f12e2551b1716a95a63a1366ca91bbcd7be059862c1b18f989b1da356cdd", size = 10865812, upload-time = "2025-10-01T14:22:12.081Z" }
sdist = { url = "https://files.pythonhosted.org/packages/eb/1c/188ce85ee380f714b704283013434976df8d3a2df8e735221a02605b6794/django-5.2.9.tar.gz", hash = "sha256:16b5ccfc5e8c27e6c0561af551d2ea32852d7352c67d452ae3e76b4f6b2ca495", size = 10848762, upload-time = "2025-12-02T14:01:08.418Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/8f/ef/81f3372b5dd35d8d354321155d1a38894b2b766f576d0abffac4d8ae78d9/django-5.2.7-py3-none-any.whl", hash = "sha256:59a13a6515f787dec9d97a0438cd2efac78c8aca1c80025244b0fe507fe0754b", size = 8307145, upload-time = "2025-10-01T14:22:49.476Z" },
{ url = "https://files.pythonhosted.org/packages/17/b0/7f42bfc38b8f19b78546d47147e083ed06e12fc29c42da95655e0962c6c2/django-5.2.9-py3-none-any.whl", hash = "sha256:3a4ea88a70370557ab1930b332fd2887a9f48654261cdffda663fef5976bb00a", size = 8290652, upload-time = "2025-12-02T14:01:03.485Z" },
]
[[package]]
@@ -111,14 +123,14 @@ wheels = [
[[package]]
name = "django-static-jquery-ui"
version = "1.13.3.1"
version = "1.13.3.2"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "django" },
]
sdist = { url = "https://files.pythonhosted.org/packages/87/51/045015a87bf97d24ce9a1f34e664f75a8a2ea01c04ee965c632ca9843aef/django-static-jquery-ui-1.13.3.1.tar.gz", hash = "sha256:e2bd1b66f5ee604cfb4f2788398c8274c3307c547eb19e7325f9e5626f040a95", size = 1506385, upload-time = "2024-05-12T13:22:58.54Z" }
sdist = { url = "https://files.pythonhosted.org/packages/1f/b3/47e5fa5166872e7fb90b28d4ea50f30a3018b4fd2c242d56fa350c8dc901/django_static_jquery_ui-1.13.3.2.tar.gz", hash = "sha256:a15aaa58807abf808b0ff9f499bccf23edc3a8e3f0d197d9881b149b9c39e3fc", size = 1506447, upload-time = "2025-10-23T03:03:33.571Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/92/10/8e4409fe2faa8afa66937f91a8ecf671e180e6437e9198f328d6044aaa4c/django_static_jquery_ui-1.13.3.1-py3-none-any.whl", hash = "sha256:ab2f474619e03d15d5016ec83cc6ed2672411c464cf4e9e3bf046ba002f5cba7", size = 1924566, upload-time = "2024-05-12T13:22:55.787Z" },
{ url = "https://files.pythonhosted.org/packages/22/e9/b2ca4adfbbebc59ed189aadc1a71d6081dd1b659a183a50038d65c29e8f3/django_static_jquery_ui-1.13.3.2-py3-none-any.whl", hash = "sha256:8cf8efb892533b34970fceb35921470c1fbabaad4c7bb27128bf23d6b428f7e3", size = 1924641, upload-time = "2025-10-23T03:03:31.742Z" },
]
[[package]]
@@ -135,14 +147,14 @@ wheels = [
[[package]]
name = "django-upgrade"
version = "1.29.0"
version = "1.29.1"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "tokenize-rt" },
]
sdist = { url = "https://files.pythonhosted.org/packages/ee/07/900c8783c26a66aec4cc6afdfa47fdd3b0ba82436988b852412bc9c64663/django_upgrade-1.29.0.tar.gz", hash = "sha256:f981e62fbccd2bbc4f165034c5c95de60010fdf1ad2c46e7ae9a66e9baa6955a", size = 39691, upload-time = "2025-10-06T15:46:51.229Z" }
sdist = { url = "https://files.pythonhosted.org/packages/9d/dc/8242d9fbf0ae64feccbdd781fa741db5cf0521127a26ed9361e5f2f31f1b/django_upgrade-1.29.1.tar.gz", hash = "sha256:8c53b6bcd326a638a5dc908a707f26d71593bad5789b33775c90a8dc8a76afd5", size = 39638, upload-time = "2025-10-23T16:34:57.003Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/ec/16/33b4645fb10e649656354f02af5c794b26ed0ebf7b70c52bb656af5c662c/django_upgrade-1.29.0-py3-none-any.whl", hash = "sha256:05d174815ed86e5cfe91f22b941f9edc424d5f648dad9fd50b967d795b466523", size = 68922, upload-time = "2025-10-06T15:46:49.94Z" },
{ url = "https://files.pythonhosted.org/packages/f0/b0/c9fe7b4dbfcacb2402169f8189d4f7fa1609237317d714f01143d167db1c/django_upgrade-1.29.1-py3-none-any.whl", hash = "sha256:39a4d71365189ce8a5ccab534b2d1f0ed69c71bea7f1ef4c46041918d5247e64", size = 68847, upload-time = "2025-10-23T16:34:55.511Z" },
]
[[package]]
@@ -172,29 +184,32 @@ wheels = [
[[package]]
name = "elastic-transport"
version = "9.1.0"
version = "9.2.1"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "certifi" },
{ name = "sniffio" },
{ name = "urllib3" },
]
sdist = { url = "https://files.pythonhosted.org/packages/ae/1f/2d1a1790df2b75e1e1eb90d8a3fe066a47ef95e34430657447e549cc274c/elastic_transport-9.1.0.tar.gz", hash = "sha256:1590e44a25b0fe208107d5e8d7dea15c070525f3ac9baafbe4cb659cd14f073d", size = 76483, upload-time = "2025-07-24T16:41:31.017Z" }
sdist = { url = "https://files.pythonhosted.org/packages/23/0a/a92140b666afdcb9862a16e4d80873b3c887c1b7e3f17e945fc3460edf1b/elastic_transport-9.2.1.tar.gz", hash = "sha256:97d9abd638ba8aa90faa4ca1bf1a18bde0fe2088fbc8757f2eb7b299f205773d", size = 77403, upload-time = "2025-12-23T11:54:12.849Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/ef/5d/dd5a919dd887fe20a91f18faf5b4345ee3a058e483d2aa84cef0f2567e17/elastic_transport-9.1.0-py3-none-any.whl", hash = "sha256:369fa56874c74daae4ea10cbf40636d139f38f42bec0e006b9cd45a168ee7fce", size = 65142, upload-time = "2025-07-24T16:41:29.648Z" },
{ url = "https://files.pythonhosted.org/packages/2c/e6/a42b600ae8b808371f740381f6c32050cad93f870d36cc697b8b7006bf7c/elastic_transport-9.2.1-py3-none-any.whl", hash = "sha256:39e1a25e486af34ce7aa1bc9005d1c736f1b6fb04c9b64ea0604ded5a61fc1d4", size = 65327, upload-time = "2025-12-23T11:54:11.681Z" },
]
[[package]]
name = "elasticsearch"
version = "9.1.1"
version = "9.2.1"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "anyio" },
{ name = "elastic-transport" },
{ name = "python-dateutil" },
{ name = "sniffio" },
{ name = "typing-extensions" },
]
sdist = { url = "https://files.pythonhosted.org/packages/59/6a/5eecef6f1ac8005b04714405cb65971d46031bd897e47c29af86e0f87353/elasticsearch-9.1.1.tar.gz", hash = "sha256:be20acda2a97591a9a6cf4981fc398ee6fca3291cf9e7a9e52b6a9f41a46d393", size = 857802, upload-time = "2025-09-12T13:27:38.62Z" }
sdist = { url = "https://files.pythonhosted.org/packages/bc/6c/67bb17ca0035b0cac4cfbbe64e18d120203fef22da66dd4c636563a0ea63/elasticsearch-9.2.1.tar.gz", hash = "sha256:97f473418e8976611349757287ac982acf12f4e305182863d985d5a031c36830", size = 878062, upload-time = "2025-12-23T14:37:31.694Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/cf/4c/c0c95d3d881732a5d1b28e12c9be4dea5953ade71810f94565bd5bd2101a/elasticsearch-9.1.1-py3-none-any.whl", hash = "sha256:2a5c27c57ca3dd3365f665c82c9dcd8666ccfb550d5b07c688c21ec636c104e5", size = 937483, upload-time = "2025-09-12T13:27:34.948Z" },
{ url = "https://files.pythonhosted.org/packages/c0/d5/84264c29ec67f2f8129676ce11f05defb52f44e97e5f411db9a220f2aa43/elasticsearch-9.2.1-py3-none-any.whl", hash = "sha256:8665f5a0b4d29a7c2772851c05ea8a09279abb7928b7d727524613bd61d75958", size = 963593, upload-time = "2025-12-23T14:37:28.047Z" },
]
[[package]]
@@ -208,7 +223,7 @@ wheels = [
[[package]]
name = "fet-homepage"
version = "2.1.0"
version = "2.1.1"
source = { virtual = "." }
dependencies = [
{ name = "django" },
@@ -243,7 +258,7 @@ dev = [
[package.metadata]
requires-dist = [
{ name = "django" },
{ name = "django", specifier = "<6.0" },
{ name = "django-ckeditor", specifier = "<6.7.3" },
{ name = "django-environ" },
{ name = "django-filter" },
@@ -290,6 +305,15 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/1d/84/1a0f9555fd5f2b1c924ff932d99b40a0f8a6b12f6dd625e2a47f415b00ea/html2text-2025.4.15-py3-none-any.whl", hash = "sha256:00569167ffdab3d7767a4cdf589b7f57e777a5ed28d12907d8c58769ec734acc", size = 34656, upload-time = "2025-04-15T04:02:28.44Z" },
]
[[package]]
name = "idna"
version = "3.11"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/6f/6d/0703ccc57f3a7233505399edb88de3cbd678da106337b9fcde432b65ed60/idna-3.11.tar.gz", hash = "sha256:795dafcc9c04ed0c1fb032c2aa73654d8e8c5023a7df64a53f39190ada629902", size = 194582, upload-time = "2025-10-12T14:55:20.501Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/0e/61/66938bbb5fc52dbdf84594873d5b51fb1f7c7794e9c0f5bd885f30bc507b/idna-3.11-py3-none-any.whl", hash = "sha256:771a87f49d9defaf64091e6e6fe9c18d4833f140bd19464795bc32d966ca37ea", size = 71008, upload-time = "2025-10-12T14:55:18.883Z" },
]
[[package]]
name = "ldap3"
version = "2.9.1"
@@ -322,60 +346,60 @@ wheels = [
[[package]]
name = "pillow"
version = "12.0.0"
version = "12.1.0"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/5a/b0/cace85a1b0c9775a9f8f5d5423c8261c858760e2466c79b2dd184638b056/pillow-12.0.0.tar.gz", hash = "sha256:87d4f8125c9988bfbed67af47dd7a953e2fc7b0cc1e7800ec6d2080d490bb353", size = 47008828, upload-time = "2025-10-15T18:24:14.008Z" }
sdist = { url = "https://files.pythonhosted.org/packages/d0/02/d52c733a2452ef1ffcc123b68e6606d07276b0e358db70eabad7e40042b7/pillow-12.1.0.tar.gz", hash = "sha256:5c5ae0a06e9ea030ab786b0251b32c7e4ce10e58d983c0d5c56029455180b5b9", size = 46977283, upload-time = "2026-01-02T09:13:29.892Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/62/f2/de993bb2d21b33a98d031ecf6a978e4b61da207bef02f7b43093774c480d/pillow-12.0.0-cp313-cp313-ios_13_0_arm64_iphoneos.whl", hash = "sha256:0869154a2d0546545cde61d1789a6524319fc1897d9ee31218eae7a60ccc5643", size = 4045493, upload-time = "2025-10-15T18:22:25.758Z" },
{ url = "https://files.pythonhosted.org/packages/0e/b6/bc8d0c4c9f6f111a783d045310945deb769b806d7574764234ffd50bc5ea/pillow-12.0.0-cp313-cp313-ios_13_0_arm64_iphonesimulator.whl", hash = "sha256:a7921c5a6d31b3d756ec980f2f47c0cfdbce0fc48c22a39347a895f41f4a6ea4", size = 4120461, upload-time = "2025-10-15T18:22:27.286Z" },
{ url = "https://files.pythonhosted.org/packages/5d/57/d60d343709366a353dc56adb4ee1e7d8a2cc34e3fbc22905f4167cfec119/pillow-12.0.0-cp313-cp313-ios_13_0_x86_64_iphonesimulator.whl", hash = "sha256:1ee80a59f6ce048ae13cda1abf7fbd2a34ab9ee7d401c46be3ca685d1999a399", size = 3576912, upload-time = "2025-10-15T18:22:28.751Z" },
{ url = "https://files.pythonhosted.org/packages/a4/a4/a0a31467e3f83b94d37568294b01d22b43ae3c5d85f2811769b9c66389dd/pillow-12.0.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:c50f36a62a22d350c96e49ad02d0da41dbd17ddc2e29750dbdba4323f85eb4a5", size = 5249132, upload-time = "2025-10-15T18:22:30.641Z" },
{ url = "https://files.pythonhosted.org/packages/83/06/48eab21dd561de2914242711434c0c0eb992ed08ff3f6107a5f44527f5e9/pillow-12.0.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:5193fde9a5f23c331ea26d0cf171fbf67e3f247585f50c08b3e205c7aeb4589b", size = 4650099, upload-time = "2025-10-15T18:22:32.73Z" },
{ url = "https://files.pythonhosted.org/packages/fc/bd/69ed99fd46a8dba7c1887156d3572fe4484e3f031405fcc5a92e31c04035/pillow-12.0.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:bde737cff1a975b70652b62d626f7785e0480918dece11e8fef3c0cf057351c3", size = 6230808, upload-time = "2025-10-15T18:22:34.337Z" },
{ url = "https://files.pythonhosted.org/packages/ea/94/8fad659bcdbf86ed70099cb60ae40be6acca434bbc8c4c0d4ef356d7e0de/pillow-12.0.0-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:a6597ff2b61d121172f5844b53f21467f7082f5fb385a9a29c01414463f93b07", size = 8037804, upload-time = "2025-10-15T18:22:36.402Z" },
{ url = "https://files.pythonhosted.org/packages/20/39/c685d05c06deecfd4e2d1950e9a908aa2ca8bc4e6c3b12d93b9cafbd7837/pillow-12.0.0-cp313-cp313-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:0b817e7035ea7f6b942c13aa03bb554fc44fea70838ea21f8eb31c638326584e", size = 6345553, upload-time = "2025-10-15T18:22:38.066Z" },
{ url = "https://files.pythonhosted.org/packages/38/57/755dbd06530a27a5ed74f8cb0a7a44a21722ebf318edbe67ddbd7fb28f88/pillow-12.0.0-cp313-cp313-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:f4f1231b7dec408e8670264ce63e9c71409d9583dd21d32c163e25213ee2a344", size = 7037729, upload-time = "2025-10-15T18:22:39.769Z" },
{ url = "https://files.pythonhosted.org/packages/ca/b6/7e94f4c41d238615674d06ed677c14883103dce1c52e4af16f000338cfd7/pillow-12.0.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:6e51b71417049ad6ab14c49608b4a24d8fb3fe605e5dfabfe523b58064dc3d27", size = 6459789, upload-time = "2025-10-15T18:22:41.437Z" },
{ url = "https://files.pythonhosted.org/packages/9c/14/4448bb0b5e0f22dd865290536d20ec8a23b64e2d04280b89139f09a36bb6/pillow-12.0.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:d120c38a42c234dc9a8c5de7ceaaf899cf33561956acb4941653f8bdc657aa79", size = 7130917, upload-time = "2025-10-15T18:22:43.152Z" },
{ url = "https://files.pythonhosted.org/packages/dd/ca/16c6926cc1c015845745d5c16c9358e24282f1e588237a4c36d2b30f182f/pillow-12.0.0-cp313-cp313-win32.whl", hash = "sha256:4cc6b3b2efff105c6a1656cfe59da4fdde2cda9af1c5e0b58529b24525d0a098", size = 6302391, upload-time = "2025-10-15T18:22:44.753Z" },
{ url = "https://files.pythonhosted.org/packages/6d/2a/dd43dcfd6dae9b6a49ee28a8eedb98c7d5ff2de94a5d834565164667b97b/pillow-12.0.0-cp313-cp313-win_amd64.whl", hash = "sha256:4cf7fed4b4580601c4345ceb5d4cbf5a980d030fd5ad07c4d2ec589f95f09905", size = 7007477, upload-time = "2025-10-15T18:22:46.838Z" },
{ url = "https://files.pythonhosted.org/packages/77/f0/72ea067f4b5ae5ead653053212af05ce3705807906ba3f3e8f58ddf617e6/pillow-12.0.0-cp313-cp313-win_arm64.whl", hash = "sha256:9f0b04c6b8584c2c193babcccc908b38ed29524b29dd464bc8801bf10d746a3a", size = 2435918, upload-time = "2025-10-15T18:22:48.399Z" },
{ url = "https://files.pythonhosted.org/packages/f5/5e/9046b423735c21f0487ea6cb5b10f89ea8f8dfbe32576fe052b5ba9d4e5b/pillow-12.0.0-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:7fa22993bac7b77b78cae22bad1e2a987ddf0d9015c63358032f84a53f23cdc3", size = 5251406, upload-time = "2025-10-15T18:22:49.905Z" },
{ url = "https://files.pythonhosted.org/packages/12/66/982ceebcdb13c97270ef7a56c3969635b4ee7cd45227fa707c94719229c5/pillow-12.0.0-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:f135c702ac42262573fe9714dfe99c944b4ba307af5eb507abef1667e2cbbced", size = 4653218, upload-time = "2025-10-15T18:22:51.587Z" },
{ url = "https://files.pythonhosted.org/packages/16/b3/81e625524688c31859450119bf12674619429cab3119eec0e30a7a1029cb/pillow-12.0.0-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:c85de1136429c524e55cfa4e033b4a7940ac5c8ee4d9401cc2d1bf48154bbc7b", size = 6266564, upload-time = "2025-10-15T18:22:53.215Z" },
{ url = "https://files.pythonhosted.org/packages/98/59/dfb38f2a41240d2408096e1a76c671d0a105a4a8471b1871c6902719450c/pillow-12.0.0-cp313-cp313t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:38df9b4bfd3db902c9c2bd369bcacaf9d935b2fff73709429d95cc41554f7b3d", size = 8069260, upload-time = "2025-10-15T18:22:54.933Z" },
{ url = "https://files.pythonhosted.org/packages/dc/3d/378dbea5cd1874b94c312425ca77b0f47776c78e0df2df751b820c8c1d6c/pillow-12.0.0-cp313-cp313t-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:7d87ef5795da03d742bf49439f9ca4d027cde49c82c5371ba52464aee266699a", size = 6379248, upload-time = "2025-10-15T18:22:56.605Z" },
{ url = "https://files.pythonhosted.org/packages/84/b0/d525ef47d71590f1621510327acec75ae58c721dc071b17d8d652ca494d8/pillow-12.0.0-cp313-cp313t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:aff9e4d82d082ff9513bdd6acd4f5bd359f5b2c870907d2b0a9c5e10d40c88fe", size = 7066043, upload-time = "2025-10-15T18:22:58.53Z" },
{ url = "https://files.pythonhosted.org/packages/61/2c/aced60e9cf9d0cde341d54bf7932c9ffc33ddb4a1595798b3a5150c7ec4e/pillow-12.0.0-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:8d8ca2b210ada074d57fcee40c30446c9562e542fc46aedc19baf758a93532ee", size = 6490915, upload-time = "2025-10-15T18:23:00.582Z" },
{ url = "https://files.pythonhosted.org/packages/ef/26/69dcb9b91f4e59f8f34b2332a4a0a951b44f547c4ed39d3e4dcfcff48f89/pillow-12.0.0-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:99a7f72fb6249302aa62245680754862a44179b545ded638cf1fef59befb57ef", size = 7157998, upload-time = "2025-10-15T18:23:02.627Z" },
{ url = "https://files.pythonhosted.org/packages/61/2b/726235842220ca95fa441ddf55dd2382b52ab5b8d9c0596fe6b3f23dafe8/pillow-12.0.0-cp313-cp313t-win32.whl", hash = "sha256:4078242472387600b2ce8d93ade8899c12bf33fa89e55ec89fe126e9d6d5d9e9", size = 6306201, upload-time = "2025-10-15T18:23:04.709Z" },
{ url = "https://files.pythonhosted.org/packages/c0/3d/2afaf4e840b2df71344ababf2f8edd75a705ce500e5dc1e7227808312ae1/pillow-12.0.0-cp313-cp313t-win_amd64.whl", hash = "sha256:2c54c1a783d6d60595d3514f0efe9b37c8808746a66920315bfd34a938d7994b", size = 7013165, upload-time = "2025-10-15T18:23:06.46Z" },
{ url = "https://files.pythonhosted.org/packages/6f/75/3fa09aa5cf6ed04bee3fa575798ddf1ce0bace8edb47249c798077a81f7f/pillow-12.0.0-cp313-cp313t-win_arm64.whl", hash = "sha256:26d9f7d2b604cd23aba3e9faf795787456ac25634d82cd060556998e39c6fa47", size = 2437834, upload-time = "2025-10-15T18:23:08.194Z" },
{ url = "https://files.pythonhosted.org/packages/54/2a/9a8c6ba2c2c07b71bec92cf63e03370ca5e5f5c5b119b742bcc0cde3f9c5/pillow-12.0.0-cp314-cp314-ios_13_0_arm64_iphoneos.whl", hash = "sha256:beeae3f27f62308f1ddbcfb0690bf44b10732f2ef43758f169d5e9303165d3f9", size = 4045531, upload-time = "2025-10-15T18:23:10.121Z" },
{ url = "https://files.pythonhosted.org/packages/84/54/836fdbf1bfb3d66a59f0189ff0b9f5f666cee09c6188309300df04ad71fa/pillow-12.0.0-cp314-cp314-ios_13_0_arm64_iphonesimulator.whl", hash = "sha256:d4827615da15cd59784ce39d3388275ec093ae3ee8d7f0c089b76fa87af756c2", size = 4120554, upload-time = "2025-10-15T18:23:12.14Z" },
{ url = "https://files.pythonhosted.org/packages/0d/cd/16aec9f0da4793e98e6b54778a5fbce4f375c6646fe662e80600b8797379/pillow-12.0.0-cp314-cp314-ios_13_0_x86_64_iphonesimulator.whl", hash = "sha256:3e42edad50b6909089750e65c91aa09aaf1e0a71310d383f11321b27c224ed8a", size = 3576812, upload-time = "2025-10-15T18:23:13.962Z" },
{ url = "https://files.pythonhosted.org/packages/f6/b7/13957fda356dc46339298b351cae0d327704986337c3c69bb54628c88155/pillow-12.0.0-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:e5d8efac84c9afcb40914ab49ba063d94f5dbdf5066db4482c66a992f47a3a3b", size = 5252689, upload-time = "2025-10-15T18:23:15.562Z" },
{ url = "https://files.pythonhosted.org/packages/fc/f5/eae31a306341d8f331f43edb2e9122c7661b975433de5e447939ae61c5da/pillow-12.0.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:266cd5f2b63ff316d5a1bba46268e603c9caf5606d44f38c2873c380950576ad", size = 4650186, upload-time = "2025-10-15T18:23:17.379Z" },
{ url = "https://files.pythonhosted.org/packages/86/62/2a88339aa40c4c77e79108facbd307d6091e2c0eb5b8d3cf4977cfca2fe6/pillow-12.0.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:58eea5ebe51504057dd95c5b77d21700b77615ab0243d8152793dc00eb4faf01", size = 6230308, upload-time = "2025-10-15T18:23:18.971Z" },
{ url = "https://files.pythonhosted.org/packages/c7/33/5425a8992bcb32d1cb9fa3dd39a89e613d09a22f2c8083b7bf43c455f760/pillow-12.0.0-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:f13711b1a5ba512d647a0e4ba79280d3a9a045aaf7e0cc6fbe96b91d4cdf6b0c", size = 8039222, upload-time = "2025-10-15T18:23:20.909Z" },
{ url = "https://files.pythonhosted.org/packages/d8/61/3f5d3b35c5728f37953d3eec5b5f3e77111949523bd2dd7f31a851e50690/pillow-12.0.0-cp314-cp314-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6846bd2d116ff42cba6b646edf5bf61d37e5cbd256425fa089fee4ff5c07a99e", size = 6346657, upload-time = "2025-10-15T18:23:23.077Z" },
{ url = "https://files.pythonhosted.org/packages/3a/be/ee90a3d79271227e0f0a33c453531efd6ed14b2e708596ba5dd9be948da3/pillow-12.0.0-cp314-cp314-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:c98fa880d695de164b4135a52fd2e9cd7b7c90a9d8ac5e9e443a24a95ef9248e", size = 7038482, upload-time = "2025-10-15T18:23:25.005Z" },
{ url = "https://files.pythonhosted.org/packages/44/34/a16b6a4d1ad727de390e9bd9f19f5f669e079e5826ec0f329010ddea492f/pillow-12.0.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:fa3ed2a29a9e9d2d488b4da81dcb54720ac3104a20bf0bd273f1e4648aff5af9", size = 6461416, upload-time = "2025-10-15T18:23:27.009Z" },
{ url = "https://files.pythonhosted.org/packages/b6/39/1aa5850d2ade7d7ba9f54e4e4c17077244ff7a2d9e25998c38a29749eb3f/pillow-12.0.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:d034140032870024e6b9892c692fe2968493790dd57208b2c37e3fb35f6df3ab", size = 7131584, upload-time = "2025-10-15T18:23:29.752Z" },
{ url = "https://files.pythonhosted.org/packages/bf/db/4fae862f8fad0167073a7733973bfa955f47e2cac3dc3e3e6257d10fab4a/pillow-12.0.0-cp314-cp314-win32.whl", hash = "sha256:1b1b133e6e16105f524a8dec491e0586d072948ce15c9b914e41cdadd209052b", size = 6400621, upload-time = "2025-10-15T18:23:32.06Z" },
{ url = "https://files.pythonhosted.org/packages/2b/24/b350c31543fb0107ab2599464d7e28e6f856027aadda995022e695313d94/pillow-12.0.0-cp314-cp314-win_amd64.whl", hash = "sha256:8dc232e39d409036af549c86f24aed8273a40ffa459981146829a324e0848b4b", size = 7142916, upload-time = "2025-10-15T18:23:34.71Z" },
{ url = "https://files.pythonhosted.org/packages/0f/9b/0ba5a6fd9351793996ef7487c4fdbde8d3f5f75dbedc093bb598648fddf0/pillow-12.0.0-cp314-cp314-win_arm64.whl", hash = "sha256:d52610d51e265a51518692045e372a4c363056130d922a7351429ac9f27e70b0", size = 2523836, upload-time = "2025-10-15T18:23:36.967Z" },
{ url = "https://files.pythonhosted.org/packages/f5/7a/ceee0840aebc579af529b523d530840338ecf63992395842e54edc805987/pillow-12.0.0-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:1979f4566bb96c1e50a62d9831e2ea2d1211761e5662afc545fa766f996632f6", size = 5255092, upload-time = "2025-10-15T18:23:38.573Z" },
{ url = "https://files.pythonhosted.org/packages/44/76/20776057b4bfd1aef4eeca992ebde0f53a4dce874f3ae693d0ec90a4f79b/pillow-12.0.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:b2e4b27a6e15b04832fe9bf292b94b5ca156016bbc1ea9c2c20098a0320d6cf6", size = 4653158, upload-time = "2025-10-15T18:23:40.238Z" },
{ url = "https://files.pythonhosted.org/packages/82/3f/d9ff92ace07be8836b4e7e87e6a4c7a8318d47c2f1463ffcf121fc57d9cb/pillow-12.0.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:fb3096c30df99fd01c7bf8e544f392103d0795b9f98ba71a8054bcbf56b255f1", size = 6267882, upload-time = "2025-10-15T18:23:42.434Z" },
{ url = "https://files.pythonhosted.org/packages/9f/7a/4f7ff87f00d3ad33ba21af78bfcd2f032107710baf8280e3722ceec28cda/pillow-12.0.0-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:7438839e9e053ef79f7112c881cef684013855016f928b168b81ed5835f3e75e", size = 8071001, upload-time = "2025-10-15T18:23:44.29Z" },
{ url = "https://files.pythonhosted.org/packages/75/87/fcea108944a52dad8cca0715ae6247e271eb80459364a98518f1e4f480c1/pillow-12.0.0-cp314-cp314t-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:5d5c411a8eaa2299322b647cd932586b1427367fd3184ffbb8f7a219ea2041ca", size = 6380146, upload-time = "2025-10-15T18:23:46.065Z" },
{ url = "https://files.pythonhosted.org/packages/91/52/0d31b5e571ef5fd111d2978b84603fce26aba1b6092f28e941cb46570745/pillow-12.0.0-cp314-cp314t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:d7e091d464ac59d2c7ad8e7e08105eaf9dafbc3883fd7265ffccc2baad6ac925", size = 7067344, upload-time = "2025-10-15T18:23:47.898Z" },
{ url = "https://files.pythonhosted.org/packages/7b/f4/2dd3d721f875f928d48e83bb30a434dee75a2531bca839bb996bb0aa5a91/pillow-12.0.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:792a2c0be4dcc18af9d4a2dfd8a11a17d5e25274a1062b0ec1c2d79c76f3e7f8", size = 6491864, upload-time = "2025-10-15T18:23:49.607Z" },
{ url = "https://files.pythonhosted.org/packages/30/4b/667dfcf3d61fc309ba5a15b141845cece5915e39b99c1ceab0f34bf1d124/pillow-12.0.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:afbefa430092f71a9593a99ab6a4e7538bc9eabbf7bf94f91510d3503943edc4", size = 7158911, upload-time = "2025-10-15T18:23:51.351Z" },
{ url = "https://files.pythonhosted.org/packages/a2/2f/16cabcc6426c32218ace36bf0d55955e813f2958afddbf1d391849fee9d1/pillow-12.0.0-cp314-cp314t-win32.whl", hash = "sha256:3830c769decf88f1289680a59d4f4c46c72573446352e2befec9a8512104fa52", size = 6408045, upload-time = "2025-10-15T18:23:53.177Z" },
{ url = "https://files.pythonhosted.org/packages/35/73/e29aa0c9c666cf787628d3f0dcf379f4791fba79f4936d02f8b37165bdf8/pillow-12.0.0-cp314-cp314t-win_amd64.whl", hash = "sha256:905b0365b210c73afb0ebe9101a32572152dfd1c144c7e28968a331b9217b94a", size = 7148282, upload-time = "2025-10-15T18:23:55.316Z" },
{ url = "https://files.pythonhosted.org/packages/c1/70/6b41bdcddf541b437bbb9f47f94d2db5d9ddef6c37ccab8c9107743748a4/pillow-12.0.0-cp314-cp314t-win_arm64.whl", hash = "sha256:99353a06902c2e43b43e8ff74ee65a7d90307d82370604746738a1e0661ccca7", size = 2525630, upload-time = "2025-10-15T18:23:57.149Z" },
{ url = "https://files.pythonhosted.org/packages/dd/c7/2530a4aa28248623e9d7f27316b42e27c32ec410f695929696f2e0e4a778/pillow-12.1.0-cp313-cp313-ios_13_0_arm64_iphoneos.whl", hash = "sha256:7b5dd7cbae20285cdb597b10eb5a2c13aa9de6cde9bb64a3c1317427b1db1ae1", size = 4062543, upload-time = "2026-01-02T09:11:31.566Z" },
{ url = "https://files.pythonhosted.org/packages/8f/1f/40b8eae823dc1519b87d53c30ed9ef085506b05281d313031755c1705f73/pillow-12.1.0-cp313-cp313-ios_13_0_arm64_iphonesimulator.whl", hash = "sha256:29a4cef9cb672363926f0470afc516dbf7305a14d8c54f7abbb5c199cd8f8179", size = 4138373, upload-time = "2026-01-02T09:11:33.367Z" },
{ url = "https://files.pythonhosted.org/packages/d4/77/6fa60634cf06e52139fd0e89e5bbf055e8166c691c42fb162818b7fda31d/pillow-12.1.0-cp313-cp313-ios_13_0_x86_64_iphonesimulator.whl", hash = "sha256:681088909d7e8fa9e31b9799aaa59ba5234c58e5e4f1951b4c4d1082a2e980e0", size = 3601241, upload-time = "2026-01-02T09:11:35.011Z" },
{ url = "https://files.pythonhosted.org/packages/4f/bf/28ab865de622e14b747f0cd7877510848252d950e43002e224fb1c9ababf/pillow-12.1.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:983976c2ab753166dc66d36af6e8ec15bb511e4a25856e2227e5f7e00a160587", size = 5262410, upload-time = "2026-01-02T09:11:36.682Z" },
{ url = "https://files.pythonhosted.org/packages/1c/34/583420a1b55e715937a85bd48c5c0991598247a1fd2eb5423188e765ea02/pillow-12.1.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:db44d5c160a90df2d24a24760bbd37607d53da0b34fb546c4c232af7192298ac", size = 4657312, upload-time = "2026-01-02T09:11:38.535Z" },
{ url = "https://files.pythonhosted.org/packages/1d/fd/f5a0896839762885b3376ff04878f86ab2b097c2f9a9cdccf4eda8ba8dc0/pillow-12.1.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:6b7a9d1db5dad90e2991645874f708e87d9a3c370c243c2d7684d28f7e133e6b", size = 6232605, upload-time = "2026-01-02T09:11:40.602Z" },
{ url = "https://files.pythonhosted.org/packages/98/aa/938a09d127ac1e70e6ed467bd03834350b33ef646b31edb7452d5de43792/pillow-12.1.0-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:6258f3260986990ba2fa8a874f8b6e808cf5abb51a94015ca3dc3c68aa4f30ea", size = 8041617, upload-time = "2026-01-02T09:11:42.721Z" },
{ url = "https://files.pythonhosted.org/packages/17/e8/538b24cb426ac0186e03f80f78bc8dc7246c667f58b540bdd57c71c9f79d/pillow-12.1.0-cp313-cp313-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:e115c15e3bc727b1ca3e641a909f77f8ca72a64fff150f666fcc85e57701c26c", size = 6346509, upload-time = "2026-01-02T09:11:44.955Z" },
{ url = "https://files.pythonhosted.org/packages/01/9a/632e58ec89a32738cabfd9ec418f0e9898a2b4719afc581f07c04a05e3c9/pillow-12.1.0-cp313-cp313-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:6741e6f3074a35e47c77b23a4e4f2d90db3ed905cb1c5e6e0d49bff2045632bc", size = 7038117, upload-time = "2026-01-02T09:11:46.736Z" },
{ url = "https://files.pythonhosted.org/packages/c7/a2/d40308cf86eada842ca1f3ffa45d0ca0df7e4ab33c83f81e73f5eaed136d/pillow-12.1.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:935b9d1aed48fcfb3f838caac506f38e29621b44ccc4f8a64d575cb1b2a88644", size = 6460151, upload-time = "2026-01-02T09:11:48.625Z" },
{ url = "https://files.pythonhosted.org/packages/f1/88/f5b058ad6453a085c5266660a1417bdad590199da1b32fb4efcff9d33b05/pillow-12.1.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:5fee4c04aad8932da9f8f710af2c1a15a83582cfb884152a9caa79d4efcdbf9c", size = 7164534, upload-time = "2026-01-02T09:11:50.445Z" },
{ url = "https://files.pythonhosted.org/packages/19/ce/c17334caea1db789163b5d855a5735e47995b0b5dc8745e9a3605d5f24c0/pillow-12.1.0-cp313-cp313-win32.whl", hash = "sha256:a786bf667724d84aa29b5db1c61b7bfdde380202aaca12c3461afd6b71743171", size = 6332551, upload-time = "2026-01-02T09:11:52.234Z" },
{ url = "https://files.pythonhosted.org/packages/e5/07/74a9d941fa45c90a0d9465098fe1ec85de3e2afbdc15cc4766622d516056/pillow-12.1.0-cp313-cp313-win_amd64.whl", hash = "sha256:461f9dfdafa394c59cd6d818bdfdbab4028b83b02caadaff0ffd433faf4c9a7a", size = 7040087, upload-time = "2026-01-02T09:11:54.822Z" },
{ url = "https://files.pythonhosted.org/packages/88/09/c99950c075a0e9053d8e880595926302575bc742b1b47fe1bbcc8d388d50/pillow-12.1.0-cp313-cp313-win_arm64.whl", hash = "sha256:9212d6b86917a2300669511ed094a9406888362e085f2431a7da985a6b124f45", size = 2452470, upload-time = "2026-01-02T09:11:56.522Z" },
{ url = "https://files.pythonhosted.org/packages/b5/ba/970b7d85ba01f348dee4d65412476321d40ee04dcb51cd3735b9dc94eb58/pillow-12.1.0-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:00162e9ca6d22b7c3ee8e61faa3c3253cd19b6a37f126cad04f2f88b306f557d", size = 5264816, upload-time = "2026-01-02T09:11:58.227Z" },
{ url = "https://files.pythonhosted.org/packages/10/60/650f2fb55fdba7a510d836202aa52f0baac633e50ab1cf18415d332188fb/pillow-12.1.0-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:7d6daa89a00b58c37cb1747ec9fb7ac3bc5ffd5949f5888657dfddde6d1312e0", size = 4660472, upload-time = "2026-01-02T09:12:00.798Z" },
{ url = "https://files.pythonhosted.org/packages/2b/c0/5273a99478956a099d533c4f46cbaa19fd69d606624f4334b85e50987a08/pillow-12.1.0-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:e2479c7f02f9d505682dc47df8c0ea1fc5e264c4d1629a5d63fe3e2334b89554", size = 6268974, upload-time = "2026-01-02T09:12:02.572Z" },
{ url = "https://files.pythonhosted.org/packages/b4/26/0bf714bc2e73d5267887d47931d53c4ceeceea6978148ed2ab2a4e6463c4/pillow-12.1.0-cp313-cp313t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:f188d580bd870cda1e15183790d1cc2fa78f666e76077d103edf048eed9c356e", size = 8073070, upload-time = "2026-01-02T09:12:04.75Z" },
{ url = "https://files.pythonhosted.org/packages/43/cf/1ea826200de111a9d65724c54f927f3111dc5ae297f294b370a670c17786/pillow-12.1.0-cp313-cp313t-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:0fde7ec5538ab5095cc02df38ee99b0443ff0e1c847a045554cf5f9af1f4aa82", size = 6380176, upload-time = "2026-01-02T09:12:06.626Z" },
{ url = "https://files.pythonhosted.org/packages/03/e0/7938dd2b2013373fd85d96e0f38d62b7a5a262af21ac274250c7ca7847c9/pillow-12.1.0-cp313-cp313t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:0ed07dca4a8464bada6139ab38f5382f83e5f111698caf3191cb8dbf27d908b4", size = 7067061, upload-time = "2026-01-02T09:12:08.624Z" },
{ url = "https://files.pythonhosted.org/packages/86/ad/a2aa97d37272a929a98437a8c0ac37b3cf012f4f8721e1bd5154699b2518/pillow-12.1.0-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:f45bd71d1fa5e5749587613037b172e0b3b23159d1c00ef2fc920da6f470e6f0", size = 6491824, upload-time = "2026-01-02T09:12:10.488Z" },
{ url = "https://files.pythonhosted.org/packages/a4/44/80e46611b288d51b115826f136fb3465653c28f491068a72d3da49b54cd4/pillow-12.1.0-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:277518bf4fe74aa91489e1b20577473b19ee70fb97c374aa50830b279f25841b", size = 7190911, upload-time = "2026-01-02T09:12:12.772Z" },
{ url = "https://files.pythonhosted.org/packages/86/77/eacc62356b4cf81abe99ff9dbc7402750044aed02cfd6a503f7c6fc11f3e/pillow-12.1.0-cp313-cp313t-win32.whl", hash = "sha256:7315f9137087c4e0ee73a761b163fc9aa3b19f5f606a7fc08d83fd3e4379af65", size = 6336445, upload-time = "2026-01-02T09:12:14.775Z" },
{ url = "https://files.pythonhosted.org/packages/e7/3c/57d81d0b74d218706dafccb87a87ea44262c43eef98eb3b164fd000e0491/pillow-12.1.0-cp313-cp313t-win_amd64.whl", hash = "sha256:0ddedfaa8b5f0b4ffbc2fa87b556dc59f6bb4ecb14a53b33f9189713ae8053c0", size = 7045354, upload-time = "2026-01-02T09:12:16.599Z" },
{ url = "https://files.pythonhosted.org/packages/ac/82/8b9b97bba2e3576a340f93b044a3a3a09841170ab4c1eb0d5c93469fd32f/pillow-12.1.0-cp313-cp313t-win_arm64.whl", hash = "sha256:80941e6d573197a0c28f394753de529bb436b1ca990ed6e765cf42426abc39f8", size = 2454547, upload-time = "2026-01-02T09:12:18.704Z" },
{ url = "https://files.pythonhosted.org/packages/8c/87/bdf971d8bbcf80a348cc3bacfcb239f5882100fe80534b0ce67a784181d8/pillow-12.1.0-cp314-cp314-ios_13_0_arm64_iphoneos.whl", hash = "sha256:5cb7bc1966d031aec37ddb9dcf15c2da5b2e9f7cc3ca7c54473a20a927e1eb91", size = 4062533, upload-time = "2026-01-02T09:12:20.791Z" },
{ url = "https://files.pythonhosted.org/packages/ff/4f/5eb37a681c68d605eb7034c004875c81f86ec9ef51f5be4a63eadd58859a/pillow-12.1.0-cp314-cp314-ios_13_0_arm64_iphonesimulator.whl", hash = "sha256:97e9993d5ed946aba26baf9c1e8cf18adbab584b99f452ee72f7ee8acb882796", size = 4138546, upload-time = "2026-01-02T09:12:23.664Z" },
{ url = "https://files.pythonhosted.org/packages/11/6d/19a95acb2edbace40dcd582d077b991646b7083c41b98da4ed7555b59733/pillow-12.1.0-cp314-cp314-ios_13_0_x86_64_iphonesimulator.whl", hash = "sha256:414b9a78e14ffeb98128863314e62c3f24b8a86081066625700b7985b3f529bd", size = 3601163, upload-time = "2026-01-02T09:12:26.338Z" },
{ url = "https://files.pythonhosted.org/packages/fc/36/2b8138e51cb42e4cc39c3297713455548be855a50558c3ac2beebdc251dd/pillow-12.1.0-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:e6bdb408f7c9dd2a5ff2b14a3b0bb6d4deb29fb9961e6eb3ae2031ae9a5cec13", size = 5266086, upload-time = "2026-01-02T09:12:28.782Z" },
{ url = "https://files.pythonhosted.org/packages/53/4b/649056e4d22e1caa90816bf99cef0884aed607ed38075bd75f091a607a38/pillow-12.1.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:3413c2ae377550f5487991d444428f1a8ae92784aac79caa8b1e3b89b175f77e", size = 4657344, upload-time = "2026-01-02T09:12:31.117Z" },
{ url = "https://files.pythonhosted.org/packages/6c/6b/c5742cea0f1ade0cd61485dc3d81f05261fc2276f537fbdc00802de56779/pillow-12.1.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:e5dcbe95016e88437ecf33544ba5db21ef1b8dd6e1b434a2cb2a3d605299e643", size = 6232114, upload-time = "2026-01-02T09:12:32.936Z" },
{ url = "https://files.pythonhosted.org/packages/bf/8f/9f521268ce22d63991601aafd3d48d5ff7280a246a1ef62d626d67b44064/pillow-12.1.0-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:d0a7735df32ccbcc98b98a1ac785cc4b19b580be1bdf0aeb5c03223220ea09d5", size = 8042708, upload-time = "2026-01-02T09:12:34.78Z" },
{ url = "https://files.pythonhosted.org/packages/1a/eb/257f38542893f021502a1bbe0c2e883c90b5cff26cc33b1584a841a06d30/pillow-12.1.0-cp314-cp314-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:0c27407a2d1b96774cbc4a7594129cc027339fd800cd081e44497722ea1179de", size = 6347762, upload-time = "2026-01-02T09:12:36.748Z" },
{ url = "https://files.pythonhosted.org/packages/c4/5a/8ba375025701c09b309e8d5163c5a4ce0102fa86bbf8800eb0d7ac87bc51/pillow-12.1.0-cp314-cp314-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:15c794d74303828eaa957ff8070846d0efe8c630901a1c753fdc63850e19ecd9", size = 7039265, upload-time = "2026-01-02T09:12:39.082Z" },
{ url = "https://files.pythonhosted.org/packages/cf/dc/cf5e4cdb3db533f539e88a7bbf9f190c64ab8a08a9bc7a4ccf55067872e4/pillow-12.1.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:c990547452ee2800d8506c4150280757f88532f3de2a58e3022e9b179107862a", size = 6462341, upload-time = "2026-01-02T09:12:40.946Z" },
{ url = "https://files.pythonhosted.org/packages/d0/47/0291a25ac9550677e22eda48510cfc4fa4b2ef0396448b7fbdc0a6946309/pillow-12.1.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:b63e13dd27da389ed9475b3d28510f0f954bca0041e8e551b2a4eb1eab56a39a", size = 7165395, upload-time = "2026-01-02T09:12:42.706Z" },
{ url = "https://files.pythonhosted.org/packages/4f/4c/e005a59393ec4d9416be06e6b45820403bb946a778e39ecec62f5b2b991e/pillow-12.1.0-cp314-cp314-win32.whl", hash = "sha256:1a949604f73eb07a8adab38c4fe50791f9919344398bdc8ac6b307f755fc7030", size = 6431413, upload-time = "2026-01-02T09:12:44.944Z" },
{ url = "https://files.pythonhosted.org/packages/1c/af/f23697f587ac5f9095d67e31b81c95c0249cd461a9798a061ed6709b09b5/pillow-12.1.0-cp314-cp314-win_amd64.whl", hash = "sha256:4f9f6a650743f0ddee5593ac9e954ba1bdbc5e150bc066586d4f26127853ab94", size = 7176779, upload-time = "2026-01-02T09:12:46.727Z" },
{ url = "https://files.pythonhosted.org/packages/b3/36/6a51abf8599232f3e9afbd16d52829376a68909fe14efe29084445db4b73/pillow-12.1.0-cp314-cp314-win_arm64.whl", hash = "sha256:808b99604f7873c800c4840f55ff389936ef1948e4e87645eaf3fccbc8477ac4", size = 2543105, upload-time = "2026-01-02T09:12:49.243Z" },
{ url = "https://files.pythonhosted.org/packages/82/54/2e1dd20c8749ff225080d6ba465a0cab4387f5db0d1c5fb1439e2d99923f/pillow-12.1.0-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:bc11908616c8a283cf7d664f77411a5ed2a02009b0097ff8abbba5e79128ccf2", size = 5268571, upload-time = "2026-01-02T09:12:51.11Z" },
{ url = "https://files.pythonhosted.org/packages/57/61/571163a5ef86ec0cf30d265ac2a70ae6fc9e28413d1dc94fa37fae6bda89/pillow-12.1.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:896866d2d436563fa2a43a9d72f417874f16b5545955c54a64941e87c1376c61", size = 4660426, upload-time = "2026-01-02T09:12:52.865Z" },
{ url = "https://files.pythonhosted.org/packages/5e/e1/53ee5163f794aef1bf84243f755ee6897a92c708505350dd1923f4afec48/pillow-12.1.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:8e178e3e99d3c0ea8fc64b88447f7cac8ccf058af422a6cedc690d0eadd98c51", size = 6269908, upload-time = "2026-01-02T09:12:54.884Z" },
{ url = "https://files.pythonhosted.org/packages/bc/0b/b4b4106ff0ee1afa1dc599fde6ab230417f800279745124f6c50bcffed8e/pillow-12.1.0-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:079af2fb0c599c2ec144ba2c02766d1b55498e373b3ac64687e43849fbbef5bc", size = 8074733, upload-time = "2026-01-02T09:12:56.802Z" },
{ url = "https://files.pythonhosted.org/packages/19/9f/80b411cbac4a732439e629a26ad3ef11907a8c7fc5377b7602f04f6fe4e7/pillow-12.1.0-cp314-cp314t-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:bdec5e43377761c5dbca620efb69a77f6855c5a379e32ac5b158f54c84212b14", size = 6381431, upload-time = "2026-01-02T09:12:58.823Z" },
{ url = "https://files.pythonhosted.org/packages/8f/b7/d65c45db463b66ecb6abc17c6ba6917a911202a07662247e1355ce1789e7/pillow-12.1.0-cp314-cp314t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:565c986f4b45c020f5421a4cea13ef294dde9509a8577f29b2fc5edc7587fff8", size = 7068529, upload-time = "2026-01-02T09:13:00.885Z" },
{ url = "https://files.pythonhosted.org/packages/50/96/dfd4cd726b4a45ae6e3c669fc9e49deb2241312605d33aba50499e9d9bd1/pillow-12.1.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:43aca0a55ce1eefc0aefa6253661cb54571857b1a7b2964bd8a1e3ef4b729924", size = 6492981, upload-time = "2026-01-02T09:13:03.314Z" },
{ url = "https://files.pythonhosted.org/packages/4d/1c/b5dc52cf713ae46033359c5ca920444f18a6359ce1020dd3e9c553ea5bc6/pillow-12.1.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:0deedf2ea233722476b3a81e8cdfbad786f7adbed5d848469fa59fe52396e4ef", size = 7191878, upload-time = "2026-01-02T09:13:05.276Z" },
{ url = "https://files.pythonhosted.org/packages/53/26/c4188248bd5edaf543864fe4834aebe9c9cb4968b6f573ce014cc42d0720/pillow-12.1.0-cp314-cp314t-win32.whl", hash = "sha256:b17fbdbe01c196e7e159aacb889e091f28e61020a8abeac07b68079b6e626988", size = 6438703, upload-time = "2026-01-02T09:13:07.491Z" },
{ url = "https://files.pythonhosted.org/packages/b8/0e/69ed296de8ea05cb03ee139cee600f424ca166e632567b2d66727f08c7ed/pillow-12.1.0-cp314-cp314t-win_amd64.whl", hash = "sha256:27b9baecb428899db6c0de572d6d305cfaf38ca1596b5c0542a5182e3e74e8c6", size = 7182927, upload-time = "2026-01-02T09:13:09.841Z" },
{ url = "https://files.pythonhosted.org/packages/fc/f5/68334c015eed9b5cff77814258717dec591ded209ab5b6fb70e2ae873d1d/pillow-12.1.0-cp314-cp314t-win_arm64.whl", hash = "sha256:f61333d817698bdcdd0f9d7793e365ac3d2a21c1f1eb02b32ad6aefb8d8ea831", size = 2545104, upload-time = "2026-01-02T09:13:12.068Z" },
]
[[package]]
@@ -389,11 +413,11 @@ wheels = [
[[package]]
name = "pypdf"
version = "6.1.1"
version = "6.5.0"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/a6/85/4c0f12616db83c2e3ef580c3cfa98bd082e88fc8d02e136bad3bede1e3fa/pypdf-6.1.1.tar.gz", hash = "sha256:10f44d49bf2a82e54c3c5ba3cdcbb118f2a44fc57df8ce51d6fb9b1ed9bfbe8b", size = 5074507, upload-time = "2025-09-28T13:29:16.165Z" }
sdist = { url = "https://files.pythonhosted.org/packages/4d/9b/db1056a54eda8cd44f9e5128e87e1142cb328295dad92bbec0d39f251641/pypdf-6.5.0.tar.gz", hash = "sha256:9e78950906380ae4f2ce1d9039e9008098ba6366a4d9c7423c4bdbd6e6683404", size = 5277655, upload-time = "2025-12-21T11:07:19.876Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/07/ed/adae13756d9dabdddee483fc7712905bb5585fbf6e922b1a19aca3a29cd1/pypdf-6.1.1-py3-none-any.whl", hash = "sha256:7781f99493208a37a7d4275601d883e19af24e62a525c25844d22157c2e4cde7", size = 323455, upload-time = "2025-09-28T13:29:14.392Z" },
{ url = "https://files.pythonhosted.org/packages/de/db/f2e7703791a1f32532618b82789ddddb7173b9e22d97e34cc11950d8e330/pypdf-6.5.0-py3-none-any.whl", hash = "sha256:9cef8002aaedeecf648dfd9ff1ce38f20ae8d88e2534fced6630038906440b25", size = 329560, upload-time = "2025-12-21T11:07:18.173Z" },
]
[[package]]
@@ -410,40 +434,40 @@ wheels = [
[[package]]
name = "pyupgrade"
version = "3.21.0"
version = "3.21.2"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "tokenize-rt" },
]
sdist = { url = "https://files.pythonhosted.org/packages/cb/11/b08f5e4d50575c944e4ea0a86f070a1ba2c0d5a4dc42fac571a605ada78d/pyupgrade-3.21.0.tar.gz", hash = "sha256:3e63a882ec1d16f5621736d938952df3cdc2446501fb049e711415cb8d273960", size = 45210, upload-time = "2025-10-09T19:32:26.448Z" }
sdist = { url = "https://files.pythonhosted.org/packages/7f/a1/dc63caaeed232b1c58eae1b7a75f262d64ab8435882f696ffa9b58c0c415/pyupgrade-3.21.2.tar.gz", hash = "sha256:1a361bea39deda78d1460f65d9dd548d3a36ff8171d2482298539b9dc11c9c06", size = 45455, upload-time = "2025-11-19T00:39:48.012Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/a4/4a/d18f2d6b2f9b90ec7e10843ea10e4cde0dc36091782443ee923e29d653bb/pyupgrade-3.21.0-py2.py3-none-any.whl", hash = "sha256:3a200cdc537980f371b4867f1d662e37166a7869d7713f309c1c3241c310c754", size = 62638, upload-time = "2025-10-09T19:32:25.086Z" },
{ url = "https://files.pythonhosted.org/packages/16/8c/433dac11910989a90c40b10149d07ef7224232236971a562d3976790ec53/pyupgrade-3.21.2-py2.py3-none-any.whl", hash = "sha256:2ac7b95cbd176475041e4dfe8ef81298bd4654a244f957167bd68af37d52be9f", size = 62814, upload-time = "2025-11-19T00:39:46.958Z" },
]
[[package]]
name = "ruff"
version = "0.14.1"
version = "0.14.10"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/9e/58/6ca66896635352812de66f71cdf9ff86b3a4f79071ca5730088c0cd0fc8d/ruff-0.14.1.tar.gz", hash = "sha256:1dd86253060c4772867c61791588627320abcb6ed1577a90ef432ee319729b69", size = 5513429, upload-time = "2025-10-16T18:05:41.766Z" }
sdist = { url = "https://files.pythonhosted.org/packages/57/08/52232a877978dd8f9cf2aeddce3e611b40a63287dfca29b6b8da791f5e8d/ruff-0.14.10.tar.gz", hash = "sha256:9a2e830f075d1a42cd28420d7809ace390832a490ed0966fe373ba288e77aaf4", size = 5859763, upload-time = "2025-12-18T19:28:57.98Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/8d/39/9cc5ab181478d7a18adc1c1e051a84ee02bec94eb9bdfd35643d7c74ca31/ruff-0.14.1-py3-none-linux_armv6l.whl", hash = "sha256:083bfc1f30f4a391ae09c6f4f99d83074416b471775b59288956f5bc18e82f8b", size = 12445415, upload-time = "2025-10-16T18:04:48.227Z" },
{ url = "https://files.pythonhosted.org/packages/ef/2e/1226961855ccd697255988f5a2474890ac7c5863b080b15bd038df820818/ruff-0.14.1-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:f6fa757cd717f791009f7669fefb09121cc5f7d9bd0ef211371fad68c2b8b224", size = 12784267, upload-time = "2025-10-16T18:04:52.515Z" },
{ url = "https://files.pythonhosted.org/packages/c1/ea/fd9e95863124ed159cd0667ec98449ae461de94acda7101f1acb6066da00/ruff-0.14.1-py3-none-macosx_11_0_arm64.whl", hash = "sha256:d6191903d39ac156921398e9c86b7354d15e3c93772e7dbf26c9fcae59ceccd5", size = 11781872, upload-time = "2025-10-16T18:04:55.396Z" },
{ url = "https://files.pythonhosted.org/packages/1e/5a/e890f7338ff537dba4589a5e02c51baa63020acfb7c8cbbaea4831562c96/ruff-0.14.1-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:ed04f0e04f7a4587244e5c9d7df50e6b5bf2705d75059f409a6421c593a35896", size = 12226558, upload-time = "2025-10-16T18:04:58.166Z" },
{ url = "https://files.pythonhosted.org/packages/a6/7a/8ab5c3377f5bf31e167b73651841217542bcc7aa1c19e83030835cc25204/ruff-0.14.1-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:5c9e6cf6cd4acae0febbce29497accd3632fe2025c0c583c8b87e8dbdeae5f61", size = 12187898, upload-time = "2025-10-16T18:05:01.455Z" },
{ url = "https://files.pythonhosted.org/packages/48/8d/ba7c33aa55406955fc124e62c8259791c3d42e3075a71710fdff9375134f/ruff-0.14.1-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:a6fa2458527794ecdfbe45f654e42c61f2503a230545a91af839653a0a93dbc6", size = 12939168, upload-time = "2025-10-16T18:05:04.397Z" },
{ url = "https://files.pythonhosted.org/packages/b4/c2/70783f612b50f66d083380e68cbd1696739d88e9b4f6164230375532c637/ruff-0.14.1-py3-none-manylinux_2_17_ppc64.manylinux2014_ppc64.whl", hash = "sha256:39f1c392244e338b21d42ab29b8a6392a722c5090032eb49bb4d6defcdb34345", size = 14386942, upload-time = "2025-10-16T18:05:07.102Z" },
{ url = "https://files.pythonhosted.org/packages/48/44/cd7abb9c776b66d332119d67f96acf15830d120f5b884598a36d9d3f4d83/ruff-0.14.1-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:7382fa12a26cce1f95070ce450946bec357727aaa428983036362579eadcc5cf", size = 13990622, upload-time = "2025-10-16T18:05:09.882Z" },
{ url = "https://files.pythonhosted.org/packages/eb/56/4259b696db12ac152fe472764b4f78bbdd9b477afd9bc3a6d53c01300b37/ruff-0.14.1-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:dd0bf2be3ae8521e1093a487c4aa3b455882f139787770698530d28ed3fbb37c", size = 13431143, upload-time = "2025-10-16T18:05:13.46Z" },
{ url = "https://files.pythonhosted.org/packages/e0/35/266a80d0eb97bd224b3265b9437bd89dde0dcf4faf299db1212e81824e7e/ruff-0.14.1-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:cabcaa9ccf8089fb4fdb78d17cc0e28241520f50f4c2e88cb6261ed083d85151", size = 13132844, upload-time = "2025-10-16T18:05:16.1Z" },
{ url = "https://files.pythonhosted.org/packages/65/6e/d31ce218acc11a8d91ef208e002a31acf315061a85132f94f3df7a252b18/ruff-0.14.1-py3-none-manylinux_2_31_riscv64.whl", hash = "sha256:747d583400f6125ec11a4c14d1c8474bf75d8b419ad22a111a537ec1a952d192", size = 13401241, upload-time = "2025-10-16T18:05:19.395Z" },
{ url = "https://files.pythonhosted.org/packages/9f/b5/dbc4221bf0b03774b3b2f0d47f39e848d30664157c15b965a14d890637d2/ruff-0.14.1-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:5a6e74c0efd78515a1d13acbfe6c90f0f5bd822aa56b4a6d43a9ffb2ae6e56cd", size = 12132476, upload-time = "2025-10-16T18:05:22.163Z" },
{ url = "https://files.pythonhosted.org/packages/98/4b/ac99194e790ccd092d6a8b5f341f34b6e597d698e3077c032c502d75ea84/ruff-0.14.1-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:0ea6a864d2fb41a4b6d5b456ed164302a0d96f4daac630aeba829abfb059d020", size = 12139749, upload-time = "2025-10-16T18:05:25.162Z" },
{ url = "https://files.pythonhosted.org/packages/47/26/7df917462c3bb5004e6fdfcc505a49e90bcd8a34c54a051953118c00b53a/ruff-0.14.1-py3-none-musllinux_1_2_i686.whl", hash = "sha256:0826b8764f94229604fa255918d1cc45e583e38c21c203248b0bfc9a0e930be5", size = 12544758, upload-time = "2025-10-16T18:05:28.018Z" },
{ url = "https://files.pythonhosted.org/packages/64/d0/81e7f0648e9764ad9b51dd4be5e5dac3fcfff9602428ccbae288a39c2c22/ruff-0.14.1-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:cbc52160465913a1a3f424c81c62ac8096b6a491468e7d872cb9444a860bc33d", size = 13221811, upload-time = "2025-10-16T18:05:30.707Z" },
{ url = "https://files.pythonhosted.org/packages/c3/07/3c45562c67933cc35f6d5df4ca77dabbcd88fddaca0d6b8371693d29fd56/ruff-0.14.1-py3-none-win32.whl", hash = "sha256:e037ea374aaaff4103240ae79168c0945ae3d5ae8db190603de3b4012bd1def6", size = 12319467, upload-time = "2025-10-16T18:05:33.261Z" },
{ url = "https://files.pythonhosted.org/packages/02/88/0ee4ca507d4aa05f67e292d2e5eb0b3e358fbcfe527554a2eda9ac422d6b/ruff-0.14.1-py3-none-win_amd64.whl", hash = "sha256:59d599cdff9c7f925a017f6f2c256c908b094e55967f93f2821b1439928746a1", size = 13401123, upload-time = "2025-10-16T18:05:35.984Z" },
{ url = "https://files.pythonhosted.org/packages/b8/81/4b6387be7014858d924b843530e1b2a8e531846807516e9bea2ee0936bf7/ruff-0.14.1-py3-none-win_arm64.whl", hash = "sha256:e3b443c4c9f16ae850906b8d0a707b2a4c16f8d2f0a7fe65c475c5886665ce44", size = 12436636, upload-time = "2025-10-16T18:05:38.995Z" },
{ url = "https://files.pythonhosted.org/packages/60/01/933704d69f3f05ee16ef11406b78881733c186fe14b6a46b05cfcaf6d3b2/ruff-0.14.10-py3-none-linux_armv6l.whl", hash = "sha256:7a3ce585f2ade3e1f29ec1b92df13e3da262178df8c8bdf876f48fa0e8316c49", size = 13527080, upload-time = "2025-12-18T19:29:25.642Z" },
{ url = "https://files.pythonhosted.org/packages/df/58/a0349197a7dfa603ffb7f5b0470391efa79ddc327c1e29c4851e85b09cc5/ruff-0.14.10-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:674f9be9372907f7257c51f1d4fc902cb7cf014b9980152b802794317941f08f", size = 13797320, upload-time = "2025-12-18T19:29:02.571Z" },
{ url = "https://files.pythonhosted.org/packages/7b/82/36be59f00a6082e38c23536df4e71cdbc6af8d7c707eade97fcad5c98235/ruff-0.14.10-py3-none-macosx_11_0_arm64.whl", hash = "sha256:d85713d522348837ef9df8efca33ccb8bd6fcfc86a2cde3ccb4bc9d28a18003d", size = 12918434, upload-time = "2025-12-18T19:28:51.202Z" },
{ url = "https://files.pythonhosted.org/packages/a6/00/45c62a7f7e34da92a25804f813ebe05c88aa9e0c25e5cb5a7d23dd7450e3/ruff-0.14.10-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:6987ebe0501ae4f4308d7d24e2d0fe3d7a98430f5adfd0f1fead050a740a3a77", size = 13371961, upload-time = "2025-12-18T19:29:04.991Z" },
{ url = "https://files.pythonhosted.org/packages/40/31/a5906d60f0405f7e57045a70f2d57084a93ca7425f22e1d66904769d1628/ruff-0.14.10-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:16a01dfb7b9e4eee556fbfd5392806b1b8550c9b4a9f6acd3dbe6812b193c70a", size = 13275629, upload-time = "2025-12-18T19:29:21.381Z" },
{ url = "https://files.pythonhosted.org/packages/3e/60/61c0087df21894cf9d928dc04bcd4fb10e8b2e8dca7b1a276ba2155b2002/ruff-0.14.10-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:7165d31a925b7a294465fa81be8c12a0e9b60fb02bf177e79067c867e71f8b1f", size = 14029234, upload-time = "2025-12-18T19:29:00.132Z" },
{ url = "https://files.pythonhosted.org/packages/44/84/77d911bee3b92348b6e5dab5a0c898d87084ea03ac5dc708f46d88407def/ruff-0.14.10-py3-none-manylinux_2_17_ppc64.manylinux2014_ppc64.whl", hash = "sha256:c561695675b972effb0c0a45db233f2c816ff3da8dcfbe7dfc7eed625f218935", size = 15449890, upload-time = "2025-12-18T19:28:53.573Z" },
{ url = "https://files.pythonhosted.org/packages/e9/36/480206eaefa24a7ec321582dda580443a8f0671fdbf6b1c80e9c3e93a16a/ruff-0.14.10-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:4bb98fcbbc61725968893682fd4df8966a34611239c9fd07a1f6a07e7103d08e", size = 15123172, upload-time = "2025-12-18T19:29:23.453Z" },
{ url = "https://files.pythonhosted.org/packages/5c/38/68e414156015ba80cef5473d57919d27dfb62ec804b96180bafdeaf0e090/ruff-0.14.10-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:f24b47993a9d8cb858429e97bdf8544c78029f09b520af615c1d261bf827001d", size = 14460260, upload-time = "2025-12-18T19:29:27.808Z" },
{ url = "https://files.pythonhosted.org/packages/b3/19/9e050c0dca8aba824d67cc0db69fb459c28d8cd3f6855b1405b3f29cc91d/ruff-0.14.10-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:59aabd2e2c4fd614d2862e7939c34a532c04f1084476d6833dddef4afab87e9f", size = 14229978, upload-time = "2025-12-18T19:29:11.32Z" },
{ url = "https://files.pythonhosted.org/packages/51/eb/e8dd1dd6e05b9e695aa9dd420f4577debdd0f87a5ff2fedda33c09e9be8c/ruff-0.14.10-py3-none-manylinux_2_31_riscv64.whl", hash = "sha256:213db2b2e44be8625002dbea33bb9c60c66ea2c07c084a00d55732689d697a7f", size = 14338036, upload-time = "2025-12-18T19:29:09.184Z" },
{ url = "https://files.pythonhosted.org/packages/6a/12/f3e3a505db7c19303b70af370d137795fcfec136d670d5de5391e295c134/ruff-0.14.10-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:b914c40ab64865a17a9a5b67911d14df72346a634527240039eb3bd650e5979d", size = 13264051, upload-time = "2025-12-18T19:29:13.431Z" },
{ url = "https://files.pythonhosted.org/packages/08/64/8c3a47eaccfef8ac20e0484e68e0772013eb85802f8a9f7603ca751eb166/ruff-0.14.10-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:1484983559f026788e3a5c07c81ef7d1e97c1c78ed03041a18f75df104c45405", size = 13283998, upload-time = "2025-12-18T19:29:06.994Z" },
{ url = "https://files.pythonhosted.org/packages/12/84/534a5506f4074e5cc0529e5cd96cfc01bb480e460c7edf5af70d2bcae55e/ruff-0.14.10-py3-none-musllinux_1_2_i686.whl", hash = "sha256:c70427132db492d25f982fffc8d6c7535cc2fd2c83fc8888f05caaa248521e60", size = 13601891, upload-time = "2025-12-18T19:28:55.811Z" },
{ url = "https://files.pythonhosted.org/packages/0d/1e/14c916087d8598917dbad9b2921d340f7884824ad6e9c55de948a93b106d/ruff-0.14.10-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:5bcf45b681e9f1ee6445d317ce1fa9d6cba9a6049542d1c3d5b5958986be8830", size = 14336660, upload-time = "2025-12-18T19:29:16.531Z" },
{ url = "https://files.pythonhosted.org/packages/f2/1c/d7b67ab43f30013b47c12b42d1acd354c195351a3f7a1d67f59e54227ede/ruff-0.14.10-py3-none-win32.whl", hash = "sha256:104c49fc7ab73f3f3a758039adea978869a918f31b73280db175b43a2d9b51d6", size = 13196187, upload-time = "2025-12-18T19:29:19.006Z" },
{ url = "https://files.pythonhosted.org/packages/fb/9c/896c862e13886fae2af961bef3e6312db9ebc6adc2b156fe95e615dee8c1/ruff-0.14.10-py3-none-win_amd64.whl", hash = "sha256:466297bd73638c6bdf06485683e812db1c00c7ac96d4ddd0294a338c62fdc154", size = 14661283, upload-time = "2025-12-18T19:29:30.16Z" },
{ url = "https://files.pythonhosted.org/packages/74/31/b0e29d572670dca3674eeee78e418f20bdf97fa8aa9ea71380885e175ca0/ruff-0.14.10-py3-none-win_arm64.whl", hash = "sha256:e51d046cf6dda98a4633b8a8a771451107413b0f07183b2bef03f075599e44e6", size = 13729839, upload-time = "2025-12-18T19:28:48.636Z" },
]
[[package]]
@@ -465,12 +489,21 @@ wheels = [
]
[[package]]
name = "sqlparse"
version = "0.5.3"
name = "sniffio"
version = "1.3.1"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/e5/40/edede8dd6977b0d3da179a342c198ed100dd2aba4be081861ee5911e4da4/sqlparse-0.5.3.tar.gz", hash = "sha256:09f67787f56a0b16ecdbde1bfc7f5d9c3371ca683cfeaa8e6ff60b4807ec9272", size = 84999, upload-time = "2024-12-10T12:05:30.728Z" }
sdist = { url = "https://files.pythonhosted.org/packages/a2/87/a6771e1546d97e7e041b6ae58d80074f81b7d5121207425c964ddf5cfdbd/sniffio-1.3.1.tar.gz", hash = "sha256:f4324edc670a0f49750a81b895f35c3adb843cca46f0530f79fc1babb23789dc", size = 20372, upload-time = "2024-02-25T23:20:04.057Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/a9/5c/bfd6bd0bf979426d405cc6e71eceb8701b148b16c21d2dc3c261efc61c7b/sqlparse-0.5.3-py3-none-any.whl", hash = "sha256:cf2196ed3418f3ba5de6af7e82c694a9fbdbfecccdfc72e281548517081f16ca", size = 44415, upload-time = "2024-12-10T12:05:27.824Z" },
{ url = "https://files.pythonhosted.org/packages/e9/44/75a9c9421471a6c4805dbf2356f7c181a29c1879239abab1ea2cc8f38b40/sniffio-1.3.1-py3-none-any.whl", hash = "sha256:2f6da418d1f1e0fddd844478f41680e794e6051915791a034ff65e5f100525a2", size = 10235, upload-time = "2024-02-25T23:20:01.196Z" },
]
[[package]]
name = "sqlparse"
version = "0.5.5"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/90/76/437d71068094df0726366574cf3432a4ed754217b436eb7429415cf2d480/sqlparse-0.5.5.tar.gz", hash = "sha256:e20d4a9b0b8585fdf63b10d30066c7c94c5d7a7ec47c889a2d83a3caa93ff28e", size = 120815, upload-time = "2025-12-19T07:17:45.073Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/49/4b/359f28a903c13438ef59ebeee215fb25da53066db67b305c125f1c6d2a25/sqlparse-0.5.5-py3-none-any.whl", hash = "sha256:12a08b3bf3eec877c519589833aed092e2444e68240a3577e8e26148acc7b1ba", size = 46138, upload-time = "2025-12-19T07:17:46.573Z" },
]
[[package]]
@@ -493,20 +526,20 @@ wheels = [
[[package]]
name = "tzdata"
version = "2025.2"
version = "2025.3"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/95/32/1a225d6164441be760d75c2c42e2780dc0873fe382da3e98a2e1e48361e5/tzdata-2025.2.tar.gz", hash = "sha256:b60a638fcc0daffadf82fe0f57e53d06bdec2f36c4df66280ae79bce6bd6f2b9", size = 196380, upload-time = "2025-03-23T13:54:43.652Z" }
sdist = { url = "https://files.pythonhosted.org/packages/5e/a7/c202b344c5ca7daf398f3b8a477eeb205cf3b6f32e7ec3a6bac0629ca975/tzdata-2025.3.tar.gz", hash = "sha256:de39c2ca5dc7b0344f2eba86f49d614019d29f060fc4ebc8a417896a620b56a7", size = 196772, upload-time = "2025-12-13T17:45:35.667Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/5c/23/c7abc0ca0a1526a0774eca151daeb8de62ec457e77262b66b359c3c7679e/tzdata-2025.2-py2.py3-none-any.whl", hash = "sha256:1a403fada01ff9221ca8044d701868fa132215d84beb92242d9acd2147f667a8", size = 347839, upload-time = "2025-03-23T13:54:41.845Z" },
{ url = "https://files.pythonhosted.org/packages/c7/b0/003792df09decd6849a5e39c28b513c06e84436a54440380862b5aeff25d/tzdata-2025.3-py2.py3-none-any.whl", hash = "sha256:06a47e5700f3081aab02b2e513160914ff0694bce9947d6b76ebd6bf57cfc5d1", size = 348521, upload-time = "2025-12-13T17:45:33.889Z" },
]
[[package]]
name = "urllib3"
version = "2.5.0"
version = "2.6.2"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/15/22/9ee70a2574a4f4599c47dd506532914ce044817c7752a79b6a51286319bc/urllib3-2.5.0.tar.gz", hash = "sha256:3fc47733c7e419d4bc3f6b3dc2b4f890bb743906a30d56ba4a5bfa4bbff92760", size = 393185, upload-time = "2025-06-18T14:07:41.644Z" }
sdist = { url = "https://files.pythonhosted.org/packages/1e/24/a2a2ed9addd907787d7aa0355ba36a6cadf1768b934c652ea78acbd59dcd/urllib3-2.6.2.tar.gz", hash = "sha256:016f9c98bb7e98085cb2b4b17b87d2c702975664e4f060c6532e64d1c1a5e797", size = 432930, upload-time = "2025-12-11T15:56:40.252Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/a7/c2/fe1e52489ae3122415c51f387e221dd0773709bad6c6cdaa599e8a2c5185/urllib3-2.5.0-py3-none-any.whl", hash = "sha256:e6b01673c0fa6a13e374b50871808eb3bf7046c4b125b216f6bf1cc604cff0dc", size = 129795, upload-time = "2025-06-18T14:07:40.39Z" },
{ url = "https://files.pythonhosted.org/packages/6d/b9/4095b668ea3678bf6a0af005527f39de12fb026516fb3df17495a733b7f8/urllib3-2.6.2-py3-none-any.whl", hash = "sha256:ec21cddfe7724fc7cb4ba4bea7aa8e2ef36f607a4bab81aa6ce42a13dc3f03dd", size = 131182, upload-time = "2025-12-11T15:56:38.584Z" },
]
[[package]]

View File

@@ -1,11 +0,0 @@
[uwsgi]
#uid
#gid
master = true
processes = 3
#chown-socket =www-data:www-data
module = fet2020.wsgi:application
pidfile=/tmp/project-master.pid
enable-threads = true
venv=/opt/venv
socket=/tmp/app.sock

View File

@@ -1,22 +0,0 @@
== Install System from scratch ==
=== Install Docker ===
apt-get update
apt-get install \
apt-transport-https \
ca-certificates \
curl \
gnupg-agent \
software-properties-common
curl -fsSL https://download.docker.com/linux/debian/gpg | apt-key add -
add-apt-repository \
"deb [arch=amd64] https://download.docker.com/linux/debian \
$(lsb_release -cs) \
stable"
apt-get update
apt-get install docker-ce docker-ce-cli containerd.io
docker run -d -p 8000:8000 -p 9000:9000 --name=portainer --restart=always -v /var/run/docker.sock:/var/run/docker.sock -v portainer_data:/data portainer/portainer-ce

View File

@@ -1,7 +0,0 @@
#/bin/bash
export REGISTRY="docker.triton2.fet.at"
git clone https://git.fet.at/bofh/fet2020.git tmp/fet2020
# Build a development Image with Theia and all content
docker build -f Dockerfile.test -t $REGISTRY/fet2020django:latest .
docker push $REGISTRY/fet2020django
rm -rf ./tmp/fet2020

View File

@@ -1,19 +0,0 @@
docker stop fetdjango
docker container rm fetdjango
docker stop mariadb
docker container rm mariadb
docker build -t fet2020django .
docker network create fet-net
docker run --name fetdjango --network fet-net -d -p 8080:8080 \
-v /srv/deploy_1/app/fet2020:/app fet2020django
docker run --name mariadb \
--network fet-net -d -p 3306:3306 \
-v mariadb2:/var/lib/mysql \
# -e SKIP_INNODB=yes \
-e MYSQL_DATABASE=wordpressdb \
-e MYSQL_USER=wordpressuser \
-e MYSQL_PASSWORD=hguyFt6S95dgfR4ryb \
jbergstroem/mariadb-alpine
docker exec fetdjango ./initdb

View File

@@ -1,11 +0,0 @@
docker stop mysql
docker container rm mysql
docker volume rm mariadb2
docker run -d --name mysql -p 3308:3306 \
-v mariadb2:/var/lib/mysql \
-e SKIP_INNODB=no \
-e MYSQL_DATABASE=fet2020db \
-e MYSQL_USER=user \
-e MYSQL_PASSWORD=hgu \
-e MYSQL_ROOT_PASSWORD=hgu \
jbergstroem/mariadb-alpine