Compare commits

..

2 Commits
main ... dev

@ -113,9 +113,7 @@ jdav_web/celerybeat-schedule.db
# docker development database folder # docker development database folder
docker/development/db docker/development/db
docker/test/db
# docker dev and production media folders # docker dev and production media folders
docker/development/media docker/development/media
docker/production/media docker/production/media
docker/test/media

7
.gitignore vendored

@ -114,17 +114,10 @@ jdav_web/celerybeat-schedule.db
# docker environment variables # docker environment variables
docker.env docker.env
!docker/test/docker.env
# docker development database folder # docker development database folder
docker/development/db docker/development/db
docker/test/db
# docker dev and production media folders # docker dev and production media folders
docker/development/media docker/development/media
docker/production/media docker/production/media
docker/test/media
*.csv
jdav_web/static/docs

21
Jenkinsfile vendored

@ -1,21 +0,0 @@
node {
checkout scm
}
pipeline {
agent any
stages {
stage('Build') {
steps {
sh "make build-test"
}
}
stage('Test') {
steps {
sh "make test"
recordCoverage(tools: [[parser: 'COBERTURA', pattern: 'docker/test/coverage.xml']])
}
}
}
}

@ -1,8 +0,0 @@
build-test:
cd docker/test; docker compose build
test:
touch docker/test/coverage.xml
chmod 666 docker/test/coverage.xml
cd docker/test; docker compose up --abort-on-container-exit
sed -i 's/\/app\/jdav_web/jdav_web/g' docker/test/coverage.xml

