Compare commits

...

13 Commits

@ -16,11 +16,13 @@ env:
NGINX_IMAGE_NAME: ${{ github.repository }}-nginx NGINX_IMAGE_NAME: ${{ github.repository }}-nginx
jobs: jobs:
build-test-and-deploy: build:
runs-on: ubuntu-latest runs-on: ubuntu-latest
permissions: permissions:
contents: write contents: write
packages: write packages: write
pull-requests: write
actions: write
steps: steps:
- name: Checkout repository - name: Checkout repository
@ -32,7 +34,6 @@ jobs:
uses: docker/setup-buildx-action@v3 uses: docker/setup-buildx-action@v3
- name: Log in to GitHub Container Registry - name: Log in to GitHub Container Registry
if: github.event_name != 'pull_request'
uses: docker/login-action@v3 uses: docker/login-action@v3
with: with:
registry: ${{ env.REGISTRY }} registry: ${{ env.REGISTRY }}
@ -47,7 +48,7 @@ jobs:
tags: | tags: |
type=ref,event=branch type=ref,event=branch
type=ref,event=pr type=ref,event=pr
type=sha,prefix={{branch}}- type=sha,prefix={{branch}}-,enable={{is_default_branch}}
type=raw,value=latest,enable={{is_default_branch}} type=raw,value=latest,enable={{is_default_branch}}
- name: Extract metadata for nginx image - name: Extract metadata for nginx image
@ -58,7 +59,7 @@ jobs:
tags: | tags: |
type=ref,event=branch type=ref,event=branch
type=ref,event=pr type=ref,event=pr
type=sha,prefix={{branch}}- type=sha,prefix={{branch}}-,enable={{is_default_branch}}
type=raw,value=latest,enable={{is_default_branch}} type=raw,value=latest,enable={{is_default_branch}}
- name: Build application image - name: Build application image
@ -95,9 +96,25 @@ jobs:
with: with:
github_token: ${{ secrets.GITHUB_TOKEN }} github_token: ${{ secrets.GITHUB_TOKEN }}
publish_dir: ./docs-output publish_dir: ./docs-output
destination_dir: ${{ github.ref == 'refs/heads/main' && '.' || github.ref_name }} destination_dir: ${{ github.ref == 'refs/heads/main' && '.' || github.head_ref || github.ref_name }}
keep_files: true keep_files: true
- name: Comment documentation link
if: github.event_name == 'pull_request'
uses: marocchino/sticky-pull-request-comment@v2
with:
header: deployment
message: |
📚 **Documentation deployed!**
**Documentation:** https://${{ github.repository_owner }}.github.io/${{ github.event.repository.name }}/${{ github.head_ref }}/
**Docker Images:**
- App: `${{ env.REGISTRY }}/${{ env.APP_IMAGE_NAME }}:pr-${{ github.event.pull_request.number }}`
- Nginx: `${{ env.REGISTRY }}/${{ env.NGINX_IMAGE_NAME }}:pr-${{ github.event.pull_request.number }}`
Add the `awaiting-deployment` label to deploy this PR to a staging server.
- name: Run tests - name: Run tests
run: make test-only run: make test-only
@ -111,7 +128,6 @@ jobs:
fi fi
- name: Tag and push application image - name: Tag and push application image
if: github.event_name != 'pull_request'
run: | run: |
# Tag the built image with all required tags # Tag the built image with all required tags
echo "${{ steps.meta-app.outputs.tags }}" | while read -r tag; do echo "${{ steps.meta-app.outputs.tags }}" | while read -r tag; do
@ -120,7 +136,6 @@ jobs:
done done
- name: Build and push nginx image - name: Build and push nginx image
if: github.event_name != 'pull_request'
uses: docker/build-push-action@v5 uses: docker/build-push-action@v5
with: with:
context: docker/production/nginx context: docker/production/nginx
@ -138,10 +153,40 @@ jobs:
BUILDKIT_INLINE_CACHE=1 BUILDKIT_INLINE_CACHE=1
- name: Output image tags - name: Output image tags
if: github.event_name != 'pull_request'
run: | run: |
echo "Application image tags:" echo "Application image tags:"
echo "${{ steps.meta-app.outputs.tags }}" echo "${{ steps.meta-app.outputs.tags }}"
echo "" echo ""
echo "Nginx image tags:" echo "Nginx image tags:"
echo "${{ steps.meta-nginx.outputs.tags }}" echo "${{ steps.meta-nginx.outputs.tags }}"
- name: Check for awaiting-deployment label and trigger deploy
if: github.event_name == 'pull_request'
uses: actions/github-script@v7
with:
script: |
const prNumber = context.payload.pull_request.number;
// Check if PR has awaiting-deployment label
const { data: pr } = await github.rest.pulls.get({
owner: context.repo.owner,
repo: context.repo.repo,
pull_number: prNumber
});
const hasLabel = pr.labels.some(label => label.name === 'awaiting-deployment');
if (hasLabel) {
console.log('PR has awaiting-deployment label, triggering deployment');
await github.rest.actions.createWorkflowDispatch({
owner: context.repo.owner,
repo: context.repo.repo,
workflow_id: 'deploy-pr.yml',
ref: context.payload.pull_request.head.ref,
inputs: {
pr_number: prNumber.toString()
}
});
} else {
console.log('PR does not have awaiting-deployment label, skipping deployment');
}

@ -0,0 +1,81 @@
name: Check Deployment Readiness
on:
pull_request:
types: [labeled]
jobs:
check-and-deploy:
runs-on: ubuntu-latest
if: github.event.label.name == 'awaiting-deployment'
permissions:
actions: write
pull-requests: read
contents: write
steps:
- name: Check build workflow status
id: check
uses: actions/github-script@v7
with:
script: |
const prNumber = context.payload.pull_request.number;
const headSha = context.payload.pull_request.head.sha;
console.log(`Checking build status for PR #${prNumber}, commit ${headSha}`);
// Use Check Runs API to get the status of the build workflow
const { data: checkRuns } = await github.rest.checks.listForRef({
owner: context.repo.owner,
repo: context.repo.repo,
ref: headSha,
check_name: 'build'
});
console.log(`Found ${checkRuns.total_count} check runs for 'build'`);
if (checkRuns.total_count === 0) {
console.log('No build check found for this commit');
core.setOutput('should_deploy', 'false');
core.setOutput('reason', 'No build check found');
return;
}
const buildCheck = checkRuns.check_runs[0];
console.log(`Build check status: ${buildCheck.status}, conclusion: ${buildCheck.conclusion}`);
// Check if build is still running
if (buildCheck.status !== 'completed') {
console.log('Build check is still running');
core.setOutput('should_deploy', 'false');
core.setOutput('reason', 'Build check is still running');
return;
}
// Check if build failed
if (buildCheck.conclusion !== 'success') {
console.log(`Build check failed with conclusion: ${buildCheck.conclusion}`);
core.setOutput('should_deploy', 'false');
core.setOutput('reason', `Build check ${buildCheck.conclusion}`);
return;
}
// Build completed successfully
console.log('Build check completed successfully, ready to deploy');
core.setOutput('should_deploy', 'true');
- name: Trigger deployment
if: steps.check.outputs.should_deploy == 'true'
uses: actions/github-script@v7
with:
script: |
await github.rest.actions.createWorkflowDispatch({
owner: context.repo.owner,
repo: context.repo.repo,
workflow_id: 'deploy-pr.yml',
ref: context.payload.pull_request.head.ref,
inputs: {
pr_number: context.payload.pull_request.number.toString()
}
});
console.log('Deployment workflow triggered');

@ -0,0 +1,92 @@
name: Deploy PR to Remote Server
on:
workflow_dispatch:
inputs:
pr_number:
description: 'PR number to deploy'
required: true
type: number
concurrency:
group: deploy-pr-${{ inputs.pr_number }}
cancel-in-progress: true
env:
REGISTRY: ghcr.io
APP_IMAGE_NAME: ${{ github.repository }}
NGINX_IMAGE_NAME: ${{ github.repository }}-nginx
jobs:
deploy:
runs-on: ubuntu-latest
environment: deploy-web
permissions:
pull-requests: write
steps:
- name: Get PR details
id: pr
uses: actions/github-script@v7
with:
script: |
const prNumber = ${{ inputs.pr_number }};
const { data: pr } = await github.rest.pulls.get({
owner: context.repo.owner,
repo: context.repo.repo,
pull_number: prNumber
});
core.setOutput('head_ref', pr.head.ref);
core.setOutput('pr_number', prNumber);
- name: Setup SSH
run: |
mkdir -p ~/.ssh
echo "${{ secrets.SSH_PRIVATE_KEY }}" > ~/.ssh/deploy_key
chmod 600 ~/.ssh/deploy_key
ssh-keyscan -H ${{ secrets.SSH_HOST }} >> ~/.ssh/known_hosts
- name: Deploy to remote server
id: deploy
run: |
OUTPUT=$(ssh -i ~/.ssh/deploy_key -p ${{ secrets.SSH_PORT || 22 }} ${{ secrets.SSH_USERNAME }}@${{ secrets.SSH_HOST }} ${{ inputs.pr_number }})
echo "$OUTPUT"
DEPLOY_URL=$(echo "$OUTPUT" | tail -n 1)
echo "url=$DEPLOY_URL" >> $GITHUB_OUTPUT
- name: Cleanup SSH
if: always()
run: |
rm -f ~/.ssh/deploy_key
- name: Comment deployment and documentation links
uses: marocchino/sticky-pull-request-comment@v2
with:
number: ${{ steps.pr.outputs.pr_number }}
header: deployment
message: |
🚀 **PR deployed successfully!**
**Website:** ${{ steps.deploy.outputs.url }}
**Documentation:** https://${{ github.repository_owner }}.github.io/${{ github.event.repository.name }}/${{ steps.pr.outputs.head_ref }}/
**Docker Images:**
- App: `${{ env.REGISTRY }}/${{ env.APP_IMAGE_NAME }}:pr-${{ steps.pr.outputs.pr_number }}`
- Nginx: `${{ env.REGISTRY }}/${{ env.NGINX_IMAGE_NAME }}:pr-${{ steps.pr.outputs.pr_number }}`
- name: Remove awaiting-deployment label
if: always()
uses: actions/github-script@v7
with:
script: |
try {
await github.rest.issues.removeLabel({
owner: context.repo.owner,
repo: context.repo.repo,
issue_number: ${{ steps.pr.outputs.pr_number }},
name: 'awaiting-deployment'
});
} catch (error) {
console.log('Label may have already been removed');
}

21
Jenkinsfile vendored

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

@ -0,0 +1,62 @@
x-kompass:
&kompass
image: ghcr.io/chrisflav/kompass:latest
env_file: docker.env
environment:
- DJANGO_SETTINGS_MODULE=jdav_web.settings
- KOMPASS_CONFIG_DIR_PATH=/app/config/
restart: always
depends_on:
- db
- redis
- cache
services:
master:
<<: *kompass
entrypoint: /app/docker/production/entrypoint-master.sh
volumes:
- uwsgi_data:/tmp/uwsgi/
- web_static:/app/static/
- web_static:/var/www/jdav_web/static/
- ./config:/app/config:ro
nginx:
image: ghcr.io/chrisflav/kompass-nginx:latest
restart: always
volumes:
- uwsgi_data:/tmp/uwsgi/
- web_static:/var/www/jdav_web/static/:ro
ports:
- "3000:80"
depends_on:
- master
db:
restart: always
image: mariadb:latest
env_file: docker.env
cache:
restart: always
image: memcached:alpine
redis:
restart: always
image: redis:6-alpine
celery_worker:
<<: *kompass
entrypoint: /app/docker/production/entrypoint-celery-worker.sh
volumes:
- ./config:/app/config:ro
celery_beat:
<<: *kompass
entrypoint: /app/docker/production/entrypoint-celery-beat.sh
volumes:
- ./config:/app/config:ro
volumes:
uwsgi_data:
web_static:

@ -10,11 +10,11 @@ About
.. rst-class:: lead .. rst-class:: lead
.. attention:: .. attention::
Die Seite befindet sich noch im Aufbau. -- The page is still under construction. Die Seite befindet sich noch im Aufbau. -- The page is still under construction.
(Stand: 08.01.2025) (Stand: 08.01.2025)
- About the kompass project - About the kompass project
- About this documentation - About this documentation

@ -4,60 +4,60 @@
# https://www.sphinx-doc.org/en/master/usage/configuration.html # https://www.sphinx-doc.org/en/master/usage/configuration.html
from dataclasses import asdict from dataclasses import asdict
from sphinxawesome_theme import ThemeOptions
from sphinxawesome_theme import ThemeOptions
# -- Project information ------------------------------------------------------- # -- Project information -------------------------------------------------------
# https://www.sphinx-doc.org/en/master/usage/configuration.html#project-information # https://www.sphinx-doc.org/en/master/usage/configuration.html#project-information
project = 'Kompass' project = "Kompass"
release = '2.0' release = "2.0"
author = 'The Kompass Team' author = "The Kompass Team"
copyright = f'2025, {author}' copyright = f"2025, {author}"
# -- General configuration ----------------------------------------------------- # -- General configuration -----------------------------------------------------
# https://www.sphinx-doc.org/en/master/usage/configuration.html#general-configuration # https://www.sphinx-doc.org/en/master/usage/configuration.html#general-configuration
extensions = [] extensions = []
templates_path = ['_templates'] templates_path = ["_templates"]
exclude_patterns = [] exclude_patterns = []
language = 'de' language = "de"
# -- Options for HTML output --------------------------------------------------- # -- Options for HTML output ---------------------------------------------------
# https://www.sphinx-doc.org/en/master/usage/configuration.html#options-for-html-output # https://www.sphinx-doc.org/en/master/usage/configuration.html#options-for-html-output
html_theme = 'sphinxawesome_theme' html_theme = "sphinxawesome_theme"
html_static_path = ['_static'] html_static_path = ["_static"]
# -- Sphinxawsome-theme options ------------------------------------------------ # -- Sphinxawsome-theme options ------------------------------------------------
# https://sphinxawesome.xyz/how-to/configure/ # https://sphinxawesome.xyz/how-to/configure/
html_logo = "_static/favicon.svg" html_logo = "_static/favicon.svg"
html_favicon = "_static/favicon.svg" html_favicon = "_static/favicon.svg"
html_sidebars = { html_sidebars = {
"about": ["sidebar_main_nav_links.html"], "about": ["sidebar_main_nav_links.html"],
# "changelog": ["sidebar_main_nav_links.html"], # "changelog": ["sidebar_main_nav_links.html"],
} }
# Code blocks color scheme # Code blocks color scheme
pygments_style = "emacs" pygments_style = "emacs"
pygments_style_dark = "emacs" pygments_style_dark = "emacs"
# Could be directly in html_theme_options, but this way it has type hints # Could be directly in html_theme_options, but this way it has type hints
# from sphinxawesome_theme # from sphinxawesome_theme
theme_options = ThemeOptions( theme_options = ThemeOptions(
show_prev_next=True, show_prev_next=True,
show_breadcrumbs=True, show_breadcrumbs=True,
main_nav_links={ main_nav_links={
"Docs": "index", "Docs": "index",
"About": "about", "About": "about",
# "Changelog": "changelog" # "Changelog": "changelog"
}, },
show_scrolltop=True, show_scrolltop=True,
) )
html_theme_options = asdict(theme_options) html_theme_options = asdict(theme_options)

@ -4,4 +4,4 @@
Architecture Architecture
================= =================
tbd tbd

@ -52,9 +52,9 @@ If you want to contribute to the documentation, please follow the steps below.
Online (latest release version): https://jdav-hd.de/static/docs/ Online (latest release version): https://jdav-hd.de/static/docs/
- This documentation is build `sphinx <https://www.sphinx-doc.org/>`_ and `awsome sphinx theme <https://sphinxawesome.xyz/>`_ the source code is located in ``docs/``. - This documentation is build `sphinx <https://www.sphinx-doc.org/>`_ and `awsome sphinx theme <https://sphinxawesome.xyz/>`_ the source code is located in ``docs/``.
- All documentation is written in `reStructuredText <https://www.sphinx-doc.org/en/master/usage/restructuredtext/index.html>`_ and uses the `sphinx directives <https://www.sphinx-doc.org/en/master/usage/restructuredtext/directives.html>`_. - All documentation is written in `reStructuredText <https://www.sphinx-doc.org/en/master/usage/restructuredtext/index.html>`_ and uses the `sphinx directives <https://www.sphinx-doc.org/en/master/usage/restructuredtext/directives.html>`_.
- The directives can vary due to the theme, see the `awesome sphinx theme documentation <https://sphinxawesome.xyz/demo/notes/>`_. - The directives can vary due to the theme, see the `awesome sphinx theme documentation <https://sphinxawesome.xyz/demo/notes/>`_.
- All technical documentation is written in english, user documentation is written in german. - 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: To read the documentation build it locally and view it in your browser:
@ -65,9 +65,9 @@ To read the documentation build it locally and view it in your browser:
make html make html
# MacOS (with firefox) # MacOS (with firefox)
open -a firefox $(pwd)/docs/build/html/index.html open -a firefox $(pwd)/docs/build/html/index.html
# Linux (I guess?!?) # Linux (I guess?!?)
firefox ${pwd}/docs/build/html/index.html firefox ${pwd}/docs/build/html/index.html
Code Code
---- ----
@ -75,11 +75,10 @@ Code
If you want to contribute code, please follow the inital setup steps in the :ref:`development_manual/setup` section. And dont forget to :ref:`document <development_manual/contributing/documentation>` your code properly and write tests. If you want to contribute code, please follow the inital setup steps in the :ref:`development_manual/setup` section. And dont forget to :ref:`document <development_manual/contributing/documentation>` your code properly and write tests.
.. note:: .. note::
Still open / to decide: Still open / to decide:
- linting - linting
- (auto) formatting - (auto) formatting
- reliable tests via ci/cd pipeline - reliable tests via ci/cd pipeline

@ -32,11 +32,9 @@ Documentation
Structure Structure
- :ref:`Nutzer Dokumentation <user_manual/index>` auf deutsch - :ref:`Nutzer Dokumentation <user_manual/index>` auf deutsch
- :ref:`Development Documentation <development_manual/index>` auf englisch - :ref:`Development Documentation <development_manual/index>` auf englisch
.. seealso:: .. seealso::
:ref:`Contributing #Documentation <development_manual/contributing/documentation>` :ref:`Contributing #Documentation <development_manual/contributing/documentation>`

@ -55,7 +55,7 @@ If you need to rebuild the container (e.g. after changing the ``requirements.txt
docker compose up --build docker compose up --build
5. Setup admin user: in a separate shell, while the docker container is running, execute 5. Setup admin user: in a separate shell, while the docker container is running, execute
.. code-block:: bash .. code-block:: bash
cd docker/development cd docker/development

@ -4,4 +4,4 @@
Testing Testing
================= =================
To run the tests, you can use the docker setup under ``docker/test``. To run the tests, you can use the docker setup under ``docker/test``.

@ -11,13 +11,13 @@ jdav Kompass
Der Kompass ist dein Kompass in der Jugendarbeit in deiner JDAV Sektion. Wenn du das 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. erste mal hier bist, schau doch mal :ref:`user_manual/getstarted` an.
.. attention:: .. attention::
Die Dokumentation befindet sich noch im Aufbau. -- The documentation is still under construction. Die Dokumentation befindet sich noch im Aufbau. -- The documentation is still under construction.
(Stand: 08.01.2025) (Stand: 08.01.2025)
Nutzer Dokumentation Nutzer Dokumentation
-------------------- --------------------
- auf deutsch - auf deutsch
@ -29,7 +29,7 @@ Nutzer Dokumentation
user_manual/index user_manual/index
Development Documentation Development Documentation
------------------------- -------------------------
- auf englisch - auf englisch
@ -39,4 +39,3 @@ Development Documentation
:titlesonly: :titlesonly:
development_manual/index development_manual/index

@ -52,4 +52,3 @@ Im Nachhinein trägst du deine Ausgaben ein, lädst Belege hoch und reichst dein
.. _anlegen: https://jdav-hd.de/kompassmembers/freizeit/add/ .. _anlegen: https://jdav-hd.de/kompassmembers/freizeit/add/
.. _Teilnehmer\*innen: https://jdav-hd.de/kompassmembers/member/ .. _Teilnehmer\*innen: https://jdav-hd.de/kompassmembers/member/

@ -32,7 +32,7 @@ er auf den entsprechenden Eintrag, ändert das Geburtsdatum und klickt auf *Spei
Manche Einträge wiederum kannst du einsehen, aber nicht bearbeiten. Für mehr Details siehe :ref:`Teilnehmer\*innenverwaltung <user_manual/members>` Manche Einträge wiederum kannst du einsehen, aber nicht bearbeiten. Für mehr Details siehe :ref:`Teilnehmer\*innenverwaltung <user_manual/members>`
Probier doch einmal aus deinen eigenen Eintrag zu ändern. Sicherlich gibt es einige 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, Felder, die nicht ausgefüllt oder nicht mehr aktuell sind. Lade z.B. ein Bild von dir hoch,
damit unsere Website schöner wird. damit unsere Website schöner wird.
Wie schicke ich eine E-Mail an meine Gruppe? Wie schicke ich eine E-Mail an meine Gruppe?

@ -39,7 +39,7 @@ Aufnahme von neuen Mitgliedern und die Pflege der Daten durch
Feedback Feedback
-------- --------
Wenn Du Feedback hast, schreibe uns gerne eine E-Mail an: `digitales@jdav-hd.de <mailto:digitales@jdav-hd.de?subject=Kompass Feedback>`_. Wenn Du Feedback hast, schreibe uns gerne eine E-Mail an: `digitales@jdav-hd.de <mailto:digitales@jdav-hd.de?subject=Kompass Feedback>`_.
Der Kompass lebt davon, dass er genau unsere Probleme löst und nicht nur ein weiteres Tool ist. Der Kompass lebt davon, dass er genau unsere Probleme löst und nicht nur ein weiteres Tool ist.
Feedback könnte sein: Feedback könnte sein:

@ -1,14 +1,12 @@
import copy import copy
from django.contrib.auth import get_permission_codename
from django.contrib import messages
from django.contrib.auth import get_permission_codename
from django.core.exceptions import PermissionDenied from django.core.exceptions import PermissionDenied
from django.contrib import admin, messages
from django.utils.translation import gettext_lazy as _
from django.http import HttpResponse, HttpResponseRedirect
from django.urls import path, reverse
from django.db import models from django.db import models
from django.contrib.admin import helpers, widgets from django.http import HttpResponseRedirect
import rules.contrib.admin from django.urls import reverse
from django.utils.translation import gettext_lazy as _
from rules.permissions import perm_exists from rules.permissions import perm_exists
@ -16,19 +14,36 @@ def decorate_admin_view(model, perm=None):
""" """
Decorator for wrapping admin views. Decorator for wrapping admin views.
""" """
def decorator(fun): def decorator(fun):
def aux(self, request, object_id): def aux(self, request, object_id):
try: try:
obj = model.objects.get(pk=object_id) obj = model.objects.get(pk=object_id)
except model.DoesNotExist: except model.DoesNotExist:
messages.error(request, _('%(modelname)s not found.') % {'modelname': self.opts.verbose_name}) messages.error(
return HttpResponseRedirect(reverse('admin:%s_%s_changelist' % (self.opts.app_label, self.opts.model_name))) request, _("%(modelname)s not found.") % {"modelname": self.opts.verbose_name}
permitted = self.has_change_permission(request, obj) if not perm else request.user.has_perm(perm) )
return HttpResponseRedirect(
reverse(
"admin:{}_{}_changelist".format(self.opts.app_label, self.opts.model_name)
)
)
permitted = (
self.has_change_permission(request, obj)
if not perm
else request.user.has_perm(perm)
)
if not permitted: if not permitted:
messages.error(request, _('Insufficient permissions.')) messages.error(request, _("Insufficient permissions."))
return HttpResponseRedirect(reverse('admin:%s_%s_changelist' % (self.opts.app_label, self.opts.model_name))) return HttpResponseRedirect(
reverse(
"admin:{}_{}_changelist".format(self.opts.app_label, self.opts.model_name)
)
)
return fun(self, request, obj) return fun(self, request, obj)
return aux return aux
return decorator return decorator
@ -37,7 +52,7 @@ class FieldPermissionsAdminMixin:
field_view_permissions = {} field_view_permissions = {}
def may_view_field(self, field_desc, request, obj=None): def may_view_field(self, field_desc, request, obj=None):
if not type(field_desc) is tuple: if type(field_desc) is not tuple:
field_desc = (field_desc,) field_desc = (field_desc,)
for fd in field_desc: for fd in field_desc:
if fd not in self.field_view_permissions: if fd not in self.field_view_permissions:
@ -47,37 +62,41 @@ class FieldPermissionsAdminMixin:
return True return True
def get_fieldsets(self, request, obj=None): def get_fieldsets(self, request, obj=None):
fieldsets = super(FieldPermissionsAdminMixin, self).get_fieldsets(request, obj) fieldsets = super().get_fieldsets(request, obj)
d = [] d = []
for title, attrs in fieldsets: for title, attrs in fieldsets:
allowed = [f for f in attrs['fields'] if self.may_view_field(f, request, obj)] allowed = [f for f in attrs["fields"] if self.may_view_field(f, request, obj)]
if len(allowed) == 0: if len(allowed) == 0:
continue continue
d.append((title, dict(attrs, **{'fields': allowed}))) d.append((title, dict(attrs, **{"fields": allowed})))
return d return d
def get_fields(self, request, obj=None): def get_fields(self, request, obj=None):
fields = super(FieldPermissionsAdminMixin, self).get_fields(request, obj) fields = super().get_fields(request, obj)
return [fd for fd in fields if self.may_view_field(fd, request, obj)] return [fd for fd in fields if self.may_view_field(fd, request, obj)]
def get_readonly_fields(self, request, obj=None): def get_readonly_fields(self, request, obj=None):
readonly_fields = super(FieldPermissionsAdminMixin, self).get_readonly_fields(request, obj) readonly_fields = super().get_readonly_fields(request, obj)
return list(readonly_fields) +\ return list(readonly_fields) + [
[fd for fd, perm in self.field_change_permissions.items() if not request.user.has_perm(perm)] fd
for fd, perm in self.field_change_permissions.items()
if not request.user.has_perm(perm)
]
class ChangeViewAdminMixin: class ChangeViewAdminMixin:
def change_view(self, request, object_id, form_url="", extra_context=None): def change_view(self, request, object_id, form_url="", extra_context=None):
try: try:
return super(ChangeViewAdminMixin, self).change_view(request, object_id, return super().change_view(
form_url=form_url, request, object_id, form_url=form_url, extra_context=extra_context
extra_context=extra_context) )
except PermissionDenied: except PermissionDenied:
opts = self.opts opts = self.opts
obj = self.model.objects.get(pk=object_id) obj = self.model.objects.get(pk=object_id)
messages.error(request, messages.error(request, _("You are not allowed to view %(name)s.") % {"name": str(obj)})
_("You are not allowed to view %(name)s.") % {'name': str(obj)}) return HttpResponseRedirect(
return HttpResponseRedirect(reverse('admin:%s_%s_changelist' % (opts.app_label, opts.model_name))) reverse("admin:{}_{}_changelist".format(opts.app_label, opts.model_name))
)
class FilteredQuerysetAdminMixin: class FilteredQuerysetAdminMixin:
@ -91,28 +110,34 @@ class FilteredQuerysetAdminMixin:
if ordering: if ordering:
qs = qs.order_by(*ordering) qs = qs.order_by(*ordering)
queryset = qs queryset = qs
list_global_perm = '%s.list_global_%s' % (self.opts.app_label, self.opts.model_name) list_global_perm = "{}.list_global_{}".format(self.opts.app_label, self.opts.model_name)
if request.user.has_perm(list_global_perm): if request.user.has_perm(list_global_perm):
view_global_perm = '%s.view_global_%s' % (self.opts.app_label, self.opts.model_name) view_global_perm = "{}.view_global_{}".format(self.opts.app_label, self.opts.model_name)
if request.user.has_perm(view_global_perm): if request.user.has_perm(view_global_perm):
return queryset return queryset
if hasattr(request.user, 'member'): if hasattr(request.user, "member"):
return request.user.member.annotate_view_permission(queryset, model=self.model) return request.user.member.annotate_view_permission(queryset, model=self.model)
return queryset.annotate(_viewable=models.Value(False)) return queryset.annotate(_viewable=models.Value(False))
if not hasattr(request.user, 'member'): if not hasattr(request.user, "member"):
return self.model.objects.none() return self.model.objects.none()
return request.user.member.filter_queryset_by_permissions(queryset, annotate=True, model=self.model) return request.user.member.filter_queryset_by_permissions(
queryset, annotate=True, model=self.model
)
# class ObjectPermissionsInlineModelAdminMixin(rules.contrib.admin.ObjectPermissionsInlineModelAdminMixin):
#class ObjectPermissionsInlineModelAdminMixin(rules.contrib.admin.ObjectPermissionsInlineModelAdminMixin):
class CommonAdminMixin(FieldPermissionsAdminMixin, ChangeViewAdminMixin, FilteredQuerysetAdminMixin): class CommonAdminMixin(
FieldPermissionsAdminMixin, ChangeViewAdminMixin, FilteredQuerysetAdminMixin
):
def has_add_permission(self, request, obj=None): def has_add_permission(self, request, obj=None):
assert obj is None assert obj is None
opts = self.opts opts = self.opts
codename = get_permission_codename("add_global", opts) codename = get_permission_codename("add_global", opts)
perm = "%s.%s" % (opts.app_label, codename) perm = "{}.{}".format(opts.app_label, codename)
return request.user.has_perm(perm, obj) return request.user.has_perm(perm, obj)
def has_view_permission(self, request, obj=None): def has_view_permission(self, request, obj=None):
@ -121,7 +146,7 @@ class CommonAdminMixin(FieldPermissionsAdminMixin, ChangeViewAdminMixin, Filtere
codename = get_permission_codename("view", opts) codename = get_permission_codename("view", opts)
else: else:
codename = get_permission_codename("view_obj", opts) codename = get_permission_codename("view_obj", opts)
perm = "%s.%s" % (opts.app_label, codename) perm = "{}.{}".format(opts.app_label, codename)
if perm_exists(perm): if perm_exists(perm):
return request.user.has_perm(perm, obj) return request.user.has_perm(perm, obj)
else: else:
@ -133,7 +158,7 @@ class CommonAdminMixin(FieldPermissionsAdminMixin, ChangeViewAdminMixin, Filtere
codename = get_permission_codename("view", opts) codename = get_permission_codename("view", opts)
else: else:
codename = get_permission_codename("change_obj", opts) codename = get_permission_codename("change_obj", opts)
return request.user.has_perm("%s.%s" % (opts.app_label, codename), obj) return request.user.has_perm("{}.{}".format(opts.app_label, codename), obj)
def has_delete_permission(self, request, obj=None): def has_delete_permission(self, request, obj=None):
opts = self.opts opts = self.opts
@ -141,7 +166,7 @@ class CommonAdminMixin(FieldPermissionsAdminMixin, ChangeViewAdminMixin, Filtere
codename = get_permission_codename("delete_global", opts) codename = get_permission_codename("delete_global", opts)
else: else:
codename = get_permission_codename("delete_obj", opts) codename = get_permission_codename("delete_obj", opts)
return request.user.has_perm("%s.%s" % (opts.app_label, codename), obj) return request.user.has_perm("{}.{}".format(opts.app_label, codename), obj)
def formfield_for_dbfield(self, db_field, request, **kwargs): def formfield_for_dbfield(self, db_field, request, **kwargs):
""" """
@ -176,7 +201,7 @@ class CommonAdminMixin(FieldPermissionsAdminMixin, ChangeViewAdminMixin, Filtere
# extra HTML -- the "add other" interface -- to the end of the # extra HTML -- the "add other" interface -- to the end of the
# rendered output. formfield can be None if it came from a # rendered output. formfield can be None if it came from a
# OneToOneField with parent_link=True or a M2M intermediary. # OneToOneField with parent_link=True or a M2M intermediary.
#if formfield and db_field.name not in self.raw_id_fields: # if formfield and db_field.name not in self.raw_id_fields:
# formfield.widget = widgets.RelatedFieldWidgetWrapper( # formfield.widget = widgets.RelatedFieldWidgetWrapper(
# formfield.widget, # formfield.widget,
# db_field.remote_field, # db_field.remote_field,
@ -198,13 +223,13 @@ class CommonAdminMixin(FieldPermissionsAdminMixin, ChangeViewAdminMixin, Filtere
class CommonAdminInlineMixin(CommonAdminMixin): class CommonAdminInlineMixin(CommonAdminMixin):
def has_add_permission(self, request, obj): def has_add_permission(self, request, obj):
#assert obj is not None # assert obj is not None
if obj is None: if obj is None:
return True return True
if obj.pk is None: if obj.pk is None:
return True return True
codename = get_permission_codename("add_obj", self.opts) codename = get_permission_codename("add_obj", self.opts)
return request.user.has_perm('%s.%s' % (self.opts.app_label, codename), obj) return request.user.has_perm("{}.{}".format(self.opts.app_label, codename), obj)
def has_view_permission(self, request, obj=None): # pragma: no cover def has_view_permission(self, request, obj=None): # pragma: no cover
if obj is None: if obj is None:
@ -216,7 +241,7 @@ class CommonAdminInlineMixin(CommonAdminMixin):
codename = get_permission_codename("view", opts) codename = get_permission_codename("view", opts)
else: else:
codename = get_permission_codename("view_obj", opts) codename = get_permission_codename("view_obj", opts)
perm = "%s.%s" % (opts.app_label, codename) perm = "{}.{}".format(opts.app_label, codename)
if perm_exists(perm): if perm_exists(perm):
return request.user.has_perm(perm, obj) return request.user.has_perm(perm, obj)
else: else:
@ -234,7 +259,7 @@ class CommonAdminInlineMixin(CommonAdminMixin):
opts = field.rel.to._meta opts = field.rel.to._meta
break break
codename = get_permission_codename("change_obj", opts) codename = get_permission_codename("change_obj", opts)
return request.user.has_perm("%s.%s" % (opts.app_label, codename), obj) return request.user.has_perm("{}.{}".format(opts.app_label, codename), obj)
def has_delete_permission(self, request, obj=None): # pragma: no cover def has_delete_permission(self, request, obj=None): # pragma: no cover
if obj is None: if obj is None:

@ -2,5 +2,5 @@ from django.apps import AppConfig
class ContribConfig(AppConfig): class ContribConfig(AppConfig):
default_auto_field = 'django.db.models.BigAutoField' default_auto_field = "django.db.models.BigAutoField"
name = 'contrib' name = "contrib"

@ -1,28 +1,26 @@
import os import os
from django.contrib.auth import get_user_model from django.contrib.auth import get_user_model
from django.core.management.base import BaseCommand from django.core.management.base import BaseCommand
class Command(BaseCommand): class Command(BaseCommand):
help = "Creates a super-user non-interactively if it doesn't exist." help = "Creates a super-user non-interactively if it doesn't exist."
def handle(self, *args, **options): def handle(self, *args, **options):
User = get_user_model() User = get_user_model()
username = os.environ.get('DJANGO_SUPERUSER_USERNAME', '') username = os.environ.get("DJANGO_SUPERUSER_USERNAME", "")
password = os.environ.get('DJANGO_SUPERUSER_PASSWORD', '') password = os.environ.get("DJANGO_SUPERUSER_PASSWORD", "")
if not username or not password: if not username or not password:
self.stdout.write( self.stdout.write(self.style.WARNING("Superuser data was not set. Skipping."))
self.style.WARNING('Superuser data was not set. Skipping.')
)
return return
if not User.objects.filter(username=username).exists(): if not User.objects.filter(username=username).exists():
User.objects.create_superuser(username=username, password=password) User.objects.create_superuser(username=username, password=password)
self.stdout.write( self.stdout.write(self.style.SUCCESS("Successfully created superuser."))
self.style.SUCCESS('Successfully created superuser.')
)
else: else:
self.stdout.write( self.stdout.write(
self.style.SUCCESS('Superuser with configured username already exists. Skipping.') self.style.SUCCESS("Superuser with configured username already exists. Skipping.")
) )

@ -1,9 +1,9 @@
import os import os
from wsgiref.util import FileWrapper
from django import template
from django.conf import settings from django.conf import settings
from django.http import HttpResponse from django.http import HttpResponse
from django import template
from django.template.loader import get_template
from wsgiref.util import FileWrapper
def find_template(template_name): def find_template(template_name):
@ -27,12 +27,15 @@ def serve_media(filename, content_type):
""" """
Serve the media file with the given `filename` as an HTTP response. Serve the media file with the given `filename` as an HTTP response.
""" """
with open(media_path(filename), 'rb') as f: with open(media_path(filename), "rb") as f:
response = HttpResponse(FileWrapper(f)) response = HttpResponse(FileWrapper(f))
response['Content-Type'] = content_type response["Content-Type"] = content_type
# download other files than pdf, show pdfs in the browser # download other files than pdf, show pdfs in the browser
response['Content-Disposition'] = 'filename='+filename if content_type == 'application/pdf' else 'attachment; filename='+filename response["Content-Disposition"] = (
"filename=" + filename
if content_type == "application/pdf"
else "attachment; filename=" + filename
)
return response return response

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

@ -1,12 +1,12 @@
from django.contrib.auth import get_permission_codename
import rules.contrib.admin import rules.contrib.admin
import rules
def memberize_user(func): def memberize_user(func):
def inner(user, other): def inner(user, other):
if not hasattr(user, 'member'): if not hasattr(user, "member"):
return False return False
return func(user.member, other) return func(user.member, other)
return inner return inner

@ -3,6 +3,7 @@ from django.conf import settings
register = template.Library() register = template.Library()
# settings value # settings value
@register.simple_tag @register.simple_tag
def settings_value(name): def settings_value(name):

@ -1,28 +1,38 @@
from datetime import datetime, timedelta from datetime import timedelta
from decimal import Decimal from unittest.mock import Mock
from django.test import TestCase, RequestFactory from unittest.mock import patch
from django.contrib.auth import get_user_model
from contrib.admin import CommonAdminMixin
from contrib.models import CommonModel
from contrib.rules import has_global_perm
from django.contrib import admin from django.contrib import admin
from django.db import models from django.contrib.auth import get_user_model
from django.core.exceptions import ValidationError from django.core.exceptions import ValidationError
from django.core.files.uploadedfile import SimpleUploadedFile from django.db import models
from django.test import RequestFactory
from django.test import TestCase
from django.utils.translation import gettext_lazy as _ from django.utils.translation import gettext_lazy as _
from unittest.mock import Mock, patch from rules.contrib.models import RulesModelBase
from rules.contrib.models import RulesModelMixin, RulesModelBase from rules.contrib.models import RulesModelMixin
from contrib.models import CommonModel from utils import file_size_validator
from contrib.rules import has_global_perm from utils import mondays_until_nth
from contrib.admin import CommonAdminMixin from utils import RestrictedFileField
from utils import file_size_validator, RestrictedFileField, cvt_to_decimal, get_member, normalize_name, normalize_filename, coming_midnight, mondays_until_nth
User = get_user_model() User = get_user_model()
class CommonModelTestCase(TestCase): class CommonModelTestCase(TestCase):
def test_common_model_abstract_base(self): def test_common_model_abstract_base(self):
"""Test that CommonModel provides the correct meta attributes""" """Test that CommonModel provides the correct meta attributes"""
meta = CommonModel._meta meta = CommonModel._meta
self.assertTrue(meta.abstract) self.assertTrue(meta.abstract)
expected_permissions = ( expected_permissions = (
'add_global', 'change_global', 'view_global', 'delete_global', 'list_global', 'view', "add_global",
"change_global",
"view_global",
"delete_global",
"list_global",
"view",
) )
self.assertEqual(meta.default_permissions, expected_permissions) self.assertEqual(meta.default_permissions, expected_permissions)
@ -38,15 +48,13 @@ class CommonModelTestCase(TestCase):
class GlobalPermissionRulesTestCase(TestCase): class GlobalPermissionRulesTestCase(TestCase):
def setUp(self): def setUp(self):
self.user = User.objects.create_user( self.user = User.objects.create_user(
username='testuser', username="testuser", email="test@example.com", password="testpass123"
email='test@example.com',
password='testpass123'
) )
def test_has_global_perm_predicate_creation(self): def test_has_global_perm_predicate_creation(self):
"""Test that has_global_perm creates a predicate function""" """Test that has_global_perm creates a predicate function"""
# has_global_perm is a decorator factory, not a direct predicate # has_global_perm is a decorator factory, not a direct predicate
predicate = has_global_perm('auth.add_user') predicate = has_global_perm("auth.add_user")
self.assertTrue(callable(predicate)) self.assertTrue(callable(predicate))
def test_has_global_perm_with_superuser(self): def test_has_global_perm_with_superuser(self):
@ -54,33 +62,32 @@ class GlobalPermissionRulesTestCase(TestCase):
self.user.is_superuser = True self.user.is_superuser = True
self.user.save() self.user.save()
predicate = has_global_perm('auth.add_user') predicate = has_global_perm("auth.add_user")
result = predicate(self.user, None) result = predicate(self.user, None)
self.assertTrue(result) self.assertTrue(result)
def test_has_global_perm_with_regular_user(self): def test_has_global_perm_with_regular_user(self):
"""Test that regular users don't automatically have global permissions""" """Test that regular users don't automatically have global permissions"""
predicate = has_global_perm('auth.add_user') predicate = has_global_perm("auth.add_user")
result = predicate(self.user, None) result = predicate(self.user, None)
self.assertFalse(result) self.assertFalse(result)
class CommonAdminMixinTestCase(TestCase): class CommonAdminMixinTestCase(TestCase):
def setUp(self): def setUp(self):
self.user = User.objects.create_user(username='testuser', password='testpass') self.user = User.objects.create_user(username="testuser", password="testpass")
def test_formfield_for_dbfield_with_formfield_overrides(self): def test_formfield_for_dbfield_with_formfield_overrides(self):
"""Test formfield_for_dbfield when db_field class is in formfield_overrides""" """Test formfield_for_dbfield when db_field class is in formfield_overrides"""
# Create a test admin instance that inherits from Django's ModelAdmin # Create a test admin instance that inherits from Django's ModelAdmin
class TestAdmin(CommonAdminMixin, admin.ModelAdmin): class TestAdmin(CommonAdminMixin, admin.ModelAdmin):
formfield_overrides = { formfield_overrides = {models.ForeignKey: {"widget": Mock()}}
models.ForeignKey: {'widget': Mock()}
}
# Create a mock model to use with the admin # Create a mock model to use with the admin
class TestModel: class TestModel:
_meta = Mock() _meta = Mock()
_meta.app_label = 'test' _meta.app_label = "test"
admin_instance = TestAdmin(TestModel, admin.site) admin_instance = TestAdmin(TestModel, admin.site)
@ -88,11 +95,11 @@ class CommonAdminMixinTestCase(TestCase):
db_field = models.ForeignKey(User, on_delete=models.CASCADE) db_field = models.ForeignKey(User, on_delete=models.CASCADE)
# Create a test request # Create a test request
request = RequestFactory().get('/') request = RequestFactory().get("/")
request.user = self.user request.user = self.user
# Call the method to test formfield_overrides usage # Call the method to test formfield_overrides usage
result = admin_instance.formfield_for_dbfield(db_field, request, help_text='Test help text') result = admin_instance.formfield_for_dbfield(db_field, request, help_text="Test help text")
# Verify that the formfield_overrides were used # Verify that the formfield_overrides were used
self.assertIsNotNone(result) self.assertIsNotNone(result)
@ -101,9 +108,7 @@ class CommonAdminMixinTestCase(TestCase):
class UtilsTestCase(TestCase): class UtilsTestCase(TestCase):
def setUp(self): def setUp(self):
self.user = User.objects.create_user( self.user = User.objects.create_user(
username='testuser', username="testuser", email="test@example.com", password="testpass123"
email='test@example.com',
password='testpass123'
) )
def test_file_size_validator_exceeds_limit(self): def test_file_size_validator_exceeds_limit(self):
@ -118,12 +123,14 @@ class UtilsTestCase(TestCase):
validator(mock_file) validator(mock_file)
# Check for the translated error message # Check for the translated error message
expected_message = str(_('Please keep filesize under {} MiB. Current filesize: {:10.2f} MiB.').format(1, 2.00)) expected_message = str(
_("Please keep filesize under {} MiB. Current filesize: {:10.2f} MiB.").format(1, 2.00)
)
self.assertIn(expected_message, str(cm.exception)) self.assertIn(expected_message, str(cm.exception))
def test_restricted_file_field_content_type_not_supported(self): def test_restricted_file_field_content_type_not_supported(self):
"""Test RestrictedFileField when content type is not supported""" """Test RestrictedFileField when content type is not supported"""
field = RestrictedFileField(content_types=['image/jpeg']) field = RestrictedFileField(content_types=["image/jpeg"])
# Create mock data with unsupported content type # Create mock data with unsupported content type
mock_data = Mock() mock_data = Mock()
@ -131,12 +138,12 @@ class UtilsTestCase(TestCase):
mock_data.file.content_type = "text/plain" mock_data.file.content_type = "text/plain"
# Mock the super().clean() to return our mock data # Mock the super().clean() to return our mock data
with patch.object(models.FileField, 'clean', return_value=mock_data): with patch.object(models.FileField, "clean", return_value=mock_data):
with self.assertRaises(ValidationError) as cm: with self.assertRaises(ValidationError) as cm:
field.clean("dummy") field.clean("dummy")
# Check for the translated error message # Check for the translated error message
expected_message = str(_('Filetype not supported.')) expected_message = str(_("Filetype not supported."))
self.assertIn(expected_message, str(cm.exception)) self.assertIn(expected_message, str(cm.exception))
def test_restricted_file_field_size_exceeds_limit(self): def test_restricted_file_field_size_exceeds_limit(self):
@ -150,12 +157,14 @@ class UtilsTestCase(TestCase):
mock_data.file._size = 2 # 2 bytes, exceeds limit mock_data.file._size = 2 # 2 bytes, exceeds limit
# Mock the super().clean() to return our mock data # Mock the super().clean() to return our mock data
with patch.object(models.FileField, 'clean', return_value=mock_data): with patch.object(models.FileField, "clean", return_value=mock_data):
with self.assertRaises(ValidationError) as cm: with self.assertRaises(ValidationError) as cm:
field.clean("dummy") field.clean("dummy")
# Check for the translated error message # Check for the translated error message
expected_message = str(_('Please keep filesize under {}. Current filesize: {}').format(1, 2)) expected_message = str(
_("Please keep filesize under {}. Current filesize: {}").format(1, 2)
)
self.assertIn(expected_message, str(cm.exception)) self.assertIn(expected_message, str(cm.exception))
def test_mondays_until_nth(self): def test_mondays_until_nth(self):

@ -1,40 +1,48 @@
import logging import logging
from django.contrib import admin, messages
from django.utils.safestring import mark_safe
from django import forms
from django.forms import Textarea, ClearableFileInput
from django.http import HttpResponse, HttpResponseRedirect
from django.db.models import TextField, Q
from django.urls import path, reverse
from functools import update_wrapper from functools import update_wrapper
from django.utils.translation import gettext_lazy as _
from django.shortcuts import render
from django.conf import settings
from contrib.admin import CommonAdminInlineMixin, CommonAdminMixin from contrib.admin import CommonAdminInlineMixin
from utils import get_member, RestrictedFileField from contrib.admin import CommonAdminMixin
from django import forms
from rules.contrib.admin import ObjectPermissionsModelAdmin from django.conf import settings
from django.contrib import admin
from django.contrib import messages
from django.db.models import TextField
from django.forms import ClearableFileInput
from django.forms import Textarea
from django.http import HttpResponseRedirect
from django.shortcuts import render
from django.urls import path
from django.urls import reverse
from django.utils.safestring import mark_safe
from django.utils.translation import gettext_lazy as _
from members.pdf import render_tex_with_attachments from members.pdf import render_tex_with_attachments
from utils import get_member
from .models import Ledger, Statement, Receipt, Transaction, Bill, StatementSubmitted, StatementConfirmed,\ from .models import Bill
StatementUnSubmitted, BillOnStatementProxy from .models import BillOnStatementProxy
from .models import Ledger
from .models import Statement
from .models import StatementConfirmed
from .models import StatementSubmitted
from .models import StatementUnSubmitted
from .models import Transaction
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
@admin.register(Ledger) @admin.register(Ledger)
class LedgerAdmin(admin.ModelAdmin): class LedgerAdmin(admin.ModelAdmin):
search_fields = ('name', ) search_fields = ("name",)
class BillOnStatementInlineForm(forms.ModelForm): class BillOnStatementInlineForm(forms.ModelForm):
class Meta: class Meta:
model = BillOnStatementProxy model = BillOnStatementProxy
fields = ['short_description', 'explanation', 'amount', 'paid_by', 'proof'] fields = ["short_description", "explanation", "amount", "paid_by", "proof"]
widgets = { widgets = {
'proof': ClearableFileInput(attrs={'accept': 'application/pdf,image/jpeg,image/png'}), "proof": ClearableFileInput(attrs={"accept": "application/pdf,image/jpeg,image/png"}),
'explanation': Textarea(attrs={'rows': 1, 'cols': 40}) "explanation": Textarea(attrs={"rows": 1, "cols": 40}),
} }
@ -51,24 +59,38 @@ def decorate_statement_view(model, perm=None):
try: try:
statement = model.objects.get(pk=object_id) statement = model.objects.get(pk=object_id)
except model.DoesNotExist: except model.DoesNotExist:
messages.error(request, _('Statement not found.')) messages.error(request, _("Statement not found."))
return HttpResponseRedirect(reverse('admin:%s_%s_changelist' % (self.opts.app_label, self.opts.model_name))) return HttpResponseRedirect(
permitted = self.has_change_permission(request, statement) if not perm else request.user.has_perm(perm) reverse(
"admin:{}_{}_changelist".format(self.opts.app_label, self.opts.model_name)
)
)
permitted = (
self.has_change_permission(request, statement)
if not perm
else request.user.has_perm(perm)
)
if not permitted: if not permitted:
messages.error(request, _('Insufficient permissions.')) messages.error(request, _("Insufficient permissions."))
return HttpResponseRedirect(reverse('admin:%s_%s_changelist' % (self.opts.app_label, self.opts.model_name))) return HttpResponseRedirect(
reverse(
"admin:{}_{}_changelist".format(self.opts.app_label, self.opts.model_name)
)
)
return fun(self, request, statement) return fun(self, request, statement)
return aux return aux
return decorator return decorator
@admin.register(Statement) @admin.register(Statement)
class StatementAdmin(CommonAdminMixin, admin.ModelAdmin): class StatementAdmin(CommonAdminMixin, admin.ModelAdmin):
fields = ['short_description', 'explanation', 'excursion', 'status'] fields = ["short_description", "explanation", "excursion", "status"]
list_display = ['__str__', 'total_pretty', 'created_by', 'submitted_date', 'status_badge'] list_display = ["__str__", "total_pretty", "created_by", "submitted_date", "status_badge"]
list_filter = ['status'] list_filter = ["status"]
search_fields = ('excursion__name', 'short_description') search_fields = ("excursion__name", "short_description")
ordering = ['-submitted_date'] ordering = ["-submitted_date"]
inlines = [BillOnStatementInline] inlines = [BillOnStatementInline]
list_per_page = 25 list_per_page = 25
@ -87,7 +109,7 @@ class StatementAdmin(CommonAdminMixin, admin.ModelAdmin):
return super().has_delete_permission(request, obj) return super().has_delete_permission(request, obj)
def save_model(self, request, obj, form, change): def save_model(self, request, obj, form, change):
if not change and hasattr(request.user, 'member'): if not change and hasattr(request.user, "member"):
obj.created_by = request.user.member obj.created_by = request.user.member
super().save_model(request, obj, form, change) super().save_model(request, obj, form, change)
@ -95,14 +117,14 @@ class StatementAdmin(CommonAdminMixin, admin.ModelAdmin):
if obj is not None and obj.excursion: if obj is not None and obj.excursion:
# if the object exists and an excursion is set, show the excursion (read only) # if the object exists and an excursion is set, show the excursion (read only)
# instead of the short description # instead of the short description
return ['excursion', 'explanation', 'status'] return ["excursion", "explanation", "status"]
else: else:
# if the object is newly created or no excursion is set, require # if the object is newly created or no excursion is set, require
# a short description # a short description
return ['short_description', 'explanation', 'status'] return ["short_description", "explanation", "status"]
def get_readonly_fields(self, request, obj=None): def get_readonly_fields(self, request, obj=None):
readonly_fields = ['status', 'excursion'] readonly_fields = ["status", "excursion"]
if obj is not None and obj.submitted: if obj is not None and obj.submitted:
return readonly_fields + self.fields return readonly_fields + self.fields
else: else:
@ -128,208 +150,338 @@ class StatementAdmin(CommonAdminMixin, admin.ModelAdmin):
path( path(
"<path:object_id>/submit/", "<path:object_id>/submit/",
wrap(self.submit_view), wrap(self.submit_view),
name="%s_%s_submit" % (self.opts.app_label, self.opts.model_name), name="{}_{}_submit".format(self.opts.app_label, self.opts.model_name),
), ),
path( path(
"<path:object_id>/overview/", "<path:object_id>/overview/",
wrap(self.overview_view), wrap(self.overview_view),
name="%s_%s_overview" % (self.opts.app_label, self.opts.model_name), name="{}_{}_overview".format(self.opts.app_label, self.opts.model_name),
), ),
path( path(
"<path:object_id>/reduce_transactions/", "<path:object_id>/reduce_transactions/",
wrap(self.reduce_transactions_view), wrap(self.reduce_transactions_view),
name="%s_%s_reduce_transactions" % (self.opts.app_label, self.opts.model_name), name="{}_{}_reduce_transactions".format(self.opts.app_label, self.opts.model_name),
), ),
path( path(
"<path:object_id>/unconfirm/", "<path:object_id>/unconfirm/",
wrap(self.unconfirm_view), wrap(self.unconfirm_view),
name="%s_%s_unconfirm" % (self.opts.app_label, self.opts.model_name), name="{}_{}_unconfirm".format(self.opts.app_label, self.opts.model_name),
), ),
path( path(
"<path:object_id>/summary/", "<path:object_id>/summary/",
wrap(self.statement_summary_view), wrap(self.statement_summary_view),
name="%s_%s_summary" % (self.opts.app_label, self.opts.model_name), name="{}_{}_summary".format(self.opts.app_label, self.opts.model_name),
), ),
] ]
return custom_urls + urls return custom_urls + urls
@decorate_statement_view(StatementUnSubmitted) @decorate_statement_view(StatementUnSubmitted)
def submit_view(self, request, statement): def submit_view(self, request, statement):
if statement.submitted: # pragma: no cover if statement.submitted: # pragma: no cover
logger.error(f"submit_view reached with submitted statement {statement}. This should not happen.") logger.error(
messages.error(request, f"submit_view reached with submitted statement {statement}. This should not happen."
_("%(name)s is already submitted.") % {'name': str(statement)}) )
return HttpResponseRedirect(reverse('admin:%s_%s_changelist' % (self.opts.app_label, self.opts.model_name))) messages.error(request, _("%(name)s is already submitted.") % {"name": str(statement)})
return HttpResponseRedirect(
reverse("admin:{}_{}_changelist".format(self.opts.app_label, self.opts.model_name))
)
if "apply" in request.POST: if "apply" in request.POST:
statement.submit(get_member(request)) statement.submit(get_member(request))
messages.success(request, messages.success(
_("Successfully submited %(name)s. The finance department will notify the requestors as soon as possible.") % {'name': str(statement)}) request,
return HttpResponseRedirect(reverse('admin:%s_%s_changelist' % (self.opts.app_label, self.opts.model_name))) _(
"Successfully submited %(name)s. The finance department will notify the requestors as soon as possible."
)
% {"name": str(statement)},
)
return HttpResponseRedirect(
reverse("admin:{}_{}_changelist".format(self.opts.app_label, self.opts.model_name))
)
if statement.excursion: if statement.excursion:
memberlist = statement.excursion memberlist = statement.excursion
context = dict(self.admin_site.each_context(request), context = dict(
title=_('Finance overview'), self.admin_site.each_context(request),
opts=self.opts, title=_("Finance overview"),
memberlist=memberlist, opts=self.opts,
object=memberlist, memberlist=memberlist,
ljp_contributions=memberlist.payable_ljp_contributions, object=memberlist,
total_relative_costs=memberlist.total_relative_costs, ljp_contributions=memberlist.payable_ljp_contributions,
**memberlist.statement.template_context()) total_relative_costs=memberlist.total_relative_costs,
return render(request, 'admin/freizeit_finance_overview.html', context=context) **memberlist.statement.template_context(),
)
return render(request, "admin/freizeit_finance_overview.html", context=context)
else: else:
context = dict(self.admin_site.each_context(request), context = dict(
title=_('Submit statement'), self.admin_site.each_context(request),
title=_("Submit statement"),
opts=self.opts, opts=self.opts,
statement=statement) statement=statement,
return render(request, 'admin/submit_statement.html', context=context) )
return render(request, "admin/submit_statement.html", context=context)
@decorate_statement_view(StatementSubmitted) @decorate_statement_view(StatementSubmitted)
def overview_view(self, request, statement): def overview_view(self, request, statement):
if not statement.submitted: # pragma: no cover if not statement.submitted: # pragma: no cover
logger.error(f"overview_view reached with unsubmitted statement {statement}. This should not happen.") logger.error(
messages.error(request, f"overview_view reached with unsubmitted statement {statement}. This should not happen."
_("%(name)s is not yet submitted.") % {'name': str(statement)}) )
return HttpResponseRedirect(reverse('admin:%s_%s_change' % (self.opts.app_label, self.opts.model_name), args=(statement.pk,))) messages.error(request, _("%(name)s is not yet submitted.") % {"name": str(statement)})
if "transaction_execution_confirm" in request.POST or "transaction_execution_confirm_and_send" in request.POST: return HttpResponseRedirect(
reverse(
"admin:{}_{}_change".format(self.opts.app_label, self.opts.model_name),
args=(statement.pk,),
)
)
if (
"transaction_execution_confirm" in request.POST
or "transaction_execution_confirm_and_send" in request.POST
):
res = statement.confirm(confirmer=get_member(request)) res = statement.confirm(confirmer=get_member(request))
if not res: # pragma: no cover if not res: # pragma: no cover
# this should NOT happen! # this should NOT happen!
logger.error(f"Error occured while confirming {statement}, this should not be possible.") logger.error(
messages.error(request, f"Error occured while confirming {statement}, this should not be possible."
_("An error occured while trying to confirm %(name)s. Please try again.") % {'name': str(statement)}) )
return HttpResponseRedirect(reverse('admin:%s_%s_overview' % (self.opts.app_label, self.opts.model_name))) messages.error(
request,
_("An error occured while trying to confirm %(name)s. Please try again.")
% {"name": str(statement)},
)
return HttpResponseRedirect(
reverse(
"admin:{}_{}_overview".format(self.opts.app_label, self.opts.model_name)
)
)
if "transaction_execution_confirm_and_send" in request.POST: if "transaction_execution_confirm_and_send" in request.POST:
statement.send_summary(cc=[request.user.member.email] if hasattr(request.user, 'member') else []) statement.send_summary(
cc=[request.user.member.email] if hasattr(request.user, "member") else []
)
messages.success(request, _("Successfully sent receipt to the office.")) messages.success(request, _("Successfully sent receipt to the office."))
messages.success(request, messages.success(
_("Successfully confirmed %(name)s. I hope you executed the associated transactions, I wont remind you again.") request,
% {'name': str(statement)}) _(
download_link = reverse('admin:%s_%s_summary' % (self.opts.app_label, self.opts.model_name), "Successfully confirmed %(name)s. I hope you executed the associated transactions, I wont remind you again."
args=(statement.pk,)) )
messages.success(request, % {"name": str(statement)},
mark_safe(_("You can download a <a href='%(link)s', target='_blank'>receipt</a>.") % {'link': download_link})) )
return HttpResponseRedirect(reverse('admin:%s_%s_changelist' % (self.opts.app_label, self.opts.model_name))) download_link = reverse(
"admin:{}_{}_summary".format(self.opts.app_label, self.opts.model_name),
args=(statement.pk,),
)
messages.success(
request,
mark_safe(
_("You can download a <a href='%(link)s', target='_blank'>receipt</a>.")
% {"link": download_link}
),
)
return HttpResponseRedirect(
reverse("admin:{}_{}_changelist".format(self.opts.app_label, self.opts.model_name))
)
if "confirm" in request.POST: if "confirm" in request.POST:
res = statement.validity res = statement.validity
if res == Statement.VALID: if res == Statement.VALID:
context = dict(self.admin_site.each_context(request), context = dict(
title=_('Statement confirmed'), self.admin_site.each_context(request),
opts=self.opts, title=_("Statement confirmed"),
statement=statement) opts=self.opts,
return render(request, 'admin/confirmed_statement.html', context=context) statement=statement,
)
return render(request, "admin/confirmed_statement.html", context=context)
elif res == Statement.NON_MATCHING_TRANSACTIONS: elif res == Statement.NON_MATCHING_TRANSACTIONS:
messages.error(request, messages.error(
_("Transactions do not match the covered expenses. Please correct the mistakes listed below.") request,
% {'name': str(statement)}) _(
return HttpResponseRedirect(reverse('admin:%s_%s_overview' % (self.opts.app_label, self.opts.model_name), args=(statement.pk,))) "Transactions do not match the covered expenses. Please correct the mistakes listed below."
)
% {"name": str(statement)},
)
return HttpResponseRedirect(
reverse(
"admin:{}_{}_overview".format(self.opts.app_label, self.opts.model_name),
args=(statement.pk,),
)
)
elif res == Statement.MISSING_LEDGER: elif res == Statement.MISSING_LEDGER:
messages.error(request, messages.error(
_("Some transactions have no ledger configured. Please fill in the gaps.") request,
% {'name': str(statement)}) _("Some transactions have no ledger configured. Please fill in the gaps.")
return HttpResponseRedirect(reverse('admin:%s_%s_overview' % (self.opts.app_label, self.opts.model_name), args=(statement.pk,))) % {"name": str(statement)},
)
return HttpResponseRedirect(
reverse(
"admin:{}_{}_overview".format(self.opts.app_label, self.opts.model_name),
args=(statement.pk,),
)
)
elif res == Statement.INVALID_ALLOWANCE_TO: elif res == Statement.INVALID_ALLOWANCE_TO:
messages.error(request, messages.error(
_("The configured recipients for the allowance don't match the regulations. Please correct this on the excursion.")) request,
return HttpResponseRedirect(reverse('admin:%s_%s_overview' % (self.opts.app_label, self.opts.model_name), args=(statement.pk,))) _(
elif res == Statement.INVALID_TOTAL: # pragma: no cover "The configured recipients for the allowance don't match the regulations. Please correct this on the excursion."
),
)
return HttpResponseRedirect(
reverse(
"admin:{}_{}_overview".format(self.opts.app_label, self.opts.model_name),
args=(statement.pk,),
)
)
elif res == Statement.INVALID_TOTAL: # pragma: no cover
logger.error(f"INVALID_TOTAL reached on {statement}.") logger.error(f"INVALID_TOTAL reached on {statement}.")
messages.error(request, messages.error(
_("The calculated total amount does not match the sum of all transactions. This is most likely a bug.")) request,
return HttpResponseRedirect(reverse('admin:%s_%s_overview' % (self.opts.app_label, self.opts.model_name), args=(statement.pk,))) _(
else: # pragma: no cover "The calculated total amount does not match the sum of all transactions. This is most likely a bug."
),
)
return HttpResponseRedirect(
reverse(
"admin:{}_{}_overview".format(self.opts.app_label, self.opts.model_name),
args=(statement.pk,),
)
)
else: # pragma: no cover
logger.error(f"Statement.validity returned invalid value for {statement}.") logger.error(f"Statement.validity returned invalid value for {statement}.")
return HttpResponseRedirect(reverse('admin:%s_%s_changelist' % (self.opts.app_label, self.opts.model_name))) return HttpResponseRedirect(
reverse(
"admin:{}_{}_changelist".format(self.opts.app_label, self.opts.model_name)
)
)
if "reject" in request.POST: if "reject" in request.POST:
statement.status = Statement.UNSUBMITTED statement.status = Statement.UNSUBMITTED
statement.save() statement.save()
messages.success(request, messages.success(
_("Successfully rejected %(name)s. The requestor can reapply, when needed.") request,
% {'name': str(statement)}) _("Successfully rejected %(name)s. The requestor can reapply, when needed.")
return HttpResponseRedirect(reverse('admin:%s_%s_changelist' % (self.opts.app_label, self.opts.model_name))) % {"name": str(statement)},
)
return HttpResponseRedirect(
reverse("admin:{}_{}_changelist".format(self.opts.app_label, self.opts.model_name))
)
if "generate_transactions" in request.POST: if "generate_transactions" in request.POST:
if statement.transaction_set.count() > 0: if statement.transaction_set.count() > 0:
messages.error(request, messages.error(
_("%(name)s already has transactions. Please delete them first, if you want to generate new ones") % {'name': str(statement)}) request,
_(
"%(name)s already has transactions. Please delete them first, if you want to generate new ones"
)
% {"name": str(statement)},
)
else: else:
success = statement.generate_transactions() success = statement.generate_transactions()
if success: if success:
messages.success(request, messages.success(
_("Successfully generated transactions for %(name)s") % {'name': str(statement)}) request,
_("Successfully generated transactions for %(name)s")
% {"name": str(statement)},
)
else: else:
messages.error(request, messages.error(
_("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)}) request,
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), "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?"
title=_('View submitted statement'), )
opts=self.opts, % {"name": str(statement)},
statement=statement, )
settings=settings, return HttpResponseRedirect(
transaction_issues=statement.transaction_issues, reverse(
**statement.template_context()) "admin:{}_{}_change".format(self.opts.app_label, self.opts.model_name),
args=(statement.pk,),
return render(request, 'admin/overview_submitted_statement.html', context=context) )
)
context = dict(
self.admin_site.each_context(request),
title=_("View submitted statement"),
opts=self.opts,
statement=statement,
settings=settings,
transaction_issues=statement.transaction_issues,
**statement.template_context(),
)
return render(request, "admin/overview_submitted_statement.html", context=context)
@decorate_statement_view(StatementSubmitted) @decorate_statement_view(StatementSubmitted)
def reduce_transactions_view(self, request, statement): def reduce_transactions_view(self, request, statement):
statement.reduce_transactions() statement.reduce_transactions()
messages.success(request, messages.success(
_("Successfully reduced transactions for %(name)s.") % {'name': str(statement)}) request, _("Successfully reduced transactions for %(name)s.") % {"name": str(statement)}
return HttpResponseRedirect(request.GET['redirectTo']) )
return HttpResponseRedirect(request.GET["redirectTo"])
@decorate_statement_view(StatementConfirmed, perm='finance.may_manage_confirmed_statements') @decorate_statement_view(StatementConfirmed, perm="finance.may_manage_confirmed_statements")
def unconfirm_view(self, request, statement): def unconfirm_view(self, request, statement):
if not statement.confirmed: # pragma: no cover if not statement.confirmed: # pragma: no cover
logger.error(f"unconfirm_view reached with unconfirmed statement {statement}.") logger.error(f"unconfirm_view reached with unconfirmed statement {statement}.")
messages.error(request, messages.error(request, _("%(name)s is not yet confirmed.") % {"name": str(statement)})
_("%(name)s is not yet confirmed.") % {'name': str(statement)}) return HttpResponseRedirect(
return HttpResponseRedirect(reverse('admin:%s_%s_change' % (self.opts.app_label, self.opts.model_name), args=(statement.pk,))) reverse(
"admin:{}_{}_change".format(self.opts.app_label, self.opts.model_name),
args=(statement.pk,),
)
)
if "unconfirm" in request.POST: if "unconfirm" in request.POST:
statement.status = Statement.SUBMITTED statement.status = Statement.SUBMITTED
statement.confirmed_date = None statement.confirmed_date = None
statement.confired_by = None statement.confired_by = None
statement.save() statement.save()
messages.success(request, messages.success(
_("Successfully unconfirmed %(name)s. I hope you know what you are doing.") request,
% {'name': str(statement)}) _("Successfully unconfirmed %(name)s. I hope you know what you are doing.")
return HttpResponseRedirect(reverse('admin:%s_%s_changelist' % (self.opts.app_label, self.opts.model_name))) % {"name": str(statement)},
)
context = dict(self.admin_site.each_context(request), return HttpResponseRedirect(
title=_('Unconfirm statement'), reverse("admin:{}_{}_changelist".format(self.opts.app_label, self.opts.model_name))
opts=self.opts, )
statement=statement)
context = dict(
return render(request, 'admin/unconfirm_statement.html', context=context) self.admin_site.each_context(request),
title=_("Unconfirm statement"),
@decorate_statement_view(StatementConfirmed, perm='finance.may_manage_confirmed_statements') opts=self.opts,
statement=statement,
)
return render(request, "admin/unconfirm_statement.html", context=context)
@decorate_statement_view(StatementConfirmed, perm="finance.may_manage_confirmed_statements")
def statement_summary_view(self, request, statement): def statement_summary_view(self, request, statement):
if not statement.confirmed: # pragma: no cover if not statement.confirmed: # pragma: no cover
logger.error(f"statement_summary_view reached with unconfirmed statement {statement}.") logger.error(f"statement_summary_view reached with unconfirmed statement {statement}.")
messages.error(request, messages.error(request, _("%(name)s is not yet confirmed.") % {"name": str(statement)})
_("%(name)s is not yet confirmed.") % {'name': str(statement)}) return HttpResponseRedirect(
return HttpResponseRedirect(reverse('admin:%s_%s_change' % (self.opts.app_label, self.opts.model_name), args=(statement.pk,))) reverse(
"admin:{}_{}_change".format(self.opts.app_label, self.opts.model_name),
args=(statement.pk,),
)
)
excursion = statement.excursion excursion = statement.excursion
context = dict(statement=statement.template_context(), excursion=excursion, settings=settings) context = dict(
statement=statement.template_context(), excursion=excursion, settings=settings
)
pdf_filename = f"{excursion.code}_{excursion.name}_Zuschussbeleg" if excursion else f"Abrechnungsbeleg" pdf_filename = (
f"{excursion.code}_{excursion.name}_Zuschussbeleg" if excursion else "Abrechnungsbeleg"
)
attachments = [bill.proof.path for bill in statement.bills_covered if bill.proof] attachments = [bill.proof.path for bill in statement.bills_covered if bill.proof]
return render_tex_with_attachments(pdf_filename, 'finance/statement_summary.tex', context, attachments) return render_tex_with_attachments(
pdf_filename, "finance/statement_summary.tex", context, attachments
)
statement_summary_view.short_description = _('Download summary') statement_summary_view.short_description = _("Download summary")
class TransactionOnSubmittedStatementInline(admin.TabularInline): class TransactionOnSubmittedStatementInline(admin.TabularInline):
model = Transaction model = Transaction
fields = ['amount', 'member', 'reference', 'text_length_warning', 'ledger'] fields = ["amount", "member", "reference", "text_length_warning", "ledger"]
formfield_overrides = { formfield_overrides = {TextField: {"widget": Textarea(attrs={"rows": 1, "cols": 40})}}
TextField: {'widget': Textarea(attrs={'rows': 1, 'cols': 40})} readonly_fields = ["text_length_warning"]
}
readonly_fields = ['text_length_warning']
extra = 0 extra = 0
def text_length_warning(self, obj): def text_length_warning(self, obj):
@ -340,6 +492,7 @@ class TransactionOnSubmittedStatementInline(admin.TabularInline):
return mark_safe(f'<span style="color: red;">{len_string}</span>') return mark_safe(f'<span style="color: red;">{len_string}</span>')
return len_string return len_string
text_length_warning.short_description = _("Length") text_length_warning.short_description = _("Length")
@ -347,13 +500,11 @@ class BillOnSubmittedStatementInline(BillOnStatementInline):
model = BillOnStatementProxy model = BillOnStatementProxy
extra = 0 extra = 0
sortable_options = [] sortable_options = []
fields = ['short_description', 'explanation', 'amount', 'paid_by', 'proof', 'costs_covered'] fields = ["short_description", "explanation", "amount", "paid_by", "proof", "costs_covered"]
formfield_overrides = { formfield_overrides = {TextField: {"widget": Textarea(attrs={"rows": 1, "cols": 40})}}
TextField: {'widget': Textarea(attrs={'rows': 1, 'cols': 40})}
}
def get_readonly_fields(self, request, obj=None): def get_readonly_fields(self, request, obj=None):
return ['short_description', 'explanation', 'amount', 'paid_by', 'proof'] return ["short_description", "explanation", "amount", "paid_by", "proof"]
@admin.register(Transaction) @admin.register(Transaction)
@ -361,16 +512,25 @@ class TransactionAdmin(admin.ModelAdmin):
"""The transaction admin site. This is only used to display transactions. All editing """The transaction admin site. This is only used to display transactions. All editing
is disabled on this site. All transactions should be changed on the respective statement is disabled on this site. All transactions should be changed on the respective statement
at the correct stage of the approval chain.""" at the correct stage of the approval chain."""
list_display = ['member', 'ledger', 'amount', 'reference', 'statement', 'confirmed',
'confirmed_date', 'confirmed_by'] list_display = [
list_filter = ('ledger', 'member', 'statement', 'confirmed') "member",
search_fields = ('reference', ) "ledger",
fields = ['reference', 'amount', 'member', 'ledger', 'statement'] "amount",
"reference",
"statement",
"confirmed",
"confirmed_date",
"confirmed_by",
]
list_filter = ("ledger", "member", "statement", "confirmed")
search_fields = ("reference",)
fields = ["reference", "amount", "member", "ledger", "statement"]
def get_readonly_fields(self, request, obj=None): def get_readonly_fields(self, request, obj=None):
if obj is not None and obj.confirmed: if obj is not None and obj.confirmed:
return self.fields return self.fields
return super(TransactionAdmin, self).get_readonly_fields(request, obj) return super().get_readonly_fields(request, obj)
def has_add_permission(self, request, obj=None): def has_add_permission(self, request, obj=None):
# To preserve integrity, no one is allowed to add transactions # To preserve integrity, no one is allowed to add transactions
@ -387,6 +547,6 @@ class TransactionAdmin(admin.ModelAdmin):
@admin.register(Bill) @admin.register(Bill)
class BillAdmin(admin.ModelAdmin): class BillAdmin(admin.ModelAdmin):
list_display = ['__str__', 'statement', 'explanation', 'pretty_amount', 'paid_by', 'refunded'] list_display = ["__str__", "statement", "explanation", "pretty_amount", "paid_by", "refunded"]
list_filter = ('statement', 'paid_by', 'refunded') list_filter = ("statement", "paid_by", "refunded")
search_fields = ('reference', 'statement') search_fields = ("reference", "statement")

@ -3,6 +3,6 @@ from django.utils.translation import gettext_lazy as _
class FinanceConfig(AppConfig): class FinanceConfig(AppConfig):
default_auto_field = 'django.db.models.BigAutoField' default_auto_field = "django.db.models.BigAutoField"
name = 'finance' name = "finance"
verbose_name = _('Finance') verbose_name = _("Finance")

@ -1,136 +1,293 @@
# Generated by Django 4.0.1 on 2023-03-29 22:16 # Generated by Django 4.0.1 on 2023-03-29 22:16
from django.db import migrations, models
import django.db.models.deletion import django.db.models.deletion
from django.db import migrations
from django.db import models
class Migration(migrations.Migration): class Migration(migrations.Migration):
initial = True initial = True
dependencies = [ dependencies = [
('members', '0002_remove_member_not_waiting_and_more'), ("members", "0002_remove_member_not_waiting_and_more"),
] ]
operations = [ operations = [
migrations.CreateModel( migrations.CreateModel(
name='Ledger', name="Ledger",
fields=[ fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), (
('name', models.CharField(max_length=30, verbose_name='Name')), "id",
models.BigAutoField(
auto_created=True, primary_key=True, serialize=False, verbose_name="ID"
),
),
("name", models.CharField(max_length=30, verbose_name="Name")),
], ],
options={ options={
'verbose_name': 'Ledger', "verbose_name": "Ledger",
'verbose_name_plural': 'Ledgers', "verbose_name_plural": "Ledgers",
}, },
), ),
migrations.CreateModel( migrations.CreateModel(
name='Statement', name="Statement",
fields=[ fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), (
('short_description', models.CharField(blank=True, max_length=30, verbose_name='Short description')), "id",
('explanation', models.TextField(blank=True, verbose_name='Explanation')), models.BigAutoField(
('night_cost', models.DecimalField(decimal_places=2, default=0, max_digits=5, verbose_name='Price per night')), auto_created=True, primary_key=True, serialize=False, verbose_name="ID"
('submitted', models.BooleanField(default=False, verbose_name='Submitted')), ),
('submitted_date', models.DateTimeField(default=None, null=True, verbose_name='Submitted on')), ),
('confirmed', models.BooleanField(default=False, verbose_name='Confirmed')), (
('confirmed_date', models.DateTimeField(default=None, null=True, verbose_name='Paid on')), "short_description",
('confirmed_by', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='confirmed_statements', to='members.member', verbose_name='Authorized by')), models.CharField(blank=True, max_length=30, verbose_name="Short description"),
('created_by', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='created_statements', to='members.member', verbose_name='Created by')), ),
('excursion', models.OneToOneField(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, to='members.freizeit', verbose_name='Associated excursion')), ("explanation", models.TextField(blank=True, verbose_name="Explanation")),
('submitted_by', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='submitted_statements', to='members.member', verbose_name='Submitted by')), (
"night_cost",
models.DecimalField(
decimal_places=2, default=0, max_digits=5, verbose_name="Price per night"
),
),
("submitted", models.BooleanField(default=False, verbose_name="Submitted")),
(
"submitted_date",
models.DateTimeField(default=None, null=True, verbose_name="Submitted on"),
),
("confirmed", models.BooleanField(default=False, verbose_name="Confirmed")),
(
"confirmed_date",
models.DateTimeField(default=None, null=True, verbose_name="Paid on"),
),
(
"confirmed_by",
models.ForeignKey(
blank=True,
null=True,
on_delete=django.db.models.deletion.SET_NULL,
related_name="confirmed_statements",
to="members.member",
verbose_name="Authorized by",
),
),
(
"created_by",
models.ForeignKey(
blank=True,
null=True,
on_delete=django.db.models.deletion.SET_NULL,
related_name="created_statements",
to="members.member",
verbose_name="Created by",
),
),
(
"excursion",
models.OneToOneField(
blank=True,
null=True,
on_delete=django.db.models.deletion.SET_NULL,
to="members.freizeit",
verbose_name="Associated excursion",
),
),
(
"submitted_by",
models.ForeignKey(
blank=True,
null=True,
on_delete=django.db.models.deletion.SET_NULL,
related_name="submitted_statements",
to="members.member",
verbose_name="Submitted by",
),
),
], ],
options={ options={
'verbose_name': 'Statement', "verbose_name": "Statement",
'verbose_name_plural': 'Statements', "verbose_name_plural": "Statements",
'permissions': [('may_edit_submitted_statements', 'Is allowed to edit submitted statements')], "permissions": [
("may_edit_submitted_statements", "Is allowed to edit submitted statements")
],
}, },
), ),
migrations.CreateModel( migrations.CreateModel(
name='Transaction', name="Transaction",
fields=[ fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), (
('reference', models.TextField(verbose_name='Reference')), "id",
('amount', models.DecimalField(decimal_places=2, max_digits=6, verbose_name='Amount')), models.BigAutoField(
('confirmed', models.BooleanField(default=False, verbose_name='Paid')), auto_created=True, primary_key=True, serialize=False, verbose_name="ID"
('confirmed_date', models.DateTimeField(default=None, null=True, verbose_name='Paid on')), ),
('confirmed_by', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='confirmed_transactions', to='members.member', verbose_name='Authorized by')), ),
('ledger', models.ForeignKey(default=None, null=True, on_delete=django.db.models.deletion.SET_NULL, to='finance.ledger', verbose_name='Ledger')), ("reference", models.TextField(verbose_name="Reference")),
('member', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='members.member', verbose_name='Recipient')), (
('statement', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='finance.statement', verbose_name='Statement')), "amount",
models.DecimalField(decimal_places=2, max_digits=6, verbose_name="Amount"),
),
("confirmed", models.BooleanField(default=False, verbose_name="Paid")),
(
"confirmed_date",
models.DateTimeField(default=None, null=True, verbose_name="Paid on"),
),
(
"confirmed_by",
models.ForeignKey(
blank=True,
null=True,
on_delete=django.db.models.deletion.SET_NULL,
related_name="confirmed_transactions",
to="members.member",
verbose_name="Authorized by",
),
),
(
"ledger",
models.ForeignKey(
default=None,
null=True,
on_delete=django.db.models.deletion.SET_NULL,
to="finance.ledger",
verbose_name="Ledger",
),
),
(
"member",
models.ForeignKey(
on_delete=django.db.models.deletion.CASCADE,
to="members.member",
verbose_name="Recipient",
),
),
(
"statement",
models.ForeignKey(
on_delete=django.db.models.deletion.CASCADE,
to="finance.statement",
verbose_name="Statement",
),
),
], ],
options={ options={
'verbose_name': 'Transaction', "verbose_name": "Transaction",
'verbose_name_plural': 'Transactions', "verbose_name_plural": "Transactions",
}, },
), ),
migrations.CreateModel( migrations.CreateModel(
name='Receipt', name="Receipt",
fields=[ fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), (
('short_description', models.CharField(max_length=30, verbose_name='Short description')), "id",
('amount', models.DecimalField(decimal_places=2, max_digits=6)), models.BigAutoField(
('comments', models.TextField()), auto_created=True, primary_key=True, serialize=False, verbose_name="ID"
('ledger', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='finance.ledger', verbose_name='Ledger')), ),
),
(
"short_description",
models.CharField(max_length=30, verbose_name="Short description"),
),
("amount", models.DecimalField(decimal_places=2, max_digits=6)),
("comments", models.TextField()),
(
"ledger",
models.ForeignKey(
on_delete=django.db.models.deletion.CASCADE,
to="finance.ledger",
verbose_name="Ledger",
),
),
], ],
), ),
migrations.CreateModel( migrations.CreateModel(
name='Bill', name="Bill",
fields=[ fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), (
('short_description', models.CharField(max_length=30, verbose_name='Short description')), "id",
('explanation', models.TextField(blank=True, verbose_name='Explanation')), models.BigAutoField(
('amount', models.DecimalField(decimal_places=2, default=0, max_digits=6)), auto_created=True, primary_key=True, serialize=False, verbose_name="ID"
('costs_covered', models.BooleanField(default=False, verbose_name='Covered')), ),
('refunded', models.BooleanField(default=False, verbose_name='Refunded')), ),
('proof', models.ImageField(blank=True, upload_to='bill_images', verbose_name='Proof')), (
('paid_by', models.ForeignKey(null=True, on_delete=django.db.models.deletion.SET_NULL, to='members.member', verbose_name='Paid by')), "short_description",
('statement', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='finance.statement', verbose_name='Statement')), models.CharField(max_length=30, verbose_name="Short description"),
),
("explanation", models.TextField(blank=True, verbose_name="Explanation")),
("amount", models.DecimalField(decimal_places=2, default=0, max_digits=6)),
("costs_covered", models.BooleanField(default=False, verbose_name="Covered")),
("refunded", models.BooleanField(default=False, verbose_name="Refunded")),
(
"proof",
models.ImageField(blank=True, upload_to="bill_images", verbose_name="Proof"),
),
(
"paid_by",
models.ForeignKey(
null=True,
on_delete=django.db.models.deletion.SET_NULL,
to="members.member",
verbose_name="Paid by",
),
),
(
"statement",
models.ForeignKey(
on_delete=django.db.models.deletion.CASCADE,
to="finance.statement",
verbose_name="Statement",
),
),
], ],
options={ options={
'verbose_name': 'Bill', "verbose_name": "Bill",
'verbose_name_plural': 'Bills', "verbose_name_plural": "Bills",
}, },
), ),
migrations.CreateModel( migrations.CreateModel(
name='StatementConfirmed', name="StatementConfirmed",
fields=[ fields=[],
],
options={ options={
'verbose_name': 'Paid statement', "verbose_name": "Paid statement",
'verbose_name_plural': 'Paid statements', "verbose_name_plural": "Paid statements",
'permissions': (('may_manage_confirmed_statements', 'Can view and manage confirmed statements.'),), "permissions": (
'proxy': True, (
'indexes': [], "may_manage_confirmed_statements",
'constraints': [], "Can view and manage confirmed statements.",
),
),
"proxy": True,
"indexes": [],
"constraints": [],
}, },
bases=('finance.statement',), bases=("finance.statement",),
), ),
migrations.CreateModel( migrations.CreateModel(
name='StatementSubmitted', name="StatementSubmitted",
fields=[ fields=[],
],
options={ options={
'verbose_name': 'Submitted statement', "verbose_name": "Submitted statement",
'verbose_name_plural': 'Submitted statements', "verbose_name_plural": "Submitted statements",
'permissions': (('may_manage_submitted_statements', 'Can view and manage submitted statements.'),), "permissions": (
'proxy': True, (
'indexes': [], "may_manage_submitted_statements",
'constraints': [], "Can view and manage submitted statements.",
),
),
"proxy": True,
"indexes": [],
"constraints": [],
}, },
bases=('finance.statement',), bases=("finance.statement",),
), ),
migrations.CreateModel( migrations.CreateModel(
name='StatementUnSubmitted', name="StatementUnSubmitted",
fields=[ fields=[],
],
options={ options={
'verbose_name': 'Statement in preparation', "verbose_name": "Statement in preparation",
'verbose_name_plural': 'Statements in preparation', "verbose_name_plural": "Statements in preparation",
'proxy': True, "proxy": True,
'indexes': [], "indexes": [],
'constraints': [], "constraints": [],
}, },
bases=('finance.statement',), bases=("finance.statement",),
), ),
] ]

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

@ -4,38 +4,121 @@ from django.db import migrations
class Migration(migrations.Migration): class Migration(migrations.Migration):
dependencies = [ dependencies = [
('finance', '0002_alter_permissions'), ("finance", "0002_alter_permissions"),
] ]
operations = [ operations = [
migrations.AlterModelOptions( migrations.AlterModelOptions(
name='bill', name="bill",
options={'default_permissions': ('add_global', 'change_global', 'view_global', 'delete_global', 'list_global', 'view'), 'verbose_name': 'Bill', 'verbose_name_plural': 'Bills'}, options={
"default_permissions": (
"add_global",
"change_global",
"view_global",
"delete_global",
"list_global",
"view",
),
"verbose_name": "Bill",
"verbose_name_plural": "Bills",
},
), ),
migrations.AlterModelOptions( migrations.AlterModelOptions(
name='billonexcursionproxy', name="billonexcursionproxy",
options={'default_permissions': ('add_global', 'change_global', 'view_global', 'delete_global', 'list_global', 'view'), 'verbose_name': 'Bill', 'verbose_name_plural': 'Bills'}, options={
"default_permissions": (
"add_global",
"change_global",
"view_global",
"delete_global",
"list_global",
"view",
),
"verbose_name": "Bill",
"verbose_name_plural": "Bills",
},
), ),
migrations.AlterModelOptions( migrations.AlterModelOptions(
name='billonstatementproxy', name="billonstatementproxy",
options={'default_permissions': ('add_global', 'change_global', 'view_global', 'delete_global', 'list_global', 'view'), 'verbose_name': 'Bill', 'verbose_name_plural': 'Bills'}, options={
"default_permissions": (
"add_global",
"change_global",
"view_global",
"delete_global",
"list_global",
"view",
),
"verbose_name": "Bill",
"verbose_name_plural": "Bills",
},
), ),
migrations.AlterModelOptions( migrations.AlterModelOptions(
name='statement', name="statement",
options={'default_permissions': ('add_global', 'change_global', 'view_global', 'delete_global', 'list_global', 'view'), 'permissions': [('may_edit_submitted_statements', 'Is allowed to edit submitted statements')], 'verbose_name': 'Statement', 'verbose_name_plural': 'Statements'}, options={
"default_permissions": (
"add_global",
"change_global",
"view_global",
"delete_global",
"list_global",
"view",
),
"permissions": [
("may_edit_submitted_statements", "Is allowed to edit submitted statements")
],
"verbose_name": "Statement",
"verbose_name_plural": "Statements",
},
), ),
migrations.AlterModelOptions( migrations.AlterModelOptions(
name='statementconfirmed', name="statementconfirmed",
options={'default_permissions': ('add_global', 'change_global', 'view_global', 'delete_global', 'list_global', 'view'), 'permissions': [('may_manage_confirmed_statements', 'Can view and manage confirmed statements.')], 'verbose_name': 'Paid statement', 'verbose_name_plural': 'Paid statements'}, options={
"default_permissions": (
"add_global",
"change_global",
"view_global",
"delete_global",
"list_global",
"view",
),
"permissions": [
("may_manage_confirmed_statements", "Can view and manage confirmed statements.")
],
"verbose_name": "Paid statement",
"verbose_name_plural": "Paid statements",
},
), ),
migrations.AlterModelOptions( migrations.AlterModelOptions(
name='statementsubmitted', name="statementsubmitted",
options={'default_permissions': ('add_global', 'change_global', 'view_global', 'delete_global', 'list_global', 'view'), 'permissions': [('process_statementsubmitted', 'Can manage submitted statements.')], 'verbose_name': 'Submitted statement', 'verbose_name_plural': 'Submitted statements'}, options={
"default_permissions": (
"add_global",
"change_global",
"view_global",
"delete_global",
"list_global",
"view",
),
"permissions": [("process_statementsubmitted", "Can manage submitted statements.")],
"verbose_name": "Submitted statement",
"verbose_name_plural": "Submitted statements",
},
), ),
migrations.AlterModelOptions( migrations.AlterModelOptions(
name='statementunsubmitted', name="statementunsubmitted",
options={'default_permissions': ('add_global', 'change_global', 'view_global', 'delete_global', 'list_global', 'view'), 'verbose_name': 'Statement in preparation', 'verbose_name_plural': 'Statements in preparation'}, options={
"default_permissions": (
"add_global",
"change_global",
"view_global",
"delete_global",
"list_global",
"view",
),
"verbose_name": "Statement in preparation",
"verbose_name_plural": "Statements in preparation",
},
), ),
] ]

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

@ -1,19 +1,20 @@
# Generated by Django 4.0.1 on 2024-12-26 09:45 # Generated by Django 4.0.1 on 2024-12-26 09:45
from django.db import migrations
import utils import utils
from django.db import migrations
class Migration(migrations.Migration): class Migration(migrations.Migration):
dependencies = [ dependencies = [
('finance', '0004_alter_bill_amount'), ("finance", "0004_alter_bill_amount"),
] ]
operations = [ operations = [
migrations.AlterField( migrations.AlterField(
model_name='bill', model_name="bill",
name='proof', name="proof",
field=utils.RestrictedFileField(blank=True, upload_to='bill_images', verbose_name='Proof'), field=utils.RestrictedFileField(
blank=True, upload_to="bill_images", verbose_name="Proof"
),
), ),
] ]

@ -1,26 +1,38 @@
# Generated by Django 4.0.1 on 2025-01-18 19:08 # Generated by Django 4.0.1 on 2025-01-18 19:08
from django.db import migrations, models
import django.db.models.deletion import django.db.models.deletion
from django.db import migrations
from django.db import models
class Migration(migrations.Migration): class Migration(migrations.Migration):
dependencies = [ dependencies = [
('members', '0032_member_upload_registration_form_key'), ("members", "0032_member_upload_registration_form_key"),
('members', '0033_freizeit_approved_extra_youth_leader_count'), ("members", "0033_freizeit_approved_extra_youth_leader_count"),
('finance', '0005_alter_bill_proof'), ("finance", "0005_alter_bill_proof"),
] ]
operations = [ operations = [
migrations.AddField( migrations.AddField(
model_name='statement', model_name="statement",
name='allowance_to', 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'), 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( migrations.AddField(
model_name='statement', model_name="statement",
name='subsidy_to', 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'), 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",
),
), ),
] ]

@ -1,19 +1,25 @@
# Generated by Django 4.0.1 on 2025-01-18 22:00 # Generated by Django 4.0.1 on 2025-01-18 22:00
from django.db import migrations, models from django.db import migrations
from django.db import models
class Migration(migrations.Migration): class Migration(migrations.Migration):
dependencies = [ dependencies = [
('members', '0033_freizeit_approved_extra_youth_leader_count'), ("members", "0033_freizeit_approved_extra_youth_leader_count"),
('finance', '0006_statement_add_allowance_to_subsidy_to'), ("finance", "0006_statement_add_allowance_to_subsidy_to"),
] ]
operations = [ operations = [
migrations.AlterField( migrations.AlterField(
model_name='statement', model_name="statement",
name='allowance_to', 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'), 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",
),
), ),
] ]

@ -1,25 +1,39 @@
# Generated by Django 4.0.1 on 2025-01-23 22:16 # Generated by Django 4.0.1 on 2025-01-23 22:16
from django.db import migrations, models
import django.db.models.deletion import django.db.models.deletion
from django.db import migrations
from django.db import models
class Migration(migrations.Migration): class Migration(migrations.Migration):
dependencies = [ dependencies = [
('members', '0033_freizeit_approved_extra_youth_leader_count'), ("members", "0033_freizeit_approved_extra_youth_leader_count"),
('finance', '0007_alter_statement_allowance_to'), ("finance", "0007_alter_statement_allowance_to"),
] ]
operations = [ operations = [
migrations.AlterField( migrations.AlterField(
model_name='statement', model_name="statement",
name='allowance_to', name="allowance_to",
field=models.ManyToManyField(blank=True, help_text='The youth leaders to which an allowance should be paid.', related_name='receives_allowance_for_statements', to='members.Member', verbose_name='Pay allowance to'), field=models.ManyToManyField(
blank=True,
help_text="The youth leaders to which an allowance should be paid.",
related_name="receives_allowance_for_statements",
to="members.Member",
verbose_name="Pay allowance to",
),
), ),
migrations.AlterField( migrations.AlterField(
model_name='statement', model_name="statement",
name='subsidy_to', name="subsidy_to",
field=models.ForeignKey(blank=True, 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'), field=models.ForeignKey(
blank=True,
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",
),
), ),
] ]

@ -1,20 +1,28 @@
# Generated by Django 4.2.20 on 2025-04-03 21:04 # Generated by Django 4.2.20 on 2025-04-03 21:04
from django.db import migrations, models
import django.db.models.deletion import django.db.models.deletion
from django.db import migrations
from django.db import models
class Migration(migrations.Migration): class Migration(migrations.Migration):
dependencies = [ dependencies = [
('members', '0039_membertraining_certificate_attendance'), ("members", "0039_membertraining_certificate_attendance"),
('finance', '0008_alter_statement_allowance_to_and_more'), ("finance", "0008_alter_statement_allowance_to_and_more"),
] ]
operations = [ operations = [
migrations.AddField( migrations.AddField(
model_name='statement', model_name="statement",
name='ljp_to', name="ljp_to",
field=models.ForeignKey(blank=True, help_text='The person that should receive the ljp contributions for the participants. Should be only selected if an ljp request was submitted.', null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='receives_ljp_for_statements', to='members.member', verbose_name='Pay ljp contributions to'), field=models.ForeignKey(
blank=True,
help_text="The person that should receive the ljp contributions for the participants. Should be only selected if an ljp request was submitted.",
null=True,
on_delete=django.db.models.deletion.SET_NULL,
related_name="receives_ljp_for_statements",
to="members.member",
verbose_name="Pay ljp contributions to",
),
), ),
] ]

@ -1,6 +1,7 @@
# Generated by Django 4.2.20 on 2025-10-11 15:43 # Generated by Django 4.2.20 on 2025-10-11 15:43
from django.db import migrations, models from django.db import migrations
from django.db import models
def set_status_from_old_fields(apps, schema_editor): def set_status_from_old_fields(apps, schema_editor):
@ -10,7 +11,7 @@ def set_status_from_old_fields(apps, schema_editor):
- If submitted is True but confirmed is False, status = SUBMITTED (1) - If submitted is True but confirmed is False, status = SUBMITTED (1)
- Otherwise, status = UNSUBMITTED (0) - Otherwise, status = UNSUBMITTED (0)
""" """
Statement = apps.get_model('finance', 'Statement') Statement = apps.get_model("finance", "Statement")
UNSUBMITTED, SUBMITTED, CONFIRMED = 0, 1, 2 UNSUBMITTED, SUBMITTED, CONFIRMED = 0, 1, 2
for statement in Statement.objects.all(): for statement in Statement.objects.all():
@ -20,20 +21,23 @@ def set_status_from_old_fields(apps, schema_editor):
statement.status = SUBMITTED statement.status = SUBMITTED
else: else:
statement.status = UNSUBMITTED statement.status = UNSUBMITTED
statement.save(update_fields=['status']) statement.save(update_fields=["status"])
class Migration(migrations.Migration): class Migration(migrations.Migration):
dependencies = [ dependencies = [
('finance', '0009_statement_ljp_to'), ("finance", "0009_statement_ljp_to"),
] ]
operations = [ operations = [
migrations.AddField( migrations.AddField(
model_name='statement', model_name="statement",
name='status', name="status",
field=models.IntegerField(choices=[(0, 'In preparation'), (1, 'Submitted'), (2, 'Confirmed')], default=0, verbose_name='Status'), field=models.IntegerField(
choices=[(0, "In preparation"), (1, "Submitted"), (2, "Confirmed")],
default=0,
verbose_name="Status",
),
), ),
migrations.RunPython(set_status_from_old_fields, reverse_code=migrations.RunPython.noop), migrations.RunPython(set_status_from_old_fields, reverse_code=migrations.RunPython.noop),
] ]

@ -4,18 +4,17 @@ from django.db import migrations
class Migration(migrations.Migration): class Migration(migrations.Migration):
dependencies = [ dependencies = [
('finance', '0010_statement_status'), ("finance", "0010_statement_status"),
] ]
operations = [ operations = [
migrations.RemoveField( migrations.RemoveField(
model_name='statement', model_name="statement",
name='confirmed', name="confirmed",
), ),
migrations.RemoveField( migrations.RemoveField(
model_name='statement', model_name="statement",
name='submitted', name="submitted",
), ),
] ]

@ -4,25 +4,30 @@ from django.db import migrations
class Migration(migrations.Migration): class Migration(migrations.Migration):
dependencies = [ dependencies = [
('finance', '0011_remove_statement_confirmed_and_submitted'), ("finance", "0011_remove_statement_confirmed_and_submitted"),
] ]
operations = [ operations = [
migrations.CreateModel( migrations.CreateModel(
name='StatementOnExcursionProxy', name="StatementOnExcursionProxy",
fields=[ fields=[],
],
options={ options={
'verbose_name': 'Statement', "verbose_name": "Statement",
'verbose_name_plural': 'Statements', "verbose_name_plural": "Statements",
'abstract': False, "abstract": False,
'proxy': True, "proxy": True,
'default_permissions': ('add_global', 'change_global', 'view_global', 'delete_global', 'list_global', 'view'), "default_permissions": (
'indexes': [], "add_global",
'constraints': [], "change_global",
"view_global",
"delete_global",
"list_global",
"view",
),
"indexes": [],
"constraints": [],
}, },
bases=('finance.statement',), bases=("finance.statement",),
), ),
] ]

@ -1,39 +1,44 @@
import math import re
from itertools import groupby from itertools import groupby
from decimal import Decimal, ROUND_HALF_DOWN
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 _
from django.utils.html import format_html
from members.models import Member, Freizeit, OEFFENTLICHE_ANREISE, MUSKELKRAFT_ANREISE
from django.conf import settings
import rules import rules
from contrib.media import media_path
from contrib.models import CommonModel from contrib.models import CommonModel
from contrib.rules import has_global_perm from contrib.rules import has_global_perm
from utils import cvt_to_decimal, RestrictedFileField from django.conf import settings
from members.pdf import render_tex_with_attachments from django.db import models
from django.db.models import Sum
from django.utils import timezone
from django.utils.html import format_html
from django.utils.translation import gettext_lazy as _
from mailer.mailutils import send as send_mail from mailer.mailutils import send as send_mail
from contrib.media import media_path from members.models import Freizeit
from members.models import Member
from members.models import MUSKELKRAFT_ANREISE
from members.models import OEFFENTLICHE_ANREISE
from members.pdf import render_tex_with_attachments
from members.rules import is_leader
from members.rules import statement_not_submitted
from schwifty import IBAN from schwifty import IBAN
import re from utils import cvt_to_decimal
from utils import RestrictedFileField
from .rules import is_creator
from .rules import leads_excursion
from .rules import not_submitted
# Create your models here. # Create your models here.
class Ledger(models.Model): class Ledger(models.Model):
name = models.CharField(verbose_name=_('Name'), max_length=30) name = models.CharField(verbose_name=_("Name"), max_length=30)
def __str__(self): def __str__(self):
return self.name return self.name
class Meta: class Meta:
verbose_name = _('Ledger') verbose_name = _("Ledger")
verbose_name_plural = _('Ledgers') verbose_name_plural = _("Ledgers")
class TransactionIssue: class TransactionIssue:
@ -51,88 +56,123 @@ class StatementManager(models.Manager):
class Statement(CommonModel): class Statement(CommonModel):
MISSING_LEDGER, NON_MATCHING_TRANSACTIONS, INVALID_ALLOWANCE_TO, INVALID_TOTAL, VALID = 0, 1, 2, 3, 4 MISSING_LEDGER, NON_MATCHING_TRANSACTIONS, INVALID_ALLOWANCE_TO, INVALID_TOTAL, VALID = (
0,
1,
2,
3,
4,
)
UNSUBMITTED, SUBMITTED, CONFIRMED = 0, 1, 2 UNSUBMITTED, SUBMITTED, CONFIRMED = 0, 1, 2
STATUS_CHOICES = [(UNSUBMITTED, _('In preparation')), STATUS_CHOICES = [
(SUBMITTED, _('Submitted')), (UNSUBMITTED, _("In preparation")),
(CONFIRMED, _('Completed'))] (SUBMITTED, _("Submitted")),
STATUS_CSS_CLASS = { SUBMITTED: 'submitted', (CONFIRMED, _("Completed")),
CONFIRMED: 'confirmed', ]
UNSUBMITTED: 'unsubmitted' } STATUS_CSS_CLASS = {SUBMITTED: "submitted", CONFIRMED: "confirmed", UNSUBMITTED: "unsubmitted"}
short_description = models.CharField(verbose_name=_('Short description'), short_description = models.CharField(
max_length=30, verbose_name=_("Short description"), max_length=30, blank=False
blank=False) )
explanation = models.TextField(verbose_name=_('Explanation'), blank=True) explanation = models.TextField(verbose_name=_("Explanation"), blank=True)
excursion = models.OneToOneField(Freizeit, verbose_name=_('Associated excursion'), excursion = models.OneToOneField(
blank=True, Freizeit,
null=True, verbose_name=_("Associated excursion"),
on_delete=models.SET_NULL) blank=True,
null=True,
allowance_to = models.ManyToManyField(Member, verbose_name=_('Pay allowance to'), on_delete=models.SET_NULL,
related_name='receives_allowance_for_statements', )
blank=True,
help_text=_('The youth leaders to which an allowance should be paid.')) allowance_to = models.ManyToManyField(
subsidy_to = models.ForeignKey(Member, verbose_name=_('Pay subsidy to'), Member,
null=True, verbose_name=_("Pay allowance to"),
blank=True, related_name="receives_allowance_for_statements",
on_delete=models.SET_NULL, blank=True,
related_name='receives_subsidy_for_statements', help_text=_("The youth leaders to which an allowance should be paid."),
help_text=_('The person that should receive the subsidy for night and travel costs. Typically the person who paid for them.')) )
subsidy_to = models.ForeignKey(
ljp_to = models.ForeignKey(Member, verbose_name=_('Pay ljp contributions to'), Member,
null=True, verbose_name=_("Pay subsidy to"),
blank=True, null=True,
on_delete=models.SET_NULL, blank=True,
related_name='receives_ljp_for_statements', on_delete=models.SET_NULL,
help_text=_('The person that should receive the ljp contributions for the participants. Should be only selected if an ljp request was submitted.')) related_name="receives_subsidy_for_statements",
help_text=_(
night_cost = models.DecimalField(verbose_name=_('Price per night'), default=0, decimal_places=2, max_digits=5) "The person that should receive the subsidy for night and travel costs. Typically the person who paid for them."
),
status = models.IntegerField(verbose_name=_('Status'), )
choices=STATUS_CHOICES,
default=UNSUBMITTED) ljp_to = models.ForeignKey(
submitted_date = models.DateTimeField(verbose_name=_('Submitted on'), default=None, null=True) Member,
confirmed_date = models.DateTimeField(verbose_name=_('Paid on'), default=None, null=True) verbose_name=_("Pay ljp contributions to"),
null=True,
created_by = models.ForeignKey(Member, verbose_name=_('Created by'), blank=True,
blank=True, on_delete=models.SET_NULL,
null=True, related_name="receives_ljp_for_statements",
on_delete=models.SET_NULL, help_text=_(
related_name='created_statements') "The person that should receive the ljp contributions for the participants. Should be only selected if an ljp request was submitted."
submitted_by = models.ForeignKey(Member, verbose_name=_('Submitted by'), ),
blank=True, )
null=True,
on_delete=models.SET_NULL, night_cost = models.DecimalField(
related_name='submitted_statements') verbose_name=_("Price per night"), default=0, decimal_places=2, max_digits=5
confirmed_by = models.ForeignKey(Member, verbose_name=_('Authorized by'), )
blank=True,
null=True, status = models.IntegerField(
on_delete=models.SET_NULL, verbose_name=_("Status"), choices=STATUS_CHOICES, default=UNSUBMITTED
related_name='confirmed_statements') )
submitted_date = models.DateTimeField(verbose_name=_("Submitted on"), default=None, null=True)
confirmed_date = models.DateTimeField(verbose_name=_("Paid on"), default=None, null=True)
created_by = models.ForeignKey(
Member,
verbose_name=_("Created by"),
blank=True,
null=True,
on_delete=models.SET_NULL,
related_name="created_statements",
)
submitted_by = models.ForeignKey(
Member,
verbose_name=_("Submitted by"),
blank=True,
null=True,
on_delete=models.SET_NULL,
related_name="submitted_statements",
)
confirmed_by = models.ForeignKey(
Member,
verbose_name=_("Authorized by"),
blank=True,
null=True,
on_delete=models.SET_NULL,
related_name="confirmed_statements",
)
class Meta(CommonModel.Meta): class Meta(CommonModel.Meta):
verbose_name = _('Statement') verbose_name = _("Statement")
verbose_name_plural = _('Statements') verbose_name_plural = _("Statements")
permissions = [ permissions = [("may_edit_submitted_statements", "Is allowed to edit submitted statements")]
('may_edit_submitted_statements', 'Is allowed to edit submitted statements')
]
rules_permissions = { rules_permissions = {
# All users may add draft statements. # All users may add draft statements.
'add_obj': rules.is_staff, "add_obj": rules.is_staff,
# All users may view their own statements and statements of excursions they are responsible for. # All users may view their own statements and statements of excursions they are responsible for.
'view_obj': is_creator | leads_excursion | has_global_perm('finance.view_global_statement'), "view_obj": is_creator
| leads_excursion
| has_global_perm("finance.view_global_statement"),
# All users may change relevant (see above) draft statements. # All users may change relevant (see above) draft statements.
'change_obj': (not_submitted & (is_creator | leads_excursion)) | has_global_perm('finance.change_global_statement'), "change_obj": (not_submitted & (is_creator | leads_excursion))
| has_global_perm("finance.change_global_statement"),
# All users may delete relevant (see above) draft statements. # All users may delete relevant (see above) draft statements.
'delete_obj': not_submitted & (is_creator | leads_excursion | has_global_perm('finance.delete_global_statement')), "delete_obj": not_submitted
& (is_creator | leads_excursion | has_global_perm("finance.delete_global_statement")),
} }
@property @property
def title(self): def title(self):
if self.excursion is not None: if self.excursion is not None:
return _('Excursion %(excursion)s') % {'excursion': str(self.excursion)} return _("Excursion %(excursion)s") % {"excursion": str(self.excursion)}
else: else:
return self.short_description return self.short_description
@ -149,10 +189,13 @@ class Statement(CommonModel):
def status_badge(self): def status_badge(self):
code = Statement.STATUS_CSS_CLASS[self.status] code = Statement.STATUS_CSS_CLASS[self.status]
return format_html(f'<span class="statement-{code}">{Statement.STATUS_CHOICES[self.status][1]}</span>') return format_html(
status_badge.short_description = _('Status') f'<span class="statement-{code}">{Statement.STATUS_CHOICES[self.status][1]}</span>'
)
status_badge.short_description = _("Status")
status_badge.allow_tags = True status_badge.allow_tags = True
status_badge.admin_order_field = 'status' status_badge.admin_order_field = "status"
def submit(self, submitter=None): def submit(self, submitter=None):
self.status = self.SUBMITTED self.status = self.SUBMITTED
@ -174,7 +217,9 @@ class Statement(CommonModel):
total still differs from the transaction total.) total still differs from the transaction total.)
- If the statement is associated with an excursion: allowances, subsidies, LJP paiment and org fee. - If the statement is associated with an excursion: allowances, subsidies, LJP paiment and org fee.
""" """
needed_paiments = [(b.paid_by, b.amount) for b in self.bill_set.all() if b.costs_covered and b.paid_by] needed_paiments = [
(b.paid_by, b.amount) for b in self.bill_set.all() if b.costs_covered and b.paid_by
]
if self.excursion is not None: if self.excursion is not None:
needed_paiments.extend([(yl, self.allowance_per_yl) for yl in self.allowance_to.all()]) needed_paiments.extend([(yl, self.allowance_per_yl) for yl in self.allowance_to.all()])
@ -189,10 +234,20 @@ class Statement(CommonModel):
needed_paiments.append((self.ljp_to, self.paid_ljp_contributions)) needed_paiments.append((self.ljp_to, self.paid_ljp_contributions))
needed_paiments = sorted(needed_paiments, key=lambda p: p[0].pk) needed_paiments = sorted(needed_paiments, key=lambda p: p[0].pk)
target = dict(map(lambda p: (p[0], sum([x[1] for x in p[1]])), groupby(needed_paiments, lambda p: p[0]))) target = dict(
map(
lambda p: (p[0], sum([x[1] for x in p[1]])),
groupby(needed_paiments, lambda p: p[0]),
)
)
transactions = sorted(self.transaction_set.all(), key=lambda trans: trans.member.pk) transactions = sorted(self.transaction_set.all(), key=lambda trans: trans.member.pk)
current = dict(map(lambda p: (p[0], sum([t.amount for t in p[1]])), groupby(transactions, lambda trans: trans.member))) current = dict(
map(
lambda p: (p[0], sum([t.amount for t in p[1]])),
groupby(transactions, lambda trans: trans.member),
)
)
issues = [] issues = []
for member, amount in target.items(): for member, amount in target.items():
@ -274,8 +329,9 @@ class Statement(CommonModel):
def is_valid(self): def is_valid(self):
return self.validity == Statement.VALID return self.validity == Statement.VALID
is_valid.boolean = True is_valid.boolean = True
is_valid.short_description = _('Ready to confirm') is_valid.short_description = _("Ready to confirm")
def confirm(self, confirmer=None): def confirm(self, confirmer=None):
if not self.submitted: if not self.submitted:
@ -303,7 +359,13 @@ class Statement(CommonModel):
if not bill.paid_by: if not bill.paid_by:
return False return False
ref = "{}: {}".format(str(self), bill.short_description) ref = "{}: {}".format(str(self), bill.short_description)
Transaction(statement=self, member=bill.paid_by, amount=bill.amount, confirmed=False, reference=ref).save() Transaction(
statement=self,
member=bill.paid_by,
amount=bill.amount,
confirmed=False,
reference=ref,
).save()
# excursion specific # excursion specific
if self.excursion is None: if self.excursion is None:
@ -311,23 +373,46 @@ class Statement(CommonModel):
# allowance # allowance
for yl in self.allowance_to.all(): for yl in self.allowance_to.all():
ref = _("Allowance for %(excu)s") % {'excu': self.excursion.name} ref = _("Allowance for %(excu)s") % {"excu": self.excursion.name}
Transaction(statement=self, member=yl, amount=self.allowance_per_yl, confirmed=False, reference=ref).save() Transaction(
statement=self,
member=yl,
amount=self.allowance_per_yl,
confirmed=False,
reference=ref,
).save()
# subsidies (i.e. night and transportation costs) # subsidies (i.e. night and transportation costs)
if self.subsidy_to: if self.subsidy_to:
ref = _("Night and travel costs for %(excu)s") % {'excu': self.excursion.name} 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() Transaction(
statement=self,
member=self.subsidy_to,
amount=self.total_subsidies,
confirmed=False,
reference=ref,
).save()
if self.total_org_fee: if self.total_org_fee:
# if no subsidy receiver is given but org fees have to be paid. Just pick one of allowance receivers # if no subsidy receiver is given but org fees have to be paid. Just pick one of allowance receivers
ref = _("reduced by org fee") ref = _("reduced by org fee")
Transaction(statement=self, member=self.org_fee_payant, amount=-self.total_org_fee, confirmed=False, reference=ref).save() Transaction(
statement=self,
member=self.org_fee_payant,
amount=-self.total_org_fee,
confirmed=False,
reference=ref,
).save()
if self.ljp_to: if self.ljp_to:
ref = _("LJP-Contribution %(excu)s") % {'excu': self.excursion.name} ref = _("LJP-Contribution %(excu)s") % {"excu": self.excursion.name}
Transaction(statement=self, member=self.ljp_to, amount=self.paid_ljp_contributions, Transaction(
confirmed=False, reference=ref).save() statement=self,
member=self.ljp_to,
amount=self.paid_ljp_contributions,
confirmed=False,
reference=ref,
).save()
return True return True
@ -335,11 +420,15 @@ class Statement(CommonModel):
# to minimize the number of needed bank transactions, we bundle transactions from same ledger to # to minimize the number of needed bank transactions, we bundle transactions from same ledger to
# same member # same member
transactions = self.transaction_set.all() transactions = self.transaction_set.all()
if any((t.ledger is None for t in transactions)): if any(t.ledger is None for t in transactions):
return return
sort_key = lambda trans: (trans.member.pk, trans.ledger.pk) def sort_key(trans):
group_key = lambda trans: (trans.member, trans.ledger) return (trans.member.pk, trans.ledger.pk)
def group_key(trans):
return (trans.member, trans.ledger)
transactions = sorted(transactions, key=sort_key) transactions = sorted(transactions, key=sort_key)
for pair, transaction_group in groupby(transactions, group_key): for pair, transaction_group in groupby(transactions, group_key):
member, ledger = pair member, ledger = pair
@ -347,10 +436,16 @@ class Statement(CommonModel):
if len(grp) == 1: if len(grp) == 1:
continue continue
new_amount = sum((trans.amount for trans in grp)) new_amount = sum(trans.amount for trans in grp)
new_ref = ", ".join((f"{trans.reference} EUR{trans.amount: .2f}" for trans in grp)) new_ref = ", ".join(f"{trans.reference} EUR{trans.amount: .2f}" for trans in grp)
Transaction(statement=self, member=member, amount=new_amount, confirmed=False, reference=new_ref, Transaction(
ledger=ledger).save() statement=self,
member=member,
amount=new_amount,
confirmed=False,
reference=new_ref,
ledger=ledger,
).save()
for trans in grp: for trans in grp:
trans.delete() trans.delete()
@ -367,7 +462,7 @@ class Statement(CommonModel):
def bills_without_proof(self): def bills_without_proof(self):
"""Returns the bills that lack a proof file""" """Returns the bills that lack a proof file"""
return [bill for bill in self.bill_set.all() if not bill.proof] return [bill for bill in self.bill_set.all() if not bill.proof]
@property @property
def total_bills_theoretic(self): def total_bills_theoretic(self):
return sum([bill.amount for bill in self.bill_set.all()]) return sum([bill.amount for bill in self.bill_set.all()])
@ -382,8 +477,10 @@ class Statement(CommonModel):
if self.excursion is None: if self.excursion is None:
return 0 return 0
if self.excursion.tour_approach == MUSKELKRAFT_ANREISE \ if (
or self.excursion.tour_approach == OEFFENTLICHE_ANREISE: self.excursion.tour_approach == MUSKELKRAFT_ANREISE
or self.excursion.tour_approach == OEFFENTLICHE_ANREISE
):
return 0.15 return 0.15
else: else:
return 0.1 return 0.1
@ -431,9 +528,7 @@ class Statement(CommonModel):
@property @property
def total_per_yl(self): def total_per_yl(self):
return self.transportation_per_yl \ return self.transportation_per_yl + self.allowance_per_yl + self.nights_per_yl
+ self.allowance_per_yl \
+ self.nights_per_yl
@property @property
def real_per_yl(self): def real_per_yl(self):
@ -447,7 +542,11 @@ class Statement(CommonModel):
"""participants older than 26.99 years need to pay a specified organisation fee per person per day.""" """participants older than 26.99 years need to pay a specified organisation fee per person per day."""
if self.excursion is None: if self.excursion is None:
return 0 return 0
return cvt_to_decimal(settings.EXCURSION_ORG_FEE * self.excursion.duration * self.excursion.old_participant_count) return cvt_to_decimal(
settings.EXCURSION_ORG_FEE
* self.excursion.duration
* self.excursion.old_participant_count
)
@property @property
def total_org_fee(self): def total_org_fee(self):
@ -480,7 +579,7 @@ class Statement(CommonModel):
@property @property
def theoretical_total_staff(self): def theoretical_total_staff(self):
""" """
the sum of subsidies and allowances if all eligible youth leaders would collect them. the sum of subsidies and allowances if all eligible youth leaders would collect them.
""" """
return self.total_per_yl * self.real_staff_count return self.total_per_yl * self.real_staff_count
@ -495,7 +594,6 @@ class Statement(CommonModel):
def total_staff_paid(self): def total_staff_paid(self):
return self.total_staff - self.total_org_fee return self.total_staff - self.total_org_fee
@property @property
def real_staff_count(self): def real_staff_count(self):
if self.excursion is None: if self.excursion is None:
@ -511,22 +609,26 @@ class Statement(CommonModel):
return 0 return 0
else: else:
return self.excursion.approved_staff_count return self.excursion.approved_staff_count
@property @property
def paid_ljp_contributions(self): def paid_ljp_contributions(self):
if hasattr(self.excursion, 'ljpproposal') and self.ljp_to: if hasattr(self.excursion, "ljpproposal") and self.ljp_to:
if self.excursion.theoretic_ljp_participant_count < 5: if self.excursion.theoretic_ljp_participant_count < 5:
return 0 return 0
return cvt_to_decimal( return cvt_to_decimal(
min( min(
# if total costs are more than the max amount of the LJP contribution, we pay the max amount, reduced by taxes # if total costs are more than the max amount of the LJP contribution, we pay the max amount, reduced by taxes
(1-settings.LJP_TAX) * settings.LJP_CONTRIBUTION_PER_DAY * self.excursion.ljp_participant_count * self.excursion.ljp_duration, (1 - settings.LJP_TAX)
* settings.LJP_CONTRIBUTION_PER_DAY
* self.excursion.ljp_participant_count
* self.excursion.ljp_duration,
# if the total costs are less than the max amount, we pay up to 90% of the total costs, reduced by taxes # if the total costs are less than the max amount, we pay up to 90% of the total costs, reduced by taxes
(1-settings.LJP_TAX) * 0.9 * (float(self.total_bills_not_covered) + float(self.total_staff) ), (1 - settings.LJP_TAX)
* 0.9
* (float(self.total_bills_not_covered) + float(self.total_staff)),
# we never pay more than the maximum costs of the trip # we never pay more than the maximum costs of the trip
float(self.total_bills_not_covered) float(self.total_bills_not_covered),
) )
) )
else: else:
@ -534,7 +636,7 @@ class Statement(CommonModel):
@property @property
def total(self): def total(self):
return self.total_bills + self.total_staff_paid + self.paid_ljp_contributions return self.total_bills + self.total_staff_paid + self.paid_ljp_contributions
@property @property
def total_theoretic(self): def total_theoretic(self):
@ -548,60 +650,61 @@ class Statement(CommonModel):
def total_pretty(self): def total_pretty(self):
return "{}".format(self.total) return "{}".format(self.total)
total_pretty.short_description = _('Total')
total_pretty.admin_order_field = 'total' total_pretty.short_description = _("Total")
total_pretty.admin_order_field = "total"
def template_context(self): def template_context(self):
context = { context = {
'total_bills': self.total_bills, "total_bills": self.total_bills,
'total_bills_theoretic': self.total_bills_theoretic, "total_bills_theoretic": self.total_bills_theoretic,
'bills_covered': self.bills_covered, "bills_covered": self.bills_covered,
'total': self.total, "total": self.total,
} }
if self.excursion: if self.excursion:
excursion_context = { excursion_context = {
'nights': self.excursion.night_count, "nights": self.excursion.night_count,
'price_per_night': self.real_night_cost, "price_per_night": self.real_night_cost,
'duration': self.excursion.duration, "duration": self.excursion.duration,
'staff_count': self.real_staff_count, "staff_count": self.real_staff_count,
'kilometers_traveled': self.excursion.kilometers_traveled, "kilometers_traveled": self.excursion.kilometers_traveled,
'means_of_transport': self.excursion.get_tour_approach(), "means_of_transport": self.excursion.get_tour_approach(),
'euro_per_km': self.euro_per_km, "euro_per_km": self.euro_per_km,
'allowance_per_day': settings.ALLOWANCE_PER_DAY, "allowance_per_day": settings.ALLOWANCE_PER_DAY,
'allowances_paid': self.allowances_paid, "allowances_paid": self.allowances_paid,
'nights_per_yl': self.nights_per_yl, "nights_per_yl": self.nights_per_yl,
'allowance_per_yl': self.allowance_per_yl, "allowance_per_yl": self.allowance_per_yl,
'total_allowance': self.total_allowance, "total_allowance": self.total_allowance,
'transportation_per_yl': self.transportation_per_yl, "transportation_per_yl": self.transportation_per_yl,
'total_per_yl': self.total_per_yl, "total_per_yl": self.total_per_yl,
'total_staff': self.total_staff, "total_staff": self.total_staff,
'total_allowance': self.total_allowance, "theoretical_total_staff": self.theoretical_total_staff,
'theoretical_total_staff': self.theoretical_total_staff, "real_staff_count": self.real_staff_count,
'real_staff_count': self.real_staff_count, "total_subsidies": self.total_subsidies,
'total_subsidies': self.total_subsidies, "subsidy_to": self.subsidy_to,
'total_allowance': self.total_allowance, "allowance_to": self.allowance_to,
'subsidy_to': self.subsidy_to, "paid_ljp_contributions": self.paid_ljp_contributions,
'allowance_to': self.allowance_to, "ljp_to": self.ljp_to,
'paid_ljp_contributions': self.paid_ljp_contributions, "theoretic_ljp_participant_count": self.excursion.theoretic_ljp_participant_count,
'ljp_to': self.ljp_to, "participant_count": self.excursion.participant_count,
'theoretic_ljp_participant_count': self.excursion.theoretic_ljp_participant_count, "total_seminar_days": self.excursion.total_seminar_days,
'participant_count': self.excursion.participant_count, "ljp_tax": settings.LJP_TAX * 100,
'total_seminar_days': self.excursion.total_seminar_days, "total_org_fee_theoretical": self.total_org_fee_theoretical,
'ljp_tax': settings.LJP_TAX * 100, "total_org_fee": self.total_org_fee,
'total_org_fee_theoretical': self.total_org_fee_theoretical, "old_participant_count": self.excursion.old_participant_count,
'total_org_fee': self.total_org_fee, "total_staff_paid": self.total_staff_paid,
'old_participant_count': self.excursion.old_participant_count, "org_fee": cvt_to_decimal(settings.EXCURSION_ORG_FEE),
'total_staff_paid': self.total_staff_paid,
'org_fee': cvt_to_decimal(settings.EXCURSION_ORG_FEE),
} }
return dict(context, **excursion_context) return dict(context, **excursion_context)
else: else:
return context return context
def grouped_bills(self): def grouped_bills(self):
return self.bill_set.values('short_description')\ return (
.order_by('short_description')\ self.bill_set.values("short_description")
.annotate(amount=Sum('amount')) .order_by("short_description")
.annotate(amount=Sum("amount"))
)
def send_summary(self, cc=None): def send_summary(self, cc=None):
""" """
@ -609,29 +712,34 @@ class Statement(CommonModel):
""" """
excursion = self.excursion excursion = self.excursion
context = dict(statement=self.template_context(), excursion=excursion, settings=settings) context = dict(statement=self.template_context(), excursion=excursion, settings=settings)
pdf_filename = f"{excursion.code}_{excursion.name}_Zuschussbeleg" if excursion else f"Abrechnungsbeleg" pdf_filename = (
f"{excursion.code}_{excursion.name}_Zuschussbeleg" if excursion else "Abrechnungsbeleg"
)
attachments = [bill.proof.path for bill in self.bills_covered if bill.proof] attachments = [bill.proof.path for bill in self.bills_covered if bill.proof]
filename = render_tex_with_attachments(pdf_filename, 'finance/statement_summary.tex', filename = render_tex_with_attachments(
context, attachments, save_only=True) pdf_filename, "finance/statement_summary.tex", context, attachments, save_only=True
send_mail(_('Statement summary for %(title)s') % { 'title': self.title }, )
settings.SEND_STATEMENT_SUMMARY.format(statement=self.title), send_mail(
sender=settings.DEFAULT_SENDING_MAIL, _("Statement summary for %(title)s") % {"title": self.title},
recipients=[settings.SEKTION_FINANCE_MAIL], settings.SEND_STATEMENT_SUMMARY.format(statement=self.title),
cc=cc, sender=settings.DEFAULT_SENDING_MAIL,
attachments=[media_path(filename)]) recipients=[settings.SEKTION_FINANCE_MAIL],
cc=cc,
attachments=[media_path(filename)],
)
class StatementOnExcursionProxy(Statement): class StatementOnExcursionProxy(Statement):
class Meta(CommonModel.Meta): class Meta(CommonModel.Meta):
proxy = True proxy = True
verbose_name = _('Statement') verbose_name = _("Statement")
verbose_name_plural = _('Statements') verbose_name_plural = _("Statements")
rules_permissions = { rules_permissions = {
# This is used as an inline on excursions, so we check for excursion permissions. # This is used as an inline on excursions, so we check for excursion permissions.
'add_obj': is_leader, "add_obj": is_leader,
'view_obj': is_leader | has_global_perm('members.view_global_freizeit'), "view_obj": is_leader | has_global_perm("members.view_global_freizeit"),
'change_obj': is_leader & statement_not_submitted, "change_obj": is_leader & statement_not_submitted,
'delete_obj': is_leader & statement_not_submitted, "delete_obj": is_leader & statement_not_submitted,
} }
@ -645,13 +753,15 @@ class StatementUnSubmitted(Statement):
class Meta(CommonModel.Meta): class Meta(CommonModel.Meta):
proxy = True proxy = True
verbose_name = _('Statement in preparation') verbose_name = _("Statement in preparation")
verbose_name_plural = _('Statements in preparation') verbose_name_plural = _("Statements in preparation")
rules_permissions = { rules_permissions = {
'add_obj': rules.is_staff, "add_obj": rules.is_staff,
'view_obj': is_creator | leads_excursion | has_global_perm('finance.view_global_statementunsubmitted'), "view_obj": is_creator
'change_obj': is_creator | leads_excursion, | leads_excursion
'delete_obj': is_creator | leads_excursion, | has_global_perm("finance.view_global_statementunsubmitted"),
"change_obj": is_creator | leads_excursion,
"delete_obj": is_creator | leads_excursion,
} }
@ -665,10 +775,10 @@ class StatementSubmitted(Statement):
class Meta(CommonModel.Meta): class Meta(CommonModel.Meta):
proxy = True proxy = True
verbose_name = _('Submitted statement') verbose_name = _("Submitted statement")
verbose_name_plural = _('Submitted statements') verbose_name_plural = _("Submitted statements")
permissions = [ permissions = [
('process_statementsubmitted', 'Can manage submitted statements.'), ("process_statementsubmitted", "Can manage submitted statements."),
] ]
@ -682,111 +792,135 @@ class StatementConfirmed(Statement):
class Meta(CommonModel.Meta): class Meta(CommonModel.Meta):
proxy = True proxy = True
verbose_name = _('Paid statement') verbose_name = _("Paid statement")
verbose_name_plural = _('Paid statements') verbose_name_plural = _("Paid statements")
permissions = [ permissions = [
('may_manage_confirmed_statements', 'Can view and manage confirmed statements.'), ("may_manage_confirmed_statements", "Can view and manage confirmed statements."),
] ]
class Bill(CommonModel): class Bill(CommonModel):
statement = models.ForeignKey(Statement, verbose_name=_('Statement'), on_delete=models.CASCADE) statement = models.ForeignKey(Statement, verbose_name=_("Statement"), on_delete=models.CASCADE)
short_description = models.CharField(verbose_name=_('Short description'), max_length=30, blank=False) short_description = models.CharField(
explanation = models.TextField(verbose_name=_('Explanation'), blank=True) verbose_name=_("Short description"), max_length=30, blank=False
)
amount = models.DecimalField(verbose_name=_('Amount'), max_digits=6, decimal_places=2, default=0) explanation = models.TextField(verbose_name=_("Explanation"), blank=True)
paid_by = models.ForeignKey(Member, verbose_name=_('Paid by'), null=True,
on_delete=models.SET_NULL) amount = models.DecimalField(
costs_covered = models.BooleanField(verbose_name=_('Covered'), default=False) verbose_name=_("Amount"), max_digits=6, decimal_places=2, default=0
refunded = models.BooleanField(verbose_name=_('Refunded'), default=False) )
paid_by = models.ForeignKey(
proof = RestrictedFileField(verbose_name=_('Proof'), Member, verbose_name=_("Paid by"), null=True, on_delete=models.SET_NULL
upload_to='bill_images', )
blank=True, costs_covered = models.BooleanField(verbose_name=_("Covered"), default=False)
max_upload_size=5, refunded = models.BooleanField(verbose_name=_("Refunded"), default=False)
content_types=['application/pdf',
'image/jpeg', proof = RestrictedFileField(
'image/png', verbose_name=_("Proof"),
'image/gif']) upload_to="bill_images",
blank=True,
max_upload_size=5,
content_types=["application/pdf", "image/jpeg", "image/png", "image/gif"],
)
def __str__(self): def __str__(self):
return "{} ({}€)".format(self.short_description, self.amount) return "{} ({}€)".format(self.short_description, self.amount)
def pretty_amount(self): def pretty_amount(self):
return "{}".format(self.amount) return "{}".format(self.amount)
pretty_amount.admin_order_field = 'amount'
pretty_amount.short_description = _('Amount') pretty_amount.admin_order_field = "amount"
pretty_amount.short_description = _("Amount")
class Meta(CommonModel.Meta): class Meta(CommonModel.Meta):
verbose_name = _('Bill') verbose_name = _("Bill")
verbose_name_plural = _('Bills') verbose_name_plural = _("Bills")
class BillOnExcursionProxy(Bill): class BillOnExcursionProxy(Bill):
class Meta(CommonModel.Meta): class Meta(CommonModel.Meta):
proxy = True proxy = True
verbose_name = _('Bill') verbose_name = _("Bill")
verbose_name_plural = _('Bills') verbose_name_plural = _("Bills")
rules_permissions = { rules_permissions = {
'add_obj': leads_excursion & not_submitted, "add_obj": leads_excursion & not_submitted,
'view_obj': leads_excursion | has_global_perm('finance.view_global_billonexcursionproxy'), "view_obj": leads_excursion
'change_obj': (leads_excursion | has_global_perm('finance.change_global_billonexcursionproxy')) & not_submitted, | has_global_perm("finance.view_global_billonexcursionproxy"),
'delete_obj': (leads_excursion | has_global_perm('finance.delete_global_billonexcursionproxy')) & not_submitted, "change_obj": (
leads_excursion | has_global_perm("finance.change_global_billonexcursionproxy")
)
& not_submitted,
"delete_obj": (
leads_excursion | has_global_perm("finance.delete_global_billonexcursionproxy")
)
& not_submitted,
} }
class BillOnStatementProxy(Bill): class BillOnStatementProxy(Bill):
class Meta(CommonModel.Meta): class Meta(CommonModel.Meta):
proxy = True proxy = True
verbose_name = _('Bill') verbose_name = _("Bill")
verbose_name_plural = _('Bills') verbose_name_plural = _("Bills")
rules_permissions = { rules_permissions = {
'add_obj': (is_creator | leads_excursion) & not_submitted, "add_obj": (is_creator | leads_excursion) & not_submitted,
'view_obj': is_creator | leads_excursion | has_global_perm('finance.view_global_billonstatementproxy'), "view_obj": is_creator
'change_obj': (is_creator | leads_excursion | has_global_perm('finance.change_global_billonstatementproxy')) | leads_excursion
& (not_submitted | has_global_perm('finance.process_statementsubmitted')), | has_global_perm("finance.view_global_billonstatementproxy"),
'delete_obj': (is_creator | leads_excursion | has_global_perm('finance.delete_global_billonstatementproxy')) "change_obj": (
& not_submitted, is_creator
| leads_excursion
| has_global_perm("finance.change_global_billonstatementproxy")
)
& (not_submitted | has_global_perm("finance.process_statementsubmitted")),
"delete_obj": (
is_creator
| leads_excursion
| has_global_perm("finance.delete_global_billonstatementproxy")
)
& not_submitted,
} }
class Transaction(models.Model): class Transaction(models.Model):
reference = models.TextField(verbose_name=_('Reference')) reference = models.TextField(verbose_name=_("Reference"))
amount = models.DecimalField(max_digits=6, decimal_places=2, verbose_name=_('Amount')) amount = models.DecimalField(max_digits=6, decimal_places=2, verbose_name=_("Amount"))
member = models.ForeignKey(Member, verbose_name=_('Recipient'), member = models.ForeignKey(Member, verbose_name=_("Recipient"), on_delete=models.CASCADE)
on_delete=models.CASCADE) ledger = models.ForeignKey(
ledger = models.ForeignKey(Ledger, blank=False, null=True, default=None, verbose_name=_('Ledger'), Ledger,
on_delete=models.SET_NULL) blank=False,
null=True,
statement = models.ForeignKey(Statement, verbose_name=_('Statement'), default=None,
on_delete=models.CASCADE) verbose_name=_("Ledger"),
on_delete=models.SET_NULL,
confirmed = models.BooleanField(verbose_name=_('Paid'), default=False) )
confirmed_date = models.DateTimeField(verbose_name=_('Paid on'), default=None, null=True)
confirmed_by = models.ForeignKey(Member, verbose_name=_('Authorized by'), statement = models.ForeignKey(Statement, verbose_name=_("Statement"), on_delete=models.CASCADE)
blank=True,
null=True, confirmed = models.BooleanField(verbose_name=_("Paid"), default=False)
on_delete=models.SET_NULL, confirmed_date = models.DateTimeField(verbose_name=_("Paid on"), default=None, null=True)
related_name='confirmed_transactions') confirmed_by = models.ForeignKey(
Member,
verbose_name=_("Authorized by"),
blank=True,
null=True,
on_delete=models.SET_NULL,
related_name="confirmed_transactions",
)
def __str__(self): def __str__(self):
return "T#{}".format(self.pk) return "T#{}".format(self.pk)
@staticmethod @staticmethod
def escape_reference(reference): def escape_reference(reference):
umlaut_map = { umlaut_map = {"ä": "ae", "ö": "oe", "ü": "ue", "Ä": "Ae", "Ö": "Oe", "Ü": "Ue", "ß": "ss"}
'ä': 'ae', 'ö': 'oe', 'ü': 'ue', pattern = re.compile("|".join(umlaut_map.keys()))
'Ä': 'Ae', 'Ö': 'Oe', 'Ü': 'Ue',
'ß': 'ss'
}
pattern = re.compile('|'.join(umlaut_map.keys()))
int_reference = pattern.sub(lambda x: umlaut_map[x.group()], reference) int_reference = pattern.sub(lambda x: umlaut_map[x.group()], reference)
allowed_chars = r"[^a-z0-9 /?: .,'+-]" allowed_chars = r"[^a-z0-9 /?: .,'+-]"
clean_reference = re.sub(allowed_chars, '', int_reference, flags=re.IGNORECASE) clean_reference = re.sub(allowed_chars, "", int_reference, flags=re.IGNORECASE)
return clean_reference return clean_reference
def code(self): def code(self):
if self.amount == 0: if self.amount == 0:
return "" return ""
@ -796,7 +930,7 @@ class Transaction(models.Model):
bic = iban.bic bic = iban.bic
reference = self.escape_reference(self.reference) reference = self.escape_reference(self.reference)
# also escaping receiver as umlaute are also not allowed here # also escaping receiver as umlaute are also not allowed here
receiver = self.escape_reference(f"{self.member.prename} {self.member.lastname}") receiver = self.escape_reference(f"{self.member.prename} {self.member.lastname}")
return f"""BCD return f"""BCD
@ -812,13 +946,14 @@ EUR{self.amount}
{reference}""" {reference}"""
class Meta: class Meta:
verbose_name = _('Transaction') verbose_name = _("Transaction")
verbose_name_plural = _('Transactions') verbose_name_plural = _("Transactions")
class Receipt(models.Model): class Receipt(models.Model):
short_description = models.CharField(verbose_name=_('Short description'), max_length=30) short_description = models.CharField(verbose_name=_("Short description"), max_length=30)
ledger = models.ForeignKey(Ledger, blank=False, null=False, verbose_name=_('Ledger'), ledger = models.ForeignKey(
on_delete=models.CASCADE) Ledger, blank=False, null=False, verbose_name=_("Ledger"), on_delete=models.CASCADE
)
amount = models.DecimalField(max_digits=6, decimal_places=2) amount = models.DecimalField(max_digits=6, decimal_places=2)
comments = models.TextField() comments = models.TextField()

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

@ -81,7 +81,7 @@ links.forEach(link => {
imageContainer.innerHTML = ''; imageContainer.innerHTML = '';
// Update the image element // Update the image element
if(imageText == "") { if(imageText == "") {
imageContainer.innerHTML = '{% trans "No QR code can be displayed." %}'; imageContainer.innerHTML = '{% trans "No QR code can be displayed." %}';
} else { } else {
@ -99,7 +99,7 @@ links.forEach(link => {
link.text = '{% trans "Showing" %}'; link.text = '{% trans "Showing" %}';
}); });
}); });
</script> </script>

@ -124,9 +124,9 @@
{% if statement.ljp_to %} {% if statement.ljp_to %}
<h3>{% trans "LJP contributions" %}</h3> <h3>{% trans "LJP contributions" %}</h3>
<p> <p>
{% blocktrans %} The youth leaders have documented interventions worth of {{ total_seminar_days }} seminar {% blocktrans %} The youth leaders have documented interventions worth of {{ total_seminar_days }} seminar
days for {{ participant_count }} eligible participants. Taking into account the maximum contribution quota days for {{ participant_count }} eligible participants. Taking into account the maximum contribution quota
of 90% and possible taxes ({{ ljp_tax }}%), this results in a total of {{ paid_ljp_contributions }}€. of 90% and possible taxes ({{ ljp_tax }}%), this results in a total of {{ paid_ljp_contributions }}€.
Once their proposal was approved, the ljp contributions of should be paid to:{% endblocktrans %} Once their proposal was approved, the ljp contributions of should be paid to:{% endblocktrans %}
<table> <table>
<th> <th>

@ -78,7 +78,7 @@ Zuschüsse und Aufwandsentschädigung werden wie folgt abgerufen:
\noindent\textbf{LJP-Zuschüsse} \noindent\textbf{LJP-Zuschüsse}
\noindent Der LJP-Zuschuss für die Teilnehmenden in Höhe von {{ statement.paid_ljp_contributions|esc_all }} € wird überwiesen an: \noindent Der LJP-Zuschuss für die Teilnehmenden in Höhe von {{ statement.paid_ljp_contributions|esc_all }} € wird überwiesen an:
{{ statement.ljp_to.name|esc_all }} Dieser Zuschuss wird aus Landesmitteln gewährt und ist daher {{ statement.ljp_to.name|esc_all }} Dieser Zuschuss wird aus Landesmitteln gewährt und ist daher
in der Ausgabenübersicht gesondert aufgeführt. in der Ausgabenübersicht gesondert aufgeführt.
{% endif %} {% endif %}

@ -1,4 +1,6 @@
# ruff: noqa F403
from .admin import * from .admin import *
from .migrations import *
from .models import * from .models import *
from .rules import * from .rules import *
from .migrations import *

@ -1,32 +1,33 @@
import unittest
from http import HTTPStatus from http import HTTPStatus
from django.test import TestCase, override_settings
from django.contrib.admin.sites import AdminSite from django.contrib.admin.sites import AdminSite
from django.test import RequestFactory, Client
from django.contrib.auth.models import User, Permission
from django.contrib.auth import models as authmodels from django.contrib.auth import models as authmodels
from django.utils import timezone from django.contrib.auth.models import User
from django.contrib.sessions.middleware import SessionMiddleware from django.contrib.messages import get_messages
from django.contrib.messages.middleware import MessageMiddleware from django.contrib.messages.middleware import MessageMiddleware
from django.contrib.messages.storage.fallback import FallbackStorage from django.contrib.messages.storage.fallback import FallbackStorage
from django.contrib.messages import get_messages from django.contrib.sessions.middleware import SessionMiddleware
from django.test import Client
from django.test import RequestFactory
from django.test import TestCase
from django.urls import reverse
from django.utils import timezone
from django.utils.translation import gettext_lazy as _ from django.utils.translation import gettext_lazy as _
from django.urls import reverse, reverse_lazy from members.models import Freizeit
from django.http import HttpResponseRedirect, HttpResponse from members.models import GEMEINSCHAFTS_TOUR
from unittest.mock import Mock, patch from members.models import MALE
from django.test.utils import override_settings from members.models import Member
from django.urls import path, include from members.models import MUSKELKRAFT_ANREISE
from django.contrib import admin as django_admin
from members.tests.utils import create_custom_user from members.tests.utils import create_custom_user
from members.models import Member, MALE, Freizeit, GEMEINSCHAFTS_TOUR, MUSKELKRAFT_ANREISE
from ..models import ( from ..admin import StatementAdmin
Ledger, Statement, StatementUnSubmitted, StatementConfirmed, Transaction, Bill, from ..admin import TransactionAdmin
StatementSubmitted from ..models import Bill
) from ..models import Ledger
from ..admin import ( from ..models import Statement
LedgerAdmin, StatementAdmin, TransactionAdmin, BillAdmin from ..models import StatementConfirmed
) from ..models import StatementUnSubmitted
from ..models import Transaction
class AdminTestCase(TestCase): class AdminTestCase(TestCase):
@ -35,17 +36,15 @@ class AdminTestCase(TestCase):
self.model = model self.model = model
if model is not None and admin is not None: if model is not None and admin is not None:
self.admin = admin(model, AdminSite()) self.admin = admin(model, AdminSite())
superuser = User.objects.create_superuser( User.objects.create_superuser(username="superuser", password="secret")
username='superuser', password='secret' create_custom_user("standard", ["Standard"], "Paul", "Wulter")
) create_custom_user("trainer", ["Standard", "Trainings"], "Lise", "Lotte")
standard = create_custom_user('standard', ['Standard'], 'Paul', 'Wulter') create_custom_user("treasurer", ["Standard", "Finance"], "Lara", "Litte")
trainer = create_custom_user('trainer', ['Standard', 'Trainings'], 'Lise', 'Lotte') create_custom_user("materialwarden", ["Standard", "Material"], "Loro", "Lutte")
treasurer = create_custom_user('treasurer', ['Standard', 'Finance'], 'Lara', 'Litte')
materialwarden = create_custom_user('materialwarden', ['Standard', 'Material'], 'Loro', 'Lutte')
def _login(self, name): def _login(self, name):
c = Client() c = Client()
res = c.login(username=name, password='secret') res = c.login(username=name, password="secret")
# make sure we logged in # make sure we logged in
assert res assert res
return c return c
@ -57,62 +56,64 @@ class StatementUnSubmittedAdminTestCase(AdminTestCase):
def setUp(self): def setUp(self):
super().setUp(model=Statement, admin=StatementAdmin) super().setUp(model=Statement, admin=StatementAdmin)
self.superuser = User.objects.get(username='superuser') self.superuser = User.objects.get(username="superuser")
self.member = Member.objects.create( self.member = Member.objects.create(
prename="Test", lastname="User", birth_date=timezone.now().date(), prename="Test",
email="test@example.com", gender=MALE, user=self.superuser lastname="User",
birth_date=timezone.now().date(),
email="test@example.com",
gender=MALE,
user=self.superuser,
) )
self.statement = StatementUnSubmitted.objects.create( self.statement = StatementUnSubmitted.objects.create(
short_description='Test Statement', short_description="Test Statement", explanation="Test explanation", night_cost=25
explanation='Test explanation',
night_cost=25
) )
# Create excursion for testing # Create excursion for testing
self.excursion = Freizeit.objects.create( self.excursion = Freizeit.objects.create(
name='Test Excursion', name="Test Excursion",
kilometers_traveled=100, kilometers_traveled=100,
tour_type=GEMEINSCHAFTS_TOUR, tour_type=GEMEINSCHAFTS_TOUR,
tour_approach=MUSKELKRAFT_ANREISE, tour_approach=MUSKELKRAFT_ANREISE,
difficulty=1 difficulty=1,
) )
# Create confirmed statement with excursion # Create confirmed statement with excursion
self.statement_with_excursion = StatementUnSubmitted.objects.create( self.statement_with_excursion = StatementUnSubmitted.objects.create(
short_description='With Excursion', short_description="With Excursion",
explanation='Test explanation', explanation="Test explanation",
night_cost=25, night_cost=25,
excursion=self.excursion, excursion=self.excursion,
) )
def test_save_model_with_member(self): def test_save_model_with_member(self):
"""Test save_model sets created_by for new objects""" """Test save_model sets created_by for new objects"""
request = self.factory.post('/') request = self.factory.post("/")
request.user = self.superuser request.user = self.superuser
# Test with change=False (new object) # Test with change=False (new object)
new_statement = Statement(short_description='New Statement') new_statement = Statement(short_description="New Statement")
self.admin.save_model(request, new_statement, None, change=False) self.admin.save_model(request, new_statement, None, change=False)
self.assertEqual(new_statement.created_by, self.member) self.assertEqual(new_statement.created_by, self.member)
def test_has_delete_permission(self): def test_has_delete_permission(self):
"""Test if unsubmitted statements may be deleted""" """Test if unsubmitted statements may be deleted"""
request = self.factory.post('/') request = self.factory.post("/")
request.user = self.superuser request.user = self.superuser
self.assertTrue(self.admin.has_delete_permission(request, self.statement)) self.assertTrue(self.admin.has_delete_permission(request, self.statement))
def test_get_fields(self): def test_get_fields(self):
"""Test get_fields when excursion is set or not set.""" """Test get_fields when excursion is set or not set."""
request = self.factory.post('/') request = self.factory.post("/")
request.user = self.superuser request.user = self.superuser
self.assertIn('excursion', self.admin.get_fields(request, self.statement_with_excursion)) self.assertIn("excursion", self.admin.get_fields(request, self.statement_with_excursion))
self.assertNotIn('excursion', self.admin.get_fields(request, self.statement)) self.assertNotIn("excursion", self.admin.get_fields(request, self.statement))
self.assertNotIn('excursion', self.admin.get_fields(request)) self.assertNotIn("excursion", self.admin.get_fields(request))
def test_get_inlines(self): def test_get_inlines(self):
"""Test get_inlines""" """Test get_inlines"""
request = self.factory.post('/') request = self.factory.post("/")
request.user = self.superuser request.user = self.superuser
self.assertEqual(len(self.admin.get_inlines(request, self.statement)), 1) self.assertEqual(len(self.admin.get_inlines(request, self.statement)), 1)
@ -121,46 +122,44 @@ class StatementUnSubmittedAdminTestCase(AdminTestCase):
# Mark statement as submitted # Mark statement as submitted
self.statement.status = Statement.SUBMITTED self.statement.status = Statement.SUBMITTED
readonly_fields = self.admin.get_readonly_fields(None, self.statement) readonly_fields = self.admin.get_readonly_fields(None, self.statement)
self.assertIn('status', readonly_fields) self.assertIn("status", readonly_fields)
self.assertIn('excursion', readonly_fields) self.assertIn("excursion", readonly_fields)
self.assertIn('short_description', readonly_fields) self.assertIn("short_description", readonly_fields)
def test_get_readonly_fields_not_submitted(self): def test_get_readonly_fields_not_submitted(self):
"""Test readonly fields when statement is not submitted""" """Test readonly fields when statement is not submitted"""
readonly_fields = self.admin.get_readonly_fields(None, self.statement) readonly_fields = self.admin.get_readonly_fields(None, self.statement)
self.assertEqual(readonly_fields, ['status', 'excursion']) self.assertEqual(readonly_fields, ["status", "excursion"])
def test_submit_view_insufficient_permission(self): def test_submit_view_insufficient_permission(self):
url = reverse('admin:finance_statement_submit', url = reverse("admin:finance_statement_submit", args=(self.statement.pk,))
args=(self.statement.pk,)) c = self._login("standard")
c = self._login('standard')
response = c.get(url, follow=True) response = c.get(url, follow=True)
self.assertEqual(response.status_code, HTTPStatus.OK) self.assertEqual(response.status_code, HTTPStatus.OK)
self.assertContains(response, _('Insufficient permissions.')) self.assertContains(response, _("Insufficient permissions."))
def test_submit_view_get(self): def test_submit_view_get(self):
url = reverse('admin:finance_statement_submit', url = reverse("admin:finance_statement_submit", args=(self.statement.pk,))
args=(self.statement.pk,)) c = self._login("superuser")
c = self._login('superuser')
response = c.get(url, follow=True) response = c.get(url, follow=True)
self.assertEqual(response.status_code, HTTPStatus.OK) self.assertEqual(response.status_code, HTTPStatus.OK)
self.assertContains(response, _('Submit statement')) self.assertContains(response, _("Submit statement"))
def test_submit_view_get_with_excursion(self): def test_submit_view_get_with_excursion(self):
url = reverse('admin:finance_statement_submit', url = reverse("admin:finance_statement_submit", args=(self.statement_with_excursion.pk,))
args=(self.statement_with_excursion.pk,)) c = self._login("superuser")
c = self._login('superuser')
response = c.get(url, follow=True) response = c.get(url, follow=True)
self.assertEqual(response.status_code, HTTPStatus.OK) self.assertEqual(response.status_code, HTTPStatus.OK)
self.assertContains(response, _('Finance overview')) self.assertContains(response, _("Finance overview"))
def test_submit_view_post(self): def test_submit_view_post(self):
url = reverse('admin:finance_statement_submit', url = reverse("admin:finance_statement_submit", args=(self.statement.pk,))
args=(self.statement.pk,)) c = self._login("superuser")
c = self._login('superuser') response = c.post(url, follow=True, data={"apply": ""})
response = c.post(url, follow=True, data={'apply': ''})
self.assertEqual(response.status_code, HTTPStatus.OK) self.assertEqual(response.status_code, HTTPStatus.OK)
text = _("Successfully submited %(name)s. The finance department will notify the requestors as soon as possible.") % {'name': str(self.statement)} text = _(
"Successfully submited %(name)s. The finance department will notify the requestors as soon as possible."
) % {"name": str(self.statement)}
self.assertContains(response, text) self.assertContains(response, text)
@ -170,79 +169,86 @@ class StatementSubmittedAdminTestCase(AdminTestCase):
def setUp(self): def setUp(self):
super().setUp(model=Statement, admin=StatementAdmin) super().setUp(model=Statement, admin=StatementAdmin)
self.user = User.objects.create_user('testuser', 'test@example.com', 'pass') self.user = User.objects.create_user("testuser", "test@example.com", "pass")
self.member = Member.objects.create( self.member = Member.objects.create(
prename="Test", lastname="User", birth_date=timezone.now().date(), prename="Test",
email="test@example.com", gender=MALE, user=self.user lastname="User",
birth_date=timezone.now().date(),
email="test@example.com",
gender=MALE,
user=self.user,
) )
self.finance_user = User.objects.create_user('finance', 'finance@example.com', 'pass') self.finance_user = User.objects.create_user("finance", "finance@example.com", "pass")
self.finance_user.groups.add(authmodels.Group.objects.get(name='Finance'), self.finance_user.groups.add(
authmodels.Group.objects.get(name='Standard')) authmodels.Group.objects.get(name="Finance"),
authmodels.Group.objects.get(name="Standard"),
)
self.statement = Statement.objects.create( self.statement = Statement.objects.create(
short_description='Submitted Statement', short_description="Submitted Statement",
explanation='Test explanation', explanation="Test explanation",
status=Statement.SUBMITTED, status=Statement.SUBMITTED,
submitted_by=self.member, submitted_by=self.member,
submitted_date=timezone.now(), submitted_date=timezone.now(),
night_cost=25 night_cost=25,
) )
self.statement_unsubmitted = StatementUnSubmitted.objects.create( self.statement_unsubmitted = StatementUnSubmitted.objects.create(
short_description='Submitted Statement', short_description="Submitted Statement", explanation="Test explanation", night_cost=25
explanation='Test explanation',
night_cost=25
) )
self.transaction = Transaction.objects.create( self.transaction = Transaction.objects.create(
reference='verylonglong' * 14, reference="verylonglong" * 14,
amount=3, amount=3,
statement=self.statement, statement=self.statement,
member=self.member, member=self.member,
) )
# Create commonly used test objects # Create commonly used test objects
self.ledger = Ledger.objects.create(name='Test Ledger') self.ledger = Ledger.objects.create(name="Test Ledger")
self.excursion = Freizeit.objects.create( self.excursion = Freizeit.objects.create(
name='Test Excursion', name="Test Excursion",
kilometers_traveled=100, kilometers_traveled=100,
tour_type=GEMEINSCHAFTS_TOUR, tour_type=GEMEINSCHAFTS_TOUR,
tour_approach=MUSKELKRAFT_ANREISE, tour_approach=MUSKELKRAFT_ANREISE,
difficulty=1 difficulty=1,
) )
self.other_member = Member.objects.create( self.other_member = Member.objects.create(
prename="Other", lastname="Member", birth_date=timezone.now().date(), prename="Other",
email="other@example.com", gender=MALE lastname="Member",
birth_date=timezone.now().date(),
email="other@example.com",
gender=MALE,
) )
# Create statements for generate transactions tests # Create statements for generate transactions tests
self.statement_no_trans_success = Statement.objects.create( self.statement_no_trans_success = Statement.objects.create(
short_description='No Transactions Success', short_description="No Transactions Success",
explanation='Test explanation', explanation="Test explanation",
status=Statement.SUBMITTED, status=Statement.SUBMITTED,
submitted_by=self.member, submitted_by=self.member,
submitted_date=timezone.now(), submitted_date=timezone.now(),
night_cost=25 night_cost=25,
) )
self.statement_no_trans_error = Statement.objects.create( self.statement_no_trans_error = Statement.objects.create(
short_description='No Transactions Error', short_description="No Transactions Error",
explanation='Test explanation', explanation="Test explanation",
status=Statement.SUBMITTED, status=Statement.SUBMITTED,
submitted_by=self.member, submitted_by=self.member,
submitted_date=timezone.now(), submitted_date=timezone.now(),
night_cost=25 night_cost=25,
) )
# Create bills for generate transactions tests # Create bills for generate transactions tests
self.bill_for_success = Bill.objects.create( self.bill_for_success = Bill.objects.create(
statement=self.statement_no_trans_success, statement=self.statement_no_trans_success,
short_description='Test Bill Success', short_description="Test Bill Success",
amount=50, amount=50,
paid_by=self.member, paid_by=self.member,
costs_covered=True costs_covered=True,
) )
self.bill_for_error = Bill.objects.create( self.bill_for_error = Bill.objects.create(
statement=self.statement_no_trans_error, statement=self.statement_no_trans_error,
short_description='Test Bill Error', short_description="Test Bill Error",
amount=50, amount=50,
paid_by=None, # No payer will cause generate_transactions to fail paid_by=None, # No payer will cause generate_transactions to fail
costs_covered=True, costs_covered=True,
@ -252,57 +258,56 @@ class StatementSubmittedAdminTestCase(AdminTestCase):
"""Helper method to create a bill that matches transaction amount""" """Helper method to create a bill that matches transaction amount"""
return Bill.objects.create( return Bill.objects.create(
statement=statement or self.statement, statement=statement or self.statement,
short_description='Test Bill', short_description="Test Bill",
amount=amount or self.transaction.amount, amount=amount or self.transaction.amount,
paid_by=self.member, paid_by=self.member,
costs_covered=True costs_covered=True,
) )
def _create_non_matching_bill(self, statement=None, amount=100): def _create_non_matching_bill(self, statement=None, amount=100):
"""Helper method to create a bill that doesn't match transaction amount""" """Helper method to create a bill that doesn't match transaction amount"""
return Bill.objects.create( return Bill.objects.create(
statement=statement or self.statement, statement=statement or self.statement,
short_description='Non-matching Bill', short_description="Non-matching Bill",
amount=amount, amount=amount,
paid_by=self.member paid_by=self.member,
) )
def test_has_change_permission_with_permission(self): def test_has_change_permission_with_permission(self):
"""Test change permission with proper permission""" """Test change permission with proper permission"""
request = self.factory.get('/') request = self.factory.get("/")
request.user = self.finance_user request.user = self.finance_user
self.assertTrue(self.admin.has_change_permission(request)) self.assertTrue(self.admin.has_change_permission(request))
def test_has_change_permission_without_permission(self): def test_has_change_permission_without_permission(self):
"""Test change permission without proper permission""" """Test change permission without proper permission"""
request = self.factory.get('/') request = self.factory.get("/")
request.user = self.user request.user = self.user
self.assertFalse(self.admin.has_change_permission(request)) self.assertFalse(self.admin.has_change_permission(request))
def test_has_delete_permission(self): def test_has_delete_permission(self):
"""Test that delete permission is disabled""" """Test that delete permission is disabled"""
request = self.factory.get('/') request = self.factory.get("/")
request.user = self.finance_user request.user = self.finance_user
self.assertFalse(self.admin.has_delete_permission(request)) self.assertFalse(self.admin.has_delete_permission(request))
def test_readonly_fields(self): def test_readonly_fields(self):
self.assertNotIn('explanation', self.assertNotIn(
self.admin.get_readonly_fields(None, self.statement_unsubmitted)) "explanation", self.admin.get_readonly_fields(None, self.statement_unsubmitted)
)
def test_change(self): def test_change(self):
url = reverse('admin:finance_statement_change', url = reverse("admin:finance_statement_change", args=(self.statement.pk,))
args=(self.statement.pk,)) c = self._login("superuser")
c = self._login('superuser')
response = c.get(url) response = c.get(url)
self.assertEqual(response.status_code, HTTPStatus.OK) self.assertEqual(response.status_code, HTTPStatus.OK)
def test_overview_view(self): def test_overview_view(self):
url = reverse('admin:finance_statement_overview', url = reverse("admin:finance_statement_overview", args=(self.statement.pk,))
args=(self.statement.pk,)) c = self._login("superuser")
c = self._login('superuser')
response = c.get(url) response = c.get(url)
self.assertEqual(response.status_code, HTTPStatus.OK) self.assertEqual(response.status_code, HTTPStatus.OK)
self.assertContains(response, _('View submitted statement')) self.assertContains(response, _("View submitted statement"))
def test_overview_view_statement_not_found(self): def test_overview_view_statement_not_found(self):
"""Test overview_view with statement that can't be found in StatementSubmitted queryset""" """Test overview_view with statement that can't be found in StatementSubmitted queryset"""
@ -311,8 +316,8 @@ class StatementSubmittedAdminTestCase(AdminTestCase):
self.statement.status = Statement.UNSUBMITTED self.statement.status = Statement.UNSUBMITTED
self.statement.save() self.statement.save()
url = reverse('admin:finance_statement_overview', args=(self.statement.pk,)) url = reverse("admin:finance_statement_overview", args=(self.statement.pk,))
c = self._login('superuser') c = self._login("superuser")
response = c.get(url, follow=True) response = c.get(url, follow=True)
self.assertEqual(response.status_code, HTTPStatus.OK) self.assertEqual(response.status_code, HTTPStatus.OK)
messages = list(get_messages(response.wsgi_request)) messages = list(get_messages(response.wsgi_request))
@ -328,11 +333,13 @@ class StatementSubmittedAdminTestCase(AdminTestCase):
# Create a bill that matches the transaction amount to make it valid # Create a bill that matches the transaction amount to make it valid
self._create_matching_bill() self._create_matching_bill()
url = reverse('admin:finance_statement_overview', args=(self.statement.pk,)) url = reverse("admin:finance_statement_overview", args=(self.statement.pk,))
c = self._login('superuser') c = self._login("superuser")
response = c.post(url, follow=True, data={'transaction_execution_confirm': ''}) response = c.post(url, follow=True, data={"transaction_execution_confirm": ""})
self.assertEqual(response.status_code, HTTPStatus.OK) self.assertEqual(response.status_code, HTTPStatus.OK)
success_text = _("Successfully confirmed %(name)s. I hope you executed the associated transactions, I wont remind you again.") % {'name': str(self.statement)} success_text = _(
"Successfully confirmed %(name)s. I hope you executed the associated transactions, I wont remind you again."
) % {"name": str(self.statement)}
self.assertContains(response, success_text) self.assertContains(response, success_text)
self.statement.refresh_from_db() self.statement.refresh_from_db()
self.assertTrue(self.statement.confirmed) self.assertTrue(self.statement.confirmed)
@ -346,9 +353,9 @@ class StatementSubmittedAdminTestCase(AdminTestCase):
# Create a bill that matches the transaction amount to make it valid # Create a bill that matches the transaction amount to make it valid
self._create_matching_bill() self._create_matching_bill()
url = reverse('admin:finance_statement_overview', args=(self.statement.pk,)) url = reverse("admin:finance_statement_overview", args=(self.statement.pk,))
c = self._login('superuser') c = self._login("superuser")
response = c.post(url, follow=True, data={'transaction_execution_confirm_and_send': ''}) response = c.post(url, follow=True, data={"transaction_execution_confirm_and_send": ""})
self.assertEqual(response.status_code, HTTPStatus.OK) self.assertEqual(response.status_code, HTTPStatus.OK)
success_text = _("Successfully sent receipt to the office.") success_text = _("Successfully sent receipt to the office.")
self.assertContains(response, success_text) self.assertContains(response, success_text)
@ -363,24 +370,24 @@ class StatementSubmittedAdminTestCase(AdminTestCase):
# Create a bill that matches the transaction amount to make total valid # Create a bill that matches the transaction amount to make total valid
self._create_matching_bill() self._create_matching_bill()
url = reverse('admin:finance_statement_overview', url = reverse("admin:finance_statement_overview", args=(self.statement.pk,))
args=(self.statement.pk,)) c = self._login("superuser")
c = self._login('superuser') response = c.post(url, data={"confirm": ""})
response = c.post(url, data={'confirm': ''})
self.assertEqual(response.status_code, HTTPStatus.OK) self.assertEqual(response.status_code, HTTPStatus.OK)
self.assertContains(response, _('Statement confirmed')) self.assertContains(response, _("Statement confirmed"))
def test_overview_view_confirm_non_matching_transactions(self): def test_overview_view_confirm_non_matching_transactions(self):
"""Test overview_view confirm with non-matching transactions""" """Test overview_view confirm with non-matching transactions"""
# Create a bill that doesn't match the transaction # Create a bill that doesn't match the transaction
self._create_non_matching_bill() self._create_non_matching_bill()
url = reverse('admin:finance_statement_overview', url = reverse("admin:finance_statement_overview", args=(self.statement.pk,))
args=(self.statement.pk,)) c = self._login("superuser")
c = self._login('superuser') response = c.post(url, follow=True, data={"confirm": ""})
response = c.post(url, follow=True, data={'confirm': ''})
self.assertEqual(response.status_code, HTTPStatus.OK) self.assertEqual(response.status_code, HTTPStatus.OK)
error_text = _("Transactions do not match the covered expenses. Please correct the mistakes listed below.") error_text = _(
"Transactions do not match the covered expenses. Please correct the mistakes listed below."
)
self.assertContains(response, error_text) self.assertContains(response, error_text)
def test_overview_view_confirm_missing_ledger(self): def test_overview_view_confirm_missing_ledger(self):
@ -392,14 +399,15 @@ class StatementSubmittedAdminTestCase(AdminTestCase):
# Create a bill that matches the transaction amount to pass the first check # Create a bill that matches the transaction amount to pass the first check
self._create_matching_bill() self._create_matching_bill()
url = reverse('admin:finance_statement_overview', url = reverse("admin:finance_statement_overview", args=(self.statement.pk,))
args=(self.statement.pk,)) c = self._login("superuser")
c = self._login('superuser') response = c.post(url, follow=True, data={"confirm": ""})
response = c.post(url, follow=True, data={'confirm': ''})
self.assertEqual(response.status_code, HTTPStatus.OK) self.assertEqual(response.status_code, HTTPStatus.OK)
# Check the Django messages for the error # Check the Django messages for the error
messages = list(get_messages(response.wsgi_request)) messages = list(get_messages(response.wsgi_request))
expected_text = str(_("Some transactions have no ledger configured. Please fill in the gaps.")) expected_text = str(
_("Some transactions have no ledger configured. Please fill in the gaps.")
)
self.assertTrue(any(expected_text in str(msg) for msg in messages)) self.assertTrue(any(expected_text in str(msg) for msg in messages))
def test_overview_view_confirm_invalid_allowance_to(self): def test_overview_view_confirm_invalid_allowance_to(self):
@ -420,24 +428,30 @@ class StatementSubmittedAdminTestCase(AdminTestCase):
# Check validity obstruction is allowances # Check validity obstruction is allowances
self.assertEqual(self.statement_no_trans_success.validity, Statement.INVALID_ALLOWANCE_TO) self.assertEqual(self.statement_no_trans_success.validity, Statement.INVALID_ALLOWANCE_TO)
url = reverse('admin:finance_statement_overview', url = reverse(
args=(self.statement_no_trans_success.pk,)) "admin:finance_statement_overview", args=(self.statement_no_trans_success.pk,)
c = self._login('superuser') )
response = c.post(url, follow=True, data={'confirm': ''}) c = self._login("superuser")
response = c.post(url, follow=True, data={"confirm": ""})
self.assertEqual(response.status_code, HTTPStatus.OK) self.assertEqual(response.status_code, HTTPStatus.OK)
# Check the Django messages for the error # Check the Django messages for the error
messages = list(get_messages(response.wsgi_request)) messages = list(get_messages(response.wsgi_request))
expected_text = str(_("The configured recipients for the allowance don't match the regulations. Please correct this on the excursion.")) expected_text = str(
_(
"The configured recipients for the allowance don't match the regulations. Please correct this on the excursion."
)
)
self.assertTrue(any(expected_text in str(msg) for msg in messages)) self.assertTrue(any(expected_text in str(msg) for msg in messages))
def test_overview_view_reject(self): def test_overview_view_reject(self):
"""Test overview_view reject statement""" """Test overview_view reject statement"""
url = reverse('admin:finance_statement_overview', args=(self.statement.pk,)) url = reverse("admin:finance_statement_overview", args=(self.statement.pk,))
c = self._login('superuser') c = self._login("superuser")
response = c.post(url, follow=True, data={'reject': ''}) response = c.post(url, follow=True, data={"reject": ""})
self.assertEqual(response.status_code, HTTPStatus.OK) self.assertEqual(response.status_code, HTTPStatus.OK)
success_text = _("Successfully rejected %(name)s. The requestor can reapply, when needed.") %\ success_text = _(
{'name': str(self.statement)} "Successfully rejected %(name)s. The requestor can reapply, when needed."
) % {"name": str(self.statement)}
self.assertContains(response, success_text) self.assertContains(response, success_text)
# Verify statement was rejected # Verify statement was rejected
@ -449,45 +463,53 @@ class StatementSubmittedAdminTestCase(AdminTestCase):
# Ensure there's already a transaction # Ensure there's already a transaction
self.assertTrue(self.statement.transaction_set.count() > 0) self.assertTrue(self.statement.transaction_set.count() > 0)
url = reverse('admin:finance_statement_overview', args=(self.statement.pk,)) url = reverse("admin:finance_statement_overview", args=(self.statement.pk,))
c = self._login('superuser') c = self._login("superuser")
response = c.post(url, follow=True, data={'generate_transactions': ''}) response = c.post(url, follow=True, data={"generate_transactions": ""})
self.assertEqual(response.status_code, HTTPStatus.OK) self.assertEqual(response.status_code, HTTPStatus.OK)
error_text = _("%(name)s already has transactions. Please delete them first, if you want to generate new ones") % {'name': str(self.statement)} error_text = _(
"%(name)s already has transactions. Please delete them first, if you want to generate new ones"
) % {"name": str(self.statement)}
self.assertContains(response, error_text) self.assertContains(response, error_text)
def test_overview_view_generate_transactions_success(self): def test_overview_view_generate_transactions_success(self):
"""Test overview_view generate transactions successfully""" """Test overview_view generate transactions successfully"""
url = reverse('admin:finance_statement_overview', url = reverse(
args=(self.statement_no_trans_success.pk,)) "admin:finance_statement_overview", args=(self.statement_no_trans_success.pk,)
c = self._login('superuser') )
response = c.post(url, follow=True, data={'generate_transactions': ''}) c = self._login("superuser")
response = c.post(url, follow=True, data={"generate_transactions": ""})
self.assertEqual(response.status_code, HTTPStatus.OK) self.assertEqual(response.status_code, HTTPStatus.OK)
success_text = _("Successfully generated transactions for %(name)s") %\ success_text = _("Successfully generated transactions for %(name)s") % {
{'name': str(self.statement_no_trans_success)} "name": str(self.statement_no_trans_success)
}
self.assertContains(response, success_text) self.assertContains(response, success_text)
def test_overview_view_generate_transactions_error(self): def test_overview_view_generate_transactions_error(self):
"""Test overview_view generate transactions with error""" """Test overview_view generate transactions with error"""
url = reverse('admin:finance_statement_overview', url = reverse("admin:finance_statement_overview", args=(self.statement_no_trans_error.pk,))
args=(self.statement_no_trans_error.pk,)) c = self._login("superuser")
c = self._login('superuser') response = c.post(url, follow=True, data={"generate_transactions": ""})
response = c.post(url, follow=True, data={'generate_transactions': ''})
self.assertEqual(response.status_code, HTTPStatus.OK) self.assertEqual(response.status_code, HTTPStatus.OK)
messages = list(get_messages(response.wsgi_request)) messages = list(get_messages(response.wsgi_request))
expected_text = str(_("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?") %\ expected_text = str(
{'name': str(self.statement_no_trans_error)}) _(
"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(self.statement_no_trans_error)}
)
self.assertTrue(any(expected_text in str(msg) for msg in messages)) self.assertTrue(any(expected_text in str(msg) for msg in messages))
def test_reduce_transactions_view(self): def test_reduce_transactions_view(self):
url = reverse('admin:finance_statement_reduce_transactions', url = reverse("admin:finance_statement_reduce_transactions", args=(self.statement.pk,))
args=(self.statement.pk,)) c = self._login("superuser")
c = self._login('superuser') response = c.get(
response = c.get(url, data={'redirectTo': reverse('admin:finance_statement_changelist')}, url, data={"redirectTo": reverse("admin:finance_statement_changelist")}, follow=True
follow=True) )
self.assertContains(response, self.assertContains(
_("Successfully reduced transactions for %(name)s.") %\ response,
{'name': str(self.statement)}) _("Successfully reduced transactions for %(name)s.") % {"name": str(self.statement)},
)
class StatementConfirmedAdminTestCase(AdminTestCase): class StatementConfirmedAdminTestCase(AdminTestCase):
@ -496,24 +518,30 @@ class StatementConfirmedAdminTestCase(AdminTestCase):
def setUp(self): def setUp(self):
super().setUp(model=Statement, admin=StatementAdmin) super().setUp(model=Statement, admin=StatementAdmin)
self.user = User.objects.create_user('testuser', 'test@example.com', 'pass') self.user = User.objects.create_user("testuser", "test@example.com", "pass")
self.member = Member.objects.create( self.member = Member.objects.create(
prename="Test", lastname="User", birth_date=timezone.now().date(), prename="Test",
email="test@example.com", gender=MALE, user=self.user lastname="User",
birth_date=timezone.now().date(),
email="test@example.com",
gender=MALE,
user=self.user,
) )
self.finance_user = User.objects.create_user('finance', 'finance@example.com', 'pass') self.finance_user = User.objects.create_user("finance", "finance@example.com", "pass")
self.finance_user.groups.add(authmodels.Group.objects.get(name='Finance'), self.finance_user.groups.add(
authmodels.Group.objects.get(name='Standard')) authmodels.Group.objects.get(name="Finance"),
authmodels.Group.objects.get(name="Standard"),
)
# Create a base statement first # Create a base statement first
base_statement = Statement.objects.create( base_statement = Statement.objects.create(
short_description='Confirmed Statement', short_description="Confirmed Statement",
explanation='Test explanation', explanation="Test explanation",
status=Statement.CONFIRMED, status=Statement.CONFIRMED,
confirmed_by=self.member, confirmed_by=self.member,
confirmed_date=timezone.now(), confirmed_date=timezone.now(),
night_cost=25 night_cost=25,
) )
# StatementConfirmed is a proxy model, so we can get it from the base statement # StatementConfirmed is a proxy model, so we can get it from the base statement
@ -521,32 +549,34 @@ class StatementConfirmedAdminTestCase(AdminTestCase):
# Create an unconfirmed statement for testing # Create an unconfirmed statement for testing
self.unconfirmed_statement = Statement.objects.create( self.unconfirmed_statement = Statement.objects.create(
short_description='Unconfirmed Statement', short_description="Unconfirmed Statement",
explanation='Test explanation', explanation="Test explanation",
status=Statement.SUBMITTED, status=Statement.SUBMITTED,
night_cost=25 night_cost=25,
) )
# Create excursion for testing # Create excursion for testing
self.excursion = Freizeit.objects.create( self.excursion = Freizeit.objects.create(
name='Test Excursion', name="Test Excursion",
kilometers_traveled=100, kilometers_traveled=100,
tour_type=GEMEINSCHAFTS_TOUR, tour_type=GEMEINSCHAFTS_TOUR,
tour_approach=MUSKELKRAFT_ANREISE, tour_approach=MUSKELKRAFT_ANREISE,
difficulty=1 difficulty=1,
) )
# Create confirmed statement with excursion # Create confirmed statement with excursion
confirmed_with_excursion_base = Statement.objects.create( confirmed_with_excursion_base = Statement.objects.create(
short_description='Confirmed with Excursion', short_description="Confirmed with Excursion",
explanation='Test explanation', explanation="Test explanation",
status=Statement.CONFIRMED, status=Statement.CONFIRMED,
confirmed_by=self.member, confirmed_by=self.member,
confirmed_date=timezone.now(), confirmed_date=timezone.now(),
excursion=self.excursion, excursion=self.excursion,
night_cost=25 night_cost=25,
)
self.statement_with_excursion = StatementConfirmed.objects.get(
pk=confirmed_with_excursion_base.pk
) )
self.statement_with_excursion = StatementConfirmed.objects.get(pk=confirmed_with_excursion_base.pk)
def _add_session_to_request(self, request): def _add_session_to_request(self, request):
"""Add session to request""" """Add session to request"""
@ -560,20 +590,20 @@ class StatementConfirmedAdminTestCase(AdminTestCase):
def test_has_change_permission(self): def test_has_change_permission(self):
"""Test that change permission is disabled""" """Test that change permission is disabled"""
request = self.factory.get('/') request = self.factory.get("/")
request.user = self.finance_user request.user = self.finance_user
self.assertFalse(self.admin.has_change_permission(request, self.statement)) self.assertFalse(self.admin.has_change_permission(request, self.statement))
def test_has_delete_permission(self): def test_has_delete_permission(self):
"""Test that delete permission is disabled""" """Test that delete permission is disabled"""
request = self.factory.get('/') request = self.factory.get("/")
request.user = self.finance_user request.user = self.finance_user
self.assertFalse(self.admin.has_delete_permission(request, self.statement)) self.assertFalse(self.admin.has_delete_permission(request, self.statement))
def test_unconfirm_view_not_confirmed_statement(self): def test_unconfirm_view_not_confirmed_statement(self):
"""Test unconfirm_view with statement that is not confirmed""" """Test unconfirm_view with statement that is not confirmed"""
# Create request for unconfirmed statement # Create request for unconfirmed statement
request = self.factory.get('/') request = self.factory.get("/")
request.user = self.finance_user request.user = self.finance_user
self._add_session_to_request(request) self._add_session_to_request(request)
@ -589,7 +619,7 @@ class StatementConfirmedAdminTestCase(AdminTestCase):
def test_unconfirm_view_post_unconfirm_action(self): def test_unconfirm_view_post_unconfirm_action(self):
"""Test unconfirm_view POST request with 'unconfirm' action""" """Test unconfirm_view POST request with 'unconfirm' action"""
# Create POST request with unconfirm action # Create POST request with unconfirm action
request = self.factory.post('/', {'unconfirm': 'true'}) request = self.factory.post("/", {"unconfirm": "true"})
request.user = self.finance_user request.user = self.finance_user
self._add_session_to_request(request) self._add_session_to_request(request)
@ -612,7 +642,7 @@ class StatementConfirmedAdminTestCase(AdminTestCase):
def test_unconfirm_view_get_render_template(self): def test_unconfirm_view_get_render_template(self):
"""Test unconfirm_view GET request rendering template""" """Test unconfirm_view GET request rendering template"""
# Create GET request (no POST data) # Create GET request (no POST data)
request = self.factory.get('/') request = self.factory.get("/")
request.user = self.finance_user request.user = self.finance_user
self._add_session_to_request(request) self._add_session_to_request(request)
@ -626,33 +656,30 @@ class StatementConfirmedAdminTestCase(AdminTestCase):
self.assertEqual(response.status_code, 200) self.assertEqual(response.status_code, 200)
# Check response content contains expected template elements # Check response content contains expected template elements
self.assertIn(str(_('Unconfirm statement')).encode('utf-8'), response.content) self.assertIn(str(_("Unconfirm statement")).encode("utf-8"), response.content)
self.assertIn(self.statement.short_description.encode(), response.content) self.assertIn(self.statement.short_description.encode(), response.content)
def test_statement_summary_view_insufficient_permission(self): def test_statement_summary_view_insufficient_permission(self):
url = reverse('admin:finance_statement_summary', url = reverse("admin:finance_statement_summary", args=(self.statement_with_excursion.pk,))
args=(self.statement_with_excursion.pk,)) c = self._login("standard")
c = self._login('standard')
response = c.get(url, follow=True) response = c.get(url, follow=True)
self.assertEqual(response.status_code, HTTPStatus.OK) self.assertEqual(response.status_code, HTTPStatus.OK)
self.assertContains(response, _('Insufficient permissions.')) self.assertContains(response, _("Insufficient permissions."))
def test_statement_summary_view_unconfirmed(self): def test_statement_summary_view_unconfirmed(self):
url = reverse('admin:finance_statement_summary', url = reverse("admin:finance_statement_summary", args=(self.unconfirmed_statement.pk,))
args=(self.unconfirmed_statement.pk,)) c = self._login("superuser")
c = self._login('superuser')
response = c.get(url, follow=True) response = c.get(url, follow=True)
self.assertEqual(response.status_code, HTTPStatus.OK) self.assertEqual(response.status_code, HTTPStatus.OK)
self.assertContains(response, _('Statement not found.')) self.assertContains(response, _("Statement not found."))
def test_statement_summary_view_confirmed_with_excursion(self): def test_statement_summary_view_confirmed_with_excursion(self):
"""Test statement_summary_view when statement is confirmed with excursion""" """Test statement_summary_view when statement is confirmed with excursion"""
url = reverse('admin:finance_statement_summary', url = reverse("admin:finance_statement_summary", args=(self.statement_with_excursion.pk,))
args=(self.statement_with_excursion.pk,)) c = self._login("superuser")
c = self._login('superuser')
response = c.get(url, follow=True) response = c.get(url, follow=True)
self.assertEqual(response.status_code, HTTPStatus.OK) self.assertEqual(response.status_code, HTTPStatus.OK)
self.assertEqual(response.headers['Content-Type'], 'application/pdf') self.assertEqual(response.headers["Content-Type"], "application/pdf")
class TransactionAdminTestCase(TestCase): class TransactionAdminTestCase(TestCase):
@ -663,41 +690,44 @@ class TransactionAdminTestCase(TestCase):
self.factory = RequestFactory() self.factory = RequestFactory()
self.admin = TransactionAdmin(Transaction, self.site) self.admin = TransactionAdmin(Transaction, self.site)
self.user = User.objects.create_user('testuser', 'test@example.com', 'pass') self.user = User.objects.create_user("testuser", "test@example.com", "pass")
self.member = Member.objects.create( self.member = Member.objects.create(
prename="Test", lastname="User", birth_date=timezone.now().date(), prename="Test",
email="test@example.com", gender=MALE, user=self.user lastname="User",
birth_date=timezone.now().date(),
email="test@example.com",
gender=MALE,
user=self.user,
) )
self.ledger = Ledger.objects.create(name='Test Ledger') self.ledger = Ledger.objects.create(name="Test Ledger")
self.statement = Statement.objects.create( self.statement = Statement.objects.create(
short_description='Test Statement', short_description="Test Statement", explanation="Test explanation"
explanation='Test explanation'
) )
self.transaction = Transaction.objects.create( self.transaction = Transaction.objects.create(
member=self.member, member=self.member,
ledger=self.ledger, ledger=self.ledger,
amount=100, amount=100,
reference='Test transaction', reference="Test transaction",
statement=self.statement statement=self.statement,
) )
def test_has_add_permission(self): def test_has_add_permission(self):
"""Test that add permission is disabled""" """Test that add permission is disabled"""
request = self.factory.get('/') request = self.factory.get("/")
request.user = self.user request.user = self.user
self.assertFalse(self.admin.has_add_permission(request)) self.assertFalse(self.admin.has_add_permission(request))
def test_has_change_permission(self): def test_has_change_permission(self):
"""Test that change permission is disabled""" """Test that change permission is disabled"""
request = self.factory.get('/') request = self.factory.get("/")
request.user = self.user request.user = self.user
self.assertFalse(self.admin.has_change_permission(request)) self.assertFalse(self.admin.has_change_permission(request))
def test_has_delete_permission(self): def test_has_delete_permission(self):
"""Test that delete permission is disabled""" """Test that delete permission is disabled"""
request = self.factory.get('/') request = self.factory.get("/")
request.user = self.user request.user = self.user
self.assertFalse(self.admin.has_delete_permission(request)) self.assertFalse(self.admin.has_delete_permission(request))

@ -1,5 +1,4 @@
import django.test import django.test
from django.apps import apps
from django.db import connection from django.db import connection
from django.db.migrations.executor import MigrationExecutor from django.db.migrations.executor import MigrationExecutor
@ -7,9 +6,9 @@ from django.db.migrations.executor import MigrationExecutor
class StatusMigrationTestCase(django.test.TransactionTestCase): class StatusMigrationTestCase(django.test.TransactionTestCase):
"""Test the migration from submitted/confirmed fields to status field.""" """Test the migration from submitted/confirmed fields to status field."""
app = 'finance' app = "finance"
migrate_from = [('finance', '0009_statement_ljp_to')] migrate_from = [("finance", "0009_statement_ljp_to")]
migrate_to = [('finance', '0010_statement_status')] migrate_to = [("finance", "0010_statement_status")]
def setUp(self): def setUp(self):
# Get the state before migration # Get the state before migration
@ -18,26 +17,20 @@ class StatusMigrationTestCase(django.test.TransactionTestCase):
# Get the old models (before migration) # Get the old models (before migration)
old_apps = executor.loader.project_state(self.migrate_from).apps old_apps = executor.loader.project_state(self.migrate_from).apps
self.Statement = old_apps.get_model(self.app, 'Statement') self.Statement = old_apps.get_model(self.app, "Statement")
# Create statements with different combinations of submitted/confirmed # Create statements with different combinations of submitted/confirmed
# created_by is nullable, so we don't need to create a Member # created_by is nullable, so we don't need to create a Member
self.unsubmitted = self.Statement.objects.create( self.unsubmitted = self.Statement.objects.create(
short_description='Unsubmitted Statement', short_description="Unsubmitted Statement", submitted=False, confirmed=False
submitted=False,
confirmed=False
) )
self.submitted = self.Statement.objects.create( self.submitted = self.Statement.objects.create(
short_description='Submitted Statement', short_description="Submitted Statement", submitted=True, confirmed=False
submitted=True,
confirmed=False
) )
self.confirmed = self.Statement.objects.create( self.confirmed = self.Statement.objects.create(
short_description='Confirmed Statement', short_description="Confirmed Statement", submitted=True, confirmed=True
submitted=True,
confirmed=True
) )
def test_status_field_migration(self): def test_status_field_migration(self):
@ -49,7 +42,7 @@ class StatusMigrationTestCase(django.test.TransactionTestCase):
# Get the new models (after migration) # Get the new models (after migration)
new_apps = executor.loader.project_state(self.migrate_to).apps new_apps = executor.loader.project_state(self.migrate_to).apps
Statement = new_apps.get_model(self.app, 'Statement') Statement = new_apps.get_model(self.app, "Statement")
# Constants from the Statement model # Constants from the Statement model
UNSUBMITTED = 0 UNSUBMITTED = 0
@ -58,13 +51,22 @@ class StatusMigrationTestCase(django.test.TransactionTestCase):
# Verify the migration worked correctly # Verify the migration worked correctly
unsubmitted = Statement.objects.get(pk=self.unsubmitted.pk) unsubmitted = Statement.objects.get(pk=self.unsubmitted.pk)
self.assertEqual(unsubmitted.status, UNSUBMITTED, self.assertEqual(
'Statement with submitted=False, confirmed=False should have status=UNSUBMITTED') unsubmitted.status,
UNSUBMITTED,
"Statement with submitted=False, confirmed=False should have status=UNSUBMITTED",
)
submitted = Statement.objects.get(pk=self.submitted.pk) submitted = Statement.objects.get(pk=self.submitted.pk)
self.assertEqual(submitted.status, SUBMITTED, self.assertEqual(
'Statement with submitted=True, confirmed=False should have status=SUBMITTED') submitted.status,
SUBMITTED,
"Statement with submitted=True, confirmed=False should have status=SUBMITTED",
)
confirmed = Statement.objects.get(pk=self.confirmed.pk) confirmed = Statement.objects.get(pk=self.confirmed.pk)
self.assertEqual(confirmed.status, CONFIRMED, self.assertEqual(
'Statement with submitted=True, confirmed=True should have status=CONFIRMED') confirmed.status,
CONFIRMED,
"Statement with submitted=True, confirmed=True should have status=CONFIRMED",
)

File diff suppressed because it is too large Load Diff

@ -1,11 +1,21 @@
from django.test import TestCase from unittest.mock import Mock
from django.utils import timezone
from django.conf import settings from django.conf import settings
from django.contrib.auth.models import User from django.contrib.auth.models import User
from unittest.mock import Mock from django.test import TestCase
from finance.rules import is_creator, not_submitted, leads_excursion from django.utils import timezone
from finance.models import Statement, Ledger from finance.models import Ledger
from members.models import Member, Group, Freizeit, GEMEINSCHAFTS_TOUR, MUSKELKRAFT_ANREISE, MALE, FEMALE from finance.models import Statement
from finance.rules import is_creator
from finance.rules import leads_excursion
from finance.rules import not_submitted
from members.models import FEMALE
from members.models import Freizeit
from members.models import GEMEINSCHAFTS_TOUR
from members.models import Group
from members.models import MALE
from members.models import Member
from members.models import MUSKELKRAFT_ANREISE
class FinanceRulesTestCase(TestCase): class FinanceRulesTestCase(TestCase):
@ -15,15 +25,23 @@ class FinanceRulesTestCase(TestCase):
self.user1 = User.objects.create_user(username="alice", password="test123") self.user1 = User.objects.create_user(username="alice", password="test123")
self.member1 = Member.objects.create( self.member1 = Member.objects.create(
prename="Alice", lastname="Smith", birth_date=timezone.now().date(), prename="Alice",
email=settings.TEST_MAIL, gender=FEMALE, user=self.user1 lastname="Smith",
birth_date=timezone.now().date(),
email=settings.TEST_MAIL,
gender=FEMALE,
user=self.user1,
) )
self.member1.group.add(self.group) self.member1.group.add(self.group)
self.user2 = User.objects.create_user(username="bob", password="test123") self.user2 = User.objects.create_user(username="bob", password="test123")
self.member2 = Member.objects.create( self.member2 = Member.objects.create(
prename="Bob", lastname="Jones", birth_date=timezone.now().date(), prename="Bob",
email=settings.TEST_MAIL, gender=MALE, user=self.user2 lastname="Jones",
birth_date=timezone.now().date(),
email=settings.TEST_MAIL,
gender=MALE,
user=self.user2,
) )
self.member2.group.add(self.group) self.member2.group.add(self.group)
@ -32,7 +50,7 @@ class FinanceRulesTestCase(TestCase):
kilometers_traveled=100, kilometers_traveled=100,
tour_type=GEMEINSCHAFTS_TOUR, tour_type=GEMEINSCHAFTS_TOUR,
tour_approach=MUSKELKRAFT_ANREISE, tour_approach=MUSKELKRAFT_ANREISE,
difficulty=2 difficulty=2,
) )
self.freizeit.jugendleiter.add(self.member1) self.freizeit.jugendleiter.add(self.member1)
@ -41,7 +59,7 @@ class FinanceRulesTestCase(TestCase):
explanation="Test explanation", explanation="Test explanation",
night_cost=27, night_cost=27,
created_by=self.member1, created_by=self.member1,
excursion=self.freizeit excursion=self.freizeit,
) )
self.statement.allowance_to.add(self.member1) self.statement.allowance_to.add(self.member1)
@ -68,8 +86,8 @@ class FinanceRulesTestCase(TestCase):
# Create a mock Freizeit that truly doesn't have the statement attribute # Create a mock Freizeit that truly doesn't have the statement attribute
mock_freizeit = Mock(spec=Freizeit) mock_freizeit = Mock(spec=Freizeit)
# Remove the statement attribute entirely # Remove the statement attribute entirely
if hasattr(mock_freizeit, 'statement'): if hasattr(mock_freizeit, "statement"):
delattr(mock_freizeit, 'statement') delattr(mock_freizeit, "statement")
self.assertTrue(not_submitted(self.user1, mock_freizeit)) self.assertTrue(not_submitted(self.user1, mock_freizeit))
def test_leads_excursion_freizeit_user_is_leader(self): def test_leads_excursion_freizeit_user_is_leader(self):
@ -96,7 +114,7 @@ class FinanceRulesTestCase(TestCase):
explanation="Test explanation", explanation="Test explanation",
night_cost=27, night_cost=27,
created_by=self.member1, created_by=self.member1,
excursion=None excursion=None,
) )
result = leads_excursion(self.user1, statement_no_excursion) result = leads_excursion(self.user1, statement_no_excursion)
self.assertFalse(result) self.assertFalse(result)

@ -1,3 +1,3 @@
from .celery import app as celery_app from .celery import app as celery_app
__all__ = ('celery_app',) __all__ = ("celery_app",)

@ -1,14 +1,13 @@
import os import os
from celery import Celery
from django.conf import settings from celery import Celery
# set the default Django settings module for the 'celery' program. # set the default Django settings module for the 'celery' program.
os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'jdav_web.settings') os.environ.setdefault("DJANGO_SETTINGS_MODULE", "jdav_web.settings")
app = Celery() app = Celery()
app.config_from_object('django.conf:settings') app.config_from_object("django.conf:settings")
app.autodiscover_tasks() app.autodiscover_tasks()
if __name__ == '__main__': if __name__ == "__main__":
app.start() # pragma: no cover app.start() # pragma: no cover

@ -2,6 +2,6 @@ class ForceLangMiddleware:
def __init__(self, get_response): def __init__(self, get_response):
self.get_response = get_response self.get_response = get_response
def __call__(self,request): def __call__(self, request):
request.META['HTTP_ACCEPT_LANGUAGE'] = "de" request.META["HTTP_ACCEPT_LANGUAGE"] = "de"
return self.get_response(request) return self.get_response(request)

@ -10,25 +10,26 @@ For the full list of settings and their values, see
https://docs.djangoproject.com/en/1.10/ref/settings/ https://docs.djangoproject.com/en/1.10/ref/settings/
""" """
from split_settings.tools import optional, include
import os import os
import tomli import tomli
from split_settings.tools import include
CONFIG_DIR_PATH = os.environ.get('KOMPASS_CONFIG_DIR_PATH', '') CONFIG_DIR_PATH = os.environ.get("KOMPASS_CONFIG_DIR_PATH", "")
SETTINGS_FILE = os.environ.get('KOMPASS_SETTINGS_FILE', 'settings.toml') SETTINGS_FILE = os.environ.get("KOMPASS_SETTINGS_FILE", "settings.toml")
TEXTS_FILE = os.environ.get('KOMPASS_TEXTS_FILE', 'texts.toml') TEXTS_FILE = os.environ.get("KOMPASS_TEXTS_FILE", "texts.toml")
with open(os.path.join(CONFIG_DIR_PATH, SETTINGS_FILE), 'rb') as f: with open(os.path.join(CONFIG_DIR_PATH, SETTINGS_FILE), "rb") as f:
config = tomli.load(f) config = tomli.load(f)
if os.path.exists(os.path.join(CONFIG_DIR_PATH, TEXTS_FILE)): if os.path.exists(os.path.join(CONFIG_DIR_PATH, TEXTS_FILE)):
with open(os.path.join(CONFIG_DIR_PATH, TEXTS_FILE), 'rb') as f: with open(os.path.join(CONFIG_DIR_PATH, TEXTS_FILE), "rb") as f:
texts = tomli.load(f) texts = tomli.load(f)
else: else:
texts = {} # pragma: no cover texts = {} # pragma: no cover
def get_var(*keys, default='', dictionary=config): def get_var(*keys, default="", dictionary=config):
""" """
Get a variable from given config dictionary. The passed keys are used Get a variable from given config dictionary. The passed keys are used
for nested retrieval from the dictionary. for nested retrieval from the dictionary.
@ -42,7 +43,7 @@ def get_var(*keys, default='', dictionary=config):
return cfg return cfg
def get_text(*keys, default=''): def get_text(*keys, default=""):
""" """
Get a text from the `texts.toml`. Get a text from the `texts.toml`.
""" """
@ -50,16 +51,16 @@ def get_text(*keys, default=''):
base_settings = [ base_settings = [
'local.py', "local.py",
'components/base.py', "components/base.py",
'components/database.py', "components/database.py",
'components/cache.py', "components/cache.py",
'components/jet.py', "components/jet.py",
'components/emails.py', "components/emails.py",
'components/texts.py', "components/texts.py",
'components/locale.py', "components/locale.py",
'components/logging.py', "components/logging.py",
'components/oauth.py', "components/oauth.py",
] ]
include(*base_settings) include(*base_settings)

@ -1,28 +1,31 @@
deployed = get_var('django', 'deployed', default=False) # ruff: noqa F821
deployed = get_var("django", "deployed", default=False)
# Build paths inside the project like this: os.path.join(BASE_DIR, ...) # Build paths inside the project like this: os.path.join(BASE_DIR, ...)
BASE_DIR = os.path.dirname(os.path.dirname(os.path.dirname(os.path.abspath(__file__)))) BASE_DIR = os.path.dirname(os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
# SECURITY WARNING: keep the secret key used in production secret! # SECURITY WARNING: keep the secret key used in production secret!
SECRET_KEY = get_var('django', 'secret_key', default='secret') SECRET_KEY = get_var("django", "secret_key", default="secret")
# SECURITY WARNING: don't run with debug turned on in production! # SECURITY WARNING: don't run with debug turned on in production!
DEBUG = get_var('django', 'debug', default=True) DEBUG = get_var("django", "debug", default=True)
ALLOWED_HOSTS = get_var('django', 'allowed_hosts', default=["*"]) ALLOWED_HOSTS = get_var("django", "allowed_hosts", default=["*"])
# hostname and base url # hostname and base url
HOST = get_var('django', 'host', default='localhost:8000') HOST = get_var("django", "host", default="localhost:8000")
PROTOCOL = get_var('django', 'protocol', default='https') PROTOCOL = get_var("django", "protocol", default="https")
BASE_URL = get_var('django', 'base_url', default=HOST) BASE_URL = get_var("django", "base_url", default=HOST)
# Define media paths e.g. for image storage # Define media paths e.g. for image storage
MEDIA_URL = '/media/' MEDIA_URL = "/media/"
MEDIA_ROOT = get_var('django', 'media_root', MEDIA_ROOT = get_var(
default=os.path.join((os.path.join(BASE_DIR, os.pardir)), "media")) "django", "media_root", default=os.path.join((os.path.join(BASE_DIR, os.pardir)), "media")
)
# default primary key auto field type # default primary key auto field type
DEFAULT_AUTO_FIELD = 'django.db.models.AutoField' DEFAULT_AUTO_FIELD = "django.db.models.AutoField"
# prevent large files from being unreadable by the server # prevent large files from being unreadable by the server
# see # see
@ -36,71 +39,70 @@ USE_X_FORWARDED_HOST = True
# Application definition # Application definition
INSTALLED_APPS = [ INSTALLED_APPS = [
'logindata.apps.LoginDataConfig', "logindata.apps.LoginDataConfig",
'contrib.apps.ContribConfig', "contrib.apps.ContribConfig",
'startpage.apps.StartpageConfig', "startpage.apps.StartpageConfig",
'material.apps.MaterialConfig', "material.apps.MaterialConfig",
'members.apps.MembersConfig', "members.apps.MembersConfig",
'mailer.apps.MailerConfig', "mailer.apps.MailerConfig",
'finance.apps.FinanceConfig', "finance.apps.FinanceConfig",
'ludwigsburgalpin.apps.LudwigsburgalpinConfig', "ludwigsburgalpin.apps.LudwigsburgalpinConfig",
#'easy_select2', #'easy_select2',
'markdownify.apps.MarkdownifyConfig', "markdownify.apps.MarkdownifyConfig",
'markdownx', "markdownx",
'djcelery_email', "djcelery_email",
'nested_admin', "nested_admin",
'django_celery_beat', "django_celery_beat",
'rules', "rules",
'jet', "jet",
'oauth2_provider', "oauth2_provider",
'django.contrib.admin', "django.contrib.admin",
'django.contrib.auth', "django.contrib.auth",
'django.contrib.contenttypes', "django.contrib.contenttypes",
'django.contrib.sessions', "django.contrib.sessions",
'django.contrib.messages', "django.contrib.messages",
'django.contrib.staticfiles', "django.contrib.staticfiles",
] ]
MIDDLEWARE = [ MIDDLEWARE = [
'django.middleware.cache.UpdateCacheMiddleware', "django.middleware.cache.UpdateCacheMiddleware",
'jdav_web.middleware.ForceLangMiddleware', "jdav_web.middleware.ForceLangMiddleware",
'django.middleware.security.SecurityMiddleware', "django.middleware.security.SecurityMiddleware",
'django.contrib.sessions.middleware.SessionMiddleware', "django.contrib.sessions.middleware.SessionMiddleware",
'django.middleware.locale.LocaleMiddleware', "django.middleware.locale.LocaleMiddleware",
'django.middleware.common.CommonMiddleware', "django.middleware.common.CommonMiddleware",
'django.middleware.csrf.CsrfViewMiddleware', "django.middleware.csrf.CsrfViewMiddleware",
'django.contrib.auth.middleware.AuthenticationMiddleware', "django.contrib.auth.middleware.AuthenticationMiddleware",
'django.contrib.messages.middleware.MessageMiddleware', "django.contrib.messages.middleware.MessageMiddleware",
'django.middleware.clickjacking.XFrameOptionsMiddleware', "django.middleware.clickjacking.XFrameOptionsMiddleware",
'django.middleware.cache.FetchFromCacheMiddleware', "django.middleware.cache.FetchFromCacheMiddleware",
] ]
X_FRAME_OPTIONS = 'SAMEORIGIN' X_FRAME_OPTIONS = "SAMEORIGIN"
ROOT_URLCONF = 'jdav_web.urls' ROOT_URLCONF = "jdav_web.urls"
TEMPLATES = [ TEMPLATES = [
{ {
'BACKEND': 'django.template.backends.django.DjangoTemplates', "BACKEND": "django.template.backends.django.DjangoTemplates",
'DIRS': [os.path.join(CONFIG_DIR_PATH, 'templates'), "DIRS": [os.path.join(CONFIG_DIR_PATH, "templates"), os.path.join(BASE_DIR, "templates")],
os.path.join(BASE_DIR, 'templates')], "APP_DIRS": True,
'APP_DIRS': True, "OPTIONS": {
'OPTIONS': { "context_processors": [
'context_processors': [ "django.template.context_processors.debug",
'django.template.context_processors.debug', "django.template.context_processors.request",
'django.template.context_processors.request', "django.contrib.auth.context_processors.auth",
'django.contrib.auth.context_processors.auth', "django.contrib.messages.context_processors.messages",
'django.contrib.messages.context_processors.messages',
], ],
}, },
}, },
] ]
WSGI_APPLICATION = 'jdav_web.wsgi.application' WSGI_APPLICATION = "jdav_web.wsgi.application"
AUTHENTICATION_BACKENDS = ( AUTHENTICATION_BACKENDS = (
'django.contrib.auth.backends.ModelBackend', "django.contrib.auth.backends.ModelBackend",
'rules.permissions.ObjectPermissionBackend', "rules.permissions.ObjectPermissionBackend",
) )
# Password validation # Password validation
@ -108,23 +110,23 @@ AUTHENTICATION_BACKENDS = (
AUTH_PASSWORD_VALIDATORS = [ AUTH_PASSWORD_VALIDATORS = [
{ {
'NAME': 'django.contrib.auth.password_validation.UserAttributeSimilarityValidator', "NAME": "django.contrib.auth.password_validation.UserAttributeSimilarityValidator",
}, },
{ {
'NAME': 'django.contrib.auth.password_validation.MinimumLengthValidator', "NAME": "django.contrib.auth.password_validation.MinimumLengthValidator",
}, },
{ {
'NAME': 'django.contrib.auth.password_validation.CommonPasswordValidator', "NAME": "django.contrib.auth.password_validation.CommonPasswordValidator",
}, },
{ {
'NAME': 'django.contrib.auth.password_validation.NumericPasswordValidator', "NAME": "django.contrib.auth.password_validation.NumericPasswordValidator",
}, },
] ]
# Static files (CSS, JavaScript, Images) # Static files (CSS, JavaScript, Images)
# https://docs.djangoproject.com/en/1.10/howto/static-files/ # https://docs.djangoproject.com/en/1.10/howto/static-files/
STATIC_URL = '/static/' STATIC_URL = "/static/"
STATICFILES_DIRS = [ STATICFILES_DIRS = [
os.path.join(CONFIG_DIR_PATH, "static"), os.path.join(CONFIG_DIR_PATH, "static"),
os.path.join(BASE_DIR, "static"), os.path.join(BASE_DIR, "static"),
@ -132,70 +134,76 @@ STATICFILES_DIRS = [
# static root where all the static files are collected to # static root where all the static files are collected to
# use python3 manage.py collectstatic to collect static files in the STATIC_ROOT # use python3 manage.py collectstatic to collect static files in the STATIC_ROOT
# this is needed for deployment # this is needed for deployment
STATIC_ROOT = get_var('django', 'static_root', default='/var/www/jdav_web/static') STATIC_ROOT = get_var("django", "static_root", default="/var/www/jdav_web/static")
DEFAULT_STATIC_PATH = get_var('django', 'default_static_path', default='/app/jdav_web/static') DEFAULT_STATIC_PATH = get_var("django", "default_static_path", default="/app/jdav_web/static")
# Locale files (translations) # Locale files (translations)
LOCALE_PATHS = (os.path.join(BASE_DIR, 'locale'),) LOCALE_PATHS = (os.path.join(BASE_DIR, "locale"),)
# Celery and Redis setup # Celery and Redis setup
BROKER_URL = get_var('django', 'broker_url', default='redis://localhost:6379/0') BROKER_URL = get_var("django", "broker_url", default="redis://redis:6379/0")
# password hash algorithms used # password hash algorithms used
PASSWORD_HASHERS = [ PASSWORD_HASHERS = [
'django.contrib.auth.hashers.BCryptPasswordHasher', "django.contrib.auth.hashers.BCryptPasswordHasher",
'django.contrib.auth.hashers.PBKDF2PasswordHasher', "django.contrib.auth.hashers.PBKDF2PasswordHasher",
'django.contrib.auth.hashers.PBKDF2SHA1PasswordHasher', "django.contrib.auth.hashers.PBKDF2SHA1PasswordHasher",
'django.contrib.auth.hashers.Argon2PasswordHasher', "django.contrib.auth.hashers.Argon2PasswordHasher",
'django.contrib.auth.hashers.ScryptPasswordHasher', "django.contrib.auth.hashers.ScryptPasswordHasher",
] ]
MARKDOWNIFY = { MARKDOWNIFY = {
'default': { "default": {
"WHITELIST_TAGS": [ "WHITELIST_TAGS": [
'img', "img",
'abbr', "abbr",
'acronym', "acronym",
'a', "a",
'b', "b",
'blockquote', "blockquote",
'em', "em",
'i', "i",
'li', "li",
'ol', "ol",
'p', "p",
'strong', "strong",
'ul', "ul",
'br', "br",
'code', "code",
'span', "span",
'div', 'class', "div",
'pre', 'h1', 'h2', 'h3', 'h4', 'h5', 'h6' "class",
"pre",
"h1",
"h2",
"h3",
"h4",
"h5",
"h6",
], ],
"WHITELIST_ATTRS": [ "WHITELIST_ATTRS": [
'src', "src",
'href', "href",
'style', "style",
'alt', "alt",
'class', "class",
], ],
"LINKIFY_TEXT": { "LINKIFY_TEXT": {
"PARSE_URLS": True, "PARSE_URLS": True,
# Next key/value-pairs only have effect if "PARSE_URLS" is True # Next key/value-pairs only have effect if "PARSE_URLS" is True
"PARSE_EMAIL": True, "PARSE_EMAIL": True,
"CALLBACKS": [], "CALLBACKS": [],
"SKIP_TAGS": [], "SKIP_TAGS": [],
} },
} }
} }
# allowed characters in names appearing in urls on the website # allowed characters in names appearing in urls on the website
STARTPAGE_URL_NAME_PATTERN = "[\w\-: *]" STARTPAGE_URL_NAME_PATTERN = r"[\w\-: *]"
# admins to contact on error messages # admins to contact on error messages
ADMINS = get_var('section', 'admins', default=[]) ADMINS = get_var("section", "admins", default=[])
LOGIN_URL = '/de/kompass/login/' LOGIN_URL = "/de/kompass/login/"

@ -1,17 +1,18 @@
# ruff: noqa F821
CACHES = { CACHES = {
'default': { "default": {
'BACKEND': 'django.core.cache.backends.memcached.PyMemcacheCache', "BACKEND": "django.core.cache.backends.memcached.PyMemcacheCache",
'LOCATION': get_var('django', 'memcached_url', default='127.0.0.1:11211'), "LOCATION": get_var("django", "memcached_url", default="cache:11211"),
'OPTIONS': { "OPTIONS": {
'no_delay': True, "no_delay": True,
'ignore_exc': True, "ignore_exc": True,
'max_pool_size': 4, "max_pool_size": 4,
'use_pooling': True, "use_pooling": True,
} },
} }
} }
CACHE_MIDDLEWARE_ALIAS = 'default' CACHE_MIDDLEWARE_ALIAS = "default"
CACHE_MIDDLEWARE_SECONDS = 1 CACHE_MIDDLEWARE_SECONDS = 1
CACHE_MIDDLEWARE_KEY_PREFIX = '' CACHE_MIDDLEWARE_KEY_PREFIX = ""

@ -1,14 +1,15 @@
# ruff: noqa F821
# Database # Database
# https://docs.djangoproject.com/en/1.10/ref/settings/#databases # https://docs.djangoproject.com/en/1.10/ref/settings/#databases
DATABASES = { DATABASES = {
'default': { "default": {
'ENGINE': 'django.db.backends.mysql', "ENGINE": "django.db.backends.mysql",
'NAME': get_var('database', 'database', default='jdav_db'), "NAME": get_var("database", "database", default="kompass"),
'USER': get_var('database', 'user', default='user'), "USER": get_var("database", "user", default="kompass"),
'PASSWORD': get_var('database', 'password', default='secret'), "PASSWORD": get_var("database", "password", default="secret"),
'HOST': get_var('database', 'host', default='127.0.0.1'), "HOST": get_var("database", "host", default="db"),
'PORT': get_var('database', 'port', default=5432) "PORT": get_var("database", "port", default=3306),
} }
} }

@ -1,17 +1,19 @@
# ruff: noqa F821
# Email setup # Email setup
EMAIL_HOST = get_var('mail', 'host', default='localhost') EMAIL_HOST = get_var("mail", "host", default="localhost")
EMAIL_PORT = get_var('mail', 'port', default=587 if deployed else 25) EMAIL_PORT = get_var("mail", "port", default=587 if deployed else 25)
EMAIL_HOST_USER = get_var('mail', 'user', default='user') EMAIL_HOST_USER = get_var("mail", "user", default="user")
EMAIL_HOST_PASSWORD = get_var('mail', 'password', default='secret') EMAIL_HOST_PASSWORD = get_var("mail", "password", default="secret")
EMAIL_USE_TLS = get_var('mail', 'tls', default=True if deployed else False) EMAIL_USE_TLS = get_var("mail", "tls", default=True if deployed else False)
EMAIL_BACKEND = 'djcelery_email.backends.CeleryEmailBackend' EMAIL_BACKEND = "djcelery_email.backends.CeleryEmailBackend"
# Celery Email Setup # Celery Email Setup
CELERY_EMAIL_TASK_CONFIG = { CELERY_EMAIL_TASK_CONFIG = {
'rate_limit' : '10/m' # * CELERY_EMAIL_CHUNK_SIZE (default: 10) "rate_limit": "10/m" # * CELERY_EMAIL_CHUNK_SIZE (default: 10)
} }
DEFAULT_SENDING_MAIL = get_var('mail', 'default_sending_address', default='kompass@localhost') DEFAULT_SENDING_MAIL = get_var("mail", "default_sending_address", default="kompass@localhost")
DEFAULT_SENDING_NAME = get_var('mail', 'default_sending_name', default='Kompass') DEFAULT_SENDING_NAME = get_var("mail", "default_sending_name", default="Kompass")

@ -1,64 +1,116 @@
# ruff: noqa F821
# JET options (admin interface) # JET options (admin interface)
JET_SIDE_MENU_COMPACT = True JET_SIDE_MENU_COMPACT = True
JET_DEFAULT_THEME = 'jdav-green' JET_DEFAULT_THEME = "jdav-green"
JET_CHANGE_FORM_SIBLING_LINKS = False JET_CHANGE_FORM_SIBLING_LINKS = False
JET_SIDE_MENU_ITEMS = [ JET_SIDE_MENU_ITEMS = [
{'label': 'Teilnehmer*innenverwaltung', 'app_label': 'members', 'items': [ {
{'name': 'member', 'label': 'Alle Teilnehmer*innen', 'permissions': ['members.view_member']}, "label": "Teilnehmer*innenverwaltung",
{'name': 'freizeit', 'permissions': ['members.view_freizeit']}, "app_label": "members",
{'name': 'group', 'permissions': ['members.view_group']}, "items": [
{'name': 'membernotelist', 'permissions': ['members.view_membernotelist']}, {
{'name': 'klettertreff', 'permissions': ['members.view_klettertreff']}, "name": "member",
]}, "label": "Alle Teilnehmer*innen",
{'label': 'Neue Mitglieder', 'app_label': 'members', 'permissions': ['members.view_memberunconfirmedproxy'], 'items': [ "permissions": ["members.view_member"],
{'name': 'memberunconfirmedproxy', 'permissions': ['members.view_memberunconfirmedproxy']}, },
{'name': 'memberwaitinglist', 'permissions': ['members.view_memberwaitinglist']}, {"name": "freizeit", "permissions": ["members.view_freizeit"]},
]}, {"name": "group", "permissions": ["members.view_group"]},
{'label': 'Ausbildung', 'app_label': 'members', 'permissions': ['members.view_membertraining'], 'items': [ {"name": "membernotelist", "permissions": ["members.view_membernotelist"]},
{'name': 'membertraining', 'permissions': ['members.view_membertraining']}, {"name": "klettertreff", "permissions": ["members.view_klettertreff"]},
{'name': 'trainingcategory', 'permissions': ['members.view_trainingcategory']}, ],
{'name': 'activitycategory', 'permissions': ['members.view_activitycategory']}, },
]}, {
{'app_label': 'mailer', 'items': [ "label": "Neue Mitglieder",
{'name': 'message', 'permissions': ['mailer.view_message']}, "app_label": "members",
{'name': 'emailaddress', 'permissions': ['mailer.view_emailaddress']}, "permissions": ["members.view_memberunconfirmedproxy"],
]}, "items": [
{'app_label': 'finance', 'items': [ {
{'name': 'statement', 'permissions': ['finance.view_statement']}, "name": "memberunconfirmedproxy",
{'name': 'ledger', 'permissions': ['finance.view_ledger']}, "permissions": ["members.view_memberunconfirmedproxy"],
{'name': 'bill', 'permissions': ['finance.view_bill', 'finance.view_bill_admin']}, },
{'name': 'transaction', 'permissions': ['finance.view_transaction']}, {"name": "memberwaitinglist", "permissions": ["members.view_memberwaitinglist"]},
]}, ],
{'app_label': 'logindata', 'permissions': ['auth'], 'items': [ },
{'name': 'authgroup', 'permissions': ['auth.group'] }, {
{'name': 'logindatum', 'permissions': ['auth.user']}, "label": "Ausbildung",
{'name': 'registrationpassword', 'permissions': ['auth.user']}, "app_label": "members",
]}, "permissions": ["members.view_membertraining"],
{'app_label': 'django_celery_beat', 'permissions': ['django_celery_beat'], 'items': [ "items": [
{'name': 'crontabschedule'}, {"name": "membertraining", "permissions": ["members.view_membertraining"]},
{'name': 'clockedschedule'}, {"name": "trainingcategory", "permissions": ["members.view_trainingcategory"]},
{'name': 'intervalschedule'}, {"name": "activitycategory", "permissions": ["members.view_activitycategory"]},
{'name': 'periodictask'}, ],
{'name': 'solarschedule'}, },
]}, {
{'app_label': 'ludwigsburgalpin', 'permissions': ['ludwigsburgalpin'], 'items': [ "app_label": "mailer",
{'name': 'termin', 'permissions': ['ludwigsburgalpin.view_termin']}, "items": [
]}, {"name": "message", "permissions": ["mailer.view_message"]},
{'app_label': 'material', 'permissions': ['material.view_materialpart'], 'items': [ {"name": "emailaddress", "permissions": ["mailer.view_emailaddress"]},
{'name': 'materialcategory', 'permissions': ['material.view_materialcategory']}, ],
{'name': 'materialpart', 'permissions': ['material.view_materialpart']}, },
]}, {
{'app_label': 'startpage', 'permissions': ['startpage'], 'items': [ "app_label": "finance",
{'name': 'section', 'permissions': ['startpage.view_section']}, "items": [
{'name': 'post', 'permissions': ['startpage.view_post']}, {"name": "statement", "permissions": ["finance.view_statement"]},
{'name': 'link', 'permissions': ['startpage.view_link']}, {"name": "ledger", "permissions": ["finance.view_ledger"]},
]}, {"name": "bill", "permissions": ["finance.view_bill", "finance.view_bill_admin"]},
{'label': 'Externe Links', 'items' : [ {"name": "transaction", "permissions": ["finance.view_transaction"]},
{ 'label': 'Nextcloud', 'url': CLOUD_LINK, 'url_blank': True }, ],
{ 'label': 'DAV 360', 'url': DAV_360_LINK, 'url_blank': True }, },
{ 'label': 'Julei-Wiki', 'url': WIKI_LINK, 'url_blank': True }, {
{ 'label': 'Kompass Dokumentation', 'url': DOCS_LINK, 'url_blank': True }, "app_label": "logindata",
]}, "permissions": ["auth"],
"items": [
{"name": "authgroup", "permissions": ["auth.group"]},
{"name": "logindatum", "permissions": ["auth.user"]},
{"name": "registrationpassword", "permissions": ["auth.user"]},
],
},
{
"app_label": "django_celery_beat",
"permissions": ["django_celery_beat"],
"items": [
{"name": "crontabschedule"},
{"name": "clockedschedule"},
{"name": "intervalschedule"},
{"name": "periodictask"},
{"name": "solarschedule"},
],
},
{
"app_label": "ludwigsburgalpin",
"permissions": ["ludwigsburgalpin"],
"items": [
{"name": "termin", "permissions": ["ludwigsburgalpin.view_termin"]},
],
},
{
"app_label": "material",
"permissions": ["material.view_materialpart"],
"items": [
{"name": "materialcategory", "permissions": ["material.view_materialcategory"]},
{"name": "materialpart", "permissions": ["material.view_materialpart"]},
],
},
{
"app_label": "startpage",
"permissions": ["startpage"],
"items": [
{"name": "section", "permissions": ["startpage.view_section"]},
{"name": "post", "permissions": ["startpage.view_post"]},
{"name": "link", "permissions": ["startpage.view_link"]},
],
},
{
"label": "Externe Links",
"items": [
{"label": "Nextcloud", "url": CLOUD_LINK, "url_blank": True},
{"label": "DAV 360", "url": DAV_360_LINK, "url_blank": True},
{"label": "Julei-Wiki", "url": WIKI_LINK, "url_blank": True},
{"label": "Kompass Dokumentation", "url": DOCS_LINK, "url_blank": True},
],
},
] ]

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

@ -1,10 +1,12 @@
import os # ruff: noqa F821
DJANGO_LOG_LEVEL = get_var('logging', 'django_level', default='INFO') DJANGO_LOG_LEVEL = get_var("logging", "django_level", default="INFO")
ROOT_LOG_LEVEL = get_var('logging', 'level', default='INFO') ROOT_LOG_LEVEL = get_var("logging", "level", default="INFO")
LOG_ERROR_TO_EMAIL = get_var('logging', 'email_admins', default=False) LOG_ERROR_TO_EMAIL = get_var("logging", "email_admins", default=False)
LOG_EMAIL_BACKEND = EMAIL_BACKEND if LOG_ERROR_TO_EMAIL else "django.core.mail.backends.console.EmailBackend" LOG_EMAIL_BACKEND = (
LOG_ERROR_INCLUDE_HTML = get_var('logging', 'error_report_include_html', default=False) EMAIL_BACKEND if LOG_ERROR_TO_EMAIL else "django.core.mail.backends.console.EmailBackend"
)
LOG_ERROR_INCLUDE_HTML = get_var("logging", "error_report_include_html", default=False)
LOGGING = { LOGGING = {
"version": 1, "version": 1,

@ -1,8 +1,10 @@
# ruff: noqa F821
OAUTH2_PROVIDER = { OAUTH2_PROVIDER = {
"OIDC_ENABLED": True, "OIDC_ENABLED": True,
"PKCE_REQUIRED": False, "PKCE_REQUIRED": False,
"OAUTH2_VALIDATOR_CLASS": "logindata.oauth.CustomOAuth2Validator", "OAUTH2_VALIDATOR_CLASS": "logindata.oauth.CustomOAuth2Validator",
"OIDC_RSA_PRIVATE_KEY": get_var('oauth', 'oidc_rsa_private_key', default=''), "OIDC_RSA_PRIVATE_KEY": get_var("oauth", "oidc_rsa_private_key", default=""),
"SCOPES": { "SCOPES": {
"openid": "OpenID Connect scope", "openid": "OpenID Connect scope",
"profile": "profile scope", "profile": "profile scope",

@ -1,17 +1,24 @@
# ruff: noqa F821
# mail texts # mail texts
CONFIRM_MAIL_TEXT = get_text('confirm_mail', default="""Hallo {name}, CONFIRM_MAIL_TEXT = get_text(
"confirm_mail",
default="""Hallo {{name}},
du hast bei der JDAV %(SEKTION)s eine E-Mail Adresse hinterlegt. Da bei uns alle Kommunikation du hast bei der JDAV {SEKTION} eine E-Mail Adresse hinterlegt. Da bei uns alle Kommunikation
per Email funktioniert, brauchen wir eine Bestätigung {whattoconfirm}. Dazu klicke bitte einfach auf per Email funktioniert, brauchen wir eine Bestätigung {{whattoconfirm}}. Dazu klicke bitte einfach auf
folgenden Link: folgenden Link:
{link} {{link}}
Viele Grüße Viele Grüße
Deine JDAV %(SEKTION)s""" % { 'SEKTION': SEKTION }) Deine JDAV {SEKTION}""".format(SEKTION=SEKTION),
)
NEW_UNCONFIRMED_REGISTRATION = get_text('new_unconfirmed_registration', default="""Hallo {name}, NEW_UNCONFIRMED_REGISTRATION = get_text(
"new_unconfirmed_registration",
default="""Hallo {name},
für deine Gruppe {group} liegt eine neue unbestätigte Reservierung vor. Die Person hat bereits ihre für deine Gruppe {group} liegt eine neue unbestätigte Reservierung vor. Die Person hat bereits ihre
E-Mailadressen bestätigt und ihr Anmeldeformular hochgeladen. Bitte prüfe die Registrierung eingehend und E-Mailadressen bestätigt und ihr Anmeldeformular hochgeladen. Bitte prüfe die Registrierung eingehend und
@ -20,69 +27,84 @@ bestätige falls möglich. Zu der Registrierung kommst du hier:
{link} {link}
Viele Grüße Viele Grüße
Dein KOMPASS""") Dein KOMPASS""",
)
GROUP_INVITATION_LEFT_WAITINGLIST = get_text('group_invitation_left_waitinglist', GROUP_INVITATION_LEFT_WAITINGLIST = get_text(
default="""Hallo {name}, "group_invitation_left_waitinglist",
default="""Hallo {name},
der*die kürzlich zu einer Schnupperstunde für die Gruppe {group} eingeladene Wartende {waiter} der*die kürzlich zu einer Schnupperstunde für die Gruppe {group} eingeladene Wartende {waiter}
hat die Warteliste verlassen. hat die Warteliste verlassen.
Viele Grüße Viele Grüße
Dein KOMPASS""") Dein KOMPASS""",
)
GROUP_INVITATION_REJECTED = get_text('group_invitation_rejected', GROUP_INVITATION_REJECTED = get_text(
default="""Hallo {name}, "group_invitation_rejected",
default="""Hallo {name},
{waiter} hat die Einladung zu einer Schnupperstunde bei der Gruppe {group} abgelehnt, ist {waiter} hat die Einladung zu einer Schnupperstunde bei der Gruppe {group} abgelehnt, ist
aber weiterhin auf der Warteliste. aber weiterhin auf der Warteliste.
Viele Grüße Viele Grüße
Dein KOMPASS""") Dein KOMPASS""",
)
GROUP_INVITATION_CONFIRMED_TEXT = get_text('group_invitation_confirmed', GROUP_INVITATION_CONFIRMED_TEXT = get_text(
default="""Hallo {name}, "group_invitation_confirmed",
default="""Hallo {name},
{waiter} hat die Einladung zu einer Schnupperstunde bei der Gruppe {group} angenommen. {waiter} hat die Einladung zu einer Schnupperstunde bei der Gruppe {group} angenommen.
Viele Grüße Viele Grüße
Dein KOMPASS""") Dein KOMPASS""",
)
TRIAL_GROUP_MEETING_CONFIRMED_TEXT = get_text('trial_group_meeting_confirmed', TRIAL_GROUP_MEETING_CONFIRMED_TEXT = get_text(
default="""Hallo {name}, "trial_group_meeting_confirmed",
default="""Hallo {{name}},
deine Teilnahme an der Schnupperstunde der Gruppe {group} wurde erfolgreich bestätigt. deine Teilnahme an der Schnupperstunde der Gruppe {{group}} wurde erfolgreich bestätigt.
{timeinfo} {{timeinfo}}
Für alle weiteren Absprachen, kontaktiere bitte die Jugendleiter*innen der Gruppe Für alle weiteren Absprachen, kontaktiere bitte die Jugendleiter*innen der Gruppe
unter {contact_email}. unter {{contact_email}}.
Viele Grüße Viele Grüße
Deine JDAV %(SEKTION)s""" % { 'SEKTION': SEKTION }) Deine JDAV {SEKTION}""".format(SEKTION=SEKTION),
)
GROUP_TIME_AVAILABLE_TEXT = get_text('group_time_available', GROUP_TIME_AVAILABLE_TEXT = get_text(
default="""Die Gruppenstunde findet jeden {weekday} von {start_time} bis {end_time} Uhr statt.""") "group_time_available",
default="""Die Gruppenstunde findet jeden {weekday} von {start_time} bis {end_time} Uhr statt.""",
)
GROUP_TIME_UNAVAILABLE_TEXT = get_text('group_time_unavailable', GROUP_TIME_UNAVAILABLE_TEXT = get_text(
default="""Bitte erfrage die Gruppenzeiten bei der Gruppenleitung ({contact_email}).""") "group_time_unavailable",
default="""Bitte erfrage die Gruppenzeiten bei der Gruppenleitung ({contact_email}).""",
)
INVITE_TEXT = get_text('invite', default="""Hallo {{name}}, INVITE_TEXT = get_text(
"invite",
default="""Hallo {{{{name}}}},
wir haben gute Neuigkeiten für dich. Es ist ein Platz in der Jugendgruppe {group_name} {group_link}freigeworden. wir haben gute Neuigkeiten für dich. Es ist ein Platz in der Jugendgruppe {{group_name}} {{group_link}}freigeworden.
{group_time} {{group_time}}
Wenn du an der Schnupperstunde teilnehmen möchtest, bestätige deine Teilnahme bitte unter folgendem Link: Wenn du an der Schnupperstunde teilnehmen möchtest, bestätige deine Teilnahme bitte unter folgendem Link:
{{invitation_confirm_link}} {{{{invitation_confirm_link}}}}
Für alle weiteren Absprachen, kontaktiere bitte die Gruppenleitung ({contact_email}). Für alle weiteren Absprachen, kontaktiere bitte die Gruppenleitung ({{contact_email}}).
Wenn du nach der Schnupperstunde beschließt der Gruppe beizutreten, benötigen wir noch ein paar Wenn du nach der Schnupperstunde beschließt der Gruppe beizutreten, benötigen wir noch ein paar
Informationen und eine schriftliche Anmeldebestätigung von dir. Das kannst du alles über folgenden Link erledigen: Informationen und eine schriftliche Anmeldebestätigung von dir. Das kannst du alles über folgenden Link erledigen:
{{link}} {{{{link}}}}
Du siehst dort auch die Daten, die du bei deiner Eintragung auf die Warteliste angegeben hast. Bitte Du siehst dort auch die Daten, die du bei deiner Eintragung auf die Warteliste angegeben hast. Bitte
überprüfe, ob die Daten noch stimmen und ändere sie bei Bedarf ab. überprüfe, ob die Daten noch stimmen und ändere sie bei Bedarf ab.
@ -90,27 +112,35 @@ Du siehst dort auch die Daten, die du bei deiner Eintragung auf die Warteliste a
Falls du zu dem obigen Termin keine Zeit hast oder dich ganz von der Warteliste abmelden möchtest, Falls du zu dem obigen Termin keine Zeit hast oder dich ganz von der Warteliste abmelden möchtest,
lehne bitte diese Einladung unter folgendem Link ab: lehne bitte diese Einladung unter folgendem Link ab:
{{invitation_reject_link}} {{{{invitation_reject_link}}}}
Bei Fragen, wende dich gerne an %(RESPONSIBLE_MAIL)s. Bei Fragen, wende dich gerne an {RESPONSIBLE_MAIL}.
Viele Grüße Viele Grüße
Deine JDAV %(SEKTION)s""" % { 'SEKTION': SEKTION, 'RESPONSIBLE_MAIL': RESPONSIBLE_MAIL, Deine JDAV {SEKTION}""".format(
'REGISTRATION_FORM_DOWNLOAD_LINK': REGISTRATION_FORM_DOWNLOAD_LINK }) SEKTION=SEKTION,
RESPONSIBLE_MAIL=RESPONSIBLE_MAIL,
),
)
LEAVE_WAITINGLIST_TEXT = get_text('leave_waitinglist', default="""Hallo {name}, LEAVE_WAITINGLIST_TEXT = get_text(
"leave_waitinglist",
default="""Hallo {{name}},
du hast dich erfolgreich von der Warteliste abgemeldet. Falls du zu einem späteren du hast dich erfolgreich von der Warteliste abgemeldet. Falls du zu einem späteren
Zeitpunkt wieder der Warteliste beitreten möchtest, kannst du das über unsere Webseite machen. Zeitpunkt wieder der Warteliste beitreten möchtest, kannst du das über unsere Webseite machen.
Falls du dich nicht selbst abgemeldet hast, wende dich bitte umgehend an %(RESPONSIBLE_MAIL)s. Falls du dich nicht selbst abgemeldet hast, wende dich bitte umgehend an {RESPONSIBLE_MAIL}.
Viele Grüße Viele Grüße
Deine JDAV %(SEKTION)s""" % { 'SEKTION': SEKTION, 'RESPONSIBLE_MAIL': RESPONSIBLE_MAIL }) Deine JDAV {SEKTION}""".format(SEKTION=SEKTION, RESPONSIBLE_MAIL=RESPONSIBLE_MAIL),
)
WAIT_CONFIRMATION_TEXT = get_text('wait_confirmation', default="""Hallo {name}, WAIT_CONFIRMATION_TEXT = get_text(
"wait_confirmation",
default="""Hallo {{name}},
leider können wir dir zur Zeit noch keinen Platz in einer Jugendgruppe anbieten. Da wir leider können wir dir zur Zeit noch keinen Platz in einer Jugendgruppe anbieten. Da wir
sehr viele Interessenten haben und wir möglichst vielen die Möglichkeit bieten möchten, an sehr viele Interessenten haben und wir möglichst vielen die Möglichkeit bieten möchten, an
@ -119,22 +149,25 @@ Warteliste ab, ob sie noch Interesse haben.
Wenn du weiterhin auf der Warteliste bleiben möchtest, klicke auf den folgenden Link: Wenn du weiterhin auf der Warteliste bleiben möchtest, klicke auf den folgenden Link:
{link} {{link}}
Falls du kein Interesse mehr hast, kannst du unter folgendem Link die Warteliste verlassen: Falls du kein Interesse mehr hast, kannst du unter folgendem Link die Warteliste verlassen:
{leave_link} {{leave_link}}
Das ist Erinnerung Nummer {reminder} von {max_reminder_count}. Nach Erinnerung Nummer {max_reminder_count} wirst Das ist Erinnerung Nummer {{reminder}} von {{max_reminder_count}}. Nach Erinnerung Nummer {{max_reminder_count}} wirst
du automatisch entfernt. du automatisch entfernt.
Viele Grüße Viele Grüße
Deine JDAV %(SEKTION)s""" % { 'SEKTION': SEKTION }) Deine JDAV {SEKTION}""".format(SEKTION=SEKTION),
)
JOIN_WAITINGLIST_CONFIRMATION_TEXT = get_text('join_waitinglist_confirmation', default="""Hallo {name}, JOIN_WAITINGLIST_CONFIRMATION_TEXT = get_text(
"join_waitinglist_confirmation",
default="""Hallo {{name}},
vielen Dank für dein Interesse an einem Platz in einer Jugendgruppe der JDAV %(SEKTION)s. Du hast dich erfolgreich vielen Dank für dein Interesse an einem Platz in einer Jugendgruppe der JDAV {SEKTION}. Du hast dich erfolgreich
für die Warteliste registriert. für die Warteliste registriert.
Leider ist die Nachfrage nach Jugendgruppenplätzen deutlich höher als unsere Kapazitäten. Daher kann es Leider ist die Nachfrage nach Jugendgruppenplätzen deutlich höher als unsere Kapazitäten. Daher kann es
@ -143,34 +176,43 @@ aktuell bleibt, werden wir dich in regelmäßigen Abständen per E-Mail bitten,
zu bestätigen. zu bestätigen.
Viele Grüße Viele Grüße
Deine JDAV %(SEKTION)s""" % { 'SEKTION': SEKTION }) Deine JDAV {SEKTION}""".format(SEKTION=SEKTION),
)
UNSUBSCRIBE_CONFIRMATION_TEXT = get_text('unsubscribe_confirmation', default="""Klicke auf den Link, um dich vom Newsletter der JDAV %(SEKTION)s abzumelden UNSUBSCRIBE_CONFIRMATION_TEXT = get_text(
"unsubscribe_confirmation",
default="""Klicke auf den Link, um dich vom Newsletter der JDAV {SEKTION} abzumelden
{link}""" % { 'SEKTION': SEKTION }) {{link}}""".format(SEKTION=SEKTION),
)
NOTIFY_MOST_ACTIVE_TEXT = get_text('notify_most_active', default="""Hallo {name}! NOTIFY_MOST_ACTIVE_TEXT = get_text(
"notify_most_active",
default="""Hallo {{name}}!
Herzlichen Glückwunsch, du hast im letzten Jahr zu den {congratulate_max} aktivsten Herzlichen Glückwunsch, du hast im letzten Jahr zu den {{congratulate_max}} aktivsten
Mitgliedern der JDAV %(SEKTION)s gehört! Um genau zu sein beträgt dein Aktivitäts Wert Mitgliedern der JDAV {SEKTION} gehört! Um genau zu sein beträgt dein Aktivitäts Wert
des letzten Jahres {score} Punkte. Das entspricht {level} Kletterer:innen. Damit warst du des letzten Jahres {{score}} Punkte. Das entspricht {{level}} Kletterer:innen. Damit warst du
im letzten Jahr das {position}aktivste Mitglied der JDAV %(SEKTION)s. im letzten Jahr das {{position}}aktivste Mitglied der JDAV {SEKTION}.
Auf ein weiteres aktives Jahr in der JDAV %(SEKTION)s. Auf ein weiteres aktives Jahr in der JDAV {SEKTION}.
Dein:e Jugendreferent:in""" % { 'SEKTION': SEKTION }) Dein:e Jugendreferent:in""".format(SEKTION=SEKTION),
)
ECHO_TEXT = get_text('echo', default="""Hallo {name}, ECHO_TEXT = get_text(
"echo",
default="""Hallo {{name}},
um unsere Daten auf dem aktuellen Stand zu halten und sicherzugehen, dass du um unsere Daten auf dem aktuellen Stand zu halten und sicherzugehen, dass du
weiterhin ein Teil unserer Jugendarbeit bleiben möchtest, brauchen wir eine weiterhin ein Teil unserer Jugendarbeit bleiben möchtest, brauchen wir eine
kurze Bestätigung von dir. Dafür besuche einfach diesen Link: kurze Bestätigung von dir. Dafür besuche einfach diesen Link:
{link} {{link}}
Dort kannst du deine Daten nach Eingabe eines Passworts überprüfen und ggf. ändern. Dein Dort kannst du deine Daten nach Eingabe eines Passworts überprüfen und ggf. ändern. Dein
Passwort ist dein Geburtsdatum. Wäre dein Geburtsdatum zum Beispiel der 4. Januar 1942, Passwort ist dein Geburtsdatum. Wäre dein Geburtsdatum zum Beispiel der 4. Januar 1942,
@ -180,76 +222,94 @@ Falls du nicht innerhalb von 30 Tagen deine Daten bestätigst, gehen wir davon a
unserer Jugendarbeit sein möchtest. Dein Platz wird dann weitervergeben, deine Daten aus unserer Datenbank unserer Jugendarbeit sein möchtest. Dein Platz wird dann weitervergeben, deine Daten aus unserer Datenbank
gelöscht und du erhälst in Zukunft keine Mails mehr von uns. gelöscht und du erhälst in Zukunft keine Mails mehr von uns.
Bei Fragen, wende dich gerne an %(RESPONSIBLE_MAIL)s. Bei Fragen, wende dich gerne an {RESPONSIBLE_MAIL}.
Viele Grüße Viele Grüße
Deine JDAV %(SEKTION)s""" % { 'SEKTION': SEKTION, 'RESPONSIBLE_MAIL': RESPONSIBLE_MAIL }) Deine JDAV {SEKTION}""".format(SEKTION=SEKTION, RESPONSIBLE_MAIL=RESPONSIBLE_MAIL),
)
PREPEND_INCOMPLETE_REGISTRATION_TEXT = get_text('prepend_incomplete_registration', default="""WICHTIGE MITTEILUNG PREPEND_INCOMPLETE_REGISTRATION_TEXT = get_text(
"prepend_incomplete_registration",
default="""WICHTIGE MITTEILUNG
Deine Anmeldung ist aktuell nicht vollständig. Bitte fülle umgehend das Deine Anmeldung ist aktuell nicht vollständig. Bitte fülle umgehend das
Anmeldeformular aus und lasse es Deine*r Jugendleiter*in zukommen! Dieses Anmeldeformular aus und lasse es Deine*r Jugendleiter*in zukommen! Dieses
kannst Du unter folgendem Link herunterladen: kannst Du unter folgendem Link herunterladen:
%(REGISTRATION_FORM_DOWNLOAD_LINK)s {REGISTRATION_FORM_DOWNLOAD_LINK}
**************** ****************
""" % { 'REGISTRATION_FORM_DOWNLOAD_LINK': REGISTRATION_FORM_DOWNLOAD_LINK }) """.format(REGISTRATION_FORM_DOWNLOAD_LINK=REGISTRATION_FORM_DOWNLOAD_LINK),
)
MAIL_FOOTER = get_text('mail_footer', default=""" MAIL_FOOTER = get_text(
"mail_footer",
default="""
**************** ****************
Diese Email wurde über die Webseite der JDAV %(SEKTION)s Diese Email wurde über die Webseite der JDAV {SEKTION}
verschickt. Wenn Du in Zukunft keine Emails mehr erhalten möchtest, verschickt. Wenn Du in Zukunft keine Emails mehr erhalten möchtest,
kannst Du hier den Newsletter deabonnieren: kannst Du hier den Newsletter deabonnieren:
{link}""" % { 'SEKTION': SEKTION }) {{link}}""".format(SEKTION=SEKTION),
)
INVITE_AS_USER_TEXT = get_text('invite_as_user', default="""Hallo {name}, INVITE_AS_USER_TEXT = get_text(
"invite_as_user",
default="""Hallo {{name}},
du bist Jugendleiter*in in der Sektion %(SEKTION)s. Die Verwaltung unserer Jugendgruppen, du bist Jugendleiter*in in der Sektion {SEKTION}. Die Verwaltung unserer Jugendgruppen,
Ausfahrten und Finanzen erfolgt in unserer Online Plattform Kompass. Deine Stammdaten sind Ausfahrten und Finanzen erfolgt in unserer Online Plattform Kompass. Deine Stammdaten sind
dort bereits hinterlegt. Damit du dich auch anmelden kannst, folge bitte dem folgenden Link dort bereits hinterlegt. Damit du dich auch anmelden kannst, folge bitte dem folgenden Link
und wähle ein Passwort. und wähle ein Passwort.
{link} {{link}}
Bei Fragen, wende dich gerne an %(RESPONSIBLE_MAIL)s. Bei Fragen, wende dich gerne an {RESPONSIBLE_MAIL}.
Viele Grüße Viele Grüße
Deine JDAV %(SEKTION)s""" % { 'SEKTION': SEKTION, 'RESPONSIBLE_MAIL': RESPONSIBLE_MAIL }) Deine JDAV {SEKTION}""".format(SEKTION=SEKTION, RESPONSIBLE_MAIL=RESPONSIBLE_MAIL),
)
UPLOAD_REGISTRATION_FORM_TEXT = get_text('upload_registration_form', default="""Hallo {name}, UPLOAD_REGISTRATION_FORM_TEXT = get_text(
"upload_registration_form",
default="""Hallo {{name}},
vielen Dank für deine Anmeldung in der JDAV %(SEKTION)s. Bevor es richtig losgehen kann, brauchen vielen Dank für deine Anmeldung in der JDAV {SEKTION}. Bevor es richtig losgehen kann, brauchen
wir noch die Bestätigung deiner Daten und die Zustimmung zu unseren Teilnahmebedingungen. wir noch die Bestätigung deiner Daten und die Zustimmung zu unseren Teilnahmebedingungen.
Dafür kannst du das für dich vorausgefüllte Anmeldeformular unter folgendem Link herunterladen, Dafür kannst du das für dich vorausgefüllte Anmeldeformular unter folgendem Link herunterladen,
durchlesen und, falls du zustimmst, das unterschriebene Formular wieder dort hochladen. durchlesen und, falls du zustimmst, das unterschriebene Formular wieder dort hochladen.
{link} {{link}}
Bist du noch nicht volljährig? Dann muss eine erziehungsberechtigte Person das Formular unterschreiben. Bist du noch nicht volljährig? Dann muss eine erziehungsberechtigte Person das Formular unterschreiben.
Bei Fragen, wende dich gerne an %(RESPONSIBLE_MAIL)s. Bei Fragen, wende dich gerne an {RESPONSIBLE_MAIL}.
Viele Grüße Viele Grüße
Deine JDAV %(SEKTION)s""" % { 'SEKTION': SEKTION, 'RESPONSIBLE_MAIL': RESPONSIBLE_MAIL }) Deine JDAV {SEKTION}""".format(SEKTION=SEKTION, RESPONSIBLE_MAIL=RESPONSIBLE_MAIL),
)
ADDRESS = get_text('address', default="""JDAV %(SEKTION)s ADDRESS = get_text(
%(STREET)s "address",
%(PLACE)s""" % { 'SEKTION': SEKTION, 'STREET': SEKTION_STREET, 'PLACE': SEKTION_TOWN }) default="""JDAV {SEKTION}
{STREET}
{PLACE}""".format(SEKTION=SEKTION, STREET=SEKTION_STREET, PLACE=SEKTION_TOWN),
)
NOTIFY_EXCURSION_PARTICIPANT_LIST = get_text('notify_excursion_participant_list', default="""Hallo {name}, NOTIFY_EXCURSION_PARTICIPANT_LIST = get_text(
"notify_excursion_participant_list",
default="""Hallo {name},
deine Ausfahrt {excursion} steht kurz bevor. Damit die Sektion dich im Notfall gut unterstützen kann, benötigt deine Ausfahrt {excursion} steht kurz bevor. Damit die Sektion dich im Notfall gut unterstützen kann, benötigt
die Geschäftsstelle eine aktuelle Kriseninterventionsliste, das heißt eine Teilnehmendenliste der Ausfahrt. die Geschäftsstelle eine aktuelle Kriseninterventionsliste, das heißt eine Teilnehmendenliste der Ausfahrt.
@ -262,9 +322,12 @@ Das sind die aktuell in der Ausfahrt eingetragenen Teilnehmenden:
Falls diese Liste nicht mehr aktuell ist, gehe bitte umgehend auf {excursion_link} und trage die Daten nach. Falls diese Liste nicht mehr aktuell ist, gehe bitte umgehend auf {excursion_link} und trage die Daten nach.
Viele Grüße Viele Grüße
Dein KOMPASS""") Dein KOMPASS""",
)
SEND_EXCURSION_CRISIS_LIST = get_text('send_excursion_crisis_list', default="""Hallo zusammen, SEND_EXCURSION_CRISIS_LIST = get_text(
"send_excursion_crisis_list",
default="""Hallo zusammen,
vom {excursion_start} bis {excursion_end} findet die Ausfahrt {excursion} der Jugend statt. Die vom {excursion_start} bis {excursion_end} findet die Ausfahrt {excursion} der Jugend statt. Die
Ausfahrt wird geleitet von {leaders}. Ausfahrt wird geleitet von {leaders}.
@ -272,12 +335,16 @@ Ausfahrt wird geleitet von {leaders}.
Im Anhang findet ihr die Kriseninterventionsliste. Im Anhang findet ihr die Kriseninterventionsliste.
Viele Grüße Viele Grüße
Euer KOMPASS""") Euer KOMPASS""",
)
SEND_STATEMENT_SUMMARY = get_text('send_statement_summary', default="""Hallo zusammen, SEND_STATEMENT_SUMMARY = get_text(
"send_statement_summary",
default="""Hallo zusammen,
anbei findet ihr die Abrechnung inklusive Belege für {statement}. Die Überweisungen anbei findet ihr die Abrechnung inklusive Belege für {statement}. Die Überweisungen
wurden wie beschrieben ausgeführt. wurden wie beschrieben ausgeführt.
Viele Grüße Viele Grüße
Euer KOMPASS""") Euer KOMPASS""",
)

@ -1,86 +1,98 @@
# ruff: noqa F821
# contact data # contact data
SEKTION = get_var('section', 'name', default='Heyo') SEKTION = get_var("section", "name", default="Heyo")
SEKTION_STREET = get_var('section', 'street', default='Street') SEKTION_STREET = get_var("section", "street", default="Street")
SEKTION_TOWN = get_var('section', 'town', default='12345 Town') SEKTION_TOWN = get_var("section", "town", default="12345 Town")
SEKTION_TELEPHONE = get_var('section', 'telephone', default='0123456789') SEKTION_TELEPHONE = get_var("section", "telephone", default="0123456789")
SEKTION_TELEFAX = get_var('section', 'telefax', default=SEKTION_TELEPHONE) SEKTION_TELEFAX = get_var("section", "telefax", default=SEKTION_TELEPHONE)
SEKTION_CONTACT_MAIL = get_var('section', 'contact_mail', default='info@example.org') SEKTION_CONTACT_MAIL = get_var("section", "contact_mail", default="info@example.org")
SEKTION_BOARD_MAIL = get_var('section', 'board_mail', default=SEKTION_CONTACT_MAIL) SEKTION_BOARD_MAIL = get_var("section", "board_mail", default=SEKTION_CONTACT_MAIL)
SEKTION_CRISIS_INTERVENTION_MAIL = get_var('section', 'crisis_intervention_mail', SEKTION_CRISIS_INTERVENTION_MAIL = get_var(
default=SEKTION_BOARD_MAIL) "section", "crisis_intervention_mail", default=SEKTION_BOARD_MAIL
SEKTION_FINANCE_MAIL = get_var('section', 'finance_mail', default=SEKTION_CONTACT_MAIL) )
SEKTION_IBAN = get_var('section', 'iban', default='Foo 123') SEKTION_FINANCE_MAIL = get_var("section", "finance_mail", default=SEKTION_CONTACT_MAIL)
SEKTION_ACCOUNT_HOLDER = get_var('section', 'account_holder', SEKTION_IBAN = get_var("section", "iban", default="Foo 123")
default='Foo') SEKTION_ACCOUNT_HOLDER = get_var("section", "account_holder", default="Foo")
RESPONSIBLE_MAIL = get_var('section', 'responsible_mail', default='foo@example.org') RESPONSIBLE_MAIL = get_var("section", "responsible_mail", default="foo@example.org")
DIGITAL_MAIL = get_var('section', 'digital_mail', default='bar@example.org') DIGITAL_MAIL = get_var("section", "digital_mail", default="bar@example.org")
# LJP # LJP
V32_HEAD_ORGANISATION = get_var('LJP', 'v32_head_organisation', default='not configured') V32_HEAD_ORGANISATION = get_var("LJP", "v32_head_organisation", default="not configured")
LJP_CONTRIBUTION_PER_DAY = get_var('LJP', 'contribution_per_day', default=25) LJP_CONTRIBUTION_PER_DAY = get_var("LJP", "contribution_per_day", default=25)
LJP_TAX = get_var('LJP', 'tax', default=0) LJP_TAX = get_var("LJP", "tax", default=0)
# echo # echo
# used to generate the personalized echo password # used to generate the personalized echo password
ECHO_PASSWORD_BIRTHDATE_FORMAT = get_var('echo', 'password_birthdate_format', default='%d.%m.%Y') ECHO_PASSWORD_BIRTHDATE_FORMAT = get_var("echo", "password_birthdate_format", default="%d.%m.%Y")
# grace period in days after which echo keys expire # grace period in days after which echo keys expire
ECHO_GRACE_PERIOD = get_var('echo', 'grace_period', default=30) ECHO_GRACE_PERIOD = get_var("echo", "grace_period", default=30)
# Waiting list configuration parameters, all numbers are in days # Waiting list configuration parameters, all numbers are in days
GRACE_PERIOD_WAITING_CONFIRMATION = get_var('waitinglist', 'grace_period_confirmation', default=30) GRACE_PERIOD_WAITING_CONFIRMATION = get_var("waitinglist", "grace_period_confirmation", default=30)
WAITING_CONFIRMATION_FREQUENCY = get_var('waitinglist', 'confirmation_frequency', default=90) WAITING_CONFIRMATION_FREQUENCY = get_var("waitinglist", "confirmation_frequency", default=90)
CONFIRMATION_REMINDER_FREQUENCY = get_var('waitinglist', 'confirmation_reminder_frequency', default=30) CONFIRMATION_REMINDER_FREQUENCY = get_var(
MAX_REMINDER_COUNT = get_var('waitinglist', 'max_reminder_count', default=3) "waitinglist", "confirmation_reminder_frequency", default=30
)
MAX_REMINDER_COUNT = get_var("waitinglist", "max_reminder_count", default=3)
# misc # misc
# the maximal number of members that get sent congratulations for highest activity on aprils fools day # the maximal number of members that get sent congratulations for highest activity on aprils fools day
CONGRATULATE_MEMBERS_MAX = get_var('misc', 'congratulate_members_max', default=10) CONGRATULATE_MEMBERS_MAX = get_var("misc", "congratulate_members_max", default=10)
# expiry duration of a good conduct certificate in months # expiry duration of a good conduct certificate in months
MAX_AGE_GOOD_CONDUCT_CERTIFICATE_MONTHS = get_var('misc', 'max_age_good_conduct_certificate_months', default=24) MAX_AGE_GOOD_CONDUCT_CERTIFICATE_MONTHS = get_var(
"misc", "max_age_good_conduct_certificate_months", default=24
)
# accepted email domains for inviting users # accepted email domains for inviting users
ALLOWED_EMAIL_DOMAINS_FOR_INVITE_AS_USER = get_var('misc', 'allowed_email_domains_for_invite_as_user', ALLOWED_EMAIL_DOMAINS_FOR_INVITE_AS_USER = get_var(
default=['example.org']) "misc", "allowed_email_domains_for_invite_as_user", default=["example.org"]
)
# send all mails from the assocation's contact mail or from personal association mails # send all mails from the assocation's contact mail or from personal association mails
SEND_FROM_ASSOCIATION_EMAIL = get_var('misc', 'send_from_association_email', default=False) SEND_FROM_ASSOCIATION_EMAIL = get_var("misc", "send_from_association_email", default=False)
# domain for association email and generated urls # domain for association email and generated urls
DOMAIN = get_var('misc', 'domain', default='example.org') DOMAIN = get_var("misc", "domain", default="example.org")
GROUP_CHECKLIST_N_WEEKS = get_var('misc', 'group_checklist_n_weeks', default=18) GROUP_CHECKLIST_N_WEEKS = get_var("misc", "group_checklist_n_weeks", default=18)
GROUP_CHECKLIST_N_MEMBERS = get_var('misc', 'group_checklist_n_members', default=20) GROUP_CHECKLIST_N_MEMBERS = get_var("misc", "group_checklist_n_members", default=20)
GROUP_CHECKLIST_TEXT = get_var('misc', 'group_checklist_text', GROUP_CHECKLIST_TEXT = get_var(
default="""Anwesende Jugendleitende und Teilnehmende werden mit einem "misc",
Kreuz ($\\times$) markiert und die ausgefüllte Liste zum Anfang der Gruppenstunde an der Kasse "group_checklist_text",
abgegeben. Zum Ende wird sie wieder abgeholt. Wenn die Punkte auf einer Karte fast aufgebraucht default="""Anwesende Jugendleitende und Teilnehmende werden mit einem
sind, notiert die Kasse die verbliebenen Eintritte (3, 2, 1) unter dem Kreuz.""") Kreuz ($\\times$) markiert und die ausgefüllte Liste zum Anfang der Gruppenstunde an der Kasse
abgegeben. Zum Ende wird sie wieder abgeholt. Wenn die Punkte auf einer Karte fast aufgebraucht
sind, notiert die Kasse die verbliebenen Eintritte (3, 2, 1) unter dem Kreuz.""",
)
# finance # finance
ALLOWANCE_PER_DAY = get_var('finance', 'allowance_per_day', default=22) ALLOWANCE_PER_DAY = get_var("finance", "allowance_per_day", default=22)
MAX_NIGHT_COST = get_var('finance', 'max_night_cost', default=11) MAX_NIGHT_COST = get_var("finance", "max_night_cost", default=11)
EXCURSION_ORG_FEE = get_var('finance', 'org_fee', default=10) EXCURSION_ORG_FEE = get_var("finance", "org_fee", default=10)
# links # links
CLOUD_LINK = get_var('links', 'cloud', default='https://startpage.com') CLOUD_LINK = get_var("links", "cloud", default="https://startpage.com")
DAV_360_LINK = get_var('links', 'dav_360', default='https://dav360.de') DAV_360_LINK = get_var("links", "dav_360", default="https://dav360.de")
WIKI_LINK = get_var('links', 'wiki', default='https://wikipedia.org') WIKI_LINK = get_var("links", "wiki", default="https://wikipedia.org")
DOCS_LINK = get_var('links', 'docs', default='https://github.com/chrisflav/kompass') DOCS_LINK = get_var("links", "docs", default="https://github.com/chrisflav/kompass")
REGISTRATION_FORM_DOWNLOAD_LINK = get_var('links', 'registration_form', default='https://startpage.com') REGISTRATION_FORM_DOWNLOAD_LINK = get_var(
"links", "registration_form", default="https://startpage.com"
)
# startpage # startpage
STARTPAGE_REDIRECT_URL = get_var('startpage', 'redirect_url', default='') STARTPAGE_REDIRECT_URL = get_var("startpage", "redirect_url", default="")
ROOT_SECTION = get_var('startpage', 'root_section', default='about') ROOT_SECTION = get_var("startpage", "root_section", default="about")
RECENT_SECTION = get_var('startpage', 'recent_section', default='recent') RECENT_SECTION = get_var("startpage", "recent_section", default="recent")
REPORTS_SECTION = get_var('startpage', 'reports_section', default='reports') REPORTS_SECTION = get_var("startpage", "reports_section", default="reports")
# testing # testing
TEST_MAIL = get_var('testing', 'mail', default='test@localhost') TEST_MAIL = get_var("testing", "mail", default="test@localhost")

@ -1,29 +1,35 @@
from django.test import TestCase, RequestFactory, override_settings from unittest.mock import Mock
from django.contrib.auth.models import User from unittest.mock import patch
from django.contrib import admin from django.contrib import admin
from unittest.mock import Mock, patch from django.contrib.auth.models import User
from jdav_web.views import media_unprotected, custom_admin_view from django.test import override_settings
from django.test import RequestFactory
from django.test import TestCase
from startpage.models import Link from startpage.models import Link
from jdav_web.views import custom_admin_view
from jdav_web.views import media_unprotected
class ViewsTestCase(TestCase): class ViewsTestCase(TestCase):
def setUp(self): def setUp(self):
self.factory = RequestFactory() self.factory = RequestFactory()
self.user = User.objects.create_user('testuser', 'test@example.com', 'password') self.user = User.objects.create_user("testuser", "test@example.com", "password")
Link.objects.create(title='Test Link', url='https://example.com') Link.objects.create(title="Test Link", url="https://example.com")
@override_settings(DEBUG=True) @override_settings(DEBUG=True)
def test_media_unprotected_debug_true(self): def test_media_unprotected_debug_true(self):
request = self.factory.get('/media/test.jpg') request = self.factory.get("/media/test.jpg")
with patch('jdav_web.views.serve') as mock_serve: with patch("jdav_web.views.serve") as mock_serve:
mock_serve.return_value = Mock() mock_serve.return_value = Mock()
result = media_unprotected(request, 'test.jpg') media_unprotected(request, "test.jpg")
mock_serve.assert_called_once() mock_serve.assert_called_once()
def test_custom_admin_view(self): def test_custom_admin_view(self):
request = self.factory.get('/admin/') request = self.factory.get("/admin/")
request.user = self.user request.user = self.user
with patch.object(admin.site, 'get_app_list') as mock_get_app_list: with patch.object(admin.site, "get_app_list") as mock_get_app_list:
mock_get_app_list.return_value = [] mock_get_app_list.return_value = []
response = custom_admin_view(request) response = custom_admin_view(request)
self.assertEqual(response.status_code, 200) self.assertEqual(response.status_code, 200)

@ -13,40 +13,44 @@ Including another URLconf
1. Import the include() function: from django.conf.urls import url, include 1. Import the include() function: from django.conf.urls import url, include
2. Add a URL to urlpatterns: url(r'^blog/', include('blog.urls')) 2. Add a URL to urlpatterns: url(r'^blog/', include('blog.urls'))
""" """
from django.urls import path, re_path, include
from django.contrib import admin
from django.conf.urls.static import static
from django.conf.urls.i18n import i18n_patterns from django.conf.urls.i18n import i18n_patterns
from django.conf import settings from django.contrib import admin
from django.urls import include
from django.urls import path
from django.urls import re_path
from django.utils.translation import gettext_lazy as _ from django.utils.translation import gettext_lazy as _
from django.views.generic.base import RedirectView from django.views.generic.base import RedirectView
from oauth2_provider import urls as oauth2_urls from oauth2_provider import urls as oauth2_urls
from .views import media_access from .views import media_access
admin.site.index_title = _('Startpage') admin.site.index_title = _("Startpage")
admin.site.site_header = 'Kompass' admin.site.site_header = "Kompass"
urlpatterns = i18n_patterns( urlpatterns = i18n_patterns(
re_path(r'^media/(?P<path>.*)', media_access, name='media'), re_path(r"^media/(?P<path>.*)", media_access, name="media"),
re_path(r'^kompass/?', admin.site.urls, name='kompass'), re_path(r"^kompass/?", admin.site.urls, name="kompass"),
re_path(r'^jet/', include('jet.urls', 'jet')), # Django JET URLS re_path(r"^jet/", include("jet.urls", "jet")), # Django JET URLS
re_path(r'^admin/?', RedirectView.as_view(url='/kompass')), re_path(r"^admin/?", RedirectView.as_view(url="/kompass")),
re_path(r'^newsletter/', include('mailer.urls', namespace="mailer")), re_path(r"^newsletter/", include("mailer.urls", namespace="mailer")),
re_path(r'^members/', include('members.urls', namespace="members")), re_path(r"^members/", include("members.urls", namespace="members")),
re_path(r'^login/', include('logindata.urls', namespace="logindata")), re_path(r"^login/", include("logindata.urls", namespace="logindata")),
re_path(r'^LBAlpin/Programm(/)?(20)?[0-9]{0,2}', include('ludwigsburgalpin.urls', re_path(
namespace="ludwigsburgalpin")), r"^LBAlpin/Programm(/)?(20)?[0-9]{0,2}",
re_path(r'^_nested_admin/', include('nested_admin.urls')), include("ludwigsburgalpin.urls", namespace="ludwigsburgalpin"),
path('o/', include(oauth2_urls)), ),
re_path(r'^', include('startpage.urls', namespace="startpage")), re_path(r"^_nested_admin/", include("nested_admin.urls")),
path("o/", include(oauth2_urls)),
re_path(r"^", include("startpage.urls", namespace="startpage")),
) )
urlpatterns += [ urlpatterns += [
re_path(r'^markdownx/', include('markdownx.urls')), re_path(r"^markdownx/", include("markdownx.urls")),
] ]
handler404 = 'startpage.views.handler404' handler404 = "startpage.views.handler404"
handler500 = 'startpage.views.handler500' handler500 = "startpage.views.handler500"
# TODO: django serving from MEDIA_URL should be disabled in production stage # TODO: django serving from MEDIA_URL should be disabled in production stage
# see # see

@ -1,11 +1,12 @@
from django.http import HttpResponse import re
from django.views.static import serve
from django.conf import settings from django.conf import settings
from django.contrib.admin.views.decorators import staff_member_required
from django.contrib import admin from django.contrib import admin
from django.contrib.admin.views.decorators import staff_member_required
from django.http import HttpResponse
from django.shortcuts import render from django.shortcuts import render
from django.views.static import serve
from startpage.models import Link from startpage.models import Link
import re
def media_unprotected(request, path): def media_unprotected(request, path):
@ -15,8 +16,8 @@ def media_unprotected(request, path):
# otherwise create a redirect to the internal nginx endpoint at /protected # otherwise create a redirect to the internal nginx endpoint at /protected
response = HttpResponse() response = HttpResponse()
# Content-type will be detected by nginx # Content-type will be detected by nginx
del response['Content-Type'] del response["Content-Type"]
response['X-Accel-Redirect'] = '/protected/' + path response["X-Accel-Redirect"] = "/protected/" + path
return response return response
@ -26,7 +27,7 @@ def media_protected(request, path):
def media_access(request, path): def media_access(request, path):
if re.match('^(people|images)/', path): if re.match("^(people|images)/", path):
return media_unprotected(request, path) return media_unprotected(request, path)
else: else:
return media_protected(request, path) return media_protected(request, path)
@ -38,11 +39,12 @@ def custom_admin_view(request):
""" """
app_list = admin.site.get_app_list(request) app_list = admin.site.get_app_list(request)
context = { context = {
'app_list': app_list, "app_list": app_list,
'site_header': admin.site.site_header, "site_header": admin.site.site_header,
'site_title': admin.site.site_title, "site_title": admin.site.site_title,
'external_links': Link.objects.all() "external_links": Link.objects.all(),
} }
return render(request, 'admin/index.html', context) return render(request, "admin/index.html", context)
admin.site.index = custom_admin_view admin.site.index = custom_admin_view

@ -8,7 +8,7 @@ msgid ""
msgstr "" msgstr ""
"Project-Id-Version: PACKAGE VERSION\n" "Project-Id-Version: PACKAGE VERSION\n"
"Report-Msgid-Bugs-To: \n" "Report-Msgid-Bugs-To: \n"
"POT-Creation-Date: 2025-04-15 23:05+0200\n" "POT-Creation-Date: 2025-12-06 00:00+0100\n"
"PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\n" "PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\n"
"Last-Translator: FULL NAME <EMAIL@ADDRESS>\n" "Last-Translator: FULL NAME <EMAIL@ADDRESS>\n"
"Language-Team: LANGUAGE <LL@li.org>\n" "Language-Team: LANGUAGE <LL@li.org>\n"
@ -135,6 +135,18 @@ msgstr ""
msgid "You entered a wrong password." msgid "You entered a wrong password."
msgstr "Das eingegebene Passwort ist falsch." msgstr "Das eingegebene Passwort ist falsch."
#: templates/admin/base.html
msgid "My Profile"
msgstr "Mein Profil"
#: templates/admin/base.html
msgid "Change password"
msgstr "Passwort ändern"
#: templates/admin/base.html
msgid "Log out"
msgstr "Abmelden"
#: templates/admin/delete_confirmation.html #: templates/admin/delete_confirmation.html
#, python-format #, python-format
msgid "" msgid ""

@ -1,10 +1,16 @@
from django.utils.translation import gettext_lazy as _
from django.contrib import admin from django.contrib import admin
from django.contrib.auth.admin import UserAdmin as BaseUserAdmin, GroupAdmin as BaseAuthGroupAdmin from django.contrib.auth.admin import GroupAdmin as BaseAuthGroupAdmin
from django.contrib.auth.models import User as BaseUser, Group as BaseAuthGroup from django.contrib.auth.admin import UserAdmin as BaseUserAdmin
from .models import AuthGroup, LoginDatum, RegistrationPassword from django.contrib.auth.models import Group as BaseAuthGroup
from django.contrib.auth.models import User as BaseUser
from django.utils.translation import gettext_lazy as _
from members.models import Member from members.models import Member
from .models import AuthGroup
from .models import LoginDatum
from .models import RegistrationPassword
# Register your models here. # Register your models here.
class AuthGroupAdmin(BaseAuthGroupAdmin): class AuthGroupAdmin(BaseAuthGroupAdmin):
pass pass
@ -17,8 +23,8 @@ class UserInline(admin.StackedInline):
class LoginDatumAdmin(BaseUserAdmin): class LoginDatumAdmin(BaseUserAdmin):
list_display = ('username', 'is_superuser') list_display = ("username", "is_superuser")
#inlines = [UserInline] # inlines = [UserInline]
fieldsets = ( fieldsets = (
(None, {"fields": ("username", "password")}), (None, {"fields": ("username", "password")}),
( (
@ -45,6 +51,7 @@ class LoginDatumAdmin(BaseUserAdmin):
), ),
) )
admin.site.unregister(BaseUser) admin.site.unregister(BaseUser)
admin.site.unregister(BaseAuthGroup) admin.site.unregister(BaseAuthGroup)
admin.site.register(LoginDatum, LoginDatumAdmin) admin.site.register(LoginDatum, LoginDatumAdmin)

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

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

@ -4,14 +4,16 @@ from django.db import migrations
class Migration(migrations.Migration): class Migration(migrations.Migration):
dependencies = [ dependencies = [
('logindata', '0001_initial'), ("logindata", "0001_initial"),
] ]
operations = [ operations = [
migrations.AlterModelOptions( migrations.AlterModelOptions(
name='registrationpassword', name="registrationpassword",
options={'verbose_name': 'Active registration password', 'verbose_name_plural': 'Active registration passwords'}, options={
"verbose_name": "Active registration password",
"verbose_name_plural": "Active registration passwords",
},
), ),
] ]

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

@ -7,7 +7,7 @@ class CustomOAuth2Validator(OAuth2Validator):
def get_additional_claims(self, request): def get_additional_claims(self, request):
if request.user.member: if request.user.member:
context = {'email': request.user.member.email} context = {"email": request.user.member.email}
else: else:
context = {} context = {}
return dict(context, preferred_username=request.user.username) return dict(context, preferred_username=request.user.username)

@ -1,2 +1,4 @@
# ruff: noqa F403
from .oauth import *
from .views import * from .views import *
from .oauth import *

@ -1,9 +1,11 @@
from django.test import TestCase
from django.contrib.auth.models import User
from django.conf import settings
from unittest.mock import Mock from unittest.mock import Mock
from django.conf import settings
from django.contrib.auth.models import User
from django.test import TestCase
from logindata.oauth import CustomOAuth2Validator from logindata.oauth import CustomOAuth2Validator
from members.models import Member, MALE from members.models import MALE
from members.models import Member
class CustomOAuth2ValidatorTestCase(TestCase): class CustomOAuth2ValidatorTestCase(TestCase):
@ -13,8 +15,12 @@ class CustomOAuth2ValidatorTestCase(TestCase):
# Create user with member # Create user with member
self.user_with_member = User.objects.create_user(username="alice", password="test123") self.user_with_member = User.objects.create_user(username="alice", password="test123")
self.member = Member.objects.create( self.member = Member.objects.create(
prename="Alice", lastname="Smith", birth_date="1990-01-01", prename="Alice",
email=settings.TEST_MAIL, gender=MALE, user=self.user_with_member lastname="Smith",
birth_date="1990-01-01",
email=settings.TEST_MAIL,
gender=MALE,
user=self.user_with_member,
) )
# Create user without member # Create user without member
@ -27,8 +33,8 @@ class CustomOAuth2ValidatorTestCase(TestCase):
result = self.validator.get_additional_claims(request) result = self.validator.get_additional_claims(request)
self.assertEqual(result['email'], settings.TEST_MAIL) self.assertEqual(result["email"], settings.TEST_MAIL)
self.assertEqual(result['preferred_username'], 'alice') self.assertEqual(result["preferred_username"], "alice")
def test_get_additional_claims_without_member(self): def test_get_additional_claims_without_member(self):
"""Test get_additional_claims when user has no member""" """Test get_additional_claims when user has no member"""

@ -1,12 +1,16 @@
from http import HTTPStatus from http import HTTPStatus
from django.test import TestCase, Client
from django.contrib.auth.models import Group
from django.contrib.auth.models import User
from django.test import Client
from django.test import TestCase
from django.urls import reverse from django.urls import reverse
from django.utils import timezone from django.utils import timezone
from django.utils.translation import gettext as _ from django.utils.translation import gettext as _
from django.contrib.auth.models import User, Group from members.models import DIVERSE
from members.models import Member
from members.models import Member, DIVERSE from ..models import RegistrationPassword
from ..models import RegistrationPassword, initial_user_setup
class RegistrationPasswordTestCase(TestCase): class RegistrationPasswordTestCase(TestCase):
@ -22,133 +26,152 @@ class RegisterViewTestCase(TestCase):
# Create a test member with invite key # Create a test member with invite key
self.member = Member.objects.create( self.member = Member.objects.create(
prename='Test', prename="Test",
lastname='User', lastname="User",
birth_date=timezone.now().date(), birth_date=timezone.now().date(),
email='test@example.com', email="test@example.com",
gender=DIVERSE, gender=DIVERSE,
invite_as_user_key='test_key_123' invite_as_user_key="test_key_123",
) )
# Create a registration password # Create a registration password
self.registration_password = RegistrationPassword.objects.create( self.registration_password = RegistrationPassword.objects.create(password="test_password")
password='test_password'
)
# Get or create Standard group for user setup # Get or create Standard group for user setup
self.standard_group, created = Group.objects.get_or_create(name='Standard') self.standard_group, created = Group.objects.get_or_create(name="Standard")
def test_register_get_without_key_redirects(self): def test_register_get_without_key_redirects(self):
"""Test GET request without key redirects to startpage.""" """Test GET request without key redirects to startpage."""
url = reverse('logindata:register') url = reverse("logindata:register")
response = self.client.get(url) response = self.client.get(url)
self.assertEqual(response.status_code, HTTPStatus.FOUND) self.assertEqual(response.status_code, HTTPStatus.FOUND)
def test_register_post_without_key_redirects(self): def test_register_post_without_key_redirects(self):
"""Test POST request without key redirects to startpage.""" """Test POST request without key redirects to startpage."""
url = reverse('logindata:register') url = reverse("logindata:register")
response = self.client.post(url) response = self.client.post(url)
self.assertEqual(response.status_code, HTTPStatus.FOUND) self.assertEqual(response.status_code, HTTPStatus.FOUND)
def test_register_get_with_empty_key_shows_failed(self): def test_register_get_with_empty_key_shows_failed(self):
"""Test GET request with empty key shows registration failed page.""" """Test GET request with empty key shows registration failed page."""
url = reverse('logindata:register') url = reverse("logindata:register")
response = self.client.get(url, {'key': ''}) response = self.client.get(url, {"key": ""})
self.assertEqual(response.status_code, HTTPStatus.OK) self.assertEqual(response.status_code, HTTPStatus.OK)
self.assertContains(response, _('Something went wrong. The registration key is invalid or has expired.')) self.assertContains(
response, _("Something went wrong. The registration key is invalid or has expired.")
)
def test_register_get_with_invalid_key_shows_failed(self): def test_register_get_with_invalid_key_shows_failed(self):
"""Test GET request with invalid key shows registration failed page.""" """Test GET request with invalid key shows registration failed page."""
url = reverse('logindata:register') url = reverse("logindata:register")
response = self.client.get(url, {'key': 'invalid_key'}) response = self.client.get(url, {"key": "invalid_key"})
self.assertEqual(response.status_code, HTTPStatus.OK) self.assertEqual(response.status_code, HTTPStatus.OK)
self.assertContains(response, _('Something went wrong. The registration key is invalid or has expired.')) self.assertContains(
response, _("Something went wrong. The registration key is invalid or has expired.")
)
def test_register_get_with_valid_key_shows_password_form(self): def test_register_get_with_valid_key_shows_password_form(self):
"""Test GET request with valid key shows password entry form.""" """Test GET request with valid key shows password entry form."""
url = reverse('logindata:register') url = reverse("logindata:register")
response = self.client.get(url, {'key': self.member.invite_as_user_key}) response = self.client.get(url, {"key": self.member.invite_as_user_key})
self.assertEqual(response.status_code, HTTPStatus.OK) self.assertEqual(response.status_code, HTTPStatus.OK)
self.assertContains(response, _('Set login data')) self.assertContains(response, _("Set login data"))
self.assertContains(response, _('Welcome, ')) self.assertContains(response, _("Welcome, "))
self.assertContains(response, self.member.prename) self.assertContains(response, self.member.prename)
def test_register_post_without_password_shows_failed(self): def test_register_post_without_password_shows_failed(self):
"""Test POST request without password shows registration failed page.""" """Test POST request without password shows registration failed page."""
url = reverse('logindata:register') url = reverse("logindata:register")
response = self.client.post(url, {'key': self.member.invite_as_user_key}) response = self.client.post(url, {"key": self.member.invite_as_user_key})
self.assertEqual(response.status_code, HTTPStatus.OK) self.assertEqual(response.status_code, HTTPStatus.OK)
self.assertContains(response, _('Something went wrong. The registration key is invalid or has expired.')) self.assertContains(
response, _("Something went wrong. The registration key is invalid or has expired.")
)
def test_register_post_with_wrong_password_shows_error(self): def test_register_post_with_wrong_password_shows_error(self):
"""Test POST request with wrong password shows error message.""" """Test POST request with wrong password shows error message."""
url = reverse('logindata:register') url = reverse("logindata:register")
response = self.client.post(url, { response = self.client.post(
'key': self.member.invite_as_user_key, url, {"key": self.member.invite_as_user_key, "password": "wrong_password"}
'password': 'wrong_password' )
})
self.assertEqual(response.status_code, HTTPStatus.OK) self.assertEqual(response.status_code, HTTPStatus.OK)
self.assertContains(response, _('You entered a wrong password.')) self.assertContains(response, _("You entered a wrong password."))
def test_register_post_with_correct_password_shows_form(self): def test_register_post_with_correct_password_shows_form(self):
"""Test POST request with correct password shows user creation form.""" """Test POST request with correct password shows user creation form."""
url = reverse('logindata:register') url = reverse("logindata:register")
response = self.client.post(url, { response = self.client.post(
'key': self.member.invite_as_user_key, url,
'password': self.registration_password.password {
}) "key": self.member.invite_as_user_key,
"password": self.registration_password.password,
},
)
self.assertEqual(response.status_code, HTTPStatus.OK) self.assertEqual(response.status_code, HTTPStatus.OK)
self.assertContains(response, _('Set login data')) self.assertContains(response, _("Set login data"))
self.assertContains(response, self.member.suggested_username()) self.assertContains(response, self.member.suggested_username())
def test_register_post_with_save_and_invalid_form_shows_errors(self): def test_register_post_with_save_and_invalid_form_shows_errors(self):
"""Test POST request with save but invalid form shows form errors.""" """Test POST request with save but invalid form shows form errors."""
url = reverse('logindata:register') url = reverse("logindata:register")
response = self.client.post(url, { response = self.client.post(
'key': self.member.invite_as_user_key, url,
'password': self.registration_password.password, {
'save': 'true', "key": self.member.invite_as_user_key,
'username': '', # Invalid - empty username "password": self.registration_password.password,
'password1': 'testpass123', "save": "true",
'password2': 'different_pass' # Invalid - passwords don't match "username": "", # Invalid - empty username
}) "password1": "testpass123",
"password2": "different_pass", # Invalid - passwords don't match
},
)
self.assertEqual(response.status_code, HTTPStatus.OK) self.assertEqual(response.status_code, HTTPStatus.OK)
self.assertContains(response, _('Set login data')) self.assertContains(response, _("Set login data"))
def test_register_post_with_save_and_valid_form_shows_success(self): def test_register_post_with_save_and_valid_form_shows_success(self):
"""Test POST request with save and valid form shows success page.""" """Test POST request with save and valid form shows success page."""
url = reverse('logindata:register') url = reverse("logindata:register")
response = self.client.post(url, { response = self.client.post(
'key': self.member.invite_as_user_key, url,
'password': self.registration_password.password, {
'save': 'true', "key": self.member.invite_as_user_key,
'username': 'testuser', "password": self.registration_password.password,
'password1': 'testpass123', "save": "true",
'password2': 'testpass123' "username": "testuser",
}) "password1": "testpass123",
"password2": "testpass123",
},
)
self.assertEqual(response.status_code, HTTPStatus.OK) self.assertEqual(response.status_code, HTTPStatus.OK)
self.assertContains(response, _('You successfully set your login data. You can now proceed to')) self.assertContains(
response, _("You successfully set your login data. You can now proceed to")
)
# Verify user was created and associated with member # Verify user was created and associated with member
user = User.objects.get(username='testuser') user = User.objects.get(username="testuser")
self.assertEqual(user.is_staff, True) self.assertEqual(user.is_staff, True)
self.member.refresh_from_db() self.member.refresh_from_db()
self.assertEqual(self.member.user, user) self.assertEqual(self.member.user, user)
self.assertEqual(self.member.invite_as_user_key, '') self.assertEqual(self.member.invite_as_user_key, "")
def test_register_post_with_save_and_no_standard_group_shows_failed(self): def test_register_post_with_save_and_no_standard_group_shows_failed(self):
"""Test POST request with save but no Standard group shows failed page.""" """Test POST request with save but no Standard group shows failed page."""
# Delete the Standard group # Delete the Standard group
self.standard_group.delete() self.standard_group.delete()
url = reverse('logindata:register') url = reverse("logindata:register")
response = self.client.post(url, { response = self.client.post(
'key': self.member.invite_as_user_key, url,
'password': self.registration_password.password, {
'save': 'true', "key": self.member.invite_as_user_key,
'username': 'testuser', "password": self.registration_password.password,
'password1': 'testpass123', "save": "true",
'password2': 'testpass123' "username": "testuser",
}) "password1": "testpass123",
"password2": "testpass123",
},
)
self.assertEqual(response.status_code, HTTPStatus.OK) self.assertEqual(response.status_code, HTTPStatus.OK)
self.assertContains(response, _('Something went wrong. The registration key is invalid or has expired.')) self.assertContains(
response, _("Something went wrong. The registration key is invalid or has expired.")
)

@ -4,5 +4,5 @@ from . import views
app_name = "logindata" app_name = "logindata"
urlpatterns = [ urlpatterns = [
re_path(r'^register', views.register , name='register'), re_path(r"^register", views.register, name="register"),
] ]

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

@ -1,26 +1,23 @@
import os import xlsxwriter
from contrib.media import ensure_media_dir
from contrib.media import media_path
from contrib.media import serve_media
from django.contrib import admin from django.contrib import admin
from wsgiref.util import FileWrapper
from django.http import HttpResponse
from django.conf import settings
from .models import Termin
from contrib.media import media_path, serve_media, ensure_media_dir
import xlsxwriter from .models import Termin
class TerminAdmin(admin.ModelAdmin): class TerminAdmin(admin.ModelAdmin):
list_display = ('title','start_date', 'end_date', 'group', 'category', 'responsible') list_display = ("title", "start_date", "end_date", "group", "category", "responsible")
list_filter = ('group',) list_filter = ("group",)
ordering = ('start_date','end_date') ordering = ("start_date", "end_date")
actions = ['make_overview'] actions = ["make_overview"]
def make_overview(self, request, queryset): def make_overview(self, request, queryset):
ensure_media_dir() ensure_media_dir()
filename = 'termine.xlsx' filename = "termine.xlsx"
workbook = xlsxwriter.Workbook(media_path(filename)) workbook = xlsxwriter.Workbook(media_path(filename))
bold = workbook.add_format({'bold': True}) bold = workbook.add_format({"bold": True})
worksheet = workbook.add_worksheet() worksheet = workbook.add_worksheet()
worksheet.write(0, 0, "Titel", bold) worksheet.write(0, 0, "Titel", bold)
worksheet.write(0, 1, "Untertitel", bold) worksheet.write(0, 1, "Untertitel", bold)
@ -44,30 +41,32 @@ class TerminAdmin(admin.ModelAdmin):
worksheet.write(0, 19, "Telefonnummer", bold) worksheet.write(0, 19, "Telefonnummer", bold)
worksheet.write(0, 20, "Emailadresse", bold) worksheet.write(0, 20, "Emailadresse", bold)
for row, termin in enumerate(queryset): for row, termin in enumerate(queryset):
worksheet.write(row+2, 0, termin.title) worksheet.write(row + 2, 0, termin.title)
worksheet.write(row+2, 1, termin.subtitle) worksheet.write(row + 2, 1, termin.subtitle)
worksheet.write(row+2, 2, termin.start_date.strftime('%d.%m.%Y')) worksheet.write(row + 2, 2, termin.start_date.strftime("%d.%m.%Y"))
worksheet.write(row+2, 3, termin.end_date.strftime('%d.%m.%Y')) worksheet.write(row + 2, 3, termin.end_date.strftime("%d.%m.%Y"))
worksheet.write(row+2, 4, termin.group) worksheet.write(row + 2, 4, termin.group)
worksheet.write(row+2, 5, termin.category) worksheet.write(row + 2, 5, termin.category)
worksheet.write(row+2, 6, termin.technik) worksheet.write(row + 2, 6, termin.technik)
worksheet.write(row+2, 7, termin.condition) worksheet.write(row + 2, 7, termin.condition)
worksheet.write(row+2, 8, termin.saison) worksheet.write(row + 2, 8, termin.saison)
worksheet.write(row+2, 9, termin.eventart) worksheet.write(row + 2, 9, termin.eventart)
worksheet.write(row+2, 10, termin.klassifizierung) worksheet.write(row + 2, 10, termin.klassifizierung)
worksheet.write(row+2, 11, termin.anforderung_hoehe) worksheet.write(row + 2, 11, termin.anforderung_hoehe)
worksheet.write(row+2, 12, termin.anforderung_strecke) worksheet.write(row + 2, 12, termin.anforderung_strecke)
worksheet.write(row+2, 13, termin.anforderung_dauer) worksheet.write(row + 2, 13, termin.anforderung_dauer)
worksheet.write(row+2, 14, termin.voraussetzungen) worksheet.write(row + 2, 14, termin.voraussetzungen)
worksheet.write(row+2, 15, termin.description) worksheet.write(row + 2, 15, termin.description)
worksheet.write(row+2, 16, termin.equipment) worksheet.write(row + 2, 16, termin.equipment)
worksheet.write(row+2, 17, termin.max_participants) worksheet.write(row + 2, 17, termin.max_participants)
worksheet.write(row+2, 18, termin.responsible) worksheet.write(row + 2, 18, termin.responsible)
worksheet.write(row+2, 19, termin.phone) worksheet.write(row + 2, 19, termin.phone)
worksheet.write(row+2, 20, termin.email) worksheet.write(row + 2, 20, termin.email)
workbook.close() workbook.close()
return serve_media(filename, 'application/xlsx') return serve_media(filename, "application/xlsx")
make_overview.short_description = "Termine in Excel Liste überführen" make_overview.short_description = "Termine in Excel Liste überführen"
# Register your models here. # Register your models here.
admin.site.register(Termin, TerminAdmin) admin.site.register(Termin, TerminAdmin)

@ -2,4 +2,4 @@ from django.apps import AppConfig
class LudwigsburgalpinConfig(AppConfig): class LudwigsburgalpinConfig(AppConfig):
name = 'ludwigsburgalpin' name = "ludwigsburgalpin"

@ -1,46 +1,194 @@
# Generated by Django 4.0.1 on 2023-03-29 20:40 # Generated by Django 4.0.1 on 2023-03-29 20:40
import django.core.validators import django.core.validators
from django.db import migrations, models from django.db import migrations
from django.db import models
class Migration(migrations.Migration): class Migration(migrations.Migration):
replaces = [
replaces = [('ludwigsburgalpin', '0001_initial'), ('ludwigsburgalpin', '0002_auto_20190926_1432'), ('ludwigsburgalpin', '0003_auto_20190926_1749'), ('ludwigsburgalpin', '0004_alter_termin_id'), ('ludwigsburgalpin', '0005_alter_termin_id'), ('ludwigsburgalpin', '0006_termin_anforderung_dauer_termin_anforderung_hoehe_and_more'), ('ludwigsburgalpin', '0007_alter_termin_group')] ("ludwigsburgalpin", "0001_initial"),
("ludwigsburgalpin", "0002_auto_20190926_1432"),
dependencies = [ ("ludwigsburgalpin", "0003_auto_20190926_1749"),
("ludwigsburgalpin", "0004_alter_termin_id"),
("ludwigsburgalpin", "0005_alter_termin_id"),
("ludwigsburgalpin", "0006_termin_anforderung_dauer_termin_anforderung_hoehe_and_more"),
("ludwigsburgalpin", "0007_alter_termin_group"),
] ]
dependencies = []
operations = [ operations = [
migrations.CreateModel( migrations.CreateModel(
name='Termin', name="Termin",
fields=[ fields=[
('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), (
('title', models.CharField(max_length=100, verbose_name='Titel')), "id",
('start_date', models.DateField(verbose_name='Von')), models.AutoField(
('end_date', models.DateField(verbose_name='Bis')), auto_created=True, primary_key=True, serialize=False, verbose_name="ID"
('group', models.CharField(choices=[('ASG', 'Alpinsportgruppe'), ('OGB', 'Ortsgruppe Bietigheim'), ('OGV', 'Ortsgruppe Vaihingen'), ('JUG', 'Jugend'), ('FAM', 'Familie'), ('Ü30', 'Ü30'), ('MTB', 'Mountainbike'), ('RA', 'RegioAktiv'), ('SEK', 'Sektion')], max_length=100, verbose_name='Gruppe')), ),
('description', models.TextField(blank=True, verbose_name='Beschreibung')), ),
('email', models.EmailField(max_length=100, verbose_name='Email')), ("title", models.CharField(max_length=100, verbose_name="Titel")),
('phone', models.CharField(blank=True, max_length=20, verbose_name='Telefonnumer')), ("start_date", models.DateField(verbose_name="Von")),
('responsible', models.CharField(max_length=100, verbose_name='Organisator')), ("end_date", models.DateField(verbose_name="Bis")),
('anforderung_dauer', models.IntegerField(blank=True, default=0, validators=[django.core.validators.MinValueValidator(0)], verbose_name='Etappendauer in Stunden')), (
('anforderung_hoehe', models.IntegerField(blank=True, default=0, validators=[django.core.validators.MinValueValidator(0)], verbose_name='Höhenmeter in Meter')), "group",
('anforderung_strecke', models.IntegerField(blank=True, default=0, validators=[django.core.validators.MinValueValidator(0)], verbose_name='Strecke in Kilometer')), models.CharField(
('category', models.CharField(choices=[('WAN', 'Wandern'), ('BW', 'Bergwandern'), ('KST', 'Klettersteig'), ('KL', 'Klettern'), ('SKI', 'Piste, Loipe'), ('SCH', 'Schneeschuhgehen'), ('ST', 'Skitour'), ('STH', 'Skihochtour'), ('HT', 'Hochtour'), ('MTB', 'Montainbike'), ('AUS', 'Ausbildung'), ('SON', 'Sonstiges z.B. Treffen')], default='SON', max_length=100, verbose_name='Kategorie')), choices=[
('condition', models.CharField(choices=[('gering', 'gering'), ('mittel', 'mittel'), ('groß', 'groß'), ('sehr groß', 'sehr groß')], default='mittel', max_length=100, verbose_name='Kondition')), ("ASG", "Alpinsportgruppe"),
('equipment', models.TextField(blank=True, verbose_name='Ausrüstung')), ("OGB", "Ortsgruppe Bietigheim"),
('eventart', models.CharField(choices=[('Einzeltermin', 'Einzeltermin'), ('Mehrtagesevent', 'Mehrtagesevent'), ('Regelmäßiges Event/Training', 'Regelmäßiges Event/Training'), ('Tagesevent', 'Tagesevent'), ('Wochenendevent', 'Wochenendevent')], default='Einzeltermin', max_length=100, verbose_name='Eventart')), ("OGV", "Ortsgruppe Vaihingen"),
('klassifizierung', models.CharField(choices=[('Gemeinschaftstour', 'Gemeinschaftstour'), ('Ausbildung', 'Ausbildung')], default='Gemeinschaftstour', max_length=100, verbose_name='Klassifizierung')), ("JUG", "Jugend"),
('max_participants', models.IntegerField(default=10, validators=[django.core.validators.MinValueValidator(1)], verbose_name='Max. Teilnehmerzahl')), ("FAM", "Familie"),
('saison', models.CharField(choices=[('ganzjährig', 'ganzjährig'), ('Indoor', 'Indoor'), ('Sommer', 'Sommer'), ('Winter', 'Winter')], default='ganzjährig', max_length=100, verbose_name='Saison')), ("Ü30", "Ü30"),
('subtitle', models.CharField(blank=True, max_length=100, verbose_name='Untertitel')), ("MTB", "Mountainbike"),
('technik', models.CharField(choices=[('leicht', 'leicht'), ('mittel', 'mittel'), ('schwer', 'schwer'), ('sehr schwer', 'sehr schwer')], default='mittel', max_length=100, verbose_name='Technik')), ("RA", "RegioAktiv"),
('voraussetzungen', models.TextField(blank=True, verbose_name='Voraussetzungen')), ("SEK", "Sektion"),
],
max_length=100,
verbose_name="Gruppe",
),
),
("description", models.TextField(blank=True, verbose_name="Beschreibung")),
("email", models.EmailField(max_length=100, verbose_name="Email")),
("phone", models.CharField(blank=True, max_length=20, verbose_name="Telefonnumer")),
("responsible", models.CharField(max_length=100, verbose_name="Organisator")),
(
"anforderung_dauer",
models.IntegerField(
blank=True,
default=0,
validators=[django.core.validators.MinValueValidator(0)],
verbose_name="Etappendauer in Stunden",
),
),
(
"anforderung_hoehe",
models.IntegerField(
blank=True,
default=0,
validators=[django.core.validators.MinValueValidator(0)],
verbose_name="Höhenmeter in Meter",
),
),
(
"anforderung_strecke",
models.IntegerField(
blank=True,
default=0,
validators=[django.core.validators.MinValueValidator(0)],
verbose_name="Strecke in Kilometer",
),
),
(
"category",
models.CharField(
choices=[
("WAN", "Wandern"),
("BW", "Bergwandern"),
("KST", "Klettersteig"),
("KL", "Klettern"),
("SKI", "Piste, Loipe"),
("SCH", "Schneeschuhgehen"),
("ST", "Skitour"),
("STH", "Skihochtour"),
("HT", "Hochtour"),
("MTB", "Montainbike"),
("AUS", "Ausbildung"),
("SON", "Sonstiges z.B. Treffen"),
],
default="SON",
max_length=100,
verbose_name="Kategorie",
),
),
(
"condition",
models.CharField(
choices=[
("gering", "gering"),
("mittel", "mittel"),
("groß", "groß"),
("sehr groß", "sehr groß"),
],
default="mittel",
max_length=100,
verbose_name="Kondition",
),
),
("equipment", models.TextField(blank=True, verbose_name="Ausrüstung")),
(
"eventart",
models.CharField(
choices=[
("Einzeltermin", "Einzeltermin"),
("Mehrtagesevent", "Mehrtagesevent"),
("Regelmäßiges Event/Training", "Regelmäßiges Event/Training"),
("Tagesevent", "Tagesevent"),
("Wochenendevent", "Wochenendevent"),
],
default="Einzeltermin",
max_length=100,
verbose_name="Eventart",
),
),
(
"klassifizierung",
models.CharField(
choices=[
("Gemeinschaftstour", "Gemeinschaftstour"),
("Ausbildung", "Ausbildung"),
],
default="Gemeinschaftstour",
max_length=100,
verbose_name="Klassifizierung",
),
),
(
"max_participants",
models.IntegerField(
default=10,
validators=[django.core.validators.MinValueValidator(1)],
verbose_name="Max. Teilnehmerzahl",
),
),
(
"saison",
models.CharField(
choices=[
("ganzjährig", "ganzjährig"),
("Indoor", "Indoor"),
("Sommer", "Sommer"),
("Winter", "Winter"),
],
default="ganzjährig",
max_length=100,
verbose_name="Saison",
),
),
(
"subtitle",
models.CharField(blank=True, max_length=100, verbose_name="Untertitel"),
),
(
"technik",
models.CharField(
choices=[
("leicht", "leicht"),
("mittel", "mittel"),
("schwer", "schwer"),
("sehr schwer", "sehr schwer"),
],
default="mittel",
max_length=100,
verbose_name="Technik",
),
),
("voraussetzungen", models.TextField(blank=True, verbose_name="Voraussetzungen")),
], ],
options={ options={
'verbose_name_plural': 'Termine', "verbose_name_plural": "Termine",
'verbose_name': 'Termin', "verbose_name": "Termin",
}, },
), ),
] ]

@ -1,121 +1,128 @@
from django.db import models
from django.core.validators import MinValueValidator from django.core.validators import MinValueValidator
from django.db import models
GRUPPE = [ GRUPPE = [
('ASG', 'Alpinsportgruppe'), ("ASG", "Alpinsportgruppe"),
('OGB', 'Ortsgruppe Bietigheim'), ("OGB", "Ortsgruppe Bietigheim"),
('OGV', 'Ortsgruppe Vaihingen'), ("OGV", "Ortsgruppe Vaihingen"),
('JUG', 'Jugend'), ("JUG", "Jugend"),
('FAM', 'Familie'), ("FAM", "Familie"),
('Ü30', 'Ü30'), ("Ü30", "Ü30"),
('MTB', 'Mountainbike'), ("MTB", "Mountainbike"),
('RA', 'RegioAktiv'), ("RA", "RegioAktiv"),
('SEK', 'Sektion'), ("SEK", "Sektion"),
] ]
KATEGORIE = [ KATEGORIE = [
('WAN', 'Wandern'), ("WAN", "Wandern"),
('BW', 'Bergwandern'), ("BW", "Bergwandern"),
('KST', 'Klettersteig'), ("KST", "Klettersteig"),
('KL', 'Klettern'), ("KL", "Klettern"),
('SKI', 'Piste, Loipe'), ("SKI", "Piste, Loipe"),
('SCH', 'Schneeschuhgehen'), ("SCH", "Schneeschuhgehen"),
('ST', 'Skitour'), ("ST", "Skitour"),
('STH', 'Skihochtour'), ("STH", "Skihochtour"),
('HT', 'Hochtour'), ("HT", "Hochtour"),
('MTB', 'Montainbike'), ("MTB", "Montainbike"),
('AUS', 'Ausbildung'), ("AUS", "Ausbildung"),
('SON', 'Sonstiges z.B. Treffen') ("SON", "Sonstiges z.B. Treffen"),
] ]
KONDITION = [ KONDITION = [
('gering', 'gering'), ("gering", "gering"),
('mittel', 'mittel'), ("mittel", "mittel"),
('groß', 'groß'), ("groß", "groß"),
('sehr groß', 'sehr groß'), ("sehr groß", "sehr groß"),
] ]
TECHNIK = [ TECHNIK = [
('leicht', 'leicht'), ("leicht", "leicht"),
('mittel', 'mittel'), ("mittel", "mittel"),
('schwer', 'schwer'), ("schwer", "schwer"),
('sehr schwer', 'sehr schwer'), ("sehr schwer", "sehr schwer"),
] ]
SAISON = [ SAISON = [
('ganzjährig','ganzjährig'), ("ganzjährig", "ganzjährig"),
('Indoor', 'Indoor'), ("Indoor", "Indoor"),
('Sommer', 'Sommer'), ("Sommer", "Sommer"),
('Winter', 'Winter'), ("Winter", "Winter"),
] ]
EVENTART = [ EVENTART = [
('Einzeltermin', 'Einzeltermin',), (
('Mehrtagesevent', 'Mehrtagesevent',), "Einzeltermin",
('Regelmäßiges Event/Training', 'Regelmäßiges Event/Training',), "Einzeltermin",
('Tagesevent', 'Tagesevent',), ),
('Wochenendevent', 'Wochenendevent',), (
"Mehrtagesevent",
"Mehrtagesevent",
),
(
"Regelmäßiges Event/Training",
"Regelmäßiges Event/Training",
),
(
"Tagesevent",
"Tagesevent",
),
(
"Wochenendevent",
"Wochenendevent",
),
] ]
KLASSIFIZIERUNG = [ KLASSIFIZIERUNG = [
('Gemeinschaftstour', 'Gemeinschaftstour'), ("Gemeinschaftstour", "Gemeinschaftstour"),
('Ausbildung', 'Ausbildung'), ("Ausbildung", "Ausbildung"),
] ]
# Create your models here. # Create your models here.
class Termin(models.Model): class Termin(models.Model):
title = models.CharField('Titel', max_length=100) title = models.CharField("Titel", max_length=100)
subtitle = models.CharField('Untertitel', max_length=100, blank=True) subtitle = models.CharField("Untertitel", max_length=100, blank=True)
start_date = models.DateField('Von') start_date = models.DateField("Von")
end_date = models.DateField('Bis') end_date = models.DateField("Bis")
group = models.CharField('Gruppe', group = models.CharField("Gruppe", choices=GRUPPE, max_length=100)
choices=GRUPPE, responsible = models.CharField("Organisator", max_length=100, blank=False)
max_length=100) phone = models.CharField(max_length=20, verbose_name="Telefonnumer", blank=True)
responsible = models.CharField('Organisator', max_length=100, blank=False) email = models.EmailField(max_length=100, verbose_name="Email", blank=False)
phone = models.CharField(max_length=20, verbose_name='Telefonnumer', blank=True) category = models.CharField(
email = models.EmailField(max_length=100, verbose_name='Email', blank=False) "Kategorie", blank=False, choices=KATEGORIE, max_length=100, default="SON"
category = models.CharField('Kategorie', blank=False, choices=KATEGORIE, max_length=100, )
default='SON') condition = models.CharField(
condition = models.CharField('Kondition', blank=False, choices=KONDITION, max_length=100, "Kondition", blank=False, choices=KONDITION, max_length=100, default="mittel"
default='mittel') )
technik = models.CharField('Technik', blank=False, choices=TECHNIK, max_length=100, technik = models.CharField(
default='mittel') "Technik", blank=False, choices=TECHNIK, max_length=100, default="mittel"
saison = models.CharField('Saison', blank=False, choices=SAISON, max_length=100, )
default='ganzjährig') saison = models.CharField(
eventart = models.CharField('Eventart', blank=False, choices=EVENTART, max_length=100, "Saison", blank=False, choices=SAISON, max_length=100, default="ganzjährig"
default='Einzeltermin') )
klassifizierung = models.CharField('Klassifizierung', blank=False, choices=KLASSIFIZIERUNG, eventart = models.CharField(
max_length=100, "Eventart", blank=False, choices=EVENTART, max_length=100, default="Einzeltermin"
default='Gemeinschaftstour') )
equipment = models.TextField('Ausrüstung', klassifizierung = models.CharField(
blank=True) "Klassifizierung",
voraussetzungen = models.TextField('Voraussetzungen', blank=False,
blank=True) choices=KLASSIFIZIERUNG,
description = models.TextField('Beschreibung', max_length=100,
blank=True) default="Gemeinschaftstour",
max_participants = models.IntegerField('Max. Teilnehmerzahl', )
blank=False, equipment = models.TextField("Ausrüstung", blank=True)
validators=[ voraussetzungen = models.TextField("Voraussetzungen", blank=True)
MinValueValidator(1) description = models.TextField("Beschreibung", blank=True)
], max_participants = models.IntegerField(
default=10) "Max. Teilnehmerzahl", blank=False, validators=[MinValueValidator(1)], default=10
anforderung_hoehe = models.IntegerField('Höhenmeter in Meter', )
blank=True, anforderung_hoehe = models.IntegerField(
validators=[ "Höhenmeter in Meter", blank=True, validators=[MinValueValidator(0)], default=0
MinValueValidator(0) )
], anforderung_strecke = models.IntegerField(
default=0) "Strecke in Kilometer", blank=True, validators=[MinValueValidator(0)], default=0
anforderung_strecke = models.IntegerField('Strecke in Kilometer', )
blank=True, anforderung_dauer = models.IntegerField(
validators=[ "Etappendauer in Stunden", blank=True, validators=[MinValueValidator(0)], default=0
MinValueValidator(0) )
],
default=0)
anforderung_dauer = models.IntegerField('Etappendauer in Stunden',
blank=True,
validators=[
MinValueValidator(0)
],
default=0)
def __str__(self): def __str__(self):
return "{} {}".format(self.title, str(self.group)) return "{} {}".format(self.title, str(self.group))
class Meta: class Meta:
verbose_name = 'Termin' verbose_name = "Termin"
verbose_name_plural = 'Termine' verbose_name_plural = "Termine"

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

@ -4,6 +4,6 @@ from . import views
app_name = "ludwigsburgalpin" app_name = "ludwigsburgalpin"
urlpatterns = [ urlpatterns = [
re_path(r'^$', views.index, name='index') re_path(r"^$", views.index, name="index")
# re_path(r'^subscribe', views.subscribe, name='subscribe'), # re_path(r'^subscribe', views.subscribe, name='subscribe'),
] ]

@ -1,100 +1,89 @@
from django.shortcuts import render
from django import forms from django import forms
from django.http import HttpResponseRedirect
from django.contrib.admin import widgets
from django.core.validators import MinValueValidator from django.core.validators import MinValueValidator
from .models import Termin, GRUPPE, KATEGORIE, KONDITION, TECHNIK, SAISON, EVENTART, KLASSIFIZIERUNG from django.shortcuts import render
datepicker = forms.TextInput(attrs={'class': 'datepicker'}) from .models import EVENTART
from .models import GRUPPE
from .models import KATEGORIE
from .models import KLASSIFIZIERUNG
from .models import KONDITION
from .models import SAISON
from .models import TECHNIK
from .models import Termin
datepicker = forms.TextInput(attrs={"class": "datepicker"})
class TerminForm(forms.Form):
class TerminForm(forms.Form):
title = forms.CharField(label='Titel') title = forms.CharField(label="Titel")
subtitle = forms.CharField(label='Untertitel') subtitle = forms.CharField(label="Untertitel")
start_date = forms.DateField(label='Von', start_date = forms.DateField(label="Von", widget=datepicker)
widget=datepicker) end_date = forms.DateField(label="Bis", widget=datepicker)
end_date = forms.DateField(label='Bis', group = forms.ChoiceField(label="Gruppe", required=True, choices=GRUPPE)
widget=datepicker) category = forms.ChoiceField(label="Kategorie", required=True, choices=KATEGORIE)
group = forms.ChoiceField(label='Gruppe', condition = forms.ChoiceField(label="Kondition", required=True, choices=KONDITION)
required=True, technik = forms.ChoiceField(label="Technik", required=True, choices=TECHNIK)
choices=GRUPPE) saison = forms.ChoiceField(label="Saison", required=True, choices=SAISON)
category = forms.ChoiceField(label='Kategorie', required=True, choices=KATEGORIE) eventart = forms.ChoiceField(label="Eventart", required=True, choices=EVENTART)
condition = forms.ChoiceField(label='Kondition', required=True, choices=KONDITION) klassifizierung = forms.ChoiceField(
technik = forms.ChoiceField(label='Technik', required=True, choices=TECHNIK) label="Klassifizierung", required=True, choices=KLASSIFIZIERUNG
saison = forms.ChoiceField(label='Saison', required=True, choices=SAISON) )
eventart = forms.ChoiceField(label='Eventart', required=True, choices=EVENTART) anforderung_hoehe = forms.IntegerField(
klassifizierung = forms.ChoiceField(label='Klassifizierung', required=True, choices=KLASSIFIZIERUNG) label="Höhenmeter in Metern", required=True, validators=[MinValueValidator(0)]
anforderung_hoehe = forms.IntegerField(label='Höhenmeter in Metern', )
required=True, anforderung_strecke = forms.IntegerField(
validators=[ label="Strecke in Kilometern", required=True, validators=[MinValueValidator(0)]
MinValueValidator(0) )
]) anforderung_dauer = forms.IntegerField(
anforderung_strecke = forms.IntegerField(label='Strecke in Kilometern', label="Etappendauer in Stunden", required=True, validators=[MinValueValidator(0)]
required=True, )
validators=[ description = forms.CharField(label="Beschreibung", widget=forms.Textarea, required=False)
MinValueValidator(0) equipment = forms.CharField(label="Ausrüstung", widget=forms.Textarea, required=False)
]) voraussetzungen = forms.CharField(
anforderung_dauer = forms.IntegerField(label='Etappendauer in Stunden', label="Voraussetzungen", widget=forms.Textarea, required=False
required=True, )
validators=[ max_participants = forms.IntegerField(
MinValueValidator(0) label="Max. Teilnehmerzahl", required=True, validators=[MinValueValidator(1)]
]) )
description = forms.CharField(label='Beschreibung', responsible = forms.CharField(label="Organisator", max_length=100, required=False)
widget=forms.Textarea, phone = forms.CharField(max_length=20, label="Telefonnumer", required=False)
required=False) email = forms.EmailField(max_length=100, label="Email", required=False)
equipment = forms.CharField(label='Ausrüstung',
widget=forms.Textarea,
required=False)
voraussetzungen = forms.CharField(label='Voraussetzungen',
widget=forms.Textarea,
required=False)
max_participants = forms.IntegerField(label='Max. Teilnehmerzahl',
required=True,
validators=[
MinValueValidator(1)
])
responsible = forms.CharField(label='Organisator', max_length=100,
required=False)
phone = forms.CharField(max_length=20, label='Telefonnumer',
required=False)
email = forms.EmailField(max_length=100, label='Email',
required=False)
# Create your views here. # Create your views here.
def index(request, *args): def index(request, *args):
if request.method == 'POST': if request.method == "POST":
form = TerminForm(request.POST) form = TerminForm(request.POST)
if form.is_valid(): if form.is_valid():
termin = Termin(title=form.cleaned_data["title"], termin = Termin(
subtitle=form.cleaned_data["subtitle"], title=form.cleaned_data["title"],
start_date=form.cleaned_data["start_date"], subtitle=form.cleaned_data["subtitle"],
end_date=form.cleaned_data["end_date"], start_date=form.cleaned_data["start_date"],
group=form.cleaned_data["group"], end_date=form.cleaned_data["end_date"],
responsible=form.cleaned_data["responsible"], group=form.cleaned_data["group"],
phone=form.cleaned_data["phone"], responsible=form.cleaned_data["responsible"],
email=form.cleaned_data["email"], phone=form.cleaned_data["phone"],
category=form.cleaned_data["category"], email=form.cleaned_data["email"],
condition=form.cleaned_data["condition"], category=form.cleaned_data["category"],
technik=form.cleaned_data["technik"], condition=form.cleaned_data["condition"],
saison=form.cleaned_data["saison"], technik=form.cleaned_data["technik"],
eventart=form.cleaned_data["eventart"], saison=form.cleaned_data["saison"],
klassifizierung=form.cleaned_data["klassifizierung"], eventart=form.cleaned_data["eventart"],
equipment=form.cleaned_data["equipment"], klassifizierung=form.cleaned_data["klassifizierung"],
voraussetzungen=form.cleaned_data["voraussetzungen"], equipment=form.cleaned_data["equipment"],
max_participants=form.cleaned_data["max_participants"], voraussetzungen=form.cleaned_data["voraussetzungen"],
anforderung_hoehe=form.cleaned_data["anforderung_hoehe"], max_participants=form.cleaned_data["max_participants"],
anforderung_strecke=form.cleaned_data["anforderung_strecke"], anforderung_hoehe=form.cleaned_data["anforderung_hoehe"],
anforderung_dauer=form.cleaned_data["anforderung_dauer"], anforderung_strecke=form.cleaned_data["anforderung_strecke"],
description=form.cleaned_data["description"]) anforderung_dauer=form.cleaned_data["anforderung_dauer"],
description=form.cleaned_data["description"],
)
termin.save() termin.save()
return published(request) return published(request)
else: else:
form = TerminForm() form = TerminForm()
return render(request, 'ludwigsburgalpin/termine.html', {'form': form.as_table()}) return render(request, "ludwigsburgalpin/termine.html", {"form": form.as_table()})
def published(request): def published(request):
return render(request, 'ludwigsburgalpin/published.html') return render(request, "ludwigsburgalpin/published.html")

@ -1,20 +1,26 @@
from django.contrib import admin, messages import json
from contrib.admin import CommonAdminInlineMixin
from contrib.admin import CommonAdminMixin
from django.conf import settings from django.conf import settings
from django.contrib import admin
from django.contrib import messages
from django.contrib.admin import helpers from django.contrib.admin import helpers
from django.utils.translation import gettext_lazy as _
from django.shortcuts import render from django.shortcuts import render
from django.db import models from django.utils.translation import gettext_lazy as _
from django import forms from members.admin import FilteredMemberFieldMixin
#from easy_select2 import apply_select2 from members.models import Member
import json
from rules.contrib.admin import ObjectPermissionsModelAdmin from rules.contrib.admin import ObjectPermissionsModelAdmin
from .models import Message, Attachment, MessageForm, EmailAddress, EmailAddressForm from .mailutils import NOT_SENT
from .mailutils import NOT_SENT, PARTLY_SENT from .mailutils import PARTLY_SENT
from members.models import Member from .models import Attachment
from members.admin import FilteredMemberFieldMixin from .models import EmailAddress
from contrib.admin import CommonAdminMixin, CommonAdminInlineMixin from .models import EmailAddressForm
from .models import Message
from .models import MessageForm
# from easy_select2 import apply_select2
class AttachmentInline(CommonAdminInlineMixin, admin.TabularInline): class AttachmentInline(CommonAdminInlineMixin, admin.TabularInline):
@ -23,91 +29,127 @@ class AttachmentInline(CommonAdminInlineMixin, admin.TabularInline):
class EmailAddressAdmin(FilteredMemberFieldMixin, admin.ModelAdmin): class EmailAddressAdmin(FilteredMemberFieldMixin, admin.ModelAdmin):
list_display = ('email', 'internal_only') list_display = ("email", "internal_only")
fields = ('name', 'to_members', 'to_groups', 'internal_only') fields = (
#formfield_overrides = { "name",
"to_members",
"to_groups",
"internal_only",
)
# formfield_overrides = {
# models.ManyToManyField: {'widget': forms.CheckboxSelectMultiple}, # models.ManyToManyField: {'widget': forms.CheckboxSelectMultiple},
# models.ForeignKey: {'widget': apply_select2(forms.Select)} # models.ForeignKey: {'widget': apply_select2(forms.Select)}
#} # }
filter_horizontal = ('to_members',) filter_horizontal = ("to_members",)
form = EmailAddressForm form = EmailAddressForm
class MessageAdmin(FilteredMemberFieldMixin, CommonAdminMixin, ObjectPermissionsModelAdmin): class MessageAdmin(
FilteredMemberFieldMixin,
CommonAdminMixin,
ObjectPermissionsModelAdmin,
):
"""Message creation view""" """Message creation view"""
exclude = ('created_by', 'to_notelist')
list_display = ('subject', 'get_recipients', 'sent') exclude = ("created_by", "to_notelist")
search_fields = ('subject',) list_display = (
list_filter = ('sent',) "subject",
"get_recipients",
"sent",
)
search_fields = ("subject",)
list_filter = ("sent",)
change_form_template = "mailer/change_form.html" change_form_template = "mailer/change_form.html"
readonly_fields = ('sent',) readonly_fields = ("sent",)
#formfield_overrides = { # formfield_overrides = {
# models.ManyToManyField: {'widget': forms.CheckboxSelectMultiple}, # models.ManyToManyField: {'widget': forms.CheckboxSelectMultiple},
# models.ForeignKey: {'widget': apply_select2(forms.Select)} # models.ForeignKey: {'widget': apply_select2(forms.Select)}
#} # }
inlines = [AttachmentInline] inlines = [AttachmentInline]
actions = ['send_message'] actions = ["send_message"]
form = MessageForm form = MessageForm
filter_horizontal = ('to_members','reply_to') filter_horizontal = ("to_members", "reply_to")
def save_model(self, request, obj, form, change): def save_model(self, request, obj, form, change):
if not change and hasattr(request.user, 'member'): if not change and hasattr(request.user, "member"):
obj.created_by = request.user.member obj.created_by = request.user.member
super().save_model(request, obj, form, change) super().save_model(request, obj, form, change)
def send_message(self, request, queryset): def send_message(self, request, queryset):
if request.POST.get('confirmed'): if request.POST.get("confirmed"):
for msg in queryset: for msg in queryset:
submit_message(msg, request) submit_message(msg, request)
else: else:
context = { context = {
'action_checkbox_name': helpers.ACTION_CHECKBOX_NAME, "action_checkbox_name": helpers.ACTION_CHECKBOX_NAME,
'mails': queryset, "mails": queryset,
'ids': queryset.values_list("id"), "ids": queryset.values_list("id"),
'some_sent': any(m.sent for m in queryset)} "some_sent": any(m.sent for m in queryset),
return render(request, 'mailer/confirm_send.html', context) }
return render(
request,
"mailer/confirm_send.html",
context,
)
send_message.short_description = _("Send message") send_message.short_description = _("Send message")
def response_change(self, request, obj): def response_change(self, request, obj):
if "_send" in request.POST: if "_send" in request.POST:
submit_message(obj, request) submit_message(obj, request)
return super(MessageAdmin, self).response_change(request, obj) return super().response_change(request, obj)
def response_add(self, request, obj): def response_add(self, request, obj):
if "_send" in request.POST: if "_send" in request.POST:
submit_message(obj, request) submit_message(obj, request)
return super(MessageAdmin, self).response_add(request, obj) return super().response_add(request, obj)
def get_form(self, request, obj=None, **kwargs): def get_form(self, request, obj=None, **kwargs):
form = super(MessageAdmin, self).get_form(request, obj, **kwargs) form = super().get_form(request, obj, **kwargs)
raw_members = request.GET.get('members', None) raw_members = request.GET.get("members", None)
if raw_members is not None: if raw_members is not None:
m_ids = json.loads(raw_members) m_ids = json.loads(raw_members)
if type(m_ids) != list: if type(m_ids) is not list:
return form return form
members = Member.objects.filter(pk__in=m_ids) members = Member.objects.filter(pk__in=m_ids)
form.base_fields['to_members'].initial = members form.base_fields["to_members"].initial = members
return form return form
def submit_message(msg, request): def submit_message(msg, request):
sender = None sender = None
if not hasattr(request.user, 'member'): if not hasattr(request.user, "member"):
messages.error(request, _("Your account is not connected to a member. Please contact your system administrator.")) messages.error(
request,
_(
"Your account is not connected to a member. Please contact your system administrator."
),
)
return return
sender = request.user.member sender = request.user.member
if not sender.has_internal_email(): if not sender.has_internal_email():
messages.error(request, messages.error(
_("Your email address is not an internal email address. Please use an email address with one of the following domains: %(domains)s.") % {'domains': ", ".join(settings.ALLOWED_EMAIL_DOMAINS_FOR_INVITE_AS_USER)}) request,
_(
"Your email address is not an internal email address. Please use an email address with one of the following domains: %(domains)s."
)
% {"domains": ", ".join(settings.ALLOWED_EMAIL_DOMAINS_FOR_INVITE_AS_USER)},
)
return return
success = msg.submit(sender) success = msg.submit(sender)
if success == NOT_SENT: if success == NOT_SENT:
messages.error(request, _("Failed to send message")) messages.error(request, _("Failed to send message"))
elif success == PARTLY_SENT: elif success == PARTLY_SENT:
messages.warning(request, _("Failed to send some messages")) messages.warning(
request,
_("Failed to send some messages"),
)
else: else:
messages.success(request, _("Successfully sent message")) messages.success(
request,
_("Successfully sent message"),
)
admin.site.register(Message, MessageAdmin) admin.site.register(Message, MessageAdmin)

@ -3,5 +3,5 @@ from django.utils.translation import gettext_lazy as _
class MailerConfig(AppConfig): class MailerConfig(AppConfig):
name = 'mailer' name = "mailer"
verbose_name = _('mailer') verbose_name = _("mailer")

@ -1,23 +1,24 @@
from django.core import mail
from django.core.mail import EmailMessage
from django.conf import settings
import logging import logging
import os
from django.conf import settings
from django.core import mail
from django.core.mail import EmailMessage
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
NOT_SENT, SENT, PARTLY_SENT = 0, 1, 2 NOT_SENT, SENT, PARTLY_SENT = 0, 1, 2
def send(subject, content, sender, recipients, message_id=None, reply_to=None,
attachments=None, cc=None): def send(
subject, content, sender, recipients, message_id=None, reply_to=None, attachments=None, cc=None
):
failed, succeeded = False, False failed, succeeded = False, False
if type(recipients) != list: if type(recipients) is not list:
recipients = [recipients] recipients = [recipients]
if not cc: if not cc:
cc = [] cc = []
elif type(cc) != list: elif type(cc) is not list:
cc = [cc] cc = [cc]
if reply_to is not None: if reply_to is not None:
kwargs = {"reply_to": reply_to} kwargs = {"reply_to": reply_to}
@ -26,15 +27,16 @@ def send(subject, content, sender, recipients, message_id=None, reply_to=None,
if sender == settings.DEFAULT_SENDING_MAIL: if sender == settings.DEFAULT_SENDING_MAIL:
sender = addr_with_name(settings.DEFAULT_SENDING_MAIL, settings.DEFAULT_SENDING_NAME) sender = addr_with_name(settings.DEFAULT_SENDING_MAIL, settings.DEFAULT_SENDING_NAME)
url = prepend_base_url("/newsletter/unsubscribe") url = prepend_base_url("/newsletter/unsubscribe")
headers = {'List-Unsubscribe': '<{unsubscribe_url}>'.format(unsubscribe_url=url)} headers = {"List-Unsubscribe": "<{unsubscribe_url}>".format(unsubscribe_url=url)}
if message_id is not None: if message_id is not None:
headers['Message-ID'] = message_id headers["Message-ID"] = message_id
# construct mails # construct mails
mails = [] mails = []
for recipient in set(recipients): for recipient in set(recipients):
email = EmailMessage(subject, content, sender, [recipient], cc=cc, email = EmailMessage(
headers=headers, **kwargs) subject, content, sender, [recipient], cc=cc, headers=headers, **kwargs
)
if attachments is not None: if attachments is not None:
for attach in attachments: for attach in attachments:
email.attach_file(attach) email.attach_file(attach)
@ -50,15 +52,16 @@ def send(subject, content, sender, recipients, message_id=None, reply_to=None,
else: else:
succeeded = True succeeded = True
return NOT_SENT if failed and not succeeded else SENT if not failed\ return (
and succeeded else PARTLY_SENT NOT_SENT if failed and not succeeded else SENT if not failed and succeeded else PARTLY_SENT
)
def get_content(content, registration_complete=True): def get_content(content, registration_complete=True):
url = prepend_base_url("/newsletter/unsubscribe")
prepend = settings.PREPEND_INCOMPLETE_REGISTRATION_TEXT prepend = settings.PREPEND_INCOMPLETE_REGISTRATION_TEXT
text = "{prepend}{content}".format(prepend="" if registration_complete else prepend, text = "{prepend}{content}".format(
content=content) prepend="" if registration_complete else prepend, content=content
)
return text return text
@ -102,7 +105,9 @@ def get_invite_as_user_key(key):
def prepend_base_url(absolutelink): def prepend_base_url(absolutelink):
return "{protocol}://{base}{link}".format(protocol=settings.PROTOCOL, base=settings.BASE_URL, link=absolutelink) return "{protocol}://{base}{link}".format(
protocol=settings.PROTOCOL, base=settings.BASE_URL, link=absolutelink
)
def addr_with_name(addr, name): def addr_with_name(addr, name):

@ -1,20 +1,19 @@
from django.conf import settings
from django.core.management.base import BaseCommand from django.core.management.base import BaseCommand
from mailer.models import Message
from members.models import Member, annotate_activity_score
from django.db.models import Q
from django.utils.translation import gettext_lazy as _ from django.utils.translation import gettext_lazy as _
from mailer.mailutils import send from mailer.mailutils import send
from django.conf import settings from members.models import annotate_activity_score
from members.models import Member
import re
class Command(BaseCommand): class Command(BaseCommand):
help = 'Congratulates the most active members' help = "Congratulates the most active members"
requires_system_checks = False requires_system_checks = False
def handle(self, *args, **options): def handle(self, *args, **options):
qs = list(reversed(annotate_activity_score(Member.objects.all()).order_by('_activity_score')))[:settings.CONGRATULATE_MEMBERS_MAX] qs = list(
reversed(annotate_activity_score(Member.objects.all()).order_by("_activity_score"))
)[: settings.CONGRATULATE_MEMBERS_MAX]
for position, member in enumerate(qs): for position, member in enumerate(qs):
positiontext = "{}. ".format(position + 1) if position > 0 else "" positiontext = "{}. ".format(position + 1) if position > 0 else ""
score = member._activity_score score = member._activity_score
@ -28,11 +27,17 @@ class Command(BaseCommand):
level = 4 level = 4
else: else:
level = 5 level = 5
content = settings.NOTIFY_MOST_ACTIVE_TEXT.format(name=member.prename, content = settings.NOTIFY_MOST_ACTIVE_TEXT.format(
congratulate_max=CONGRATULATE_MEMBERS_MAX, name=member.prename,
score=score, congratulate_max=settings.CONGRATULATE_MEMBERS_MAX,
level=level, score=score,
position=positiontext) level=level,
send(_("Congratulation %(name)s") % { 'name': member.prename }, position=positiontext,
content, settings.DEFAULT_SENDING_ADDRESS, [member.email], )
reply_to=[settings.RESPONSIBLE_MAIL]) send(
_("Congratulation %(name)s") % {"name": member.prename},
content,
settings.DEFAULT_SENDING_ADDRESS,
[member.email],
reply_to=[settings.RESPONSIBLE_MAIL],
)

@ -1,30 +1,30 @@
import re
from django.core.management.base import BaseCommand from django.core.management.base import BaseCommand
from mailer.models import Message from mailer.models import Message
from members.models import Member from members.models import Member
from django.db.models import Q
import re
class Command(BaseCommand): class Command(BaseCommand):
help = 'Shows reply-to addresses' help = "Shows reply-to addresses"
requires_system_checks = False requires_system_checks = False
def add_arguments(self, parser): def add_arguments(self, parser):
parser.add_argument('--message_id', default="-1") parser.add_argument("--message_id", default="-1")
parser.add_argument('--subject', default="") parser.add_argument("--subject", default="")
def handle(self, *args, **options): def handle(self, *args, **options):
replies = [] replies = []
try: try:
message_id = int(options['message_id']) message_id = int(options["message_id"])
message = Message.objects.get(pk=message_id) message = Message.objects.get(pk=message_id)
if message.reply_to: if message.reply_to:
replies = list(message.reply_to.all()) replies = list(message.reply_to.all())
replies.extend(message.reply_to_email_address.all()) replies.extend(message.reply_to_email_address.all())
except (Message.DoesNotExist, ValueError): except (Message.DoesNotExist, ValueError):
extracted = re.match("^([Ww][Gg]: *|[Ff][Ww]: *|[Rr][Ee]: *|[Aa][Ww]: *)* *(.*)$", extracted = re.match(
options['subject']).group(2) "^([Ww][Gg]: *|[Ff][Ww]: *|[Rr][Ee]: *|[Aa][Ww]: *)* *(.*)$", options["subject"]
).group(2)
try: try:
msgs = Message.objects.filter(subject=extracted) msgs = Message.objects.filter(subject=extracted)
message = msgs.all()[0] message = msgs.all()[0]
@ -36,8 +36,7 @@ class Command(BaseCommand):
if not replies: if not replies:
# send mail to all jugendleiters # send mail to all jugendleiters
replies = Member.objects.filter(group__name='Jugendleiter', replies = Member.objects.filter(group__name="Jugendleiter", gets_newsletter=True)
gets_newsletter=True) forwards = [lst.email for lst in replies]
forwards = [l.email for l in replies]
self.stdout.write(" ".join(forwards)) self.stdout.write(" ".join(forwards))

@ -1,79 +1,165 @@
# Generated by Django 4.0.1 on 2023-03-29 20:38 # Generated by Django 4.0.1 on 2023-03-29 20:38
import django.core.validators import django.core.validators
from django.db import migrations, models
import django.db.models.deletion import django.db.models.deletion
import utils import utils
from django.db import migrations
from django.db import models
class Migration(migrations.Migration): class Migration(migrations.Migration):
replaces = [
replaces = [('mailer', '0001_initial'), ('mailer', '0002_auto_20190615_1225'), ('mailer', '0003_emailaddress'), ('mailer', '0004_auto_20200924_1744'), ('mailer', '0005_auto_20200924_2139'), ('mailer', '0006_auto_20210924_1155')] ("mailer", "0001_initial"),
("mailer", "0002_auto_20190615_1225"),
("mailer", "0003_emailaddress"),
("mailer", "0004_auto_20200924_1744"),
("mailer", "0005_auto_20200924_2139"),
("mailer", "0006_auto_20210924_1155"),
]
dependencies = [ dependencies = [
('members', '0006_auto_20190914_2341'), ("members", "0006_auto_20190914_2341"),
('members', '0008_auto_20210924_1155'), ("members", "0008_auto_20210924_1155"),
('members', '0001_initial'), ("members", "0001_initial"),
('members', '0007_auto_20200924_1512'), ("members", "0007_auto_20200924_1512"),
('members', '0005_auto_20190615_1224'), ("members", "0005_auto_20190615_1224"),
] ]
operations = [ operations = [
migrations.CreateModel( migrations.CreateModel(
name='Message', name="Message",
fields=[ fields=[
('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), (
('subject', models.CharField(max_length=50, verbose_name='subject')), "id",
('content', models.TextField(verbose_name='content')), models.AutoField(
('sent', models.BooleanField(default=False, verbose_name='sent')), auto_created=True, primary_key=True, serialize=False, verbose_name="ID"
('to_groups', models.ManyToManyField(blank=True, to='members.Group', verbose_name='to group')), ),
('to_members', models.ManyToManyField(blank=True, to='members.Member', verbose_name='to member')), ),
('reply_to', models.ManyToManyField(blank=True, related_name='reply_to', to='members.Member', verbose_name='reply to participant')), ("subject", models.CharField(max_length=50, verbose_name="subject")),
("content", models.TextField(verbose_name="content")),
("sent", models.BooleanField(default=False, verbose_name="sent")),
(
"to_groups",
models.ManyToManyField(blank=True, to="members.Group", verbose_name="to group"),
),
(
"to_members",
models.ManyToManyField(
blank=True, to="members.Member", verbose_name="to member"
),
),
(
"reply_to",
models.ManyToManyField(
blank=True,
related_name="reply_to",
to="members.Member",
verbose_name="reply to participant",
),
),
], ],
options={ options={
'verbose_name_plural': 'messages', "verbose_name_plural": "messages",
'permissions': (('submit_mails', 'Can submit mails'),), "permissions": (("submit_mails", "Can submit mails"),),
'verbose_name': 'message', "verbose_name": "message",
}, },
), ),
migrations.CreateModel( migrations.CreateModel(
name='Attachment', name="Attachment",
fields=[ fields=[
('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), (
('f', utils.RestrictedFileField(blank=True, upload_to='attachments', verbose_name='file')), "id",
('msg', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='mailer.message')), models.AutoField(
auto_created=True, primary_key=True, serialize=False, verbose_name="ID"
),
),
(
"f",
utils.RestrictedFileField(
blank=True, upload_to="attachments", verbose_name="file"
),
),
(
"msg",
models.ForeignKey(
on_delete=django.db.models.deletion.CASCADE, to="mailer.message"
),
),
], ],
options={ options={
'verbose_name_plural': 'attachments', "verbose_name_plural": "attachments",
'verbose_name': 'attachment', "verbose_name": "attachment",
}, },
), ),
migrations.CreateModel( migrations.CreateModel(
name='EmailAddress', name="EmailAddress",
fields=[ fields=[
('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), (
('name', models.CharField(max_length=50, validators=[django.core.validators.RegexValidator('^[0-9a-zA-Z]*$', 'Only alphanumeric characters are allowed')], verbose_name='name')), "id",
('to_members', models.ManyToManyField(blank=True, to='members.Member', verbose_name='Forward to participants')), models.AutoField(
('to_groups', models.ManyToManyField(blank=True, to='members.Group', verbose_name='Forward to group')), auto_created=True, primary_key=True, serialize=False, verbose_name="ID"
),
),
(
"name",
models.CharField(
max_length=50,
validators=[
django.core.validators.RegexValidator(
"^[0-9a-zA-Z]*$", "Only alphanumeric characters are allowed"
)
],
verbose_name="name",
),
),
(
"to_members",
models.ManyToManyField(
blank=True, to="members.Member", verbose_name="Forward to participants"
),
),
(
"to_groups",
models.ManyToManyField(
blank=True, to="members.Group", verbose_name="Forward to group"
),
),
], ],
options={ options={
'verbose_name_plural': 'email addresses', "verbose_name_plural": "email addresses",
'verbose_name': 'email address', "verbose_name": "email address",
}, },
), ),
migrations.AddField( migrations.AddField(
model_name='message', model_name="message",
name='reply_to_email_address', name="reply_to_email_address",
field=models.ManyToManyField(blank=True, related_name='reply_to_email_addr', to='mailer.EmailAddress', verbose_name='reply to custom email address'), field=models.ManyToManyField(
blank=True,
related_name="reply_to_email_addr",
to="mailer.EmailAddress",
verbose_name="reply to custom email address",
),
), ),
migrations.AddField( migrations.AddField(
model_name='message', model_name="message",
name='to_freizeit', name="to_freizeit",
field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, to='members.freizeit', verbose_name='to freizeit'), field=models.ForeignKey(
blank=True,
null=True,
on_delete=django.db.models.deletion.CASCADE,
to="members.freizeit",
verbose_name="to freizeit",
),
), ),
migrations.AddField( migrations.AddField(
model_name='message', model_name="message",
name='to_notelist', name="to_notelist",
field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, to='members.membernotelist', verbose_name='to notes list'), field=models.ForeignKey(
blank=True,
null=True,
on_delete=django.db.models.deletion.CASCADE,
to="members.membernotelist",
verbose_name="to notes list",
),
), ),
] ]

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

@ -4,14 +4,25 @@ from django.db import migrations
class Migration(migrations.Migration): class Migration(migrations.Migration):
dependencies = [ dependencies = [
('mailer', '0002_message_created_by'), ("mailer", "0002_message_created_by"),
] ]
operations = [ operations = [
migrations.AlterModelOptions( migrations.AlterModelOptions(
name='message', name="message",
options={'default_permissions': ('add_global', 'change_global', 'view_global', 'delete_global', 'list_global', 'view'), 'permissions': (('submit_mails', 'Can submit mails'),), 'verbose_name': 'message', 'verbose_name_plural': 'messages'}, options={
"default_permissions": (
"add_global",
"change_global",
"view_global",
"delete_global",
"list_global",
"view",
),
"permissions": (("submit_mails", "Can submit mails"),),
"verbose_name": "message",
"verbose_name_plural": "messages",
},
), ),
] ]

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

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

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

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

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

@ -1,118 +1,139 @@
import logging import logging
from django.db import models import os
from django.core.exceptions import ValidationError
from contrib.models import CommonModel
from contrib.rules import has_global_perm
from django import forms from django import forms
from django.utils.translation import gettext_lazy as _ from django.conf import settings
from django.core.exceptions import ValidationError
from django.core.validators import RegexValidator
from django.db import models
from django.utils.translation import gettext from django.utils.translation import gettext
from .mailutils import send, get_content, NOT_SENT, SENT, PARTLY_SENT,\ from django.utils.translation import gettext_lazy as _
addr_with_name
from utils import RestrictedFileField from utils import RestrictedFileField
from jdav_web.celery import app
from django.core.validators import RegexValidator
from django.conf import settings
from contrib.rules import has_global_perm from .mailutils import addr_with_name
from contrib.models import CommonModel from .mailutils import get_content
from .mailutils import NOT_SENT
from .mailutils import PARTLY_SENT
from .mailutils import send
from .mailutils import SENT
from .rules import is_creator from .rules import is_creator
import os
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
alphanumeric = RegexValidator(r'^[0-9a-zA-Z._-]*$', alphanumeric = RegexValidator(
_('Only alphanumeric characters, ., - and _ are allowed')) r"^[0-9a-zA-Z._-]*$", _("Only alphanumeric characters, ., - and _ are allowed")
)
class EmailAddress(models.Model): class EmailAddress(models.Model):
"""Represents an email address, that is forwarded to specific members""" """Represents an email address, that is forwarded to specific members"""
name = models.CharField(_('name'), max_length=50, validators=[alphanumeric],
unique=True) name = models.CharField(_("name"), max_length=50, validators=[alphanumeric], unique=True)
to_members = models.ManyToManyField('members.Member', to_members = models.ManyToManyField(
verbose_name=_('Forward to participants'), "members.Member", verbose_name=_("Forward to participants"), blank=True
blank=True) )
to_groups = models.ManyToManyField('members.Group', to_groups = models.ManyToManyField(
verbose_name=_('Forward to group'), "members.Group", verbose_name=_("Forward to group"), blank=True
blank=True) )
internal_only = models.BooleanField(verbose_name=_('Restrict to internal email addresses'), internal_only = models.BooleanField(
help_text=_('Only allow forwarding to this e-mail address from one of the following domains: %(domains)s.') % {'domains': ", ".join(settings.ALLOWED_EMAIL_DOMAINS_FOR_INVITE_AS_USER)}, verbose_name=_("Restrict to internal email addresses"),
default=False) help_text=_(
allowed_senders = models.ManyToManyField('members.Group', "Only allow forwarding to this e-mail address from one of the following domains: %(domains)s."
verbose_name=_('Allowed sender'), )
help_text=_('Only forward e-mails of members of selected groups. Leave empty to allow all senders.'), % {"domains": ", ".join(settings.ALLOWED_EMAIL_DOMAINS_FOR_INVITE_AS_USER)},
blank=True, default=False,
related_name='allowed_sender_on_emailaddresses') )
allowed_senders = models.ManyToManyField(
"members.Group",
verbose_name=_("Allowed sender"),
help_text=_(
"Only forward e-mails of members of selected groups. Leave empty to allow all senders."
),
blank=True,
related_name="allowed_sender_on_emailaddresses",
)
@property @property
def email(self): def email(self):
return "{0}@{1}".format(self.name, settings.DOMAIN) return "{}@{}".format(self.name, settings.DOMAIN)
@property @property
def forwards(self): def forwards(self):
mails = set(member.email for member in self.to_members.all()) mails = {member.email for member in self.to_members.all()}
mails.update([member.email for group in self.to_groups.all() for member in group.member_set.all()]) mails.update(
[member.email for group in self.to_groups.all() for member in group.member_set.all()]
)
return mails return mails
def __str__(self): def __str__(self):
return self.email return self.email
class Meta: class Meta:
verbose_name = _('email address') verbose_name = _("email address")
verbose_name_plural = _('email addresses') verbose_name_plural = _("email addresses")
class EmailAddressForm(forms.ModelForm): class EmailAddressForm(forms.ModelForm):
class Meta: class Meta:
model = EmailAddress model = EmailAddress
exclude = [] exclude = []
def clean(self): def clean(self):
super(EmailAddressForm, self).clean() super().clean()
group = self.cleaned_data.get('to_groups') group = self.cleaned_data.get("to_groups")
members = self.cleaned_data.get('to_members') members = self.cleaned_data.get("to_members")
if not group and not members: if not group and not members:
raise ValidationError(_('Either a group or at least' raise ValidationError(
' one member is required as forward recipient.')) _("Either a group or at least one member is required as forward recipient.")
)
# Create your models here. # Create your models here.
class Message(CommonModel): class Message(CommonModel):
"""Represents a message that can be sent to some members""" """Represents a message that can be sent to some members"""
subject = models.CharField(_('subject'), max_length=50)
content = models.TextField(_('content')) subject = models.CharField(_("subject"), max_length=50)
to_groups = models.ManyToManyField('members.Group', content = models.TextField(_("content"))
verbose_name=_('to group'), to_groups = models.ManyToManyField("members.Group", verbose_name=_("to group"), blank=True)
blank=True) to_freizeit = models.ForeignKey(
to_freizeit = models.ForeignKey('members.Freizeit', "members.Freizeit",
verbose_name=_('to freizeit'), verbose_name=_("to freizeit"),
on_delete=models.CASCADE, on_delete=models.CASCADE,
blank=True, blank=True,
null=True) null=True,
to_notelist = models.ForeignKey('members.MemberNoteList', )
verbose_name=_('to notes list'), to_notelist = models.ForeignKey(
on_delete=models.CASCADE, "members.MemberNoteList",
blank=True, verbose_name=_("to notes list"),
null=True) on_delete=models.CASCADE,
to_members = models.ManyToManyField('members.Member', blank=True,
verbose_name=_('to member'), null=True,
blank=True) )
reply_to = models.ManyToManyField('members.Member', to_members = models.ManyToManyField("members.Member", verbose_name=_("to member"), blank=True)
verbose_name=_('reply to participant'), reply_to = models.ManyToManyField(
blank=True, "members.Member",
related_name='reply_to') verbose_name=_("reply to participant"),
reply_to_email_address = models.ManyToManyField('mailer.EmailAddress', blank=True,
verbose_name=_('reply to custom email address'), related_name="reply_to",
blank=True, )
related_name='reply_to_email_addr') reply_to_email_address = models.ManyToManyField(
sent = models.BooleanField(_('sent'), default=False) "mailer.EmailAddress",
created_by = models.ForeignKey('members.Member', verbose_name=_('Created by'), verbose_name=_("reply to custom email address"),
blank=True, blank=True,
null=True, related_name="reply_to_email_addr",
on_delete=models.SET_NULL, )
related_name='created_messages') sent = models.BooleanField(_("sent"), default=False)
created_by = models.ForeignKey(
"members.Member",
verbose_name=_("Created by"),
blank=True,
null=True,
on_delete=models.SET_NULL,
related_name="created_messages",
)
def __str__(self): def __str__(self):
return self.subject return self.subject
@ -126,9 +147,10 @@ class Message(CommonModel):
if 3 > self.to_members.count() > 0: if 3 > self.to_members.count() > 0:
recipients.extend([m.name for m in self.to_members.all()]) recipients.extend([m.name for m in self.to_members.all()])
elif self.to_members.count() > 2: elif self.to_members.count() > 2:
recipients.append(gettext('Some other members')) recipients.append(gettext("Some other members"))
return ", ".join(recipients) return ", ".join(recipients)
get_recipients.short_description = _('recipients')
get_recipients.short_description = _("recipients")
def submit(self, sender=None): def submit(self, sender=None):
"""Sends the mail to the specified group of members""" """Sends the mail to the specified group of members"""
@ -141,24 +163,21 @@ class Message(CommonModel):
members.update(self.to_members.all()) members.update(self.to_members.all())
# get all the members of the selected freizeit # get all the members of the selected freizeit
if self.to_freizeit is not None: if self.to_freizeit is not None:
members.update([mol.member for mol in members.update([mol.member for mol in self.to_freizeit.membersonlist.all()])
self.to_freizeit.membersonlist.all()])
members.update(self.to_freizeit.jugendleiter.all()) members.update(self.to_freizeit.jugendleiter.all())
# get all the members of the selected notes list # get all the members of the selected notes list
if self.to_notelist is not None: if self.to_notelist is not None:
members.update([mol.member for mol in members.update([mol.member for mol in self.to_notelist.membersonlist.all()])
self.to_notelist.membersonlist.all()])
filtered = [m for m in members if m.gets_newsletter] filtered = [m for m in members if m.gets_newsletter]
logger.info(f"sending mail to {filtered}") logger.info(f"sending mail to {filtered}")
attach = [a.f.path for a in Attachment.objects.filter(msg__id=self.pk) attach = [a.f.path for a in Attachment.objects.filter(msg__id=self.pk) if a.f.name]
if a.f.name]
emails = [member.email for member in filtered] emails = [member.email for member in filtered]
emails.extend([member.alternative_email for member in filtered if member.alternative_email]) emails.extend([member.alternative_email for member in filtered if member.alternative_email])
# remove any underscores from subject to prevent Arne from using # remove any underscores from subject to prevent Arne from using
# terrible looking underscores in subjects # terrible looking underscores in subjects
self.subject = self.subject.replace('_', ' ') self.subject = self.subject.replace("_", " ")
# generate message id # generate message id
message_id = "<{pk}@{domain}>".format(pk=self.pk, domain=settings.DOMAIN) message_id = "<{pk}@{domain}>".format(pk=self.pk, domain=settings.DOMAIN)
# reply to addresses # reply to addresses
@ -176,15 +195,23 @@ class Message(CommonModel):
# if sending from the association email has been disabled, # if sending from the association email has been disabled,
# a sender was supplied and the reply to is empty, add the sender's # a sender was supplied and the reply to is empty, add the sender's
# DAV360 email as reply to # DAV360 email as reply to
if sender and not settings.SEND_FROM_ASSOCIATION_EMAIL and sender.has_internal_email() and reply_to == []: if (
sender
and not settings.SEND_FROM_ASSOCIATION_EMAIL
and sender.has_internal_email()
and reply_to == []
):
reply_to.append(addr_with_name(sender.email, sender.name)) reply_to.append(addr_with_name(sender.email, sender.name))
try: try:
success = send(self.subject, get_content(self.content, registration_complete=True), success = send(
from_addr, self.subject,
emails, get_content(self.content, registration_complete=True),
message_id=message_id, from_addr,
attachments=attach, emails,
reply_to=reply_to) message_id=message_id,
attachments=attach,
reply_to=reply_to,
)
if success == SENT or success == PARTLY_SENT: if success == SENT or success == PARTLY_SENT:
self.sent = True self.sent = True
for a in Attachment.objects.filter(msg__id=self.pk): for a in Attachment.objects.filter(msg__id=self.pk):
@ -200,51 +227,48 @@ class Message(CommonModel):
return success return success
class Meta(CommonModel.Meta): class Meta(CommonModel.Meta):
verbose_name = _('message') verbose_name = _("message")
verbose_name_plural = _('messages') verbose_name_plural = _("messages")
permissions = ( permissions = (("submit_mails", _("Can submit mails")),)
("submit_mails", _("Can submit mails")),
)
rules_permissions = { rules_permissions = {
"view_obj": is_creator | has_global_perm('mailer.view_global_message'), "view_obj": is_creator | has_global_perm("mailer.view_global_message"),
"change_obj": is_creator | has_global_perm('mailer.change_global_message'), "change_obj": is_creator | has_global_perm("mailer.change_global_message"),
"delete_obj": is_creator | has_global_perm('mailer.delete_global_message'), "delete_obj": is_creator | has_global_perm("mailer.delete_global_message"),
} }
class MessageForm(forms.ModelForm): class MessageForm(forms.ModelForm):
class Meta: class Meta:
model = Message model = Message
exclude = [] exclude = []
def clean(self): def clean(self):
group = self.cleaned_data.get('to_groups') group = self.cleaned_data.get("to_groups")
freizeit = self.cleaned_data.get('to_freizeit') freizeit = self.cleaned_data.get("to_freizeit")
notelist = self.cleaned_data.get('to_notelist') notelist = self.cleaned_data.get("to_notelist")
members = self.cleaned_data.get('to_members') members = self.cleaned_data.get("to_members")
if not group and freizeit is None and not members and notelist is None: if not group and freizeit is None and not members and notelist is None:
raise ValidationError(_('Either a group, a memberlist or at least' raise ValidationError(
' one member is required as recipient')) _("Either a group, a memberlist or at least one member is required as recipient")
)
class Attachment(CommonModel): class Attachment(CommonModel):
"""Represents an attachment to an email""" """Represents an attachment to an email"""
msg = models.ForeignKey(Message, on_delete=models.CASCADE) msg = models.ForeignKey(Message, on_delete=models.CASCADE)
# file (not naming it file because of builtin) # file (not naming it file because of builtin)
f = RestrictedFileField(_('file'), f = RestrictedFileField(_("file"), upload_to="attachments", max_upload_size=10)
upload_to='attachments',
max_upload_size=10)
def __str__(self): def __str__(self):
return os.path.basename(self.f.name) if self.f.name else str(_("Empty")) return os.path.basename(self.f.name) if self.f.name else str(_("Empty"))
class Meta: class Meta:
verbose_name = _('attachment') verbose_name = _("attachment")
verbose_name_plural = _('attachments') verbose_name_plural = _("attachments")
rules_permissions = { rules_permissions = {
"add_obj": is_creator | has_global_perm('mailer.view_global_message'), "add_obj": is_creator | has_global_perm("mailer.view_global_message"),
"view_obj": is_creator | has_global_perm('mailer.view_global_message'), "view_obj": is_creator | has_global_perm("mailer.view_global_message"),
"change_obj": is_creator | has_global_perm('mailer.change_global_message'), "change_obj": is_creator | has_global_perm("mailer.change_global_message"),
"delete_obj": is_creator | has_global_perm('mailer.delete_global_message'), "delete_obj": is_creator | has_global_perm("mailer.delete_global_message"),
} }

@ -1,6 +1,7 @@
from contrib.rules import memberize_user from contrib.rules import memberize_user
from rules import predicate from rules import predicate
@predicate @predicate
@memberize_user @memberize_user
def is_creator(self, message): def is_creator(self, message):

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

Loading…
Cancel
Save