diff --git a/Makefile b/Makefile
index b76f56c..f40252a 100644
--- a/Makefile
+++ b/Makefile
@@ -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"
diff --git a/README.md b/README.md
index 24d267a..e37f645 100644
--- a/README.md
+++ b/README.md
@@ -1,4 +1,4 @@
-# kompass
+# jdav Kompass
[](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 `. 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.
diff --git a/docker/test/docker-compose.yaml b/docker/test/docker-compose.yaml
index 9bc9bae..7bf2a02 100644
--- a/docker/test/docker-compose.yaml
+++ b/docker/test/docker-compose.yaml
@@ -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
diff --git a/docker/test/entrypoint-master.sh b/docker/test/entrypoint-master.sh
index 8c70043..e36cc94 100755
--- a/docker/test/entrypoint-master.sh
+++ b/docker/test/entrypoint-master.sh
@@ -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
diff --git a/docs/source/_static/favicon.png b/docs/source/_static/favicon.png
new file mode 100644
index 0000000..c8498b9
Binary files /dev/null and b/docs/source/_static/favicon.png differ
diff --git a/docs/source/_static/favicon2.png b/docs/source/_static/favicon2.png
new file mode 100644
index 0000000..bac2794
Binary files /dev/null and b/docs/source/_static/favicon2.png differ
diff --git a/docs/source/_static/jdav_logo.png b/docs/source/_static/jdav_logo.png
new file mode 100644
index 0000000..87e1400
Binary files /dev/null and b/docs/source/_static/jdav_logo.png differ
diff --git a/docs/source/_static/jdav_logo_transparent.png b/docs/source/_static/jdav_logo_transparent.png
new file mode 100644
index 0000000..d3ef7b7
Binary files /dev/null and b/docs/source/_static/jdav_logo_transparent.png differ
diff --git a/docs/source/about.rst b/docs/source/about.rst
new file mode 100644
index 0000000..d057902
--- /dev/null
+++ b/docs/source/about.rst
@@ -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
diff --git a/docs/source/conf.py b/docs/source/conf.py
index e3ff583..c4c7f60 100644
--- a/docs/source/conf.py
+++ b/docs/source/conf.py
@@ -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)
diff --git a/docs/source/development_manual/architecture.rst b/docs/source/development_manual/architecture.rst
new file mode 100644
index 0000000..d278f90
--- /dev/null
+++ b/docs/source/development_manual/architecture.rst
@@ -0,0 +1,7 @@
+.. _development_manual/architecture:
+
+=================
+Architecture
+=================
+
+tbd
\ No newline at end of file
diff --git a/docs/source/development_manual/contributing.rst b/docs/source/development_manual/contributing.rst
new file mode 100644
index 0000000..dce0c53
--- /dev/null
+++ b/docs/source/development_manual/contributing.rst
@@ -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 `_. If you want to help with the documentation or
+want to contribute code, please open a `pull request `_.
+
+.. note::
+
+ Please read this page carefully before contributing.
+
+Miscellaneous
+-------------
+
+- version control with `git `_
+- 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: ``/``.
+
+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 `_ to work on or create a new one
+- branch out to an own branch (naming convention: ``/``) 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 `_ and `awsome sphinx theme `_ the source code is located in ``docs/``.
+- All documentation is written in `reStructuredText `_ and uses the `sphinx directives `_.
+ - The directives can vary due to the theme, see the `awesome sphinx theme documentation `_.
+- 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 ` your code properly and write tests.
+
+
+.. note::
+
+ Still open / to decide:
+
+ - linting
+ - (auto) formatting
+ - reliable tests via ci/cd pipeline
+
diff --git a/docs/source/development_manual/deployment.rst b/docs/source/development_manual/deployment.rst
new file mode 100644
index 0000000..afc9acc
--- /dev/null
+++ b/docs/source/development_manual/deployment.rst
@@ -0,0 +1,7 @@
+.. _development_manual/deployment:
+
+=====================
+Production Deployment
+=====================
+
+tbd
\ No newline at end of file
diff --git a/docs/source/development_manual/index.rst b/docs/source/development_manual/index.rst
new file mode 100644
index 0000000..f219dc9
--- /dev/null
+++ b/docs/source/development_manual/index.rst
@@ -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 `
+
+
+
+Documentation
+-------------
+
+Structure
+
+- :ref:`Nutzer Dokumentation ` auf deutsch
+- :ref:`Development Documentation ` auf englisch
+
+.. seealso::
+
+ :ref:`Contributing #Documentation `
+
+
diff --git a/docs/source/development_manual/setup.rst b/docs/source/development_manual/setup.rst
new file mode 100644
index 0000000..25a8de6
--- /dev/null
+++ b/docs/source/development_manual/setup.rst
@@ -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 `_.
+
+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 ``. 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 ` above.
diff --git a/docs/source/development_manual/testing.rst b/docs/source/development_manual/testing.rst
new file mode 100644
index 0000000..218894f
--- /dev/null
+++ b/docs/source/development_manual/testing.rst
@@ -0,0 +1,7 @@
+.. _development_manual/testing:
+
+=================
+Testing
+=================
+
+To run the tests, you can use the docker setup under ``docker/test``.
\ No newline at end of file
diff --git a/docs/source/index.rst b/docs/source/index.rst
index 9970d37..5c4bddb 100644
--- a/docs/source/index.rst
+++ b/docs/source/index.rst
@@ -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
diff --git a/docs/source/excursions.rst b/docs/source/user_manual/excursions.rst
similarity index 92%
rename from docs/source/excursions.rst
rename to docs/source/user_manual/excursions.rst
index eb50caf..6169a87 100644
--- a/docs/source/excursions.rst
+++ b/docs/source/user_manual/excursions.rst
@@ -1,9 +1,9 @@
-.. _excursions:
+.. _user_manual/excursions:
Ausfahrten
==========
-Neben der :ref:`Teilnehmer\*innenverwaltung ` ist das Abwickeln von Ausfahrten
+Neben der :ref:`Teilnehmer\*innenverwaltung ` 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
----------
diff --git a/docs/source/finance.rst b/docs/source/user_manual/finance.rst
similarity index 91%
rename from docs/source/finance.rst
rename to docs/source/user_manual/finance.rst
index e093ee1..238aacf 100644
--- a/docs/source/finance.rst
+++ b/docs/source/user_manual/finance.rst
@@ -1,3 +1,5 @@
+.. _user_manual/finance:
+
Finanzen
========
diff --git a/docs/source/getstarted.rst b/docs/source/user_manual/getstarted.rst
similarity index 97%
rename from docs/source/getstarted.rst
rename to docs/source/user_manual/getstarted.rst
index 5010292..72e9d0c 100644
--- a/docs/source/getstarted.rst
+++ b/docs/source/user_manual/getstarted.rst
@@ -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 `
+ Manche Einträge wiederum kannst du einsehen, aber nicht bearbeiten. Für mehr Details siehe :ref:`Teilnehmer\*innenverwaltung `
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/
diff --git a/docs/source/images/members_change_tabs.png b/docs/source/user_manual/images/members_change_tabs.png
similarity index 100%
rename from docs/source/images/members_change_tabs.png
rename to docs/source/user_manual/images/members_change_tabs.png
diff --git a/docs/source/images/members_changelist_action.png b/docs/source/user_manual/images/members_changelist_action.png
similarity index 100%
rename from docs/source/images/members_changelist_action.png
rename to docs/source/user_manual/images/members_changelist_action.png
diff --git a/docs/source/images/members_changelist_filters.png b/docs/source/user_manual/images/members_changelist_filters.png
similarity index 100%
rename from docs/source/images/members_changelist_filters.png
rename to docs/source/user_manual/images/members_changelist_filters.png
diff --git a/docs/source/images/members_changelist_group_filter.png b/docs/source/user_manual/images/members_changelist_group_filter.png
similarity index 100%
rename from docs/source/images/members_changelist_group_filter.png
rename to docs/source/user_manual/images/members_changelist_group_filter.png
diff --git a/docs/source/images/members_changelist_pages.png b/docs/source/user_manual/images/members_changelist_pages.png
similarity index 100%
rename from docs/source/images/members_changelist_pages.png
rename to docs/source/user_manual/images/members_changelist_pages.png
diff --git a/docs/source/images/members_changelist_sorting.png b/docs/source/user_manual/images/members_changelist_sorting.png
similarity index 100%
rename from docs/source/images/members_changelist_sorting.png
rename to docs/source/user_manual/images/members_changelist_sorting.png
diff --git a/docs/source/images/members_registration_form.png b/docs/source/user_manual/images/members_registration_form.png
similarity index 100%
rename from docs/source/images/members_registration_form.png
rename to docs/source/user_manual/images/members_registration_form.png
diff --git a/docs/source/images/members_unconfirmed_registration_demote.png b/docs/source/user_manual/images/members_unconfirmed_registration_demote.png
similarity index 100%
rename from docs/source/images/members_unconfirmed_registration_demote.png
rename to docs/source/user_manual/images/members_unconfirmed_registration_demote.png
diff --git a/docs/source/images/members_waitinglist_change_invite_to_group.png b/docs/source/user_manual/images/members_waitinglist_change_invite_to_group.png
similarity index 100%
rename from docs/source/images/members_waitinglist_change_invite_to_group.png
rename to docs/source/user_manual/images/members_waitinglist_change_invite_to_group.png
diff --git a/docs/source/images/members_waitinglist_change_invite_to_group_button.png b/docs/source/user_manual/images/members_waitinglist_change_invite_to_group_button.png
similarity index 100%
rename from docs/source/images/members_waitinglist_change_invite_to_group_button.png
rename to docs/source/user_manual/images/members_waitinglist_change_invite_to_group_button.png
diff --git a/docs/source/images/members_waitinglist_change_invite_to_group_selection.png b/docs/source/user_manual/images/members_waitinglist_change_invite_to_group_selection.png
similarity index 100%
rename from docs/source/images/members_waitinglist_change_invite_to_group_selection.png
rename to docs/source/user_manual/images/members_waitinglist_change_invite_to_group_selection.png
diff --git a/docs/source/user_manual/index.rst b/docs/source/user_manual/index.rst
new file mode 100644
index 0000000..6d802e8
--- /dev/null
+++ b/docs/source/user_manual/index.rst
@@ -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 `_.
+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.
diff --git a/docs/source/members.rst b/docs/source/user_manual/members.rst
similarity index 94%
rename from docs/source/members.rst
rename to docs/source/user_manual/members.rst
index 9eed0b5..5b72e86 100644
--- a/docs/source/members.rst
+++ b/docs/source/user_manual/members.rst
@@ -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
diff --git a/docs/source/waitinglist.rst b/docs/source/user_manual/waitinglist.rst
similarity index 98%
rename from docs/source/waitinglist.rst
rename to docs/source/user_manual/waitinglist.rst
index 09bfe7b..22f8a56 100644
--- a/docs/source/waitinglist.rst
+++ b/docs/source/user_manual/waitinglist.rst
@@ -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 `
+:ref:`Teilnehmer\*innenverwaltung `
.. _group-registration-password:
diff --git a/jdav_web/finance/admin.py b/jdav_web/finance/admin.py
index 463e552..e6fddc1 100644
--- a/jdav_web/finance/admin.py
+++ b/jdav_web/finance/admin.py
@@ -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'),
diff --git a/jdav_web/finance/locale/de/LC_MESSAGES/django.po b/jdav_web/finance/locale/de/LC_MESSAGES/django.po
index 533e564..9f6d9e3 100644
--- a/jdav_web/finance/locale/de/LC_MESSAGES/django.po
+++ b/jdav_web/finance/locale/de/LC_MESSAGES/django.po
@@ -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€"
diff --git a/jdav_web/finance/migrations/0006_statement_add_allowance_to_subsidy_to.py b/jdav_web/finance/migrations/0006_statement_add_allowance_to_subsidy_to.py
new file mode 100644
index 0000000..df2d124
--- /dev/null
+++ b/jdav_web/finance/migrations/0006_statement_add_allowance_to_subsidy_to.py
@@ -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'),
+ ),
+ ]
diff --git a/jdav_web/finance/migrations/0007_alter_statement_allowance_to.py b/jdav_web/finance/migrations/0007_alter_statement_allowance_to.py
new file mode 100644
index 0000000..237ced2
--- /dev/null
+++ b/jdav_web/finance/migrations/0007_alter_statement_allowance_to.py
@@ -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'),
+ ),
+ ]
diff --git a/jdav_web/finance/models.py b/jdav_web/finance/models.py
index d27b74f..8be4291 100644
--- a/jdav_web/finance/models.py
+++ b/jdav_web/finance/models.py
@@ -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:
diff --git a/jdav_web/finance/templates/admin/overview_submitted_statement.html b/jdav_web/finance/templates/admin/overview_submitted_statement.html
index 4fd11f8..4a33ae6 100644
--- a/jdav_web/finance/templates/admin/overview_submitted_statement.html
+++ b/jdav_web/finance/templates/admin/overview_submitted_statement.html
@@ -73,6 +73,25 @@
{% blocktrans %}In total this is {{ total_per_yl }}€ times {{ staff_count }}, giving {{ total_staff }}€.{% endblocktrans %}
+
+{% blocktrans %}The allowance of {{ allowance_per_yl }}€ per person should be paid to:{% endblocktrans %}
+
+ {% for member in statement.allowance_to.all %}
+ -
+ {{ member.name }}
+
+ {% endfor %}
+
+
+
+{% blocktrans %}The subsidies for night and transportation costs of {{ total_subsidies }}€ should be paid to:{% endblocktrans %}
+
+ -
+ {{ statement.subsidy_to.name }}
+
+
+
+
{% endif %}
{% trans "Total" %}
diff --git a/jdav_web/finance/tests.py b/jdav_web/finance/tests.py
index 0014d8d..cc57fd7 100644
--- a/jdav_web/finance/tests.py
+++ b/jdav_web/finance/tests.py
@@ -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.')
diff --git a/jdav_web/locale/de/LC_MESSAGES/django.po b/jdav_web/locale/de/LC_MESSAGES/django.po
index c4738bd..c28a46f 100644
--- a/jdav_web/locale/de/LC_MESSAGES/django.po
+++ b/jdav_web/locale/de/LC_MESSAGES/django.po
@@ -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 \n"
"Language-Team: LANGUAGE \n"
diff --git a/jdav_web/ludwigsburgalpin/tests.py b/jdav_web/ludwigsburgalpin/tests.py
index 7ce503c..38f15a9 100644
--- a/jdav_web/ludwigsburgalpin/tests.py
+++ b/jdav_web/ludwigsburgalpin/tests.py
@@ -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)
diff --git a/jdav_web/mailer/locale/de/LC_MESSAGES/django.po b/jdav_web/mailer/locale/de/LC_MESSAGES/django.po
index a16e5dd..b97b0df 100644
--- a/jdav_web/mailer/locale/de/LC_MESSAGES/django.po
+++ b/jdav_web/mailer/locale/de/LC_MESSAGES/django.po
@@ -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 \n"
"Language-Team: LANGUAGE \n"
diff --git a/jdav_web/material/locale/de/LC_MESSAGES/django.po b/jdav_web/material/locale/de/LC_MESSAGES/django.po
index c8467dd..c438219 100644
--- a/jdav_web/material/locale/de/LC_MESSAGES/django.po
+++ b/jdav_web/material/locale/de/LC_MESSAGES/django.po
@@ -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 \n"
"Language-Team: LANGUAGE \n"
diff --git a/jdav_web/members/admin.py b/jdav_web/members/admin.py
index 8b89c44..83f9517 100644
--- a/jdav_web/members/admin.py
+++ b/jdav_web/members/admin.py
@@ -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."))
diff --git a/jdav_web/members/locale/de/LC_MESSAGES/django.po b/jdav_web/members/locale/de/LC_MESSAGES/django.po
index 16f2958..96ebca3 100644
--- a/jdav_web/members/locale/de/LC_MESSAGES/django.po
+++ b/jdav_web/members/locale/de/LC_MESSAGES/django.po
@@ -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."
diff --git a/jdav_web/members/migrations/0033_freizeit_approved_extra_youth_leader_count.py b/jdav_web/members/migrations/0033_freizeit_approved_extra_youth_leader_count.py
new file mode 100644
index 0000000..5d7d9c2
--- /dev/null
+++ b/jdav_web/members/migrations/0033_freizeit_approved_extra_youth_leader_count.py
@@ -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'),
+ ),
+ ]
diff --git a/jdav_web/members/models.py b/jdav_web/members/models.py
index c5bb60f..36f8a39 100644
--- a/jdav_web/members/models.py
+++ b/jdav_web/members/models.py
@@ -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
diff --git a/jdav_web/members/pdf.py b/jdav_web/members/pdf.py
index a273b95..00a9877 100644
--- a/jdav_web/members/pdf.py
+++ b/jdav_web/members/pdf.py
@@ -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)
diff --git a/jdav_web/members/templates/admin/freizeit_finance_overview.html b/jdav_web/members/templates/admin/freizeit_finance_overview.html
index 81d0b57..2b11d26 100644
--- a/jdav_web/members/templates/admin/freizeit_finance_overview.html
+++ b/jdav_web/members/templates/admin/freizeit_finance_overview.html
@@ -86,8 +86,28 @@ cost plan!
-{% 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 %}
+
+ {% for member in memberlist.statement.allowance_to.all %}
+ -
+ {{ member.name }}
+
+ {% endfor %}
+
+
+
+{% blocktrans %}The subsidies for night and transportation costs of {{ total_subsidies }}€ is configured to be paid to:{% endblocktrans %}
+
+ -
+ {{ memberlist.statement.subsidy_to.name }}
+
+
+{% if not memberlist.statement.allowance_to_valid %}
+
+{% 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 %}
+
+{% endif %}
{% trans "LJP contributions" %}
diff --git a/jdav_web/members/templates/admin/generate_sjr_application.html b/jdav_web/members/templates/admin/generate_sjr_application.html
new file mode 100644
index 0000000..98f6f53
--- /dev/null
+++ b/jdav_web/members/templates/admin/generate_sjr_application.html
@@ -0,0 +1,52 @@
+{% extends "admin/base_site.html" %}
+{% load i18n admin_urls static %}
+
+{% block extrahead %}
+ {{ block.super }}
+ {{ media }}
+
+
+
+{% endblock %}
+
+{% block bodyclass %}{{ block.super }} app-{{ opts.app_label }} model-{{ opts.model_name }} invite-waiter
+{% endblock %}
+
+{% block breadcrumbs %}
+
+{% endblock %}
+
+{% block content %}
+
+{% blocktrans %}Here you can generate an allowance application for the SJR.{% endblocktrans %}
+
+
+{% blocktrans %}The application needs to be complemented with an invoice from the trip as proof.{% endblocktrans %}
+
+
+
+
+
+{% endblock %}
diff --git a/jdav_web/members/templates/members/seminar_report.tex b/jdav_web/members/templates/members/seminar_report.tex
index d73cb67..a5fc4d2 100644
--- a/jdav_web/members/templates/members/seminar_report.tex
+++ b/jdav_web/members/templates/members/seminar_report.tex
@@ -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 %}
diff --git a/jdav_web/members/tests.py b/jdav_web/members/tests.py
index 673d658..2f86987 100644
--- a/jdav_web/members/tests.py
+++ b/jdav_web/members/tests.py
@@ -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()
diff --git a/jdav_web/startpage/locale/de/LC_MESSAGES/django.po b/jdav_web/startpage/locale/de/LC_MESSAGES/django.po
index acfca1f..db3bb87 100644
--- a/jdav_web/startpage/locale/de/LC_MESSAGES/django.po
+++ b/jdav_web/startpage/locale/de/LC_MESSAGES/django.po
@@ -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 \n"
"Language-Team: LANGUAGE \n"
diff --git a/jdav_web/startpage/tests.py b/jdav_web/startpage/tests.py
index c012738..8a8ceef 100644
--- a/jdav_web/startpage/tests.py
+++ b/jdav_web/startpage/tests.py
@@ -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.')
diff --git a/requirements.txt b/requirements.txt
index 747d2f9..1ad9aa9 100644
--- a/requirements.txt
+++ b/requirements.txt
@@ -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