@ -1,7 +1,5 @@
# kompass # kompass
[![Build Status](https://jenkins.merten.dev/buildStatus/icon?job=gitea%2Fkompass%2Fmain)](https://jenkins.merten.dev/job/gitea/job/kompass/job/main/)
This repository has the purpose to develop a webapplication that can be used by This repository has the purpose to develop a webapplication that can be used by
JDAV to send newsletters, manage user lists and keep material lists up to date. JDAV to send newsletters, manage user lists and keep material lists up to date.
As this repository is also meant to be a base for exchange during development, feel free As this repository is also meant to be a base for exchange during development, feel free

@ -7,7 +7,7 @@ WORKDIR /app
# install requirements # install requirements
COPY ./requirements.txt /app/requirements.txt COPY ./requirements.txt /app/requirements.txt
RUN pip install coverage -r requirements.txt RUN pip install -r requirements.txt
ARG GID ARG GID
ARG UID ARG UID

@ -1,3 +1,5 @@
version: "3.9"
x-kompass: x-kompass:
&kompass &kompass
image: kompass:development image: kompass:development
@ -23,7 +25,6 @@ services:
tty: true tty: true
volumes: volumes:
- ./../../jdav_web:/app/jdav_web - ./../../jdav_web:/app/jdav_web
- ./../../docs:/app/docs
- ./media:/app/media - ./media:/app/media
ports: ports:
- "8000:8000" - "8000:8000"
@ -41,5 +42,4 @@ services:
image: mariadb image: mariadb
volumes: volumes:
- ./db:/var/lib/mysql - ./db:/var/lib/mysql
- ./provision/mysql/init:/docker-entrypoint-initdb.d
env_file: docker.env env_file: docker.env

@ -33,10 +33,6 @@ cd /app
if ! [ -f /tmp/completed_initial_run ]; then if ! [ -f /tmp/completed_initial_run ]; then
echo 'Initialising kompass master container' echo 'Initialising kompass master container'
cd docs
make html
cd /app
python jdav_web/manage.py compilemessages --locale de python jdav_web/manage.py compilemessages --locale de
# python jdav_web/manage.py makemigrations # python jdav_web/manage.py makemigrations

@ -1 +0,0 @@
GRANT ALL PRIVILEGES ON test_kompass.* TO 'kompass'@'%';

@ -8,7 +8,7 @@ RUN groupadd -g 501 app && useradd -g 501 -u 501 -m -d /app app
# create static directory and set permissions, when doing this before mounting a named volume # create static directory and set permissions, when doing this before mounting a named volume
# in docker-compose.yaml, the permissions are inherited during the mount. # in docker-compose.yaml, the permissions are inherited during the mount.
RUN mkdir -p /var/www/jdav_web/static && chown -R app:app /var/www/jdav_web/static RUN mkdir -p /var/www/jdav_web/assets && chown -R app:app /var/www/jdav_web/assets
# create static directory and set permissions, when doing this before mounting a named volume # create static directory and set permissions, when doing this before mounting a named volume
# in docker-compose.yaml, the permissions are inherited during the mount. # in docker-compose.yaml, the permissions are inherited during the mount.

@ -1,3 +1,5 @@
version: "3.9"
x-kompass: x-kompass:
&kompass &kompass
image: kompass:production image: kompass:production
@ -17,16 +19,15 @@ services:
volumes: volumes:
- uwsgi_data:/tmp/uwsgi/ - uwsgi_data:/tmp/uwsgi/
- web_static:/app/static/ - web_static:/app/static/
- web_static:/var/www/jdav_web/static/ - web_static:/var/www/jdav_web/assets/
- ./media:/var/www/jdav_web/media/ - ./media:/app/media/
nginx: nginx:
build: ./nginx/ build: ./nginx/
restart: always restart: always
volumes: volumes:
- uwsgi_data:/tmp/uwsgi/ - uwsgi_data:/tmp/uwsgi/
- web_static:/var/www/jdav_web/static/:ro - web_static:/var/www/jdav_web/assets/:ro
- ./media:/var/www/jdav_web/media/:ro
ports: ports:
- "3000:80" - "3000:80"
depends_on: depends_on:

@ -7,11 +7,6 @@ cd /app
if ! [ -f completed_initial_run ]; then if ! [ -f completed_initial_run ]; then
echo 'Initialising kompass master container' echo 'Initialising kompass master container'
cd docs
make html
cp -r build/html /app/jdav_web/static/docs
cd /app
python jdav_web/manage.py collectstatic --noinput python jdav_web/manage.py collectstatic --noinput
python jdav_web/manage.py compilemessages --locale de python jdav_web/manage.py compilemessages --locale de

@ -6,18 +6,9 @@ server {
listen 80; listen 80;
server_name 127.0.0.1; server_name 127.0.0.1;
charset utf-8; charset utf-8;
error_page 502 /downtime/502.html;
location /static { location /static {
alias /var/www/jdav_web/static; alias /var/www/jdav_web/assets;
}
location /media {
alias /var/www/jdav_web/media;
}
location /downtime {
alias /var/www/jdav_web/static/downtime;
} }
location / { location / {

@ -26,9 +26,6 @@ sendfile on;
keepalive_timeout 65; keepalive_timeout 65;
# max upload size
client_max_body_size 15M;
#gzip on; #gzip on;
#include /etc/nginx/conf.d/*.conf; #include /etc/nginx/conf.d/*.conf;

@ -1,29 +0,0 @@
FROM python:3.9-bullseye
# install additional dependencies
RUN apt-get update && apt-get install -y gettext texlive texlive-fonts-extra
# create user
RUN groupadd -g 501 app && useradd -g 501 -u 501 -m -d /app app
# create static directory and set permissions, when doing this before mounting a named volume
# in docker-compose.yaml, the permissions are inherited during the mount.
RUN mkdir -p /var/www/jdav_web/static && chown -R app:app /var/www/jdav_web/static
# create static directory and set permissions, when doing this before mounting a named volume
# in docker-compose.yaml, the permissions are inherited during the mount.
RUN mkdir -p /tmp/uwsgi && chown -R app:app /tmp/uwsgi
WORKDIR /app
USER app
# add .local/bin to PATH
ENV PATH="/app/.local/bin:$PATH"
# install requirements
COPY --chown=app:app ./requirements.txt /app/requirements.txt
# we install uwsgi here to check if packages dependencies are resolved, but we don't actually
# need uwsgi in test
RUN pip install coverage uwsgi -r requirements.txt
COPY --chown=app:app . /app

@ -1,34 +0,0 @@
version: "3.9"
services:
master:
image: kompass:test
build:
context: ./../../
dockerfile: docker/test/Dockerfile
env_file: docker.env
depends_on:
- redis
- cache
- db
entrypoint: /app/docker/test/entrypoint-master.sh
volumes:
- type: bind
source: ./coverage.xml
target: /app/jdav_web/coverage.xml
cache:
restart: always
image: memcached:alpine
redis:
restart: always
image: redis:6-alpine
db:
restart: always
image: mariadb
volumes:
- ./db:/var/lib/mysql
- ./provision/mysql/init:/docker-entrypoint-initdb.d
env_file: docker.env

@ -1,28 +0,0 @@
DJANGO_ALLOWED_HOST='*'
DJANGO_BASE_URL='localhost:8000'
DJANGO_PROTOCOL='http'
EMAIL_HOST='localhost'
EMAIL_HOST_USER='test'
EMAIL_HOST_PASSWORD='password'
EMAIL_SENDING_ADDRESS='test@localhost'
DJANGO_DEPLOY=1
DJANGO_DEBUG=1
DJANGO_DATABASE_NAME='kompass'
DJANGO_DATABASE_USER='kompass'
DJANGO_DATABASE_PASSWORD='password'
DJANGO_DATABASE_HOST='db'
DJANGO_DATABASE_PORT=3306
MYSQL_ROOT_PASSWORD='secretpassword'
MYSQL_PASSWORD='password'
MYSQL_USER='kompass'
MYSQL_DATABASE='kompass'
DJANGO_SETTINGS_MODULE='jdav_web.settings'
DJANGO_STATIC_ROOT='/var/www/jdav_web/assets'
MEMCACHED_URL='cache:11211'
BROKER_URL='redis://redis:6379/0'

@ -1,42 +0,0 @@
#!/usr/bin/env bash
set -o errexit
mysql_ready() {
cd /app/jdav_web
python << END
import sys
from django.db import connections
from django.db.utils import OperationalError
db_conn = connections['default']
try:
c = db_conn.cursor()
except OperationalError:
sys.exit(-1)
else:
sys.exit(0)
END
}
until mysql_ready; do
>&2 echo 'Waiting for MySQL to become available...'
sleep 1
done
>&2 echo 'MySQL is available'
cd /app
if ! [ -f /tmp/completed_initial_run ]; then
echo 'Initialising kompass master container'
python jdav_web/manage.py compilemessages --locale de
fi
cd jdav_web
coverage run manage.py test startpage finance members -v 2
coverage xml

@ -1 +0,0 @@
GRANT ALL PRIVILEGES ON test_kompass.* TO 'kompass'@'%';

@ -1,20 +0,0 @@
# Minimal makefile for Sphinx documentation
#
# You can set these variables from the command line, and also
# from the environment for the first two.
SPHINXOPTS ?=
SPHINXBUILD ?= sphinx-build
SOURCEDIR = source
BUILDDIR = build
# Put it first so that "make" without argument is like "make help".
help:
@$(SPHINXBUILD) -M help "$(SOURCEDIR)" "$(BUILDDIR)" $(SPHINXOPTS) $(O)
.PHONY: help Makefile
# Catch-all target: route all unknown targets to Sphinx using the new
# "make mode" option. $(O) is meant as a shortcut for $(SPHINXOPTS).
%: Makefile
@$(SPHINXBUILD) -M $@ "$(SOURCEDIR)" "$(BUILDDIR)" $(SPHINXOPTS) $(O)

@ -1,35 +0,0 @@
@ECHO OFF
pushd %~dp0
REM Command file for Sphinx documentation
if "%SPHINXBUILD%" == "" (
set SPHINXBUILD=sphinx-build
)
set SOURCEDIR=source
set BUILDDIR=build
%SPHINXBUILD% >NUL 2>NUL
if errorlevel 9009 (
echo.
echo.The 'sphinx-build' command was not found. Make sure you have Sphinx
echo.installed, then set the SPHINXBUILD environment variable to point
echo.to the full path of the 'sphinx-build' executable. Alternatively you
echo.may add the Sphinx directory to PATH.
echo.
echo.If you don't have Sphinx installed, grab it from
echo.https://www.sphinx-doc.org/
exit /b 1
)
if "%1" == "" goto help
%SPHINXBUILD% -M %1 %SOURCEDIR% %BUILDDIR% %SPHINXOPTS% %O%
goto end
:help
%SPHINXBUILD% -M help %SOURCEDIR% %BUILDDIR% %SPHINXOPTS% %O%
:end
popd

@ -1,28 +0,0 @@
# Configuration file for the Sphinx documentation builder.
#
# For the full list of built-in configuration values, see the documentation:
# https://www.sphinx-doc.org/en/master/usage/configuration.html
# -- Project information -----------------------------------------------------
# https://www.sphinx-doc.org/en/master/usage/configuration.html#project-information
project = 'Kompass'
copyright = '2024, Christian Merten'
author = 'Christian Merten'
release = '2.0'
# -- General configuration ---------------------------------------------------
# https://www.sphinx-doc.org/en/master/usage/configuration.html#general-configuration
extensions = []
templates_path = ['_templates']
exclude_patterns = []
language = 'de'
# -- Options for HTML output -------------------------------------------------
# https://www.sphinx-doc.org/en/master/usage/configuration.html#options-for-html-output
html_theme = 'alabaster'
html_static_path = ['_static']

@ -1,53 +0,0 @@
.. _excursions:
Ausfahrten
==========
Neben der :ref:`Teilnehmer\*innenverwaltung <members>` ist das Abwickeln von Ausfahrten
die zweite wichtige Aufgabe des Kompass. Eine Ausfahrt für die eigene Jugendgruppe
anbieten ist neben der ganzen inhaltlichen Arbeit auch jede Menge bürokratischer Aufwand. Der Kompass
versucht dir von diesem Aufwand so viel wie möglich abzunehmen.
Konkret hilft dir der Kompass dabei
- Kriseninterventionslisten zu generieren
- Stadtjugendring oder Landesjugendplan Anträge zu erstellen
- Abrechnungen anzufertigen
.. warning::
Diese Seite ist noch im Aufbau.
Stammdaten
----------
Sobald du mit deinen Co-Jugendleiter\*innen eine Ausfahrt angedacht hast, kannst du diese im Kompass `anlegen`_.
Die bekannten Informationen trägst du schon ein, die noch unbekannten lässt du leer oder trägst
vorläufige Daten ein.
Wenn du weißt wer mitkommt, trägst du im Tab *Teilnehmer\*innen* alle ein, die zur Ausfahrt kommen.
.. _crisis-intervention-list:
Kriseninterventionsliste
------------------------
Bevor die Ausfahrt stattfindet, lässt du dir eine Kriseninterventionsliste mit allen Notfallkontakten der
Mitfahrer\*innen erstellen und schickst sie an die Geschäftsstelle.
Landesjugendplanantrag
----------------------
Möchtest du einen Landesjugendplan- oder SJR Antrag stellen? Dann trage alle Informationen für den
Seminarbericht direkt ein und lass dir den Papierkram vom Kompass erledigen.
SJR Antrag
----------
Abrechnung
----------
Im Nachhinein trägst du deine Ausgaben ein, lädst Belege hoch und reichst deine Abrechnung per Knopfdruck ein.
.. _anlegen: https://jdav-hd.de/kompassmembers/freizeit/add/
.. _Teilnehmer\*innen: https://jdav-hd.de/kompassmembers/member/

@ -1,9 +0,0 @@
Finanzen
========
Auf dieser Seite wird das Einreichen, Bearbeiten und Abwickeln von Abrechnungen
erklärt. Diese Seite ist für Finanzbeauftragte der Sektion gedacht und daher
für die meisten Benutzer\*innen des Kompass unwichtig.
.. warning::
Diese Seite ist noch im Aufbau.

@ -1,77 +0,0 @@
.. _first-steps:
Erste Schritte
==============
Wenn du zum ersten Mal den Kompass deiner Sektion benutzt ist diese
Seite der richtige Einstieg. Wir verfolgen in dieser Anleitung den Jugendleiter
Fritz Walter bei seinen ersten Schritten mit seinem Kompass. Fritz Walter leitet
die Gruppe *Kletterfüchse*.
Wie finde ich die Teilnehmer\*innen meiner Jugendgruppe?
--------------------------------------------------------
Auf der `Startseite`_ siehst du eine Auflistung der von dir geleiteten Jugendgruppen.
Klickst du auf eine der Gruppen landest du in der `Teilnehmer\*innenanzeige`_.
.. image:: images/members_changelist_filters.png
Fritz hat die Gruppe *Kletterfüchse* ausgewählt, wie du oben rechts sehen kannst.
Versuche einmal dort bei dir eine andere Gruppe auszuwählen. Falls dir keine Teilnehmer\*innen
angezeigt werden liegt das daran, dass deine *Zugriffsrechte* nicht ausreichen.
Wie ändere ich eine\*n Teilnehmer\*in meiner Jugendgruppe?
----------------------------------------------------------
Fritz möchte das eingetragene Geburtsdatum von *Lisa Lotte* ändern. Dazu klickt
er auf den entsprechenden Eintrag, ändert das Geburtsdatum und klickt auf *Speichern*.
.. note::
Nicht alle Einträge in der `Teilnehmer\*innenanzeige`_ sind klickbar. Das liegt daran,
dass du manche Teilnehmer\*innen zwar sehen, aber nicht ihre Details einsehen kannst.
Manche Einträge wiederum kannst du einsehen, aber nicht bearbeiten.
Probier doch einmal aus deinen eigenen Eintrag zu ändern. Sicherlich gibt es einige
Felder, die nicht ausgefüllt oder nicht mehr aktuell sind.
Wie schicke ich eine E-Mail an meine Gruppe?
--------------------------------------------
Nachdem Fritz die Daten seiner Gruppe auf den neusten Stand gebracht hat, möchte er nun
eine E-Mail über die bevorstehende Hallenübernachtung an seine Gruppe schreiben. Dazu
geht er zurück auf die `Startseite`_ und wählt `Nachricht verfassen`_ aus.
Als Empfänger wählt er im Feld *An Gruppe* die *Kletterfüchse* aus. Damit seine
Co-Jugendleiterin Julia auch die Antworten erhält, wählt er im Feld
*Antwort an Teilnehmer* sowohl sich selbst, als auch Julia aus. Schließlich
klickt er auf *Speichern und Email senden*, um die Nachricht zu verschicken.
.. note::
Es kann sein, dass über den Kompass verschickte E-Mails nur verzögert ankommen. Das
liegt daran, dass pro Minute stets nur 10 E-Mails verschickt werden um Stau
zu verhindern.
Probier doch mal aus dir selbst eine Nachricht zu schicken. Wähle einfach im Feld
*An Teilnehmer* dich selbst aus.
Wie organisiere ich eine Ausfahrt?
----------------------------------
Nun da Fritz seine Gruppe zur Hallenübernachtung eingeladen hat, möchte er die
Ausfahrt auch im Kompass anlegen. Dazu navigiert er zurück zur `Startseite`_ und wählt
`Ausfahrten`_ aus.
Dort wählt er oben rechts *Ausfahrt hinzufügen* aus und füllt die verschiedenen Felder
aus. Im Reiter *Teilnehmer* trägt er bereits Julia und sich selbst ein, die stehen ja
schließlich schon fest. Schließlich speichert er die Ausfahrt mit *Sichern*.
Wie geht es weiter?
-------------------
Nun hat Fritz den Bürokratiekram für heute erledigt. Du willst noch mehr wissen? Dann
geh zurück zum :ref:`index`.
.. _Startseite: https://jdav-hd.de/kompass
.. _Teilnehmer\*innenanzeige: https://jdav-hd.de/kompassmembers/member/
.. _Nachricht verfassen: https://jdav-hd.de/kompassmailer/message/add/
.. _Ausfahrten: https://jdav-hd.de/kompassmembers/freizeit/

Binary file not shown.

Before

Width:  |  Height:  |  Size: 6.6 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 19 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 32 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 12 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 4.1 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 4.8 KiB

@ -1,43 +0,0 @@
.. Kompass documentation master file, created by
sphinx-quickstart on Sun Nov 24 18:37:20 2024.
You can adapt this file completely to your liking, but it should at least
contain the root `toctree` directive.
=======
Kompass
=======
Der Kompass ist dein Kompass in der Jugendarbeit in deiner JDAV Sektion. Wenn du das
erste mal hier bist, schau doch mal :ref:`first-steps` an.
Was ist der Kompass?
--------------------
Der Kompass ist eine Verwaltungsplattform für die tägliche Jugendarbeit in der JDAV.
Die wichtigsten Funktionen sind
- Verwaltung von Teilnehmer\*innen von Jugendgruppen
- Organisation von Ausfahrten
- Abwicklung von Abrechnungen
- Senden von E-Mails
Neben diesen Funktionen für die tägliche Arbeit, automatisiert der Kompass die
Aufnahme von neuen Mitgliedern und die Pflege der Daten durch
- Wartelistenverwaltung
- Registrierung neuer Mitglieder
- Rückmeldeverfahren
.. _index:
Inhaltsverzeichnis
------------------
.. toctree::
:maxdepth: 2
getstarted
members
excursions
waitinglist
finance

@ -1,170 +0,0 @@
.. _members:
Jugendgruppenverwaltung
=======================
Das wichtigste Objekt im Kompass ist ein\*e Teilnehmer\*in. Hier meint ein\*e Teilnehmer\*in ein im
Kompass hinterlegtes Mitglied der JDAV deiner Sektion, das heißt ob 5-jähriges Jugendgruppenkind,
langgediente\*r Jugendleiter\*in oder frischgebackene\*r Jugendreferent\*in, alle haben
einen Eintrag als Teilnehmer\*in im Kompass. Insbesondere heißt das, dass auch du selbst hier einen
Eintrag hast.
Der Startpunkt der Teilnehmer\*innenverwaltung ist der Abschnitt `Meine Jugendgruppe`_. Hier siehst du
in der Regel zwei Menüpunkte:
- Teilnehmer\*innen
- Ausfahrten
In diesem Abschnitt geht es nur um den ersten Menüpunkt. Falls du etwas über den zweiten Menüpunkt
lernen möchtest, kannst du zu :ref:`excursions` springen.
.. note::
Falls du ein Amt in deiner Sektion ausübst und zum Beispiel für Jugendgruppenkoordination
oder die Verwaltung der Warteliste zuständig ist, siehst du hier noch mehr Punkte. Mehr
Informationen dazu findest du unter :ref:`waitinglist`.
Teilnehmer\*innen Übersicht
---------------------------
Um eine Übersicht über alle Teilnehmer\*innen zu bekommen, klicke auf `Teilnehmer\*innen`_. Hier siehst du
nun alle Mitglieder, für die du die einfachen Anzeigeberechtigungen hast, das heißt deren Namen du sehen darfst.
Typischerweise sind das die Gruppenkinder deiner Jugendgruppe, aber vielleicht noch zusätzlich alle Mitglieder
des Jugendausschuss.
Wie sehe ich meine Gruppenkinder?
^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
Oberhalb der großen Auflistung mit allen Teilnehmer\*innen siehst du verschiedene Auswahlfelder.
Eines davon heißt *Nach Gruppe*. Wenn du dort drauf klickst, kannst du die Ansicht nach einer Gruppe
filtern.
.. image:: images/members_changelist_group_filter.png
In der selben Zeile siehst du noch weitere Filtermöglichkeiten.
Ich möchte nach Alter sortieren, wie geht das?
^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
Standardmäßig ist die Teilnehmer\*innenanzeige nach Nachname sortiert, wie du im folgenden Bild an dem
kleinen Pfeil erkennen kannst:
.. image:: images/members_changelist_sorting.png
Um zum Beispiel nach Geburtsdatum zu sortieren, klicke auf die Spalte *Geburtsdatum*. Wenn du die Reihenfolge
(das heißt von jung nach alt oder von alt nach jung), klicke auf den kleinen Pfeil im *Geburtsdatum* Reiter.
Wieso sehe ich nicht alle meine Gruppenkinder?
^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
Hast du deine Gruppe ausgewählt und siehst trotzdem nicht alle deine Gruppenkinder auf einer Seite?
Dann liegt das vermutlich daran, dass deine Gruppe mehr als 25 Teilnehmer\*innen hat. Chapeau!
In diesem Fall kannst du unten Rechts auf der Seite zwischen den verschiedenen Seiten auswählen oder
alle auf einmal anzeigen lassen:
.. image:: images/members_changelist_pages.png
.. _Meine Jugendgruppe: https://jdav-hd.de/kompassmembers
.. _Teilnehmer\*innen: https://jdav-hd.de/kompassmembers/member/
Teilnehmer\*in Detailansicht
----------------------------
Möchtest du eine\*n Teilnehmer\*in im Detail ansehen, um zum Beispiel Personendaten, wie die Anschrift
nachzuschauen oder eine Änderung an den Daten machen, klicke auf den entsprechenden Eintrag in der Liste.
Die nun folgende Seite kann auf den ersten Blick ein wenig erschlagen, daher dröseln wir hier die wichtigsten
Punkte auf. Zunächst ist die Seite in mehrere Reiter unterteilt:
.. image:: images/members_change_tabs.png
Diese sind
- Allgemein: wichtigste Informationen wie Name und E-Mail Adresse
- Kontaktinformationen: Anschrift, Kontodaten (für Jugendleiter\*innen beim Abwickeln von Ausfahrten)
- Fähigkeiten: z.B. alpine Erfahrungen
- Sonstiges: z.B. medizinische Daten
- Notfallkontakte: Liste mit Namen und mindestens Telefonnummern. Mehr Informationen
unter :ref:`emergency-contacts`.
- Fortbildungen: eine Liste von besuchten Fortbildungen.
.. note::
Der Reiter *Fortbildungen* wird nur auf deiner Seite angezeigt, das heißt falls du eines deiner
Gruppenkinder ausgewählt hast, ist dieser Reiter nicht vorhanden.
Wieso kann ich nicht alle Felder ändern?
^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
Manche Felder werden dir nur angezeigt, sind aber nicht änderbar. Das sind entweder
- geschützte Felder, für die du besondere Berechtigungen benötigst um sie zu ändern
(z.B. das *Gruppe* Feld). Um diese Felder zu ändern, wende dich an deine\*n Jugendreferent\*in
für Jugendkoordination. Oder,
- automatisch berechnete Felder wie zum Beispiel das *Rückgemeldet* Feld.
Wieso haben manche Einträge in der Teilnehmer\*innenübersicht keinen Link?
^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
Die Teilnehmer\*innen die dir in der Übersicht angezeigt werden sind diejenigen für die du
einfache Ansichtberechtigungen hast. Um die Personendetails eines\*einer Teilnehmer\*in einzusehen,
benötigst du normale Ansichtberechtigungen. Falls du diese nicht hast, wird anstatt des Links
in der Übersicht nur der Name angezeigt.
Falls du denkst, dass du eine\*n Teilnehmer\*in einsehen können solltest, aber es nicht kannst, melde
dich gerne bei deine\*r Jugendreferent\*in für Jugendkoordination.
.. _echo:
Rückmeldung
-----------
Damit die Teilnehmer\*innendaten im Kompass aktuell bleiben, kannst du jederzeit deine Gruppenkinder
zu einer Rückmeldung auffordern. Dazu wählst du in der Teilnehmer\*innenübersicht alle
Teilnehmer\*innen aus, die du zur Rückmeldung auffordern möchtest,
.. image:: images/members_changelist_action.png
und wählst dann im Menü unten links *Rückmeldungsaufforderungen an ausgewählte Teilnehmer\*innen verschicken*
aus. Um die Aufforderungen zu verschicken, musst du dann nur noch auf *Ausführen* klicken.
Was passiert nach der Aufforderung zur Rückmeldung?
^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
Der\*die ausgewählte Teilnehmer\*in erhält eine E-Mail mit einem Link. Dieser Link führt auf eine
Seite auf der die Person ihr Geburtsdatum eingeben muss.
.. note::
Das Geburtsdatumsformat ist TT.MM.JJJJ, also wenn Peter am
1.4.1999 geboren ist, müsste er *01.04.1999* eingeben.
Nach erfolgreich eingegebenem Geburtsdatum, wird die Person auf ein Formular mit ihren Daten weitergeleitet.
Dann einfach prüfen, gegebenenfalls aktualisieren und schließlich speichern. Der Link ist
immer 30 Tage lang gültig und kann in dieser Zeit auch beliebig oft benutzt werden.
Klingt alles noch abstrakt? Dann fordere doch mal dich selbst zur Rückmeldung auf und probiere es aus.
.. _emergency-contacts:
Notfallkontakte
---------------
Im Notfall helfen uns die Anschrift oder Telefonnummer einer\*eines Teilnehmer\*in nicht weiter. Stattdessen
benötigen wir Kontaktdaten von Personen, die wir im Notfall kontaktieren können. Diese können
im Reiter *Notfallkontakte* gepflegt werden. Bei der initialen Registrierung muss jede\*r Teilnehmer\*in
mindestens einen Notfallkontakt angeben.
.. note::
Bei vielen Teilnehmer\*innen sind keine Notfallkontakte eingetragen. Das liegt dann vermutlich daran,
dass die aus einem anderen System migriert wurden und daher nicht verfügbar sind.
Bei der regelmäßigen :ref:`echo` werden die Notfallkontakte ebenfalls abgefragt. Falls
du bei einem deiner Gruppenkinder feststellst, dass die Notfallkontakte fehlen
oder nicht mehr aktuell sind, trage das so schnell wie möglich nach oder benutze die :ref:`echo`.
Was bringen mir die Notfallkontakte im Kompass?
^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
Passiert ein Notfall auf einer Ausfahrt, wirst du natürlich nicht immer die Möglichkeit
haben im Kompass die Notfallkontakte herauszusuchen. Daher kannst du dir zu jeder Ausfahrt
eine :ref:`crisis-intervention-list` generieren lassen, die zu allen Teilnehmer\*innen deiner Ausfahrt
auch alle Notfallkontakte auflistet.

@ -1,10 +0,0 @@
.. _waitinglist:
Warteliste und neue Mitglieder
==============================
Hier wird die Warteliste erklärt und verschiedene Möglichkeiten erläutert, wie
neue Teilnehmer\*innen angelegt werden können.
.. warning::
Diese Seite ist noch im Aufbau.

@ -1,226 +0,0 @@
import copy
from django.contrib.auth import get_permission_codename
from django.core.exceptions import PermissionDenied
from django.contrib import admin, messages
from django.utils.translation import gettext_lazy as _
from django.http import HttpResponse, HttpResponseRedirect
from django.urls import path, reverse
from django.db import models
from django.contrib.admin import helpers, widgets
import rules.contrib.admin
from rules.permissions import perm_exists
class FieldPermissionsAdminMixin:
field_change_permissions = {}
field_view_permissions = {}
def may_view_field(self, field_desc, request, obj=None):
if not type(field_desc) is tuple:
field_desc = (field_desc,)
for fd in field_desc:
if fd not in self.field_view_permissions:
continue
if not request.user.has_perm(self.field_view_permissions[fd]):
return False
return True
def get_fieldsets(self, request, obj=None):
fieldsets = super(FieldPermissionsAdminMixin, self).get_fieldsets(request, obj)
d = []
for title, attrs in fieldsets:
allowed = [f for f in attrs['fields'] if self.may_view_field(f, request, obj)]
if len(allowed) == 0:
continue
d.append((title, dict(attrs, **{'fields': allowed})))
return d
def get_fields(self, request, obj=None):
fields = super(FieldPermissionsAdminMixin, self).get_fields(request, obj)
return [fd for fd in fields if self.may_view_field(fd, request, obj)]
def get_readonly_fields(self, request, obj=None):
readonly_fields = super(FieldPermissionsAdminMixin, self).get_readonly_fields(request, obj)
return list(readonly_fields) +\
[fd for fd, perm in self.field_change_permissions.items() if not request.user.has_perm(perm)]
class ChangeViewAdminMixin:
def change_view(self, request, object_id, form_url="", extra_context=None):
try:
return super(ChangeViewAdminMixin, self).change_view(request, object_id,
form_url=form_url,
extra_context=extra_context)
except PermissionDenied:
opts = self.opts
obj = self.model.objects.get(pk=object_id)
messages.error(request,
_("You are not allowed to view %(name)s.") % {'name': str(obj)})
return HttpResponseRedirect(reverse('admin:%s_%s_changelist' % (opts.app_label, opts.model_name)))
class FilteredQuerysetAdminMixin:
def get_queryset(self, request):
"""
Return a QuerySet of all model instances that can be edited by the
admin site. This is used by changelist_view.
"""
qs = self.model._default_manager.get_queryset()
ordering = self.get_ordering(request)
if ordering:
qs = qs.order_by(*ordering)
queryset = qs
list_global_perm = '%s.list_global_%s' % (self.opts.app_label, self.opts.model_name)
if request.user.has_perm(list_global_perm):
view_global_perm = '%s.view_global_%s' % (self.opts.app_label, self.opts.model_name)
if request.user.has_perm(view_global_perm):
return queryset
if hasattr(request.user, 'member'):
return request.user.member.annotate_view_permission(queryset, model=self.model)
return queryset.annotate(_viewable=models.Value(False))
if not hasattr(request.user, 'member'):
return self.model.objects.none()
return request.user.member.filter_queryset_by_permissions(queryset, annotate=True, model=self.model)
#class ObjectPermissionsInlineModelAdminMixin(rules.contrib.admin.ObjectPermissionsInlineModelAdminMixin):
class CommonAdminMixin(FieldPermissionsAdminMixin, ChangeViewAdminMixin, FilteredQuerysetAdminMixin):
def has_add_permission(self, request, obj=None):
assert obj is None
opts = self.opts
codename = get_permission_codename("add_global", opts)
perm = "%s.%s" % (opts.app_label, codename)
return request.user.has_perm(perm, obj)
def has_view_permission(self, request, obj=None):
opts = self.opts
if obj is None:
codename = get_permission_codename("view", opts)
else:
codename = get_permission_codename("view_obj", opts)
perm = "%s.%s" % (opts.app_label, codename)
if perm_exists(perm):
return request.user.has_perm(perm, obj)
else:
return self.has_change_permission(request, obj)
def has_change_permission(self, request, obj=None):
opts = self.opts
if obj is None:
codename = get_permission_codename("view", opts)
else:
codename = get_permission_codename("change_obj", opts)
return request.user.has_perm("%s.%s" % (opts.app_label, codename), obj)
def has_delete_permission(self, request, obj=None):
opts = self.opts
if obj is None:
codename = get_permission_codename("delete_global", opts)
else:
codename = get_permission_codename("delete_obj", opts)
return request.user.has_perm("%s.%s" % (opts.app_label, codename), obj)
def formfield_for_dbfield(self, db_field, request, **kwargs):
"""
COPIED from django to disable related actions
Hook for specifying the form Field instance for a given database Field
instance.
If kwargs are given, they're passed to the form Field's constructor.
"""
# If the field specifies choices, we don't need to look for special
# admin widgets - we just need to use a select widget of some kind.
if db_field.choices:
return self.formfield_for_choice_field(db_field, request, **kwargs)
# ForeignKey or ManyToManyFields
if isinstance(db_field, (models.ForeignKey, models.ManyToManyField)):
# Combine the field kwargs with any options for formfield_overrides.
# Make sure the passed in **kwargs override anything in
# formfield_overrides because **kwargs is more specific, and should
# always win.
if db_field.__class__ in self.formfield_overrides:
kwargs = {**self.formfield_overrides[db_field.__class__], **kwargs}
# Get the correct formfield.
if isinstance(db_field, models.ForeignKey):
formfield = self.formfield_for_foreignkey(db_field, request, **kwargs)
elif isinstance(db_field, models.ManyToManyField):
formfield = self.formfield_for_manytomany(db_field, request, **kwargs)
# For non-raw_id fields, wrap the widget with a wrapper that adds
# extra HTML -- the "add other" interface -- to the end of the
# rendered output. formfield can be None if it came from a
# OneToOneField with parent_link=True or a M2M intermediary.
#if formfield and db_field.name not in self.raw_id_fields:
# formfield.widget = widgets.RelatedFieldWidgetWrapper(
# formfield.widget,
# db_field.remote_field,
# self.admin_site,
# )
return formfield
# If we've got overrides for the formfield defined, use 'em. **kwargs
# passed to formfield_for_dbfield override the defaults.
for klass in db_field.__class__.mro():
if klass in self.formfield_overrides:
kwargs = {**copy.deepcopy(self.formfield_overrides[klass]), **kwargs}
return db_field.formfield(**kwargs)
# For any other type of field, just call its formfield() method.
return db_field.formfield(**kwargs)
class CommonAdminInlineMixin(CommonAdminMixin):
def has_add_permission(self, request, obj):
#assert obj is not None
if obj is None:
return True
if obj.pk is None:
return True
codename = get_permission_codename("add_obj", self.opts)
return request.user.has_perm('%s.%s' % (self.opts.app_label, codename), obj)
def has_view_permission(self, request, obj=None): # pragma: no cover
if obj is None:
return True
if obj.pk is None:
return True
opts = self.opts
if obj is None:
codename = get_permission_codename("view", opts)
else:
codename = get_permission_codename("view_obj", opts)
perm = "%s.%s" % (opts.app_label, codename)
if perm_exists(perm):
return request.user.has_perm(perm, obj)
else:
return self.has_change_permission(request, obj)
def has_change_permission(self, request, obj=None): # pragma: no cover
if obj is None:
return True
if obj.pk is None:
return True
opts = self.opts
if opts.auto_created:
for field in opts.fields:
if field.rel and field.rel.to != self.parent_model:
opts = field.rel.to._meta
break
codename = get_permission_codename("change_obj", opts)
return request.user.has_perm("%s.%s" % (opts.app_label, codename), obj)
def has_delete_permission(self, request, obj=None): # pragma: no cover
if obj is None:
return True
if obj.pk is None:
return True
if self.opts.auto_created:
return self.has_change_permission(request, obj)
return super().has_delete_permission(request, obj)

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

@ -1,10 +0,0 @@
from django.db import models
from rules.contrib.models import RulesModelBase, RulesModelMixin
# Create your models here.
class CommonModel(models.Model, RulesModelMixin, metaclass=RulesModelBase):
class Meta:
abstract = True
default_permissions = (
'add_global', 'change_global', 'view_global', 'delete_global', 'list_global', 'view',
)

@ -1,18 +0,0 @@
from django.contrib.auth import get_permission_codename
import rules.contrib.admin
import rules
def memberize_user(func):
def inner(user, other):
if not hasattr(user, 'member'):
return False
return func(user.member, other)
return inner
def has_global_perm(name):
@rules.predicate
def pred(user, obj):
return user.has_perm(name)
return pred

@ -1,9 +0,0 @@
from django import template
from django.conf import settings
register = template.Library()
# settings value
@register.simple_tag
def settings_value(name):
return getattr(settings, name, "")

@ -1,3 +0,0 @@
from django.test import TestCase
# Create your tests here.

@ -1,3 +0,0 @@
from django.shortcuts import render
# Create your views here.

@ -8,13 +8,8 @@ from django.utils.translation import gettext_lazy as _
from django.shortcuts import render from django.shortcuts import render
from django.conf import settings from django.conf import settings
from contrib.admin import CommonAdminInlineMixin, CommonAdminMixin
from utils import get_member
from rules.contrib.admin import ObjectPermissionsModelAdmin
from .models import Ledger, Statement, Receipt, Transaction, Bill, StatementSubmitted, StatementConfirmed,\ from .models import Ledger, Statement, Receipt, Transaction, Bill, StatementSubmitted, StatementConfirmed,\
StatementUnSubmitted, BillOnStatementProxy StatementUnSubmitted
@admin.register(Ledger) @admin.register(Ledger)
@ -22,8 +17,8 @@ class LedgerAdmin(admin.ModelAdmin):
search_fields = ('name', ) search_fields = ('name', )
class BillOnStatementInline(CommonAdminInlineMixin, admin.TabularInline): class BillOnStatementInline(admin.TabularInline):
model = BillOnStatementProxy model = Bill
extra = 0 extra = 0
sortable_options = [] sortable_options = []
fields = ['short_description', 'explanation', 'amount', 'paid_by', 'proof'] fields = ['short_description', 'explanation', 'amount', 'paid_by', 'proof']
@ -31,11 +26,16 @@ class BillOnStatementInline(CommonAdminInlineMixin, admin.TabularInline):
TextField: {'widget': Textarea(attrs={'rows': 1, 'cols': 40})} TextField: {'widget': Textarea(attrs={'rows': 1, 'cols': 40})}
} }
def get_readonly_fields(self, request, obj=None):
if obj is not None and obj.submitted:
return self.fields
return super(BillOnStatementInline, self).get_readonly_fields(request, obj)
@admin.register(StatementUnSubmitted) @admin.register(StatementUnSubmitted)
class StatementUnSubmittedAdmin(CommonAdminMixin, admin.ModelAdmin): class StatementUnSubmittedAdmin(admin.ModelAdmin):
fields = ['short_description', 'explanation', 'excursion', 'submitted'] fields = ['short_description', 'explanation', 'excursion', 'submitted']
list_display = ['__str__', 'excursion', 'created_by'] list_display = ['__str__', 'excursion']
inlines = [BillOnStatementInline] inlines = [BillOnStatementInline]
def save_model(self, request, obj, form, change): def save_model(self, request, obj, form, change):
@ -43,6 +43,16 @@ class StatementUnSubmittedAdmin(CommonAdminMixin, admin.ModelAdmin):
obj.created_by = request.user.member obj.created_by = request.user.member
super().save_model(request, obj, form, change) super().save_model(request, obj, form, change)
def get_queryset(self, request):
queryset = super().get_queryset(request)
if request.user.has_perm('members.may_list_all_statements'):
return queryset
if not hasattr(request.user, 'member'):
return Member.objects.none()
return queryset.filter(Q(created_by=request.user.member) | Q(excursion__jugendleiter=request.user.member))
def get_readonly_fields(self, request, obj=None): def get_readonly_fields(self, request, obj=None):
readonly_fields = ['submitted', 'excursion'] readonly_fields = ['submitted', 'excursion']
if obj is not None and obj.submitted: if obj is not None and obj.submitted:
@ -99,7 +109,7 @@ class TransactionOnSubmittedStatementInline(admin.TabularInline):
class BillOnSubmittedStatementInline(BillOnStatementInline): class BillOnSubmittedStatementInline(BillOnStatementInline):
model = BillOnStatementProxy model = Bill
extra = 0 extra = 0
sortable_options = [] sortable_options = []
fields = ['short_description', 'explanation', 'amount', 'paid_by', 'proof', 'costs_covered'] fields = ['short_description', 'explanation', 'amount', 'paid_by', 'proof', 'costs_covered']
@ -119,16 +129,6 @@ class StatementSubmittedAdmin(admin.ModelAdmin):
inlines = [BillOnSubmittedStatementInline, TransactionOnSubmittedStatementInline] inlines = [BillOnSubmittedStatementInline, TransactionOnSubmittedStatementInline]
def has_add_permission(self, request, obj=None): def has_add_permission(self, request, obj=None):
# Submitted statements should not be added directly, but instead be created
# as unsubmitted statements and then submitted.
return False
def has_change_permission(self, request, obj=None):
return request.user.has_perm('finance.process_statementsubmitted')
def has_delete_permission(self, request, obj=None):
# Submitted statements should not be deleted. Instead they can be rejected
# and then deleted as unsubmitted statements.
return False return False
def get_readonly_fields(self, request, obj=None): def get_readonly_fields(self, request, obj=None):
@ -213,20 +213,32 @@ class StatementSubmittedAdmin(admin.ModelAdmin):
messages.error(request, messages.error(request,
_("%(name)s already has transactions. Please delete them first, if you want to generate new ones") % {'name': str(statement)}) _("%(name)s already has transactions. Please delete them first, if you want to generate new ones") % {'name': str(statement)})
else: else:
success = statement.generate_transactions() statement.generate_transactions()
if success:
messages.success(request, messages.success(request,
_("Successfully generated transactions for %(name)s") % {'name': str(statement)}) _("Successfully generated transactions for %(name)s") % {'name': str(statement)})
else:
messages.error(request,
_("Error while generating transactions for %(name)s. Do all bills have a payer?") % {'name': str(statement)})
return HttpResponseRedirect(reverse('admin:%s_%s_change' % (self.opts.app_label, self.opts.model_name), args=(statement.pk,))) return HttpResponseRedirect(reverse('admin:%s_%s_change' % (self.opts.app_label, self.opts.model_name), args=(statement.pk,)))
context = dict(self.admin_site.each_context(request), context = dict(self.admin_site.each_context(request),
title=_('View submitted statement'), title=_('View submitted statement'),
opts=self.opts, opts=self.opts,
statement=statement, statement=statement,
transaction_issues=statement.transaction_issues, transaction_issues=statement.transaction_issues,
**statement.template_context()) total_bills=statement.total_bills,
total=statement.total)
if statement.excursion is not None:
context = dict(context,
nights=statement.excursion.night_count,
price_per_night=statement.real_night_cost,
duration=statement.excursion.duration,
staff_count=statement.real_staff_count,
kilometers_traveled=statement.excursion.kilometers_traveled,
means_of_transport=statement.excursion.get_tour_approach(),
euro_per_km=statement.euro_per_km,
allowance_per_day=settings.ALLOWANCE_PER_DAY,
nights_per_yl=statement.nights_per_yl,
allowance_per_yl=statement.allowance_per_yl,
transportation_per_yl=statement.transportation_per_yl,
total_per_yl=statement.total_per_yl,
total_staff=statement.total_staff)
return render(request, 'admin/overview_submitted_statement.html', context=context) return render(request, 'admin/overview_submitted_statement.html', context=context)
@ -245,68 +257,13 @@ class StatementConfirmedAdmin(admin.ModelAdmin):
#readonly_fields = fields #readonly_fields = fields
list_display = ['__str__', 'total_pretty', 'confirmed_date', 'confirmed_by'] list_display = ['__str__', 'total_pretty', 'confirmed_date', 'confirmed_by']
ordering = ('-confirmed_date',) ordering = ('-confirmed_date',)
inlines = [BillOnSubmittedStatementInline, TransactionOnSubmittedStatementInline]
def has_add_permission(self, request, obj=None): def has_add_permission(self, request, obj=None):
# To preserve integrity, no one is allowed to add confirmed statements
return False return False
def has_change_permission(self, request, obj=None):
# To preserve integrity, no one is allowed to change confirmed statements
return False
def has_delete_permission(self, request, obj=None):
# To preserve integrity, no one is allowed to delete confirmed statements
return False
def get_urls(self):
urls = super().get_urls()
def wrap(view):
def wrapper(*args, **kwargs):
return self.admin_site.admin_view(view)(*args, **kwargs)
wrapper.model_admin = self
return update_wrapper(wrapper, view)
custom_urls = [
path(
"<path:object_id>/unconfirm/",
wrap(self.unconfirm_view),
name="%s_%s_unconfirm" % (self.opts.app_label, self.opts.model_name),
),
]
return custom_urls + urls
def unconfirm_view(self, request, object_id):
statement = StatementConfirmed.objects.get(pk=object_id)
if not statement.confirmed:
messages.error(request,
_("%(name)s is not yet confirmed.") % {'name': str(statement)})
return HttpResponseRedirect(reverse('admin:%s_%s_change' % (self.opts.app_label, self.opts.model_name), args=(statement.pk,)))
if "unconfirm" in request.POST:
statement.confirmed = False
statement.confirmed_date = None
statement.confired_by = None
statement.save()
messages.success(request,
_("Successfully unconfirmed %(name)s. I hope you know what you are doing.")
% {'name': str(statement)})
return HttpResponseRedirect(reverse('admin:%s_%s_changelist' % (self.opts.app_label, self.opts.model_name)))
context = dict(self.admin_site.each_context(request),
title=_('Unconfirm statement'),
opts=self.opts,
statement=statement)
return render(request, 'admin/unconfirm_statement.html', context=context)
@admin.register(Transaction) @admin.register(Transaction)
class TransactionAdmin(admin.ModelAdmin): class TransactionAdmin(admin.ModelAdmin):
"""The transaction admin site. This is only used to display transactions. All editing
is disabled on this site. All transactions should be changed on the respective statement
at the correct stage of the approval chain."""
list_display = ['member', 'ledger', 'amount', 'reference', 'statement', 'confirmed', list_display = ['member', 'ledger', 'amount', 'reference', 'statement', 'confirmed',
'confirmed_date', 'confirmed_by'] 'confirmed_date', 'confirmed_by']
list_filter = ('ledger', 'member', 'statement', 'confirmed') list_filter = ('ledger', 'member', 'statement', 'confirmed')
@ -318,21 +275,16 @@ class TransactionAdmin(admin.ModelAdmin):
return self.fields return self.fields
return super(TransactionAdmin, self).get_readonly_fields(request, obj) return super(TransactionAdmin, self).get_readonly_fields(request, obj)
def has_add_permission(self, request, obj=None):
# To preserve integrity, no one is allowed to add transactions
return False
def has_change_permission(self, request, obj=None):
# To preserve integrity, no one is allowed to change transactions
return False
def has_delete_permission(self, request, obj=None):
# To preserve integrity, no one is allowed to delete transactions
return False
@admin.register(Bill) @admin.register(Bill)
class BillAdmin(admin.ModelAdmin): class BillAdmin(admin.ModelAdmin):
list_display = ['__str__', 'statement', 'explanation', 'pretty_amount', 'paid_by', 'refunded'] list_display = ['__str__', 'statement', 'short_description', 'pretty_amount', 'paid_by', 'refunded']
list_filter = ('statement', 'paid_by', 'refunded') list_filter = ('statement', 'paid_by', 'refunded')
search_fields = ('reference', 'statement') search_fields = ('reference', 'statement')
def get_member(request):
if not hasattr(request.user, 'member'):
return None
else:
return request.user.member

@ -8,7 +8,7 @@ msgid ""
msgstr "" msgstr ""
"Project-Id-Version: PACKAGE VERSION\n" "Project-Id-Version: PACKAGE VERSION\n"
"Report-Msgid-Bugs-To: \n" "Report-Msgid-Bugs-To: \n"
"POT-Creation-Date: 2024-12-01 16:23+0100\n" "POT-Creation-Date: 2023-03-20 18:48+0100\n"
"PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\n" "PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\n"
"Last-Translator: FULL NAME <EMAIL@ADDRESS>\n" "Last-Translator: FULL NAME <EMAIL@ADDRESS>\n"
"Language-Team: LANGUAGE <LL@li.org>\n" "Language-Team: LANGUAGE <LL@li.org>\n"
@ -18,12 +18,12 @@ msgstr ""
"Content-Transfer-Encoding: 8bit\n" "Content-Transfer-Encoding: 8bit\n"
"Plural-Forms: nplurals=2; plural=(n != 1);\n" "Plural-Forms: nplurals=2; plural=(n != 1);\n"
#: finance/admin.py:76 #: finance/admin.py:69
#, python-format #, python-format
msgid "%(name)s is already submitted." msgid "%(name)s is already submitted."
msgstr "%(name)s ist bereits eingereicht." msgstr "%(name)s ist bereits eingereicht."
#: finance/admin.py:82 #: finance/admin.py:75
#, python-format #, python-format
msgid "" msgid ""
"Successfully submited %(name)s. The finance department will notify the " "Successfully submited %(name)s. The finance department will notify the "
@ -32,23 +32,23 @@ msgstr ""
"Rechnung %(name)s erfolgreich eingereicht. Das Finanzreferat wird auf dich " "Rechnung %(name)s erfolgreich eingereicht. Das Finanzreferat wird auf dich "
"sobald wie möglich zukommen." "sobald wie möglich zukommen."
#: finance/admin.py:85 #: finance/admin.py:78
msgid "Submit statement" msgid "Submit statement"
msgstr "Rechnung einreichen" msgstr "Rechnung einreichen"
#: finance/admin.py:162 #: finance/admin.py:152
#, python-format #, python-format
msgid "%(name)s is not yet submitted." msgid "%(name)s is not yet submitted."
msgstr "%(name)s ist noch nicht eingereicht." msgstr "%(name)s ist noch nicht eingereicht."
#: finance/admin.py:169 #: finance/admin.py:159
#, python-format #, python-format
msgid "An error occured while trying to confirm %(name)s. Please try again." msgid "An error occured while trying to confirm %(name)s. Please try again."
msgstr "" msgstr ""
"Beim Abwickeln von %(name)s ist ein Fehler aufgetreten. Bitte versuche es " "Beim Abwickeln von %(name)s ist ein Fehler aufgetreten. Bitte versuche es "
"erneut." "erneut."
#: finance/admin.py:173 #: finance/admin.py:163
#, python-format #, python-format
msgid "" msgid ""
"Successfully confirmed %(name)s. I hope you executed the associated " "Successfully confirmed %(name)s. I hope you executed the associated "
@ -57,11 +57,11 @@ msgstr ""
"Erfolgreich %(name)s abgewickelt. Ich hoffe du hast die zugehörigen " "Erfolgreich %(name)s abgewickelt. Ich hoffe du hast die zugehörigen "
"Überweisungen ausgeführt, ich werde dich nicht nochmal erinnern." "Überweisungen ausgeführt, ich werde dich nicht nochmal erinnern."
#: finance/admin.py:180 #: finance/admin.py:170
msgid "Statement confirmed" msgid "Statement confirmed"
msgstr "Abrechnung abgewickelt" msgstr "Abrechnung abgewickelt"
#: finance/admin.py:186 #: finance/admin.py:176
msgid "" msgid ""
"Transactions do not match the covered expenses. Please correct the mistakes " "Transactions do not match the covered expenses. Please correct the mistakes "
"listed below." "listed below."
@ -69,19 +69,19 @@ msgstr ""
"Überweisungen stimmen nicht mit den übernommenen Kosten überein. Bitte " "Überweisungen stimmen nicht mit den übernommenen Kosten überein. Bitte "
"korrigiere die unten aufgeführten Fehler." "korrigiere die unten aufgeführten Fehler."
#: finance/admin.py:191 #: finance/admin.py:181
msgid "Some transactions have no ledger configured. Please fill in the gaps." msgid "Some transactions have no ledger configured. Please fill in the gaps."
msgstr "" msgstr ""
"Manche Überweisungen haben kein Geldtopf eingestellt. Bitte trage das nach." "Manche Überweisungen haben kein Geldtopf eingestellt. Bitte trage das nach."
#: finance/admin.py:200 #: finance/admin.py:190
#, python-format #, python-format
msgid "Successfully rejected %(name)s. The requestor can reapply, when needed." msgid "Successfully rejected %(name)s. The requestor can reapply, when needed."
msgstr "" msgstr ""
"Die Rechnung %(name)s wurde abgelehnt. Die Person kann die Rechnung erneut " "Die Rechnung %(name)s wurde abgelehnt. Die Person kann die Rechnung erneut "
"einstellen, wenn es benötigt wird." "einstellen, wenn es benötigt wird."
#: finance/admin.py:207 #: finance/admin.py:197
#, python-format #, python-format
msgid "" msgid ""
"%(name)s already has transactions. Please delete them first, if you want to " "%(name)s already has transactions. Please delete them first, if you want to "
@ -90,20 +90,12 @@ msgstr ""
"%(name)s hat bereits Überweisungen. Bitte lösche diese zunächst, bevor du " "%(name)s hat bereits Überweisungen. Bitte lösche diese zunächst, bevor du "
"neue generierst." "neue generierst."
#: finance/admin.py:212 #: finance/admin.py:201
#, python-format #, python-format
msgid "Successfully generated transactions for %(name)s" msgid "Successfully generated transactions for %(name)s"
msgstr "Automatisch Überweisungsträger für %(name)s generiert." msgstr "Automatisch Überweisungsträger für %(name)s generiert."
#: finance/admin.py:215 #: finance/admin.py:204
#, python-format
msgid ""
"Error while generating transactions for %(name)s. Do all bills have a payer?"
msgstr ""
"Fehler beim Erzeugen der Überweisungsträger für %(name)s. Sind für alle "
"Quittungen eine bezahlende Person eingestellt? "
#: finance/admin.py:218
msgid "View submitted statement" msgid "View submitted statement"
msgstr "Eingereichte Abrechnung einsehen" msgstr "Eingereichte Abrechnung einsehen"
@ -112,185 +104,165 @@ msgstr "Eingereichte Abrechnung einsehen"
msgid "Successfully reduced transactions for %(name)s." msgid "Successfully reduced transactions for %(name)s."
msgstr "Überweisungsträger für %(name)s minimiert." msgstr "Überweisungsträger für %(name)s minimiert."
#: finance/admin.py:274
#, python-format
msgid "%(name)s is not yet confirmed."
msgstr "%(name)s ist noch nicht bestätigt."
#: finance/admin.py:283
#, python-format
msgid "Successfully unconfirmed %(name)s. I hope you know what you are doing."
msgstr ""
"Erfolgreich die Bestätigung von %(name)s zurückgenommen. Ich hoffe du weißt "
"was du machst."
#: finance/admin.py:288 finance/templates/admin/unconfirm_statement.html:26
msgid "Unconfirm statement"
msgstr "Bestätigung zurücknehmen"
#: finance/apps.py:8 #: finance/apps.py:8
msgid "Finance" msgid "Finance"
msgstr "Finanzen" msgstr "Finanzen"
#: finance/models.py:21 #: finance/models.py:13
msgid "Name" msgid "Name"
msgstr "Name" msgstr "Name"
#: finance/models.py:27 finance/models.py:472 finance/models.py:496 #: finance/models.py:19 finance/models.py:372 finance/models.py:396
#: finance/templates/admin/confirmed_statement.html:38 #: finance/templates/admin/confirmed_statement.html:38
#: finance/templates/admin/overview_submitted_statement.html:100 #: finance/templates/admin/overview_submitted_statement.html:100
msgid "Ledger" msgid "Ledger"
msgstr "Geldtopf" msgstr "Geldtopf"
#: finance/models.py:28 #: finance/models.py:20
msgid "Ledgers" msgid "Ledgers"
msgstr "Geldtöpfe" msgstr "Geldtöpfe"
#: finance/models.py:48 finance/models.py:415 finance/models.py:495 #: finance/models.py:42 finance/models.py:343 finance/models.py:395
msgid "Short description" msgid "Short description"
msgstr "Kurzbeschreibung" msgstr "Kurzbeschreibung"
#: finance/models.py:51 finance/models.py:416 #: finance/models.py:45 finance/models.py:344
msgid "Explanation" msgid "Explanation"
msgstr "Erklärung" msgstr "Erklärung"
#: finance/models.py:53 #: finance/models.py:47
msgid "Associated excursion" msgid "Associated excursion"
msgstr "Zugehörige Ausfahrt" msgstr "Zugehörige Freizeit"
#: finance/models.py:58 #: finance/models.py:52
msgid "Price per night" msgid "Price per night"
msgstr "Preis pro Nacht" msgstr "Preis pro Nacht"
#: finance/models.py:60 #: finance/models.py:54
msgid "Submitted" msgid "Submitted"
msgstr "Eingericht" msgstr "Eingericht"
#: finance/models.py:61 #: finance/models.py:55
msgid "Submitted on" msgid "Submitted on"
msgstr "Eingereicht am" msgstr "Eingereicht am"
#: finance/models.py:62 #: finance/models.py:56
msgid "Confirmed" msgid "Confirmed"
msgstr "Abgewickelt" msgstr "Abgewickelt"
#: finance/models.py:63 finance/models.py:479 #: finance/models.py:57 finance/models.py:379
msgid "Paid on" msgid "Paid on"
msgstr "Bezahlt am" msgstr "Bezahlt am"
#: finance/models.py:65 #: finance/models.py:59
msgid "Created by"
msgstr "Erstellt von"
#: finance/models.py:70
msgid "Submitted by" msgid "Submitted by"
msgstr "Eingereicht von" msgstr "Eingereicht bei"
#: finance/models.py:75 finance/models.py:480 #: finance/models.py:64 finance/models.py:380
msgid "Authorized by" msgid "Authorized by"
msgstr "Autorisiert von" msgstr "Autorisiert von"
#: finance/models.py:82 finance/models.py:414 finance/models.py:475 #: finance/models.py:71 finance/models.py:342 finance/models.py:375
msgid "Statement" msgid "Statement"
msgstr "Abrechnung" msgstr "Abrechnung"
#: finance/models.py:83 #: finance/models.py:72
msgid "Statements" msgid "Statements"
msgstr "Abrechnungen" msgstr "Abrechnungen"
#: finance/models.py:98 #: finance/models.py:77
#, python-format #, python-format
msgid "Statement: %(excursion)s" msgid "Statement: %(excursion)s"
msgstr "Abrechnung: %(excursion)s" msgstr "Abrechnung: %(excursion)s"
#: finance/models.py:150 #: finance/models.py:123
msgid "Ready to confirm" msgid "Ready to confirm"
msgstr "Bereit zur Abwicklung" msgstr "Bereit zur Abwicklung"
#: finance/models.py:194 #: finance/models.py:162
#, python-format #, python-format
msgid "Compensation for %(excu)s" msgid "Compensation for %(excu)s"
msgstr "Entschädigung für %(excu)s" msgstr "Entschädigung für %(excu)s"
#: finance/models.py:327 #: finance/models.py:294
#: finance/templates/admin/overview_submitted_statement.html:78 #: finance/templates/admin/overview_submitted_statement.html:78
msgid "Total" msgid "Total"
msgstr "Gesamtbetrag" msgstr "Gesamtbetrag"
#: finance/models.py:369 #: finance/models.py:307
msgid "Statement in preparation" msgid "Statement in preparation"
msgstr "Abrechnung in Vorbereitung" msgstr "Abrechnung in Vorbereitung"
#: finance/models.py:370 #: finance/models.py:308
msgid "Statements in preparation" msgid "Statements in preparation"
msgstr "Abrechnungen in Vorbereitung" msgstr "Abrechnungen in Vorbereitung"
#: finance/models.py:389 #: finance/models.py:321
msgid "Submitted statement" msgid "Submitted statement"
msgstr "Eingereichte Abrechnung" msgstr "Eingereichte Abrechnung"
#: finance/models.py:390 #: finance/models.py:322
msgid "Submitted statements" msgid "Submitted statements"
msgstr "Eingereichte Abrechnungen" msgstr "Eingereichte Abrechnungen"
#: finance/models.py:406 #: finance/models.py:336
msgid "Paid statement" msgid "Paid statement"
msgstr "Bezahlte Abrechnung" msgstr "Bezahlte Abrechnung"
#: finance/models.py:407 #: finance/models.py:337
msgid "Paid statements" msgid "Paid statements"
msgstr "Bezahlte Abrechnungen" msgstr "Bezahlte Abrechnungen"
#: finance/models.py:418 finance/models.py:432 finance/models.py:469 #: finance/models.py:347
#: finance/templates/admin/confirmed_statement.html:36
#: finance/templates/admin/overview_submitted_statement.html:31
#: finance/templates/admin/overview_submitted_statement.html:98
msgid "Amount"
msgstr "Betrag"
#: finance/models.py:419
msgid "Paid by" msgid "Paid by"
msgstr "Bezahlt von" msgstr "Bezahlt von"
#: finance/models.py:421 #: finance/models.py:349
msgid "Covered" msgid "Covered"
msgstr "Übernommen" msgstr "Übernommen"
#: finance/models.py:422 #: finance/models.py:350
msgid "Refunded" msgid "Refunded"
msgstr "Ausgezahlt" msgstr "Ausgezahlt"
#: finance/models.py:424 #: finance/models.py:352
msgid "Proof" msgid "Proof"
msgstr "Beleg" msgstr "Beleg"
#: finance/models.py:435 finance/models.py:442 finance/models.py:455 #: finance/models.py:360 finance/models.py:369
#: finance/templates/admin/confirmed_statement.html:36
#: finance/templates/admin/overview_submitted_statement.html:31
#: finance/templates/admin/overview_submitted_statement.html:98
msgid "Amount"
msgstr "Betrag"
#: finance/models.py:363
msgid "Bill" msgid "Bill"
msgstr "Ausgabe" msgstr "Quittung"
#: finance/models.py:436 finance/models.py:443 finance/models.py:456 #: finance/models.py:364
#: finance/templates/admin/overview_submitted_statement.html:26 #: finance/templates/admin/overview_submitted_statement.html:26
msgid "Bills" msgid "Bills"
msgstr "Ausgaben" msgstr "Quittungen"
#: finance/models.py:468 finance/templates/admin/confirmed_statement.html:37 #: finance/models.py:368 finance/templates/admin/confirmed_statement.html:37
#: finance/templates/admin/overview_submitted_statement.html:99 #: finance/templates/admin/overview_submitted_statement.html:99
msgid "Reference" msgid "Reference"
msgstr "Verwendungszweck" msgstr "Verwendungszweck"
#: finance/models.py:470 #: finance/models.py:370
msgid "Recipient" msgid "Recipient"
msgstr "Empfänger" msgstr "Empfänger"
#: finance/models.py:478 #: finance/models.py:378
msgid "Paid" msgid "Paid"
msgstr "Bezahlt" msgstr "Bezahlt"
#: finance/models.py:490 #: finance/models.py:390
msgid "Transaction" msgid "Transaction"
msgstr "Überweisung" msgstr "Überweisung"
#: finance/models.py:491 #: finance/models.py:391
#: finance/templates/admin/overview_submitted_statement.html:84 #: finance/templates/admin/overview_submitted_statement.html:84
msgid "Transactions" msgid "Transactions"
msgstr "Überweisungen" msgstr "Überweisungen"
@ -298,7 +270,6 @@ msgstr "Überweisungen"
#: finance/templates/admin/confirmed_statement.html:17 #: finance/templates/admin/confirmed_statement.html:17
#: finance/templates/admin/overview_submitted_statement.html:17 #: finance/templates/admin/overview_submitted_statement.html:17
#: finance/templates/admin/submit_statement.html:17 #: finance/templates/admin/submit_statement.html:17
#: finance/templates/admin/unconfirm_statement.html:17
msgid "Home" msgid "Home"
msgstr "Start" msgstr "Start"
@ -345,13 +316,13 @@ msgstr "Der Gesamtbetrag beträgt %(total_bills)s €."
#: finance/templates/admin/overview_submitted_statement.html:54 #: finance/templates/admin/overview_submitted_statement.html:54
msgid "Excursion" msgid "Excursion"
msgstr "Ausfahrt" msgstr "Freizeit"
#: finance/templates/admin/overview_submitted_statement.html:57 #: finance/templates/admin/overview_submitted_statement.html:57
#, python-format #, python-format
msgid "This excursion featured %(staff_count)s youth leader(s), each costing" msgid "This excursion featured %(staff_count)s youth leader(s), each costing"
msgstr "" msgstr ""
"Diese Ausfahrt hatte %(staff_count)s Jugendleiter*innen. Auf jede*n " "Diese Freizeit hatte %(staff_count)s Jugendleiter:innen. Auf jede:n "
"entfallen die folgenden Kosten:" "entfallen die folgenden Kosten:"
#: finance/templates/admin/overview_submitted_statement.html:62 #: finance/templates/admin/overview_submitted_statement.html:62
@ -445,7 +416,6 @@ msgstr "Ablehnen"
#: finance/templates/admin/overview_submitted_statement.html:178 #: finance/templates/admin/overview_submitted_statement.html:178
#: finance/templates/admin/submit_statement.html:35 #: finance/templates/admin/submit_statement.html:35
#: finance/templates/admin/unconfirm_statement.html:39
msgid "Cancel" msgid "Cancel"
msgstr "Abbruch" msgstr "Abbruch"
@ -465,24 +435,3 @@ msgid ""
msgstr "" msgstr ""
"Möchtest du die Abrechnung beim Finanzreferat einreichen? Wenn du " "Möchtest du die Abrechnung beim Finanzreferat einreichen? Wenn du "
"fortschreitest, sind keine weiteren Änderungen an der Abrechnung möglich." "fortschreitest, sind keine weiteren Änderungen an der Abrechnung möglich."
#: finance/templates/admin/unconfirm_statement.html:21
#: finance/templates/admin/unconfirm_statement.html:38
msgid "Unconfirm"
msgstr "Bestätigung zurücknehmen"
#: finance/templates/admin/unconfirm_statement.html:29
msgid ""
"You are entering risk zone! Do you really want to manually set this "
"statement back to unconfirmed?"
msgstr ""
"Du bewegst dich in einer Risiko Zone! Möchtest du wirklich manuell die "
"Bestätigung dieser Abrechnung zurücknehmen?"
#: finance/templates/admin/unconfirm_statement.html:36
msgid ""
"I am aware that this is not a standard procedure and this might cause data "
"integrity issues."
msgstr ""
"Mir ist bewusst, dass das keine Standardprozedur ist und das dies die "
"Integrität der Daten zerstören kann."

@ -1,49 +0,0 @@
# Generated by Django 4.0.1 on 2023-04-03 20:34
from django.db import migrations
class Migration(migrations.Migration):
#replaces = [('finance', '0002_billonexcursionproxy_billonstatementproxy_and_more'), ('finance', '0003_alter_statementunsubmitted_options'), ('finance', '0004_alter_billonexcursionproxy_options'), ('finance', '0005_alter_billonstatementproxy_options'), ('finance', '0006_alter_statementsubmitted_options'), ('finance', '0007_alter_billonexcursionproxy_options_and_more')]
dependencies = [
('finance', '0001_initial'),
]
operations = [
migrations.AlterModelOptions(
name='statementsubmitted',
options={'permissions': [('process_statementsubmitted', 'Can manage submitted statements.')], 'verbose_name': 'Submitted statement', 'verbose_name_plural': 'Submitted statements'},
),
migrations.CreateModel(
name='BillOnExcursionProxy',
fields=[
],
options={
'verbose_name': 'Bill',
'verbose_name_plural': 'Bills',
'proxy': True,
'indexes': [],
'constraints': [],
},
bases=('finance.bill',),
),
migrations.CreateModel(
name='BillOnStatementProxy',
fields=[
],
options={
'verbose_name': 'Bill',
'verbose_name_plural': 'Bills',
'proxy': True,
'indexes': [],
'constraints': [],
},
bases=('finance.bill',),
),
migrations.AlterModelOptions(
name='statementunsubmitted',
options={'verbose_name': 'Statement in preparation', 'verbose_name_plural': 'Statements in preparation'},
),
]

@ -1,41 +0,0 @@
# Generated by Django 4.0.1 on 2023-04-04 12:37
from django.db import migrations
class Migration(migrations.Migration):
dependencies = [
('finance', '0002_alter_permissions'),
]
operations = [
migrations.AlterModelOptions(
name='bill',
options={'default_permissions': ('add_global', 'change_global', 'view_global', 'delete_global', 'list_global', 'view'), 'verbose_name': 'Bill', 'verbose_name_plural': 'Bills'},
),
migrations.AlterModelOptions(
name='billonexcursionproxy',
options={'default_permissions': ('add_global', 'change_global', 'view_global', 'delete_global', 'list_global', 'view'), 'verbose_name': 'Bill', 'verbose_name_plural': 'Bills'},
),
migrations.AlterModelOptions(
name='billonstatementproxy',
options={'default_permissions': ('add_global', 'change_global', 'view_global', 'delete_global', 'list_global', 'view'), 'verbose_name': 'Bill', 'verbose_name_plural': 'Bills'},
),
migrations.AlterModelOptions(
name='statement',
options={'default_permissions': ('add_global', 'change_global', 'view_global', 'delete_global', 'list_global', 'view'), 'permissions': [('may_edit_submitted_statements', 'Is allowed to edit submitted statements')], 'verbose_name': 'Statement', 'verbose_name_plural': 'Statements'},
),
migrations.AlterModelOptions(
name='statementconfirmed',
options={'default_permissions': ('add_global', 'change_global', 'view_global', 'delete_global', 'list_global', 'view'), 'permissions': [('may_manage_confirmed_statements', 'Can view and manage confirmed statements.')], 'verbose_name': 'Paid statement', 'verbose_name_plural': 'Paid statements'},
),
migrations.AlterModelOptions(
name='statementsubmitted',
options={'default_permissions': ('add_global', 'change_global', 'view_global', 'delete_global', 'list_global', 'view'), 'permissions': [('process_statementsubmitted', 'Can manage submitted statements.')], 'verbose_name': 'Submitted statement', 'verbose_name_plural': 'Submitted statements'},
),
migrations.AlterModelOptions(
name='statementunsubmitted',
options={'default_permissions': ('add_global', 'change_global', 'view_global', 'delete_global', 'list_global', 'view'), 'verbose_name': 'Statement in preparation', 'verbose_name_plural': 'Statements in preparation'},
),
]

@ -1,18 +0,0 @@
# Generated by Django 4.0.1 on 2024-12-02 00:22
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('finance', '0003_alter_bill_options_and_more'),
]
operations = [
migrations.AlterField(
model_name='bill',
name='amount',
field=models.DecimalField(decimal_places=2, default=0, max_digits=6, verbose_name='Amount'),
),
]

@ -2,18 +2,11 @@ import math
from itertools import groupby from itertools import groupby
from decimal import Decimal, ROUND_HALF_DOWN from decimal import Decimal, ROUND_HALF_DOWN
from django.utils import timezone from django.utils import timezone
from .rules import is_creator, not_submitted, leads_excursion
from members.rules import is_leader, statement_not_submitted
from django.db import models from django.db import models
from django.db.models import Sum
from django.utils.translation import gettext_lazy as _ from django.utils.translation import gettext_lazy as _
from members.models import Member, Freizeit, OEFFENTLICHE_ANREISE, MUSKELKRAFT_ANREISE from members.models import Member, Freizeit, OEFFENTLICHE_ANREISE, MUSKELKRAFT_ANREISE
from django.conf import settings from django.conf import settings
import rules
from contrib.models import CommonModel
from contrib.rules import has_global_perm
from utils import cvt_to_decimal
# Create your models here. # Create your models here.
@ -42,7 +35,7 @@ class StatementManager(models.Manager):
return super().get_queryset().filter(submitted=False, confirmed=False) return super().get_queryset().filter(submitted=False, confirmed=False)
class Statement(CommonModel): class Statement(models.Model):
MISSING_LEDGER, NON_MATCHING_TRANSACTIONS, VALID = 0, 1, 2 MISSING_LEDGER, NON_MATCHING_TRANSACTIONS, VALID = 0, 1, 2
short_description = models.CharField(verbose_name=_('Short description'), short_description = models.CharField(verbose_name=_('Short description'),
@ -78,20 +71,10 @@ class Statement(CommonModel):
on_delete=models.SET_NULL, on_delete=models.SET_NULL,
related_name='confirmed_statements') related_name='confirmed_statements')
class Meta(CommonModel.Meta): class Meta:
verbose_name = _('Statement') verbose_name = _('Statement')
verbose_name_plural = _('Statements') verbose_name_plural = _('Statements')
permissions = [ permissions = [('may_edit_submitted_statements', 'Is allowed to edit submitted statements')]
('may_edit_submitted_statements', 'Is allowed to edit submitted statements')
]
rules_permissions = {
# this is suboptimal, but Statement is only ever used as an inline on Freizeit
# so we check for excursion permissions
'add_obj': is_leader,
'view_obj': is_leader | has_global_perm('members.view_global_freizeit'),
'change_obj': is_leader & statement_not_submitted,
'delete_obj': is_leader & statement_not_submitted,
}
def __str__(self): def __str__(self):
if self.excursion is not None: if self.excursion is not None:
@ -107,20 +90,20 @@ class Statement(CommonModel):
@property @property
def transaction_issues(self): def transaction_issues(self):
needed_paiments = [(b.paid_by, b.amount) for b in self.bill_set.all() if b.costs_covered and b.paid_by] needed_paiments = [(b.paid_by, b.amount) for b in self.bill_set.all() if b.costs_covered]
if self.excursion is not None: if self.excursion is not None:
needed_paiments.extend([(yl, self.real_per_yl) for yl in self.excursion.jugendleiter.all()]) needed_paiments.extend([(yl, self.real_per_yl) for yl in self.excursion.jugendleiter.all()])
needed_paiments = sorted(needed_paiments, key=lambda p: p[0].pk) needed_paiments = sorted(needed_paiments, key=lambda p: p[0].pk)
target = dict(map(lambda p: (p[0], sum([x[1] for x in p[1]])), groupby(needed_paiments, lambda p: p[0]))) target = map(lambda p: (p[0], sum([x[1] for x in p[1]])), groupby(needed_paiments, lambda p: p[0]))
transactions = sorted(self.transaction_set.all(), key=lambda trans: trans.member.pk) transactions = sorted(self.transaction_set.all(), key=lambda trans: trans.member.pk)
current = dict(map(lambda p: (p[0], sum([t.amount for t in p[1]])), groupby(transactions, lambda trans: trans.member))) current = dict(map(lambda p: (p[0], sum([t.amount for t in p[1]])), groupby(transactions, lambda trans: trans.member)))
issues = [] issues = []
for member, amount in target.items(): for member, amount in target:
if amount == 0 and member not in current: if amount == 0:
continue continue
elif member not in current: elif member not in current:
issue = TransactionIssue(member=member, current=0, target=amount) issue = TransactionIssue(member=member, current=0, target=amount)
@ -128,12 +111,6 @@ class Statement(CommonModel):
elif current[member] != amount: elif current[member] != amount:
issue = TransactionIssue(member=member, current=current[member], target=amount) issue = TransactionIssue(member=member, current=current[member], target=amount)
issues.append(issue) issues.append(issue)
for member, amount in current.items():
if amount != 0 and member not in target:
issue = TransactionIssue(member=member, current=amount, target=0)
issues.append(issue)
return issues return issues
@property @property
@ -159,9 +136,6 @@ class Statement(CommonModel):
return Statement.VALID return Statement.VALID
def confirm(self, confirmer=None): def confirm(self, confirmer=None):
if not self.submitted:
return False
if not self.validity == Statement.VALID: if not self.validity == Statement.VALID:
return False return False
@ -181,19 +155,16 @@ class Statement(CommonModel):
for bill in self.bill_set.all(): for bill in self.bill_set.all():
if not bill.costs_covered: if not bill.costs_covered:
continue continue
if not bill.paid_by:
return False
ref = "{}: {}".format(str(self), bill.short_description) ref = "{}: {}".format(str(self), bill.short_description)
Transaction(statement=self, member=bill.paid_by, amount=bill.amount, confirmed=False, reference=ref).save() Transaction(statement=self, member=bill.paid_by, amount=bill.amount, confirmed=False, reference=ref).save()
# excursion specific # excursion specific
if self.excursion is None: if self.excursion is None:
return True return
for yl in self.excursion.jugendleiter.all(): for yl in self.excursion.jugendleiter.all():
ref = _("Compensation for %(excu)s") % {'excu': self.excursion.name} ref = _("Compensation for %(excu)s") % {'excu': self.excursion.name}
Transaction(statement=self, member=yl, amount=self.real_per_yl, confirmed=False, reference=ref).save() Transaction(statement=self, member=yl, amount=self.real_per_yl, confirmed=False, reference=ref).save()
return True
def reduce_transactions(self): def reduce_transactions(self):
# to minimize the number of needed bank transactions, we bundle transactions from same ledger to # to minimize the number of needed bank transactions, we bundle transactions from same ledger to
@ -285,7 +256,7 @@ class Statement(CommonModel):
if self.excursion is None: if self.excursion is None:
return 0 return 0
return cvt_to_decimal(self.total_staff / self.excursion.staff_count) return self.total_staff / self.excursion.staff_count
@property @property
def total_staff(self): def total_staff(self):
@ -326,37 +297,6 @@ class Statement(CommonModel):
return "{}".format(self.total) return "{}".format(self.total)
total_pretty.short_description = _('Total') total_pretty.short_description = _('Total')
def template_context(self):
context = {
'total_bills': self.total_bills,
'total_bills_theoretic': self.total_bills_theoretic,
'total': self.total,
}
if self.excursion:
excursion_context = {
'nights': self.excursion.night_count,
'price_per_night': self.real_night_cost,
'duration': self.excursion.duration,
'staff_count': self.real_staff_count,
'kilometers_traveled': self.excursion.kilometers_traveled,
'means_of_transport': self.excursion.get_tour_approach(),
'euro_per_km': self.euro_per_km,
'allowance_per_day': settings.ALLOWANCE_PER_DAY,
'nights_per_yl': self.nights_per_yl,
'allowance_per_yl': self.allowance_per_yl,
'transportation_per_yl': self.transportation_per_yl,
'total_per_yl': self.total_per_yl,
'total_staff': self.total_staff,
}
return dict(context, **excursion_context)
else:
return context
def grouped_bills(self):
return self.bill_set.values('short_description')\
.order_by('short_description')\
.annotate(amount=Sum('amount'))
class StatementUnSubmittedManager(models.Manager): class StatementUnSubmittedManager(models.Manager):
def get_queryset(self): def get_queryset(self):
@ -366,16 +306,10 @@ class StatementUnSubmittedManager(models.Manager):
class StatementUnSubmitted(Statement): class StatementUnSubmitted(Statement):
objects = StatementUnSubmittedManager() objects = StatementUnSubmittedManager()
class Meta(CommonModel.Meta): class Meta:
proxy = True proxy = True
verbose_name = _('Statement in preparation') verbose_name = _('Statement in preparation')
verbose_name_plural = _('Statements in preparation') verbose_name_plural = _('Statements in preparation')
rules_permissions = {
'add_obj': rules.is_staff,
'view_obj': is_creator | leads_excursion | has_global_perm('finance.view_global_statementunsubmitted'),
'change_obj': is_creator | leads_excursion,
'delete_obj': is_creator | leads_excursion,
}
class StatementSubmittedManager(models.Manager): class StatementSubmittedManager(models.Manager):
@ -386,13 +320,11 @@ class StatementSubmittedManager(models.Manager):
class StatementSubmitted(Statement): class StatementSubmitted(Statement):
objects = StatementSubmittedManager() objects = StatementSubmittedManager()
class Meta(CommonModel.Meta): class Meta:
proxy = True proxy = True
verbose_name = _('Submitted statement') verbose_name = _('Submitted statement')
verbose_name_plural = _('Submitted statements') verbose_name_plural = _('Submitted statements')
permissions = [ permissions = (('may_manage_submitted_statements', 'Can view and manage submitted statements.'),)
('process_statementsubmitted', 'Can manage submitted statements.'),
]
class StatementConfirmedManager(models.Manager): class StatementConfirmedManager(models.Manager):
@ -403,21 +335,19 @@ class StatementConfirmedManager(models.Manager):
class StatementConfirmed(Statement): class StatementConfirmed(Statement):
objects = StatementConfirmedManager() objects = StatementConfirmedManager()
class Meta(CommonModel.Meta): class Meta:
proxy = True proxy = True
verbose_name = _('Paid statement') verbose_name = _('Paid statement')
verbose_name_plural = _('Paid statements') verbose_name_plural = _('Paid statements')
permissions = [ permissions = (('may_manage_confirmed_statements', 'Can view and manage confirmed statements.'),)
('may_manage_confirmed_statements', 'Can view and manage confirmed statements.'),
]
class Bill(CommonModel): class Bill(models.Model):
statement = models.ForeignKey(Statement, verbose_name=_('Statement'), on_delete=models.CASCADE) statement = models.ForeignKey(Statement, verbose_name=_('Statement'), on_delete=models.CASCADE)
short_description = models.CharField(verbose_name=_('Short description'), max_length=30) short_description = models.CharField(verbose_name=_('Short description'), max_length=30)
explanation = models.TextField(verbose_name=_('Explanation'), blank=True) explanation = models.TextField(verbose_name=_('Explanation'), blank=True)
amount = models.DecimalField(verbose_name=_('Amount'), max_digits=6, decimal_places=2, default=0) amount = models.DecimalField(max_digits=6, decimal_places=2, default=0)
paid_by = models.ForeignKey(Member, verbose_name=_('Paid by'), null=True, paid_by = models.ForeignKey(Member, verbose_name=_('Paid by'), null=True,
on_delete=models.SET_NULL) on_delete=models.SET_NULL)
costs_covered = models.BooleanField(verbose_name=_('Covered'), default=False) costs_covered = models.BooleanField(verbose_name=_('Covered'), default=False)
@ -433,37 +363,9 @@ class Bill(CommonModel):
pretty_amount.admin_order_field = 'amount' pretty_amount.admin_order_field = 'amount'
pretty_amount.short_description = _('Amount') pretty_amount.short_description = _('Amount')
class Meta(CommonModel.Meta): class Meta:
verbose_name = _('Bill')
verbose_name_plural = _('Bills')
class BillOnExcursionProxy(Bill):
class Meta(CommonModel.Meta):
proxy = True
verbose_name = _('Bill')
verbose_name_plural = _('Bills')
rules_permissions = {
'add_obj': leads_excursion & not_submitted,
'view_obj': leads_excursion | has_global_perm('finance.view_global_billonexcursionproxy'),
'change_obj': (leads_excursion | has_global_perm('finance.change_global_billonexcursionproxy')) & not_submitted,
'delete_obj': (leads_excursion | has_global_perm('finance.delete_global_billonexcursionproxy')) & not_submitted,
}
class BillOnStatementProxy(Bill):
class Meta(CommonModel.Meta):
proxy = True
verbose_name = _('Bill') verbose_name = _('Bill')
verbose_name_plural = _('Bills') verbose_name_plural = _('Bills')
rules_permissions = {
'add_obj': (is_creator | leads_excursion) & not_submitted,
'view_obj': is_creator | leads_excursion | has_global_perm('finance.view_global_billonstatementproxy'),
'change_obj': (is_creator | leads_excursion | has_global_perm('finance.change_global_billonstatementproxy'))
& (not_submitted | has_global_perm('finance.process_statementsubmitted')),
'delete_obj': (is_creator | leads_excursion | has_global_perm('finance.delete_global_billonstatementproxy'))
& not_submitted,
}
class Transaction(models.Model): class Transaction(models.Model):
@ -499,3 +401,7 @@ class Receipt(models.Model):
on_delete=models.CASCADE) on_delete=models.CASCADE)
amount = models.DecimalField(max_digits=6, decimal_places=2) amount = models.DecimalField(max_digits=6, decimal_places=2)
comments = models.TextField() comments = models.TextField()
def cvt_to_decimal(f):
return Decimal(f).quantize(Decimal('.01'), rounding=ROUND_HALF_DOWN)

