Compare commits

...

10 Commits

@ -16,11 +16,13 @@ env:
NGINX_IMAGE_NAME: ${{ github.repository }}-nginx
jobs:
build-test-and-deploy:
build:
runs-on: ubuntu-latest
permissions:
contents: write
packages: write
pull-requests: write
actions: write
steps:
- name: Checkout repository
@ -32,7 +34,6 @@ jobs:
uses: docker/setup-buildx-action@v3
- name: Log in to GitHub Container Registry
if: github.event_name != 'pull_request'
uses: docker/login-action@v3
with:
registry: ${{ env.REGISTRY }}
@ -47,7 +48,7 @@ jobs:
tags: |
type=ref,event=branch
type=ref,event=pr
type=sha,prefix={{branch}}-
type=sha,prefix={{branch}}-,enable={{is_default_branch}}
type=raw,value=latest,enable={{is_default_branch}}
- name: Extract metadata for nginx image
@ -58,7 +59,7 @@ jobs:
tags: |
type=ref,event=branch
type=ref,event=pr
type=sha,prefix={{branch}}-
type=sha,prefix={{branch}}-,enable={{is_default_branch}}
type=raw,value=latest,enable={{is_default_branch}}
- name: Build application image
@ -95,9 +96,25 @@ jobs:
with:
github_token: ${{ secrets.GITHUB_TOKEN }}
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
- 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
run: make test-only
@ -111,7 +128,6 @@ jobs:
fi
- name: Tag and push application image
if: github.event_name != 'pull_request'
run: |
# Tag the built image with all required tags
echo "${{ steps.meta-app.outputs.tags }}" | while read -r tag; do
@ -120,7 +136,6 @@ jobs:
done
- name: Build and push nginx image
if: github.event_name != 'pull_request'
uses: docker/build-push-action@v5
with:
context: docker/production/nginx
@ -138,10 +153,40 @@ jobs:
BUILDKIT_INLINE_CACHE=1
- name: Output image tags
if: github.event_name != 'pull_request'
run: |
echo "Application image tags:"
echo "${{ steps.meta-app.outputs.tags }}"
echo ""
echo "Nginx image 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:

@ -4,32 +4,32 @@
# https://www.sphinx-doc.org/en/master/usage/configuration.html
from dataclasses import asdict
from sphinxawesome_theme import ThemeOptions
from sphinxawesome_theme import ThemeOptions
# -- Project information -------------------------------------------------------
# https://www.sphinx-doc.org/en/master/usage/configuration.html#project-information
project = 'Kompass'
release = '2.0'
author = 'The Kompass Team'
copyright = f'2025, {author}'
project = "Kompass"
release = "2.0"
author = "The Kompass Team"
copyright = f"2025, {author}"
# -- General configuration -----------------------------------------------------
# https://www.sphinx-doc.org/en/master/usage/configuration.html#general-configuration
extensions = []
templates_path = ['_templates']
templates_path = ["_templates"]
exclude_patterns = []
language = 'de'
language = "de"
# -- Options for HTML output ---------------------------------------------------
# https://www.sphinx-doc.org/en/master/usage/configuration.html#options-for-html-output
html_theme = 'sphinxawesome_theme'
html_static_path = ['_static']
html_theme = "sphinxawesome_theme"
html_static_path = ["_static"]
# -- Sphinxawsome-theme options ------------------------------------------------

@ -82,4 +82,3 @@ If you want to contribute code, please follow the inital setup steps in the :ref
- linting
- (auto) formatting
- reliable tests via ci/cd pipeline

@ -38,5 +38,3 @@ Structure
.. seealso::
:ref:`Contributing #Documentation <development_manual/contributing/documentation>`

@ -39,4 +39,3 @@ Development Documentation
:titlesonly:
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/
.. _Teilnehmer\*innen: https://jdav-hd.de/kompassmembers/member/

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

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

@ -1,28 +1,26 @@
import os
from django.contrib.auth import get_user_model
from django.core.management.base import BaseCommand
class Command(BaseCommand):
help = "Creates a super-user non-interactively if it doesn't exist."
def handle(self, *args, **options):
User = get_user_model()
username = os.environ.get('DJANGO_SUPERUSER_USERNAME', '')
password = os.environ.get('DJANGO_SUPERUSER_PASSWORD', '')
username = os.environ.get("DJANGO_SUPERUSER_USERNAME", "")
password = os.environ.get("DJANGO_SUPERUSER_PASSWORD", "")
if not username or not password:
self.stdout.write(
self.style.WARNING('Superuser data was not set. Skipping.')
)
self.stdout.write(self.style.WARNING("Superuser data was not set. Skipping."))
return
if not User.objects.filter(username=username).exists():
User.objects.create_superuser(username=username, password=password)
self.stdout.write(
self.style.SUCCESS('Successfully created superuser.')
)
self.stdout.write(self.style.SUCCESS("Successfully created superuser."))
else:
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
from wsgiref.util import FileWrapper
from django import template
from django.conf import settings
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):
@ -27,12 +27,15 @@ def serve_media(filename, content_type):
"""
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['Content-Type'] = content_type
response["Content-Type"] = content_type
# 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

@ -1,10 +1,17 @@
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.
class CommonModel(models.Model, RulesModelMixin, metaclass=RulesModelBase):
class Meta:
abstract = True
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
def memberize_user(func):
def inner(user, other):
if not hasattr(user, 'member'):
if not hasattr(user, "member"):
return False
return func(user.member, other)
return inner

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

@ -1,28 +1,38 @@
from datetime import datetime, timedelta
from decimal import Decimal
from django.test import TestCase, RequestFactory
from django.contrib.auth import get_user_model
from datetime import timedelta
from unittest.mock import Mock
from unittest.mock import patch
from contrib.admin import CommonAdminMixin
from contrib.models import CommonModel
from contrib.rules import has_global_perm
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.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 unittest.mock import Mock, patch
from rules.contrib.models import RulesModelMixin, RulesModelBase
from contrib.models import CommonModel
from contrib.rules import has_global_perm
from contrib.admin import CommonAdminMixin
from utils import file_size_validator, RestrictedFileField, cvt_to_decimal, get_member, normalize_name, normalize_filename, coming_midnight, mondays_until_nth
from rules.contrib.models import RulesModelBase
from rules.contrib.models import RulesModelMixin
from utils import file_size_validator
from utils import mondays_until_nth
from utils import RestrictedFileField
User = get_user_model()
class CommonModelTestCase(TestCase):
def test_common_model_abstract_base(self):
"""Test that CommonModel provides the correct meta attributes"""
meta = CommonModel._meta
self.assertTrue(meta.abstract)
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)
@ -38,15 +48,13 @@ class CommonModelTestCase(TestCase):
class GlobalPermissionRulesTestCase(TestCase):
def setUp(self):
self.user = User.objects.create_user(
username='testuser',
email='test@example.com',
password='testpass123'
username="testuser", email="test@example.com", password="testpass123"
)
def test_has_global_perm_predicate_creation(self):
"""Test that has_global_perm creates a predicate function"""
# 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))
def test_has_global_perm_with_superuser(self):
@ -54,33 +62,32 @@ class GlobalPermissionRulesTestCase(TestCase):
self.user.is_superuser = True
self.user.save()
predicate = has_global_perm('auth.add_user')
predicate = has_global_perm("auth.add_user")
result = predicate(self.user, None)
self.assertTrue(result)
def test_has_global_perm_with_regular_user(self):
"""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)
self.assertFalse(result)
class CommonAdminMixinTestCase(TestCase):
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):
"""Test formfield_for_dbfield when db_field class is in formfield_overrides"""
# Create a test admin instance that inherits from Django's ModelAdmin
class TestAdmin(CommonAdminMixin, admin.ModelAdmin):
formfield_overrides = {
models.ForeignKey: {'widget': Mock()}
}
formfield_overrides = {models.ForeignKey: {"widget": Mock()}}
# Create a mock model to use with the admin
class TestModel:
_meta = Mock()
_meta.app_label = 'test'
_meta.app_label = "test"
admin_instance = TestAdmin(TestModel, admin.site)
@ -88,11 +95,11 @@ class CommonAdminMixinTestCase(TestCase):
db_field = models.ForeignKey(User, on_delete=models.CASCADE)
# Create a test request
request = RequestFactory().get('/')
request = RequestFactory().get("/")
request.user = self.user
# 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
self.assertIsNotNone(result)
@ -101,9 +108,7 @@ class CommonAdminMixinTestCase(TestCase):
class UtilsTestCase(TestCase):
def setUp(self):
self.user = User.objects.create_user(
username='testuser',
email='test@example.com',
password='testpass123'
username="testuser", email="test@example.com", password="testpass123"
)
def test_file_size_validator_exceeds_limit(self):
@ -118,12 +123,14 @@ class UtilsTestCase(TestCase):
validator(mock_file)
# 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))
def test_restricted_file_field_content_type_not_supported(self):
"""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
mock_data = Mock()
@ -131,12 +138,12 @@ class UtilsTestCase(TestCase):
mock_data.file.content_type = "text/plain"
# 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:
field.clean("dummy")
# 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))
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 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:
field.clean("dummy")
# 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))
def test_mondays_until_nth(self):

@ -1,136 +1,293 @@
# Generated by Django 4.0.1 on 2023-03-29 22:16
from django.db import migrations, models
import django.db.models.deletion
from django.db import migrations
from django.db import models
class Migration(migrations.Migration):
initial = True
dependencies = [
('members', '0002_remove_member_not_waiting_and_more'),
("members", "0002_remove_member_not_waiting_and_more"),
]
operations = [
migrations.CreateModel(
name='Ledger',
name="Ledger",
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={
'verbose_name': 'Ledger',
'verbose_name_plural': 'Ledgers',
"verbose_name": "Ledger",
"verbose_name_plural": "Ledgers",
},
),
migrations.CreateModel(
name='Statement',
name="Statement",
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')),
('explanation', models.TextField(blank=True, verbose_name='Explanation')),
('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')),
(
"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"),
),
("explanation", models.TextField(blank=True, verbose_name="Explanation")),
(
"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={
'verbose_name': 'Statement',
'verbose_name_plural': 'Statements',
'permissions': [('may_edit_submitted_statements', 'Is allowed to edit submitted statements')],
"verbose_name": "Statement",
"verbose_name_plural": "Statements",
"permissions": [
("may_edit_submitted_statements", "Is allowed to edit submitted statements")
],
},
),
migrations.CreateModel(
name='Transaction',
name="Transaction",
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('reference', models.TextField(verbose_name='Reference')),
('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')),
(
"id",
models.BigAutoField(
auto_created=True, primary_key=True, serialize=False, verbose_name="ID"
),
),
("reference", models.TextField(verbose_name="Reference")),
(
"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={
'verbose_name': 'Transaction',
'verbose_name_plural': 'Transactions',
"verbose_name": "Transaction",
"verbose_name_plural": "Transactions",
},
),
migrations.CreateModel(
name='Receipt',
name="Receipt",
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')),
('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')),
(
"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"),
),
("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(
name='Bill',
name="Bill",
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')),
('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')),
(
"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"),
),
("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={
'verbose_name': 'Bill',
'verbose_name_plural': 'Bills',
"verbose_name": "Bill",
"verbose_name_plural": "Bills",
},
),
migrations.CreateModel(
name='StatementConfirmed',
fields=[
],
name="StatementConfirmed",
fields=[],
options={
'verbose_name': 'Paid statement',
'verbose_name_plural': 'Paid statements',
'permissions': (('may_manage_confirmed_statements', 'Can view and manage confirmed statements.'),),
'proxy': True,
'indexes': [],
'constraints': [],
"verbose_name": "Paid statement",
"verbose_name_plural": "Paid statements",
"permissions": (
(
"may_manage_confirmed_statements",
"Can view and manage confirmed statements.",
),
),
"proxy": True,
"indexes": [],
"constraints": [],
},
bases=('finance.statement',),
bases=("finance.statement",),
),
migrations.CreateModel(
name='StatementSubmitted',
fields=[
],
name="StatementSubmitted",
fields=[],
options={
'verbose_name': 'Submitted statement',
'verbose_name_plural': 'Submitted statements',
'permissions': (('may_manage_submitted_statements', 'Can view and manage submitted statements.'),),
'proxy': True,
'indexes': [],
'constraints': [],
"verbose_name": "Submitted statement",
"verbose_name_plural": "Submitted statements",
"permissions": (
(
"may_manage_submitted_statements",
"Can view and manage submitted statements.",
),
),
"proxy": True,
"indexes": [],
"constraints": [],
},
bases=('finance.statement',),
bases=("finance.statement",),
),
migrations.CreateModel(
name='StatementUnSubmitted',
fields=[
],
name="StatementUnSubmitted",
fields=[],
options={
'verbose_name': 'Statement in preparation',
'verbose_name_plural': 'Statements in preparation',
'proxy': True,
'indexes': [],
'constraints': [],
"verbose_name": "Statement in preparation",
"verbose_name_plural": "Statements in preparation",
"proxy": True,
"indexes": [],
"constraints": [],
},
bases=('finance.statement',),
bases=("finance.statement",),
),
]

@ -4,46 +4,50 @@ from django.db import migrations
class Migration(migrations.Migration):
#replaces = [('finance', '0002_billonexcursionproxy_billonstatementproxy_and_more'), ('finance', '0003_alter_statementunsubmitted_options'), ('finance', '0004_alter_billonexcursionproxy_options'), ('finance', '0005_alter_billonstatementproxy_options'), ('finance', '0006_alter_statementsubmitted_options'), ('finance', '0007_alter_billonexcursionproxy_options_and_more')]
# replaces = [('finance', '0002_billonexcursionproxy_billonstatementproxy_and_more'), ('finance', '0003_alter_statementunsubmitted_options'), ('finance', '0004_alter_billonexcursionproxy_options'), ('finance', '0005_alter_billonstatementproxy_options'), ('finance', '0006_alter_statementsubmitted_options'), ('finance', '0007_alter_billonexcursionproxy_options_and_more')]
dependencies = [
('finance', '0001_initial'),
("finance", "0001_initial"),
]
operations = [
migrations.AlterModelOptions(
name='statementsubmitted',
options={'permissions': [('process_statementsubmitted', 'Can manage submitted statements.')], 'verbose_name': 'Submitted statement', 'verbose_name_plural': 'Submitted statements'},
name="statementsubmitted",
options={
"permissions": [("process_statementsubmitted", "Can manage submitted statements.")],
"verbose_name": "Submitted statement",
"verbose_name_plural": "Submitted statements",
},
),
migrations.CreateModel(
name='BillOnExcursionProxy',
fields=[
],
name="BillOnExcursionProxy",
fields=[],
options={
'verbose_name': 'Bill',
'verbose_name_plural': 'Bills',
'proxy': True,
'indexes': [],
'constraints': [],
"verbose_name": "Bill",
"verbose_name_plural": "Bills",
"proxy": True,
"indexes": [],
"constraints": [],
},
bases=('finance.bill',),
bases=("finance.bill",),
),
migrations.CreateModel(
name='BillOnStatementProxy',
fields=[
],
name="BillOnStatementProxy",
fields=[],
options={
'verbose_name': 'Bill',
'verbose_name_plural': 'Bills',
'proxy': True,
'indexes': [],
'constraints': [],
"verbose_name": "Bill",
"verbose_name_plural": "Bills",
"proxy": True,
"indexes": [],
"constraints": [],
},
bases=('finance.bill',),
bases=("finance.bill",),
),
migrations.AlterModelOptions(
name='statementunsubmitted',
options={'verbose_name': 'Statement in preparation', 'verbose_name_plural': 'Statements in preparation'},
name="statementunsubmitted",
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):
dependencies = [
('finance', '0002_alter_permissions'),
("finance", "0002_alter_permissions"),
]
operations = [
migrations.AlterModelOptions(
name='bill',
options={'default_permissions': ('add_global', 'change_global', 'view_global', 'delete_global', 'list_global', 'view'), 'verbose_name': 'Bill', 'verbose_name_plural': 'Bills'},
name="bill",
options={
"default_permissions": (
"add_global",
"change_global",
"view_global",
"delete_global",
"list_global",
"view",
),
"verbose_name": "Bill",
"verbose_name_plural": "Bills",
},
),
migrations.AlterModelOptions(
name='billonexcursionproxy',
options={'default_permissions': ('add_global', 'change_global', 'view_global', 'delete_global', 'list_global', 'view'), 'verbose_name': 'Bill', 'verbose_name_plural': 'Bills'},
name="billonexcursionproxy",
options={
"default_permissions": (
"add_global",
"change_global",
"view_global",
"delete_global",
"list_global",
"view",
),
"verbose_name": "Bill",
"verbose_name_plural": "Bills",
},
),
migrations.AlterModelOptions(
name='billonstatementproxy',
options={'default_permissions': ('add_global', 'change_global', 'view_global', 'delete_global', 'list_global', 'view'), 'verbose_name': 'Bill', 'verbose_name_plural': 'Bills'},
name="billonstatementproxy",
options={
"default_permissions": (
"add_global",
"change_global",
"view_global",
"delete_global",
"list_global",
"view",
),
"verbose_name": "Bill",
"verbose_name_plural": "Bills",
},
),
migrations.AlterModelOptions(
name='statement',
options={'default_permissions': ('add_global', 'change_global', 'view_global', 'delete_global', 'list_global', 'view'), 'permissions': [('may_edit_submitted_statements', 'Is allowed to edit submitted statements')], 'verbose_name': 'Statement', 'verbose_name_plural': 'Statements'},
name="statement",
options={
"default_permissions": (
"add_global",
"change_global",
"view_global",
"delete_global",
"list_global",
"view",
),
"permissions": [
("may_edit_submitted_statements", "Is allowed to edit submitted statements")
],
"verbose_name": "Statement",
"verbose_name_plural": "Statements",
},
),
migrations.AlterModelOptions(
name='statementconfirmed',
options={'default_permissions': ('add_global', 'change_global', 'view_global', 'delete_global', 'list_global', 'view'), 'permissions': [('may_manage_confirmed_statements', 'Can view and manage confirmed statements.')], 'verbose_name': 'Paid statement', 'verbose_name_plural': 'Paid statements'},
name="statementconfirmed",
options={
"default_permissions": (
"add_global",
"change_global",
"view_global",
"delete_global",
"list_global",
"view",
),
"permissions": [
("may_manage_confirmed_statements", "Can view and manage confirmed statements.")
],
"verbose_name": "Paid statement",
"verbose_name_plural": "Paid statements",
},
),
migrations.AlterModelOptions(
name='statementsubmitted',
options={'default_permissions': ('add_global', 'change_global', 'view_global', 'delete_global', 'list_global', 'view'), 'permissions': [('process_statementsubmitted', 'Can manage submitted statements.')], 'verbose_name': 'Submitted statement', 'verbose_name_plural': 'Submitted statements'},
name="statementsubmitted",
options={
"default_permissions": (
"add_global",
"change_global",
"view_global",
"delete_global",
"list_global",
"view",
),
"permissions": [("process_statementsubmitted", "Can manage submitted statements.")],
"verbose_name": "Submitted statement",
"verbose_name_plural": "Submitted statements",
},
),
migrations.AlterModelOptions(
name='statementunsubmitted',
options={'default_permissions': ('add_global', 'change_global', 'view_global', 'delete_global', 'list_global', 'view'), 'verbose_name': 'Statement in preparation', 'verbose_name_plural': 'Statements in preparation'},
name="statementunsubmitted",
options={
"default_permissions": (
"add_global",
"change_global",
"view_global",
"delete_global",
"list_global",
"view",
),
"verbose_name": "Statement in preparation",
"verbose_name_plural": "Statements in preparation",
},
),
]

@ -1,18 +1,20 @@
# 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):
dependencies = [
('finance', '0003_alter_bill_options_and_more'),
("finance", "0003_alter_bill_options_and_more"),
]
operations = [
migrations.AlterField(
model_name='bill',
name='amount',
field=models.DecimalField(decimal_places=2, default=0, max_digits=6, verbose_name='Amount'),
model_name="bill",
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
from django.db import migrations
import utils
from django.db import migrations
class Migration(migrations.Migration):
dependencies = [
('finance', '0004_alter_bill_amount'),
("finance", "0004_alter_bill_amount"),
]
operations = [
migrations.AlterField(
model_name='bill',
name='proof',
field=utils.RestrictedFileField(blank=True, upload_to='bill_images', verbose_name='Proof'),
model_name="bill",
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
from django.db import migrations, models
import django.db.models.deletion
from django.db import migrations
from django.db import models
class Migration(migrations.Migration):
dependencies = [
('members', '0032_member_upload_registration_form_key'),
('members', '0033_freizeit_approved_extra_youth_leader_count'),
('finance', '0005_alter_bill_proof'),
("members", "0032_member_upload_registration_form_key"),
("members", "0033_freizeit_approved_extra_youth_leader_count"),
("finance", "0005_alter_bill_proof"),
]
operations = [
migrations.AddField(
model_name='statement',
name='allowance_to',
field=models.ManyToManyField(help_text='The youth leaders to which an allowance should be paid. The count must match the number of permitted youth leaders.', related_name='receives_allowance_for_statements', to='members.Member', verbose_name='Pay allowance to'),
model_name="statement",
name="allowance_to",
field=models.ManyToManyField(
help_text="The youth leaders to which an allowance should be paid. The count must match the number of permitted youth leaders.",
related_name="receives_allowance_for_statements",
to="members.Member",
verbose_name="Pay allowance to",
),
),
migrations.AddField(
model_name='statement',
name='subsidy_to',
field=models.ForeignKey(help_text='The person that should receive the subsidy for night and travel costs. Typically the person who paid for them.', null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='receives_subsidy_for_statements', to='members.member', verbose_name='Pay subsidy to'),
model_name="statement",
name="subsidy_to",
field=models.ForeignKey(
help_text="The person that should receive the subsidy for night and travel costs. Typically the person who paid for them.",
null=True,
on_delete=django.db.models.deletion.SET_NULL,
related_name="receives_subsidy_for_statements",
to="members.member",
verbose_name="Pay subsidy to",
),
),
]

@ -1,19 +1,25 @@
# 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):
dependencies = [
('members', '0033_freizeit_approved_extra_youth_leader_count'),
('finance', '0006_statement_add_allowance_to_subsidy_to'),
("members", "0033_freizeit_approved_extra_youth_leader_count"),
("finance", "0006_statement_add_allowance_to_subsidy_to"),
]
operations = [
migrations.AlterField(
model_name='statement',
name='allowance_to',
field=models.ManyToManyField(blank=True, help_text='The youth leaders to which an allowance should be paid. The count must match the number of permitted youth leaders.', related_name='receives_allowance_for_statements', to='members.Member', verbose_name='Pay allowance to'),
model_name="statement",
name="allowance_to",
field=models.ManyToManyField(
blank=True,
help_text="The youth leaders to which an allowance should be paid. The count must match the number of permitted youth leaders.",
related_name="receives_allowance_for_statements",
to="members.Member",
verbose_name="Pay allowance to",
),
),
]

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

@ -1,20 +1,28 @@
# Generated by Django 4.2.20 on 2025-04-03 21:04
from django.db import migrations, models
import django.db.models.deletion
from django.db import migrations
from django.db import models
class Migration(migrations.Migration):
dependencies = [
('members', '0039_membertraining_certificate_attendance'),
('finance', '0008_alter_statement_allowance_to_and_more'),
("members", "0039_membertraining_certificate_attendance"),
("finance", "0008_alter_statement_allowance_to_and_more"),
]
operations = [
migrations.AddField(
model_name='statement',
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'),
model_name="statement",
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",
),
),
]

@ -1,6 +1,7 @@
# 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):
@ -10,7 +11,7 @@ def set_status_from_old_fields(apps, schema_editor):
- If submitted is True but confirmed is False, status = SUBMITTED (1)
- Otherwise, status = UNSUBMITTED (0)
"""
Statement = apps.get_model('finance', 'Statement')
Statement = apps.get_model("finance", "Statement")
UNSUBMITTED, SUBMITTED, CONFIRMED = 0, 1, 2
for statement in Statement.objects.all():
@ -20,20 +21,23 @@ def set_status_from_old_fields(apps, schema_editor):
statement.status = SUBMITTED
else:
statement.status = UNSUBMITTED
statement.save(update_fields=['status'])
statement.save(update_fields=["status"])
class Migration(migrations.Migration):
dependencies = [
('finance', '0009_statement_ljp_to'),
("finance", "0009_statement_ljp_to"),
]
operations = [
migrations.AddField(
model_name='statement',
name='status',
field=models.IntegerField(choices=[(0, 'In preparation'), (1, 'Submitted'), (2, 'Confirmed')], default=0, verbose_name='Status'),
model_name="statement",
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),
]

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

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

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

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

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

