Merge branch 'main' into MK/finance_qr_codes

pull/94/head
Christian Merten 1 year ago
commit 106f0b56d9
Signed by: christian.merten
GPG Key ID: D953D69721B948B3

@ -2,29 +2,105 @@
[![Build Status](https://jenkins.merten.dev/buildStatus/icon?job=gitea%2Fkompass%2Fmain)](https://jenkins.merten.dev/job/gitea/job/kompass/job/main/) [![Build Status](https://jenkins.merten.dev/buildStatus/icon?job=gitea%2Fkompass%2Fmain)](https://jenkins.merten.dev/job/gitea/job/kompass/job/main/)
This repository has the purpose to develop a webapplication that can be used by Kompass is an administration platform designed for local sections of the Young German Alpine Club. It provides
JDAV to send newsletters, manage user lists and keep material lists up to date. tools to contact and (automatically) manage members, groups, material, excursions and statements.
As this repository is also meant to be a base for exchange during development, feel free
to contribute ideas in form of edits to this README, issues, landmarks, projects, wiki entries, ...
# Docker For more details on the features, see the (German) [documentation](https://jdav-hd.de/static/docs/index.html).
In the `docker` subfolder, there are `docker-compose.yaml`s for development and production use. For the development # Contributing
version, no further setup is needed.
# Production Any form of contribution is appreciated. If you found a bug or have a feature request, please file an
[issue](https://git.jdav-hd.merten.dev/digitales/kompass/issues). If you want to help with the documentation or
want to contribute code, please open a [pull request](https://git.jdav-hd.merten.dev/digitales/kompass/pulls).
In production, the docker setup needs an external database. The exact access credentials are configured in the respective The following is a short description of the development setup and an explanation of the various
docker.env files. branches.
# Useful stuff ## Development setup
## Reset database for certain app The project is run with `docker` and all related files are in the `docker/` subfolder. Besides the actual Kompass
application, a database (postgresql) and a broker (redis) are setup and run in the docker container. No
external services are needed for running the development container.
The following can be useful in case that automatic migrations throw errors. ### Initial installation
1. delete everything in the migrations folder except for __init__.py. A working `docker` setup (with `docker compose` support) is required. For installation instructions see the
2. drop into my MySQL console and do: DELETE FROM django_migrations WHERE app='my_app' [docker manual](https://docs.docker.com/engine/install/).
3. while at the MySQL console, drop all of the tables associated with my_app.
4. re-run ./manage.py makemigrations my_app - this generates a 0001_initial.py file in my migrations folder. 1. Clone the repository and change into the directory of the repository.
5. run ./manage migrate my_app - I expect this command to re-build all my tables, but instead it says: "No migrations to apply."
2. Fetch submodules
```bash
git submodule update --init
```
3. Prepare development environment: to allow automatic rebuilding upon changes in the source,
the owner of the `/app/jdav_web` directory in the docker container must agree with
your user. For this, make sure that the output of `echo UID` and `echo UID` is not empty. Then run
```bash
export GID=${GID}
export UID=${UID}
```
4. Start docker
```bash
cd docker/development
docker compose up
```
This runs the docker in your current shell, which is useful to see any log output. If you want to run
the development server in the background instead, use `docker compose up -d`.
During the initial run, the container is built and all dependencies are installed which can take a few minutes.
After successful installation, the Kompass initialization runs, which in particular sets up all tables in the
database.
5. Setup admin user: in a separate shell, while the docker container is running, run
```bash
cd docker/development
docker compose exec master bash -c "cd jdav_web && python3 manage.py createsuperuser"
```
This creates an admin user for the administration interface.
### Development
If the initial installation was successful, you can start developing. Changes to files cause an automatic
reload of the development server. If you need to generate and perform database migrations or generate locale files,
use
```
cd docker/development
docker compose exec master bash
cd jdav_web
```
This starts a shell in the container, where you can execute any django maintenance commands via
`python3 manage.py <command>`. For more information, see the [django documentation](https://docs.djangoproject.com/en/4.0/ref/django-admin).
### Testing
To run the tests, you can use the docker setup under `docker/test`.
### Known Issues
- If the `UID` and `GID` variables are not setup properly, you will encounter the following error message
after running `docker compose up`.
```bash
=> ERROR [master 6/7] RUN groupadd -g fritze && useradd -g -u -m -d /app fritze 0.2s
------
> [master 6/7] RUN groupadd -g fritze && useradd -g -u -m -d /app fritze:
0.141 groupadd: invalid group ID 'fritze'
------
failed to solve: process "/bin/sh -c groupadd -g $GID $USER && useradd -g $GID -u $UID -m -d /app $USER" did not complete successfully: exit code: 3
```
In this case repeat step 3 above.
## Organization and branches
The stable development happens on the `main` branch for which only maintainers have write access. Any pull request
should hence be targeted at `main`. Regularly, the production instances are updated to the latest `main` version,
in particular these are considered to be stable.
If you have standard write access to the repository, feel free to create new branches. To make organization
easier, please indicate your username in the branch name.
The `testing` branch is deployed on the development instances. No development should happen there, this branch
is regularly reset to the `main` branch.

@ -26,7 +26,7 @@ GROUP_TIME_AVAILABLE_TEXT = """Die Gruppenstunde findet jeden {weekday} von {sta
GROUP_TIME_UNAVAILABLE_TEXT = """Bitte erfrage die Gruppenzeiten bei der Gruppenleitung ({contact_email}).""" GROUP_TIME_UNAVAILABLE_TEXT = """Bitte erfrage die Gruppenzeiten bei der Gruppenleitung ({contact_email})."""
INVITE_TEXT = """Hallo {name}, INVITE_TEXT = """Hallo {{name}},
wir haben gute Neuigkeiten für dich. Es ist ein Platz in der Jugendgruppe {group_name} {group_link}freigeworden. wir haben gute Neuigkeiten für dich. Es ist ein Platz in der Jugendgruppe {group_name} {group_link}freigeworden.
{group_time} {group_time}
@ -34,11 +34,9 @@ wir haben gute Neuigkeiten für dich. Es ist ein Platz in der Jugendgruppe {grou
Bitte kontaktiere die Gruppenleitung ({contact_email}) für alle weiteren Absprachen. Bitte kontaktiere die Gruppenleitung ({contact_email}) für alle weiteren Absprachen.
Wenn du nach der Schnupperstunde beschließt der Gruppe beizutreten, benötigen wir noch ein paar Wenn du nach der Schnupperstunde beschließt der Gruppe beizutreten, benötigen wir noch ein paar
Informationen und deine Anmeldebestätigung von dir. Die lädst du herunter Informationen und eine schriftliche Anmeldebestätigung von dir. Das kannst du alles über folgenden Link erledigen:
(siehe %(REGISTRATION_FORM_DOWNLOAD_LINK)s ), lässt sie von deinen Eltern ausfüllen, unterschreiben
und lädst ein Foto davon in unserem Anmeldeformular hoch. Das kannst du alles über folgenden Link erledigen:
{link} {{link}}
Du siehst dort auch die Daten, die du bei deiner Eintragung auf die Warteliste angegeben hast. Bitte Du siehst dort auch die Daten, die du bei deiner Eintragung auf die Warteliste angegeben hast. Bitte
überprüfe, ob die Daten noch stimmen und ändere sie bei Bedarf ab. überprüfe, ob die Daten noch stimmen und ändere sie bei Bedarf ab.
@ -46,7 +44,7 @@ Du siehst dort auch die Daten, die du bei deiner Eintragung auf die Warteliste a
Falls du zu dem obigen Termin keine Zeit hast oder dich ganz von der Warteliste abmelden möchtest, Falls du zu dem obigen Termin keine Zeit hast oder dich ganz von der Warteliste abmelden möchtest,
lehne bitte diese Einladung unter folgendem Link ab: lehne bitte diese Einladung unter folgendem Link ab:
{invitation_reject_link} {{invitation_reject_link}}
Bei Fragen, wende dich gerne an %(RESPONSIBLE_MAIL)s. Bei Fragen, wende dich gerne an %(RESPONSIBLE_MAIL)s.

@ -579,6 +579,12 @@ class WaiterInviteForm(forms.Form):
label=_('Group')) label=_('Group'))
class WaiterInviteTextForm(forms.Form):
_selected_action = forms.CharField(widget=forms.MultipleHiddenInput)
text_template = forms.CharField(label=_('Invitation text'),
widget=forms.Textarea(attrs={'rows': 30, 'cols': 100}))
class InvitationToGroupAdmin(admin.TabularInline): class InvitationToGroupAdmin(admin.TabularInline):
model = InvitationToGroup model = InvitationToGroup
fields = ['group', 'date', 'status'] fields = ['group', 'date', 'status']
@ -612,7 +618,7 @@ class MemberWaitingListAdmin(CommonAdminMixin, admin.ModelAdmin):
'confirmed_mail', 'waiting_confirmed', 'sent_reminders') 'confirmed_mail', 'waiting_confirmed', 'sent_reminders')
search_fields = ('prename', 'lastname', 'email') search_fields = ('prename', 'lastname', 'email')
list_filter = ['confirmed_mail', 'gender', InvitedToGroupFilter] list_filter = ['confirmed_mail', 'gender', InvitedToGroupFilter]
actions = ['ask_for_registration', 'ask_for_wait_confirmation'] actions = ['ask_for_registration_action', 'ask_for_wait_confirmation']
inlines = [InvitationToGroupAdmin] inlines = [InvitationToGroupAdmin]
readonly_fields= ['application_date', 'sent_reminders'] readonly_fields= ['application_date', 'sent_reminders']
@ -627,38 +633,6 @@ class MemberWaitingListAdmin(CommonAdminMixin, admin.ModelAdmin):
_("Successfully asked %(name)s to confirm their waiting status.") % {'name': waiter.name}) _("Successfully asked %(name)s to confirm their waiting status.") % {'name': waiter.name})
ask_for_wait_confirmation.short_description = _('Ask selected waiters to confirm their waiting status') ask_for_wait_confirmation.short_description = _('Ask selected waiters to confirm their waiting status')
def ask_for_registration(self, request, queryset):
"""Asks the waiting person to register with all required data."""
if "apply" in request.POST:
try:
group = Group.objects.get(pk=request.POST['group'])
except Group.DoesNotExist:
messages.error(request,
_("An error occurred while trying to invite said members. Please try again."))
return HttpResponseRedirect(request.get_full_path())
if not group.contact_email:
messages.error(request,
_('The selected group does not have a contact email. Please first set a contact email and then try again.'))
return HttpResponseRedirect(request.get_full_path())
for waiter in queryset:
waiter.invited_for_group = group
waiter.save()
waiter.invite_to_group(group)
messages.success(request,
_("Successfully invited %(name)s to %(group)s.") % {'name': waiter.name, 'group': waiter.invited_for_group.name})
return HttpResponseRedirect(request.get_full_path())
context = dict(self.admin_site.each_context(request),
title=_('Select group for invitation'),
opts=self.opts,
waiters=queryset.all(),
form=WaiterInviteForm(initial={'_selected_action': queryset.values_list('id', flat=True)}))
return render(request,
'admin/invite_selected_for_group.html',
context=context)
ask_for_registration.short_description = _('Offer waiter a place in a group.')
def response_change(self, request, waiter): def response_change(self, request, waiter):
ret = super(MemberWaitingListAdmin, self).response_change(request, waiter) ret = super(MemberWaitingListAdmin, self).response_change(request, waiter)
if "_invite" in request.POST: if "_invite" in request.POST:
@ -690,8 +664,19 @@ class MemberWaitingListAdmin(CommonAdminMixin, admin.ModelAdmin):
queryset = super().get_queryset(request) queryset = super().get_queryset(request)
return queryset.prefetch_related('invitationtogroup_set') return queryset.prefetch_related('invitationtogroup_set')
def ask_for_registration_action(self, request, queryset):
return self.invite_view(request, queryset)
ask_for_registration_action.short_description = _('Offer waiter a place in a group.')
def invite_view(self, request, object_id): def invite_view(self, request, object_id):
waiter = MemberWaitingList.objects.get(pk=object_id) if type(object_id) == str:
waiter = MemberWaitingList.objects.get(pk=object_id)
queryset = [waiter]
id_list = [waiter.pk]
else:
waiter = None
queryset = object_id
id_list = queryset.values_list('id', flat=True)
if "apply" in request.POST: if "apply" in request.POST:
try: try:
@ -705,22 +690,49 @@ class MemberWaitingListAdmin(CommonAdminMixin, admin.ModelAdmin):
messages.error(request, messages.error(request,
_('The selected group does not have a contact email. Please first set a contact email and then try again.')) _('The selected group does not have a contact email. Please first set a contact email and then try again.'))
return HttpResponseRedirect(request.get_full_path()) return HttpResponseRedirect(request.get_full_path())
context = dict(self.admin_site.each_context(request),
title=_('Select group for invitation'),
opts=self.opts,
group=group,
queryset=queryset,
form=WaiterInviteTextForm(initial={
'_selected_action': id_list,
'text_template': group.get_invitation_text_template()
}))
if waiter:
context = dict(context, object=waiter, waiter=waiter)
return render(request,
'admin/invite_for_group_text.html',
context=context)
if "send" in request.POST:
try:
group = Group.objects.get(pk=request.POST['group'])
text_template = request.POST['text_template']
except (Group.DoesNotExist, KeyError):
messages.error(request,
_("An error occurred while trying to invite said members. Please try again."))
return HttpResponseRedirect(request.get_full_path())
for w in queryset:
w.invite_to_group(group, text_template=text_template)
messages.success(request,
_("Successfully invited %(name)s to %(group)s.") % {'name': w.name, 'group': w.invited_for_group.name})
waiter.invited_for_group = group if waiter:
waiter.save() return HttpResponseRedirect(reverse('admin:%s_%s_change' % (self.opts.app_label, self.opts.model_name),
waiter.invite_to_group(group) args=(object_id,)))
messages.success(request, else:
_("Successfully invited %(name)s to %(group)s.") % {'name': waiter.name, 'group': waiter.invited_for_group.name}) return HttpResponseRedirect(reverse('admin:%s_%s_changelist' % (self.opts.app_label, self.opts.model_name)))
return HttpResponseRedirect(reverse('admin:%s_%s_change' % (waiter._meta.app_label, waiter._meta.model_name),
args=(object_id,)))
context = dict(self.admin_site.each_context(request), context = dict(self.admin_site.each_context(request),
title=_('Select group for invitation'), title=_('Select group for invitation'),
opts=self.opts, opts=self.opts,
object=waiter, queryset=queryset,
waiter=waiter, form=WaiterInviteForm(initial={
form=WaiterInviteForm(initial={'_selected_action': [waiter.pk]})) '_selected_action': id_list
}))
if waiter:
context = dict(context, object=waiter, waiter=waiter)
return render(request, return render(request,
'admin/invite_for_group.html', 'admin/invite_for_group.html',
context=context) context=context)

File diff suppressed because it is too large Load Diff

@ -97,6 +97,24 @@ class Group(models.Model):
# return if the group has all relevant time slot information filled # return if the group has all relevant time slot information filled
return self.weekday and self.start_time and self.end_time return self.weekday and self.start_time and self.end_time
def get_invitation_text_template(self):
"""The text template used to invite waiters to this group. This contains
placeholders for the name of the waiter and personalized links."""
if self.show_website:
group_link = '({url}) '.format(url=prepend_base_url(reverse('startpage:gruppe_detail', args=[self.name])))
else:
group_link = ''
if self.has_time_info():
group_time = settings.GROUP_TIME_AVAILABLE_TEXT.format(weekday=WEEKDAYS[self.weekday][1],
start_time=self.start_time.strftime('%H:%M'),
end_time=self.end_time.strftime('%H:%M'))
else:
group_time = settings.GROUP_TIME_UNAVAILABLE_TEXT.format(contact_email=self.contact_email)
return settings.INVITE_TEXT.format(group_time=group_time,
group_name=self.name,
group_link=group_link,
contact_email=self.contact_email)
class MemberManager(models.Manager): class MemberManager(models.Manager):
def get_queryset(self): def get_queryset(self):
@ -945,27 +963,21 @@ class MemberWaitingList(Person):
except InvitationToGroup.DoesNotExist: except InvitationToGroup.DoesNotExist:
return False return False
def invite_to_group(self, group): def invite_to_group(self, group, text_template=None):
if group.show_website: """
group_link = '({url}) '.format(url=prepend_base_url(reverse('startpage:gruppe_detail', args=[group.name]))) Invite waiter to given group. Stores a new group invitation
else: and sends a personalized e-mail based on the passed template.
group_link = '' """
if group.has_time_info(): self.invited_for_group = group
group_time = settings.GROUP_TIME_AVAILABLE_TEXT.format(weekday=WEEKDAYS[group.weekday][1], self.save()
start_time=group.start_time.strftime('%H:%M'), if not text_template:
end_time=group.end_time.strftime('%H:%M')) text_template = group.get_invitation_text_template()
else:
group_time = settings.GROUP_TIME_UNAVAILABLE_TEXT.format(contact_email=group.contact_email)
invitation = InvitationToGroup(group=group, waiter=self) invitation = InvitationToGroup(group=group, waiter=self)
invitation.save() invitation.save()
self.send_mail(_("Invitation to trial group meeting"), self.send_mail(_("Invitation to trial group meeting"),
settings.INVITE_TEXT.format(name=self.prename, text_template.format(name=self.prename,
group_time=group_time, link=get_registration_link(invitation.key),
group_name=group.name, invitation_reject_link=get_invitation_reject_link(invitation.key)),
group_link=group_link,
contact_email=group.contact_email,
link=get_registration_link(invitation.key),
invitation_reject_link=get_invitation_reject_link(invitation.key)),
cc=group.contact_email.email) cc=group.contact_email.email)
def unregister(self): def unregister(self):

@ -17,7 +17,9 @@
<a href="{% url 'admin:index' %}">{% translate 'Home' %}</a> <a href="{% url 'admin:index' %}">{% translate 'Home' %}</a>
&rsaquo; <a href="{% url 'admin:app_list' app_label=opts.app_label %}">{{ opts.app_config.verbose_name }}</a> &rsaquo; <a href="{% url 'admin:app_list' app_label=opts.app_label %}">{{ opts.app_config.verbose_name }}</a>
&rsaquo; <a href="{% url opts|admin_urlname:'changelist' %}">{{ opts.verbose_name_plural|capfirst }}</a> &rsaquo; <a href="{% url opts|admin_urlname:'changelist' %}">{{ opts.verbose_name_plural|capfirst }}</a>
{% if object %}
&rsaquo; <a href="{% url opts|admin_urlname:'change' object.pk|admin_urlquote %}">{{ object|truncatewords:"18" }}</a> &rsaquo; <a href="{% url opts|admin_urlname:'change' object.pk|admin_urlquote %}">{{ object|truncatewords:"18" }}</a>
{% endif %}
&rsaquo; {% translate 'Invite to group' %} &rsaquo; {% translate 'Invite to group' %}
</div> </div>
{% endblock %} {% endblock %}
@ -25,18 +27,28 @@
{% block content %} {% block content %}
<h2>{% translate "Invite to a group" %}</h2> <h2>{% translate "Invite to a group" %}</h2>
<p> <p>
{% if waiter %}
{% trans "You are inviting:" %} {% trans "You are inviting:" %}
{% else %}
{% trans "You are inviting the following waiters for registration:" %}
{% endif %}
</p> </p>
<p> <p>
<ul> <ul>
{% for waiter in queryset %}
<li> <li>
<a href="{% url 'admin:members_memberwaitinglist_change' waiter.id %}">{{ waiter }}</a> <a href="{% url 'admin:members_memberwaitinglist_change' waiter.id %}">{{ waiter }}</a>
</li> </li>
{% endfor %}
</ul> </ul>
</p> </p>
<p> <p>
{% if waiter %}
{% blocktrans %}Please choose the group that you want to invite {{ waiter }} to.{% endblocktrans %} {% blocktrans %}Please choose the group that you want to invite {{ waiter }} to.{% endblocktrans %}
{% else %}
{% blocktrans %}To which group do you want to invite these waiters?{% endblocktrans %}
{% endif %}
</p> </p>
<form action="" method="post"> <form action="" method="post">
@ -47,7 +59,7 @@
<br> <br>
<div> <div>
<p> <p>
<input type="hidden" name="action" value="ask_for_registration"> <input type="hidden" name="action" value="ask_for_registration_action">
<input class="default" style="color: $default-link-color" type="submit" name="apply" value="{% translate 'Invite' %}"> <input class="default" style="color: $default-link-color" type="submit" name="apply" value="{% translate 'Invite' %}">
<a href="#" class="button cancel-link">{% translate "Cancel" %}</a> <a href="#" class="button cancel-link">{% translate "Cancel" %}</a>
</p> </p>

@ -0,0 +1,68 @@
{% extends "admin/base_site.html" %}
{% load i18n admin_urls static %}
{% block extrahead %}
{{ block.super }}
{{ media }}
<script src="{% static 'admin/js/cancel.js' %}" async></script>
<script type="text/javascript" src="{% static "admin/js/vendor/jquery/jquery.js" %}"></script>
<script type="text/javascript" src="{% static "admin/js/jquery.init.js" %}"></script>
{% endblock %}
{% block bodyclass %}{{ block.super }} app-{{ opts.app_label }} model-{{ opts.model_name }} invite-waiter
{% endblock %}
{% block breadcrumbs %}
<div class="breadcrumbs">
<a href="{% url 'admin:index' %}">{% translate 'Home' %}</a>
&rsaquo; <a href="{% url 'admin:app_list' app_label=opts.app_label %}">{{ opts.app_config.verbose_name }}</a>
&rsaquo; <a href="{% url opts|admin_urlname:'changelist' %}">{{ opts.verbose_name_plural|capfirst }}</a>
{% if object %}
&rsaquo; <a href="{% url opts|admin_urlname:'change' object.pk|admin_urlquote %}">{{ object|truncatewords:"18" }}</a>
{% endif %}
&rsaquo; {% translate 'Invite to group' %}
</div>
{% endblock %}
{% block content %}
<h2>{% translate "Invite to a group" %}</h2>
<p>
{% if waiter %}
{% blocktrans %}You are inviting the following waiter for registration in group {{ group }}.{% endblocktrans %}
{% else %}
{% blocktrans %}You are inviting the following waiters for registration in group {{ group }}.{% endblocktrans %}
{% endif %}
</p>
<p>
<ul>
{% for waiter in queryset %}
<li>
<a href="{% url 'admin:members_memberwaitinglist_change' waiter.id %}">{{ waiter }}</a>
</li>
{% endfor %}
</ul>
</p>
<p>
{% blocktrans %}The following text will be sent as an invitation email. The patterns
{name}, {link} and {invitation_reject_link} will be automatically replaced by personalized
data upon sending. Please adapt if needed and confirm.{% endblocktrans %}
</p>
<form action="" method="post">
{% csrf_token %}
<p>
{{form}}
</p>
<br>
<div>
<p>
<input type="hidden" name="group" value="{{ group.pk }}">
<input type="hidden" name="action" value="ask_for_registration_action">
<input class="default" style="color: $default-link-color" type="submit" name="send" value="{% translate 'Send' %}">
<a href="#" class="button cancel-link">{% translate "Back" %}</a>
</p>
</div>
</form>
{% endblock %}
Loading…
Cancel
Save