@ -1,36 +0,0 @@
from members.models import Freizeit
from contrib.rules import memberize_user
from rules import predicate
from members.rules import _is_leader
@predicate
@memberize_user
def is_creator(self, statement):
assert statement is not None
return statement.created_by == self
@predicate
@memberize_user
def not_submitted(self, statement):
assert statement is not None
if isinstance(statement, Freizeit):
if hasattr(statement, 'statement'):
return not statement.statement.submitted
else:
return True
return not statement.submitted
@predicate
@memberize_user
def leads_excursion(self, statement):
assert statement is not None
if isinstance(statement, Freizeit):
return _is_leader(self, statement)
if not hasattr(statement, 'excursion'):
return False
if statement.excursion is None:
return False
return _is_leader(self, statement.excursion)

@ -1,41 +0,0 @@
{% extends "admin/base_site.html" %}
{% load i18n admin_urls static %}
{% block extrahead %}
{{ block.super }}
{{ media }}
<script src="{% static 'admin/js/cancel.js' %}" async></script>
<script type="text/javascript" src="{% static "admin/js/vendor/jquery/jquery.js" %}"></script>
<script type="text/javascript" src="{% static "admin/js/jquery.init.js" %}"></script>
{% endblock %}
{% block bodyclass %}{{ block.super }} app-{{ opts.app_label }} model-{{ opts.model_name }} admin-view
{% endblock %}
{% block breadcrumbs %}
<div class="breadcrumbs">
<a href="{% url 'admin:index' %}">{% translate 'Home' %}</a>
&rsaquo; <a href="{% url 'admin:app_list' app_label=opts.app_label %}">{{ opts.app_config.verbose_name }}</a>
&rsaquo; <a href="{% url opts|admin_urlname:'changelist' %}">{{ opts.verbose_name_plural|capfirst }}</a>
&rsaquo; <a href="{% url opts|admin_urlname:'change' statement.pk|admin_urlquote %}">{{ statement|truncatewords:"18" }}</a>
&rsaquo; {% translate 'Unconfirm' %}
</div>
{% endblock %}
{% block content %}
<h2>{% translate "Unconfirm statement" %}</h2>
<p>
{% blocktrans %}You are entering risk zone! Do you really want to manually set this statement back to unconfirmed?{% endblocktrans %}
</p>
<form action="" method="post">
{% csrf_token %}
<p>
<input type="checkbox" required>
{% blocktrans %}I am aware that this is not a standard procedure and this might cause data integrity issues.{% endblocktrans %}
</p>
<input class="default danger" type="submit" name="unconfirm" value="{% translate 'Unconfirm' %}">
<a class="button cancel-link" href="{% add_preserved_filters change_url %}">{% trans 'Cancel' %}</a>
</form>
{% endblock %}