@ -1,16 +1,34 @@
from unittest import skip
from decimal import Decimal
from dateutil.relativedelta import relativedelta
from django.conf import settings
from django.test import TestCase
from django.utils import timezone
from django.conf import settings
from django.utils.translation import gettext_lazy as _
from decimal import Decimal
from finance.models import Statement, StatementUnSubmitted, StatementSubmitted, Bill, Ledger, Transaction,\
StatementUnSubmittedManager, StatementSubmittedManager, StatementConfirmedManager,\
StatementConfirmed, TransactionIssue, StatementManager
from members.models import Member, Group, Freizeit, LJPProposal, Intervention, GEMEINSCHAFTS_TOUR, MUSKELKRAFT_ANREISE, NewMemberOnList,\
FAHRGEMEINSCHAFT_ANREISE, MALE, FEMALE, DIVERSE
from dateutil.relativedelta import relativedelta
from utils import get_member
from finance.models import Bill
from finance.models import Ledger
from finance.models import Statement
from finance.models import StatementConfirmed
from finance.models import StatementConfirmedManager
from finance.models import StatementManager
from finance.models import StatementSubmitted
from finance.models import StatementSubmittedManager
from finance.models import StatementUnSubmitted
from finance.models import StatementUnSubmittedManager
from finance.models import Transaction
from finance.models import TransactionIssue
from members.models import DIVERSE
from members.models import FAHRGEMEINSCHAFT_ANREISE
from members.models import Freizeit
from members.models import GEMEINSCHAFTS_TOUR
from members.models import Group
from members.models import Intervention
from members.models import LJPProposal
from members.models import MALE
from members.models import Member
from members.models import MUSKELKRAFT_ANREISE
from members.models import NewMemberOnList
# Create your tests here.
class StatementTestCase(TestCase):
@ -22,82 +40,149 @@ class StatementTestCase(TestCase):
def setUp(self):
self.jl = Group.objects.create(name="Jugendleiter")
self.fritz = Member.objects.create(prename="Fritz", lastname="Wulter", birth_date=timezone.now().date(),
email=settings.TEST_MAIL, gender=MALE)
self.fritz = Member.objects.create(
prename="Fritz",
lastname="Wulter",
birth_date=timezone.now().date(),
email=settings.TEST_MAIL,
gender=MALE,
)
self.fritz.group.add(self.jl)
self.fritz.save()
self.personal_account = Ledger.objects.create(name='personal account')
self.personal_account = Ledger.objects.create(name="personal account")
self.st = Statement.objects.create(short_description='A statement', explanation='Important!', night_cost=0)
Bill.objects.create(statement=self.st, short_description='food', explanation='i was hungry',
amount=67.3, costs_covered=False, paid_by=self.fritz)
Transaction.objects.create(reference='gift', amount=12.3,
ledger=self.personal_account, member=self.fritz,
statement=self.st)
self.st = Statement.objects.create(
short_description="A statement", explanation="Important!", night_cost=0
)
Bill.objects.create(
statement=self.st,
short_description="food",
explanation="i was hungry",
amount=67.3,
costs_covered=False,
paid_by=self.fritz,
)
Transaction.objects.create(
reference="gift",
amount=12.3,
ledger=self.personal_account,
member=self.fritz,
statement=self.st,
)
self.st2 = Statement.objects.create(short_description='Actual expenses', night_cost=0)
Bill.objects.create(statement=self.st2, short_description='food', explanation='i was hungry',
amount=67.3, costs_covered=True, paid_by=self.fritz)
self.st2 = Statement.objects.create(short_description="Actual expenses", night_cost=0)
Bill.objects.create(
statement=self.st2,
short_description="food",
explanation="i was hungry",
amount=67.3,
costs_covered=True,
paid_by=self.fritz,
)
ex = Freizeit.objects.create(name='Wild trip', kilometers_traveled=self.kilometers_traveled,
ex = Freizeit.objects.create(
name="Wild trip",
kilometers_traveled=self.kilometers_traveled,
tour_type=GEMEINSCHAFTS_TOUR,
tour_approach=MUSKELKRAFT_ANREISE,
difficulty=1)
self.st3 = Statement.objects.create(night_cost=self.night_cost, excursion=ex, subsidy_to=self.fritz)
difficulty=1,
)
self.st3 = Statement.objects.create(
night_cost=self.night_cost, excursion=ex, subsidy_to=self.fritz
)
for i in range(self.participant_count):
m = Member.objects.create(prename='Fritz {}'.format(i), lastname='Walter', birth_date=timezone.now().date(),
email=settings.TEST_MAIL, gender=MALE)
m = Member.objects.create(
prename="Fritz {}".format(i),
lastname="Walter",
birth_date=timezone.now().date(),
email=settings.TEST_MAIL,
gender=MALE,
)
mol = NewMemberOnList.objects.create(member=m, memberlist=ex)
ex.membersonlist.add(mol)
for i in range(self.staff_count):
m = Member.objects.create(prename='Fritz {}'.format(i), lastname='Walter', birth_date=timezone.now().date(),
email=settings.TEST_MAIL, gender=MALE)
Bill.objects.create(statement=self.st3, short_description='food', explanation='i was hungry',
amount=42.69, costs_covered=True, paid_by=m)
m = Member.objects.create(
prename="Fritz {}".format(i),
lastname="Walter",
birth_date=timezone.now().date(),
email=settings.TEST_MAIL,
gender=MALE,
)
Bill.objects.create(
statement=self.st3,
short_description="food",
explanation="i was hungry",
amount=42.69,
costs_covered=True,
paid_by=m,
)
m.group.add(self.jl)
ex.jugendleiter.add(m)
if i < self.allowance_to_count:
self.st3.allowance_to.add(m)
# Create a small excursion with < 5 theoretic LJP participants for LJP contribution test
small_ex = Freizeit.objects.create(name='Small trip', kilometers_traveled=100,
small_ex = Freizeit.objects.create(
name="Small trip",
kilometers_traveled=100,
tour_type=GEMEINSCHAFTS_TOUR,
tour_approach=MUSKELKRAFT_ANREISE,
difficulty=1)
difficulty=1,
)
# Add only 3 participants (< 5 for theoretic_ljp_participant_count)
for i in range(3):
# Create young participants (< 6 years old) so they don't count toward LJP
birth_date = timezone.now().date() - relativedelta(years=4)
m = Member.objects.create(prename='Small {}'.format(i), lastname='Participant',
m = Member.objects.create(
prename="Small {}".format(i),
lastname="Participant",
birth_date=birth_date,
email=settings.TEST_MAIL, gender=MALE)
email=settings.TEST_MAIL,
gender=MALE,
)
NewMemberOnList.objects.create(member=m, memberlist=small_ex)
# Create LJP proposal for the small excursion
ljp_proposal = LJPProposal.objects.create(title='Small LJP', category=LJPProposal.LJP_STAFF_TRAINING)
ljp_proposal = LJPProposal.objects.create(
title="Small LJP", category=LJPProposal.LJP_STAFF_TRAINING
)
small_ex.ljpproposal = ljp_proposal
small_ex.save()
self.st_small = Statement.objects.create(night_cost=10, excursion=small_ex)
ex = Freizeit.objects.create(name='Wild trip 2', kilometers_traveled=self.kilometers_traveled,
ex = Freizeit.objects.create(
name="Wild trip 2",
kilometers_traveled=self.kilometers_traveled,
tour_type=GEMEINSCHAFTS_TOUR,
tour_approach=MUSKELKRAFT_ANREISE,
difficulty=2)
self.st4 = Statement.objects.create(night_cost=self.night_cost, excursion=ex, subsidy_to=self.fritz)
difficulty=2,
)
self.st4 = Statement.objects.create(
night_cost=self.night_cost, excursion=ex, subsidy_to=self.fritz
)
for i in range(2):
m = Member.objects.create(prename='Peter {}'.format(i), lastname='Walter',
m = Member.objects.create(
prename="Peter {}".format(i),
lastname="Walter",
birth_date=timezone.now().date() - relativedelta(years=30),
email=settings.TEST_MAIL, gender=DIVERSE)
email=settings.TEST_MAIL,
gender=DIVERSE,
)
mol = NewMemberOnList.objects.create(member=m, memberlist=ex)
ex.membersonlist.add(mol)
base = timezone.now()
ex = Freizeit.objects.create(name='Wild trip with old people', kilometers_traveled=self.kilometers_traveled,
ex = Freizeit.objects.create(
name="Wild trip with old people",
kilometers_traveled=self.kilometers_traveled,
tour_type=GEMEINSCHAFTS_TOUR,
tour_approach=MUSKELKRAFT_ANREISE,
difficulty=2, date=timezone.datetime(2024, 1, 2, 8, 0, 0, tzinfo=base.tzinfo), end=timezone.datetime(2024, 1, 5, 17, 0, 0, tzinfo=base.tzinfo) )
difficulty=2,
date=timezone.datetime(2024, 1, 2, 8, 0, 0, tzinfo=base.tzinfo),
end=timezone.datetime(2024, 1, 5, 17, 0, 0, tzinfo=base.tzinfo),
)
settings.EXCURSION_ORG_FEE = 20
settings.LJP_TAX = 0.2
@ -106,207 +191,335 @@ class StatementTestCase(TestCase):
self.st5 = Statement.objects.create(night_cost=self.night_cost, excursion=ex)
for i in range(9):
m = Member.objects.create(prename='Peter {}'.format(i), lastname='Walter', birth_date=timezone.now().date() - relativedelta(years=i+21),
email=settings.TEST_MAIL, gender=DIVERSE)
m = Member.objects.create(
prename="Peter {}".format(i),
lastname="Walter",
birth_date=timezone.now().date() - relativedelta(years=i + 21),
email=settings.TEST_MAIL,
gender=DIVERSE,
)
mol = NewMemberOnList.objects.create(member=m, memberlist=ex)
ex.membersonlist.add(mol)
ljpproposal = LJPProposal.objects.create(
title='Test proposal',
title="Test proposal",
category=LJPProposal.LJP_STAFF_TRAINING,
goal=LJPProposal.LJP_ENVIRONMENT,
goal_strategy='my strategy',
goal_strategy="my strategy",
not_bw_reason=LJPProposal.NOT_BW_ROOMS,
excursion=self.st5.excursion)
excursion=self.st5.excursion,
)
for i in range(3):
int = Intervention.objects.create(
date_start=timezone.datetime(2024, 1, 2+i, 12, 0, 0, tzinfo=base.tzinfo),
duration = 2+i,
activity = 'hi',
ljp_proposal=ljpproposal
Intervention.objects.create(
date_start=timezone.datetime(2024, 1, 2 + i, 12, 0, 0, tzinfo=base.tzinfo),
duration=2 + i,
activity="hi",
ljp_proposal=ljpproposal,
)
self.b1 = Bill.objects.create(
statement=self.st5,
short_description='covered bill',
explanation='hi',
amount='300',
short_description="covered bill",
explanation="hi",
amount="300",
paid_by=self.fritz,
costs_covered=True,
refunded=False
refunded=False,
)
self.b2 = Bill.objects.create(
statement=self.st5,
short_description='non-covered bill',
explanation='hi',
amount='900',
short_description="non-covered bill",
explanation="hi",
amount="900",
paid_by=self.fritz,
costs_covered=False,
refunded=False
refunded=False,
)
self.st6 = Statement.objects.create(night_cost=self.night_cost)
Bill.objects.create(statement=self.st6, amount='42', costs_covered=True)
Bill.objects.create(statement=self.st6, amount="42", costs_covered=True)
def test_org_fee(self):
# org fee should be collected if participants are older than 26
self.assertEqual(self.st5.excursion.old_participant_count, 3, 'Calculation of number of old people in excursion is incorrect.')
self.assertEqual(
self.st5.excursion.old_participant_count,
3,
"Calculation of number of old people in excursion is incorrect.",
)
total_org = 4 * 3 * 20 # 4 days, 3 old people, 20€ per day
self.assertEqual(self.st5.total_org_fee_theoretical, total_org, 'Theoretical org_fee should equal to amount per day per person * n_persons * n_days if there are old people.')
self.assertEqual(self.st5.total_org_fee, 0, 'Paid org fee should be 0 if no allowance and subsidies are paid if there are old people.')
self.assertEqual(
self.st5.total_org_fee_theoretical,
total_org,
"Theoretical org_fee should equal to amount per day per person * n_persons * n_days if there are old people.",
)
self.assertEqual(
self.st5.total_org_fee,
0,
"Paid org fee should be 0 if no allowance and subsidies are paid if there are old people.",
)
self.assertIsNone(self.st5.org_fee_payant)
# now collect subsidies
self.st5.subsidy_to = self.fritz
self.assertEqual(self.st5.total_org_fee, total_org, 'Paid org fee should equal to amount per day per person * n_persons * n_days if subsidies are paid.')
self.assertEqual(
self.st5.total_org_fee,
total_org,
"Paid org fee should equal to amount per day per person * n_persons * n_days if subsidies are paid.",
)
# now collect allowances
self.st5.allowance_to.add(self.fritz)
self.st5.subsidy_to = None
self.assertEqual(self.st5.total_org_fee, total_org, 'Paid org fee should equal to amount per day per person * n_persons * n_days if allowances are paid.')
self.assertEqual(
self.st5.total_org_fee,
total_org,
"Paid org fee should equal to amount per day per person * n_persons * n_days if allowances are paid.",
)
# now collect both
self.st5.subsidy_to = self.fritz
self.assertEqual(self.st5.total_org_fee, total_org, 'Paid org fee should equal to amount per day per person * n_persons * n_days if subsidies and allowances are paid.')
self.assertEqual(
self.st5.total_org_fee,
total_org,
"Paid org fee should equal to amount per day per person * n_persons * n_days if subsidies and allowances are paid.",
)
self.assertEqual(self.st5.org_fee_payant, self.fritz, 'Org fee payant should be the receiver allowances and subsidies.')
self.assertEqual(
self.st5.org_fee_payant,
self.fritz,
"Org fee payant should be the receiver allowances and subsidies.",
)
# return to previous state
self.st5.subsidy_to = None
self.st5.allowance_to.remove(self.fritz)
def test_ljp_payment(self):
expected_intervention_hours = 2 + 3 + 4
expected_seminar_days = 0 + 0.5 + 0.5 # >=2.5h = 0.5days, >=5h = 1.0day
expected_ljp = (1-settings.LJP_TAX) * expected_seminar_days * settings.LJP_CONTRIBUTION_PER_DAY * 9
expected_ljp = (
(1 - settings.LJP_TAX) * expected_seminar_days * settings.LJP_CONTRIBUTION_PER_DAY * 9
)
# (1 - 20% tax) * 1 seminar day * 20€ * 9 participants
self.assertEqual(self.st5.excursion.total_intervention_hours, expected_intervention_hours, 'Calculation of total intervention hours is incorrect.')
self.assertEqual(self.st5.excursion.total_seminar_days, expected_seminar_days, 'Calculation of total seminar days is incorrect.')
self.assertEqual(
self.st5.excursion.total_intervention_hours,
expected_intervention_hours,
"Calculation of total intervention hours is incorrect.",
)
self.assertEqual(
self.st5.excursion.total_seminar_days,
expected_seminar_days,
"Calculation of total seminar days is incorrect.",
)
self.assertEqual(self.st5.paid_ljp_contributions, 0, 'No LJP contributions should be paid if no receiver is set.')
self.assertEqual(
self.st5.paid_ljp_contributions,
0,
"No LJP contributions should be paid if no receiver is set.",
)
# now we want to pay out the LJP contributions
self.st5.ljp_to = self.fritz
self.assertEqual(self.st5.paid_ljp_contributions, expected_ljp, 'LJP contributions should be paid if a receiver is set.')
self.assertEqual(
self.st5.paid_ljp_contributions,
expected_ljp,
"LJP contributions should be paid if a receiver is set.",
)
# now the total costs paid by trip organisers is lower than expected ljp contributions, should be reduced automatically
self.b2.amount=100
self.b2.amount = 100
self.b2.save()
self.assertEqual(self.st5.total_bills_not_covered, 100, 'Changes in bills should be reflected in the total costs paid by trip organisers')
self.assertGreaterEqual(self.st5.total_bills_not_covered, self.st5.paid_ljp_contributions, 'LJP contributions should be less than or equal to the costs paid by trip organisers')
self.assertEqual(
self.st5.total_bills_not_covered,
100,
"Changes in bills should be reflected in the total costs paid by trip organisers",
)
self.assertGreaterEqual(
self.st5.total_bills_not_covered,
self.st5.paid_ljp_contributions,
"LJP contributions should be less than or equal to the costs paid by trip organisers",
)
self.st5.ljp_to = None
def test_staff_count(self):
self.assertEqual(self.st4.admissible_staff_count, 0,
'Admissible staff count is not 0, although not enough participants.')
self.assertEqual(
self.st4.admissible_staff_count,
0,
"Admissible staff count is not 0, although not enough participants.",
)
for i in range(2):
m = Member.objects.create(prename='Peter {}'.format(i), lastname='Walter', birth_date=timezone.now().date(),
email=settings.TEST_MAIL, gender=DIVERSE)
m = Member.objects.create(
prename="Peter {}".format(i),
lastname="Walter",
birth_date=timezone.now().date(),
email=settings.TEST_MAIL,
gender=DIVERSE,
)
mol = NewMemberOnList.objects.create(member=m, memberlist=self.st4.excursion)
self.st4.excursion.membersonlist.add(mol)
self.assertEqual(self.st4.admissible_staff_count, 2,
'Admissible staff count is not 2, although there are 4 participants.')
self.assertEqual(
self.st4.admissible_staff_count,
2,
"Admissible staff count is not 2, although there are 4 participants.",
)
def test_reduce_transactions(self):
self.st3.generate_transactions()
self.assertTrue(self.st3.allowance_to_valid, 'Configured `allowance_to` field is invalid.')
self.assertTrue(self.st3.allowance_to_valid, "Configured `allowance_to` field is invalid.")
# every youth leader on `st3` paid one bill, the first three receive the allowance
# and one receives the subsidies
self.assertEqual(self.st3.transaction_set.count(), self.st3.real_staff_count + self.staff_count + 1,
'Transaction count is not twice the staff count.')
self.assertEqual(
self.st3.transaction_set.count(),
self.st3.real_staff_count + self.staff_count + 1,
"Transaction count is not twice the staff count.",
)
self.st3.reduce_transactions()
self.assertEqual(self.st3.transaction_set.count(), self.st3.real_staff_count + self.staff_count + 1,
'Transaction count after reduction is not the same as before, although no ledgers are configured.')
self.assertEqual(
self.st3.transaction_set.count(),
self.st3.real_staff_count + self.staff_count + 1,
"Transaction count after reduction is not the same as before, although no ledgers are configured.",
)
for trans in self.st3.transaction_set.all():
trans.ledger = self.personal_account
trans.save()
self.st3.reduce_transactions()
# the three yls that receive an allowance should only receive one transaction after reducing,
# the additional one is the one for the subsidies
self.assertEqual(self.st3.transaction_set.count(), self.staff_count + 1,
'Transaction count after setting ledgers and reduction is incorrect.')
self.assertEqual(
self.st3.transaction_set.count(),
self.staff_count + 1,
"Transaction count after setting ledgers and reduction is incorrect.",
)
self.st3.reduce_transactions()
self.assertEqual(self.st3.transaction_set.count(), self.staff_count + 1,
'Transaction count did change after reducing a second time.')
self.assertEqual(
self.st3.transaction_set.count(),
self.staff_count + 1,
"Transaction count did change after reducing a second time.",
)
def test_confirm_statement(self):
self.assertFalse(self.st3.confirm(confirmer=self.fritz), 'Statement was confirmed, although it is not submitted.')
self.assertFalse(
self.st3.confirm(confirmer=self.fritz),
"Statement was confirmed, although it is not submitted.",
)
self.st3.submit(submitter=self.fritz)
self.assertTrue(self.st3.submitted, 'Statement is not submitted, although it was.')
self.assertEqual(self.st3.submitted_by, self.fritz,
'Statement was not submitted by fritz.')
self.assertTrue(self.st3.submitted, "Statement is not submitted, although it was.")
self.assertEqual(self.st3.submitted_by, self.fritz, "Statement was not submitted by fritz.")
self.assertFalse(self.st3.confirm(), 'Statement was confirmed, but is not valid yet.')
self.assertFalse(self.st3.confirm(), "Statement was confirmed, but is not valid yet.")
self.st3.generate_transactions()
for trans in self.st3.transaction_set.all():
trans.ledger = self.personal_account
trans.save()
self.assertEqual(self.st3.validity, Statement.VALID,
'Statement is not valid, although it was setup to be so.')
self.assertTrue(self.st3.confirm(confirmer=self.fritz),
'Statement was not confirmed, although it submitted and valid.')
self.assertEqual(self.st3.confirmed_by, self.fritz, 'Statement not confirmed by fritz.')
self.assertEqual(
self.st3.validity,
Statement.VALID,
"Statement is not valid, although it was setup to be so.",
)
self.assertTrue(
self.st3.confirm(confirmer=self.fritz),
"Statement was not confirmed, although it submitted and valid.",
)
self.assertEqual(self.st3.confirmed_by, self.fritz, "Statement not confirmed by fritz.")
for trans in self.st3.transaction_set.all():
self.assertTrue(trans.confirmed, 'Transaction on confirmed statement is not confirmed.')
self.assertEqual(trans.confirmed_by, self.fritz, 'Transaction on confirmed statement is not confirmed by fritz.')
self.assertTrue(trans.confirmed, "Transaction on confirmed statement is not confirmed.")
self.assertEqual(
trans.confirmed_by,
self.fritz,
"Transaction on confirmed statement is not confirmed by fritz.",
)
def test_excursion_statement(self):
self.assertEqual(self.st3.excursion.staff_count, self.staff_count,
'Calculated staff count is not constructed staff count.')
self.assertEqual(self.st3.excursion.participant_count, self.participant_count,
'Calculated participant count is not constructed participant count.')
self.assertLess(self.st3.admissible_staff_count, self.staff_count,
'All staff members are refinanced, although {} is too much for {} participants.'.format(self.staff_count, self.participant_count))
self.assertFalse(self.st3.transactions_match_expenses,
'Transactions match expenses, but currently no one is paid.')
self.assertGreater(self.st3.total_staff, 0,
'There are no costs for the staff, although there are enough participants.')
self.assertEqual(self.st3.total_nights, 0,
'There are costs for the night, although there was no night.')
self.assertEqual(self.st3.real_night_cost, settings.MAX_NIGHT_COST,
'Real night cost is not the max, although the given one is way too high.')
self.assertEqual(
self.st3.excursion.staff_count,
self.staff_count,
"Calculated staff count is not constructed staff count.",
)
self.assertEqual(
self.st3.excursion.participant_count,
self.participant_count,
"Calculated participant count is not constructed participant count.",
)
self.assertLess(
self.st3.admissible_staff_count,
self.staff_count,
"All staff members are refinanced, although {} is too much for {} participants.".format(
self.staff_count, self.participant_count
),
)
self.assertFalse(
self.st3.transactions_match_expenses,
"Transactions match expenses, but currently no one is paid.",
)
self.assertGreater(
self.st3.total_staff,
0,
"There are no costs for the staff, although there are enough participants.",
)
self.assertEqual(
self.st3.total_nights, 0, "There are costs for the night, although there was no night."
)
self.assertEqual(
self.st3.real_night_cost,
settings.MAX_NIGHT_COST,
"Real night cost is not the max, although the given one is way too high.",
)
# changing means of transport changes euro_per_km
epkm = self.st3.euro_per_km
self.st3.excursion.tour_approach = FAHRGEMEINSCHAFT_ANREISE
self.assertNotEqual(epkm, self.st3.euro_per_km, 'Changing means of transport did not change euro per km.')
self.assertNotEqual(
epkm, self.st3.euro_per_km, "Changing means of transport did not change euro per km."
)
self.st3.generate_transactions()
self.assertTrue(self.st3.transactions_match_expenses,
"Transactions don't match expenses after generating them.")
self.assertGreater(self.st3.total, 0, 'Total is 0.')
self.assertTrue(
self.st3.transactions_match_expenses,
"Transactions don't match expenses after generating them.",
)
self.assertGreater(self.st3.total, 0, "Total is 0.")
def test_generate_transactions(self):
# self.st2 has an unpaid bill
self.assertFalse(self.st2.transactions_match_expenses,
'Transactions match expenses, but one bill is not paid.')
self.assertFalse(
self.st2.transactions_match_expenses,
"Transactions match expenses, but one bill is not paid.",
)
self.st2.generate_transactions()
# now transactions should match expenses
self.assertTrue(self.st2.transactions_match_expenses,
"Transactions don't match expenses after generating them.")
self.assertTrue(
self.st2.transactions_match_expenses,
"Transactions don't match expenses after generating them.",
)
# self.st2 is still not valid
self.assertEqual(self.st2.validity, Statement.MISSING_LEDGER,
'Statement is valid, although transaction has no ledger setup.')
self.assertEqual(
self.st2.validity,
Statement.MISSING_LEDGER,
"Statement is valid, although transaction has no ledger setup.",
)
for trans in self.st2.transaction_set.all():
trans.ledger = self.personal_account
trans.save()
self.assertEqual(self.st2.validity, Statement.VALID,
'Statement is still invalid, after setting up ledger.')
self.assertEqual(
self.st2.validity,
Statement.VALID,
"Statement is still invalid, after setting up ledger.",
)
# create a new transaction issue by manually changing amount
t1 = self.st2.transaction_set.all()[0]
t1.amount = 123
t1.save()
self.assertFalse(self.st2.transactions_match_expenses,
'Transactions match expenses, but one transaction was tweaked.')
self.assertFalse(
self.st2.transactions_match_expenses,
"Transactions match expenses, but one transaction was tweaked.",
)
def test_generate_transactions_not_covered(self):
bill = self.st2.bill_set.all()[0]
@ -334,19 +547,28 @@ class StatementTestCase(TestCase):
def test_detect_unallowed_gift(self):
# there is a bill
self.assertGreater(self.st.total_bills_theoretic, 0, 'Theoretic bill total is 0 (should be > 0).')
self.assertGreater(
self.st.total_bills_theoretic, 0, "Theoretic bill total is 0 (should be > 0)."
)
# but it is not covered
self.assertEqual(self.st.total_bills, 0, 'Real bill total is not 0.')
self.assertEqual(self.st.total, 0, 'Total is not 0.')
self.assertGreater(self.st.total_theoretic, 0, 'Total in theorey is 0.')
self.assertEqual(self.st.total_bills, 0, "Real bill total is not 0.")
self.assertEqual(self.st.total, 0, "Total is not 0.")
self.assertGreater(self.st.total_theoretic, 0, "Total in theorey is 0.")
self.st.generate_transactions()
self.assertEqual(self.st.transaction_set.count(), 1, 'Generating transactions did produce new transactions.')
self.assertEqual(
self.st.transaction_set.count(),
1,
"Generating transactions did produce new transactions.",
)
# but there is a transaction anyway
self.assertFalse(self.st.transactions_match_expenses,
'Transactions match expenses, although an unreasonable gift is paid.')
self.assertFalse(
self.st.transactions_match_expenses,
"Transactions match expenses, although an unreasonable gift is paid.",
)
# so statement must be invalid
self.assertFalse(self.st.is_valid(),
'Transaction is valid, although an unreasonable gift is paid.')
self.assertFalse(
self.st.is_valid(), "Transaction is valid, although an unreasonable gift is paid."
)
def test_allowance_to_valid(self):
self.assertEqual(self.st3.excursion.participant_count, self.participant_count)
@ -391,20 +613,18 @@ class StatementTestCase(TestCase):
def test_template_context(self):
# with excursion
self.assertTrue('euro_per_km' in self.st3.template_context())
self.assertTrue("euro_per_km" in self.st3.template_context())
# without excursion
self.assertFalse('euro_per_km' in self.st2.template_context())
self.assertFalse("euro_per_km" in self.st2.template_context())
def test_grouped_bills(self):
bills = self.st2.grouped_bills()
self.assertTrue('amount' in bills[0])
self.assertTrue("amount" in bills[0])
def test_euro_per_km_no_excursion(self):
"""Test euro_per_km when no excursion is associated"""
statement = Statement.objects.create(
short_description="Test Statement",
explanation="Test explanation",
night_cost=25
short_description="Test Statement", explanation="Test explanation", night_cost=25
)
self.assertEqual(statement.euro_per_km, 0)
@ -414,7 +634,7 @@ class StatementTestCase(TestCase):
short_description="Test Statement",
explanation="Test explanation",
night_cost=25,
created_by=self.fritz
created_by=self.fritz,
)
self.assertFalse(statement.submitted)
@ -431,12 +651,12 @@ class StatementTestCase(TestCase):
"""Test statement template context when excursion is present"""
# Use existing excursion from setUp
context = self.st3.template_context()
self.assertIn('euro_per_km', context)
self.assertIsInstance(context['euro_per_km'], (int, float, Decimal))
self.assertIn("euro_per_km", context)
self.assertIsInstance(context["euro_per_km"], (int, float, Decimal))
def test_title_with_excursion(self):
title = self.st3.title
self.assertIn('Wild trip', title)
self.assertIn("Wild trip", title)
def test_transaction_issues_with_org_fee(self):
issues = self.st4.transaction_issues
@ -455,14 +675,17 @@ class StatementTestCase(TestCase):
self.st4.save()
# Verify org fee is calculated
self.assertGreater(self.st4.total_org_fee, 0, "Org fee should be > 0 with subsidies and old participants")
self.assertGreater(
self.st4.total_org_fee, 0, "Org fee should be > 0 with subsidies and old participants"
)
initial_count = Transaction.objects.count()
self.st4.generate_transactions()
final_count = Transaction.objects.count()
self.assertGreater(final_count, initial_count)
org_fee_transaction = Transaction.objects.filter(statement=self.st4,
reference__icontains=_('reduced by org fee')).first()
org_fee_transaction = Transaction.objects.filter(
statement=self.st4, reference__icontains=_("reduced by org fee")
).first()
self.assertIsNotNone(org_fee_transaction)
def test_generate_transactions_ljp(self):
@ -472,7 +695,9 @@ class StatementTestCase(TestCase):
self.st3.generate_transactions()
final_count = Transaction.objects.count()
self.assertGreater(final_count, initial_count)
ljp_transaction = Transaction.objects.filter(statement=self.st3, member=self.fritz, reference__icontains='LJP').first()
ljp_transaction = Transaction.objects.filter(
statement=self.st3, member=self.fritz, reference__icontains="LJP"
).first()
self.assertIsNotNone(ljp_transaction)
def test_subsidies_paid_property(self):
@ -485,8 +710,11 @@ class StatementTestCase(TestCase):
self.st_small.save()
# Verify that the small excursion has < 5 theoretic LJP participants
self.assertLess(self.st_small.excursion.theoretic_ljp_participant_count, 5,
"Should have < 5 theoretic LJP participants")
self.assertLess(
self.st_small.excursion.theoretic_ljp_participant_count,
5,
"Should have < 5 theoretic LJP participants",
)
ljp_contrib = self.st_small.paid_ljp_contributions
self.assertEqual(ljp_contrib, 0)
@ -499,25 +727,29 @@ class StatementTestCase(TestCase):
class LedgerTestCase(TestCase):
def setUp(self):
self.personal_account = Ledger.objects.create(name='personal account')
self.personal_account = Ledger.objects.create(name="personal account")
def test_str(self):
self.assertTrue(str(self.personal_account), 'personal account')
self.assertTrue(str(self.personal_account), "personal account")
class ManagerTestCase(TestCase):
def setUp(self):
self.st = Statement.objects.create(short_description='A statement',
explanation='Important!',
night_cost=0)
self.st_submitted = Statement.objects.create(short_description='A statement',
explanation='Important!',
self.st = Statement.objects.create(
short_description="A statement", explanation="Important!", night_cost=0
)
self.st_submitted = Statement.objects.create(
short_description="A statement",
explanation="Important!",
night_cost=0,
status=Statement.SUBMITTED)
self.st_confirmed = Statement.objects.create(short_description='A statement',
explanation='Important!',
status=Statement.SUBMITTED,
)
self.st_confirmed = Statement.objects.create(
short_description="A statement",
explanation="Important!",
night_cost=0,
status=Statement.CONFIRMED)
status=Statement.CONFIRMED,
)
def test_get_queryset(self):
# TODO: remove this manager, since it is not used
@ -527,30 +759,43 @@ class ManagerTestCase(TestCase):
mgr_unsubmitted = StatementUnSubmittedManager()
mgr_unsubmitted.model = StatementUnSubmitted
self.assertQuerysetEqual(mgr_unsubmitted.get_queryset(), Statement.objects.filter(pk=self.st.pk))
self.assertQuerysetEqual(
mgr_unsubmitted.get_queryset(), Statement.objects.filter(pk=self.st.pk)
)
mgr_submitted = StatementSubmittedManager()
mgr_submitted.model = StatementSubmitted
self.assertQuerysetEqual(mgr_submitted.get_queryset(), Statement.objects.filter(pk=self.st_submitted.pk))
self.assertQuerysetEqual(
mgr_submitted.get_queryset(), Statement.objects.filter(pk=self.st_submitted.pk)
)
mgr_confirmed = StatementConfirmedManager()
mgr_confirmed.model = StatementConfirmed
self.assertQuerysetEqual(mgr_confirmed.get_queryset(), Statement.objects.filter(pk=self.st_confirmed.pk))
self.assertQuerysetEqual(
mgr_confirmed.get_queryset(), Statement.objects.filter(pk=self.st_confirmed.pk)
)
class TransactionTestCase(TestCase):
def setUp(self):
self.st = Statement.objects.create(short_description='A statement',
explanation='Important!',
night_cost=0)
self.personal_account = Ledger.objects.create(name='personal account')
self.fritz = Member.objects.create(prename="Fritz", lastname="Wulter", birth_date=timezone.now().date(),
email=settings.TEST_MAIL, gender=MALE)
self.trans = Transaction.objects.create(reference='foobar',
self.st = Statement.objects.create(
short_description="A statement", explanation="Important!", night_cost=0
)
self.personal_account = Ledger.objects.create(name="personal account")
self.fritz = Member.objects.create(
prename="Fritz",
lastname="Wulter",
birth_date=timezone.now().date(),
email=settings.TEST_MAIL,
gender=MALE,
)
self.trans = Transaction.objects.create(
reference="foobar",
amount=42,
member=self.fritz,
ledger=self.personal_account,
statement=self.st)
statement=self.st,
)
def test_str(self):
self.assertTrue(str(self.trans.pk) in str(self.trans))
@ -558,9 +803,9 @@ class TransactionTestCase(TestCase):
def test_escape_reference(self):
"""Test transaction reference escaping with various special characters"""
test_cases = [
('harmless', 'harmless'),
('äöüÄÖÜß', 'aeoeueAeOeUess'),
('ha@r!?mless+09', 'har?mless+09'),
("harmless", "harmless"),
("äöüÄÖÜß", "aeoeueAeOeUess"),
("ha@r!?mless+09", "har?mless+09"),
("simple", "simple"),
("test@email.com", "testemail.com"),
("ref!with#special$chars%", "refwithspecialchars"),
@ -574,26 +819,26 @@ class TransactionTestCase(TestCase):
def test_code(self):
self.trans.amount = 0
# amount is zero, so empty
self.assertEqual(self.trans.code(), '')
self.assertEqual(self.trans.code(), "")
self.trans.amount = 42
# iban is invalid, so empty
self.assertEqual(self.trans.code(), '')
self.assertEqual(self.trans.code(), "")
# a valid (random) iban
self.fritz.iban = 'DE89370400440532013000'
self.assertNotEqual(self.trans.code(), '')
self.fritz.iban = "DE89370400440532013000"
self.assertNotEqual(self.trans.code(), "")
def test_code_with_zero_amount(self):
"""Test transaction code generation with zero amount"""
transaction = Transaction.objects.create(
reference="test-ref",
amount=Decimal('0.00'),
amount=Decimal("0.00"),
member=self.fritz,
ledger=self.personal_account,
statement=self.st
statement=self.st,
)
# Zero amount should return empty code
self.assertEqual(transaction.code(), '')
self.assertEqual(transaction.code(), "")
def test_code_with_invalid_iban(self):
"""Test transaction code generation with invalid IBAN"""
@ -602,36 +847,33 @@ class TransactionTestCase(TestCase):
transaction = Transaction.objects.create(
reference="test-ref",
amount=Decimal('100.00'),
amount=Decimal("100.00"),
member=self.fritz,
ledger=self.personal_account,
statement=self.st
statement=self.st,
)
# Invalid IBAN should return empty code
self.assertEqual(transaction.code(), '')
self.assertEqual(transaction.code(), "")
class BillTestCase(TestCase):
def setUp(self):
self.st = Statement.objects.create(short_description='A statement',
explanation='Important!',
night_cost=0)
self.bill = Bill.objects.create(statement=self.st,
short_description='foobar')
self.st = Statement.objects.create(
short_description="A statement", explanation="Important!", night_cost=0
)
self.bill = Bill.objects.create(statement=self.st, short_description="foobar")
def test_str(self):
self.assertTrue('' in str(self.bill))
self.assertTrue("" in str(self.bill))
def test_pretty_amount(self):
self.assertTrue('' in self.bill.pretty_amount())
self.assertTrue("" in self.bill.pretty_amount())
def test_pretty_amount_formatting(self):
"""Test bill pretty_amount formatting with specific values"""
bill = Bill.objects.create(
statement=self.st,
short_description="Test Bill",
amount=Decimal('42.50')
statement=self.st, short_description="Test Bill", amount=Decimal("42.50")
)
pretty = bill.pretty_amount()
@ -641,19 +883,17 @@ class BillTestCase(TestCase):
def test_zero_amount(self):
"""Test bill with zero amount"""
bill = Bill.objects.create(
statement=self.st,
short_description="Zero Bill",
amount=Decimal('0.00')
statement=self.st, short_description="Zero Bill", amount=Decimal("0.00")
)
self.assertEqual(bill.amount, Decimal('0.00'))
self.assertEqual(bill.amount, Decimal("0.00"))
pretty = bill.pretty_amount()
self.assertIn("0.00", pretty)
class TransactionIssueTestCase(TestCase):
def setUp(self):
self.issue = TransactionIssue('foo', 42, 26)
self.issue = TransactionIssue("foo", 42, 26)
def test_difference(self):
self.assertEqual(self.issue.difference, 26 - 42)

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

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

@ -1,14 +1,13 @@
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.
os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'jdav_web.settings')
os.environ.setdefault("DJANGO_SETTINGS_MODULE", "jdav_web.settings")
app = Celery()
app.config_from_object('django.conf:settings')
app.config_from_object("django.conf:settings")
app.autodiscover_tasks()
if __name__ == '__main__':
if __name__ == "__main__":
app.start() # pragma: no cover

