Merge branch 'main' into MK/iban_checks

pull/104/head
mariusrklein 11 months ago
commit a281f6cf70

@ -1,8 +1,12 @@
build-test:
cd docker/test; docker compose build
test:
touch docker/test/coverage.xml
chmod 666 docker/test/coverage.xml
test: build-test
mkdir -p docker/test/htmlcov
chmod 777 docker/test/htmlcov
ifeq ($(keepdb), true)
cd docker/test; DJANGO_TEST_KEEPDB=1 docker compose up --abort-on-container-exit
else
cd docker/test; docker compose up --abort-on-container-exit
sed -i 's/\/app\/jdav_web/jdav_web/g' docker/test/coverage.xml
endif
echo "Generated coverage report. To read it, point your browser to:\n\nfile://$$(pwd)/docker/test/htmlcov/index.html"

@ -1,4 +1,4 @@
# kompass
# jdav Kompass
[![Build Status](https://jenkins.merten.dev/buildStatus/icon?job=gitea%2Fkompass%2Fmain)](https://jenkins.merten.dev/job/gitea/job/kompass/job/main/)
@ -13,94 +13,34 @@ Any form of contribution is appreciated. If you found a bug or have a feature re
[issue](https://git.jdav-hd.merten.dev/digitales/kompass/issues). If you want to help with the documentation or
want to contribute code, please open a [pull request](https://git.jdav-hd.merten.dev/digitales/kompass/pulls).
The following is a short description of the development setup and an explanation of the various
branches.
The following is a short description of where to find the documentation with more information.
## Development setup
The project is run with `docker` and all related files are in the `docker/` subfolder. Besides the actual Kompass
application, a database (postgresql) and a broker (redis) are setup and run in the docker container. No
external services are needed for running the development container.
# Documentation
### Initial installation
Documentation is handled by [sphinx](https://www.sphinx-doc.org/) and located in `docs/`.
A working `docker` setup (with `docker compose` support) is required. For installation instructions see the
[docker manual](https://docs.docker.com/engine/install/).
The sphinx documentation contains information about:
- Development Setup
- Architecture
- Testing
- Production Deployment
- End user documentation
- and much more...
1. Clone the repository and change into the directory of the repository.
> Please add all further documentation also in the sphinx documentation. And not in the readme
2. Fetch submodules
```bash
git submodule update --init
```
## online
Online (latest release version): https://jdav-hd.de/static/docs/
3. Prepare development environment: to allow automatic rebuilding upon changes in the source,
the owner of the `/app/jdav_web` directory in the docker container must agree with
your user. For this, make sure that the output of `echo UID` and `echo UID` is not empty. Then run
```bash
export GID=${GID}
export UID=${UID}
```
## local
To read the documentation build it locally and view it in your browser:
```bash
cd docs/
make html
4. Start docker
```bash
cd docker/development
docker compose up
```
This runs the docker in your current shell, which is useful to see any log output. If you want to run
the development server in the background instead, use `docker compose up -d`.
During the initial run, the container is built and all dependencies are installed which can take a few minutes.
After successful installation, the Kompass initialization runs, which in particular sets up all tables in the
database.
5. Setup admin user: in a separate shell, while the docker container is running, run
```bash
cd docker/development
docker compose exec master bash -c "cd jdav_web && python3 manage.py createsuperuser"
```
This creates an admin user for the administration interface.
### Development
If the initial installation was successful, you can start developing. Changes to files cause an automatic
reload of the development server. If you need to generate and perform database migrations or generate locale files,
use
# MacOS (with firefox)
open -a firefox $(pwd)/docs/build/html/index.html
# Linux (I guess?!?)
firefox ${pwd}/docs/build/html/index.html
```
cd docker/development
docker compose exec master bash
cd jdav_web
```
This starts a shell in the container, where you can execute any django maintenance commands via
`python3 manage.py <command>`. For more information, see the [django documentation](https://docs.djangoproject.com/en/4.0/ref/django-admin).
### Testing
To run the tests, you can use the docker setup under `docker/test`.
### Known Issues
- If the `UID` and `GID` variables are not setup properly, you will encounter the following error message
after running `docker compose up`.
```bash
=> ERROR [master 6/7] RUN groupadd -g fritze && useradd -g -u -m -d /app fritze 0.2s
------
> [master 6/7] RUN groupadd -g fritze && useradd -g -u -m -d /app fritze:
0.141 groupadd: invalid group ID 'fritze'
------
failed to solve: process "/bin/sh -c groupadd -g $GID $USER && useradd -g $GID -u $UID -m -d /app $USER" did not complete successfully: exit code: 3
```
In this case repeat step 3 above.
## Organization and branches
The stable development happens on the `main` branch for which only maintainers have write access. Any pull request
should hence be targeted at `main`. Regularly, the production instances are updated to the latest `main` version,
in particular these are considered to be stable.
If you have standard write access to the repository, feel free to create new branches. To make organization
easier, please indicate your username in the branch name.
The `testing` branch is deployed on the development instances. No development should happen there, this branch
is regularly reset to the `main` branch.

@ -7,6 +7,8 @@ services:
context: ./../../
dockerfile: docker/test/Dockerfile
env_file: docker.env
environment:
- DJANGO_TEST_KEEPDB=$DJANGO_TEST_KEEPDB
depends_on:
- redis
- cache
@ -14,8 +16,8 @@ services:
entrypoint: /app/docker/test/entrypoint-master.sh
volumes:
- type: bind
source: ./coverage.xml
target: /app/jdav_web/coverage.xml
source: ./htmlcov/
target: /app/jdav_web/htmlcov/
cache:
restart: always

@ -38,5 +38,9 @@ fi
cd jdav_web
coverage run manage.py test startpage finance members -v 2
coverage xml
if [[ "$DJANGO_TEST_KEEPDB" == 1 ]]; then
coverage run manage.py test startpage finance members contrib logindata mailer material ludwigsburgalpin -v 2 --noinput --keepdb
else
coverage run manage.py test startpage finance members contrib logindata mailer material ludwigsburgalpin -v 2 --noinput
fi
coverage html

Binary file not shown.

After

Width:  |  Height:  |  Size: 47 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 48 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 249 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 251 KiB

@ -0,0 +1,20 @@
:orphan: true
.. meta::
:description: Miscellaneous information about the Kompass project
.. vale off
About
=====
.. rst-class:: lead
.. attention::
Die Seite befindet sich noch im Aufbau. -- The page is still under construction.
(Stand: 08.01.2025)
- About the kompass project
- About this documentation

@ -3,15 +3,19 @@
# For the full list of built-in configuration values, see the documentation:
# https://www.sphinx-doc.org/en/master/usage/configuration.html
# -- Project information -----------------------------------------------------
from dataclasses import asdict
from sphinxawesome_theme import ThemeOptions
# -- 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'
author = 'The Kompass Team'
copyright = f'2025, {author}'
# -- General configuration ---------------------------------------------------
# -- General configuration -----------------------------------------------------
# https://www.sphinx-doc.org/en/master/usage/configuration.html#general-configuration
extensions = []
@ -21,8 +25,39 @@ exclude_patterns = []
language = 'de'
# -- Options for HTML output -------------------------------------------------
# -- Options for HTML output ---------------------------------------------------
# https://www.sphinx-doc.org/en/master/usage/configuration.html#options-for-html-output
html_theme = 'alabaster'
html_theme = 'sphinxawesome_theme'
html_static_path = ['_static']
# -- Sphinxawsome-theme options ------------------------------------------------
# https://sphinxawesome.xyz/how-to/configure/
html_logo = "_static/favicon2.png"
html_favicon = "_static/favicon2.png"
html_sidebars = {
"about": ["sidebar_main_nav_links.html"],
# "changelog": ["sidebar_main_nav_links.html"],
}
# Code blocks color scheme
pygments_style = "emacs"
pygments_style_dark = "emacs"
# Could be directly in html_theme_options, but this way it has type hints
# from sphinxawesome_theme
theme_options = ThemeOptions(
show_prev_next=True,
show_breadcrumbs=True,
main_nav_links={
"Docs": "index",
"About": "about",
# "Changelog": "changelog"
},
show_scrolltop=True,
)
html_theme_options = asdict(theme_options)

@ -0,0 +1,7 @@
.. _development_manual/architecture:
=================
Architecture
=================
tbd

@ -0,0 +1,85 @@
.. _development_manual/contributing:
============
Contributing
============
Any form of contribution is appreciated. If you found a bug or have a feature request, please file an
`issue <https://git.jdav-hd.merten.dev/digitales/kompass/issues>`_. If you want to help with the documentation or
want to contribute code, please open a `pull request <https://git.jdav-hd.merten.dev/digitales/kompass/pulls>`_.
.. note::
Please read this page carefully before contributing.
Miscellaneous
-------------
- version control with `git <https://git-scm.com/>`_
- own gitea instance at https://git.jdav-hd.merten.dev/
- protected ``main`` branch
Organization and branches
-------------------------
The stable development happens on the ``main``-branch for which only maintainers have write access. Any pull request
should hence be targeted at ``main``. Regularly, the production instances are updated to the latest ``main`` version,
in particular these are considered to be stable.
If you have standard write access to the repository, feel free to create new branches. To make organization
easier, please follow the branch naming convention: ``<username>/<feature>``.
The ``testing``-branch is deployed on the development instances. No development should happen there, this branch
is regularly reset to the ``main``-branch.
Workflow
--------
- request a gitea account from the project maintainers
- decide on an `issue <https://git.jdav-hd.merten.dev/digitales/kompass/issues>`_ to work on or create a new one
- branch out to an own branch (naming convention: ``<username>/<feature>``) from the ``main``-branch
- work on the issue and commit your changes
- create a pull request from your branch to the ``main``-branch
.. _development_manual/contributing/documentation:
Documentation
-------------
If you want to contribute to the documentation, please follow the steps below.
Online (latest release version): https://jdav-hd.de/static/docs/
- This documentation is build `sphinx <https://www.sphinx-doc.org/>`_ and `awsome sphinx theme <https://sphinxawesome.xyz/>`_ the source code is located in ``docs/``.
- All documentation is written in `reStructuredText <https://www.sphinx-doc.org/en/master/usage/restructuredtext/index.html>`_ and uses the `sphinx directives <https://www.sphinx-doc.org/en/master/usage/restructuredtext/directives.html>`_.
- The directives can vary due to the theme, see the `awesome sphinx theme documentation <https://sphinxawesome.xyz/demo/notes/>`_.
- All technical documentation is written in english, user documentation is written in german.
To read the documentation build it locally and view it in your browser:
.. code-block:: bash
cd docs/
make html
# MacOS (with firefox)
open -a firefox $(pwd)/docs/build/html/index.html
# Linux (I guess?!?)
firefox ${pwd}/docs/build/html/index.html
Code
----
If you want to contribute code, please follow the inital setup steps in the :ref:`development_manual/setup` section. And dont forget to :ref:`document <development_manual/contributing/documentation>` your code properly and write tests.
.. note::
Still open / to decide:
- linting
- (auto) formatting
- reliable tests via ci/cd pipeline

@ -0,0 +1,7 @@
.. _development_manual/deployment:
=====================
Production Deployment
=====================
tbd

@ -0,0 +1,42 @@
.. _development_manual/index:
#########################
Development Documentation
#########################
This part of the documentation describes the development and maintenance of the Kompass project.
.. toctree::
:titlesonly:
contributing
setup
architecture
testing
deployment
Contributing
------------
Any form of contribution is appreciated!
.. seealso::
:ref:`Contributing <development_manual/contributing>`
Documentation
-------------
Structure
- :ref:`Nutzer Dokumentation <user_manual/index>` auf deutsch
- :ref:`Development Documentation <development_manual/index>` auf englisch
.. seealso::
:ref:`Contributing #Documentation <development_manual/contributing/documentation>`

@ -0,0 +1,101 @@
.. _development_manual/setup:
=================
Development Setup
=================
The project is run with ``docker`` and all related files are in the ``docker/`` subfolder. Besides the actual Kompass
application, a database (postgresql) and a broker (redis) are setup and run in the docker container. No
external services are needed for running the development container.
Initial installation
--------------------
A working ``docker`` setup (with ``docker compose``) is required. For installation instructions see the
`docker manual <https://docs.docker.com/engine/install/>`_.
1. Clone the repository and change into the directory of the repository.
2. Fetch submodules
.. code-block:: bash
git submodule update --init
.. _step-3:
3. Prepare development environment: to allow automatic rebuilding upon changes in the source,
the owner of the ``/app/jdav_web`` directory in the Docker container must match your
user. For this, make sure that the output of ``echo UID`` and ``echo UID`` is not empty. Then run
.. code-block:: bash
export GID=${GID}
export UID=${UID}
4. Start docker
.. code-block:: bash
cd docker/development
docker compose up
This runs the docker in your current shell, which is useful to see any log output. If you want to run
the development server in the background instead, use ``docker compose up -d``.
During the initial run, the container is built and all dependencies are installed which can take a few minutes.
After successful installation, the Kompass initialization runs, which in particular sets up all tables in the
database.
If you need to rebuild the container (e.g. after changing the ``requirements.txt``), execute
.. code-block:: bash
docker compose up --build
5. Setup admin user: in a separate shell, while the docker container is running, execute
.. code-block:: bash
cd docker/development
docker compose exec master bash -c "cd jdav_web && python3 manage.py createsuperuser"
This creates an admin user for the administration interface.
Development
-----------
If the initial installation was successful, you can start developing. Changes to files cause an automatic
reload of the development server. If you need to generate and perform database migrations or generate locale files,
use
.. code-block:: bash
cd docker/development
docker compose exec master bash
cd jdav_web
This starts a shell in the container, where you can execute any django maintenance commands via
``python3 manage.py <command>``. For more information, see the https://docs.djangoproject.com/en/4.0/ref/django-admin.
Known Issues
------------
- If the ``UID`` and ``GID`` variables are not setup properly, you will encounter the following error message
after running ``docker compose up``.
.. code-block:: bash
=> ERROR [master 6/7] RUN groupadd -g fritze && useradd -g -u -m -d /app fritze 0.2s
------
> [master 6/7] RUN groupadd -g fritze && useradd -g -u -m -d /app fritze:
0.141 groupadd: invalid group ID 'fritze'
------
failed to solve: process "/bin/sh -c groupadd -g $GID $USER && useradd -g $GID -u $UID -m -d /app $USER" did not complete successfully: exit code: 3
In this case repeat :ref:`step 3 <step-3>` above.

@ -0,0 +1,7 @@
.. _development_manual/testing:
=================
Testing
=================
To run the tests, you can use the docker setup under ``docker/test``.

@ -2,42 +2,41 @@
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.
.. _index:
=======
Kompass
=======
############
jdav 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.
erste mal hier bist, schau doch mal :ref:`user_manual/getstarted` an.
.. attention::
Die Dokumentation befindet sich noch im Aufbau. -- The documentation is still under construction.
(Stand: 08.01.2025)
Was ist der Kompass?
Nutzer Dokumentation
--------------------
Der Kompass ist eine Verwaltungsplattform für die tägliche Jugendarbeit in der JDAV.
Die wichtigsten Funktionen sind
- auf deutsch
- Hier findest Du als Nutzer alles was Du wissen musst um den Kompass bedienen zu können.
- Verwaltung von Teilnehmer\*innen von Jugendgruppen
- Organisation von Ausfahrten
- Abwicklung von Abrechnungen
- Senden von E-Mails
.. toctree::
:titlesonly:
Neben diesen Funktionen für die tägliche Arbeit, automatisiert der Kompass die
Aufnahme von neuen Mitgliedern und die Pflege der Daten durch
user_manual/index
- Wartelistenverwaltung
- Registrierung neuer Mitglieder
- Rückmeldeverfahren
.. _index:
Development Documentation
-------------------------
Inhaltsverzeichnis
------------------
- auf englisch
- Hier findest Du als Entwickler alles was Du wissen musst um den Kompass entwickeln und zu pflegen.
.. toctree::
:maxdepth: 2
:titlesonly:
development_manual/index
getstarted
members
excursions
waitinglist
finance

@ -1,9 +1,9 @@
.. _excursions:
.. _user_manual/excursions:
Ausfahrten
==========
Neben der :ref:`Teilnehmer\*innenverwaltung <members>` ist das Abwickeln von Ausfahrten
Neben der :ref:`Teilnehmer\*innenverwaltung <user_manual/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.
@ -43,6 +43,8 @@ Seminarbericht direkt ein und lass dir den Papierkram vom Kompass erledigen.
SJR Antrag
----------
tbd
Abrechnung
----------

@ -1,3 +1,5 @@
.. _user_manual/finance:
Finanzen
========

@ -1,4 +1,4 @@
.. _first-steps:
.. _user_manual/getstarted:
Erste Schritte
==============
@ -29,7 +29,7 @@ er auf den entsprechenden Eintrag, ändert das Geburtsdatum und klickt auf *Spei
.. 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. Für mehr Details siehe :ref:`Teilnehmer\*innenverwaltung <members>`
Manche Einträge wiederum kannst du einsehen, aber nicht bearbeiten. Für mehr Details siehe :ref:`Teilnehmer\*innenverwaltung <user_manual/members>`
Probier doch einmal aus deinen eigenen Eintrag zu ändern. Sicherlich gibt es einige
Felder, die nicht ausgefüllt oder nicht mehr aktuell sind. Lade z.B. ein Bild von dir hoch,
@ -70,7 +70,7 @@ 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`.
geh zurück zur :ref:`user_manual/index`.
.. _Startseite: https://jdav-hd.de/kompass
.. _Teilnehmer\*innenanzeige: https://jdav-hd.de/kompassmembers/member/

Before

Width:  |  Height:  |  Size: 6.6 KiB

After

Width:  |  Height:  |  Size: 6.6 KiB

@ -0,0 +1,50 @@
.. _user_manual/index:
####################
Nutzer Dokumentation
####################
Der Kompass ist dein Kompass in der Jugendarbeit in deiner JDAV Sektion. Wenn du das
erste mal hier bist, schau doch mal :ref:`user_manual/getstarted` an.
.. toctree::
:titlesonly:
getstarted
members
excursions
waitinglist
finance
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
Feedback
--------
Wenn Du Feedback hast, schreibe uns gerne eine E-Mail an: `digitales@jdav-hd.de <mailto:digitales@jdav-hd.de?subject=Kompass Feedback>`_.
Der Kompass lebt davon, dass er genau unsere Probleme löst und nicht nur ein weiteres Tool ist.
Feedback könnte sein:
- Fehler in der Software (bug)
- Verbesserungsvorschläge
- Wünsche für neue Funktionen
- etc. pp.

@ -1,4 +1,4 @@
.. _members:
.. _user_manual/members:
Teilnehmer\*innenverwaltung
===========================
@ -15,12 +15,12 @@ In der Teilnehmer\*innenverwaltung siehst du in der Regel zwei Menüpunkte:
- Teilnehmer\*innenverwaltung: Ausfahrten und *Alle Teilnehmer\*innen*.
In diesem Abschnitt geht es nur um die Teilnehmer\*innen selbst. Wenn du etwas zum Punkt Ausfahrten
lernen möchtest, kannst du zu :ref:`excursions` springen.
lernen möchtest, kannst du zu :ref:`user_manual/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`.
Informationen dazu findest du unter :ref:`user_manual/waitinglist`.
Falls du direkt zu einer von dir geleiteten Jugendgruppe gehen möchtest, findest
du unter `Teilnehmer*innenverwaltung`_ oder auf der `Startseite`_
@ -138,14 +138,14 @@ Der\*die ausgewählte Teilnehmer\*in erhält eine E-Mail mit einem Link. Dieser
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.
Das Geburtsdatumsformat ist ``TT.MM.JJJJ``, also wenn Peter am
1.4.1999 geboren ist, muss 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
Dann 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.
Klingt alles noch abstrakt? Dann fordere dich doch mal selbst zur Rückmeldung auf und probiere es aus.
.. _emergency-contacts:
@ -159,7 +159,7 @@ 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.
dass sie 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

@ -1,4 +1,4 @@
.. _waitinglist:
.. _user_manual/waitinglist:
Warteliste und neue Mitglieder
==============================
@ -65,7 +65,7 @@ Neues Mitglied in euerer Gruppe
Nach dem ihr ein neues Mitglied in eurer Gruppe habt seid ihr auch vorrangig für die Datenpflege
zuständig. Bitte ruft die Detailansicht des\*der Teilnehmer\*in auf. Öffnet das Anmeldeformular und
Übertragt die Infos in die zugehörigen Felder. Weiteres dazu findet ihr in der
:ref:`Teilnehmer\*innenverwaltung <members>`
:ref:`Teilnehmer\*innenverwaltung <user_manual/members>`
.. _group-registration-password:

@ -206,6 +206,14 @@ class StatementSubmittedAdmin(admin.ModelAdmin):
_("Some transactions have no ledger configured. Please fill in the gaps.")
% {'name': str(statement)})
return HttpResponseRedirect(reverse('admin:%s_%s_overview' % (self.opts.app_label, self.opts.model_name), args=(statement.pk,)))
elif res == Statement.INVALID_ALLOWANCE_TO:
messages.error(request,
_("The configured recipients for the allowance don't match the regulations. Please correct this on the excursion."))
return HttpResponseRedirect(reverse('admin:%s_%s_overview' % (self.opts.app_label, self.opts.model_name), args=(statement.pk,)))
elif res == Statement.INVALID_TOTAL:
messages.error(request,
_("The calculated total amount does not match the sum of all transactions. This is most likely a bug."))
return HttpResponseRedirect(reverse('admin:%s_%s_overview' % (self.opts.app_label, self.opts.model_name), args=(statement.pk,)))
return HttpResponseRedirect(reverse('admin:%s_%s_changelist' % (self.opts.app_label, self.opts.model_name)))
if "reject" in request.POST:
@ -227,7 +235,7 @@ class StatementSubmittedAdmin(admin.ModelAdmin):
_("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)})
_("Error while generating transactions for %(name)s. Do all bills have a payer and, if this statement is attached to an excursion, was a person selected that receives the subsidies?") % {'name': str(statement)})
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),
title=_('View submitted statement'),

@ -74,6 +74,22 @@ msgid "Some transactions have no ledger configured. Please fill in the gaps."
msgstr ""
"Manche Überweisungen haben kein Geldtopf eingestellt. Bitte trage das nach."
#: finance/admin.py
msgid ""
"The configured recipients for the allowance don't match the regulations. "
"Please correct this on the excursion."
msgstr ""
"Die ausgewählten Empfänger*innen der Aufwandsentschädigungen entsprechen "
"nicht den Regularien. Bitte korrigiere das in der Ausfahrt."
#: finance/admin.py
msgid ""
"The calculated total amount does not match the sum of all transactions. This "
"is most likely a bug."
msgstr ""
"Der berechnete Gesamtbetrag stimmt nicht mit der Summe aller Überweisungen "
"überein. Das ist höchstwahrscheinlich ein Fehler in der Implementierung."
#: finance/admin.py
#, python-format
msgid "Successfully rejected %(name)s. The requestor can reapply, when needed."
@ -98,10 +114,14 @@ msgstr "Automatisch Überweisungsträger für %(name)s generiert."
#: finance/admin.py
#, python-format
msgid ""
"Error while generating transactions for %(name)s. Do all bills have a payer?"
"Error while generating transactions for %(name)s. Do all bills have a payer "
"and, if this statement is attached to an excursion, was a person selected "
"that receives the subsidies?"
msgstr ""
"Fehler beim Erzeugen der Überweisungsträger für %(name)s. Sind für alle "
"Quittungen eine bezahlende Person eingestellt? "
"Quittungen eine bezahlende Person eingestellt und, falls diese Abrechnung zu "
"einer Ausfahrt gehört, wurde eine Person als Empfänger*in der Übernachtungs- "
"und Fahrtkostenzuschüsse ausgewählt?"
#: finance/admin.py
msgid "View submitted statement"
@ -157,6 +177,31 @@ msgstr "Erklärung"
msgid "Associated excursion"
msgstr "Zugehörige Ausfahrt"
#: finance/models.py
msgid "Pay allowance to"
msgstr "Aufwandsentschädigung auszahlen an"
#: finance/models.py
msgid ""
"The youth leaders to which an allowance should be paid. The count must match "
"the number of permitted youth leaders."
msgstr ""
"Die Jugendleiter*innen an die eine Aufwandsentschädigung ausgezahlt werden "
"soll. Die Anzahl muss mit der Anzahl an zugelassenen Jugendleiter*innen "
"übereinstimmen. "
#: finance/models.py
msgid "Pay subsidy to"
msgstr "Übernachtungs- und Fahrtkostenzuschüsse auszahlen an"
#: finance/models.py
msgid ""
"The person that should receive the subsidy for night and travel costs. "
"Typically the person who paid for them."
msgstr ""
"Die Person, die die Übernachtungs- und Fahrtkostenzuschüsse erhalten soll. "
"Dies ist in der Regel die Person, die sie bezahlt hat."
#: finance/models.py
msgid "Price per night"
msgstr "Preis pro Nacht"
@ -208,8 +253,13 @@ msgstr "Bereit zur Abwicklung"
#: finance/models.py
#, python-format
msgid "Compensation for %(excu)s"
msgstr "Entschädigung für %(excu)s"
msgid "Allowance for %(excu)s"
msgstr "Aufwandsentschädigung für %(excu)s"
#: finance/models.py
#, python-format
msgid "Night and travel costs for %(excu)s"
msgstr "Übernachtungs- und Fahrtkosten für %(excu)s"
#: finance/models.py finance/templates/admin/overview_submitted_statement.html
msgid "Total"
@ -362,8 +412,8 @@ msgstr "Ausfahrt"
#, python-format
msgid "This excursion featured %(staff_count)s youth leader(s), each costing"
msgstr ""
"Diese Ausfahrt hatte %(staff_count)s Jugendleiter*innen. Auf jede*n "
"entfallen die folgenden Kosten:"
"Diese Ausfahrt hatte %(staff_count)s genehmigte Jugendleiter*innen. Auf "
"jede*n entfallen die folgenden Kosten:"
#: finance/templates/admin/overview_submitted_statement.html
#, python-format
@ -401,6 +451,22 @@ msgstr ""
"Insgesamt sind das Kosten von %(total_per_yl)s€ mal %(staff_count)s, "
"insgesamt also %(total_staff)s€."
#: finance/templates/admin/overview_submitted_statement.html
#, python-format
msgid "The allowance of %(allowance_per_yl)s€ per person should be paid to:"
msgstr ""
"Die Aufwandsentschädigung von %(allowance_per_yl)s€ pro Person soll "
"ausgezahlt werden an:"
#: finance/templates/admin/overview_submitted_statement.html
#, python-format
msgid ""
"The subsidies for night and transportation costs of %(total_subsidies)s€ "
"should be paid to:"
msgstr ""
"Die Zuschüsse für Übernachtungs- und Fahrtkosten von %(total_subsidies)s€ "
"sollen ausgezahlt werden an:"
#: finance/templates/admin/overview_submitted_statement.html
#, python-format
msgid "This results in a total amount of %(total)s€"

@ -0,0 +1,26 @@
# Generated by Django 4.0.1 on 2025-01-18 19:08
from django.db import migrations, models
import django.db.models.deletion
class Migration(migrations.Migration):
dependencies = [
('members', '0032_member_upload_registration_form_key'),
('members', '0033_freizeit_approved_extra_youth_leader_count'),
('finance', '0005_alter_bill_proof'),
]
operations = [
migrations.AddField(
model_name='statement',
name='allowance_to',
field=models.ManyToManyField(help_text='The youth leaders to which an allowance should be paid. The count must match the number of permitted youth leaders.', related_name='receives_allowance_for_statements', to='members.Member', verbose_name='Pay allowance to'),
),
migrations.AddField(
model_name='statement',
name='subsidy_to',
field=models.ForeignKey(help_text='The person that should receive the subsidy for night and travel costs. Typically the person who paid for them.', null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='receives_subsidy_for_statements', to='members.member', verbose_name='Pay subsidy to'),
),
]

@ -0,0 +1,19 @@
# Generated by Django 4.0.1 on 2025-01-18 22:00
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('members', '0033_freizeit_approved_extra_youth_leader_count'),
('finance', '0006_statement_add_allowance_to_subsidy_to'),
]
operations = [
migrations.AlterField(
model_name='statement',
name='allowance_to',
field=models.ManyToManyField(blank=True, help_text='The youth leaders to which an allowance should be paid. The count must match the number of permitted youth leaders.', related_name='receives_allowance_for_statements', to='members.Member', verbose_name='Pay allowance to'),
),
]

@ -5,6 +5,7 @@ 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.core.exceptions import ValidationError
from django.db import models
from django.db.models import Sum
from django.utils.translation import gettext_lazy as _
@ -46,7 +47,7 @@ class StatementManager(models.Manager):
class Statement(CommonModel):
MISSING_LEDGER, NON_MATCHING_TRANSACTIONS, VALID = 0, 1, 2
MISSING_LEDGER, NON_MATCHING_TRANSACTIONS, INVALID_ALLOWANCE_TO, INVALID_TOTAL, VALID = 0, 1, 2, 3, 4
short_description = models.CharField(verbose_name=_('Short description'),
max_length=30,
@ -58,6 +59,16 @@ class Statement(CommonModel):
null=True,
on_delete=models.SET_NULL)
allowance_to = models.ManyToManyField(Member, verbose_name=_('Pay allowance to'),
related_name='receives_allowance_for_statements',
blank=True,
help_text=_('The youth leaders to which an allowance should be paid. The count must match the number of permitted youth leaders.'))
subsidy_to = models.ForeignKey(Member, verbose_name=_('Pay subsidy to'),
null=True,
on_delete=models.SET_NULL,
related_name='receives_subsidy_for_statements',
help_text=_('The person that should receive the subsidy for night and travel costs. Typically the person who paid for them.'))
night_cost = models.DecimalField(verbose_name=_('Price per night'), default=0, decimal_places=2, max_digits=5)
submitted = models.BooleanField(verbose_name=_('Submitted'), default=False)
@ -113,7 +124,9 @@ class Statement(CommonModel):
needed_paiments = [(b.paid_by, b.amount) for b in self.bill_set.all() if b.costs_covered and b.paid_by]
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.allowance_per_yl) for yl in self.allowance_to.all()])
if self.subsidy_to:
needed_paiments.append((self.subsidy_to, self.total_subsidies))
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])))
@ -147,10 +160,25 @@ class Statement(CommonModel):
def transactions_match_expenses(self):
return len(self.transaction_issues) == 0
def is_valid(self):
return self.ledgers_configured and self.transactions_match_expenses
is_valid.boolean = True
is_valid.short_description = _('Ready to confirm')
@property
def allowance_to_valid(self):
"""Checks if the configured `allowance_to` field matches the regulations."""
if self.allowance_to.count() != self.real_staff_count:
return False
if self.excursion is not None:
yls = self.excursion.jugendleiter.all()
for yl in self.allowance_to.all():
if yl not in yls:
return False
return True
@property
def total_valid(self):
"""Checks if the calculated total agrees with the total amount of all transactions."""
total_transactions = 0
for transaction in self.transaction_set.all():
total_transactions += transaction.amount
return self.total == total_transactions
@property
def validity(self):
@ -158,9 +186,18 @@ class Statement(CommonModel):
return Statement.NON_MATCHING_TRANSACTIONS
if not self.ledgers_configured:
return Statement.MISSING_LEDGER
if not self.allowance_to_valid:
return Statement.INVALID_ALLOWANCE_TO
if not self.total_valid:
return Statement.INVALID_TOTAL
else:
return Statement.VALID
def is_valid(self):
return self.validity == Statement.VALID
is_valid.boolean = True
is_valid.short_description = _('Ready to confirm')
def confirm(self, confirmer=None):
if not self.submitted:
return False
@ -193,9 +230,17 @@ class Statement(CommonModel):
if self.excursion is None:
return True
for yl in self.excursion.jugendleiter.all():
ref = _("Compensation for %(excu)s") % {'excu': self.excursion.name}
Transaction(statement=self, member=yl, amount=self.real_per_yl, confirmed=False, reference=ref).save()
# allowance
for yl in self.allowance_to.all():
ref = _("Allowance for %(excu)s") % {'excu': self.excursion.name}
Transaction(statement=self, member=yl, amount=self.allowance_per_yl, confirmed=False, reference=ref).save()
# subsidies (i.e. night and transportation costs)
if self.subsidy_to:
ref = _("Night and travel costs for %(excu)s") % {'excu': self.excursion.name}
Transaction(statement=self, member=self.subsidy_to, amount=self.total_subsidies, confirmed=False, reference=ref).save()
else:
return False
return True
def reduce_transactions(self):
@ -290,6 +335,14 @@ class Statement(CommonModel):
return cvt_to_decimal(self.total_staff / self.excursion.staff_count)
@property
def total_subsidies(self):
"""
The total amount of subsidies excluding the allowance, i.e. the transportation
and night costs per youth leader multiplied with the real number of youth leaders.
"""
return (self.transportation_per_yl + self.nights_per_yl) * self.real_staff_count
@property
def total_staff(self):
return self.total_per_yl * self.real_staff_count
@ -307,15 +360,8 @@ class Statement(CommonModel):
are refinanced though."""
if self.excursion is None:
return 0
#raw_staff_count = self.excursion.jugendleiter.count()
participant_count = self.excursion.participant_count
if participant_count < 4:
return 0
elif 4 <= participant_count <= 7:
return 2
else:
return 2 + math.ceil((participant_count - 7) / 7)
return self.excursion.approved_staff_count
@property
def total(self):
@ -323,7 +369,13 @@ class Statement(CommonModel):
@property
def total_theoretic(self):
return self.total_bills_theoretic + self.total_staff
"""
The theoretic total used in SJR and LJP applications. This is the sum of all
bills (ignoring whether they are paid by the association or not) plus the
total allowance. This does not include the subsidies for night and travel costs,
since they are expected to be included in the bills.
"""
return self.total_bills_theoretic + self.total_allowance
def total_pretty(self):
return "{}".format(self.total)
@ -350,6 +402,7 @@ class Statement(CommonModel):
'transportation_per_yl': self.transportation_per_yl,
'total_per_yl': self.total_per_yl,
'total_staff': self.total_staff,
'total_subsidies': self.total_subsidies,
}
return dict(context, **excursion_context)
else:

@ -73,6 +73,25 @@
{% blocktrans %}In total this is {{ total_per_yl }}€ times {{ staff_count }}, giving {{ total_staff }}€.{% endblocktrans %}
</p>
<p>
{% blocktrans %}The allowance of {{ allowance_per_yl }}€ per person should be paid to:{% endblocktrans %}
<ul>
{% for member in statement.allowance_to.all %}
<li>
{{ member.name }}
</li>
{% endfor %}
</ul>
</p>
<p>
{% blocktrans %}The subsidies for night and transportation costs of {{ total_subsidies }}€ should be paid to:{% endblocktrans %}
<ul>
<li>
{{ statement.subsidy_to.name }}
</li>
</ul>
</p>
{% endif %}
<h2>{% trans "Total" %}</h2>

@ -3,7 +3,7 @@ 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
FAHRGEMEINSCHAFT_ANREISE, MALE, FEMALE, DIVERSE
# Create your tests here.
class StatementTestCase(TestCase):
@ -11,11 +11,11 @@ class StatementTestCase(TestCase):
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)
email=settings.TEST_MAIL, gender=MALE)
self.fritz.group.add(self.jl)
self.fritz.save()
@ -36,28 +36,30 @@ class StatementTestCase(TestCase):
tour_type=GEMEINSCHAFTS_TOUR,
tour_approach=MUSKELKRAFT_ANREISE,
difficulty=1)
self.st3 = Statement.objects.create(night_cost=self.night_cost, excursion=ex)
self.st3 = Statement.objects.create(night_cost=self.night_cost, excursion=ex, subsidy_to=self.fritz)
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)
email=settings.TEST_MAIL, gender=MALE)
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)
email=settings.TEST_MAIL, gender=MALE)
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)
if i < 3:
self.st3.allowance_to.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)
self.st4 = Statement.objects.create(night_cost=self.night_cost, excursion=ex, subsidy_to=self.fritz)
for i in range(2):
m = Member.objects.create(prename='Peter {}'.format(i), lastname='Walter', birth_date=timezone.now().date(),
email=settings.TEST_MAIL)
email=settings.TEST_MAIL, gender=DIVERSE)
mol = NewMemberOnList.objects.create(member=m, memberlist=ex)
ex.membersonlist.add(mol)
@ -66,7 +68,7 @@ class StatementTestCase(TestCase):
'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)
email=settings.TEST_MAIL, gender=DIVERSE)
mol = NewMemberOnList.objects.create(member=m, memberlist=self.st4.excursion)
self.st4.excursion.membersonlist.add(mol)
self.assertEqual(self.st4.admissible_staff_count, 2,
@ -74,19 +76,24 @@ class StatementTestCase(TestCase):
def test_reduce_transactions(self):
self.st3.generate_transactions()
self.assertEqual(self.st3.transaction_set.count(), self.staff_count * 2,
self.assertTrue(self.st3.allowance_to_valid, 'Configured `allowance_to` field is invalid.')
# every youth leader on `st3` paid one bill, the first three receive the allowance
# and one receives the subsidies
self.assertEqual(self.st3.transaction_set.count(), self.st3.real_staff_count + self.staff_count + 1,
'Transaction count is not twice the staff count.')
self.st3.reduce_transactions()
self.assertEqual(self.st3.transaction_set.count(), self.staff_count * 2,
self.assertEqual(self.st3.transaction_set.count(), self.st3.real_staff_count + self.staff_count + 1,
'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.')
# the three yls that receive an allowance should only receive one transaction after reducing,
# the additional one is the one for the subsidies
self.assertEqual(self.st3.transaction_set.count(), self.staff_count + 1,
'Transaction count after setting ledgers and reduction is incorrect.')
self.st3.reduce_transactions()
self.assertEqual(self.st3.transaction_set.count(), self.staff_count,
self.assertEqual(self.st3.transaction_set.count(), self.staff_count + 1,
'Transaction count did change after reducing a second time.')
def test_confirm_statement(self):
@ -101,6 +108,8 @@ class StatementTestCase(TestCase):
for trans in self.st3.transaction_set.all():
trans.ledger = self.personal_account
trans.save()
self.assertEqual(self.st3.validity, Statement.VALID,
'Statement is not valid, although it was setup to be so.')
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.')

@ -8,7 +8,7 @@ msgid ""
msgstr ""
"Project-Id-Version: PACKAGE VERSION\n"
"Report-Msgid-Bugs-To: \n"
"POT-Creation-Date: 2025-01-19 14:26+0100\n"
"POT-Creation-Date: 2025-01-01 21:48+0100\n"
"PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\n"
"Last-Translator: FULL NAME <EMAIL@ADDRESS>\n"
"Language-Team: LANGUAGE <LL@li.org>\n"

@ -1,3 +1,82 @@
from django.test import TestCase
from http import HTTPStatus
# Create your tests here.
from django.test import TestCase, RequestFactory
from django.utils import timezone
from django.contrib.admin.sites import AdminSite
from django.urls import reverse
from django.conf import settings
from .models import Termin, GRUPPE, KATEGORIE, KONDITION, TECHNIK, SAISON,\
EVENTART, KLASSIFIZIERUNG
from .admin import TerminAdmin
class BasicTerminTestCase(TestCase):
TERMIN_NO = 10
def setUp(self):
for i in range(BasicTerminTestCase.TERMIN_NO):
Termin.objects.create(title='Foo {}'.format(i),
start_date=timezone.now().date(),
end_date=timezone.now().date(),
group=GRUPPE[0][0],
email=settings.TEST_MAIL,
category=KATEGORIE[0][0],
technik=TECHNIK[0][0],
max_participants=42,
anforderung_hoehe=10)
class TerminAdminTestCase(BasicTerminTestCase):
def test_str(self):
t = Termin.objects.all()[0]
self.assertEqual(str(t), '{} {}'.format(t.title, str(t.group)))
def test_make_overview(self):
factory = RequestFactory()
admin = TerminAdmin(Termin, AdminSite())
url = reverse('admin:ludwigsburgalpin_termin_changelist')
request = factory.get(url)
response = admin.make_overview(request, Termin.objects.all())
self.assertEqual(response['Content-Type'], 'application/xlsx',
'The content-type of the generated overview should be an .xlsx file.')
class ViewTestCase(BasicTerminTestCase):
def test_get_index(self):
url = reverse('ludwigsburgalpin:index')
response = self.client.get(url)
self.assertEqual(response.status_code, HTTPStatus.OK)
def test_submit_termin(self):
url = reverse('ludwigsburgalpin:index')
response = self.client.post(url, data={
'title': 'My Title',
'subtitle': 'My Subtitle',
'start_date': '2024-01-01',
'end_date': '2024-02-01',
'group': GRUPPE[0][0],
'category': KATEGORIE[0][0],
'condition': KONDITION[0][0],
'technik': TECHNIK[0][0],
'saison': SAISON[0][0],
'eventart': EVENTART[0][0],
'klassifizierung': KLASSIFIZIERUNG[0][0],
'anforderung_hoehe': 10,
'anforderung_strecke': 10,
'anforderung_dauer': 10,
'max_participants': 100,
})
t = Termin.objects.get(title='My Title')
self.assertEqual(t.group, GRUPPE[0][0])
self.assertEqual(response.status_code, HTTPStatus.OK)
self.assertContains(response, "Termin erfolgreich eingereicht", html=True)
def test_submit_termin_invalid(self):
url = reverse('ludwigsburgalpin:index')
# many required fields are missing
response = self.client.post(url, data={
'title': 'My Title',
})
self.assertEqual(response.status_code, HTTPStatus.OK)
self.assertContains(response, "Dieses Feld ist zwingend erforderlich.", html=True)

@ -8,7 +8,7 @@ msgid ""
msgstr ""
"Project-Id-Version: PACKAGE VERSION\n"
"Report-Msgid-Bugs-To: \n"
"POT-Creation-Date: 2025-01-19 14:26+0100\n"
"POT-Creation-Date: 2025-01-01 21:48+0100\n"
"PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\n"
"Last-Translator: FULL NAME <EMAIL@ADDRESS>\n"
"Language-Team: LANGUAGE <LL@li.org>\n"

@ -8,7 +8,7 @@ msgid ""
msgstr ""
"Project-Id-Version: PACKAGE VERSION\n"
"Report-Msgid-Bugs-To: \n"
"POT-Creation-Date: 2025-01-19 14:26+0100\n"
"POT-Creation-Date: 2025-01-01 21:48+0100\n"
"PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\n"
"Last-Translator: FULL NAME <EMAIL@ADDRESS>\n"
"Language-Team: LANGUAGE <LL@li.org>\n"

@ -23,7 +23,7 @@ from django.contrib.contenttypes.admin import GenericTabularInline
from django.utils.html import format_html
from django.utils.translation import gettext_lazy as _
from django.db.models import TextField, ManyToManyField, ForeignKey, Count,\
Sum, Case, Q, F, When, Value, IntegerField, Subquery, OuterRef
Sum, Case, Q, F, When, Value, IntegerField, Subquery, OuterRef, ExpressionWrapper
from django.forms import Textarea, RadioSelect, TypedChoiceField, CheckboxInput
from django.shortcuts import render
from django.core.exceptions import PermissionDenied, ValidationError
@ -596,6 +596,20 @@ class InvitationToGroupAdmin(admin.TabularInline):
return False
class AgeFilter(admin.SimpleListFilter):
title = _('Age')
parameter_name = 'age'
def lookups(self, request, model_admin):
return [(n, str(n)) for n in range(101)]
def queryset(self, request, queryset):
age = self.value()
if not age:
return queryset
return queryset.filter(birth_date_delta=age)
class InvitedToGroupFilter(admin.SimpleListFilter):
title = _('Pending group invitation for group')
parameter_name = 'pending_group_invitation'
@ -617,7 +631,7 @@ class MemberWaitingListAdmin(CommonAdminMixin, admin.ModelAdmin):
list_display = ('name', 'birth_date', 'age', 'gender', 'application_date', 'latest_group_invitation',
'confirmed_mail', 'waiting_confirmed', 'sent_reminders')
search_fields = ('prename', 'lastname', 'email')
list_filter = ['confirmed_mail', 'gender', InvitedToGroupFilter]
list_filter = ['confirmed_mail', InvitedToGroupFilter, AgeFilter, 'gender']
actions = ['ask_for_registration_action', 'ask_for_wait_confirmation']
inlines = [InvitationToGroupAdmin]
readonly_fields= ['application_date', 'sent_reminders']
@ -625,6 +639,11 @@ class MemberWaitingListAdmin(CommonAdminMixin, admin.ModelAdmin):
def has_add_permission(self, request, obj=None):
return False
def age(self, obj):
return obj.birth_date_delta
age.short_description=_('age')
age.admin_order_field = 'birth_date_delta'
def ask_for_wait_confirmation(self, request, queryset):
"""Asks the waiting person to confirm their waiting status."""
for waiter in queryset:
@ -661,7 +680,19 @@ class MemberWaitingListAdmin(CommonAdminMixin, admin.ModelAdmin):
return custom_urls + urls
def get_queryset(self, request):
queryset = super().get_queryset(request)
now = timezone.now()
age_expr = ExpressionWrapper(
Case(
# if the month of the birth date has not yet passed, subtract one year
When(birth_date__month__gt=now.month, then=now.year - F('birth_date__year') - 1),
# if it is the month of the birth date but the day has not yet passed, subtract one year
When(birth_date__month=now.month, birth_date__day__gt=now.day, then=now.year - F('birth_date__year') - 1),
# otherwise return the difference in years
default=now.year - F('birth_date__year'),
),
output_field=IntegerField()
)
queryset = super().get_queryset(request).annotate(birth_date_delta=age_expr)
return queryset.prefetch_related('invitationtogroup_set')
def ask_for_registration_action(self, request, queryset):
@ -805,13 +836,62 @@ class BillOnExcursionInline(CommonAdminInlineMixin, admin.TabularInline):
}
class StatementOnListForm(forms.ModelForm):
"""
Form to edit a statement attached to an excursion. This is used in an inline on
the excursion admin.
"""
def __init__(self, *args, **kwargs):
excursion = kwargs.pop('parent_obj')
super(StatementOnListForm, self).__init__(*args, **kwargs)
# only allow youth leaders of this excursion to be selected as recipients
# of subsidies and allowance
self.fields['allowance_to'].queryset = excursion.jugendleiter.all()
self.fields['subsidy_to'].queryset = excursion.jugendleiter.all()
class Meta:
model = Statement
fields = ['night_cost', 'allowance_to', 'subsidy_to']
def clean(self):
"""Check if the `allowance_to` and `subsidy_to` fields are compatible with
the total number of approved youth leaders."""
allowance_to = self.cleaned_data.get('allowance_to')
excursion = self.cleaned_data.get('excursion')
if allowance_to is None:
return
if allowance_to.count() > excursion.approved_staff_count:
raise ValidationError({
'allowance_to': _("This excursion only has up to %(approved_count)s approved youth leaders, but you listed %(entered_count)s.") % {'approved_count': str(excursion.approved_staff_count),
'entered_count': str(allowance_to.count())},
})
if allowance_to.count() < min(excursion.approved_staff_count, excursion.jugendleiter.count()):
raise ValidationError({
'allowance_to': _("This excursion has %(approved_count)s approved youth leaders, but you listed only %(entered_count)s.") % {'approved_count': str(excursion.approved_staff_count),
'entered_count': str(allowance_to.count())},
})
class StatementOnListInline(CommonAdminInlineMixin, nested_admin.NestedStackedInline):
model = Statement
extra = 1
description = _('Please list here all expenses in relation with this excursion and upload relevant bills. These have to be permanently stored for the application of LJP contributions. The short descriptions are used in the seminar report cost overview (possible descriptions are e.g. food, material, etc.).')
sortable_options = []
fields = ['night_cost']
fields = ['night_cost', 'allowance_to', 'subsidy_to']
inlines = [BillOnExcursionInline]
form = StatementOnListForm
def get_formset(self, request, obj=None, **kwargs):
BaseFormSet = kwargs.pop('formset', self.formset)
class CustomFormSet(BaseFormSet):
def get_form_kwargs(self, index):
kwargs = super().get_form_kwargs(index)
kwargs['parent_obj'] = obj
return kwargs
kwargs['formset'] = CustomFormSet
return super().get_formset(request, obj, **kwargs)
class InterventionOnLJPInline(CommonAdminInlineMixin, admin.TabularInline):
@ -901,6 +981,15 @@ class GenerateSeminarReportForm(forms.Form):
widget=CheckboxInput(attrs={'style': 'display: inherit'}),
required=False)
class GenerateSjrForm(forms.Form):
def __init__(self, *args, **kwargs):
self.attachments = kwargs.pop('attachments')
super(GenerateSjrForm,self).__init__(*args,**kwargs)
self.fields['invoice'] = forms.ChoiceField(choices=self.attachments, label=_('Invoice'))
class FreizeitAdmin(CommonAdminMixin, nested_admin.NestedModelAdmin):
#inlines = [MemberOnListInline, LJPOnListInline, StatementOnListInline]
@ -912,6 +1001,7 @@ class FreizeitAdmin(CommonAdminMixin, nested_admin.NestedModelAdmin):
fieldsets = (
(None, {
'fields': ('name', 'place', 'destination', 'date', 'end', 'description', 'groups', 'jugendleiter',
'approved_extra_youth_leader_count',
'tour_type', 'tour_approach', 'kilometers_traveled', 'activity', 'difficulty'),
'description': _('General information on your excursion. These are partly relevant for the amount of financial compensation (means of transport, travel distance, etc.).')
}),
@ -996,23 +1086,51 @@ class FreizeitAdmin(CommonAdminMixin, nested_admin.NestedModelAdmin):
return serve_pdf(fp)
return self.render_seminar_report_options(request, memberlist, GenerateSeminarReportForm())
seminar_report.short_description = _('Generate seminar report')
def render_sjr_options(self, request, memberlist, form):
context = dict(self.admin_site.each_context(request),
title=_('Generate SJR application'),
opts=self.opts,
memberlist=memberlist,
form=form,
object=memberlist)
return render(request, 'admin/generate_sjr_application.html', context=context)
def sjr_application(self, request, memberlist):
if not self.may_view_excursion(request, memberlist):
return self.not_allowed_view(request, memberlist)
context = memberlist.sjr_application_fields()
if hasattr(memberlist, 'statement'):
attachments = [b.proof.path for b in memberlist.statement.bill_set.all() if b.proof]
attachment_names = [f"{b.short_description}: {b.explanation} ({b.amount:.2f}€)" for b in memberlist.statement.bill_set.all() if b.proof]
attachment_paths = [b.proof.path for b in memberlist.statement.bill_set.all() if b.proof]
else:
attachments = []
title = memberlist.ljpproposal.title if hasattr(memberlist, 'ljpproposal') else memberlist.name
return fill_pdf_form(title + "_SJR_Antrag", 'members/sjr_template.pdf', context, attachments)
attachment_names = []
attachment_paths = []
attachments = zip(attachment_paths, attachment_names)
if not self.may_view_excursion(request, memberlist):
return self.not_allowed_view(request, memberlist)
if "apply" in request.POST:
form = GenerateSjrForm(request.POST, attachments=attachments)
if not form.is_valid():
messages.error(request, _('Please select an invoice.'))
return self.render_sjr_options(request, memberlist, form)
selected_attachments = [form.cleaned_data['invoice']]
context = memberlist.sjr_application_fields()
title = memberlist.ljpproposal.title if hasattr(memberlist, 'ljpproposal') else memberlist.name
return fill_pdf_form(title + "_SJR_Antrag", 'members/sjr_template.pdf', context, selected_attachments)
return self.render_sjr_options(request, memberlist, GenerateSjrForm(attachments=attachments))
sjr_application.short_description = _('Generate SJR application')
def finance_overview(self, request, memberlist):
if not memberlist.statement:
messages.error(request, _("No statement found. Please add a statement and then retry."))
if "apply" in request.POST:
if not memberlist.statement.allowance_to_valid:
messages.error(request,
_("The configured recipients of the allowance don't match the regulations. Please correct this and try again."))
return HttpResponseRedirect(reverse('admin:%s_%s_change' % (self.opts.app_label, self.opts.model_name), args=(memberlist.pk,)))
memberlist.statement.submit(get_member(request))
messages.success(request,
_("Successfully submited statement. The finance department will notify you as soon as possible."))

@ -187,10 +187,18 @@ msgstr "Gruppe"
msgid "Invitation text"
msgstr "Einladungstext"
#: members/admin.py
msgid "Age"
msgstr "Alter"
#: members/admin.py
msgid "Pending group invitation for group"
msgstr "Ausstehende Gruppeneinladung für Gruppe"
#: members/admin.py members/models.py
msgid "age"
msgstr "Alter"
#: members/admin.py
#, python-format
msgid "Successfully asked %(name)s to confirm their waiting status."
@ -251,6 +259,24 @@ msgstr "Art der Tour"
msgid "Means of transportation"
msgstr "Verkehrsmittel"
#: members/admin.py
#, python-format
msgid ""
"This excursion only has up to %(approved_count)s approved youth leaders, but "
"you listed %(entered_count)s."
msgstr ""
"Diese Ausfahrt hat nur bis zu %(approved_count)s zugelassene "
"Jugendleiter*innen, aber du hast %(entered_count)s eingetragen."
#: members/admin.py
#, python-format
msgid ""
"This excursion has %(approved_count)s approved youth leaders, but you listed "
"only %(entered_count)s."
msgstr ""
"Diese Ausfahrt hat %(approved_count)s zugelassene Jugendleiter*innen, aber "
"du hast nur %(entered_count)s eingetragen."
#: members/admin.py
msgid ""
"Please list here all expenses in relation with this excursion and upload "
@ -315,6 +341,10 @@ msgstr "Modus"
msgid "Prepend V32"
msgstr "V32 Formblatt einfügen"
#: members/admin.py
msgid "Invoice"
msgstr "Beleg"
#: members/admin.py
msgid ""
"General information on your excursion. These are partly relevant for the "
@ -354,16 +384,28 @@ msgstr ""
"Der vollständiger Modus ist nur verfügbar, wenn der Seminarbericht "
"ausgefüllt ist. "
#: members/admin.py
#: members/admin.py members/templates/admin/generate_sjr_application.html
msgid "Generate SJR application"
msgstr "SJR Antrag erstellen"
#: members/admin.py
msgid "Please select an invoice."
msgstr "Bitte wähle einen Beleg aus."
#: members/admin.py
msgid "No statement found. Please add a statement and then retry."
msgstr ""
"Keine Abrechnung angelegt. Bitte lege eine Abrechnung and und versuche es "
"erneut."
#: members/admin.py
msgid ""
"The configured recipients of the allowance don't match the regulations. "
"Please correct this and try again."
msgstr ""
"Die ausgewählten Empfänger*innen der Aufwandsentschädigung stimmen nicht mit "
"den Richtlinien überein. Bitte korrigiere das und versuche es erneut. "
#: members/admin.py
msgid ""
"Successfully submited statement. The finance department will notify you as "
@ -781,6 +823,21 @@ msgstr "Ende"
msgid "Groups"
msgstr "Gruppen"
#: members/models.py
msgid "Number of additional approved youth leaders"
msgstr "Anzahl zusätzlich genehmigter Jugendleiter*innen"
#: members/models.py
msgid ""
"The number of approved youth leaders per excursion is determined by the "
"number of participants. In special circumstances, e.g. in case of a "
"technically demanding excursion, more youth leaders may be approved."
msgstr ""
"Die Anzahl der genehmigten Jugendleiter*innen pro Ausfahrt wird "
"grundsätzlich durch die Anzahl der Teilnehmer*innen festgelegt. In "
"besonderen Fällen, zum Beispiel bei einer fachlich herausfordernden "
"Ausfahrt, können zusätzliche Jugendleiter*innen genehmigt werden."
#: members/models.py
msgid "Kilometers traveled"
msgstr "Fahrstrecke in Kilometer"
@ -972,6 +1029,7 @@ msgstr "Fortbildungen"
#: members/templates/admin/demote_to_waiter.html
#: members/templates/admin/freizeit_finance_overview.html
#: members/templates/admin/generate_seminar_report.html
#: members/templates/admin/generate_sjr_application.html
#: members/templates/admin/invite_as_user.html
#: members/templates/admin/invite_for_group.html
#: members/templates/admin/invite_for_group_text.html
@ -996,6 +1054,7 @@ msgstr "Zurück auf die Warteliste setzen"
#: members/templates/admin/demote_to_waiter.html
#: members/templates/admin/freizeit_finance_overview.html
#: members/templates/admin/generate_seminar_report.html
#: members/templates/admin/generate_sjr_application.html
#: members/templates/admin/invite_as_user.html
#: members/templates/admin/invite_for_group.html
#: members/templates/admin/invite_selected_as_user.html
@ -1088,11 +1147,31 @@ msgstr ""
#: members/templates/admin/freizeit_finance_overview.html
#, python-format
msgid ""
"In total these are contributions of %(total_per_yl)s€ times %(staff_count)s, "
"giving %(total_staff)s€."
"The allowance of %(allowance_per_yl)s€ per person is configured to be paid "
"to:"
msgstr ""
"Die Aufwandsentschädigung von %(allowance_per_yl)s€ pro Person wird "
"ausgezahlt an:"
#: members/templates/admin/freizeit_finance_overview.html
#, python-format
msgid ""
"The subsidies for night and transportation costs of %(total_subsidies)s€ is "
"configured to be paid to:"
msgstr ""
"Insgesamt sind das Kosten von %(total_per_yl)s€ mal %(staff_count)s, "
"insgesamt also %(total_staff)s€."
"Die Zuschüsse für Übernachtungs- und Fahrtkosten von %(total_subsidies)s€ "
"werden ausgezahlt an:"
#: members/templates/admin/freizeit_finance_overview.html
msgid ""
"Warning: The configured recipients of the allowance don't match the "
"regulations. This might be because the number of recipients is bigger then "
"the number of admissable youth leaders for this excursion."
msgstr ""
"Warnung: Die ausgewählten Empfänger*innen der Aufwandsentschädigung stimmen "
"nicht mit den Richtlinien überein. Das kann daran liegen, dass die Anzahl "
"der ausgewählten Empfänger*innen die Anzahl genehmigter Jugendleiter*innen "
"übersteigt."
#: members/templates/admin/freizeit_finance_overview.html
msgid "LJP contributions"
@ -1241,9 +1320,27 @@ msgstr ""
"Felder im Formblatt selbst aus und unterschreibe das PDF."
#: members/templates/admin/generate_seminar_report.html
#: members/templates/admin/generate_sjr_application.html
msgid "Generate"
msgstr "Erstellen"
#: members/templates/admin/generate_sjr_application.html
msgid "Here you can generate an allowance application for the SJR."
msgstr "Hier kannst du einen SJR-Zuschussantrag erstellen."
#: members/templates/admin/generate_sjr_application.html
msgid ""
"The application needs to be complemented with an invoice from the trip as "
"proof."
msgstr ""
"An den Antrag muss ein Ausgabenbeleg angehängt werden, der beweist, dass die "
"Aktivität stattgefunden hat."
#: members/templates/admin/generate_sjr_application.html
msgid ""
"Please send this application form to the jdav finance officer via email."
msgstr "Bitte sende diesen Antrag an den/die JDAV-Finanzwart*in per E-Mail."
#: members/templates/admin/invite_as_user.html
#, python-format
msgid ""
@ -1792,8 +1889,13 @@ msgstr "abgelaufen"
msgid "Invalid emergency contacts"
msgstr "Ungültige Notfallkontakte"
#~ msgid "Change here"
#~ msgstr "Hier ändern"
#, python-format
#~ msgid ""
#~ "In total these are contributions of %(total_per_yl)s€ times "
#~ "%(staff_count)s, giving %(total_staff)s€."
#~ msgstr ""
#~ "Insgesamt sind das Kosten von %(total_per_yl)s€ mal %(staff_count)s, "
#~ "insgesamt also %(total_staff)s€."
#~ msgid "Your registration succeeded."
#~ msgstr "Deine Registrierung war erfolgreich."

@ -0,0 +1,18 @@
# Generated by Django 4.0.1 on 2025-01-18 18:27
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('members', '0032_member_upload_registration_form_key'),
]
operations = [
migrations.AddField(
model_name='freizeit',
name='approved_extra_youth_leader_count',
field=models.PositiveIntegerField(default=0, help_text='The number of approved youth leaders per excursion is determined by the number of participants. In special circumstances, e.g. in case of a technically demanding excursion, more youth leaders may be approved.', verbose_name='Number of additional approved youth leaders'),
),
]

@ -1,5 +1,6 @@
from datetime import datetime, timedelta
import uuid
import math
import pytz
import unicodedata
import re
@ -240,10 +241,11 @@ class Person(Contact):
class Meta(CommonModel.Meta):
abstract = True
@property
def age(self):
"""Age of member"""
return relativedelta(datetime.today(), self.birth_date).years
age.admin_order_field = 'birth_date'
age.short_description = _('age')
@property
def birth_date_str(self):
@ -1052,6 +1054,9 @@ class Freizeit(CommonModel):
# comment = models.TextField(_('Comments'), default='', blank=True)
groups = models.ManyToManyField(Group, verbose_name=_('Groups'))
jugendleiter = models.ManyToManyField(Member)
approved_extra_youth_leader_count = models.PositiveIntegerField(verbose_name=_('Number of additional approved youth leaders'),
default=0,
help_text=_('The number of approved youth leaders per excursion is determined by the number of participants. In special circumstances, e.g. in case of a technically demanding excursion, more youth leaders may be approved.'))
tour_type_choices = ((GEMEINSCHAFTS_TOUR, 'Gemeinschaftstour'),
(FUEHRUNGS_TOUR, 'Führungstour'),
(AUSBILDUNGS_TOUR, 'Ausbildung'))
@ -1138,13 +1143,60 @@ class Freizeit(CommonModel):
jls = set(self.jugendleiter.distinct())
return len(ps - jls)
@property
def approved_staff_count(self):
"""Number of approved youth leaders for this excursion. The base number is calculated
from the participant count. To this, the number of additional approved youth leaders is added."""
participant_count = self.participant_count
if participant_count < 4:
base_count = 0
elif 4 <= participant_count <= 7:
base_count = 2
else:
base_count = 2 + math.ceil((participant_count - 7) / 7)
return base_count + self.approved_extra_youth_leader_count
@property
def theoretic_ljp_participant_count(self):
"""
Calculate the participant count in the sense of the LJP regulations. This means
that all youth leaders are counted and all participants which are at least 6 years old and
strictly less than 27 years old. Additionally, up to 20% of the participants may violate the
age restrictions.
This is the theoretic value, ignoring the cutoff at 5 participants.
"""
# participants (possibly including youth leaders)
ps = {x.member for x in self.membersonlist.distinct()}
# youth leaders
jls = set(self.jugendleiter.distinct())
# non-youth leader participants
ps_only = ps - jls
# participants of the correct age
ps_correct_age = {m for m in ps_only if m.age() >= 6 and m.age() < 27}
# m = the official non-youth-leader participant count
# and, assuming there exist enough participants, unrounded m satisfies the equation
# len(ps_correct_age) + 1/5 * m = m
# if there are not enough participants,
# m = len(ps_only)
m = min(len(ps_only), math.floor(5/4 * len(ps_correct_age)))
return m + len(jls)
@property
def ljp_participant_count(self):
ps = set(map(lambda x: x.member, self.membersonlist.distinct()))
"""
The number of participants in the sense of LJP regulations. If the total
number of participants (including youth leaders and too old / young ones) is less
than 5, this is zero, otherwise it is `theoretic_ljp_participant_count`.
"""
# participants (possibly including youth leaders)
ps = {x.member for x in self.membersonlist.distinct()}
# youth leaders
jls = set(self.jugendleiter.distinct())
count = len(ps.union(jls))
return count
#return count if count >= 5 else 0
if len(ps.union(jls)) < 5:
return 0
else:
return self.theoretic_ljp_participant_count
@property
def maximal_ljp_contributions(self):
@ -1208,9 +1260,9 @@ class Freizeit(CommonModel):
members = set(map(lambda x: x.member, self.membersonlist.distinct()))
total = len(members)
total_b27_local = len([m for m in members
if m.age <= 27 and settings.SEKTION in m.town])
if m.age() <= 27 and settings.SEKTION in m.town])
total_b27_non_local = len([m for m in members
if m.age <= 27 and not settings.SEKTION in m.town])
if m.age() <= 27 and not settings.SEKTION in m.town])
jls = self.jugendleiter.distinct()
title = self.ljpproposal.title if hasattr(self, 'ljpproposal') else self.name
base = {'Haushaltsjahr': str(datetime.now().year),
@ -1235,7 +1287,7 @@ class Freizeit(CommonModel):
suffix = '12'
base['Vor- und Nachname' + suffix] = m.name
base['Anschrift' + suffix] = m.address
base['Alter' + suffix] = str(m.age)
base['Alter' + suffix] = str(m.age())
base['Status' + suffix] = str(2)
return base

@ -100,12 +100,18 @@ def fill_pdf_form(name, template_path, fields, attachments=[], save_only=False):
for fp in attachments:
try:
img = Image.open(fp)
img_pdf = BytesIO()
img.save(img_pdf, "pdf")
if fp.endswith(".pdf"):
# append pdf directly
img_pdf = PdfReader(fp)
else:
# convert ensures that png files with an alpha channel can be appended
img = Image.open(fp).convert("RGB")
img_pdf = BytesIO()
img.save(img_pdf, "pdf")
writer.append(img_pdf)
except:
except Exception as e:
print("Could not add image", fp)
print(e)
with open(media_path(filename_pdf), 'wb') as output_stream:
writer.write(output_stream)

@ -86,8 +86,28 @@ cost plan!
</ul>
</p>
<p>
{% blocktrans %}In total these are contributions of {{ total_per_yl }}€ times {{ staff_count }}, giving {{ total_staff }}€.{% endblocktrans %}
{% blocktrans %}The allowance of {{ allowance_per_yl }}€ per person is configured to be paid to:{% endblocktrans %}
<ul>
{% for member in memberlist.statement.allowance_to.all %}
<li>
{{ member.name }}
</li>
{% endfor %}
</ul>
</p>
<p>
{% blocktrans %}The subsidies for night and transportation costs of {{ total_subsidies }}€ is configured to be paid to:{% endblocktrans %}
<ul>
<li>
{{ memberlist.statement.subsidy_to.name }}
</li>
</ul>
</p>
{% if not memberlist.statement.allowance_to_valid %}
<p>
{% blocktrans %}Warning: The configured recipients of the allowance don't match the regulations. This might be because the number of recipients is bigger then the number of admissable youth leaders for this excursion.{% endblocktrans %}
</p>
{% endif %}
<h3>{% trans "LJP contributions" %}</h3>

@ -0,0 +1,52 @@
{% 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 }} invite-waiter
{% 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' object.pk|admin_urlquote %}">{{ object|truncatewords:"18" }}</a>
&rsaquo; {% translate 'Generate SJR application' %}
</div>
{% endblock %}
{% block content %}
<p>
{% blocktrans %}Here you can generate an allowance application for the SJR.{% endblocktrans %}
</p>
<p>
{% blocktrans %}The application needs to be complemented with an invoice from the trip as proof.{% endblocktrans %}
</p>
<form action="" method="post">
{% csrf_token %}
<p>
<table>
{{ form }}
</table>
</p>
<p>
{% blocktrans %}Please send this application form to the jdav finance officer via email.{% endblocktrans %}
</p>
<br>
<input type="hidden" name="action" value="sjr_application">
<input type="hidden" name="sjr_application">
<input class="default" style="color: $default-link-color" type="submit" name="apply"
value="{% translate 'Generate' %}">
<a href="#" class="button cancel-link">{% translate "Cancel" %}</a>
</form>
{% endblock %}

@ -146,8 +146,6 @@
\textbf{Beschreibung} & \textbf{Betrag} \\
\midrule
Aufwandsentschädigung & {{ memberlist.statement.total_allowance }}\\
Fahrtkosten & {{ memberlist.statement.total_transportation }}\\
Übernachtungskosten & {{ memberlist.statement.total_nights }}\\
{% for bill in memberlist.statement.grouped_bills %}
{{ bill.short_description|esc_all }} & {{ bill.amount }}\\
{% endfor %}

@ -6,19 +6,24 @@ from django.test import TestCase, Client, RequestFactory
from django.utils import timezone, translation
from django.conf import settings
from django.urls import reverse
from unittest import skip, mock
from .models import Member, Group, PermissionMember, PermissionGroup, Freizeit, GEMEINSCHAFTS_TOUR, MUSKELKRAFT_ANREISE,\
MemberNoteList, NewMemberOnList, confirm_mail_by_key, EmergencyContact
MemberNoteList, NewMemberOnList, confirm_mail_by_key, EmergencyContact, MemberWaitingList,\
DIVERSE, MALE, FEMALE
from .admin import MemberWaitingListAdmin, MemberAdmin, FreizeitAdmin
from django.db import connection
from django.db.migrations.executor import MigrationExecutor
from .admin import FreizeitAdmin
import random
import datetime
from dateutil.relativedelta import relativedelta
import math
def create_custom_user(username, groups, prename, lastname):
user = User.objects.create_user(
username=username, password='secret'
)
member = Member.objects.create(prename=prename, lastname=lastname, birth_date=timezone.localdate(), email=settings.TEST_MAIL)
member = Member.objects.create(prename=prename, lastname=lastname, birth_date=timezone.localdate(), email=settings.TEST_MAIL, gender=DIVERSE)
member.user = user
member.save()
user.is_staff = True
@ -37,22 +42,30 @@ class BasicMemberTestCase(TestCase):
self.spiel = Group.objects.create(name="Spielkinder")
self.fritz = Member.objects.create(prename="Fritz", lastname="Wulter", birth_date=timezone.now().date(),
email=settings.TEST_MAIL)
email=settings.TEST_MAIL, gender=DIVERSE)
self.fritz.group.add(self.jl)
self.fritz.group.add(self.alp)
self.fritz.save()
self.peter = Member.objects.create(prename="Peter", lastname="Wulter",
birth_date=timezone.now().date(),
email=settings.TEST_MAIL, gender=MALE)
self.peter.group.add(self.jl)
self.peter.group.add(self.alp)
self.peter.save()
self.lara = Member.objects.create(prename="Lara", lastname="Wallis", birth_date=timezone.now().date(),
email=settings.TEST_MAIL)
email=settings.TEST_MAIL, gender=DIVERSE)
self.lara.group.add(self.alp)
self.lara.save()
self.fridolin = Member.objects.create(prename="Fridolin", lastname="Spargel", birth_date=timezone.now().date(),
email=settings.TEST_MAIL)
email=settings.TEST_MAIL, gender=MALE)
self.fridolin.group.add(self.alp)
self.fridolin.group.add(self.spiel)
self.fridolin.save()
self.lise = Member.objects.create(prename="Lise", lastname="Lotte", birth_date=timezone.now().date(),
email=settings.TEST_MAIL)
email=settings.TEST_MAIL, gender=FEMALE)
class MemberTestCase(BasicMemberTestCase):
@ -66,11 +79,11 @@ class MemberTestCase(BasicMemberTestCase):
self.ja = Group.objects.create(name="Jugendausschuss")
self.peter = Member.objects.create(prename="Peter", lastname="Keks", birth_date=timezone.now().date(),
email=settings.TEST_MAIL)
email=settings.TEST_MAIL, gender=MALE)
self.anna = Member.objects.create(prename="Anna", lastname="Keks", birth_date=timezone.now().date(),
email=settings.TEST_MAIL)
email=settings.TEST_MAIL, gender=FEMALE)
self.lisa = Member.objects.create(prename="Lisa", lastname="Keks", birth_date=timezone.now().date(),
email=settings.TEST_MAIL)
email=settings.TEST_MAIL, gender=DIVERSE)
self.peter.group.add(self.ja)
self.anna.group.add(self.ja)
self.lisa.group.add(self.ja)
@ -127,8 +140,10 @@ class PDFTestCase(TestCase):
self.note = MemberNoteList.objects.create(title='Cool list')
for i in range(7):
m = Member.objects.create(prename='Lise {}'.format(i), lastname='Walter', birth_date=timezone.now().date(),
email=settings.TEST_MAIL)
m = Member.objects.create(prename='Lise {}'.format(i),
lastname='Walter',
birth_date=timezone.now().date(),
email=settings.TEST_MAIL, gender=FEMALE)
NewMemberOnList.objects.create(member=m, comments='a' * i, memberlist=self.ex)
NewMemberOnList.objects.create(member=m, comments='a' * i, memberlist=self.note)
@ -158,6 +173,16 @@ class PDFTestCase(TestCase):
self._test_pdf('notes_list')
self._test_pdf('notes_list', username='standard', invalid=True)
# TODO: Since generating a seminar report requires more input now, this test rightly
# fails. Replace this test with one that fills the POST form and generates a pdf.
@skip("Currently rightly fails, because expected behaviour changed.")
def test_sjr_application(self):
self._test_pdf('sjr_application')
self._test_pdf('sjr_application', username='standard', invalid=True)
# TODO: Since generating a seminar report requires more input now, this test rightly
# fails. Replace this test with one that fills the POST form and generates a pdf.
@skip("Currently rightly fails, because expected behaviour changed.")
def test_seminar_report(self):
self._test_pdf('seminar_report')
self._test_pdf('seminar_report', username='standard', invalid=True)
@ -174,12 +199,12 @@ class PDFTestCase(TestCase):
class AdminTestCase(TestCase):
def setUp(self, model):
def setUp(self, model, admin):
self.factory = RequestFactory()
self.model = model
if model is not None:
self.admin = FreizeitAdmin(model, AdminSite())
User.objects.create_superuser(
if model is not None and admin is not None:
self.admin = admin(model, AdminSite())
superuser = User.objects.create_superuser(
username='superuser', password='secret'
)
standard = create_custom_user('standard', ['Standard'], 'Paul', 'Wulter')
@ -200,21 +225,21 @@ class AdminTestCase(TestCase):
for i in range(3):
m = Member.objects.create(prename='Fritz {}'.format(i), lastname='Walter', birth_date=timezone.now().date(),
email=settings.TEST_MAIL)
email=settings.TEST_MAIL, gender=MALE)
m.group.add(cool_kids)
m.save()
for i in range(7):
m = Member.objects.create(prename='Lise {}'.format(i), lastname='Walter', birth_date=timezone.now().date(),
email=settings.TEST_MAIL)
email=settings.TEST_MAIL, gender=FEMALE)
m.group.add(super_kids)
m.save()
for i in range(5):
m = Member.objects.create(prename='Lulla {}'.format(i), lastname='Hulla', birth_date=timezone.now().date(),
email=settings.TEST_MAIL)
email=settings.TEST_MAIL, gender=DIVERSE)
m.group.add(staff)
m.save()
m = Member.objects.create(prename='Peter', lastname='Hulla', birth_date=timezone.now().date(),
email=settings.TEST_MAIL)
email=settings.TEST_MAIL, gender=MALE)
m.group.add(staff)
p1.list_members.add(m)
@ -228,7 +253,7 @@ class AdminTestCase(TestCase):
class PermissionTestCase(AdminTestCase):
def setUp(self):
super().setUp(model=None)
super().setUp(model=None, admin=None)
def test_standard_permissions(self):
u = User.objects.get(username='standard')
@ -249,14 +274,14 @@ class PermissionTestCase(AdminTestCase):
class MemberAdminTestCase(AdminTestCase):
def setUp(self):
super().setUp(model=Member)
super().setUp(model=Member, admin=MemberAdmin)
cool_kids = Group.objects.get(name='cool kids')
super_kids = Group.objects.get(name='super kids')
mega_kids = Group.objects.create(name='mega kids')
for i in range(1):
m = Member.objects.create(prename='Peter {}'.format(i), lastname='Walter', birth_date=timezone.now().date(),
email=settings.TEST_MAIL)
email=settings.TEST_MAIL, gender=MALE)
m.group.add(mega_kids)
m.save()
@ -373,9 +398,84 @@ class MemberAdminTestCase(AdminTestCase):
self.assertEqual(final, final_target, 'Did redirect to wrong url.')
class FreizeitTestCase(BasicMemberTestCase):
def setUp(self):
super().setUp()
self.ex = Freizeit.objects.create(name='Wild trip', kilometers_traveled=120,
tour_type=GEMEINSCHAFTS_TOUR,
tour_approach=MUSKELKRAFT_ANREISE,
difficulty=1)
def _setup_test_ljp_participant_count(self, n_yl, n_correct_age, n_too_old):
for i in range(n_yl):
# a 50 years old
m = Member.objects.create(prename='Peter {}'.format(i),
lastname='Wulter',
birth_date=datetime.datetime.today() - relativedelta(years=50),
email=settings.TEST_MAIL,
gender=FEMALE)
self.ex.jugendleiter.add(m)
for i in range(n_correct_age):
# a 10 years old
m = Member.objects.create(prename='Lise {}'.format(i),
lastname='Walter',
birth_date=datetime.datetime.today() - relativedelta(years=10),
email=settings.TEST_MAIL,
gender=FEMALE)
NewMemberOnList.objects.create(member=m, comments='a', memberlist=self.ex)
for i in range(n_too_old):
# a 27 years old
m = Member.objects.create(prename='Lise {}'.format(i),
lastname='Walter',
birth_date=datetime.datetime.today() - relativedelta(years=27),
email=settings.TEST_MAIL,
gender=FEMALE)
NewMemberOnList.objects.create(member=m, comments='a', memberlist=self.ex)
def _cleanup_excursion(self):
# delete all members on excursion for clean up
NewMemberOnList.objects.all().delete()
self.ex.jugendleiter.all().delete()
def _test_theoretic_ljp_participant_count_proportion(self, n_yl, n_correct_age, n_too_old):
self._setup_test_ljp_participant_count(n_yl, n_correct_age, n_too_old)
self.assertGreaterEqual(self.ex.theoretic_ljp_participant_count, n_yl,
'An excursion with {n_yl} youth leaders and {n_correct_age} participants in the correct age range should have at least {n} participants.'.format(n_yl=n_yl, n_correct_age=n_correct_age, n=n_yl + n_correct_age))
self.assertLessEqual(self.ex.theoretic_ljp_participant_count, n_yl + n_correct_age + n_too_old,
'An excursion with a total number of youth leaders and participants of {n} should have not more than {n} participants'.format(n=n_yl + n_correct_age + n_too_old))
n_parts_only = self.ex.theoretic_ljp_participant_count - n_yl
self.assertLessEqual(n_parts_only - n_correct_age, 1/5 * n_parts_only,
'An excursion with {n_parts_only} non-youth-leaders, of which {n_correct_age} have the correct age, the number of participants violating the age range must not exceed 20% of the total participants, i.e. {d}'.format(n_parts_only=n_parts_only, n_correct_age=n_correct_age, d=1/5 * n_parts_only))
self.assertEqual(n_parts_only - n_correct_age, min(math.floor(1/5 * n_parts_only), n_too_old),
'An excursion with {n_parts_only} non-youth-leaders, of which {n_correct_age} have the correct age, the number of participants violating the age range must be equal to the minimum of {n_too_old} and the smallest integer less than 20% of the total participants, i.e. {d}'.format(n_parts_only=n_parts_only, n_correct_age=n_correct_age, d=math.floor(1/5 * n_parts_only), n_too_old=n_too_old))
# cleanup
self._cleanup_excursion()
def _test_ljp_participant_count_proportion(self, n_yl, n_correct_age, n_too_old):
self._setup_test_ljp_participant_count(n_yl, n_correct_age, n_too_old)
if n_yl + n_correct_age + n_too_old < 5:
self.assertEqual(self.ex.ljp_participant_count, 0)
else:
self.assertEqual(self.ex.ljp_participant_count, self.ex.theoretic_ljp_participant_count)
# cleanup
self._cleanup_excursion()
def test_theoretic_ljp_participant_count(self):
self._test_theoretic_ljp_participant_count_proportion(2, 0, 0)
for i in range(10):
self._test_theoretic_ljp_participant_count_proportion(2, 10 - i, i)
def test_ljp_participant_count(self):
self._test_ljp_participant_count_proportion(2, 1, 1)
self._test_ljp_participant_count_proportion(2, 5, 1)
class FreizeitAdminTestCase(AdminTestCase):
def setUp(self):
super().setUp(model=Freizeit)
super().setUp(model=Freizeit, admin=FreizeitAdmin)
ex = Freizeit.objects.create(name='Wild trip', kilometers_traveled=120,
tour_type=GEMEINSCHAFTS_TOUR,
tour_approach=MUSKELKRAFT_ANREISE,
@ -383,7 +483,7 @@ class FreizeitAdminTestCase(AdminTestCase):
for i in range(7):
m = Member.objects.create(prename='Lise {}'.format(i), lastname='Walter', birth_date=timezone.now().date(),
email=settings.TEST_MAIL)
email=settings.TEST_MAIL, gender=FEMALE)
NewMemberOnList.objects.create(member=m, comments='a' * i, memberlist=ex)
def test_changelist(self):
@ -413,11 +513,17 @@ class FreizeitAdminTestCase(AdminTestCase):
response = c.get(url)
self.assertEqual(response.status_code, 200, 'Response code is not 200.')
@skip("The filtering is currently (intentionally) disabled.")
def test_add_queryset_filter(self):
"""Test if queryset on `jugendleiter` field is properly filtered by permissions."""
u = User.objects.get(username='standard')
c = self._login('standard')
url = reverse('admin:members_freizeit_add')
request = self.factory.get(url)
request.user = u
#staff = Group.objects.get(name='Jugendleiter')
field = Freizeit._meta.get_field('jugendleiter')
queryset = self.admin.formfield_for_manytomany(field, request).queryset
self.assertQuerysetEqual(queryset, u.member.filter_queryset_by_permissions(model=Member),
@ -446,6 +552,32 @@ class FreizeitAdminTestCase(AdminTestCase):
self.assertQuerysetEqual(queryset, Member.objects.none())
class MemberWaitingListAdminTestCase(AdminTestCase):
def setUp(self):
super().setUp(model=MemberWaitingList, admin=MemberWaitingListAdmin)
for i in range(10):
day = random.randint(1, 28)
month = random.randint(1, 12)
year = random.randint(1900, timezone.now().year - 1)
ex = MemberWaitingList.objects.create(prename='Peter {}'.format(i),
lastname='Puter',
birth_date=datetime.date(year, month, day),
email=settings.TEST_MAIL,
gender=FEMALE)
def test_age_eq_birth_date_delta(self):
u = User.objects.get(username='superuser')
url = reverse('admin:members_memberwaitinglist_changelist')
request = self.factory.get(url)
request.user = u
queryset = self.admin.get_queryset(request)
today = timezone.now().date()
for m in queryset:
self.assertEqual(m.birth_date_delta, m.age(),
msg='Queryset based age calculation differs from python based age calculation for birth date {birth_date} compared to {today}.'.format(birth_date=m.birth_date, today=today))
class MailConfirmationTestCase(BasicMemberTestCase):
def setUp(self):
super().setUp()
@ -472,6 +604,7 @@ class MailConfirmationTestCase(BasicMemberTestCase):
# father's mail should now be confirmed
self.assertTrue(self.father.confirmed_mail, msg='After confirming by key, the mail should be confirmed.')
@skip("Currently, emergency contact email addresses are not required to be confirmed.")
def test_emergency_contact_confirmation(self):
# request mail confirmation of fritz, should also ask for confirmation of father
requested_confirmation = self.fritz.request_mail_confirmation()

@ -8,7 +8,7 @@ msgid ""
msgstr ""
"Project-Id-Version: PACKAGE VERSION\n"
"Report-Msgid-Bugs-To: \n"
"POT-Creation-Date: 2025-01-19 14:26+0100\n"
"POT-Creation-Date: 2025-01-01 21:48+0100\n"
"PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\n"
"Last-Translator: FULL NAME <EMAIL@ADDRESS>\n"
"Language-Team: LANGUAGE <LL@li.org>\n"

@ -1,46 +1,55 @@
from django.test import TestCase, Client
from django.urls import reverse
from django.conf import settings
from members.models import Group
from .models import Post, Section
class ModelsTestCase(TestCase):
class BasicTestCase(TestCase):
def setUp(self):
orga = Section.objects.create(title='Organisation', urlname='orga', website_text='Section is a about everything.')
Post.objects.create(title='Climbing is fun', urlname='climbing-is-fun', website_text='Climbing is fun!')
recent = Section.objects.create(title='Recent', urlname=settings.RECENT_SECTION, website_text='Recently recent.')
reports = Section.objects.create(title='Reports', urlname=settings.REPORTS_SECTION, website_text='Reporty reports.')
Post.objects.create(title='Climbing is fun', urlname='climbing-is-fun', website_text='Climbing is fun!',
section=recent)
Post.objects.create(title='Last trip', urlname='last-trip', website_text='A fun trip.',
section=reports)
Post.objects.create(title='Staff', urlname='staff', website_text='This is our staff: Peter.',
section=orga)
Group.objects.create(name='CrazyClimbers', show_website=True)
Group.objects.create(name='SuperClimbers', show_website=False)
class ModelsTestCase(BasicTestCase):
def test_str(self):
orga = Section.objects.get(urlname='orga')
self.assertEqual(str(orga), orga.title, 'String representation does not match title.')
post = Post.objects.get(urlname='staff', section=orga)
self.assertEqual(post.absolute_section(), orga.title, 'Displayed section of post does not match section title.')
self.assertEqual(str(post), post.title, 'String representation does not match title.')
for post in Post.objects.filter(section=None):
self.assertEqual(post.absolute_section(), 'Aktuelles', 'Displayed section of post does not "Aktuelles".')
def test_absolute_urlnames(self):
orga = Section.objects.get(urlname='orga')
recent = Section.objects.get(urlname=settings.RECENT_SECTION)
reports = Section.objects.get(urlname=settings.REPORTS_SECTION)
self.assertEqual(orga.absolute_urlname(), '/de/orga')
post1 = Post.objects.get(urlname='staff', section=orga)
self.assertEqual(post1.absolute_urlname(), '/de/orga/staff')
post2 = Post.objects.get(urlname='climbing-is-fun', section=None)
self.assertEqual(post2.absolute_urlname(), '/de/aktuelles/climbing-is-fun')
class ViewTestCase(TestCase):
def setUp(self):
orga = Section.objects.create(title='Organisation', urlname='orga', website_text='Section is a about everything.')
Post.objects.create(title='Climbing is fun', urlname='climbing-is-fun', website_text='Climbing is fun!')
Post.objects.create(title='Staff', urlname='staff', website_text='This is our staff: Peter.',
section=orga)
Group.objects.create(name='CrazyClimbers', show_website=True)
Group.objects.create(name='SuperClimbers', show_website=False)
self.assertEqual(post1.absolute_urlname(), reverse('startpage:post', args=(orga.urlname, 'staff')))
post2 = Post.objects.get(urlname='climbing-is-fun', section=recent)
self.assertEqual(post2.absolute_urlname(),
'/de/{name}/climbing-is-fun'.format(name=settings.RECENT_SECTION))
self.assertEqual(post2.absolute_urlname(), reverse('startpage:post', args=(recent.urlname, 'climbing-is-fun')))
post3 = Post.objects.get(urlname='last-trip', section=reports)
self.assertEqual(post3.absolute_urlname(),
'/de/{name}/last-trip'.format(name=settings.REPORTS_SECTION))
self.assertEqual(post3.absolute_urlname(), reverse('startpage:post', args=(reports.urlname, 'last-trip')))
class ViewTestCase(BasicTestCase):
def test_index(self):
c = Client()
url = reverse('startpage:index')
@ -49,7 +58,7 @@ class ViewTestCase(TestCase):
def test_posts_no_category(self):
c = Client()
url = reverse('startpage:post', args=('aktuelles', 'climbing-is-fun'))
url = reverse('startpage:post', args=(settings.RECENT_SECTION, 'climbing-is-fun'))
response = c.get(url)
self.assertEqual(response.status_code, 200, 'Response code is not 200 for climbing post.')
@ -67,7 +76,13 @@ class ViewTestCase(TestCase):
def test_section_recent(self):
c = Client()
url = reverse('startpage:aktuelles')
url = reverse('startpage:' + settings.RECENT_SECTION)
response = c.get(url)
self.assertEqual(response.status_code, 200, 'Response code is not 200 for section page.')
def test_section_reports(self):
c = Client()
url = reverse('startpage:' + settings.REPORTS_SECTION)
response = c.get(url)
self.assertEqual(response.status_code, 200, 'Response code is not 200 for section page.')

@ -1,4 +1,3 @@
alabaster==0.7.16
amqp==5.0.9
asgiref==3.4.1
auditlog3==1.0.1
@ -52,6 +51,7 @@ schwifty==2024.11.0
six==1.16.0
snowballstemmer==2.2.0
Sphinx==7.4.7
sphinxawesome-theme==5.3.2
sphinxcontrib-applehelp==2.0.0
sphinxcontrib-devhelp==2.0.0
sphinxcontrib-htmlhelp==2.1.0

Loading…
Cancel
Save