@ -1,185 +1,3 @@
from django.test import TestCase from django.test import TestCase
from django.utils import timezone
from django.conf import settings
from .models import Statement, StatementUnSubmitted, StatementSubmitted, Bill, Ledger, Transaction
from members.models import Member, Group, Freizeit, GEMEINSCHAFTS_TOUR, MUSKELKRAFT_ANREISE, NewMemberOnList,\
FAHRGEMEINSCHAFT_ANREISE
# Create your tests here. # Create your tests here.
class StatementTestCase(TestCase):
night_cost = 27
kilometers_traveled = 512
participant_count = 10
staff_count = 5
def setUp(self):
self.jl = Group.objects.create(name="Jugendleiter")
self.fritz = Member.objects.create(prename="Fritz", lastname="Wulter", birth_date=timezone.now().date(),
email=settings.TEST_MAIL)
self.fritz.group.add(self.jl)
self.fritz.save()
self.personal_account = Ledger.objects.create(name='personal account')
self.st = Statement.objects.create(short_description='A statement', explanation='Important!', night_cost=0)
Bill.objects.create(statement=self.st, short_description='food', explanation='i was hungry',
amount=67.3, costs_covered=False, paid_by=self.fritz)
Transaction.objects.create(reference='gift', amount=12.3,
ledger=self.personal_account, member=self.fritz,
statement=self.st)
self.st2 = Statement.objects.create(short_description='Actual expenses', night_cost=0)
Bill.objects.create(statement=self.st2, short_description='food', explanation='i was hungry',
amount=67.3, costs_covered=True, paid_by=self.fritz)
ex = Freizeit.objects.create(name='Wild trip', kilometers_traveled=self.kilometers_traveled,
tour_type=GEMEINSCHAFTS_TOUR,
tour_approach=MUSKELKRAFT_ANREISE,
difficulty=1)
self.st3 = Statement.objects.create(night_cost=self.night_cost, excursion=ex)
for i in range(self.participant_count):
m = Member.objects.create(prename='Fritz {}'.format(i), lastname='Walter', birth_date=timezone.now().date(),
email=settings.TEST_MAIL)
mol = NewMemberOnList.objects.create(member=m, memberlist=ex)
ex.membersonlist.add(mol)
for i in range(self.staff_count):
m = Member.objects.create(prename='Fritz {}'.format(i), lastname='Walter', birth_date=timezone.now().date(),
email=settings.TEST_MAIL)
Bill.objects.create(statement=self.st3, short_description='food', explanation='i was hungry',
amount=42.69, costs_covered=True, paid_by=m)
m.group.add(self.jl)
ex.jugendleiter.add(m)
ex = Freizeit.objects.create(name='Wild trip 2', kilometers_traveled=self.kilometers_traveled,
tour_type=GEMEINSCHAFTS_TOUR,
tour_approach=MUSKELKRAFT_ANREISE,
difficulty=2)
self.st4 = Statement.objects.create(night_cost=self.night_cost, excursion=ex)
for i in range(2):
m = Member.objects.create(prename='Peter {}'.format(i), lastname='Walter', birth_date=timezone.now().date(),
email=settings.TEST_MAIL)
mol = NewMemberOnList.objects.create(member=m, memberlist=ex)
ex.membersonlist.add(mol)
def test_staff_count(self):
self.assertEqual(self.st4.admissible_staff_count, 0,
'Admissible staff count is not 0, although not enough participants.')
for i in range(2):
m = Member.objects.create(prename='Peter {}'.format(i), lastname='Walter', birth_date=timezone.now().date(),
email=settings.TEST_MAIL)
mol = NewMemberOnList.objects.create(member=m, memberlist=self.st4.excursion)
self.st4.excursion.membersonlist.add(mol)
self.assertEqual(self.st4.admissible_staff_count, 2,
'Admissible staff count is not 2, although there are 4 participants.')
def test_reduce_transactions(self):
self.st3.generate_transactions()
self.assertEqual(self.st3.transaction_set.count(), self.staff_count * 2,
'Transaction count is not twice the staff count.')
self.st3.reduce_transactions()
self.assertEqual(self.st3.transaction_set.count(), self.staff_count * 2,
'Transaction count after reduction is not the same as before, although no ledgers are configured.')
for trans in self.st3.transaction_set.all():
trans.ledger = self.personal_account
trans.save()
self.st3.reduce_transactions()
self.assertEqual(self.st3.transaction_set.count(), self.staff_count,
'Transaction count after setting ledgers and reduction is not halved.')
self.st3.reduce_transactions()
self.assertEqual(self.st3.transaction_set.count(), self.staff_count,
'Transaction count did change after reducing a second time.')
def test_confirm_statement(self):
self.assertFalse(self.st3.confirm(confirmer=self.fritz), 'Statement was confirmed, although it is not submitted.')
self.st3.submit(submitter=self.fritz)
self.assertTrue(self.st3.submitted, 'Statement is not submitted, although it was.')
self.assertEqual(self.st3.submitted_by, self.fritz,
'Statement was not submitted by fritz.')
self.assertFalse(self.st3.confirm(), 'Statement was confirmed, but is not valid yet.')
self.st3.generate_transactions()
for trans in self.st3.transaction_set.all():
trans.ledger = self.personal_account
trans.save()
self.assertTrue(self.st3.confirm(confirmer=self.fritz),
'Statement was not confirmed, although it submitted and valid.')
self.assertEqual(self.st3.confirmed_by, self.fritz, 'Statement not confirmed by fritz.')
for trans in self.st3.transaction_set.all():
self.assertTrue(trans.confirmed, 'Transaction on confirmed statement is not confirmed.')
self.assertEqual(trans.confirmed_by, self.fritz, 'Transaction on confirmed statement is not confirmed by fritz.')
def test_excursion_statement(self):
self.assertEqual(self.st3.excursion.staff_count, self.staff_count,
'Calculated staff count is not constructed staff count.')
self.assertEqual(self.st3.excursion.participant_count, self.participant_count,
'Calculated participant count is not constructed participant count.')
self.assertLess(self.st3.admissible_staff_count, self.staff_count,
'All staff members are refinanced, although {} is too much for {} participants.'.format(self.staff_count, self.participant_count))
self.assertFalse(self.st3.transactions_match_expenses,
'Transactions match expenses, but currently no one is paid.')
self.assertGreater(self.st3.total_staff, 0,
'There are no costs for the staff, although there are enough participants.')
self.assertEqual(self.st3.total_nights, 0,
'There are costs for the night, although there was no night.')
self.assertEqual(self.st3.real_night_cost, settings.MAX_NIGHT_COST,
'Real night cost is not the max, although the given one is way too high.')
# changing means of transport changes euro_per_km
epkm = self.st3.euro_per_km
self.st3.excursion.tour_approach = FAHRGEMEINSCHAFT_ANREISE
self.assertNotEqual(epkm, self.st3.euro_per_km, 'Changing means of transport did not change euro per km.')
self.st3.generate_transactions()
self.assertTrue(self.st3.transactions_match_expenses,
"Transactions don't match expenses after generating them.")
self.assertGreater(self.st3.total, 0, 'Total is 0.')
def test_generate_transactions(self):
# self.st2 has an unpaid bill
self.assertFalse(self.st2.transactions_match_expenses,
'Transactions match expenses, but one bill is not paid.')
self.st2.generate_transactions()
# now transactions should match expenses
self.assertTrue(self.st2.transactions_match_expenses,
"Transactions don't match expenses after generating them.")
# self.st2 is still not valid
self.assertEqual(self.st2.validity, Statement.MISSING_LEDGER,
'Statement is valid, although transaction has no ledger setup.')
for trans in self.st2.transaction_set.all():
trans.ledger = self.personal_account
trans.save()
self.assertEqual(self.st2.validity, Statement.VALID,
'Statement is still invalid, after setting up ledger.')
# create a new transaction issue by manually changing amount
t1 = self.st2.transaction_set.all()[0]
t1.amount = 123
t1.save()
self.assertFalse(self.st2.transactions_match_expenses,
'Transactions match expenses, but one transaction was tweaked.')
def test_statement_without_excursion(self):
# should be all 0, since no excursion is associated
self.assertEqual(self.st.real_staff_count, 0)
self.assertEqual(self.st.admissible_staff_count, 0)
self.assertEqual(self.st.nights_per_yl, 0)
self.assertEqual(self.st.allowance_per_yl, 0)
self.assertEqual(self.st.real_per_yl, 0)
self.assertEqual(self.st.transportation_per_yl, 0)
self.assertEqual(self.st.euro_per_km, 0)
self.assertEqual(self.st.total_allowance, 0)
self.assertEqual(self.st.total_transportation, 0)
def test_detect_unallowed_gift(self):
# there is a bill
self.assertGreater(self.st.total_bills_theoretic, 0, 'Theoretic bill total is 0 (should be > 0).')
# but it is not covered
self.assertEqual(self.st.total_bills, 0, 'Real bill total is not 0.')
self.assertEqual(self.st.total, 0, 'Total is not 0.')
self.assertGreater(self.st.total_theoretic, 0, 'Total in theorey is 0.')
self.st.generate_transactions()
self.assertEqual(self.st.transaction_set.count(), 1, 'Generating transactions did produce new transactions.')
# but there is a transaction anyway
self.assertFalse(self.st.transactions_match_expenses,
'Transactions match expenses, although an unreasonable gift is paid.')
# so statement must be invalid
self.assertFalse(self.st.is_valid(),
'Transaction is valid, although an unreasonable gift is paid.')