@ -2,6 +2,6 @@ class ForceLangMiddleware:
def __init__(self, get_response):
self.get_response = get_response
def __call__(self,request):
request.META['HTTP_ACCEPT_LANGUAGE'] = "de"
def __call__(self, request):
request.META["HTTP_ACCEPT_LANGUAGE"] = "de"
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/
"""
from split_settings.tools import optional, include
import os
import tomli
from split_settings.tools import include
CONFIG_DIR_PATH = os.environ.get('KOMPASS_CONFIG_DIR_PATH', '')
SETTINGS_FILE = os.environ.get('KOMPASS_SETTINGS_FILE', 'settings.toml')
TEXTS_FILE = os.environ.get('KOMPASS_TEXTS_FILE', 'texts.toml')
CONFIG_DIR_PATH = os.environ.get("KOMPASS_CONFIG_DIR_PATH", "")
SETTINGS_FILE = os.environ.get("KOMPASS_SETTINGS_FILE", "settings.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)
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)
else:
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
for nested retrieval from the dictionary.
@ -42,7 +43,7 @@ def get_var(*keys, default='', dictionary=config):
return cfg
def get_text(*keys, default=''):
def get_text(*keys, default=""):
"""
Get a text from the `texts.toml`.
"""
@ -50,16 +51,16 @@ def get_text(*keys, default=''):
base_settings = [
'local.py',
'components/base.py',
'components/database.py',
'components/cache.py',
'components/jet.py',
'components/emails.py',
'components/texts.py',
'components/locale.py',
'components/logging.py',
'components/oauth.py',
"local.py",
"components/base.py",
"components/database.py",
"components/cache.py",
"components/jet.py",
"components/emails.py",
"components/texts.py",
"components/locale.py",
"components/logging.py",
"components/oauth.py",
]
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, ...)
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!
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!
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
HOST = get_var('django', 'host', default='localhost:8000')
PROTOCOL = get_var('django', 'protocol', default='https')
BASE_URL = get_var('django', 'base_url', default=HOST)
HOST = get_var("django", "host", default="localhost:8000")
PROTOCOL = get_var("django", "protocol", default="https")
BASE_URL = get_var("django", "base_url", default=HOST)
# Define media paths e.g. for image storage
MEDIA_URL = '/media/'
MEDIA_ROOT = get_var('django', 'media_root',
default=os.path.join((os.path.join(BASE_DIR, os.pardir)), "media"))
MEDIA_URL = "/media/"
MEDIA_ROOT = get_var(
"django", "media_root", default=os.path.join((os.path.join(BASE_DIR, os.pardir)), "media")
)
# 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
# see
@ -36,71 +39,70 @@ USE_X_FORWARDED_HOST = True
# Application definition
INSTALLED_APPS = [
'logindata.apps.LoginDataConfig',
'contrib.apps.ContribConfig',
'startpage.apps.StartpageConfig',
'material.apps.MaterialConfig',
'members.apps.MembersConfig',
'mailer.apps.MailerConfig',
'finance.apps.FinanceConfig',
'ludwigsburgalpin.apps.LudwigsburgalpinConfig',
"logindata.apps.LoginDataConfig",
"contrib.apps.ContribConfig",
"startpage.apps.StartpageConfig",
"material.apps.MaterialConfig",
"members.apps.MembersConfig",
"mailer.apps.MailerConfig",
"finance.apps.FinanceConfig",
"ludwigsburgalpin.apps.LudwigsburgalpinConfig",
#'easy_select2',
'markdownify.apps.MarkdownifyConfig',
'markdownx',
'djcelery_email',
'nested_admin',
'django_celery_beat',
'rules',
'jet',
'oauth2_provider',
'django.contrib.admin',
'django.contrib.auth',
'django.contrib.contenttypes',
'django.contrib.sessions',
'django.contrib.messages',
'django.contrib.staticfiles',
"markdownify.apps.MarkdownifyConfig",
"markdownx",
"djcelery_email",
"nested_admin",
"django_celery_beat",
"rules",
"jet",
"oauth2_provider",
"django.contrib.admin",
"django.contrib.auth",
"django.contrib.contenttypes",
"django.contrib.sessions",
"django.contrib.messages",
"django.contrib.staticfiles",
]
MIDDLEWARE = [
'django.middleware.cache.UpdateCacheMiddleware',
'jdav_web.middleware.ForceLangMiddleware',
'django.middleware.security.SecurityMiddleware',
'django.contrib.sessions.middleware.SessionMiddleware',
'django.middleware.locale.LocaleMiddleware',
'django.middleware.common.CommonMiddleware',
'django.middleware.csrf.CsrfViewMiddleware',
'django.contrib.auth.middleware.AuthenticationMiddleware',
'django.contrib.messages.middleware.MessageMiddleware',
'django.middleware.clickjacking.XFrameOptionsMiddleware',
'django.middleware.cache.FetchFromCacheMiddleware',
"django.middleware.cache.UpdateCacheMiddleware",
"jdav_web.middleware.ForceLangMiddleware",
"django.middleware.security.SecurityMiddleware",
"django.contrib.sessions.middleware.SessionMiddleware",
"django.middleware.locale.LocaleMiddleware",
"django.middleware.common.CommonMiddleware",
"django.middleware.csrf.CsrfViewMiddleware",
"django.contrib.auth.middleware.AuthenticationMiddleware",
"django.contrib.messages.middleware.MessageMiddleware",
"django.middleware.clickjacking.XFrameOptionsMiddleware",
"django.middleware.cache.FetchFromCacheMiddleware",
]
X_FRAME_OPTIONS = 'SAMEORIGIN'
X_FRAME_OPTIONS = "SAMEORIGIN"
ROOT_URLCONF = 'jdav_web.urls'
ROOT_URLCONF = "jdav_web.urls"
TEMPLATES = [
{
'BACKEND': 'django.template.backends.django.DjangoTemplates',
'DIRS': [os.path.join(CONFIG_DIR_PATH, 'templates'),
os.path.join(BASE_DIR, 'templates')],
'APP_DIRS': True,
'OPTIONS': {
'context_processors': [
'django.template.context_processors.debug',
'django.template.context_processors.request',
'django.contrib.auth.context_processors.auth',
'django.contrib.messages.context_processors.messages',
"BACKEND": "django.template.backends.django.DjangoTemplates",
"DIRS": [os.path.join(CONFIG_DIR_PATH, "templates"), os.path.join(BASE_DIR, "templates")],
"APP_DIRS": True,
"OPTIONS": {
"context_processors": [
"django.template.context_processors.debug",
"django.template.context_processors.request",
"django.contrib.auth.context_processors.auth",
"django.contrib.messages.context_processors.messages",
],
},
},
]
WSGI_APPLICATION = 'jdav_web.wsgi.application'
WSGI_APPLICATION = "jdav_web.wsgi.application"
AUTHENTICATION_BACKENDS = (
'django.contrib.auth.backends.ModelBackend',
'rules.permissions.ObjectPermissionBackend',
"django.contrib.auth.backends.ModelBackend",
"rules.permissions.ObjectPermissionBackend",
)
# Password validation
@ -108,23 +110,23 @@ AUTHENTICATION_BACKENDS = (
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)
# https://docs.djangoproject.com/en/1.10/howto/static-files/
STATIC_URL = '/static/'
STATIC_URL = "/static/"
STATICFILES_DIRS = [
os.path.join(CONFIG_DIR_PATH, "static"),
os.path.join(BASE_DIR, "static"),
@ -132,70 +134,76 @@ STATICFILES_DIRS = [
# static root where all the static files are collected to
# use python3 manage.py collectstatic to collect static files in the STATIC_ROOT
# this is needed for deployment
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')
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")
# Locale files (translations)
LOCALE_PATHS = (os.path.join(BASE_DIR, 'locale'),)
LOCALE_PATHS = (os.path.join(BASE_DIR, "locale"),)
# 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_HASHERS = [
'django.contrib.auth.hashers.BCryptPasswordHasher',
'django.contrib.auth.hashers.PBKDF2PasswordHasher',
'django.contrib.auth.hashers.PBKDF2SHA1PasswordHasher',
'django.contrib.auth.hashers.Argon2PasswordHasher',
'django.contrib.auth.hashers.ScryptPasswordHasher',
"django.contrib.auth.hashers.BCryptPasswordHasher",
"django.contrib.auth.hashers.PBKDF2PasswordHasher",
"django.contrib.auth.hashers.PBKDF2SHA1PasswordHasher",
"django.contrib.auth.hashers.Argon2PasswordHasher",
"django.contrib.auth.hashers.ScryptPasswordHasher",
]
MARKDOWNIFY = {
'default': {
"default": {
"WHITELIST_TAGS": [
'img',
'abbr',
'acronym',
'a',
'b',
'blockquote',
'em',
'i',
'li',
'ol',
'p',
'strong',
'ul',
'br',
'code',
'span',
'div', 'class',
'pre', 'h1', 'h2', 'h3', 'h4', 'h5', 'h6'
"img",
"abbr",
"acronym",
"a",
"b",
"blockquote",
"em",
"i",
"li",
"ol",
"p",
"strong",
"ul",
"br",
"code",
"span",
"div",
"class",
"pre",
"h1",
"h2",
"h3",
"h4",
"h5",
"h6",
],
"WHITELIST_ATTRS": [
'src',
'href',
'style',
'alt',
'class',
"src",
"href",
"style",
"alt",
"class",
],
"LINKIFY_TEXT": {
"PARSE_URLS": True,
# Next key/value-pairs only have effect if "PARSE_URLS" is True
"PARSE_EMAIL": True,
"CALLBACKS": [],
"SKIP_TAGS": [],
}
},
}
}
# 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 = 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 = {
'default': {
'BACKEND': 'django.core.cache.backends.memcached.PyMemcacheCache',
'LOCATION': get_var('django', 'memcached_url', default='127.0.0.1:11211'),
'OPTIONS': {
'no_delay': True,
'ignore_exc': True,
'max_pool_size': 4,
'use_pooling': True,
}
"default": {
"BACKEND": "django.core.cache.backends.memcached.PyMemcacheCache",
"LOCATION": get_var("django", "memcached_url", default="cache:11211"),
"OPTIONS": {
"no_delay": True,
"ignore_exc": True,
"max_pool_size": 4,
"use_pooling": True,
},
}
}
CACHE_MIDDLEWARE_ALIAS = 'default'
CACHE_MIDDLEWARE_ALIAS = "default"
CACHE_MIDDLEWARE_SECONDS = 1
CACHE_MIDDLEWARE_KEY_PREFIX = ''
CACHE_MIDDLEWARE_KEY_PREFIX = ""

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

@ -1,17 +1,19 @@
# ruff: noqa F821
# Email setup
EMAIL_HOST = get_var('mail', 'host', default='localhost')
EMAIL_PORT = get_var('mail', 'port', default=587 if deployed else 25)
EMAIL_HOST_USER = get_var('mail', 'user', default='user')
EMAIL_HOST_PASSWORD = get_var('mail', 'password', default='secret')
EMAIL_USE_TLS = get_var('mail', 'tls', default=True if deployed else False)
EMAIL_BACKEND = 'djcelery_email.backends.CeleryEmailBackend'
EMAIL_HOST = get_var("mail", "host", default="localhost")
EMAIL_PORT = get_var("mail", "port", default=587 if deployed else 25)
EMAIL_HOST_USER = get_var("mail", "user", default="user")
EMAIL_HOST_PASSWORD = get_var("mail", "password", default="secret")
EMAIL_USE_TLS = get_var("mail", "tls", default=True if deployed else False)
EMAIL_BACKEND = "djcelery_email.backends.CeleryEmailBackend"
# Celery Email Setup
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_NAME = get_var('mail', 'default_sending_name', default='Kompass')
DEFAULT_SENDING_MAIL = get_var("mail", "default_sending_address", default="kompass@localhost")
DEFAULT_SENDING_NAME = get_var("mail", "default_sending_name", default="Kompass")

@ -1,64 +1,116 @@
# ruff: noqa F821
# JET options (admin interface)
JET_SIDE_MENU_COMPACT = True
JET_DEFAULT_THEME = 'jdav-green'
JET_DEFAULT_THEME = "jdav-green"
JET_CHANGE_FORM_SIBLING_LINKS = False
JET_SIDE_MENU_ITEMS = [
{'label': 'Teilnehmer*innenverwaltung', 'app_label': 'members', 'items': [
{'name': 'member', 'label': 'Alle Teilnehmer*innen', 'permissions': ['members.view_member']},
{'name': 'freizeit', 'permissions': ['members.view_freizeit']},
{'name': 'group', 'permissions': ['members.view_group']},
{'name': 'membernotelist', 'permissions': ['members.view_membernotelist']},
{'name': 'klettertreff', 'permissions': ['members.view_klettertreff']},
]},
{'label': 'Neue Mitglieder', 'app_label': 'members', 'permissions': ['members.view_memberunconfirmedproxy'], 'items': [
{'name': 'memberunconfirmedproxy', 'permissions': ['members.view_memberunconfirmedproxy']},
{'name': 'memberwaitinglist', 'permissions': ['members.view_memberwaitinglist']},
]},
{'label': 'Ausbildung', 'app_label': 'members', 'permissions': ['members.view_membertraining'], 'items': [
{'name': 'membertraining', 'permissions': ['members.view_membertraining']},
{'name': 'trainingcategory', 'permissions': ['members.view_trainingcategory']},
{'name': 'activitycategory', 'permissions': ['members.view_activitycategory']},
]},
{'app_label': 'mailer', 'items': [
{'name': 'message', 'permissions': ['mailer.view_message']},
{'name': 'emailaddress', 'permissions': ['mailer.view_emailaddress']},
]},
{'app_label': 'finance', 'items': [
{'name': 'statement', 'permissions': ['finance.view_statement']},
{'name': 'ledger', 'permissions': ['finance.view_ledger']},
{'name': 'bill', 'permissions': ['finance.view_bill', 'finance.view_bill_admin']},
{'name': 'transaction', 'permissions': ['finance.view_transaction']},
]},
{'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 },
]},
{
"label": "Teilnehmer*innenverwaltung",
"app_label": "members",
"items": [
{
"name": "member",
"label": "Alle Teilnehmer*innen",
"permissions": ["members.view_member"],
},
{"name": "freizeit", "permissions": ["members.view_freizeit"]},
{"name": "group", "permissions": ["members.view_group"]},
{"name": "membernotelist", "permissions": ["members.view_membernotelist"]},
{"name": "klettertreff", "permissions": ["members.view_klettertreff"]},
],
},
{
"label": "Neue Mitglieder",
"app_label": "members",
"permissions": ["members.view_memberunconfirmedproxy"],
"items": [
{
"name": "memberunconfirmedproxy",
"permissions": ["members.view_memberunconfirmedproxy"],
},
{"name": "memberwaitinglist", "permissions": ["members.view_memberwaitinglist"]},
],
},
{
"label": "Ausbildung",
"app_label": "members",
"permissions": ["members.view_membertraining"],
"items": [
{"name": "membertraining", "permissions": ["members.view_membertraining"]},
{"name": "trainingcategory", "permissions": ["members.view_trainingcategory"]},
{"name": "activitycategory", "permissions": ["members.view_activitycategory"]},
],
},
{
"app_label": "mailer",
"items": [
{"name": "message", "permissions": ["mailer.view_message"]},
{"name": "emailaddress", "permissions": ["mailer.view_emailaddress"]},
],
},
{
"app_label": "finance",
"items": [
{"name": "statement", "permissions": ["finance.view_statement"]},
{"name": "ledger", "permissions": ["finance.view_ledger"]},
{"name": "bill", "permissions": ["finance.view_bill", "finance.view_bill_admin"]},
{"name": "transaction", "permissions": ["finance.view_transaction"]},
],
},
{
"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
# 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_L10N = True

@ -1,10 +1,12 @@
import os
# ruff: noqa F821
DJANGO_LOG_LEVEL = get_var('logging', 'django_level', default='INFO')
ROOT_LOG_LEVEL = get_var('logging', 'level', default='INFO')
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_ERROR_INCLUDE_HTML = get_var('logging', 'error_report_include_html', default=False)
DJANGO_LOG_LEVEL = get_var("logging", "django_level", default="INFO")
ROOT_LOG_LEVEL = get_var("logging", "level", default="INFO")
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_ERROR_INCLUDE_HTML = get_var("logging", "error_report_include_html", default=False)
LOGGING = {
"version": 1,

@ -1,8 +1,10 @@
# ruff: noqa F821
OAUTH2_PROVIDER = {
"OIDC_ENABLED": True,
"PKCE_REQUIRED": False,
"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": {
"openid": "OpenID Connect scope",
"profile": "profile scope",

@ -1,17 +1,24 @@
# ruff: noqa F821
# 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
per Email funktioniert, brauchen wir eine Bestätigung {whattoconfirm}. Dazu klicke bitte einfach auf
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
folgenden Link:
{link}
{{link}}
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
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}
Viele Grüße
Dein KOMPASS""")
Dein KOMPASS""",
)
GROUP_INVITATION_LEFT_WAITINGLIST = get_text('group_invitation_left_waitinglist',
GROUP_INVITATION_LEFT_WAITINGLIST = get_text(
"group_invitation_left_waitinglist",
default="""Hallo {name},
der*die kürzlich zu einer Schnupperstunde für die Gruppe {group} eingeladene Wartende {waiter}
hat die Warteliste verlassen.
Viele Grüße
Dein KOMPASS""")
Dein KOMPASS""",
)
GROUP_INVITATION_REJECTED = get_text('group_invitation_rejected',
GROUP_INVITATION_REJECTED = get_text(
"group_invitation_rejected",
default="""Hallo {name},
{waiter} hat die Einladung zu einer Schnupperstunde bei der Gruppe {group} abgelehnt, ist
aber weiterhin auf der Warteliste.
Viele Grüße
Dein KOMPASS""")
Dein KOMPASS""",
)
GROUP_INVITATION_CONFIRMED_TEXT = get_text('group_invitation_confirmed',
GROUP_INVITATION_CONFIRMED_TEXT = get_text(
"group_invitation_confirmed",
default="""Hallo {name},
{waiter} hat die Einladung zu einer Schnupperstunde bei der Gruppe {group} angenommen.
Viele Grüße
Dein KOMPASS""")
Dein KOMPASS""",
)
TRIAL_GROUP_MEETING_CONFIRMED_TEXT = get_text('trial_group_meeting_confirmed',
default="""Hallo {name},
TRIAL_GROUP_MEETING_CONFIRMED_TEXT = get_text(
"trial_group_meeting_confirmed",
default="""Hallo {{name}},
deine Teilnahme an der Schnupperstunde der Gruppe {group} wurde erfolgreich bestätigt.
{timeinfo}
deine Teilnahme an der Schnupperstunde der Gruppe {{group}} wurde erfolgreich bestätigt.
{{timeinfo}}
Für alle weiteren Absprachen, kontaktiere bitte die Jugendleiter*innen der Gruppe
unter {contact_email}.
unter {{contact_email}}.
Viele Grüße
Deine JDAV %(SEKTION)s""" % { 'SEKTION': SEKTION })
Deine JDAV {SEKTION}""".format(SEKTION=SEKTION),
)
GROUP_TIME_AVAILABLE_TEXT = get_text('group_time_available',
default="""Die Gruppenstunde findet jeden {weekday} von {start_time} bis {end_time} Uhr statt.""")
GROUP_TIME_AVAILABLE_TEXT = get_text(
"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',
default="""Bitte erfrage die Gruppenzeiten bei der Gruppenleitung ({contact_email}).""")
GROUP_TIME_UNAVAILABLE_TEXT = get_text(
"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.
{group_time}
wir haben gute Neuigkeiten für dich. Es ist ein Platz in der Jugendgruppe {{group_name}} {{group_link}}freigeworden.
{{group_time}}
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
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
ü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,
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
Deine JDAV %(SEKTION)s""" % { 'SEKTION': SEKTION, 'RESPONSIBLE_MAIL': RESPONSIBLE_MAIL,
'REGISTRATION_FORM_DOWNLOAD_LINK': REGISTRATION_FORM_DOWNLOAD_LINK })
Deine JDAV {SEKTION}""".format(
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
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
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
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:
{link}
{{link}}
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.
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.
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.
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
Mitgliedern der JDAV %(SEKTION)s 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
im letzten Jahr das {position}aktivste Mitglied der JDAV %(SEKTION)s.
Herzlichen Glückwunsch, du hast im letzten Jahr zu den {{congratulate_max}} aktivsten
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
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
weiterhin ein Teil unserer Jugendarbeit bleiben möchtest, brauchen wir eine
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
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
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
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
Anmeldeformular aus und lasse es Deine*r Jugendleiter*in zukommen! Dieses
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,
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
dort bereits hinterlegt. Damit du dich auch anmelden kannst, folge bitte dem folgenden Link
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
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.
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.
{link}
{{link}}
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
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
%(STREET)s
%(PLACE)s""" % { 'SEKTION': SEKTION, 'STREET': SEKTION_STREET, 'PLACE': SEKTION_TOWN })
ADDRESS = get_text(
"address",
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
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.
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
Ausfahrt wird geleitet von {leaders}.
@ -272,12 +335,16 @@ Ausfahrt wird geleitet von {leaders}.
Im Anhang findet ihr die Kriseninterventionsliste.
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
wurden wie beschrieben ausgeführt.
Viele Grüße
Euer KOMPASS""")
Euer KOMPASS""",
)

@ -1,86 +1,98 @@
# ruff: noqa F821
# contact data
SEKTION = get_var('section', 'name', default='Heyo')
SEKTION_STREET = get_var('section', 'street', default='Street')
SEKTION_TOWN = get_var('section', 'town', default='12345 Town')
SEKTION_TELEPHONE = get_var('section', 'telephone', default='0123456789')
SEKTION_TELEFAX = get_var('section', 'telefax', default=SEKTION_TELEPHONE)
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_CRISIS_INTERVENTION_MAIL = get_var('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_ACCOUNT_HOLDER = get_var('section', 'account_holder',
default='Foo')
RESPONSIBLE_MAIL = get_var('section', 'responsible_mail', default='foo@example.org')
DIGITAL_MAIL = get_var('section', 'digital_mail', default='bar@example.org')
SEKTION = get_var("section", "name", default="Heyo")
SEKTION_STREET = get_var("section", "street", default="Street")
SEKTION_TOWN = get_var("section", "town", default="12345 Town")
SEKTION_TELEPHONE = get_var("section", "telephone", default="0123456789")
SEKTION_TELEFAX = get_var("section", "telefax", default=SEKTION_TELEPHONE)
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_CRISIS_INTERVENTION_MAIL = get_var(
"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_ACCOUNT_HOLDER = get_var("section", "account_holder", default="Foo")
RESPONSIBLE_MAIL = get_var("section", "responsible_mail", default="foo@example.org")
DIGITAL_MAIL = get_var("section", "digital_mail", default="bar@example.org")
# LJP
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_TAX = get_var('LJP', 'tax', default=0)
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_TAX = get_var("LJP", "tax", default=0)
# echo
# 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
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
GRACE_PERIOD_WAITING_CONFIRMATION = get_var('waitinglist', 'grace_period_confirmation', default=30)
WAITING_CONFIRMATION_FREQUENCY = get_var('waitinglist', 'confirmation_frequency', default=90)
CONFIRMATION_REMINDER_FREQUENCY = get_var('waitinglist', 'confirmation_reminder_frequency', default=30)
MAX_REMINDER_COUNT = get_var('waitinglist', 'max_reminder_count', default=3)
GRACE_PERIOD_WAITING_CONFIRMATION = get_var("waitinglist", "grace_period_confirmation", default=30)
WAITING_CONFIRMATION_FREQUENCY = get_var("waitinglist", "confirmation_frequency", default=90)
CONFIRMATION_REMINDER_FREQUENCY = get_var(
"waitinglist", "confirmation_reminder_frequency", default=30
)
MAX_REMINDER_COUNT = get_var("waitinglist", "max_reminder_count", default=3)
# misc
# 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
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
ALLOWED_EMAIL_DOMAINS_FOR_INVITE_AS_USER = get_var('misc', 'allowed_email_domains_for_invite_as_user',
default=['example.org'])
ALLOWED_EMAIL_DOMAINS_FOR_INVITE_AS_USER = get_var(
"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_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 = 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_MEMBERS = get_var('misc', 'group_checklist_n_members', default=20)
GROUP_CHECKLIST_TEXT = get_var('misc', 'group_checklist_text',
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_TEXT = get_var(
"misc",
"group_checklist_text",
default="""Anwesende Jugendleitende und Teilnehmende werden mit einem
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.""")
sind, notiert die Kasse die verbliebenen Eintritte (3, 2, 1) unter dem Kreuz.""",
)
# finance
ALLOWANCE_PER_DAY = get_var('finance', 'allowance_per_day', default=22)
MAX_NIGHT_COST = get_var('finance', 'max_night_cost', default=11)
ALLOWANCE_PER_DAY = get_var("finance", "allowance_per_day", default=22)
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
CLOUD_LINK = get_var('links', 'cloud', default='https://startpage.com')
DAV_360_LINK = get_var('links', 'dav_360', default='https://dav360.de')
WIKI_LINK = get_var('links', 'wiki', default='https://wikipedia.org')
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')
CLOUD_LINK = get_var("links", "cloud", default="https://startpage.com")
DAV_360_LINK = get_var("links", "dav_360", default="https://dav360.de")
WIKI_LINK = get_var("links", "wiki", default="https://wikipedia.org")
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"
)
# startpage
STARTPAGE_REDIRECT_URL = get_var('startpage', 'redirect_url', default='')
ROOT_SECTION = get_var('startpage', 'root_section', default='about')
RECENT_SECTION = get_var('startpage', 'recent_section', default='recent')
REPORTS_SECTION = get_var('startpage', 'reports_section', default='reports')
STARTPAGE_REDIRECT_URL = get_var("startpage", "redirect_url", default="")
ROOT_SECTION = get_var("startpage", "root_section", default="about")
RECENT_SECTION = get_var("startpage", "recent_section", default="recent")
REPORTS_SECTION = get_var("startpage", "reports_section", default="reports")
# 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 django.contrib.auth.models import User
from unittest.mock import Mock
from unittest.mock import patch
from django.contrib import admin
from unittest.mock import Mock, patch
from jdav_web.views import media_unprotected, custom_admin_view
from django.contrib.auth.models import User
from django.test import override_settings
from django.test import RequestFactory
from django.test import TestCase
from startpage.models import Link
from jdav_web.views import custom_admin_view
from jdav_web.views import media_unprotected
class ViewsTestCase(TestCase):
def setUp(self):
self.factory = RequestFactory()
self.user = User.objects.create_user('testuser', 'test@example.com', 'password')
Link.objects.create(title='Test Link', url='https://example.com')
self.user = User.objects.create_user("testuser", "test@example.com", "password")
Link.objects.create(title="Test Link", url="https://example.com")
@override_settings(DEBUG=True)
def test_media_unprotected_debug_true(self):
request = self.factory.get('/media/test.jpg')
with patch('jdav_web.views.serve') as mock_serve:
request = self.factory.get("/media/test.jpg")
with patch("jdav_web.views.serve") as mock_serve:
mock_serve.return_value = Mock()
result = media_unprotected(request, 'test.jpg')
media_unprotected(request, "test.jpg")
mock_serve.assert_called_once()
def test_custom_admin_view(self):
request = self.factory.get('/admin/')
request = self.factory.get("/admin/")
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 = []
response = custom_admin_view(request)
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
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 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.views.generic.base import RedirectView
from oauth2_provider import urls as oauth2_urls
from .views import media_access
admin.site.index_title = _('Startpage')
admin.site.site_header = 'Kompass'
admin.site.index_title = _("Startpage")
admin.site.site_header = "Kompass"
urlpatterns = i18n_patterns(
re_path(r'^media/(?P<path>.*)', media_access, name='media'),
re_path(r'^kompass/?', admin.site.urls, name='kompass'),
re_path(r'^jet/', include('jet.urls', 'jet')), # Django JET URLS
re_path(r'^admin/?', RedirectView.as_view(url='/kompass')),
re_path(r'^newsletter/', include('mailer.urls', namespace="mailer")),
re_path(r'^members/', include('members.urls', namespace="members")),
re_path(r'^login/', include('logindata.urls', namespace="logindata")),
re_path(r'^LBAlpin/Programm(/)?(20)?[0-9]{0,2}', include('ludwigsburgalpin.urls',
namespace="ludwigsburgalpin")),
re_path(r'^_nested_admin/', include('nested_admin.urls')),
path('o/', include(oauth2_urls)),
re_path(r'^', include('startpage.urls', namespace="startpage")),
re_path(r"^media/(?P<path>.*)", media_access, name="media"),
re_path(r"^kompass/?", admin.site.urls, name="kompass"),
re_path(r"^jet/", include("jet.urls", "jet")), # Django JET URLS
re_path(r"^admin/?", RedirectView.as_view(url="/kompass")),
re_path(r"^newsletter/", include("mailer.urls", namespace="mailer")),
re_path(r"^members/", include("members.urls", namespace="members")),
re_path(r"^login/", include("logindata.urls", namespace="logindata")),
re_path(
r"^LBAlpin/Programm(/)?(20)?[0-9]{0,2}",
include("ludwigsburgalpin.urls", namespace="ludwigsburgalpin"),
),
re_path(r"^_nested_admin/", include("nested_admin.urls")),
path("o/", include(oauth2_urls)),
re_path(r"^", include("startpage.urls", namespace="startpage")),
)
urlpatterns += [
re_path(r'^markdownx/', include('markdownx.urls')),
re_path(r"^markdownx/", include("markdownx.urls")),
]
handler404 = 'startpage.views.handler404'
handler500 = 'startpage.views.handler500'
handler404 = "startpage.views.handler404"
handler500 = "startpage.views.handler500"
# TODO: django serving from MEDIA_URL should be disabled in production stage
# see

@ -1,11 +1,12 @@
from django.http import HttpResponse
from django.views.static import serve
import re
from django.conf import settings
from django.contrib.admin.views.decorators import staff_member_required
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.views.static import serve
from startpage.models import Link
import re
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
response = HttpResponse()
# Content-type will be detected by nginx
del response['Content-Type']
response['X-Accel-Redirect'] = '/protected/' + path
del response["Content-Type"]
response["X-Accel-Redirect"] = "/protected/" + path
return response
@ -26,7 +27,7 @@ def media_protected(request, path):
def media_access(request, path):
if re.match('^(people|images)/', path):
if re.match("^(people|images)/", path):
return media_unprotected(request, path)
else:
return media_protected(request, path)
@ -38,11 +39,12 @@ def custom_admin_view(request):
"""
app_list = admin.site.get_app_list(request)
context = {
'app_list': app_list,
'site_header': admin.site.site_header,
'site_title': admin.site.site_title,
'external_links': Link.objects.all()
"app_list": app_list,
"site_header": admin.site.site_header,
"site_title": admin.site.site_title,
"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

@ -8,7 +8,7 @@ msgid ""
msgstr ""
"Project-Id-Version: PACKAGE VERSION\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"
"Last-Translator: FULL NAME <EMAIL@ADDRESS>\n"
"Language-Team: LANGUAGE <LL@li.org>\n"
@ -135,6 +135,18 @@ msgstr ""
msgid "You entered a wrong password."
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
#, python-format
msgid ""

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

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

@ -1,55 +1,58 @@
# Generated by Django 4.0.1 on 2024-11-23 21:15
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):
initial = True
dependencies = [
('auth', '0012_alter_user_first_name_max_length'),
("auth", "0012_alter_user_first_name_max_length"),
]
operations = [
migrations.CreateModel(
name='RegistrationPassword',
name="RegistrationPassword",
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('password', models.CharField(max_length=100, verbose_name='Password')),
(
"id",
models.BigAutoField(
auto_created=True, primary_key=True, serialize=False, verbose_name="ID"
),
),
("password", models.CharField(max_length=100, verbose_name="Password")),
],
),
migrations.CreateModel(
name='AuthGroup',
fields=[
],
name="AuthGroup",
fields=[],
options={
'verbose_name': 'Permission group',
'verbose_name_plural': 'Permission groups',
'proxy': True,
'indexes': [],
'constraints': [],
"verbose_name": "Permission group",
"verbose_name_plural": "Permission groups",
"proxy": True,
"indexes": [],
"constraints": [],
},
bases=('auth.group',),
bases=("auth.group",),
managers=[
('objects', django.contrib.auth.models.GroupManager()),
("objects", django.contrib.auth.models.GroupManager()),
],
),
migrations.CreateModel(
name='LoginDatum',
fields=[
],
name="LoginDatum",
fields=[],
options={
'verbose_name': 'Login Datum',
'verbose_name_plural': 'Login Data',
'proxy': True,
'indexes': [],
'constraints': [],
"verbose_name": "Login Datum",
"verbose_name_plural": "Login Data",
"proxy": True,
"indexes": [],
"constraints": [],
},
bases=('auth.user',),
bases=("auth.user",),
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):
dependencies = [
('logindata', '0001_initial'),
("logindata", "0001_initial"),
]
operations = [
migrations.AlterModelOptions(
name='registrationpassword',
options={'verbose_name': 'Active registration password', 'verbose_name_plural': 'Active registration passwords'},
name="registrationpassword",
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.contrib.auth.admin import UserAdmin as BaseUserAdmin, GroupAdmin as BaseAuthGroupAdmin
from django.contrib.auth.models import User as BaseUser, Group as BaseAuthGroup
from django.utils.translation import gettext_lazy as _
class AuthGroup(BaseAuthGroup):
class Meta:
proxy = True
verbose_name = _('Permission group')
verbose_name_plural = _('Permission groups')
verbose_name = _("Permission group")
verbose_name_plural = _("Permission groups")
class LoginDatum(BaseUser):
class Meta:
proxy = True
verbose_name = _('Login Datum')
verbose_name_plural = _('Login Data')
verbose_name = _("Login Datum")
verbose_name_plural = _("Login Data")
class RegistrationPassword(models.Model):
"""
A password that can be used to register after inviting a member.
"""
password = models.CharField(max_length=100, verbose_name=_('Password'))
password = models.CharField(max_length=100, verbose_name=_("Password"))
def __str__(self):
return self.password
class Meta:
verbose_name = _('Active registration password')
verbose_name_plural = _('Active registration passwords')
verbose_name = _("Active registration password")
verbose_name_plural = _("Active registration passwords")
def initial_user_setup(user, member):
try:
standard_group = AuthGroup.objects.get(name='Standard')
standard_group = AuthGroup.objects.get(name="Standard")
except AuthGroup.DoesNotExist:
return False
@ -41,6 +43,6 @@ def initial_user_setup(user, member):
user.save()
user.groups.add(standard_group)
member.user = user
member.invite_as_user_key = ''
member.invite_as_user_key = ""
member.save()
return True

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

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

@ -1,12 +1,16 @@
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.utils import timezone
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, initial_user_setup
from ..models import RegistrationPassword
class RegistrationPasswordTestCase(TestCase):
@ -22,133 +26,152 @@ class RegisterViewTestCase(TestCase):
# Create a test member with invite key
self.member = Member.objects.create(
prename='Test',
lastname='User',
prename="Test",
lastname="User",
birth_date=timezone.now().date(),
email='test@example.com',
email="test@example.com",
gender=DIVERSE,
invite_as_user_key='test_key_123'
invite_as_user_key="test_key_123",
)
# Create a registration password
self.registration_password = RegistrationPassword.objects.create(
password='test_password'
)
self.registration_password = RegistrationPassword.objects.create(password="test_password")
# 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):
"""Test GET request without key redirects to startpage."""
url = reverse('logindata:register')
url = reverse("logindata:register")
response = self.client.get(url)
self.assertEqual(response.status_code, HTTPStatus.FOUND)
def test_register_post_without_key_redirects(self):
"""Test POST request without key redirects to startpage."""
url = reverse('logindata:register')
url = reverse("logindata:register")
response = self.client.post(url)
self.assertEqual(response.status_code, HTTPStatus.FOUND)
def test_register_get_with_empty_key_shows_failed(self):
"""Test GET request with empty key shows registration failed page."""
url = reverse('logindata:register')
response = self.client.get(url, {'key': ''})
url = reverse("logindata:register")
response = self.client.get(url, {"key": ""})
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):
"""Test GET request with invalid key shows registration failed page."""
url = reverse('logindata:register')
response = self.client.get(url, {'key': 'invalid_key'})
url = reverse("logindata:register")
response = self.client.get(url, {"key": "invalid_key"})
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):
"""Test GET request with valid key shows password entry form."""
url = reverse('logindata:register')
response = self.client.get(url, {'key': self.member.invite_as_user_key})
url = reverse("logindata:register")
response = self.client.get(url, {"key": self.member.invite_as_user_key})
self.assertEqual(response.status_code, HTTPStatus.OK)
self.assertContains(response, _('Set login data'))
self.assertContains(response, _('Welcome, '))
self.assertContains(response, _("Set login data"))
self.assertContains(response, _("Welcome, "))
self.assertContains(response, self.member.prename)
def test_register_post_without_password_shows_failed(self):
"""Test POST request without password shows registration failed page."""
url = reverse('logindata:register')
response = self.client.post(url, {'key': self.member.invite_as_user_key})
url = reverse("logindata:register")
response = self.client.post(url, {"key": self.member.invite_as_user_key})
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):
"""Test POST request with wrong password shows error message."""
url = reverse('logindata:register')
response = self.client.post(url, {
'key': self.member.invite_as_user_key,
'password': 'wrong_password'
})
url = reverse("logindata:register")
response = self.client.post(
url, {"key": self.member.invite_as_user_key, "password": "wrong_password"}
)
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):
"""Test POST request with correct password shows user creation form."""
url = reverse('logindata:register')
response = self.client.post(url, {
'key': self.member.invite_as_user_key,
'password': self.registration_password.password
})
url = reverse("logindata:register")
response = self.client.post(
url,
{
"key": self.member.invite_as_user_key,
"password": self.registration_password.password,
},
)
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())
def test_register_post_with_save_and_invalid_form_shows_errors(self):
"""Test POST request with save but invalid form shows form errors."""
url = reverse('logindata:register')
response = self.client.post(url, {
'key': self.member.invite_as_user_key,
'password': self.registration_password.password,
'save': 'true',
'username': '', # Invalid - empty username
'password1': 'testpass123',
'password2': 'different_pass' # Invalid - passwords don't match
})
url = reverse("logindata:register")
response = self.client.post(
url,
{
"key": self.member.invite_as_user_key,
"password": self.registration_password.password,
"save": "true",
"username": "", # Invalid - empty username
"password1": "testpass123",
"password2": "different_pass", # Invalid - passwords don't match
},
)
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):
"""Test POST request with save and valid form shows success page."""
url = reverse('logindata:register')
response = self.client.post(url, {
'key': self.member.invite_as_user_key,
'password': self.registration_password.password,
'save': 'true',
'username': 'testuser',
'password1': 'testpass123',
'password2': 'testpass123'
})
url = reverse("logindata:register")
response = self.client.post(
url,
{
"key": self.member.invite_as_user_key,
"password": self.registration_password.password,
"save": "true",
"username": "testuser",
"password1": "testpass123",
"password2": "testpass123",
},
)
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
user = User.objects.get(username='testuser')
user = User.objects.get(username="testuser")
self.assertEqual(user.is_staff, True)
self.member.refresh_from_db()
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):
"""Test POST request with save but no Standard group shows failed page."""
# Delete the Standard group
self.standard_group.delete()
url = reverse('logindata:register')
response = self.client.post(url, {
'key': self.member.invite_as_user_key,
'password': self.registration_password.password,
'save': 'true',
'username': 'testuser',
'password1': 'testpass123',
'password2': 'testpass123'
})
url = reverse("logindata:register")
response = self.client.post(
url,
{
"key": self.member.invite_as_user_key,
"password": self.registration_password.password,
"save": "true",
"username": "testuser",
"password1": "testpass123",
"password2": "testpass123",
},
)
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"
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.shortcuts import render
from django.contrib.auth.forms import UserCreationForm
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.contrib.auth.forms import UserCreationForm
from django.utils.translation import gettext_lazy as _
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=''):
return render(request, 'logindata/register_password.html',
context={'key': key,
'member': member,
'error_message': error_message})
def render_register_password(request, key, member, error_message=""):
return render(
request,
"logindata/register_password.html",
context={"key": key, "member": member, "error_message": error_message},
)
def render_register_failed(request):
return render(request, 'logindata/register_failed.html')
return render(request, "logindata/register_failed.html")
def render_register_form(request, key, password, member, form):
return render(request, 'logindata/register_form.html',
context={'key': key,
'password': password,
'member': member,
'form': form})
return render(
request,
"logindata/register_form.html",
context={"key": key, "password": password, "member": member, "form": form},
)
def render_register_success(request):
return render(request, 'logindata/register_success.html')
return render(request, "logindata/register_success.html")
# Create your views here.
def register(request):
if request.method == 'GET' and 'key' not in request.GET:
return HttpResponseRedirect(reverse('startpage:index'))
if request.method == 'POST' and 'key' not in request.POST:
return HttpResponseRedirect(reverse('startpage:index'))
if request.method == "GET" and "key" not in request.GET:
return HttpResponseRedirect(reverse("startpage:index"))
if request.method == "POST" and "key" not in request.POST:
return HttpResponseRedirect(reverse("startpage:index"))
key = request.GET['key'] if request.method == 'GET' else request.POST['key']
key = request.GET["key"] if request.method == "GET" else request.POST["key"]
if not key:
return render_register_failed(request)
try:
@ -46,17 +48,19 @@ def register(request):
except (Member.DoesNotExist, Member.MultipleObjectsReturned):
return render_register_failed(request)
if request.method == 'GET':
return render_register_password(request, request.GET['key'], member)
if request.method == "GET":
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)
password = request.POST['password']
password = request.POST["password"]
# check if the entered password is one of the active registration passwords
if RegistrationPassword.objects.filter(password=password).count() == 0:
return render_register_password(request, key, member, error_message=_('You entered a wrong password.'))
return render_register_password(
request, key, member, error_message=_("You entered a wrong password.")
)
if "save" in request.POST:
form = UserCreationForm(request.POST)
@ -70,6 +74,6 @@ def register(request):
else:
return render_register_failed(request)
else:
prefill = {'username': member.suggested_username()}
prefill = {"username": member.suggested_username()}
form = UserCreationForm(initial=prefill)
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 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):
list_display = ('title','start_date', 'end_date', 'group', 'category', 'responsible')
list_filter = ('group',)
ordering = ('start_date','end_date')
actions = ['make_overview']
list_display = ("title", "start_date", "end_date", "group", "category", "responsible")
list_filter = ("group",)
ordering = ("start_date", "end_date")
actions = ["make_overview"]
def make_overview(self, request, queryset):
ensure_media_dir()
filename = 'termine.xlsx'
filename = "termine.xlsx"
workbook = xlsxwriter.Workbook(media_path(filename))
bold = workbook.add_format({'bold': True})
bold = workbook.add_format({"bold": True})
worksheet = workbook.add_worksheet()
worksheet.write(0, 0, "Titel", bold)
worksheet.write(0, 1, "Untertitel", bold)
@ -44,30 +41,32 @@ class TerminAdmin(admin.ModelAdmin):
worksheet.write(0, 19, "Telefonnummer", bold)
worksheet.write(0, 20, "Emailadresse", bold)
for row, termin in enumerate(queryset):
worksheet.write(row+2, 0, termin.title)
worksheet.write(row+2, 1, termin.subtitle)
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, 4, termin.group)
worksheet.write(row+2, 5, termin.category)
worksheet.write(row+2, 6, termin.technik)
worksheet.write(row+2, 7, termin.condition)
worksheet.write(row+2, 8, termin.saison)
worksheet.write(row+2, 9, termin.eventart)
worksheet.write(row+2, 10, termin.klassifizierung)
worksheet.write(row+2, 11, termin.anforderung_hoehe)
worksheet.write(row+2, 12, termin.anforderung_strecke)
worksheet.write(row+2, 13, termin.anforderung_dauer)
worksheet.write(row+2, 14, termin.voraussetzungen)
worksheet.write(row+2, 15, termin.description)
worksheet.write(row+2, 16, termin.equipment)
worksheet.write(row+2, 17, termin.max_participants)
worksheet.write(row+2, 18, termin.responsible)
worksheet.write(row+2, 19, termin.phone)
worksheet.write(row+2, 20, termin.email)
worksheet.write(row + 2, 0, termin.title)
worksheet.write(row + 2, 1, termin.subtitle)
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, 4, termin.group)
worksheet.write(row + 2, 5, termin.category)
worksheet.write(row + 2, 6, termin.technik)
worksheet.write(row + 2, 7, termin.condition)
worksheet.write(row + 2, 8, termin.saison)
worksheet.write(row + 2, 9, termin.eventart)
worksheet.write(row + 2, 10, termin.klassifizierung)
worksheet.write(row + 2, 11, termin.anforderung_hoehe)
worksheet.write(row + 2, 12, termin.anforderung_strecke)
worksheet.write(row + 2, 13, termin.anforderung_dauer)
worksheet.write(row + 2, 14, termin.voraussetzungen)
worksheet.write(row + 2, 15, termin.description)
worksheet.write(row + 2, 16, termin.equipment)
worksheet.write(row + 2, 17, termin.max_participants)
worksheet.write(row + 2, 18, termin.responsible)
worksheet.write(row + 2, 19, termin.phone)
worksheet.write(row + 2, 20, termin.email)
workbook.close()
return serve_media(filename, 'application/xlsx')
return serve_media(filename, "application/xlsx")
make_overview.short_description = "Termine in Excel Liste überführen"
# Register your models here.
admin.site.register(Termin, TerminAdmin)

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

@ -1,46 +1,194 @@
# Generated by Django 4.0.1 on 2023-03-29 20:40
import django.core.validators
from django.db import migrations, models
from django.db import migrations
from django.db import models
class Migration(migrations.Migration):
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')]
dependencies = [
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"),
]
dependencies = []
operations = [
migrations.CreateModel(
name='Termin',
name="Termin",
fields=[
('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('title', models.CharField(max_length=100, verbose_name='Titel')),
('start_date', models.DateField(verbose_name='Von')),
('end_date', models.DateField(verbose_name='Bis')),
('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')),
('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')),
(
"id",
models.AutoField(
auto_created=True, primary_key=True, serialize=False, verbose_name="ID"
),
),
("title", models.CharField(max_length=100, verbose_name="Titel")),
("start_date", models.DateField(verbose_name="Von")),
("end_date", models.DateField(verbose_name="Bis")),
(
"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")),
("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={
'verbose_name_plural': 'Termine',
'verbose_name': 'Termin',
"verbose_name_plural": "Termine",
"verbose_name": "Termin",
},
),
]

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

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

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

@ -1,74 +1,62 @@
from django.shortcuts import render
from django import forms
from django.http import HttpResponseRedirect
from django.contrib.admin import widgets
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):
title = forms.CharField(label='Titel')
subtitle = forms.CharField(label='Untertitel')
start_date = forms.DateField(label='Von',
widget=datepicker)
end_date = forms.DateField(label='Bis',
widget=datepicker)
group = forms.ChoiceField(label='Gruppe',
required=True,
choices=GRUPPE)
category = forms.ChoiceField(label='Kategorie', required=True, choices=KATEGORIE)
condition = forms.ChoiceField(label='Kondition', required=True, choices=KONDITION)
technik = forms.ChoiceField(label='Technik', required=True, choices=TECHNIK)
saison = forms.ChoiceField(label='Saison', required=True, choices=SAISON)
eventart = forms.ChoiceField(label='Eventart', required=True, choices=EVENTART)
klassifizierung = forms.ChoiceField(label='Klassifizierung', required=True, choices=KLASSIFIZIERUNG)
anforderung_hoehe = forms.IntegerField(label='Höhenmeter in Metern',
required=True,
validators=[
MinValueValidator(0)
])
anforderung_strecke = forms.IntegerField(label='Strecke in Kilometern',
required=True,
validators=[
MinValueValidator(0)
])
anforderung_dauer = forms.IntegerField(label='Etappendauer in Stunden',
required=True,
validators=[
MinValueValidator(0)
])
description = forms.CharField(label='Beschreibung',
widget=forms.Textarea,
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)
class TerminForm(forms.Form):
title = forms.CharField(label="Titel")
subtitle = forms.CharField(label="Untertitel")
start_date = forms.DateField(label="Von", widget=datepicker)
end_date = forms.DateField(label="Bis", widget=datepicker)
group = forms.ChoiceField(label="Gruppe", required=True, choices=GRUPPE)
category = forms.ChoiceField(label="Kategorie", required=True, choices=KATEGORIE)
condition = forms.ChoiceField(label="Kondition", required=True, choices=KONDITION)
technik = forms.ChoiceField(label="Technik", required=True, choices=TECHNIK)
saison = forms.ChoiceField(label="Saison", required=True, choices=SAISON)
eventart = forms.ChoiceField(label="Eventart", required=True, choices=EVENTART)
klassifizierung = forms.ChoiceField(
label="Klassifizierung", required=True, choices=KLASSIFIZIERUNG
)
anforderung_hoehe = forms.IntegerField(
label="Höhenmeter in Metern", required=True, validators=[MinValueValidator(0)]
)
anforderung_strecke = forms.IntegerField(
label="Strecke in Kilometern", required=True, validators=[MinValueValidator(0)]
)
anforderung_dauer = forms.IntegerField(
label="Etappendauer in Stunden", required=True, validators=[MinValueValidator(0)]
)
description = forms.CharField(label="Beschreibung", widget=forms.Textarea, 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.
def index(request, *args):
if request.method == 'POST':
if request.method == "POST":
form = TerminForm(request.POST)
if form.is_valid():
termin = Termin(title=form.cleaned_data["title"],
termin = Termin(
title=form.cleaned_data["title"],
subtitle=form.cleaned_data["subtitle"],
start_date=form.cleaned_data["start_date"],
end_date=form.cleaned_data["end_date"],
@ -88,13 +76,14 @@ def index(request, *args):
anforderung_hoehe=form.cleaned_data["anforderung_hoehe"],
anforderung_strecke=form.cleaned_data["anforderung_strecke"],
anforderung_dauer=form.cleaned_data["anforderung_dauer"],
description=form.cleaned_data["description"])
description=form.cleaned_data["description"],
)
termin.save()
return published(request)
else:
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):
return render(request, 'ludwigsburgalpin/published.html')
return render(request, "ludwigsburgalpin/published.html")

@ -7,14 +7,10 @@ from django.contrib import admin
from django.contrib import messages
from django.contrib.admin import helpers
from django.shortcuts import render
from django.utils.translation import (
gettext_lazy as _,
)
from django.utils.translation import gettext_lazy as _
from members.admin import FilteredMemberFieldMixin
from members.models import Member
from rules.contrib.admin import (
ObjectPermissionsModelAdmin,
)
from rules.contrib.admin import ObjectPermissionsModelAdmin
from .mailutils import NOT_SENT
from .mailutils import PARTLY_SENT
@ -23,6 +19,7 @@ from .models import EmailAddress
from .models import EmailAddressForm
from .models import Message
from .models import MessageForm
# from easy_select2 import apply_select2

@ -4,7 +4,6 @@ from django.conf import settings
from django.core import mail
from django.core.mail import EmailMessage
logger = logging.getLogger(__name__)

@ -1,20 +1,19 @@
from django.conf import settings
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 mailer.mailutils import send
from django.conf import settings
import re
from members.models import annotate_activity_score
from members.models import Member
class Command(BaseCommand):
help = 'Congratulates the most active members'
help = "Congratulates the most active members"
requires_system_checks = False
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):
positiontext = "{}. ".format(position + 1) if position > 0 else ""
score = member._activity_score
@ -28,11 +27,17 @@ class Command(BaseCommand):
level = 4
else:
level = 5
content = settings.NOTIFY_MOST_ACTIVE_TEXT.format(name=member.prename,
congratulate_max=CONGRATULATE_MEMBERS_MAX,
content = settings.NOTIFY_MOST_ACTIVE_TEXT.format(
name=member.prename,
congratulate_max=settings.CONGRATULATE_MEMBERS_MAX,
score=score,
level=level,
position=positiontext)
send(_("Congratulation %(name)s") % { 'name': member.prename },
content, settings.DEFAULT_SENDING_ADDRESS, [member.email],
reply_to=[settings.RESPONSIBLE_MAIL])
position=positiontext,
)
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 mailer.models import Message
from members.models import Member
from django.db.models import Q
import re
class Command(BaseCommand):
help = 'Shows reply-to addresses'
help = "Shows reply-to addresses"
requires_system_checks = False
def add_arguments(self, parser):
parser.add_argument('--message_id', default="-1")
parser.add_argument('--subject', default="")
parser.add_argument("--message_id", default="-1")
parser.add_argument("--subject", default="")
def handle(self, *args, **options):
replies = []
try:
message_id = int(options['message_id'])
message_id = int(options["message_id"])
message = Message.objects.get(pk=message_id)
if message.reply_to:
replies = list(message.reply_to.all())
replies.extend(message.reply_to_email_address.all())
except (Message.DoesNotExist, ValueError):
extracted = re.match("^([Ww][Gg]: *|[Ff][Ww]: *|[Rr][Ee]: *|[Aa][Ww]: *)* *(.*)$",
options['subject']).group(2)
extracted = re.match(
"^([Ww][Gg]: *|[Ff][Ww]: *|[Rr][Ee]: *|[Aa][Ww]: *)* *(.*)$", options["subject"]
).group(2)
try:
msgs = Message.objects.filter(subject=extracted)
message = msgs.all()[0]
@ -36,8 +36,7 @@ class Command(BaseCommand):
if not replies:
# send mail to all jugendleiters
replies = Member.objects.filter(group__name='Jugendleiter',
gets_newsletter=True)
forwards = [l.email for l in replies]
replies = Member.objects.filter(group__name="Jugendleiter", gets_newsletter=True)
forwards = [lst.email for lst in replies]
self.stdout.write(" ".join(forwards))

@ -1,79 +1,165 @@
# Generated by Django 4.0.1 on 2023-03-29 20:38
import django.core.validators
from django.db import migrations, models
import django.db.models.deletion
import utils
from django.db import migrations
from django.db import models
class Migration(migrations.Migration):
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')]
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"),
]
dependencies = [
('members', '0006_auto_20190914_2341'),
('members', '0008_auto_20210924_1155'),
('members', '0001_initial'),
('members', '0007_auto_20200924_1512'),
('members', '0005_auto_20190615_1224'),
("members", "0006_auto_20190914_2341"),
("members", "0008_auto_20210924_1155"),
("members", "0001_initial"),
("members", "0007_auto_20200924_1512"),
("members", "0005_auto_20190615_1224"),
]
operations = [
migrations.CreateModel(
name='Message',
name="Message",
fields=[
('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('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')),
(
"id",
models.AutoField(
auto_created=True, primary_key=True, serialize=False, verbose_name="ID"
),
),
("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={
'verbose_name_plural': 'messages',
'permissions': (('submit_mails', 'Can submit mails'),),
'verbose_name': 'message',
"verbose_name_plural": "messages",
"permissions": (("submit_mails", "Can submit mails"),),
"verbose_name": "message",
},
),
migrations.CreateModel(
name='Attachment',
name="Attachment",
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')),
('msg', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='mailer.message')),
(
"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"
),
),
(
"msg",
models.ForeignKey(
on_delete=django.db.models.deletion.CASCADE, to="mailer.message"
),
),
],
options={
'verbose_name_plural': 'attachments',
'verbose_name': 'attachment',
"verbose_name_plural": "attachments",
"verbose_name": "attachment",
},
),
migrations.CreateModel(
name='EmailAddress',
name="EmailAddress",
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')),
('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')),
(
"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",
),
),
(
"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={
'verbose_name_plural': 'email addresses',
'verbose_name': 'email address',
"verbose_name_plural": "email addresses",
"verbose_name": "email address",
},
),
migrations.AddField(
model_name='message',
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'),
model_name="message",
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",
),
),
migrations.AddField(
model_name='message',
name='to_freizeit',
field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, to='members.freizeit', verbose_name='to freizeit'),
model_name="message",
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(
model_name='message',
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'),
model_name="message",
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",
),
),
]

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

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

@ -1,19 +1,18 @@
# Generated by Django 4.0.1 on 2024-11-17 23:31
from django.db import migrations
import utils
from django.db import migrations
class Migration(migrations.Migration):
dependencies = [
('mailer', '0003_alter_message_options'),
("mailer", "0003_alter_message_options"),
]
operations = [
migrations.AlterField(
model_name='attachment',
name='f',
field=utils.RestrictedFileField(upload_to='attachments', verbose_name='file'),
model_name="attachment",
name="f",
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
import django.core.validators
from django.db import migrations, models
from django.db import migrations
from django.db import models
class Migration(migrations.Migration):
dependencies = [
('mailer', '0004_alter_attachment_f'),
("mailer", "0004_alter_attachment_f"),
]
operations = [
migrations.AlterField(
model_name='emailaddress',
name='name',
field=models.CharField(max_length=50, validators=[django.core.validators.RegexValidator('^[0-9a-zA-Z._-]*$', 'Only alphanumeric characters, ., - and _ are allowed')], verbose_name='name'),
model_name="emailaddress",
name="name",
field=models.CharField(
max_length=50,
validators=[
django.core.validators.RegexValidator(
"^[0-9a-zA-Z._-]*$", "Only alphanumeric characters, ., - and _ are allowed"
)
],
verbose_name="name",
),
),
]

@ -1,19 +1,25 @@
# 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):
dependencies = [
('members', '0029_alter_member_gender_alter_memberwaitinglist_gender'),
('mailer', '0005_alter_emailaddress_name'),
("members", "0029_alter_member_gender_alter_memberwaitinglist_gender"),
("mailer", "0005_alter_emailaddress_name"),
]
operations = [
migrations.AddField(
model_name='emailaddress',
name='allowed_senders',
field=models.ManyToManyField(blank=True, help_text='Only forward e-mails of members of selected groups. Leave empty to allow all senders.', related_name='allowed_sender_on_emailaddresses', to='members.Group', verbose_name='Allowed sender'),
model_name="emailaddress",
name="allowed_senders",
field=models.ManyToManyField(
blank=True,
help_text="Only forward e-mails of members of selected groups. Leave empty to allow all senders.",
related_name="allowed_sender_on_emailaddresses",
to="members.Group",
verbose_name="Allowed sender",
),
),
]

@ -1,18 +1,22 @@
# 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):
dependencies = [
('mailer', '0006_emailaddress_allowed_senders'),
("mailer", "0006_emailaddress_allowed_senders"),
]
operations = [
migrations.AddField(
model_name='emailaddress',
name='internal_only',
field=models.BooleanField(default=False, help_text='Only allow forwarding to this e-mail address from the internal domain.', verbose_name='Restrict to internal email addresses'),
model_name="emailaddress",
name="internal_only",
field=models.BooleanField(
default=False,
help_text="Only allow forwarding to this e-mail address from the internal domain.",
verbose_name="Restrict to internal email addresses",
),
),
]

@ -1,19 +1,28 @@
# Generated by Django 4.0.1 on 2024-12-03 23:19
import django.core.validators
from django.db import migrations, models
from django.db import migrations
from django.db import models
class Migration(migrations.Migration):
dependencies = [
('mailer', '0007_emailaddress_internal_only'),
("mailer", "0007_emailaddress_internal_only"),
]
operations = [
migrations.AlterField(
model_name='emailaddress',
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'),
model_name="emailaddress",
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",
),
),
]

@ -20,7 +20,6 @@ from .mailutils import send
from .mailutils import SENT
from .rules import is_creator
logger = logging.getLogger(__name__)

@ -1,5 +1,7 @@
from .models import *
# ruff: noqa F403
from .admin import *
from .views import *
from .rules import *
from .mailutils import *
from .models import *
from .rules import *
from .views import *

@ -1,29 +1,31 @@
import json
import unittest
from http import HTTPStatus
from django.test import TestCase, override_settings
from unittest.mock import patch
from django.conf import settings
from django.contrib.admin.sites import AdminSite
from django.test import RequestFactory, Client
from django.contrib.auth.models import User, Permission
from django.utils import timezone
from django.contrib.sessions.middleware import SessionMiddleware
from django.contrib.auth.models import User
from django.contrib.messages import get_messages
from django.contrib.messages.middleware import MessageMiddleware
from django.contrib.messages.storage.fallback import FallbackStorage
from django.contrib.messages import get_messages
from django.contrib.sessions.middleware import SessionMiddleware
from django.http import HttpResponseRedirect
from django.test import RequestFactory
from django.test import TestCase
from django.utils import timezone
from django.utils.translation import gettext_lazy as _
from django.urls import reverse, reverse_lazy
from django.http import HttpResponseRedirect, HttpResponse
from unittest.mock import Mock, patch
from django.test.utils import override_settings
from django.urls import path, include
from django.contrib import admin as django_admin
from django.conf import settings
from members.models import DIVERSE
from members.models import Group
from members.models import Member
from members.tests.utils import create_custom_user
from members.models import Member, MALE, DIVERSE, Group
from ..models import Message, Attachment, EmailAddress
from ..admin import MessageAdmin, submit_message
from ..mailutils import SENT, NOT_SENT, PARTLY_SENT
from ..admin import MessageAdmin
from ..admin import submit_message
from ..mailutils import NOT_SENT
from ..mailutils import PARTLY_SENT
from ..mailutils import SENT
from ..models import EmailAddress
from ..models import Message
class AdminTestCase(TestCase):
@ -32,11 +34,9 @@ class AdminTestCase(TestCase):
self.model = model
if model is not None and admin is not None:
self.admin = admin(model, AdminSite())
superuser = User.objects.create_superuser(
username='superuser', password='secret'
)
standard = create_custom_user('standard', ['Standard'], 'Paul', 'Wulter')
trainer = create_custom_user('trainer', ['Standard', 'Trainings'], 'Lise', 'Lotte')
User.objects.create_superuser(username="superuser", password="secret")
create_custom_user("standard", ["Standard"], "Paul", "Wulter")
create_custom_user("trainer", ["Standard", "Trainings"], "Lise", "Lotte")
def _add_middleware(self, request):
"""Add required middleware to request."""
@ -56,53 +56,56 @@ class MessageAdminTestCase(AdminTestCase):
super().setUp(Message, MessageAdmin)
# Create test data
self.group = Group.objects.create(name='Test Group')
self.email_address = EmailAddress.objects.create(name='testmail')
self.group = Group.objects.create(name="Test Group")
self.email_address = EmailAddress.objects.create(name="testmail")
# Create test member with internal email
self.internal_member = Member.objects.create(
prename='Internal',
lastname='User',
prename="Internal",
lastname="User",
birth_date=timezone.now().date(),
email=f'internal@{settings.ALLOWED_EMAIL_DOMAINS_FOR_INVITE_AS_USER[0]}',
gender=DIVERSE
email=f"internal@{settings.ALLOWED_EMAIL_DOMAINS_FOR_INVITE_AS_USER[0]}",
gender=DIVERSE,
)
# Create test member with external email
self.external_member = Member.objects.create(
prename='External',
lastname='User',
prename="External",
lastname="User",
birth_date=timezone.now().date(),
email='external@example.com',
gender=DIVERSE
email="external@example.com",
gender=DIVERSE,
)
# Create users for testing
self.user_with_internal_member = User.objects.create_user(username='testuser', password='secret')
self.user_with_internal_member = User.objects.create_user(
username="testuser", password="secret"
)
self.user_with_internal_member.member = self.internal_member
self.user_with_internal_member.save()
self.user_with_external_member = User.objects.create_user(username='external_user', password='secret')
self.user_with_external_member = User.objects.create_user(
username="external_user", password="secret"
)
self.user_with_external_member.member = self.external_member
self.user_with_external_member.save()
self.user_without_member = User.objects.create_user(username='no_member_user', password='secret')
self.user_without_member = User.objects.create_user(
username="no_member_user", password="secret"
)
# Create test message
self.message = Message.objects.create(
subject='Test Message',
content='Test content'
)
self.message = Message.objects.create(subject="Test Message", content="Test content")
self.message.to_groups.add(self.group)
self.message.to_members.add(self.internal_member)
def test_save_model_sets_created_by(self):
"""Test that save_model sets created_by when creating new message."""
request = self.factory.post('/admin/mailer/message/add/')
request = self.factory.post("/admin/mailer/message/add/")
request.user = self.user_with_internal_member
# Create new message
new_message = Message(subject='New Message', content='New content')
new_message = Message(subject="New Message", content="New content")
# Test save_model for new object (change=False)
self.admin.save_model(request, new_message, None, change=False)
@ -111,7 +114,7 @@ class MessageAdminTestCase(AdminTestCase):
def test_save_model_does_not_change_created_by_on_update(self):
"""Test that save_model doesn't change created_by when updating."""
request = self.factory.post('/admin/mailer/message/1/change/')
request = self.factory.post("/admin/mailer/message/1/change/")
request.user = self.user_with_internal_member
# Message already has created_by set
@ -122,12 +125,12 @@ class MessageAdminTestCase(AdminTestCase):
self.assertEqual(self.message.created_by, self.external_member)
@patch('mailer.models.Message.submit')
@patch("mailer.models.Message.submit")
def test_submit_message_success(self, mock_submit):
"""Test submit_message with successful send."""
mock_submit.return_value = SENT
request = self.factory.post('/admin/mailer/message/')
request = self.factory.post("/admin/mailer/message/")
request.user = self.user_with_internal_member
self._add_middleware(request)
@ -140,14 +143,14 @@ class MessageAdminTestCase(AdminTestCase):
# Check success message
messages_list = list(get_messages(request))
self.assertEqual(len(messages_list), 1)
self.assertIn(str(_('Successfully sent message')), str(messages_list[0]))
self.assertIn(str(_("Successfully sent message")), str(messages_list[0]))
@patch('mailer.models.Message.submit')
@patch("mailer.models.Message.submit")
def test_submit_message_not_sent(self, mock_submit):
"""Test submit_message when sending fails."""
mock_submit.return_value = NOT_SENT
request = self.factory.post('/admin/mailer/message/')
request = self.factory.post("/admin/mailer/message/")
request.user = self.user_with_internal_member
self._add_middleware(request)
@ -157,14 +160,14 @@ class MessageAdminTestCase(AdminTestCase):
# Check error message
messages_list = list(get_messages(request))
self.assertEqual(len(messages_list), 1)
self.assertIn(str(_('Failed to send message')), str(messages_list[0]))
self.assertIn(str(_("Failed to send message")), str(messages_list[0]))
@patch('mailer.models.Message.submit')
@patch("mailer.models.Message.submit")
def test_submit_message_partly_sent(self, mock_submit):
"""Test submit_message when partially sent."""
mock_submit.return_value = PARTLY_SENT
request = self.factory.post('/admin/mailer/message/')
request = self.factory.post("/admin/mailer/message/")
request.user = self.user_with_internal_member
self._add_middleware(request)
@ -174,11 +177,11 @@ class MessageAdminTestCase(AdminTestCase):
# Check warning message
messages_list = list(get_messages(request))
self.assertEqual(len(messages_list), 1)
self.assertIn(str(_('Failed to send some messages')), str(messages_list[0]))
self.assertIn(str(_("Failed to send some messages")), str(messages_list[0]))
def test_submit_message_user_has_no_member(self):
"""Test submit_message when user has no associated member."""
request = self.factory.post('/admin/mailer/message/')
request = self.factory.post("/admin/mailer/message/")
request.user = self.user_without_member
self._add_middleware(request)
@ -188,11 +191,18 @@ class MessageAdminTestCase(AdminTestCase):
# Check error message
messages_list = list(get_messages(request))
self.assertEqual(len(messages_list), 1)
self.assertIn(str(_('Your account is not connected to a member. Please contact your system administrator.')), str(messages_list[0]))
self.assertIn(
str(
_(
"Your account is not connected to a member. Please contact your system administrator."
)
),
str(messages_list[0]),
)
def test_submit_message_user_has_external_email(self):
"""Test submit_message when user has external email."""
request = self.factory.post('/admin/mailer/message/')
request = self.factory.post("/admin/mailer/message/")
request.user = self.user_with_external_member
self._add_middleware(request)
@ -202,12 +212,20 @@ class MessageAdminTestCase(AdminTestCase):
# Check error message
messages_list = list(get_messages(request))
self.assertEqual(len(messages_list), 1)
self.assertIn(str(_('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)}), str(messages_list[0]))
self.assertIn(
str(
_(
"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)}
),
str(messages_list[0]),
)
@patch('mailer.admin.submit_message')
@patch("mailer.admin.submit_message")
def test_send_message_action_confirmed(self, mock_submit_message):
"""Test send_message action when confirmed."""
request = self.factory.post('/admin/mailer/message/', {'confirmed': 'true'})
request = self.factory.post("/admin/mailer/message/", {"confirmed": "true"})
request.user = self.user_with_internal_member
self._add_middleware(request)
@ -224,7 +242,7 @@ class MessageAdminTestCase(AdminTestCase):
def test_send_message_action_not_confirmed(self):
"""Test send_message action when not confirmed (shows confirmation page)."""
request = self.factory.post('/admin/mailer/message/')
request = self.factory.post("/admin/mailer/message/")
request.user = self.user_with_internal_member
self._add_middleware(request)
@ -237,17 +255,17 @@ class MessageAdminTestCase(AdminTestCase):
self.assertIsNotNone(result)
self.assertEqual(result.status_code, HTTPStatus.OK)
@patch('mailer.admin.submit_message')
@patch("mailer.admin.submit_message")
def test_response_change_with_send(self, mock_submit_message):
"""Test response_change when _send is in POST."""
request = self.factory.post('/admin/mailer/message/1/change/', {'_send': 'Send'})
request = self.factory.post("/admin/mailer/message/1/change/", {"_send": "Send"})
request.user = self.user_with_internal_member
self._add_middleware(request)
# Test response_change
with patch.object(self.admin.__class__.__bases__[2], 'response_change') as mock_super:
mock_super.return_value = HttpResponseRedirect('/admin/')
result = self.admin.response_change(request, self.message)
with patch.object(self.admin.__class__.__bases__[2], "response_change") as mock_super:
mock_super.return_value = HttpResponseRedirect("/admin/")
self.admin.response_change(request, self.message)
# Verify submit_message was called
mock_submit_message.assert_called_once_with(self.message, request)
@ -255,17 +273,17 @@ class MessageAdminTestCase(AdminTestCase):
# Verify super method was called
mock_super.assert_called_once()
@patch('mailer.admin.submit_message')
@patch("mailer.admin.submit_message")
def test_response_change_without_send(self, mock_submit_message):
"""Test response_change when _send is not in POST."""
request = self.factory.post('/admin/mailer/message/1/change/', {'_save': 'Save'})
request = self.factory.post("/admin/mailer/message/1/change/", {"_save": "Save"})
request.user = self.user_with_internal_member
self._add_middleware(request)
# Test response_change
with patch.object(self.admin.__class__.__bases__[2], 'response_change') as mock_super:
mock_super.return_value = HttpResponseRedirect('/admin/')
result = self.admin.response_change(request, self.message)
with patch.object(self.admin.__class__.__bases__[2], "response_change") as mock_super:
mock_super.return_value = HttpResponseRedirect("/admin/")
self.admin.response_change(request, self.message)
# Verify submit_message was NOT called
mock_submit_message.assert_not_called()
@ -273,17 +291,17 @@ class MessageAdminTestCase(AdminTestCase):
# Verify super method was called
mock_super.assert_called_once()
@patch('mailer.admin.submit_message')
@patch("mailer.admin.submit_message")
def test_response_add_with_send(self, mock_submit_message):
"""Test response_add when _send is in POST."""
request = self.factory.post('/admin/mailer/message/add/', {'_send': 'Send'})
request = self.factory.post("/admin/mailer/message/add/", {"_send": "Send"})
request.user = self.user_with_internal_member
self._add_middleware(request)
# Test response_add
with patch.object(self.admin.__class__.__bases__[2], 'response_add') as mock_super:
mock_super.return_value = HttpResponseRedirect('/admin/')
result = self.admin.response_add(request, self.message)
with patch.object(self.admin.__class__.__bases__[2], "response_add") as mock_super:
mock_super.return_value = HttpResponseRedirect("/admin/")
self.admin.response_add(request, self.message)
# Verify submit_message was called
mock_submit_message.assert_called_once_with(self.message, request)
@ -295,7 +313,7 @@ class MessageAdminTestCase(AdminTestCase):
"""Test get_form when members parameter is provided."""
# Create request with members parameter
members_ids = [self.internal_member.pk, self.external_member.pk]
request = self.factory.get(f'/admin/mailer/message/add/?members={json.dumps(members_ids)}')
request = self.factory.get(f"/admin/mailer/message/add/?members={json.dumps(members_ids)}")
request.user = self.user_with_internal_member
# Test get_form
@ -303,7 +321,9 @@ class MessageAdminTestCase(AdminTestCase):
form = form_class()
# Verify initial members are set
self.assertEqual(list(form.fields['to_members'].initial), [self.internal_member, self.external_member])
self.assertEqual(
list(form.fields["to_members"].initial), [self.internal_member, self.external_member]
)
def test_get_form_with_invalid_members_param(self):
"""Test get_form when members parameter is not a list."""
@ -320,7 +340,7 @@ class MessageAdminTestCase(AdminTestCase):
def test_get_form_without_members_param(self):
"""Test get_form when no members parameter is provided."""
# Create request without members parameter
request = self.factory.get('/admin/mailer/message/add/')
request = self.factory.get("/admin/mailer/message/add/")
request.user = self.user_with_internal_member
# Test get_form

@ -1,6 +1,10 @@
from django.test import TestCase, override_settings
from unittest.mock import patch, Mock
from mailer.mailutils import send, SENT, NOT_SENT
from unittest.mock import Mock
from unittest.mock import patch
from django.test import TestCase
from mailer.mailutils import NOT_SENT
from mailer.mailutils import send
from mailer.mailutils import SENT
class MailUtilsTest(TestCase):
@ -11,24 +15,36 @@ class MailUtilsTest(TestCase):
self.recipient = "recipient@example.com"
def test_send_with_reply_to(self):
with patch('mailer.mailutils.mail.get_connection') as mock_connection:
with patch("mailer.mailutils.mail.get_connection") as mock_connection:
mock_conn = Mock()
mock_connection.return_value = mock_conn
result = send(self.subject, self.content, self.sender, self.recipient, reply_to=["reply@example.com"])
result = send(
self.subject,
self.content,
self.sender,
self.recipient,
reply_to=["reply@example.com"],
)
self.assertEqual(result, SENT)
def test_send_with_message_id(self):
with patch('mailer.mailutils.mail.get_connection') as mock_connection:
with patch("mailer.mailutils.mail.get_connection") as mock_connection:
mock_conn = Mock()
mock_connection.return_value = mock_conn
result = send(self.subject, self.content, self.sender, self.recipient, message_id="<test@example.com>")
result = send(
self.subject,
self.content,
self.sender,
self.recipient,
message_id="<test@example.com>",
)
self.assertEqual(result, SENT)
def test_send_exception_handling(self):
with patch('mailer.mailutils.mail.get_connection') as mock_connection:
with patch("mailer.mailutils.mail.get_connection") as mock_connection:
mock_conn = Mock()
mock_conn.send_messages.side_effect = Exception("Test exception")
mock_connection.return_value = mock_conn
with patch('builtins.print'):
with patch("builtins.print"):
result = send(self.subject, self.content, self.sender, self.recipient)
self.assertEqual(result, NOT_SENT)

@ -1,13 +1,23 @@
from unittest import skip, mock
from django.test import TestCase
from unittest import mock
from django.conf import settings
from django.core.files.uploadedfile import SimpleUploadedFile
from django.utils import timezone
from django.core.exceptions import ValidationError
from django.utils.translation import gettext as _
from django.core.files.uploadedfile import SimpleUploadedFile
from members.models import Member, Group, DIVERSE, Freizeit, MemberNoteList, GEMEINSCHAFTS_TOUR, MUSKELKRAFT_ANREISE
from mailer.models import EmailAddress, EmailAddressForm, Message, MessageForm, Attachment
from mailer.mailutils import SENT, NOT_SENT, PARTLY_SENT
from mailer.mailutils import NOT_SENT
from mailer.mailutils import PARTLY_SENT
from mailer.mailutils import SENT
from mailer.models import Attachment
from mailer.models import EmailAddressForm
from mailer.models import Message
from mailer.models import MessageForm
from members.models import DIVERSE
from members.models import Freizeit
from members.models import GEMEINSCHAFTS_TOUR
from members.models import Member
from members.models import MemberNoteList
from members.models import MUSKELKRAFT_ANREISE
from .utils import BasicMailerTestCase
@ -19,13 +29,13 @@ class EmailAddressTestCase(BasicMailerTestCase):
self.assertEqual(self.em.email, str(self.em))
def test_forwards(self):
self.assertEqual(self.em.forwards, {'fritz@foo.com', 'paul@foo.com'})
self.assertEqual(self.em.forwards, {"fritz@foo.com", "paul@foo.com"})
class EmailAddressFormTestCase(BasicMailerTestCase):
def test_clean(self):
# instantiate form with only name field set
form = EmailAddressForm(data={'name': 'bar'})
form = EmailAddressForm(data={"name": "bar"})
# validate the form - this should fail due to missing required recipients
self.assertFalse(form.is_valid())
@ -33,7 +43,7 @@ class EmailAddressFormTestCase(BasicMailerTestCase):
class MessageFormTestCase(BasicMailerTestCase):
def test_clean(self):
# instantiate form with only subject and content fields set
form = MessageForm(data={'subject': 'Test Subject', 'content': 'Test content'})
form = MessageForm(data={"subject": "Test Subject", "content": "Test content"})
# validate the form - this should fail due to missing required recipients
self.assertFalse(form.is_valid())
@ -42,19 +52,16 @@ class MessageTestCase(BasicMailerTestCase):
def setUp(self):
super().setUp()
self.message = Message.objects.create(
subject='Test Message',
content='This is a test message'
subject="Test Message", content="This is a test message"
)
self.freizeit = Freizeit.objects.create(
name='Test Freizeit',
name="Test Freizeit",
kilometers_traveled=120,
tour_type=GEMEINSCHAFTS_TOUR,
tour_approach=MUSKELKRAFT_ANREISE,
difficulty=1
)
self.notelist = MemberNoteList.objects.create(
title='Test Note List'
difficulty=1,
)
self.notelist = MemberNoteList.objects.create(title="Test Note List")
# Set up message with multiple recipient types
self.message.to_groups.add(self.mygroup)
@ -65,39 +72,39 @@ class MessageTestCase(BasicMailerTestCase):
# Create a sender member for submit tests
self.sender = Member.objects.create(
prename='Sender',
lastname='Test',
prename="Sender",
lastname="Test",
birth_date=timezone.now().date(),
email='sender@test.com',
gender=DIVERSE
email="sender@test.com",
gender=DIVERSE,
)
def test_str(self):
self.assertEqual(str(self.message), 'Test Message')
self.assertEqual(str(self.message), "Test Message")
def test_get_recipients(self):
recipients = self.message.get_recipients()
self.assertIn('My Group', recipients)
self.assertIn('Test Freizeit', recipients)
self.assertIn('Test Note List', recipients)
self.assertIn('Fritz Wulter', recipients)
self.assertIn("My Group", recipients)
self.assertIn("Test Freizeit", recipients)
self.assertIn("Test Note List", recipients)
self.assertIn("Fritz Wulter", recipients)
def test_get_recipients_with_many_members(self):
# Add additional members to test the "Some other members" case
for i in range(3):
member = Member.objects.create(
prename=f'Member{i}',
lastname='Test',
prename=f"Member{i}",
lastname="Test",
birth_date=timezone.now().date(),
email=f'member{i}@test.com',
gender=DIVERSE
email=f"member{i}@test.com",
gender=DIVERSE,
)
self.message.to_members.add(member)
recipients = self.message.get_recipients()
self.assertIn(_('Some other members'), recipients)
self.assertIn(_("Some other members"), recipients)
@mock.patch('mailer.models.send')
@mock.patch("mailer.models.send")
def test_submit_successful(self, mock_send):
# Mock successful email sending
mock_send.return_value = SENT
@ -113,7 +120,7 @@ class MessageTestCase(BasicMailerTestCase):
# Verify send was called
self.assertTrue(mock_send.called)
@mock.patch('mailer.models.send')
@mock.patch("mailer.models.send")
def test_submit_failed(self, mock_send):
# Mock failed email sending
mock_send.return_value = NOT_SENT
@ -127,7 +134,7 @@ class MessageTestCase(BasicMailerTestCase):
# Note: The submit method always returns SENT when an exception occurs
self.assertEqual(result, SENT)
@mock.patch('mailer.models.send')
@mock.patch("mailer.models.send")
def test_submit_without_sender(self, mock_send):
# Mock successful email sending
mock_send.return_value = SENT
@ -140,26 +147,25 @@ class MessageTestCase(BasicMailerTestCase):
self.assertTrue(self.message.sent)
self.assertEqual(result, SENT)
@mock.patch('mailer.models.send')
@mock.patch("mailer.models.send")
def test_submit_subject_cleaning(self, mock_send):
# Mock successful email sending
mock_send.return_value = SENT
# Create message with underscores in subject
message_with_underscores = Message.objects.create(
subject='Test_Message_With_Underscores',
content='Test content'
subject="Test_Message_With_Underscores", content="Test content"
)
message_with_underscores.to_members.add(self.fritz)
# Test submit method
result = message_with_underscores.submit()
message_with_underscores.submit()
# Verify underscores were removed from subject
message_with_underscores.refresh_from_db()
self.assertEqual(message_with_underscores.subject, 'Test Message With Underscores')
self.assertEqual(message_with_underscores.subject, "Test Message With Underscores")
@mock.patch('mailer.models.send')
@mock.patch("mailer.models.send")
def test_submit_exception_handling(self, mock_send):
# Mock an exception during email sending
mock_send.side_effect = Exception("Email sending failed")
@ -173,8 +179,8 @@ class MessageTestCase(BasicMailerTestCase):
# When exception occurs, it should return NOT_SENT
self.assertEqual(result, NOT_SENT)
@mock.patch('mailer.models.send')
@mock.patch('django.conf.settings.SEND_FROM_ASSOCIATION_EMAIL', False)
@mock.patch("mailer.models.send")
@mock.patch("django.conf.settings.SEND_FROM_ASSOCIATION_EMAIL", False)
def test_submit_with_sender_no_association_email(self, mock_send):
# Mock successful email sending
mock_send.return_value = PARTLY_SENT
@ -187,23 +193,23 @@ class MessageTestCase(BasicMailerTestCase):
self.assertTrue(self.message.sent)
self.assertEqual(result, SENT)
@mock.patch('mailer.models.send')
@mock.patch('django.conf.settings.SEND_FROM_ASSOCIATION_EMAIL', False)
@mock.patch("mailer.models.send")
@mock.patch("django.conf.settings.SEND_FROM_ASSOCIATION_EMAIL", False)
def test_submit_with_reply_to_logic(self, mock_send):
# Mock successful email sending
mock_send.return_value = SENT
# Create a sender with internal email capability
sender_with_internal = Member.objects.create(
prename='Internal',
lastname='Sender',
prename="Internal",
lastname="Sender",
birth_date=timezone.now().date(),
email='internal@test.com',
gender=DIVERSE
email="internal@test.com",
gender=DIVERSE,
)
# Mock has_internal_email to return True
with mock.patch.object(sender_with_internal, 'has_internal_email', return_value=True):
with mock.patch.object(sender_with_internal, "has_internal_email", return_value=True):
# Test submit method
result = self.message.submit(sender=sender_with_internal)
@ -212,14 +218,16 @@ class MessageTestCase(BasicMailerTestCase):
self.assertTrue(self.message.sent)
self.assertEqual(result, SENT)
@mock.patch('mailer.models.send')
@mock.patch('os.remove')
@mock.patch("mailer.models.send")
@mock.patch("os.remove")
def test_submit_with_attachments(self, mock_os_remove, mock_send):
# Mock successful email sending
mock_send.return_value = SENT
# Create an attachment with a file
test_file = SimpleUploadedFile("test_file.pdf", b"file_content", content_type="application/pdf")
test_file = SimpleUploadedFile(
"test_file.pdf", b"file_content", content_type="application/pdf"
)
attachment = Attachment.objects.create(msg=self.message, f=test_file)
# Test submit method
@ -236,14 +244,14 @@ class MessageTestCase(BasicMailerTestCase):
with self.assertRaises(Attachment.DoesNotExist):
attachment.refresh_from_db()
@mock.patch('mailer.models.send')
@mock.patch("mailer.models.send")
def test_submit_with_association_email_enabled(self, mock_send):
"""Test submit method when SEND_FROM_ASSOCIATION_EMAIL is True and sender has association_email"""
mock_send.return_value = SENT
# Mock settings to enable association email sending
with mock.patch.object(settings, 'SEND_FROM_ASSOCIATION_EMAIL', True):
result = self.message.submit(sender=self.sender)
with mock.patch.object(settings, "SEND_FROM_ASSOCIATION_EMAIL", True):
self.message.submit(sender=self.sender)
# Check that send was called with sender's association email
self.assertTrue(mock_send.called)
@ -256,16 +264,13 @@ class MessageTestCase(BasicMailerTestCase):
class AttachmentTestCase(BasicMailerTestCase):
def setUp(self):
super().setUp()
self.message = Message.objects.create(
subject='Test Message',
content='Test content'
)
self.message = Message.objects.create(subject="Test Message", content="Test content")
self.attachment = Attachment.objects.create(msg=self.message)
def test_str_with_file(self):
# Simulate a file name
self.attachment.f.name = 'attachments/test_document.pdf'
self.assertEqual(str(self.attachment), 'test_document.pdf')
self.attachment.f.name = "attachments/test_document.pdf"
self.assertEqual(str(self.attachment), "test_document.pdf")
def test_str_without_file(self):
self.assertEqual(str(self.attachment), _('Empty'))
self.assertEqual(str(self.attachment), _("Empty"))

@ -1,23 +1,26 @@
from django.test import TestCase
from django.conf import settings
from django.contrib.auth.models import User
from mailer.rules import is_creator
from django.test import TestCase
from mailer.models import Message
from members.models import Member, MALE
from mailer.rules import is_creator
from members.models import MALE
from members.models import Member
class MailerRulesTestCase(TestCase):
def setUp(self):
self.user1 = User.objects.create_user(username="alice", password="test123")
self.member1 = Member.objects.create(
prename="Alice", lastname="Smith", birth_date="1990-01-01",
email=settings.TEST_MAIL, gender=MALE, user=self.user1
prename="Alice",
lastname="Smith",
birth_date="1990-01-01",
email=settings.TEST_MAIL,
gender=MALE,
user=self.user1,
)
self.message = Message.objects.create(
subject="Test Message",
content="Test content",
created_by=self.member1
subject="Test Message", content="Test content", created_by=self.member1
)
def test_is_creator_returns_true_when_user_created_message(self):

@ -1,27 +1,33 @@
from unittest import skip, mock
from django.test import TestCase
from django.conf import settings
from django.utils import timezone
from django.core.exceptions import ValidationError
from django.utils.translation import gettext as _
from django.core.files.uploadedfile import SimpleUploadedFile
from members.models import Member, Group, DIVERSE, Freizeit, MemberNoteList, GEMEINSCHAFTS_TOUR, MUSKELKRAFT_ANREISE
from mailer.models import EmailAddress, EmailAddressForm, Message, MessageForm, Attachment
from mailer.mailutils import SENT, NOT_SENT, PARTLY_SENT
from mailer.models import EmailAddress
from members.models import DIVERSE
from members.models import Group
from members.models import Member
class BasicMailerTestCase(TestCase):
def setUp(self):
self.mygroup = Group.objects.create(name="My Group")
self.fritz = Member.objects.create(prename="Fritz", lastname="Wulter", birth_date=timezone.now().date(),
email='fritz@foo.com', gender=DIVERSE)
self.fritz = Member.objects.create(
prename="Fritz",
lastname="Wulter",
birth_date=timezone.now().date(),
email="fritz@foo.com",
gender=DIVERSE,
)
self.fritz.group.add(self.mygroup)
self.fritz.save()
self.fritz.generate_key()
self.paul = Member.objects.create(prename="Paul", lastname="Wulter", birth_date=timezone.now().date(),
email='paul@foo.com', gender=DIVERSE)
self.paul = Member.objects.create(
prename="Paul",
lastname="Wulter",
birth_date=timezone.now().date(),
email="paul@foo.com",
gender=DIVERSE,
)
self.em = EmailAddress.objects.create(name='foobar')
self.em = EmailAddress.objects.create(name="foobar")
self.em.to_groups.add(self.mygroup)
self.em.to_members.add(self.paul)

@ -1,65 +1,59 @@
from unittest import skip, mock
from http import HTTPStatus
from django.urls import reverse
from django.test import TestCase
from django.conf import settings
from django.utils import timezone
from django.core.exceptions import ValidationError
from django.utils.translation import gettext as _
from django.core.files.uploadedfile import SimpleUploadedFile
from members.models import Member, Group, DIVERSE, Freizeit, MemberNoteList, GEMEINSCHAFTS_TOUR, MUSKELKRAFT_ANREISE
from mailer.models import EmailAddress, EmailAddressForm, Message, MessageForm, Attachment
from mailer.mailutils import SENT, NOT_SENT, PARTLY_SENT
from .utils import BasicMailerTestCase
class IndexTestCase(BasicMailerTestCase):
def test_index(self):
url = reverse('mailer:index')
url = reverse("mailer:index")
response = self.client.get(url)
self.assertEqual(response.status_code, HTTPStatus.FOUND)
class UnsubscribeTestCase(BasicMailerTestCase):
def test_unsubscribe(self):
url = reverse('mailer:unsubscribe')
url = reverse("mailer:unsubscribe")
response = self.client.get(url)
self.assertEqual(response.status_code, HTTPStatus.OK)
self.assertContains(response, _("Here you can unsubscribe from the newsletter"))
def test_unsubscribe_key_invalid(self):
url = reverse('mailer:unsubscribe')
url = reverse("mailer:unsubscribe")
# invalid key
response = self.client.get(url, data={'key': 'invalid'})
response = self.client.get(url, data={"key": "invalid"})
self.assertEqual(response.status_code, HTTPStatus.OK)
self.assertContains(response, _("Can't verify this link. Try again!"))
# expired key
self.fritz.unsubscribe_expire = timezone.now()
self.fritz.save()
response = self.client.get(url, data={'key': self.fritz.unsubscribe_key})
response = self.client.get(url, data={"key": self.fritz.unsubscribe_key})
self.assertEqual(response.status_code, HTTPStatus.OK)
self.assertContains(response, _("Can't verify this link. Try again!"))
def test_unsubscribe_key(self):
url = reverse('mailer:unsubscribe')
response = self.client.get(url, data={'key': self.fritz.unsubscribe_key})
url = reverse("mailer:unsubscribe")
response = self.client.get(url, data={"key": self.fritz.unsubscribe_key})
self.assertEqual(response.status_code, HTTPStatus.OK)
self.assertContains(response, _("Successfully unsubscribed from the newsletter for "))
def test_unsubscribe_post_incomplete(self):
url = reverse('mailer:unsubscribe')
response = self.client.post(url, data={'post': True})
url = reverse("mailer:unsubscribe")
response = self.client.post(url, data={"post": True})
self.assertEqual(response.status_code, HTTPStatus.OK)
self.assertContains(response, _("Please fill in every field"))
response = self.client.post(url, data={'post': True, 'email': 'foobar@notexisting.com'})
response = self.client.post(url, data={"post": True, "email": "foobar@notexisting.com"})
self.assertEqual(response.status_code, HTTPStatus.OK)
self.assertContains(response, _("Please fill in every field"))
def test_unsubscribe_post(self):
url = reverse('mailer:unsubscribe')
response = self.client.post(url, data={'post': True, 'email': self.fritz.email})
url = reverse("mailer:unsubscribe")
response = self.client.post(url, data={"post": True, "email": self.fritz.email})
self.assertEqual(response.status_code, HTTPStatus.OK)
self.assertContains(response, _("Sent confirmation mail to"))

@ -5,6 +5,7 @@ from django.utils.translation import gettext_lazy as _
from .models import MaterialCategory
from .models import MaterialPart
from .models import Ownership
# from easy_select2 import apply_select2

@ -1,61 +1,98 @@
# Generated by Django 4.0.1 on 2023-03-29 20:39
from django.db import migrations, models
import django.db.models.deletion
from django.db import migrations
from django.db import models
class Migration(migrations.Migration):
replaces = [('material', '0001_initial'), ('material', '0002_auto_20171011_2045')]
replaces = [("material", "0001_initial"), ("material", "0002_auto_20171011_2045")]
dependencies = [
('members', '0001_initial'),
("members", "0001_initial"),
]
operations = [
migrations.CreateModel(
name='MaterialPart',
name="MaterialPart",
fields=[
('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('name', models.CharField(max_length=30, verbose_name='name')),
('description', models.CharField(default='', max_length=140, verbose_name='description')),
('quantity', models.IntegerField(default=0, verbose_name='quantity')),
('buy_date', models.DateField(verbose_name='purchase date')),
('lifetime', models.DecimalField(decimal_places=0, max_digits=3, verbose_name='lifetime (years)')),
('photo', models.ImageField(blank=True, upload_to='images', verbose_name='photo')),
(
"id",
models.AutoField(
auto_created=True, primary_key=True, serialize=False, verbose_name="ID"
),
),
("name", models.CharField(max_length=30, verbose_name="name")),
(
"description",
models.CharField(default="", max_length=140, verbose_name="description"),
),
("quantity", models.IntegerField(default=0, verbose_name="quantity")),
("buy_date", models.DateField(verbose_name="purchase date")),
(
"lifetime",
models.DecimalField(
decimal_places=0, max_digits=3, verbose_name="lifetime (years)"
),
),
("photo", models.ImageField(blank=True, upload_to="images", verbose_name="photo")),
],
options={
'verbose_name': 'material part',
'verbose_name_plural': 'material parts',
"verbose_name": "material part",
"verbose_name_plural": "material parts",
},
),
migrations.CreateModel(
name='Ownership',
name="Ownership",
fields=[
('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('count', models.IntegerField(default=1, verbose_name='count')),
('material', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='material.materialpart')),
('owner', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='members.member', verbose_name='owner')),
(
"id",
models.AutoField(
auto_created=True, primary_key=True, serialize=False, verbose_name="ID"
),
),
("count", models.IntegerField(default=1, verbose_name="count")),
(
"material",
models.ForeignKey(
on_delete=django.db.models.deletion.CASCADE, to="material.materialpart"
),
),
(
"owner",
models.ForeignKey(
on_delete=django.db.models.deletion.CASCADE,
to="members.member",
verbose_name="owner",
),
),
],
options={
'verbose_name': 'ownership',
'verbose_name_plural': 'ownerships',
"verbose_name": "ownership",
"verbose_name_plural": "ownerships",
},
),
migrations.CreateModel(
name='MaterialCategory',
name="MaterialCategory",
fields=[
('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('name', models.CharField(max_length=40, verbose_name='Name')),
(
"id",
models.AutoField(
auto_created=True, primary_key=True, serialize=False, verbose_name="ID"
),
),
("name", models.CharField(max_length=40, verbose_name="Name")),
],
options={
'verbose_name': 'Material category',
'verbose_name_plural': 'Material categories',
"verbose_name": "Material category",
"verbose_name_plural": "Material categories",
},
),
migrations.AddField(
model_name='materialpart',
name='material_cat',
field=models.ManyToManyField(default=None, to='material.MaterialCategory', verbose_name='Material category'),
model_name="materialpart",
name="material_cat",
field=models.ManyToManyField(
default=None, to="material.MaterialCategory", verbose_name="Material category"
),
),
]

File diff suppressed because it is too large Load Diff

@ -0,0 +1,139 @@
from django.db.models import F, When, IntegerField, Case, Subquery, OuterRef, Count
from datetime import timedelta
from django.utils import timezone
from .constants import *
from .activity import ActivityCategory
from .group import Group
from .member import Member, MemberManager
from .emergency_contact import EmergencyContact
from .member_unconfirmed import MemberUnconfirmedProxy, MemberUnconfirmedManager
from .invitation import InvitationToGroup
from .waiting_list import MemberWaitingList
from .member_on_list import NewMemberOnList
from .excursion import Freizeit
from .member_note_list import MemberNoteList
from .klettertreff import Klettertreff, KlettertreffAttendee
from .registration import RegistrationPassword
from .ljp import LJPProposal, Intervention
from .permission import PermissionMember, PermissionGroup
from .training import TrainingCategory, MemberTraining
from .base import Contact, ContactWithPhoneNumber, Person, gen_key
__all__ = [
'ActivityCategory',
'Group',
'Member', 'MemberManager', 'Contact', 'ContactWithPhoneNumber', 'Person',
'EmergencyContact',
'MemberUnconfirmedProxy', 'MemberUnconfirmedManager',
'InvitationToGroup',
'MemberWaitingList',
'NewMemberOnList',
'Freizeit',
'MemberNoteList',
'Klettertreff', 'KlettertreffAttendee',
'RegistrationPassword',
'LJPProposal', 'Intervention',
'PermissionMember', 'PermissionGroup',
'TrainingCategory', 'MemberTraining', 'gen_key',
]
def annotate_activity_score(queryset):
one_year_ago = timezone.now() - timedelta(days=365)
queryset = queryset.annotate(
_jugendleiter_freizeit_score_calc=Subquery(
Freizeit.objects.filter(jugendleiter=OuterRef('pk'),
date__gte=one_year_ago)
.values('jugendleiter')
.annotate(cnt=Count('pk', distinct=True))
.values('cnt'),
output_field=IntegerField()
),
# better solution but does not work in production apparently
#_jugendleiter_freizeit_score=Sum(Case(
# When(
# freizeit__date__gte=one_year_ago,
# then=1),
# default=0,
# output_field=IntegerField()
# ),
# distinct=True),
_jugendleiter_klettertreff_score_calc=Subquery(
Klettertreff.objects.filter(jugendleiter=OuterRef('pk'),
date__gte=one_year_ago)
.values('jugendleiter')
.annotate(cnt=Count('pk', distinct=True))
.values('cnt'),
output_field=IntegerField()
),
# better solution but does not work in production apparently
#_jugendleiter_klettertreff_score=Sum(Case(
# When(
# klettertreff__date__gte=one_year_ago,
# then=1),
# default=0,
# output_field=IntegerField()
# ),
# distinct=True),
_freizeit_score_calc=Subquery(
Freizeit.objects.filter(membersonlist__member=OuterRef('pk'),
date__gte=one_year_ago)
.values('membersonlist__member')
.annotate(cnt=Count('pk', distinct=True))
.values('cnt'),
output_field=IntegerField()
),
_klettertreff_score_calc=Subquery(
KlettertreffAttendee.objects.filter(member=OuterRef('pk'),
klettertreff__date__gte=one_year_ago)
.values('member')
.annotate(cnt=Count('pk', distinct=True))
.values('cnt'),
output_field=IntegerField()))
queryset = queryset.annotate(
_jugendleiter_freizeit_score=Case(
When(
_jugendleiter_freizeit_score_calc=None,
then=0
),
default=F('_jugendleiter_freizeit_score_calc'),
output_field=IntegerField()),
_jugendleiter_klettertreff_score=Case(
When(
_jugendleiter_klettertreff_score_calc=None,
then=0
),
default=F('_jugendleiter_klettertreff_score_calc'),
output_field=IntegerField()),
_klettertreff_score=Case(
When(
_klettertreff_score_calc=None,
then=0
),
default=F('_klettertreff_score_calc'),
output_field=IntegerField()),
_freizeit_score=Case(
When(
_freizeit_score_calc=None,
then=0
),
default=F('_freizeit_score_calc'),
output_field=IntegerField()))
queryset = queryset.annotate(
#_activity_score=F('_jugendleiter_freizeit_score')
_activity_score=(F('_klettertreff_score') + 3 * F('_freizeit_score')
+ F('_jugendleiter_klettertreff_score') + 3 * F('_jugendleiter_freizeit_score'))
)
return queryset
def confirm_mail_by_key(key):
matching_unconfirmed = MemberUnconfirmedProxy.objects.filter(confirm_mail_key=key) \
| MemberUnconfirmedProxy.objects.filter(confirm_alternative_mail_key=key)
matching_waiter = MemberWaitingList.objects.filter(confirm_mail_key=key)
matching_emergency_contact = EmergencyContact.objects.filter(confirm_mail_key=key)
matches = list(matching_unconfirmed) + list(matching_waiter) + list(matching_emergency_contact)
# if not exactly one match, return None. The case > 1 match should not occur!
if len(matches) != 1:
return None
person = matches[0]
return person, person.confirm_mail(key)

@ -0,0 +1,26 @@
from django.db import models
from django.utils.translation import gettext_lazy as _
class ActivityCategory(models.Model):
"""
Describes one kind of activity
"""
LJP_CATEGORIES = [('Winter', _('winter')),
('Skibergsteigen', _('ski mountaineering')),
('Klettern', _('climbing')),
('Bergsteigen', _('mountaineering')),
('Theorie', _('theory')),
('Sonstiges', _('others'))]
name = models.CharField(max_length=20, verbose_name=_('Name'))
ljp_category = models.CharField(choices=LJP_CATEGORIES,
verbose_name=_('LJP category'),
max_length=20,
help_text=_('The official category for LJP applications associated with this activity.'))
description = models.TextField(_('Description'))
class Meta:
verbose_name = _('Activity')
verbose_name_plural = _('Activities')
def __str__(self):
return self.name

@ -0,0 +1,139 @@
from django.db import models
from django.utils.translation import gettext_lazy as _
from django.utils.html import format_html
from django.conf import settings
from contrib.models import CommonModel
from mailer.mailutils import send as send_mail, get_mail_confirmation_link
from datetime import datetime, date
from dateutil.relativedelta import relativedelta
import uuid
from .constants import MALE, FEMALE, DIVERSE
def gen_key():
return uuid.uuid4().hex
class Contact(CommonModel):
"""
Represents an abstract person with only absolutely necessary contact information.
"""
prename = models.CharField(max_length=20, verbose_name=_('prename'))
lastname = models.CharField(max_length=20, verbose_name=_('last name'))
email = models.EmailField(max_length=100, default="")
confirmed_mail = models.BooleanField(default=False, verbose_name=_('Email confirmed'))
confirm_mail_key = models.CharField(max_length=32, default="")
class Meta(CommonModel.Meta):
abstract = True
def __str__(self):
"""String representation"""
return self.name
@property
def name(self):
"""Returning whole name (prename + lastname)"""
return "{0} {1}".format(self.prename, self.lastname)
def phone_number_tel_link(self):
"""Returns the phone number as tel link."""
return format_html('<a href="tel:{tel}">{tel}</a>'.format(tel=self.phone_number))
phone_number_tel_link.short_description = _('phone number')
phone_number_tel_link.admin_order_field = 'phone_number'
def email_mailto_link(self):
"""Returns the emails as a mailto link."""
return format_html('<a href="mailto:{email}">{email}</a>'.format(email=self.email))
email_mailto_link.short_description = 'Email'
email_mailto_link.admin_order_field = 'email'
@property
def email_fields(self):
"""Returns all tuples of emails and confirmation data related to this contact.
By default, this is only the principal email field, but extending classes can add
more email fields and then override this method."""
return [('email', 'confirmed_mail', 'confirm_mail_key')]
def request_mail_confirmation(self, rerequest=True):
"""Request mail confirmation for every mail field. If `rerequest` is false, then only
confirmation is requested for currently unconfirmed emails.
Returns true if any mail confirmation was requested, false otherwise."""
requested_confirmation = False
for email_fd, confirmed_email_fd, confirm_mail_key_fd in self.email_fields:
if getattr(self, confirmed_email_fd) and not rerequest:
continue
if not getattr(self, email_fd): # pragma: no cover
# Only reachable with misconfigured `email_fields`
continue
requested_confirmation = True
setattr(self, confirmed_email_fd, False)
confirm_mail_key = uuid.uuid4().hex
setattr(self, confirm_mail_key_fd, confirm_mail_key)
send_mail(_('Email confirmation needed'),
settings.CONFIRM_MAIL_TEXT.format(name=self.prename,
link=get_mail_confirmation_link(confirm_mail_key),
whattoconfirm='deiner Emailadresse'),
settings.DEFAULT_SENDING_MAIL,
getattr(self, email_fd))
self.save()
return requested_confirmation
def confirm_mail(self, key):
for email_fd, confirmed_email_fd, confirm_mail_key_fd in self.email_fields:
if getattr(self, confirm_mail_key_fd) == key:
setattr(self, confirmed_email_fd, True)
setattr(self, confirm_mail_key_fd, "")
self.save()
return getattr(self, email_fd)
return None
def send_mail(self, subject, content, cc=None):
send_mail(subject, content, settings.DEFAULT_SENDING_MAIL,
[getattr(self, email_fd) for email_fd, _, _ in self.email_fields], cc=cc)
class ContactWithPhoneNumber(Contact):
"""
A contact with a phone number.
"""
phone_number = models.CharField(max_length=100, verbose_name=_('phone number'))
class Meta(CommonModel.Meta):
abstract = True
class Person(Contact):
"""
Represents an abstract person. Not necessarily a member of any group.
"""
birth_date = models.DateField(_('birth date'), null=True, blank=True) # to determine the age
gender_choices = ((MALE, 'Männlich'),
(FEMALE, 'Weiblich'),
(DIVERSE, 'Divers'))
gender = models.IntegerField(choices=gender_choices,
verbose_name=_('Gender'))
comments = models.TextField(_('comments'), default='', blank=True)
class Meta(CommonModel.Meta):
abstract = True
def age(self):
"""Age of member"""
return relativedelta(datetime.today(), self.birth_date).years
age.admin_order_field = 'birth_date'
age.short_description = _('age')
def age_at(self, date: date):
"""Age of member at a given date"""
return relativedelta(date.replace(tzinfo=None), self.birth_date).years
@property
def birth_date_str(self):
if self.birth_date is None:
return "---"
return self.birth_date.strftime("%d.%m.%Y")
@property
def gender_str(self):
return self.gender_choices[self.gender][1]

@ -0,0 +1,15 @@
from django.utils.translation import gettext_lazy as _
GEMEINSCHAFTS_TOUR = MUSKELKRAFT_ANREISE = MALE = 0
FUEHRUNGS_TOUR = OEFFENTLICHE_ANREISE = FEMALE = 1
AUSBILDUNGS_TOUR = FAHRGEMEINSCHAFT_ANREISE = DIVERSE = 2
WEEKDAYS = (
(0, _('Monday')),
(1, _('Tuesday')),
(2, _('Wednesday')),
(3, _('Thursday')),
(4, _('Friday')),
(5, _('Saturday')),
(6, _('Sunday')),
)

@ -0,0 +1,27 @@
from django.db import models
from django.utils.translation import gettext_lazy as _
from contrib.models import CommonModel
from members.rules import may_view, may_change, may_delete
from contrib.rules import has_global_perm
from .base import ContactWithPhoneNumber
from .member import Member
class EmergencyContact(ContactWithPhoneNumber):
"""
Emergency contact of a member
"""
member = models.ForeignKey(Member, verbose_name=_('Member'), on_delete=models.CASCADE)
email = models.EmailField(max_length=100, default='', blank=True)
def __str__(self):
return str(self.member)
class Meta(CommonModel.Meta):
verbose_name = _('Emergency contact')
verbose_name_plural = _('Emergency contacts')
rules_permissions = {
'add_obj': may_change | has_global_perm('members.change_global_member'),
'view_obj': may_view | has_global_perm('members.view_global_member'),
'change_obj': may_change | has_global_perm('members.change_global_member'),
'delete_obj': may_delete | has_global_perm('members.delete_global_member'),
}

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

Loading…
Cancel
Save