@ -38,8 +38,6 @@ USE_X_FORWARDED_HOST = True
# Application definition # Application definition
INSTALLED_APPS = [ INSTALLED_APPS = [
'logindata.apps.LoginDataConfig',
'contrib.apps.ContribConfig',
'startpage.apps.StartpageConfig', 'startpage.apps.StartpageConfig',
'material.apps.MaterialConfig', 'material.apps.MaterialConfig',
'members.apps.MembersConfig', 'members.apps.MembersConfig',
@ -47,12 +45,10 @@ INSTALLED_APPS = [
'finance.apps.FinanceConfig', 'finance.apps.FinanceConfig',
'ludwigsburgalpin.apps.LudwigsburgalpinConfig', 'ludwigsburgalpin.apps.LudwigsburgalpinConfig',
#'easy_select2', #'easy_select2',
'markdownify.apps.MarkdownifyConfig',
'markdownx',
'djcelery_email', 'djcelery_email',
'nested_admin', 'nested_admin',
'django_celery_beat', 'django_celery_beat',
'rules', 'debug_toolbar',
'jet', 'jet',
'django.contrib.admin', 'django.contrib.admin',
'django.contrib.auth', 'django.contrib.auth',
@ -64,6 +60,7 @@ INSTALLED_APPS = [
MIDDLEWARE = [ MIDDLEWARE = [
'django.middleware.cache.UpdateCacheMiddleware', 'django.middleware.cache.UpdateCacheMiddleware',
'debug_toolbar.middleware.DebugToolbarMiddleware',
'jdav_web.middleware.ForceLangMiddleware', 'jdav_web.middleware.ForceLangMiddleware',
'django.middleware.security.SecurityMiddleware', 'django.middleware.security.SecurityMiddleware',
'django.contrib.sessions.middleware.SessionMiddleware', 'django.contrib.sessions.middleware.SessionMiddleware',
@ -76,8 +73,6 @@ MIDDLEWARE = [
'django.middleware.cache.FetchFromCacheMiddleware', 'django.middleware.cache.FetchFromCacheMiddleware',
] ]
X_FRAME_OPTIONS = 'SAMEORIGIN'
ROOT_URLCONF = 'jdav_web.urls' ROOT_URLCONF = 'jdav_web.urls'
TEMPLATES = [ TEMPLATES = [
@ -98,11 +93,6 @@ TEMPLATES = [
WSGI_APPLICATION = 'jdav_web.wsgi.application' WSGI_APPLICATION = 'jdav_web.wsgi.application'
AUTHENTICATION_BACKENDS = (
'django.contrib.auth.backends.ModelBackend',
'rules.permissions.ObjectPermissionBackend',
)
# Password validation # Password validation
# https://docs.djangoproject.com/en/1.10/ref/settings/#auth-password-validators # https://docs.djangoproject.com/en/1.10/ref/settings/#auth-password-validators
@ -132,7 +122,7 @@ STATICFILES_DIRS = [
# use python3 manage.py collectstatic to collect static files in the STATIC_ROOT # use python3 manage.py collectstatic to collect static files in the STATIC_ROOT
# this is needed for deployment # this is needed for deployment
STATIC_ROOT = os.environ.get('DJANGO_STATIC_ROOT', STATIC_ROOT = os.environ.get('DJANGO_STATIC_ROOT',
'/var/www/jdav_web/static') '/var/www/jdav_web/assets')
# Locale files (translations) # Locale files (translations)
@ -152,42 +142,7 @@ PASSWORD_HASHERS = [
'django.contrib.auth.hashers.ScryptPasswordHasher', 'django.contrib.auth.hashers.ScryptPasswordHasher',
] ]
MARKDOWNIFY = { INTERNAL_IPS = [
'default': { '127.0.0.1',
"WHITELIST_TAGS": [ os.environ.get('INTERNAL_IP', '172.17.0.1'),
'img', ]
'abbr',
'acronym',
'a',
'b',
'blockquote',
'em',
'i',
'li',
'ol',
'p',
'strong',
'ul',
'br',
'code',
'span',
'div', 'class',
'pre', 'h1', 'h2', 'h3', 'h4', 'h5', 'h6'
],
"WHITELIST_ATTRS": [
'src',
'href',
'style',
'alt',
'class',
],
"LINKIFY_TEXT": {
"PARSE_URLS": True,
# Next key/value-pairs only have effect if "PARSE_URLS" is True
"PARSE_EMAIL": True,
"CALLBACKS": [],
"SKIP_TAGS": [],
}
}
}

@ -6,12 +6,12 @@ CACHES = {
'no_delay': True, 'no_delay': True,
'ignore_exc': True, 'ignore_exc': True,
'max_pool_size': 4, 'max_pool_size': 4,
'use_pooling': True, 'use_pooling': True
} }
} }
} }
CACHE_MIDDLEWARE_ALIAS = 'default' CACHE_MIDDLEWARE_ALIAS = 'default'
CACHE_MIDDLEWARE_SECONDS = 1 CACHE_MIDDLEWARE_SECONDS = 120
CACHE_MIDDLEWARE_KEY_PREFIX = '' CACHE_MIDDLEWARE_KEY_PREFIX = ''

@ -14,4 +14,3 @@ CELERY_EMAIL_TASK_CONFIG = {
} }
DEFAULT_SENDING_MAIL = os.environ.get('EMAIL_SENDING_ADDRESS', 'django@localhost') DEFAULT_SENDING_MAIL = os.environ.get('EMAIL_SENDING_ADDRESS', 'django@localhost')
DEFAULT_SENDING_NAME = os.environ.get('EMAIL_SENDING_NAME', 'Kompass')

@ -5,10 +5,9 @@ JET_DEFAULT_THEME = 'jdav-green'
JET_CHANGE_FORM_SIBLING_LINKS = False JET_CHANGE_FORM_SIBLING_LINKS = False
JET_SIDE_MENU_ITEMS = [ JET_SIDE_MENU_ITEMS = [
{'app_label': 'logindata', 'permissions': ['auth'], 'items': [ {'app_label': 'auth', 'permissions': ['auth'], 'items': [
{'name': 'authgroup', 'permissions': ['auth.group'] }, {'name': 'group', 'permissions': ['auth.group'] },
{'name': 'logindatum', 'permissions': ['auth.user']}, {'name': 'user', 'permissions': ['auth.user']},
{'name': 'registrationpassword', 'permissions': ['auth.user']},
]}, ]},
{'app_label': 'django_celery_beat', 'permissions': ['django_celery_beat'], 'items': [ {'app_label': 'django_celery_beat', 'permissions': ['django_celery_beat'], 'items': [
{'name': 'crontabschedule'}, {'name': 'crontabschedule'},
@ -34,16 +33,12 @@ JET_SIDE_MENU_ITEMS = [
]}, ]},
{'app_label': 'members', 'items': [ {'app_label': 'members', 'items': [
{'name': 'member', 'permissions': ['members.view_member']}, {'name': 'member', 'permissions': ['members.view_member']},
{'name': 'group', 'permissions': ['members.view_group']},
{'name': 'membernotelist', 'permissions': ['members.view_membernotelist']}, {'name': 'membernotelist', 'permissions': ['members.view_membernotelist']},
{'name': 'freizeit', 'permissions': ['members.view_freizeit']}, {'name': 'freizeit', 'permissions': ['members.view_freizeit']},
{'name': 'klettertreff', 'permissions': ['members.view_klettertreff']}, {'name': 'klettertreff', 'permissions': ['members.view_klettertreff']},
]},
{'label': 'Gruppenverwaltung', 'app_label': 'members', 'permissions': ['members.view_group'], 'items': [
{'name': 'group', 'permissions': ['members.view_group']},
{'name': 'activitycategory', 'permissions': ['members.view_activitycategory']}, {'name': 'activitycategory', 'permissions': ['members.view_activitycategory']},
{'name': 'trainingcategory', 'permissions': ['members.view_trainingcategory']}, {'name': 'trainingcategory', 'permissions': ['members.view_trainingcategory']},
]},
{'label': 'Neue Mitglieder', 'app_label': 'members', 'permissions': ['members.view_memberunconfirmedproxy'], 'items': [
{'name': 'memberunconfirmedproxy', 'permissions': ['members.view_memberunconfirmedproxy']}, {'name': 'memberunconfirmedproxy', 'permissions': ['members.view_memberunconfirmedproxy']},
{'name': 'memberwaitinglist', 'permissions': ['members.view_memberwaitinglist']}, {'name': 'memberwaitinglist', 'permissions': ['members.view_memberwaitinglist']},
]}, ]},
@ -51,14 +46,7 @@ JET_SIDE_MENU_ITEMS = [
{'name': 'materialcategory', 'permissions': ['material.view_materialcategory']}, {'name': 'materialcategory', 'permissions': ['material.view_materialcategory']},
{'name': 'materialpart', 'permissions': ['material.view_materialpart']}, {'name': 'materialpart', 'permissions': ['material.view_materialpart']},
]}, ]},
{'app_label': 'startpage', 'permissions': ['startpage'], 'items': [
{'name': 'section', 'permissions': ['startpage.view_section']},
{'name': 'post', 'permissions': ['startpage.view_post']},
]},
{'label': 'Externe Links', 'items' : [ {'label': 'Externe Links', 'items' : [
{ 'label': 'Nextcloud', 'url': CLOUD_LINK }, { 'label': 'Packlisten und Co.', 'url': CLOUD_LINK }
{ 'label': 'DAV 360', 'url': DAV_360_LINK },
{ 'label': 'Julei-Wiki', 'url': WIKI_LINK },
{ 'label': 'Kompass Dokumentation', 'url': DOCS_LINK },
]}, ]},
] ]

@ -1,7 +1,7 @@
# Internationalization # Internationalization
# https://docs.djangoproject.com/en/1.10/topics/i18n/ # https://docs.djangoproject.com/en/1.10/topics/i18n/
LANGUAGE_CODE = 'de' LANGUAGE_CODE = 'de-de'
TIME_ZONE = 'Europe/Berlin' TIME_ZONE = 'Europe/Berlin'

@ -22,46 +22,19 @@ der Registrierung kommst du hier:
Viele Grüße Viele Grüße
Dein KOMPASS""" Dein KOMPASS"""
GROUP_TIME_AVAILABLE_TEXT = """Die Gruppenstunde findet jeden {weekday} von {start_time} bis {end_time} Uhr statt."""
GROUP_TIME_UNAVAILABLE_TEXT = """Bitte erfrage die Gruppenzeiten bei der Gruppenleitung ({contact_email})."""
INVITE_TEXT = """Hallo {name}, INVITE_TEXT = """Hallo {name},
wir haben gute Neuigkeiten für dich. Es ist ein Platz in der Jugendgruppe {group_name} {group_link}freigeworden. wir haben gute Neuigkeiten für dich. Es ist ein Platz in der Jugendgruppe freigeworden. Wir brauchen
{group_time} jetzt noch ein paar Informationen von dir und deine Anmeldebestätigung. Das kannst du alles über folgenden
Link erledigen:
Bitte kontaktiere die Gruppenleitung ({contact_email}) für alle weiteren Absprachen.
Wenn du nach der Schnupperstunde beschließt der Gruppe beizutreten, benötigen wir noch ein paar
Informationen und deine Anmeldebestätigung von dir. Die lädst du herunter
(siehe %(REGISTRATION_FORM_DOWNLOAD_LINK)s), lässt sie von deinen Eltern ausfüllen, unterschreiben
und lädst ein Foto davon in unserem Anmeldeformular hoch. Das kannst du alles über folgenden Link erledigen:
{link} {link}
Du siehst dort auch die Daten, die du bei deiner Eintragung auf die Warteliste angegeben hast. Bitte Du siehst dort auch die Daten, die du bei deiner Eintragung auf die Warteliste angegeben hast. Bitte
überprüfe, ob die Daten noch stimmen und ändere sie bei Bedarf ab. überprüfe, ob die Daten noch stimmen und ändere sie bei Bedarf ab.
Falls du zu dem obigen Termin keine Zeit hast oder dich ganz von der Warteliste abmelden möchtest,
lehne bitte diese Einladung unter folgendem Link ab:
{invitation_reject_link}
Bei Fragen, wende dich gerne an %(RESPONSIBLE_MAIL)s. Bei Fragen, wende dich gerne an %(RESPONSIBLE_MAIL)s.
Viele Grüße
Deine JDAV %(SEKTION)s""" % { 'SEKTION': SEKTION, 'RESPONSIBLE_MAIL': RESPONSIBLE_MAIL,
'REGISTRATION_FORM_DOWNLOAD_LINK': REGISTRATION_FORM_DOWNLOAD_LINK }
LEAVE_WAITINGLIST_TEXT = """Hallo {name},
du hast dich erfolgreich von der Warteliste abgemeldet. Falls du zu einem späteren
Zeitpunkt wieder der Warteliste beitreten möchtest, kannst du das über unsere Webseite machen.
Falls du dich nicht selbst abgemeldet hast, wende dich bitte umgehend an %(RESPONSIBLE_MAIL)s.
Viele Grüße Viele Grüße
Deine JDAV %(SEKTION)s""" % { 'SEKTION': SEKTION, 'RESPONSIBLE_MAIL': RESPONSIBLE_MAIL } Deine JDAV %(SEKTION)s""" % { 'SEKTION': SEKTION, 'RESPONSIBLE_MAIL': RESPONSIBLE_MAIL }
@ -77,8 +50,7 @@ Wenn du weiterhin auf der Warteliste bleiben möchtest, klicke auf den folgenden
{link} {link}
Das ist Erinnerung Nummer {reminder} von {max_reminder_count}. Nach Erinnerung Nummer {max_reminder_count} wirst Falls du nicht mehr auf der Warteliste bleiben möchtest, musst du nichts machen. Du wirst automatisch entfernt.
du automatisch entfernt.
Viele Grüße Viele Grüße
Deine JDAV %(SEKTION)s""" % { 'SEKTION': SEKTION } Deine JDAV %(SEKTION)s""" % { 'SEKTION': SEKTION }
@ -104,19 +76,14 @@ Dein:e Jugendreferent:in""" % { 'SEKTION': SEKTION }
ECHO_TEXT = """Hallo {name}, ECHO_TEXT = """Hallo {name},
um unsere Daten auf dem aktuellen Stand zu halten und sicherzugehen, dass du um unsere Daten auf dem aktuellen Stand zu halten, brauchen wir eine
weiterhin ein Teil unserer Jugendarbeit bleiben möchtest, brauchen wir eine
kurze Bestätigung von dir. Dafür besuche einfach diesen Link: kurze Bestätigung von dir. Dafür besuche einfach diesen Link:
{link} {link}
Dort kannst du deine Daten nach Eingabe eines Passworts überprüfen und ggf. ändern. Dein Dort kannst du deine Daten überprüfen und ändern. Falls du nicht innerhalb von
Passwort ist dein Geburtsdatum. Wäre dein Geburtsdatum zum Beispiel der 4. Januar 1942, 30 Tagen deine Daten bestätigst, wirst du aus unserer Datenbank gelöscht und
so wäre dein Passwort: 04.01.1942 erhälst in Zukunft keine Mails mehr von uns.
Falls du nicht innerhalb von 30 Tagen deine Daten bestätigst, gehen wir davon aus, dass du nicht mehr Teil
unserer Jugendarbeit sein möchtest. Dein Platz wird dann weitervergeben, deine Daten aus unserer Datenbank
gelöscht und du erhälst in Zukunft keine Mails mehr von uns.
Bei Fragen, wende dich gerne an %(RESPONSIBLE_MAIL)s. Bei Fragen, wende dich gerne an %(RESPONSIBLE_MAIL)s.
@ -147,23 +114,3 @@ verschickt. Wenn Du in Zukunft keine Emails mehr erhalten möchtest,
kannst Du hier den Newsletter deabonnieren: kannst Du hier den Newsletter deabonnieren:
{link}""" % { 'SEKTION': SEKTION } {link}""" % { 'SEKTION': SEKTION }
INVITE_AS_USER_TEXT = """Hallo {name},
du bist Jugendleiter*in in der Sektion %(SEKTION)s. Die Verwaltung unserer Jugendgruppen,
Ausfahrten und Finanzen erfolgt in unserer Online Plattform Kompass. Deine Stammdaten sind
dort bereits hinterlegt. Damit du dich auch anmelden kannst, folge bitte dem folgenden Link
und wähle ein Passwort.
{link}
Bei Fragen, wende dich gerne an %(RESPONSIBLE_MAIL)s.
Viele Grüße
Deine JDAV %(SEKTION)s""" % { 'SEKTION': SEKTION, 'RESPONSIBLE_MAIL': RESPONSIBLE_MAIL }
ADDRESS = """JDAV %(SEKTION)s
%(STREET)s
%(PLACE)s""" % { 'SEKTION': SEKTION, 'STREET': SEKTION_STREET, 'PLACE': SEKTION_TOWN }

@ -7,48 +7,19 @@ SEKTION_TELEPHONE = "06221 284076"
SEKTION_TELEFAX = "06221 437338" SEKTION_TELEFAX = "06221 437338"
SEKTION_CONTACT_MAIL = "geschaeftsstelle@alpenverein-heidelberg.de" SEKTION_CONTACT_MAIL = "geschaeftsstelle@alpenverein-heidelberg.de"
SEKTION_BOARD_MAIL = "vorstand@alpenverein-heidelberg.de" SEKTION_BOARD_MAIL = "vorstand@alpenverein-heidelberg.de"
SEKTION_CRISIS_INTERVENTION_MAIL = "krisenmanagement@alpenverein-heidelberg.de"
SEKTION_IBAN = "DE22 6729 0000 0000 1019 40"
SEKTION_ACCOUNT_HOLDER = "Deutscher Alpenverein Sektion Heidelberg 1869"
RESPONSIBLE_MAIL = "jugendreferat@jdav-hd.de" RESPONSIBLE_MAIL = "jugendreferat@jdav-hd.de"
DIGITAL_MAIL = "digitales@jdav-hd.de"
# LJP
V32_HEAD_ORGANISATION = """JDAV Baden-Württemberg
Rotebühlstraße 59A
70178 Stuttgart
info@jdav-bw.de
0711 - 49 09 46 00"""
LJP_CONTRIBUTION_PER_DAY = 25
# echo
ECHO_PASSWORD_BIRTHDATE_FORMAT = '%d.%m.%Y'
ECHO_GRACE_PERIOD = 30
# misc # misc
CONGRATULATE_MEMBERS_MAX = 10 CONGRATULATE_MEMBERS_MAX = 10
MAX_AGE_GOOD_CONDUCT_CERTIFICATE_MONTHS = 24
ALLOWED_EMAIL_DOMAINS_FOR_INVITE_AS_USER = ('alpenverein-heidelberg.de', )
# mail mode
SEND_FROM_ASSOCIATION_EMAIL = os.environ.get('SEND_FROM_ASSOCIATION_EMAIL', '0') == '1'
# finance # finance
ALLOWANCE_PER_DAY = 22 ALLOWANCE_PER_DAY = 10
MAX_NIGHT_COST = 11 MAX_NIGHT_COST = 11
CLOUD_LINK = 'https://nc.cloud-jdav-hd.de' CLOUD_LINK = 'https://cloud.jdav-ludwigsburg.de/index.php/s/qxQCTR8JqYSXXCQ'
DAV_360_LINK = 'https://dav360.de'
WIKI_LINK = 'https://davbgs.sharepoint.com/sites/S-114-O-JDAV-Jugendreferat'
DOCS_LINK = os.environ.get('DOCS_LINK', 'https://jdav-hd.de/static/docs/')
# Admin setup # Admin setup
@ -58,18 +29,13 @@ ADMINS = (('admin', 'christian@merten-moser.de'),)
GRACE_PERIOD_WAITING_CONFIRMATION = 30 GRACE_PERIOD_WAITING_CONFIRMATION = 30
WAITING_CONFIRMATION_FREQUENCY = 90 WAITING_CONFIRMATION_FREQUENCY = 90
CONFIRMATION_REMINDER_FREQUENCY = 30
MAX_REMINDER_COUNT = 3
# testing # testing
TEST_MAIL = "post@flavigny.de" TEST_MAIL = "post@flavigny.de"
REGISTRATION_FORM_DOWNLOAD_LINK = 'https://nc.cloud-jdav-hd.de' REGISTRATION_FORM_DOWNLOAD_LINK = 'https://cloud.jdav-ludwigsburg.de/index.php/s/NQfRqA9MTKfPBkC'
DOMAIN = os.environ.get('DOMAIN', 'example.com') DOMAIN = 'jdav-hd.merten.dev'
STARTPAGE_REDIRECT_URL = 'https://jdav-hd.de' STARTPAGE_REDIRECT_URL = 'https://jdav-hd.de'
ROOT_SECTION = os.environ.get('ROOT_SECTION', 'wir')
RECENT_SECTION = 'aktuelles'
REPORTS_SECTION = 'berichte'

@ -27,25 +27,18 @@ admin.site.index_title = _('Startpage')
admin.site.site_header = 'Kompass' admin.site.site_header = 'Kompass'
urlpatterns += i18n_patterns( urlpatterns += i18n_patterns(
re_path(r'^kompass/?', admin.site.urls, name='kompass'), re_path(r'^kompass/', admin.site.urls),
re_path(r'^jet/', include('jet.urls', 'jet')), # Django JET URLS re_path(r'^jet/', include('jet.urls', 'jet')), # Django JET URLS
re_path(r'^admin/?', RedirectView.as_view(url='/kompass')), re_path(r'^admin/', RedirectView.as_view(url='/kompass')),
re_path(r'^newsletter/', include('mailer.urls', namespace="mailer")), re_path(r'^newsletter/', include('mailer.urls', namespace="mailer")),
re_path(r'^members/', include('members.urls', namespace="members")), re_path(r'^members/', include('members.urls', namespace="members")),
re_path(r'^login/', include('logindata.urls', namespace="logindata")),
re_path(r'^LBAlpin/Programm(/)?(20)?[0-9]{0,2}', include('ludwigsburgalpin.urls', re_path(r'^LBAlpin/Programm(/)?(20)?[0-9]{0,2}', include('ludwigsburgalpin.urls',
namespace="ludwigsburgalpin")), namespace="ludwigsburgalpin")),
re_path(r'^$', include('startpage.urls', namespace="startpage")),
re_path(r'^_nested_admin/', include('nested_admin.urls')), re_path(r'^_nested_admin/', include('nested_admin.urls')),
re_path(r'^', include('startpage.urls', namespace="startpage")), re_path(r'^__debug__', include('debug_toolbar.urls')),
) )
urlpatterns += [
re_path(r'^markdownx/', include('markdownx.urls')),
]
handler404 = 'startpage.views.handler404'
handler500 = 'startpage.views.handler500'
# TODO: django serving from MEDIA_URL should be disabled in production stage # TODO: django serving from MEDIA_URL should be disabled in production stage
# see # see
# http://stackoverflow.com/questions/5871730/need-a-minimal-django-file-upload-example # http://stackoverflow.com/questions/5871730/need-a-minimal-django-file-upload-example

@ -1 +1 @@
Subproject commit 69133f184f0f9b53a3b0a39f91e8eba99698cabc Subproject commit 6cf14c28de9bfefc28c0c296d87e52dc098f98b8

@ -8,7 +8,7 @@ msgid ""
msgstr "" msgstr ""
"Project-Id-Version: PACKAGE VERSION\n" "Project-Id-Version: PACKAGE VERSION\n"
"Report-Msgid-Bugs-To: \n" "Report-Msgid-Bugs-To: \n"
"POT-Creation-Date: 2024-12-01 16:23+0100\n" "POT-Creation-Date: 2023-03-20 18:48+0100\n"
"PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\n" "PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\n"
"Last-Translator: FULL NAME <EMAIL@ADDRESS>\n" "Last-Translator: FULL NAME <EMAIL@ADDRESS>\n"
"Language-Team: LANGUAGE <LL@li.org>\n" "Language-Team: LANGUAGE <LL@li.org>\n"
@ -18,220 +18,10 @@ msgstr ""
"Content-Transfer-Encoding: 8bit\n" "Content-Transfer-Encoding: 8bit\n"
"Plural-Forms: nplurals=2; plural=(n != 1);\n" "Plural-Forms: nplurals=2; plural=(n != 1);\n"
#: contrib/admin.py:59
#, python-format
msgid "You are not allowed to view %(name)s."
msgstr "Du hast nicht die notwendigen Berechtigungen um %(name)s zu sehen."
#: jdav_web/urls.py:26 #: jdav_web/urls.py:26
msgid "Startpage" msgid "Startpage"
msgstr "Startseite" msgstr "Startseite"
#: logindata/admin.py:25
msgid "Permissions"
msgstr "Berechtigungen"
#: logindata/admin.py:36
msgid "Important dates"
msgstr "Wichtigen Daten"
#: logindata/apps.py:8
msgid "Authentication"
msgstr "Authentifizierung"
#: logindata/models.py:10
msgid "Permission group"
msgstr "Berechtigungsgruppe"
#: logindata/models.py:11
msgid "Permission groups"
msgstr "Berechtigungsgruppen"
#: logindata/models.py:17
msgid "Login Datum"
msgstr "Zugangsdaten"
#: logindata/models.py:18
msgid "Login Data"
msgstr "Zugangsdaten"
#: logindata/models.py:25
msgid "Password"
msgstr "Passwort"
#: logindata/models.py:31
msgid "Active registration password"
msgstr "Aktives Registrierungspasswort"
#: logindata/models.py:32
msgid "Active registration passwords"
msgstr "Aktive Registrierungspasswörter"
#: logindata/templates/logindata/register_failed.html:5
msgid "Registration"
msgstr "Registrierung"
#: logindata/templates/logindata/register_failed.html:10
#: logindata/templates/logindata/register_form.html:13
#: logindata/templates/logindata/register_password.html:11
#: logindata/templates/logindata/register_success.html:10
msgid "Set login data"
msgstr "Zugangsdaten wählen"
#: logindata/templates/logindata/register_failed.html:12
msgid "Something went wrong. The registration key is invalid or has expired."
msgstr ""
"Etwas ist schief gegangen. Der Registrierungscode ist ungültig oder ist "
"abgelaufen."
#: logindata/templates/logindata/register_failed.html:14
msgid "If you think this is a mistake, please"
msgstr "Falls du denkst, dass das ein Fehler ist, bitte"
#: logindata/templates/logindata/register_failed.html:14
msgid "contact us."
msgstr "kontaktiere uns."
#: logindata/templates/logindata/register_form.html:6
#: logindata/templates/logindata/register_password.html:6
msgid "Register"
msgstr "Registrieren"
#: logindata/templates/logindata/register_form.html:15
#: logindata/templates/logindata/register_password.html:13
msgid "Welcome, "
msgstr "Willkommen, "
#: logindata/templates/logindata/register_form.html:16
msgid ""
"To set your personal login data, please enter the password that you received."
msgstr ""
"Um deine persönlichen Zugansdaten festzulegen, gib bitte das Passwort ein, "
"das du erhalten hast."
#: logindata/templates/logindata/register_form.html:30
#: logindata/templates/logindata/register_password.html:23
msgid "submit"
msgstr "Einreichen"
#: logindata/templates/logindata/register_password.html:13
msgid ""
"To set your personal login data for Kompass, please enter the password that "
"you received."
msgstr ""
"Um deine persönlichen Zugangsdaten festzulegen, gib bitte das Passwort ein, "
"das du erhalten hast."
#: logindata/templates/logindata/register_success.html:5
msgid "Registration successful"
msgstr "Zugangsdaten erfolgreich festgelegt"
#: logindata/templates/logindata/register_success.html:12
msgid "You successfully set your login data. You can now proceed to"
msgstr ""
"Du hast deine Zugangsdaten erfolgreich festgelegt. Du kannst nun weiter zum"
#: logindata/views.py:59
msgid "You entered a wrong password."
msgstr "Das eingegebene Passwort ist falsch."
#: templates/admin/delete_confirmation.html:7
#, python-format
msgid ""
"Deleting the %(object_name)s '%(escaped_object)s' would result in deleting "
"related objects, but your account doesn't have permission to delete the "
"following types of objects:"
msgstr ""
"Löschen von %(object_name)s '%(escaped_object)s' würde zur Löschung der "
"folgenden verknüpften Objekte führen, aber du hast nicht die Berechtigung "
"die folgenden Typen von Objekten zu löschen:"
#: templates/admin/delete_confirmation.html:12
#, python-format
msgid ""
"Deleting the %(object_name)s '%(escaped_object)s' would require deleting the "
"following protected related objects:"
msgstr ""
"Löschen von %(object_name)s '%(escaped_object)s' würde zur Löschung der "
"folgenden geschützten verknüpften Objekte führen:"
#: templates/admin/delete_confirmation.html:17
#, python-format
msgid ""
"Are you sure you want to delete the %(object_name)s \"%(escaped_object)s\"?"
msgstr ""
"Bist du sicher, dass du %(object_name)s \"%(escaped_object)s\" und alle "
"davon abhängigen Objekte löschen möchtest? "
#: templates/admin/delete_confirmation.html:29
#: templates/admin/delete_selected_confirmation.html:34
msgid "Yes, Im sure"
msgstr "Ja, ich bin sicher"
#: templates/admin/delete_confirmation.html:30
#: templates/admin/delete_selected_confirmation.html:35
msgid "No, take me back"
msgstr "Nein, bitte abbrechen"
#: templates/admin/delete_selected_confirmation.html:6
#, python-format
msgid ""
"Deleting the selected %(objects_name)s would result in deleting related "
"objects, but your account doesn't have permission to delete the following "
"types of objects:"
msgstr ""
"Löschen der ausgewählten %(objects_name)s würde zur Löschung der folgenden "
"verknüpften Objekte führen, aber du hast nicht die Berechtigung die "
"folgenden Typen von Objekten zu löschen:"
#: templates/admin/delete_selected_confirmation.html:9
#, python-format
msgid ""
"Deleting the selected %(objects_name)s would require deleting the following "
"protected related objects:"
msgstr ""
"Löschen der ausgewählten %(objects_name)s würde zur Löschung der folgenden "
"geschützten verknüpften Objekte führen:"
#: templates/admin/delete_selected_confirmation.html:12
#, python-format
msgid ""
"Are you sure you want to delete the selected %(objects_name)s? All of the "
"following objects and their related items will be deleted:"
msgstr ""
"Bist du sicher, dass du die ausgewählten %(objects_name)s löschen möchtest? "
"Alle folgenden Objekte und alle davon abhängigen Objekte werden gelöscht:"
#: templates/admin/delete_selected_confirmation.html:14
msgid "Summary"
msgstr "Zusammenfassung"
#: templates/admin/delete_selected_confirmation.html:18
msgid "Objects"
msgstr "Objekte"
#: templates/admin/edit_inline/stacked.html:20
#: templates/admin/edit_inline/tabular.html:47
#: templates/nesting/admin/inlines/stacked.html:42
msgid "Change"
msgstr "Ändern"
#: templates/admin/edit_inline/stacked.html:20
#: templates/admin/edit_inline/tabular.html:47
#: templates/nesting/admin/inlines/stacked.html:42
msgid "View"
msgstr "Anzeigen"
#: templates/admin/edit_inline/stacked.html:22
#: templates/admin/edit_inline/tabular.html:49
#: templates/nesting/admin/inlines/stacked.html:44
msgid "View on site"
msgstr "Auf der Website anzeigen"
#: templates/admin/edit_inline/tabular.html:33
msgid "Delete?"
msgstr "Löschen?"
#: templates/admin/finance/statementconfirmed/change_form_object_tools.html:8 #: templates/admin/finance/statementconfirmed/change_form_object_tools.html:8
msgid "Unconfirm" msgid "Unconfirm"
msgstr "Bestätigung zurücknehmen" msgstr "Bestätigung zurücknehmen"
@ -253,50 +43,26 @@ msgid "Generate crisis intervention list"
msgstr "Kriseninterventionsliste erstellen" msgstr "Kriseninterventionsliste erstellen"
#: templates/admin/members/freizeit/change_form_object_tools.html:16 #: templates/admin/members/freizeit/change_form_object_tools.html:16
msgid "Generate SJR application" msgid "Generate overview"
msgstr "SJR Antrag erstellen" msgstr "Übersicht erstellen"
#: templates/admin/members/freizeit/change_form_object_tools.html:23 #: templates/admin/members/freizeit/change_form_object_tools.html:23
msgid "Generate seminar report" msgid "Generate seminar report"
msgstr "Landesjugendplan Antrag erstellen" msgstr "Seminarbericht erstellen"
#: templates/admin/members/freizeit/change_form_object_tools.html:30
msgid "Generate overview"
msgstr "Hinweise für Jugendleiter*innen erstellen"
#: templates/admin/members/freizeit/change_form_object_tools.html:38
msgid "Finance overview"
msgstr "Kostenübersicht"
#: templates/admin/members/member/change_form_object_tools.html:8 #: templates/admin/members/freizeit/change_form_object_tools.html:29
msgid "Invite as user" msgid "Submit statement"
msgstr "Als Kompassbenutzer*in einladen" msgstr "Abrechnung einreichen"
#: templates/admin/members/memberunconfirmedproxy/change_form_object_tools.html:8
msgid "Demote to waiter"
msgstr "Zurück auf die Warteliste setzen"
#: templates/admin/members/memberwaitinglist/change_form_object_tools.html:8 #: templates/admin/members/memberwaitinglist/change_form_object_tools.html:8
#: templates/admin/members/memberwaitinglist/submit_line.html:9 #: templates/admin/members/memberwaitinglist/submit_line.html:9
msgid "Invite to group" msgid "Invite to group"
msgstr "Zu Gruppe einladen" msgstr "Zu Gruppe einladen"
#: templates/nesting/admin/inlines/stacked.html:87 #: utils.py:26
#, python-format
msgid "Add another %(verbose_name)s"
msgstr "Weiteren %(verbose_name)s hinzufügen"
#: utils.py:15
msgid "Please keep filesize under {} MiB. Current filesize: {:10.2f} MiB."
msgstr "Maximale Dateigröße {} MiB. Aktuelle Dateigröße: {:10.2f} MiB."
#: utils.py:43
msgid "Filetype not supported." msgid "Filetype not supported."
msgstr "Dateityp nicht unterstützt." msgstr "Dateityp nicht unterstützt."
#: utils.py:45 #: utils.py:28
msgid "Please keep filesize under {}. Current filesize: {}" msgid "Please keep filesize under {}. Current filesize: {}"
msgstr "Maximale Dateigröße {}. Aktuelle Dateigröße: {}." msgstr "Maximale Dateigröße {}. Aktuelle Dateigröße: {}."
#~ msgid "Submit statement"
#~ msgstr "Abrechnung einreichen"

@ -1,52 +0,0 @@
from django.utils.translation import gettext_lazy as _
from django.contrib import admin
from django.contrib.auth.admin import UserAdmin as BaseUserAdmin, GroupAdmin as BaseAuthGroupAdmin
from django.contrib.auth.models import User as BaseUser, Group as BaseAuthGroup
from .models import AuthGroup, LoginDatum, RegistrationPassword
from members.models import Member
# Register your models here.
class AuthGroupAdmin(BaseAuthGroupAdmin):
pass
class UserInline(admin.StackedInline):
model = Member
can_delete = False
verbose_name_plural = "member"
class LoginDatumAdmin(BaseUserAdmin):
list_display = ('username', 'is_superuser')
#inlines = [UserInline]
fieldsets = (
(None, {"fields": ("username", "password")}),
(
_("Permissions"),
{
"fields": (
"is_active",
"is_staff",
"is_superuser",
"groups",
"user_permissions",
),
},
),
(_("Important dates"), {"fields": ("last_login", "date_joined")}),
)
add_fieldsets = (
(
None,
{
"classes": ("wide",),
"fields": ("username", "password1", "password2"),
},
),
)
admin.site.unregister(BaseUser)
admin.site.unregister(BaseAuthGroup)
admin.site.register(LoginDatum, LoginDatumAdmin)
admin.site.register(AuthGroup, AuthGroupAdmin)
admin.site.register(RegistrationPassword)

@ -1,8 +0,0 @@
from django.apps import AppConfig
from django.utils.translation import gettext_lazy as _
class LoginDataConfig(AppConfig):
default_auto_field = 'django.db.models.BigAutoField'
name = 'logindata'
verbose_name = _('Authentication')

@ -1,55 +0,0 @@
# Generated by Django 4.0.1 on 2024-11-23 21:15
import django.contrib.auth.models
from django.db import migrations, models
class Migration(migrations.Migration):
initial = True
dependencies = [
('auth', '0012_alter_user_first_name_max_length'),
]
operations = [
migrations.CreateModel(
name='RegistrationPassword',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('password', models.CharField(max_length=100, verbose_name='Password')),
],
),
migrations.CreateModel(
name='AuthGroup',
fields=[
],
options={
'verbose_name': 'Permission group',
'verbose_name_plural': 'Permission groups',
'proxy': True,
'indexes': [],
'constraints': [],
},
bases=('auth.group',),
managers=[
('objects', django.contrib.auth.models.GroupManager()),
],
),
migrations.CreateModel(
name='LoginDatum',
fields=[
],
options={
'verbose_name': 'Login Datum',
'verbose_name_plural': 'Login Data',
'proxy': True,
'indexes': [],
'constraints': [],
},
bases=('auth.user',),
managers=[
('objects', django.contrib.auth.models.UserManager()),
],
),
]

@ -1,17 +0,0 @@
# Generated by Django 4.0.1 on 2024-11-24 00:36
from django.db import migrations
class Migration(migrations.Migration):
dependencies = [
('logindata', '0001_initial'),
]
operations = [
migrations.AlterModelOptions(
name='registrationpassword',
options={'verbose_name': 'Active registration password', 'verbose_name_plural': 'Active registration passwords'},
),
]

@ -1,46 +0,0 @@
from django.utils.translation import gettext_lazy as _
from django.db import models
from django.contrib.auth.admin import UserAdmin as BaseUserAdmin, GroupAdmin as BaseAuthGroupAdmin
from django.contrib.auth.models import User as BaseUser, Group as BaseAuthGroup
class AuthGroup(BaseAuthGroup):
class Meta:
proxy = True
verbose_name = _('Permission group')
verbose_name_plural = _('Permission groups')
class LoginDatum(BaseUser):
class Meta:
proxy = True
verbose_name = _('Login Datum')
verbose_name_plural = _('Login Data')
class RegistrationPassword(models.Model):
"""
A password that can be used to register after inviting a member.
"""
password = models.CharField(max_length=100, verbose_name=_('Password'))
def __str__(self):
return self.password
class Meta:
verbose_name = _('Active registration password')
verbose_name_plural = _('Active registration passwords')
def initial_user_setup(user, member):
try:
standard_group = AuthGroup.objects.get(name='Standard')
except AuthGroup.DoesNotExist:
return False
user.is_staff = True
user.save()
user.groups.add(standard_group)
member.user = user
member.invite_as_user_key = ''
member.save()
return True

@ -1,16 +0,0 @@
{% extends "members/base.html" %}
{% load i18n static common %}
{% block title %}
{% trans "Registration" %}
{% endblock %}
{% block content %}
<h1>{% trans "Set login data" %}</h1>
<p>{% trans "Something went wrong. The registration key is invalid or has expired." %}</p>
<p>{% trans "If you think this is a mistake, please" %} <a href="mailto:{% settings_value 'RESPONSIBLE_MAIL' %}">{% trans "contact us." %}</a></p>
{% endblock %}

@ -1,33 +0,0 @@
{% extends "members/base.html" %}
{% load i18n %}
{% load static %}
{% block title %}
{% trans "Register" %}
{% endblock %}
{% block content %}
<link rel="stylesheet" href="{% static "ludwigsburgalpin/termine.css" static %}">
<h1>{% trans "Set login data" %}</h1>
<p>{% trans "Welcome, " %} {{ member.prename }}.
{% blocktrans %}To set your personal login data, please enter the password that you received.{% endblocktrans %}</p>
{% if error_message %}
<p><b>{{ error_message }}</b></p>
{% endif %}
<form action="" method="post" enctype="multipart/form-data">
<table class="termine">
{% csrf_token %}
{{form}}
</table>
<input name="key" type="hidden" value="{{key}}">
<input name="password" type="hidden" value="{{password}}">
<input name="save" type="hidden">
<input type="submit" value="{% trans "submit" %}"/>
</form>
{% endblock %}

@ -1,26 +0,0 @@
{% extends "members/base.html" %}
{% load i18n %}
{% load static %}
{% block title %}
{% trans "Register" %}
{% endblock %}
{% block content %}
<h1>{% trans "Set login data" %}</h1>
<p>{% trans "Welcome, " %} {{ member.prename }}. {% blocktrans %}To set your personal login data for Kompass, please enter the password that you received.{% endblocktrans%}</p>
{% if error_message %}
<p class="errorlist">{{ error_message }}</p>
{% endif %}
<form action="" method="post">
{% csrf_token %}
<input type="password" name="password" required>
<input type="hidden" name="key" value="{{key}}">
<p><input type="submit" value="{% trans "submit" %}"/></p>
</form>
{% endblock %}

@ -1,15 +0,0 @@
{% extends "members/base.html" %}
{% load i18n %}
{% block title %}
{% trans "Registration successful" %}
{% endblock %}
{% block content %}
<h1>{% trans "Set login data" %}</h1>
<p>{% blocktrans %}You successfully set your login data. You can now proceed to{% endblocktrans%}
<a href="/kompass">login</a>.</p>
{% endblock %}

@ -1,3 +0,0 @@
from django.test import TestCase
# Create your tests here.

@ -1,8 +0,0 @@
from django.urls import re_path
from . import views
app_name = "logindata"
urlpatterns = [
re_path(r'^register', views.register , name='register'),
]

@ -1,75 +0,0 @@
from django import forms
from django.shortcuts import render
from django.http import HttpResponseRedirect
from django.utils.translation import gettext_lazy as _
from django.urls import reverse
from django.contrib.auth.forms import UserCreationForm
from members.models import Member
from .models import initial_user_setup, RegistrationPassword
def render_register_password(request, key, member, error_message=''):
return render(request, 'logindata/register_password.html',
context={'key': key,
'member': member,
'error_message': error_message})
def render_register_failed(request):
return render(request, 'logindata/register_failed.html')
def render_register_form(request, key, password, member, form):
return render(request, 'logindata/register_form.html',
context={'key': key,
'password': password,
'member': member,
'form': form})
def render_register_success(request):
return render(request, 'logindata/register_success.html')
# Create your views here.
def register(request):
if request.method == 'GET' and 'key' not in request.GET:
return HttpResponseRedirect(reverse('startpage:index'))
if request.method == 'POST' and 'key' not in request.POST:
return HttpResponseRedirect(reverse('startpage:index'))
key = request.GET['key'] if request.method == 'GET' else request.POST['key']
if not key:
return render_register_failed(request)
try:
member = Member.objects.get(invite_as_user_key=key)
except (Member.DoesNotExist, Member.MultipleObjectsReturned):
return render_register_failed(request)
if request.method == 'GET':
return render_register_password(request, request.GET['key'], member)
if 'password' not in request.POST:
return render_register_failed(request)
password = request.POST['password']
# check if the entered password is one of the active registration passwords
if RegistrationPassword.objects.filter(password=password).count() == 0:
return render_register_password(request, key, member, error_message=_('You entered a wrong password.'))
if "save" in request.POST:
form = UserCreationForm(request.POST)
if not form.is_valid():
# form is invalid, reprint form with (automatic) error messages
return render_register_form(request, key, password, member, form)
user = form.save(commit=False)
success = initial_user_setup(user, member)
if success:
return render_register_success(request)
else:
return render_register_failed(request)
else:
prefill = {'username': member.suggested_username()}
form = UserCreationForm(initial=prefill)
return render_register_form(request, key, password, member, form)

@ -7,23 +7,18 @@ from django import forms
#from easy_select2 import apply_select2 #from easy_select2 import apply_select2
import json import json
from rules.contrib.admin import ObjectPermissionsModelAdmin
from .models import Message, Attachment, MessageForm, EmailAddress, EmailAddressForm from .models import Message, Attachment, MessageForm, EmailAddress, EmailAddressForm
from .mailutils import NOT_SENT, PARTLY_SENT from .mailutils import NOT_SENT, PARTLY_SENT
from members.models import Member from members.models import Member
from members.admin import FilteredMemberFieldMixin
from contrib.admin import CommonAdminMixin, CommonAdminInlineMixin
class AttachmentInline(CommonAdminInlineMixin, admin.TabularInline): class AttachmentInline(admin.TabularInline):
model = Attachment model = Attachment
extra = 0 extra = 0
class EmailAddressAdmin(FilteredMemberFieldMixin, admin.ModelAdmin): class EmailAddressAdmin(admin.ModelAdmin):
list_display = ('email', 'internal_only') list_display = ('email', )
fields = ('name', 'to_members', 'to_groups', 'internal_only')
#formfield_overrides = { #formfield_overrides = {
# models.ManyToManyField: {'widget': forms.CheckboxSelectMultiple}, # models.ManyToManyField: {'widget': forms.CheckboxSelectMultiple},
# models.ForeignKey: {'widget': apply_select2(forms.Select)} # models.ForeignKey: {'widget': apply_select2(forms.Select)}
@ -32,14 +27,11 @@ class EmailAddressAdmin(FilteredMemberFieldMixin, admin.ModelAdmin):
form = EmailAddressForm form = EmailAddressForm
class MessageAdmin(FilteredMemberFieldMixin, CommonAdminMixin, ObjectPermissionsModelAdmin): class MessageAdmin(admin.ModelAdmin):
"""Message creation view""" """Message creation view"""
exclude = ('created_by', 'to_notelist')
list_display = ('subject', 'get_recipients', 'sent') list_display = ('subject', 'get_recipients', 'sent')
search_fields = ('subject',) search_fields = ('subject',)
list_filter = ('sent',)
change_form_template = "mailer/change_form.html" change_form_template = "mailer/change_form.html"
readonly_fields = ('sent',)
#formfield_overrides = { #formfield_overrides = {
# models.ManyToManyField: {'widget': forms.CheckboxSelectMultiple}, # models.ManyToManyField: {'widget': forms.CheckboxSelectMultiple},
# models.ForeignKey: {'widget': apply_select2(forms.Select)} # models.ForeignKey: {'widget': apply_select2(forms.Select)}
@ -50,11 +42,6 @@ class MessageAdmin(FilteredMemberFieldMixin, CommonAdminMixin, ObjectPermissions
form = MessageForm form = MessageForm
filter_horizontal = ('to_members','reply_to') filter_horizontal = ('to_members','reply_to')
def save_model(self, request, obj, form, change):
if not change and hasattr(request.user, 'member'):
obj.created_by = request.user.member
super().save_model(request, obj, form, change)
def send_message(self, request, queryset): def send_message(self, request, queryset):
if request.POST.get('confirmed'): if request.POST.get('confirmed'):
for msg in queryset: for msg in queryset:
@ -89,17 +76,12 @@ class MessageAdmin(FilteredMemberFieldMixin, CommonAdminMixin, ObjectPermissions
form.base_fields['to_members'].initial = members form.base_fields['to_members'].initial = members
return form return form
class Media:
css = {'all': ('admin/css/tabular_hide_original.css',)}
def submit_message(msg, request): def submit_message(msg, request):
sender = None success = msg.submit()
if not hasattr(request.user, 'member'):
messages.error(request, _("Your account is not connected to a member. Please contact your system administrator."))
return
sender = request.user.member
if not sender.has_internal_email():
messages.error(request, _("Your email address is not an internal email address. Please change your email address and try again."))
return
success = msg.submit(sender)
if success == NOT_SENT: if success == NOT_SENT:
messages.error(request, _("Failed to send message")) messages.error(request, _("Failed to send message"))
elif success == PARTLY_SENT: elif success == PARTLY_SENT:

@ -8,7 +8,7 @@ msgid ""
msgstr "" msgstr ""
"Project-Id-Version: PACKAGE VERSION\n" "Project-Id-Version: PACKAGE VERSION\n"
"Report-Msgid-Bugs-To: \n" "Report-Msgid-Bugs-To: \n"
"POT-Creation-Date: 2024-12-02 22:50+0100\n" "POT-Creation-Date: 2023-03-20 18:48+0100\n"
"PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\n" "PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\n"
"Last-Translator: FULL NAME <EMAIL@ADDRESS>\n" "Last-Translator: FULL NAME <EMAIL@ADDRESS>\n"
"Language-Team: LANGUAGE <LL@li.org>\n" "Language-Team: LANGUAGE <LL@li.org>\n"
@ -18,35 +18,19 @@ msgstr ""
"Content-Transfer-Encoding: 8bit\n" "Content-Transfer-Encoding: 8bit\n"
"Plural-Forms: nplurals=2; plural=(n != 1);\n" "Plural-Forms: nplurals=2; plural=(n != 1);\n"
#: mailer/admin.py:69 #: mailer/admin.py:56
msgid "Send message" msgid "Send message"
msgstr "Nachricht verschicken" msgstr "Nachricht verschicken"
#: mailer/admin.py:96 #: mailer/admin.py:86
msgid ""
"Your account is not connected to a member. Please contact your system "
"administrator."
msgstr ""
"Dein Account ist nicht mit eine*r Teilnehmer*in verknüpft. Bitte kontaktiere "
"deine*n Systemadministrator*in."
#: mailer/admin.py:100
msgid ""
"Your email address is not an internal email address. Please change your "
"email address and try again."
msgstr ""
"Deine E-Mail Adresse ist keine DAV360 E-Mail Adresse. Bitte stelle sicher, "
"dass deine E-Mail Adresse mit @alpenverein-heidelberg.de endet."
#: mailer/admin.py:104
msgid "Failed to send message" msgid "Failed to send message"
msgstr "Fehler beim Senden der Email" msgstr "Fehler beim Senden der Email"
#: mailer/admin.py:106 #: mailer/admin.py:88
msgid "Failed to send some messages" msgid "Failed to send some messages"
msgstr "Fehler beim Senden der Emails" msgstr "Fehler beim Senden der Emails"
#: mailer/admin.py:108 #: mailer/admin.py:90
msgid "Successfully sent message" msgid "Successfully sent message"
msgstr "Email wurde erfolgreich verschickt" msgstr "Email wurde erfolgreich verschickt"
@ -54,143 +38,125 @@ msgstr "Email wurde erfolgreich verschickt"
msgid "mailer" msgid "mailer"
msgstr "Verteiler" msgstr "Verteiler"
#: mailer/management/commands/notify_active.py:36 #: mailer/management/commands/notify_active.py:38
#, python-format #, python-format
msgid "Congratulation %(name)s" msgid "Congratulation %(name)s"
msgstr "Herzlichen Glückwunsch %(name)s" msgstr "Herzlichen Glückwunsch %(name)s"
#: mailer/models.py:20 #: mailer/models.py:18
msgid "Only alphanumeric characters, ., - and _ are allowed" msgid "Only alphanumeric characters are allowed"
msgstr "Nur Buchstaben, Zahlen, ., . und _ sind erlaubt" msgstr "Nur Buchstaben und Zahlen erlaubt"
#: mailer/models.py:25 #: mailer/models.py:23
msgid "name" msgid "name"
msgstr "Name" msgstr "Name"
#: mailer/models.py:27 #: mailer/models.py:25
msgid "Forward to participants" msgid "Forward to participants"
msgstr "Weiterleitung an Teilnehmer*innen" msgstr "Weiterleitung an Teilnehmer"
#: mailer/models.py:30 #: mailer/models.py:28
msgid "Forward to group" msgid "Forward to group"
msgstr "Weiterleitung an Gruppe" msgstr "Weiterleitung an Gruppe"
#: mailer/models.py:32 #: mailer/models.py:45
msgid "Restrict to internal email addresses"
msgstr "Weiterleitung nur von internen E-Mail Adressen erlaubt"
#: mailer/models.py:33
msgid "Only allow forwarding to this e-mail address from the internal domain."
msgstr ""
"Leite nur E-Mails weiter, die von ...@alpenverein-heidelberg.de verschickt "
"wurden. "
#: mailer/models.py:36
msgid "Allowed sender"
msgstr "Erlaubte Absender:innen"
#: mailer/models.py:37
msgid ""
"Only forward e-mails of members of selected groups. Leave empty to allow all "
"senders."
msgstr ""
"Leite nur E-Mails von Mitgliedern dieser Gruppen weiter. Lasse dieses Feld "
"frei, um alle Absender*innen zu erlauben."
#: mailer/models.py:55
msgid "email address" msgid "email address"
msgstr "Email-Adresse" msgstr "Email-Adresse"
#: mailer/models.py:56 #: mailer/models.py:46
msgid "email addresses" msgid "email addresses"
msgstr "Email-Adressen" msgstr "Email-Adressen"
#: mailer/models.py:69 #: mailer/models.py:59
msgid "Either a group or at least one member is required as forward recipient." msgid "Either a group or at least one member is required as forward recipient."
msgstr "" msgstr ""
"Es muss entweder eine Gruppe oder mindestens ein*e Teilnehmer*in als " "Es muss entweder eine Gruppe oder mindestens ein Teilnehmer als Empfänger "
"Empfänger*in ausgewählt werden." "ausgewählt werden."
#: mailer/models.py:77 #: mailer/models.py:67
msgid "subject" msgid "subject"
msgstr "Betreff" msgstr "Betreff"
#: mailer/models.py:78 #: mailer/models.py:68
msgid "content" msgid "content"
msgstr "Inhalt" msgstr "Inhalt"
#: mailer/models.py:80 #: mailer/models.py:70
msgid "to group" msgid "to group"
msgstr "An Gruppe" msgstr "An Gruppe"
#: mailer/models.py:83 #: mailer/models.py:73
msgid "to freizeit" msgid "to freizeit"
msgstr "An Ausfahrt" msgstr "An Freizeit"
#: mailer/models.py:88 #: mailer/models.py:78
msgid "to notes list" msgid "to notes list"
msgstr "An Notizliste" msgstr "An Notizliste"
#: mailer/models.py:93 #: mailer/models.py:83
msgid "to member" msgid "to member"
msgstr "An Teilnehmer*innen" msgstr "An Teilnehmer"
#: mailer/models.py:96 #: mailer/models.py:86
msgid "reply to participant" msgid "reply to participant"
msgstr "Antwort an Teilnehmer*innen" msgstr "Antwort an Teilnehmer"
#: mailer/models.py:100 #: mailer/models.py:90
msgid "reply to custom email address" msgid "reply to custom email address"
msgstr "Antwort an Email-Adresse" msgstr "Antwort an Email-Adresse"
#: mailer/models.py:103 #: mailer/models.py:93
msgid "sent" msgid "sent"
msgstr "Gesendet" msgstr "Gesendet"
#: mailer/models.py:104 #: mailer/models.py:107
msgid "Created by"
msgstr "Erstellt von"
#: mailer/models.py:122
msgid "Some other members" msgid "Some other members"
msgstr "Andere Teilnehmer*innen" msgstr "Andere Teilnehmer"
#: mailer/models.py:124 #: mailer/models.py:109
msgid "recipients" msgid "recipients"
msgstr "Empfänger" msgstr "Empfänger"
#: mailer/models.py:196 #: mailer/models.py:182
msgid "message" msgid "message"
msgstr "Nachricht" msgstr "Nachricht"
#: mailer/models.py:197 #: mailer/models.py:183
msgid "messages" msgid "messages"
msgstr "Nachrichten" msgstr "Nachrichten"
#: mailer/models.py:199 #: mailer/models.py:185
msgid "Can submit mails" msgid "Can submit mails"
msgstr "Kann Mails verschicken" msgstr "Kann Mails verschicken"
#: mailer/models.py:220 #: mailer/models.py:201
msgid "" msgid ""
"Either a group, a memberlist or at least one member is required as recipient" "Either a group, a memberlist or at least one member is required as recipient"
msgstr "" msgstr ""
"Es muss entweder eine Gruppe, eine Teilnehmer*innenliste oder mindestens " "Es muss entweder eine Gruppe, eine Teilnehmerliste oder mindestens ein "
"ein*e Teilnehmer*in als Empfänger*in ausgewählt werden." "Teilnehmer als Empfänger ausgewählt werden."
#: mailer/models.py:206
msgid ""
"At least one reply-to recipient is required. Use the info mail if you really "
"want no reply-to recipient."
msgstr ""
"Es muss mindestens ein Antwort-An Empfänger angegeben werden. Nutze die info "
"Email-Adresse falls du wirklich keinen Antwort-An Empfänger haben möchtest."
#: mailer/models.py:227 #: mailer/models.py:213
msgid "file" msgid "file"
msgstr "Datei" msgstr "Datei"
#: mailer/models.py:232 #: mailer/models.py:219
msgid "Empty" msgid "Empty"
msgstr "Leer" msgstr "Leer"
#: mailer/models.py:235 #: mailer/models.py:222
msgid "attachment" msgid "attachment"
msgstr "Anhang" msgstr "Anhang"
#: mailer/models.py:236 #: mailer/models.py:223
msgid "attachments" msgid "attachments"
msgstr "Anhänge" msgstr "Anhänge"
@ -271,7 +237,7 @@ msgstr "Vorname"
msgid "Lastname" msgid "Lastname"
msgstr "Nachname" msgstr "Nachname"
#: mailer/templates/mailer/subscribe.html:26 mailer/views.py:60 #: mailer/templates/mailer/subscribe.html:26 mailer/views.py:59
msgid "Birthdate" msgid "Birthdate"
msgstr "Geburtsdatum" msgstr "Geburtsdatum"
@ -296,30 +262,22 @@ msgstr "Hier kannst du dich vom Newsletter abmelden"
msgid "Successfully unsubscribed from the newsletter for " msgid "Successfully unsubscribed from the newsletter for "
msgstr "Newsletter erfolgreich abbestellt für " msgstr "Newsletter erfolgreich abbestellt für "
#: mailer/views.py:36 #: mailer/views.py:35
msgid "Can't verify this link. Try again!" msgid "Can't verify this link. Try again!"
msgstr "Ungültiger Link. Bitte nochmal versuchen!" msgstr "Ungültiger Link. Bitte nochmal versuchen!"
#: mailer/views.py:48 #: mailer/views.py:47
msgid "Please fill in every field" msgid "Please fill in every field"
msgstr "Bitte jedes Feld ausfüllen!" msgstr "Bitte jedes Feld ausfüllen!"
#: mailer/views.py:50 #: mailer/views.py:49
msgid "Unsubscription confirmation" msgid "Unsubscription confirmation"
msgstr "Abmeldebestätigung" msgstr "Abmeldebestätigung"
#: mailer/views.py:83 #: mailer/views.py:82
msgid "Please fill in every field!" msgid "Please fill in every field!"
msgstr "Bitte jedes Feld ausfüllen!" msgstr "Bitte jedes Feld ausfüllen!"
#: mailer/views.py:90 #: mailer/views.py:89
msgid "Member already exists" msgid "Member already exists"
msgstr "Mitglied schon vorhanden" msgstr "Mitglied schon vorhanden"
#~ msgid ""
#~ "At least one reply-to recipient is required. Use the info mail if you "
#~ "really want no reply-to recipient."
#~ msgstr ""
#~ "Es muss mindestens ein Antwort-An Empfänger angegeben werden. Nutze die "
#~ "info Email-Adresse falls du wirklich keinen Antwort-An Empfänger haben "
#~ "möchtest."

@ -7,29 +7,23 @@ import os
NOT_SENT, SENT, PARTLY_SENT = 0, 1, 2 NOT_SENT, SENT, PARTLY_SENT = 0, 1, 2
def send(subject, content, sender, recipients, message_id=None, reply_to=None, def send(subject, content, sender, recipients, message_id=None, reply_to=None,
attachments=None, cc=None): attachments=None):
failed, succeeded = False, False failed, succeeded = False, False
if type(recipients) != list: if type(recipients) != list:
recipients = [recipients] recipients = [recipients]
if not cc:
cc = []
elif type(cc) != list:
cc = [cc]
if reply_to is not None: if reply_to is not None:
kwargs = {"reply_to": reply_to} kwargs = {"reply_to": reply_to}
else: else:
kwargs = {} kwargs = {}
if sender == settings.DEFAULT_SENDING_MAIL:
sender = addr_with_name(settings.DEFAULT_SENDING_MAIL, settings.DEFAULT_SENDING_NAME)
url = prepend_base_url("/newsletter/unsubscribe")
headers = {'List-Unsubscribe': '<{unsubscribe_url}>'.format(unsubscribe_url=url)}
if message_id is not None: if message_id is not None:
headers['Message-ID'] = message_id headers = {'Message-ID': message_id}
else:
headers = {}
# construct mails # construct mails
mails = [] mails = []
for recipient in set(recipients): for recipient in set(recipients):
email = EmailMessage(subject, content, sender, [recipient], cc=cc, email = EmailMessage(subject, content, sender, [recipient],
headers=headers, **kwargs) headers=headers, **kwargs)
if attachments is not None: if attachments is not None:
for attach in attachments: for attach in attachments:
@ -53,8 +47,10 @@ def send(subject, content, sender, recipients, message_id=None, reply_to=None,
def get_content(content, registration_complete=True): def get_content(content, registration_complete=True):
url = prepend_base_url("/newsletter/unsubscribe") url = prepend_base_url("/newsletter/unsubscribe")
prepend = settings.PREPEND_INCOMPLETE_REGISTRATION_TEXT prepend = settings.PREPEND_INCOMPLETE_REGISTRATION_TEXT
text = "{prepend}{content}".format(prepend="" if registration_complete else prepend, footer = settings.MAIL_FOOTER.format(link=url)
content=content) text = "{prepend}{content}{footer}".format(prepend="" if registration_complete else prepend,
content=content,
footer=footer)
return text return text
@ -68,14 +64,11 @@ def get_echo_link(member):
return prepend_base_url("/members/echo?key={}".format(key)) return prepend_base_url("/members/echo?key={}".format(key))
def get_registration_link(key): def get_registration_link(waiter):
key = waiter.generate_registration_key()
return prepend_base_url("/members/registration?key={}".format(key)) return prepend_base_url("/members/registration?key={}".format(key))
def get_invitation_reject_link(key):
return prepend_base_url("/members/waitinglist/invitation/reject?key={}".format(key))
def get_wait_confirmation_link(waiter): def get_wait_confirmation_link(waiter):
key = waiter.generate_wait_confirmation_key() key = waiter.generate_wait_confirmation_key()
return prepend_base_url("/members/waitinglist/confirm?key={}".format(key)) return prepend_base_url("/members/waitinglist/confirm?key={}".format(key))
@ -85,13 +78,5 @@ def get_mail_confirmation_link(key):
return prepend_base_url("/members/mail/confirm?key={}".format(key)) return prepend_base_url("/members/mail/confirm?key={}".format(key))
def get_invite_as_user_key(key):
return prepend_base_url("/login/register?key={}".format(key))
def prepend_base_url(absolutelink): def prepend_base_url(absolutelink):
return "{protocol}://{base}{link}".format(protocol=settings.PROTOCOL, base=settings.BASE_URL, link=absolutelink) return "{protocol}://{base}{link}".format(protocol=settings.PROTOCOL, base=settings.BASE_URL, link=absolutelink)
def addr_with_name(addr, name):
return "{name} <{addr}>".format(name=name, addr=addr)

@ -1,20 +0,0 @@
# Generated by Django 4.0.1 on 2023-04-02 12:06
from django.db import migrations, models
import django.db.models.deletion
class Migration(migrations.Migration):
dependencies = [
('members', '0006_rename_permissions'),
('mailer', '0001_initial_squashed_0006_auto_20210924_1155'),
]
operations = [
migrations.AddField(
model_name='message',
name='created_by',
field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='created_messages', to='members.member', verbose_name='Created by'),
),
]

@ -1,17 +0,0 @@
# Generated by Django 4.0.1 on 2023-04-04 12:35
from django.db import migrations
class Migration(migrations.Migration):
dependencies = [
('mailer', '0002_message_created_by'),
]
operations = [
migrations.AlterModelOptions(
name='message',
options={'default_permissions': ('add_global', 'change_global', 'view_global', 'delete_global', 'list_global', 'view'), 'permissions': (('submit_mails', 'Can submit mails'),), 'verbose_name': 'message', 'verbose_name_plural': 'messages'},
),
]

@ -1,19 +0,0 @@
# Generated by Django 4.0.1 on 2024-11-17 23:31
from django.db import migrations
import utils
class Migration(migrations.Migration):
dependencies = [
('mailer', '0003_alter_message_options'),
]
operations = [
migrations.AlterField(
model_name='attachment',
name='f',
field=utils.RestrictedFileField(upload_to='attachments', verbose_name='file'),
),
]

@ -1,19 +0,0 @@
# Generated by Django 4.0.1 on 2024-11-23 14:03
import django.core.validators
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('mailer', '0004_alter_attachment_f'),
]
operations = [
migrations.AlterField(
model_name='emailaddress',
name='name',
field=models.CharField(max_length=50, validators=[django.core.validators.RegexValidator('^[0-9a-zA-Z._-]*$', 'Only alphanumeric characters, ., - and _ are allowed')], verbose_name='name'),
),
]

@ -1,19 +0,0 @@
# Generated by Django 4.0.1 on 2024-12-01 15:54
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('members', '0029_alter_member_gender_alter_memberwaitinglist_gender'),
('mailer', '0005_alter_emailaddress_name'),
]
operations = [
migrations.AddField(
model_name='emailaddress',
name='allowed_senders',
field=models.ManyToManyField(blank=True, help_text='Only forward e-mails of members of selected groups. Leave empty to allow all senders.', related_name='allowed_sender_on_emailaddresses', to='members.Group', verbose_name='Allowed sender'),
),
]

@ -1,18 +0,0 @@
# Generated by Django 4.0.1 on 2024-12-01 17:45
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('mailer', '0006_emailaddress_allowed_senders'),
]
operations = [
migrations.AddField(
model_name='emailaddress',
name='internal_only',
field=models.BooleanField(default=False, help_text='Only allow forwarding to this e-mail address from the internal domain.', verbose_name='Restrict to internal email addresses'),
),
]

@ -3,21 +3,16 @@ from django.core.exceptions import ValidationError
from django import forms from django import forms
from django.utils.translation import gettext_lazy as _ from django.utils.translation import gettext_lazy as _
from django.utils.translation import gettext from django.utils.translation import gettext
from .mailutils import send, get_content, NOT_SENT, SENT, PARTLY_SENT,\ from .mailutils import send, get_content, NOT_SENT, SENT, PARTLY_SENT
addr_with_name
from utils import RestrictedFileField from utils import RestrictedFileField
from jdav_web.celery import app from jdav_web.celery import app
from django.core.validators import RegexValidator from django.core.validators import RegexValidator
from django.conf import settings from django.conf import settings
from contrib.models import CommonModel
from .rules import is_creator
import os import os
alphanumeric = RegexValidator(r'^[0-9a-zA-Z._-]*$', alphanumeric = RegexValidator(r'^[0-9a-zA-Z]*$', _('Only alphanumeric characters are allowed'))
_('Only alphanumeric characters, ., - and _ are allowed'))
class EmailAddress(models.Model): class EmailAddress(models.Model):
@ -29,14 +24,6 @@ class EmailAddress(models.Model):
to_groups = models.ManyToManyField('members.Group', to_groups = models.ManyToManyField('members.Group',
verbose_name=_('Forward to group'), verbose_name=_('Forward to group'),
blank=True) blank=True)
internal_only = models.BooleanField(verbose_name=_('Restrict to internal email addresses'),
help_text=_('Only allow forwarding to this e-mail address from the internal domain.'),
default=False)
allowed_senders = models.ManyToManyField('members.Group',
verbose_name=_('Allowed sender'),
help_text=_('Only forward e-mails of members of selected groups. Leave empty to allow all senders.'),
blank=True,
related_name='allowed_sender_on_emailaddresses')
@property @property
def email(self): def email(self):
@ -72,7 +59,7 @@ class EmailAddressForm(forms.ModelForm):
# Create your models here. # Create your models here.
class Message(CommonModel): class Message(models.Model):
"""Represents a message that can be sent to some members""" """Represents a message that can be sent to some members"""
subject = models.CharField(_('subject'), max_length=50) subject = models.CharField(_('subject'), max_length=50)
content = models.TextField(_('content')) content = models.TextField(_('content'))
@ -101,11 +88,6 @@ class Message(CommonModel):
blank=True, blank=True,
related_name='reply_to_email_addr') related_name='reply_to_email_addr')
sent = models.BooleanField(_('sent'), default=False) sent = models.BooleanField(_('sent'), default=False)
created_by = models.ForeignKey('members.Member', verbose_name=_('Created by'),
blank=True,
null=True,
on_delete=models.SET_NULL,
related_name='created_messages')
def __str__(self): def __str__(self):
return self.subject return self.subject
@ -123,7 +105,7 @@ class Message(CommonModel):
return ", ".join(recipients) return ", ".join(recipients)
get_recipients.short_description = _('recipients') get_recipients.short_description = _('recipients')
def submit(self, sender=None): def submit(self):
"""Sends the mail to the specified group of members""" """Sends the mail to the specified group of members"""
# recipients # recipients
members = set() members = set()
@ -146,39 +128,40 @@ class Message(CommonModel):
attach = [a.f.path for a in Attachment.objects.filter(msg__id=self.pk) attach = [a.f.path for a in Attachment.objects.filter(msg__id=self.pk)
if a.f.name] if a.f.name]
recipients_with_reminder = [m for m in filtered if not m.registered]
emails = [member.email for member in filtered] recipients_without_reminder = [m for m in filtered if m.registered]
emails.extend([member.alternative_email for member in filtered if member.alternative_email])
emails_rem = [member.email for member in recipients_with_reminder]
emails_rem.extend([member.email_parents for member in recipients_with_reminder
if member.email_parents and member.cc_email_parents])
emails_no_rem = [member.email for member in recipients_without_reminder]
emails_no_rem.extend([member.email_parents for member in recipients_without_reminder
if member.email_parents and member.cc_email_parents])
# remove any underscores from subject to prevent Arne from using # remove any underscores from subject to prevent Arne from using
# terrible looking underscores in subjects # terrible looking underscores in subjects
self.subject = self.subject.replace('_', ' ') self.subject = self.subject.replace('_', ' ')
# generate message id # generate message id
message_id = "<{pk}@{domain}>".format(pk=self.pk, domain=settings.DOMAIN) message_id = "<{pk}@{domain}>".format(pk=self.pk, domain=settings.DOMAIN)
# reply to addresses # reply to addresses
reply_to = [jl.association_email for jl in self.reply_to.all()] reply_to_unfiltered = [jl.association_email for jl in self.reply_to.all()]
reply_to.extend([ml.email for ml in self.reply_to_email_address.all()]) reply_to_unfiltered.extend([ml.email for ml in self.reply_to_email_address.all()])
# set correct from address # remove sending address from reply-to field (probably unnecessary since it's removed by
# if the sender is none or if sending from association emails has been # the mail provider anyways)
# disabled, use the default sending mail reply_to = [mail for mail in reply_to_unfiltered if mail != settings.DEFAULT_SENDING_MAIL ]
if sender is None:
from_addr = addr_with_name(settings.DEFAULT_SENDING_MAIL, settings.DEFAULT_SENDING_NAME)
elif sender and settings.SEND_FROM_ASSOCIATION_EMAIL:
from_addr = addr_with_name(sender.association_email, sender.name)
else:
from_addr = addr_with_name(settings.DEFAULT_SENDING_MAIL, sender.name)
# if sending from the association email has been disabled,
# a sender was supplied and the reply to is empty, add the sender's
# DAV360 email as reply to
if sender and not settings.SEND_FROM_ASSOCIATION_EMAIL and sender.has_internal_email() and reply_to == []:
reply_to.append(addr_with_name(sender.email, sender.name))
try: try:
success = send(self.subject, get_content(self.content, registration_complete=True), success1 = send(self.subject, get_content(self.content, registration_complete=False),
from_addr, settings.DEFAULT_SENDING_MAIL,
emails, emails_rem,
message_id=message_id,
attachments=attach,
reply_to=reply_to)
success2 = send(self.subject, get_content(self.content, registration_complete=True),
settings.DEFAULT_SENDING_MAIL,
emails_no_rem,
message_id=message_id, message_id=message_id,
attachments=attach, attachments=attach,
reply_to=reply_to) reply_to=reply_to)
if success == SENT or success == PARTLY_SENT: if (success1 == SENT or success1 == PARTLY_SENT) and (success2 == SENT or success2 == PARTLY_SENT):
self.sent = True self.sent = True
for a in Attachment.objects.filter(msg__id=self.pk): for a in Attachment.objects.filter(msg__id=self.pk):
if a.f.name: if a.f.name:
@ -192,17 +175,12 @@ class Message(CommonModel):
self.save() self.save()
return success return success
class Meta(CommonModel.Meta): class Meta:
verbose_name = _('message') verbose_name = _('message')
verbose_name_plural = _('messages') verbose_name_plural = _('messages')
permissions = ( permissions = (
("submit_mails", _("Can submit mails")), ("submit_mails", _("Can submit mails")),
) )
rules_permissions = {
"view_obj": is_creator,
"change_obj": is_creator,
"delete_obj": is_creator,
}
class MessageForm(forms.ModelForm): class MessageForm(forms.ModelForm):
@ -219,14 +197,20 @@ class MessageForm(forms.ModelForm):
if not group and freizeit is None and not members and notelist is None: if not group and freizeit is None and not members and notelist is None:
raise ValidationError(_('Either a group, a memberlist or at least' raise ValidationError(_('Either a group, a memberlist or at least'
' one member is required as recipient')) ' one member is required as recipient'))
reply_to = self.cleaned_data.get('reply_to')
reply_to_email_address = self.cleaned_data.get('reply_to_email_address')
if not reply_to and not reply_to_email_address:
raise ValidationError(_('At least one reply-to recipient is required. '
'Use the info mail if you really want no reply-to recipient.'))
class Attachment(CommonModel): class Attachment(models.Model):
"""Represents an attachment to an email""" """Represents an attachment to an email"""
msg = models.ForeignKey(Message, on_delete=models.CASCADE) msg = models.ForeignKey(Message, on_delete=models.CASCADE)
# file (not naming it file because of builtin) # file (not naming it file because of builtin)
f = RestrictedFileField(_('file'), f = RestrictedFileField(_('file'),
upload_to='attachments', upload_to='attachments',
max_upload_size=10) blank=True,
max_upload_size=10485760)
def __str__(self): def __str__(self):
return os.path.basename(self.f.name) if self.f.name else _("Empty") return os.path.basename(self.f.name) if self.f.name else _("Empty")
@ -234,10 +218,3 @@ class Attachment(CommonModel):
class Meta: class Meta:
verbose_name = _('attachment') verbose_name = _('attachment')
verbose_name_plural = _('attachments') verbose_name_plural = _('attachments')
rules_permissions = {
"add_obj": is_creator,
"view_obj": is_creator,
"change_obj": is_creator,
"delete_obj": is_creator,
}

@ -1,10 +0,0 @@
from contrib.rules import memberize_user
from rules import predicate
@predicate
@memberize_user
def is_creator(self, message):
if message is None:
return False
return message.created_by == self

@ -55,6 +55,9 @@ class MaterialAdmin(admin.ModelAdmin):
# models.ManyToManyField: {'widget': forms.CheckboxSelectMultiple} # models.ManyToManyField: {'widget': forms.CheckboxSelectMultiple}
#} #}
#class Media:
# css = {'all': ('admin/css/tabular_hide_original.css',)}
admin.site.register(MaterialCategory, MaterialCategoryAdmin) admin.site.register(MaterialCategory, MaterialCategoryAdmin)
admin.site.register(MaterialPart, MaterialAdmin) admin.site.register(MaterialPart, MaterialAdmin)

@ -8,7 +8,7 @@ msgid ""
msgstr "" msgstr ""
"Project-Id-Version: PACKAGE VERSION\n" "Project-Id-Version: PACKAGE VERSION\n"
"Report-Msgid-Bugs-To: \n" "Report-Msgid-Bugs-To: \n"
"POT-Creation-Date: 2024-12-01 16:23+0100\n" "POT-Creation-Date: 2023-03-20 18:48+0100\n"
"PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\n" "PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\n"
"Last-Translator: FULL NAME <EMAIL@ADDRESS>\n" "Last-Translator: FULL NAME <EMAIL@ADDRESS>\n"
"Language-Team: LANGUAGE <LL@li.org>\n" "Language-Team: LANGUAGE <LL@li.org>\n"

File diff suppressed because it is too large Load Diff

@ -4,4 +4,4 @@ from django.utils.translation import gettext_lazy as _
class MembersConfig(AppConfig): class MembersConfig(AppConfig):
name = 'members' name = 'members'
verbose_name = _('member administration') verbose_name = _('members')

File diff suppressed because it is too large Load Diff

@ -1,27 +0,0 @@
# Generated by Django 4.0.1 on 2023-04-01 20:46
from django.db import migrations
class Migration(migrations.Migration):
dependencies = [
('members', '0004_add_training_data_alter_required_flags'),
]
operations = [
migrations.RemoveField(
model_name='oldmemberonlist',
name='member',
),
migrations.RemoveField(
model_name='oldmemberonlist',
name='memberlist',
),
migrations.DeleteModel(
name='MemberList',
),
migrations.DeleteModel(
name='OldMemberOnList',
),
]

@ -1,32 +0,0 @@
# Stub Generated by Django 4.0.1 on 2023-04-02 08:29 and edited manually
from django.db import migrations
def rename_permissions(apps, schema_editor):
Permission = apps.get_model("auth", "Permission")
for modelcodename in ["klettertreffattendee", "member", "newmemberonlist"]:
for action in ["view", "add", "change", "delete"]:
Permission.objects \
.filter(codename="{action}_{modelcodename}".format(action=action, modelcodename=modelcodename)) \
.update(name='Can {action} {modelcodename}'.format(action=action, modelcodename=modelcodename))
def remove_old_memberlist_permissions(apps, schema_editor):
Permission = apps.get_model("auth", "Permission")
for action in ["view", "add", "change", "delete"]:
Permission.objects.filter(codename="{action}_oldmemberonlist".format(action=action)).delete()
Permission.objects.filter(codename="{action}_memberlist".format(action=action)).delete()
class Migration(migrations.Migration):
dependencies = [
('members', '0005_remove_oldmemberonlist_member_and_more'),
]
operations = [
migrations.RunPython(rename_permissions, migrations.RunPython.noop),
migrations.RunPython(remove_old_memberlist_permissions, migrations.RunPython.noop),
]

@ -1,25 +0,0 @@
# Generated by Django 4.0.1 on 2023-04-03 20:33
from django.db import migrations
class Migration(migrations.Migration):
dependencies = [
('members', '0006_rename_permissions'),
]
operations = [
migrations.AlterModelOptions(
name='freizeit',
options={'verbose_name': 'Freizeit', 'verbose_name_plural': 'Freizeiten'},
),
migrations.AlterModelOptions(
name='member',
options={'permissions': (('may_see_qualities', 'Is allowed to see the quality overview'), ('may_set_auth_user', 'Is allowed to set auth user member connections.'), ('change_member_group', 'Can change the group field')), 'verbose_name': 'member', 'verbose_name_plural': 'members'},
),
migrations.AlterModelOptions(
name='membertraining',
options={'verbose_name': 'Training', 'verbose_name_plural': 'Trainings'},
),
]

@ -1,38 +0,0 @@
# Generated by Django 4.0.1 on 2023-04-03 21:20
from django.db import migrations
import django.db.migrations.operations.special
class Migration(migrations.Migration):
dependencies = [
('members', '0007_alter_permission_options'),
]
operations = [
migrations.AlterModelOptions(
name='member',
options={'default_permissions': ('add_global', 'change_global', 'delete_global', 'view_global'), 'permissions': (('may_see_qualities', 'Is allowed to see the quality overview'), ('may_set_auth_user', 'Is allowed to set auth user member connections.'), ('change_member_group', 'Can change the group field')), 'verbose_name': 'member', 'verbose_name_plural': 'members'},
),
migrations.AlterModelOptions(
name='freizeit',
options={'default_permissions': ('add_global', 'change_global', 'view_global', 'delete_global', 'list_global'), 'verbose_name': 'Freizeit', 'verbose_name_plural': 'Freizeiten'},
),
migrations.AlterModelOptions(
name='ljpproposal',
options={'default_permissions': ('add_global', 'change_global', 'view_global', 'delete_global', 'list_global'), 'verbose_name': 'LJP Proposal', 'verbose_name_plural': 'LJP Proposals'},
),
migrations.AlterModelOptions(
name='membertraining',
options={'default_permissions': ('add_global', 'change_global', 'view_global', 'delete_global', 'list_global'), 'verbose_name': 'Training', 'verbose_name_plural': 'Trainings'},
),
migrations.AlterModelOptions(
name='newmemberonlist',
options={'default_permissions': ('add_global', 'change_global', 'view_global', 'delete_global', 'list_global'), 'verbose_name': 'Member', 'verbose_name_plural': 'Members'},
),
migrations.AlterModelOptions(
name='member',
options={'default_permissions': ('add_global', 'change_global', 'view_global', 'delete_global', 'list_global'), 'permissions': (('may_see_qualities', 'Is allowed to see the quality overview'), ('may_set_auth_user', 'Is allowed to set auth user member connections.'), ('change_member_group', 'Can change the group field')), 'verbose_name': 'member', 'verbose_name_plural': 'members'},
),
]

@ -1,37 +0,0 @@
# Generated by Django 4.0.1 on 2023-04-04 12:20
from django.db import migrations
class Migration(migrations.Migration):
dependencies = [
('members', '0008_change_default_permissions'),
]
operations = [
migrations.AlterModelOptions(
name='freizeit',
options={'default_permissions': ('add_global', 'change_global', 'view_global', 'delete_global', 'list_global', 'view'), 'verbose_name': 'Freizeit', 'verbose_name_plural': 'Freizeiten'},
),
migrations.AlterModelOptions(
name='ljpproposal',
options={'default_permissions': ('add_global', 'change_global', 'view_global', 'delete_global', 'list_global', 'view'), 'verbose_name': 'LJP Proposal', 'verbose_name_plural': 'LJP Proposals'},
),
migrations.AlterModelOptions(
name='member',
options={'default_permissions': ('add_global', 'change_global', 'view_global', 'delete_global', 'list_global', 'view'), 'permissions': (('may_see_qualities', 'Is allowed to see the quality overview'), ('may_set_auth_user', 'Is allowed to set auth user member connections.'), ('change_member_group', 'Can change the group field')), 'verbose_name': 'member', 'verbose_name_plural': 'members'},
),
migrations.AlterModelOptions(
name='membertraining',
options={'default_permissions': ('add_global', 'change_global', 'view_global', 'delete_global', 'list_global', 'view'), 'verbose_name': 'Training', 'verbose_name_plural': 'Trainings'},
),
migrations.AlterModelOptions(
name='memberwaitinglist',
options={'default_permissions': ('add_global', 'change_global', 'view_global', 'delete_global', 'list_global', 'view'), 'permissions': (('may_manage_waiting_list', 'Can view and manage the waiting list.'),), 'verbose_name': 'Waiter', 'verbose_name_plural': 'Waiters'},
),
migrations.AlterModelOptions(
name='newmemberonlist',
options={'default_permissions': ('add_global', 'change_global', 'view_global', 'delete_global', 'list_global', 'view'), 'verbose_name': 'Member', 'verbose_name_plural': 'Members'},
),
]

@ -1,125 +0,0 @@
# Generated by Django 4.0.1 on 2023-04-04 14:23
from django.utils.translation import gettext_lazy as _
from django.db import migrations
from django.contrib.auth.management import create_permissions
STANDARD_PERMS = [
('members', 'view_member'),
('members', 'view_freizeit'),
('members', 'add_global_freizeit'),
('mailer', 'view_message'),
('mailer', 'add_global_message'),
('finance', 'view_statementunsubmitted'),
('finance', 'add_global_statementunsubmitted'),
]
FINANCE_PERMS = [
('finance', 'view_bill'),
('finance', 'view_ledger'),
('finance', 'add_ledger'),
('finance', 'change_ledger'),
('finance', 'delete_ledger'),
('finance', 'view_statementsubmitted'),
('finance', 'view_global_statementsubmitted'),
('finance', 'change_global_statementsubmitted'),
('finance', 'view_transaction'),
('finance', 'change_transaction'),
('finance', 'add_transaction'),
('finance', 'delete_transaction'),
('finance', 'process_statementsubmitted'),
('members', 'list_global_freizeit'),
('members', 'view_global_freizeit'),
]
WAITINGLIST_PERMS = [
('members', 'view_memberwaitinglist'),
('members', 'view_global_memberwaitinglist'),
('members', 'list_global_memberwaitinglist'),
('members', 'change_global_memberwaitinglist'),
('members', 'delete_global_memberwaitinglist'),
]
TRAINING_PERMS = [
('members', 'change_global_member'),
('members', 'list_global_member'),
('members', 'view_global_member'),
('members', 'add_global_membertraining'),
('members', 'change_global_membertraining'),
('members', 'list_global_membertraining'),
('members', 'view_global_membertraining'),
('members', 'view_trainingcategory'),
('members', 'add_trainingcategory'),
('members', 'change_trainingcategory'),
('members', 'delete_trainingcategory'),
]
REGISTRATION_PERMS = [
('members', 'may_manage_all_registrations'),
('members', 'change_memberunconfirmedproxy'),
('members', 'view_memberunconfirmedproxy'),
('members', 'delete_memberunconfirmedproxy'),
]
MATERIAL_PERMS = [
('members', 'list_global_member'),
('material', 'view_materialpart'),
('material', 'change_materialpart'),
('material', 'add_materialpart'),
('material', 'delete_materialpart'),
('material', 'view_materialcategory'),
('material', 'change_materialcategory'),
('material', 'add_materialcategory'),
('material', 'delete_materialcategory'),
('material', 'view_ownership'),
('material', 'change_ownership'),
('material', 'add_ownership'),
('material', 'delete_ownership'),
]
def create_group_with_perms(apps, schema_editor, name, perm_names):
db_alias = schema_editor.connection.alias
Group = apps.get_model("auth", "Group")
Permission = apps.get_model("auth", "Permission")
if Group.objects.filter(name=name).exists():
raise ValueError("A group with name %s already exists." % name)
perms = [ Permission.objects.get(codename=codename, content_type__app_label=app_label) for app_label, codename in perm_names ]
g = Group.objects.using(db_alias).create(name=name)
g.permissions.set(perms)
g.save()
def try_create_group_with_perms(apps, schema_editor, name, perm_names):
Group = apps.get_model("auth", "Group")
if not Group.objects.filter(name=name).exists():
create_group_with_perms(apps, schema_editor, name, perm_names)
def create_default_permission_groups(apps, schema_editor):
for app_config in apps.get_app_configs():
app_config.models_module = True
create_permissions(app_config, verbosity=0)
try_create_group_with_perms(apps, schema_editor, "Standard", STANDARD_PERMS)
try_create_group_with_perms(apps, schema_editor, "Finance", FINANCE_PERMS)
try_create_group_with_perms(apps, schema_editor, "Waitinglist", WAITINGLIST_PERMS)
try_create_group_with_perms(apps, schema_editor, "Trainings", TRAINING_PERMS)
try_create_group_with_perms(apps, schema_editor, "Registrations", REGISTRATION_PERMS)
try_create_group_with_perms(apps, schema_editor, "Material", MATERIAL_PERMS)
class Migration(migrations.Migration):
dependencies = [
('auth', '0001_initial'),
('contenttypes', '0001_initial'),
('material', '0001_initial_squashed_0002_auto_20171011_2045'),
('finance', '0003_alter_bill_options_and_more'),
('mailer', '0003_alter_message_options'),
('members', '0009_alter_freizeit_options_alter_ljpproposal_options_and_more'),
]
operations = [
migrations.RunPython(create_default_permission_groups, migrations.RunPython.noop),
]

@ -1,24 +0,0 @@
# Generated by Django 4.0.1 on 2023-04-04 21:50
from django.db import migrations, models
import django.utils.timezone
class Migration(migrations.Migration):
dependencies = [
('members', '0010_create_default_permission_groups'),
]
operations = [
migrations.AlterField(
model_name='freizeit',
name='date',
field=models.DateTimeField(default=django.utils.timezone.now, verbose_name='Begin'),
),
migrations.AlterField(
model_name='freizeit',
name='end',
field=models.DateTimeField(default=django.utils.timezone.now, verbose_name='End (optional)'),
),
]

@ -1,29 +0,0 @@
# Generated by Django 4.0.1 on 2023-04-09 11:46
from django.db import migrations, models
import utils
class Migration(migrations.Migration):
dependencies = [
('members', '0011_alter_freizeit_date_alter_freizeit_end'),
]
operations = [
migrations.AddField(
model_name='group',
name='description',
field=models.TextField(blank=True, default='', verbose_name='description'),
),
migrations.AddField(
model_name='group',
name='show_website',
field=models.BooleanField(default=False, verbose_name='show on website'),
),
migrations.AddField(
model_name='member',
name='image',
field=utils.RestrictedFileField(blank=True, upload_to='people', verbose_name='image'),
),
]

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

Loading…
Cancel
Save