From 3fc01b6070bc9f94d6d6f05c8fd5cac06f2d5ec2 Mon Sep 17 00:00:00 2001 From: flavis Date: Wed, 23 Sep 2020 16:52:41 +0200 Subject: [PATCH 01/16] change theme from light-green to green to show checkmarks --- jdav_web/jdav_web/settings.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/jdav_web/jdav_web/settings.py b/jdav_web/jdav_web/settings.py index a6271a8..bd1c9ad 100644 --- a/jdav_web/jdav_web/settings.py +++ b/jdav_web/jdav_web/settings.py @@ -185,7 +185,7 @@ BROKER_URL = os.environ.get('BROKER_URL', 'redis://localhost:6379/0') # JET options (admin interface) JET_SIDE_MENU_COMPACT = True -JET_DEFAULT_THEME = 'light-green' +JET_DEFAULT_THEME = 'green' JET_CHANGE_FORM_SIBLING_LINKS = False JET_SIDE_MENU_ITEMS = [ From bf719601a7e71a87ebf4d1348c0f62a6dbdfa12e Mon Sep 17 00:00:00 2001 From: flavis Date: Wed, 23 Sep 2020 16:57:31 +0200 Subject: [PATCH 02/16] require permission for group and material category --- jdav_web/jdav_web/settings.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/jdav_web/jdav_web/settings.py b/jdav_web/jdav_web/settings.py index bd1c9ad..d4b8a88 100644 --- a/jdav_web/jdav_web/settings.py +++ b/jdav_web/jdav_web/settings.py @@ -209,13 +209,13 @@ JET_SIDE_MENU_ITEMS = [ ]}, {'app_label': 'members', 'items': [ {'name': 'member'}, - {'name': 'group'}, + {'name': 'group', 'permissions': ['members.group']}, {'name': 'memberlist'}, {'name': 'klettertreff'}, {'name': 'activitycategory', 'permissions': ['members.activitycategory'] }, ]}, {'app_label': 'material', 'items': [ - {'name': 'materialcategory'}, + {'name': 'materialcategory', 'permissions': ['material.materialcategory']}, {'name': 'materialpart'}, ]}, {'label': 'Externe Links', 'items' : [ From 56696edb325b1fe14293e1d630006f733b992bdb Mon Sep 17 00:00:00 2001 From: flavis Date: Thu, 24 Sep 2020 15:06:03 +0200 Subject: [PATCH 03/16] seperate teilnehmerlisten into notizlisten and freizeiten, add migration from old teilnehmerlisten to both new versions --- jdav_web/mailer/models.py | 2 +- jdav_web/members/admin.py | 168 +++++++++++++++++++++++++++++++++---- jdav_web/members/models.py | 89 +++++++++++++++++++- 3 files changed, 240 insertions(+), 19 deletions(-) diff --git a/jdav_web/mailer/models.py b/jdav_web/mailer/models.py index 2a8b7ee..1630b30 100644 --- a/jdav_web/mailer/models.py +++ b/jdav_web/mailer/models.py @@ -23,7 +23,7 @@ class Message(models.Model): to_groups = models.ManyToManyField('members.Group', verbose_name=_('to group'), blank=True) - to_memberlist = models.ForeignKey('members.MemberList', + to_memberlist = models.ForeignKey('members.Freizeit', verbose_name=_('to member list'), blank=True, null=True) diff --git a/jdav_web/members/admin.py b/jdav_web/members/admin.py index e67dcde..04cf571 100644 --- a/jdav_web/members/admin.py +++ b/jdav_web/members/admin.py @@ -9,15 +9,16 @@ import unicodedata from django.http import HttpResponse, HttpResponseRedirect from wsgiref.util import FileWrapper from django import forms -from django.contrib import admin +from django.contrib import admin, messages from django.contrib.admin import DateFieldListFilter +from django.contrib.contenttypes.admin import GenericTabularInline from django.utils.translation import ugettext_lazy as _ from django.db.models import TextField, ManyToManyField, ForeignKey from django.forms import Textarea, RadioSelect, TypedChoiceField from django.shortcuts import render -from .models import (Member, Group, MemberList, MemberOnList, Klettertreff, - KlettertreffAttendee, ActivityCategory) +from .models import (Member, Group, Freizeit, MemberNoteList, NewMemberOnList, Klettertreff, + KlettertreffAttendee, ActivityCategory, OldMemberOnList, MemberList) from django.conf import settings #from easy_select2 import apply_select2 @@ -103,28 +104,28 @@ class ActivityCategoryAdmin(admin.ModelAdmin): fields = ['name', 'description'] -class MemberListAdminForm(forms.ModelForm): - difficulty = TypedChoiceField(MemberList.difficulty_choices, +class FreizeitAdminForm(forms.ModelForm): + difficulty = TypedChoiceField(Freizeit.difficulty_choices, #widget=RadioSelect, coerce=int, label=_('Difficulty')) - tour_type = TypedChoiceField(MemberList.tour_type_choices, + tour_type = TypedChoiceField(Freizeit.tour_type_choices, #widget=RadioSelect, coerce=int, label=_('Tour type')) class Meta: - model = MemberList + model = Freizeit exclude = ['add_member'] def __init__(self, *args, **kwargs): - super(MemberListAdminForm, self).__init__(*args, **kwargs) + super(FreizeitAdminForm, self).__init__(*args, **kwargs) self.fields['jugendleiter'].queryset = Member.objects.filter(group__name='Jugendleiter') #self.fields['add_member'].queryset = Member.objects.filter(prename__startswith='F') -class MemberOnListInline(admin.TabularInline): - model = MemberOnList +class MemberOnListInline(GenericTabularInline): + model = NewMemberOnList extra = 0 #formfield_overrides = { # TextField: {'widget': Textarea(attrs={'rows': 1, @@ -134,9 +135,144 @@ class MemberOnListInline(admin.TabularInline): #} +class OldMemberOnListInline(admin.TabularInline): + model = OldMemberOnList + extra = 0 + + +class MemberNoteListAdmin(admin.ModelAdmin): + inlines = [MemberOnListInline] + list_display = ['__str__'] + search_fields = ('name',) + actions = ['generate_summary'] + + def generate_summary(self, request, queryset): + """Generates a pdf summary of the given NoteMemberLists + """ + for memberlist in queryset: + # unique filename + filename = memberlist.title + "_notes_" + datetime.today().strftime("%d_%m_%Y") + filename = filename.replace(' ', '_').replace('&', '') + # drop umlauts, accents etc. + filename = unicodedata.normalize('NFKD', filename).\ + encode('ASCII', 'ignore').decode() + filename_tex = filename + '.tex' + filename_pdf = filename + '.pdf' + + # generate table + table = "" + for memberonlist in memberlist.membersonlist.all(): + m = memberonlist.member + comment = ". ".join(c for c + in (m.comments, + memberonlist.comments) if + c).replace("..", ".") + line = '{0} {1} & {2} \\\\'.format( + esc_ampersand(m.prename), esc_ampersand(m.lastname), + esc_ampersand(comment) or "---") + table += esc_underscore(line) + + # copy template + shutil.copy(media_path('memberlistnote_template.tex'), + media_path(filename_tex)) + + # read in template + with open(media_path(filename_tex), 'r', encoding='utf-8') as f: + template_content = f.read() + + # adapt template + title = esc_all(memberlist.title) + template_content = template_content.replace('MEMBERLIST-TITLE', title) + template_content = template_content.replace('MEMBERLIST-DATE', + datetime.today().strftime('%d.%m.%Y')) + template_content = template_content.replace('TABLE', table) + + # write adapted template to file + with open(media_path(filename_tex), 'w', encoding='utf-8') as f: + f.write(template_content) + + # compile using pdflatex + oldwd = os.getcwd() + os.chdir(media_dir()) + subprocess.call(['pdflatex', filename_tex]) + time.sleep(1) + + # do some cleanup + for f in glob.glob('*.log'): + os.remove(f) + for f in glob.glob('*.aux'): + os.remove(f) + os.remove(filename_tex) + + os.chdir(oldwd) + + # provide the user with the resulting pdf file + with open(media_path(filename_pdf), 'rb') as pdf: + response = HttpResponse(FileWrapper(pdf)) + response['Content-Type'] = 'application/pdf' + response['Content-Disposition'] = 'attachment; filename=' + filename_pdf + + return response + generate_summary.short_description = "PDF Übersicht erstellen" + + + class MemberListAdmin(admin.ModelAdmin): + inlines = [OldMemberOnListInline] + form = FreizeitAdminForm + list_display = ['__str__', 'date'] + search_fields = ('name',) + actions = ['migrate_to_freizeit', 'migrate_to_notelist'] + #formfield_overrides = { + # ManyToManyField: {'widget': forms.CheckboxSelectMultiple}, + # ForeignKey: {'widget': apply_select2(forms.Select)} + #} + + class Media: + css = {'all': ('admin/css/tabular_hide_original.css',)} + + def __init__(self, *args, **kwargs): + super(MemberListAdmin, self).__init__(*args, **kwargs) + + def migrate_to_freizeit(self, request, queryset): + """Creates 'Freizeiten' from the given memberlists """ + for memberlist in queryset: + freizeit = Freizeit(name=memberlist.name, + place=memberlist.place, + destination=memberlist.destination, + date=memberlist.date, + end=memberlist.end, + tour_type=memberlist.tour_type, + difficulty=memberlist.difficulty) + freizeit.save() + freizeit.jugendleiter = memberlist.jugendleiter.all() + freizeit.groups = memberlist.groups.all() + freizeit.activity = memberlist.activity.all() + for memberonlist in memberlist.oldmemberonlist_set.all(): + newonlist = NewMemberOnList(member=memberonlist.member, + comments=memberonlist.comments, + memberlist=freizeit) + newonlist.save() + messages.info(request, "Freizeit(en) erfolgreich erstellt.") + migrate_to_freizeit.short_description = "Aus Teilnehmerliste(n) Freizeit(en) erstellen" + + def migrate_to_notelist(self, request, queryset): + """Creates 'MemberNoteList' from the given memberlists """ + for memberlist in queryset: + notelist = MemberNoteList(title=memberlist.name, + date=memberlist.date) + notelist.save() + for memberonlist in memberlist.oldmemberonlist_set.all(): + newonlist = NewMemberOnList(member=memberonlist.member, + comments=memberonlist.comments, + memberlist=notelist) + newonlist.save() + messages.info(request, "Teilnehmerlist(en) erfolgreich erstellt.") + migrate_to_notelist.short_description = "Aus Teilnehmerliste(n) Notizliste erstellen" + +class FreizeitAdmin(admin.ModelAdmin): inlines = [MemberOnListInline] - form = MemberListAdminForm + form = FreizeitAdminForm list_display = ['__str__', 'date'] search_fields = ('name',) actions = ['convert_to_pdf', 'generate_notes'] @@ -149,7 +285,7 @@ class MemberListAdmin(admin.ModelAdmin): css = {'all': ('admin/css/tabular_hide_original.css',)} def __init__(self, *args, **kwargs): - super(MemberListAdmin, self).__init__(*args, **kwargs) + super(FreizeitAdmin, self).__init__(*args, **kwargs) def convert_to_pdf(self, request, queryset): """Converts a member list to pdf. @@ -167,11 +303,11 @@ class MemberListAdmin(admin.ModelAdmin): # open temporary file for table with open(media_path(filename_table), 'w+', encoding='utf-8') as f: - if memberlist.memberonlist_set.count() == 0: + if memberlist.membersonlist.count() == 0: f.write('{0} & {1} & {2} & {3} \\\\ \n'.format( 'keine Teilnehmer', '-', '-', '-' )) - for memberonlist in memberlist.memberonlist_set.all(): + for memberonlist in memberlist.membersonlist.all(): # write table of members in latex compatible format member = memberonlist.member # use parents phone number if available @@ -278,7 +414,7 @@ class MemberListAdmin(admin.ModelAdmin): table = "" activities = [a.name for a in memberlist.activity.all()] skills = {a: [] for a in activities} - for memberonlist in memberlist.memberonlist_set.all(): + for memberonlist in memberlist.membersonlist.all(): m = memberonlist.member qualities = [] for activity, value in m.get_skills().items(): @@ -440,6 +576,8 @@ class KlettertreffAdmin(admin.ModelAdmin): admin.site.register(Member, MemberAdmin) admin.site.register(Group, GroupAdmin) +admin.site.register(Freizeit, FreizeitAdmin) +admin.site.register(MemberNoteList, MemberNoteListAdmin) admin.site.register(MemberList, MemberListAdmin) admin.site.register(Klettertreff, KlettertreffAdmin) admin.site.register(ActivityCategory, ActivityCategoryAdmin) diff --git a/jdav_web/members/models.py b/jdav_web/members/models.py index 7548307..13b3423 100644 --- a/jdav_web/members/models.py +++ b/jdav_web/members/models.py @@ -4,6 +4,8 @@ from django.db import models from django.utils.translation import ugettext_lazy as _ from django.utils import timezone from django.urls import reverse +from django.contrib.contenttypes.fields import GenericForeignKey, GenericRelation +from django.contrib.contenttypes.models import ContentType from utils import RestrictedFileField import os @@ -160,14 +162,14 @@ class Member(models.Model): skills = {} for kind in ActivityCategory.objects.all(): lists = MemberList.objects.filter(activity=kind, - memberonlist__member=self) + oldmemberonlist__member=self) skills[kind.name] = sum([l.difficulty * 3 for l in lists if l.date < datetime.now().date()]) return skills def get_activities(self): # get activity overview - return MemberList.objects.filter(memberonlist__member=self) + return MemberList.objects.filter(oldmemberonlist__member=self) class MemberList(models.Model): @@ -214,7 +216,7 @@ class MemberList(models.Model): return reverse('admin:members_memberlist_change', args=[str(self.id)]) -class MemberOnList(models.Model): +class OldMemberOnList(models.Model): """ Connects members to a list of members. """ @@ -230,6 +232,87 @@ class MemberOnList(models.Model): verbose_name_plural = _('Members') +class NewMemberOnList(models.Model): + """ + Connects members to a list of members. + """ + member = models.ForeignKey(Member, verbose_name=_('Member')) + content_type = models.ForeignKey(ContentType, on_delete=models.CASCADE, + default=ContentType('members', 'Freizeit').pk) + object_id = models.PositiveIntegerField() + memberlist = GenericForeignKey('content_type', 'object_id') + comments = models.TextField(_('Comment'), default='', blank=True) + + def __str__(self): + return str(self.member) + + class Meta: + verbose_name = _('Member') + verbose_name_plural = _('Members') + + +class Freizeit(models.Model): + """Lets the user create a 'Freizeit' and generate a members overview in pdf format. """ + + name = models.CharField(verbose_name=_('Activity'), default='', + max_length=50) + place = models.CharField(verbose_name=_('Place'), default='', max_length=50) + destination = models.CharField(verbose_name=_('Destination (optional)'), + default='', max_length=50, blank=True) + date = models.DateField(default=datetime.today, verbose_name=_('Date')) + end = models.DateField(verbose_name=_('End (optional)'), blank=True, default=datetime.today) + # comment = models.TextField(_('Comments'), default='', blank=True) + groups = models.ManyToManyField(Group, verbose_name=_('Groups')) + jugendleiter = models.ManyToManyField(Member) + tour_type_choices = ((GEMEINSCHAFTS_TOUR, 'Gemeinschaftstour'), + (FUEHRUNGS_TOUR, 'Führungstour'), + (AUSBILDUNGS_TOUR, 'Ausbildung')) + # verbose_name is overriden by form, label is set in admin.py + tour_type = models.IntegerField(choices=tour_type_choices) + activity = models.ManyToManyField(ActivityCategory, default=None, + verbose_name=_('Categories')) + difficulty_choices = [(1, _('easy')), (2, _('medium')), (3, _('hard'))] + # verbose_name is overriden by form, label is set in admin.py + difficulty = models.IntegerField(choices=difficulty_choices) + membersonlist = GenericRelation(NewMemberOnList) + + def __str__(self): + """String represenation""" + return self.name + + class Meta: + verbose_name = "Freizeit" + verbose_name_plural = "Freizeiten" + + def get_tour_type(self): + if self.tour_type == FUEHRUNGS_TOUR: + return "Führungstour" + elif self.tour_type == AUSBILDUNGS_TOUR: + return "Ausbildung" + else: + return "Gemeinschaftstour" + + def get_absolute_url(self): + return reverse('admin:members_memberlist_change', args=[str(self.id)]) + + +class MemberNoteList(models.Model): + """ + A member list with a title and a bunch of members to take some notes. + """ + title = models.CharField(verbose_name=_('Title'), default='', max_length=50) + date = models.DateField(default=datetime.today, verbose_name=_('Date'), null=True, blank=True) + membersonlist = GenericRelation(NewMemberOnList) + + def __str__(self): + """String represenation""" + return self.title + + class Meta: + verbose_name = "Notizliste" + verbose_name_plural = "Notizlisten" + + class Klettertreff(models.Model): """ This model represents a Klettertreff event. From 0dd8578a327fdd57e0850a7dfe7de5db37123057 Mon Sep 17 00:00:00 2001 From: flavis Date: Thu, 24 Sep 2020 15:34:13 +0200 Subject: [PATCH 04/16] add default ordering for freizeiten and notelists --- jdav_web/members/admin.py | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/jdav_web/members/admin.py b/jdav_web/members/admin.py index 04cf571..dbbf5ef 100644 --- a/jdav_web/members/admin.py +++ b/jdav_web/members/admin.py @@ -142,8 +142,9 @@ class OldMemberOnListInline(admin.TabularInline): class MemberNoteListAdmin(admin.ModelAdmin): inlines = [MemberOnListInline] - list_display = ['__str__'] + list_display = ['__str__', 'date'] search_fields = ('name',) + ordering = ('-date',) actions = ['generate_summary'] def generate_summary(self, request, queryset): @@ -275,6 +276,7 @@ class FreizeitAdmin(admin.ModelAdmin): form = FreizeitAdminForm list_display = ['__str__', 'date'] search_fields = ('name',) + ordering = ('-date',) actions = ['convert_to_pdf', 'generate_notes'] #formfield_overrides = { # ManyToManyField: {'widget': forms.CheckboxSelectMultiple}, From e8b23a4475f21e9c1d6307ea8bf79ec677da25b3 Mon Sep 17 00:00:00 2001 From: flavis Date: Thu, 24 Sep 2020 15:34:46 +0200 Subject: [PATCH 05/16] add new template for note pdf overview --- media/memberlists/memberlistnote_template.tex | 34 +++++++++++++++++++ 1 file changed, 34 insertions(+) create mode 100644 media/memberlists/memberlistnote_template.tex diff --git a/media/memberlists/memberlistnote_template.tex b/media/memberlists/memberlistnote_template.tex new file mode 100644 index 0000000..5955560 --- /dev/null +++ b/media/memberlists/memberlistnote_template.tex @@ -0,0 +1,34 @@ +\documentclass{article} + +\usepackage[utf8]{inputenc} +\usepackage{booktabs} +\usepackage{tabularx} +\usepackage{ragged2e} +\usepackage{amssymb} +\usepackage{cmbright} +\usepackage{graphicx} +\usepackage{textpos} +\usepackage[colorlinks]{hyperref} +\usepackage{float} +\usepackage[margin=1cm]{geometry} + +\renewcommand{\arraystretch}{1.5} + +\newcolumntype{Y}{>{\RaggedRight\arraybackslash}X} +\begin{document} + +% HEADLINE +{\noindent\LARGE\textsc{MEMBERLIST-TITLE}}\\ +\textit{Erstellt: MEMBERLIST-DATE}\\ + +\begin{table}[H] + \begin{tabularx}{\textwidth}{@{} l l Y @{}} + \toprule + \textbf{Name} & \textbf{Kommentare} \\ + \midrule + TABLE + \bottomrule + \end{tabularx} +\end{table} + +\end{document} From f321cdf82e3d0d2c646c8f300ba3d7e400d3c2ee Mon Sep 17 00:00:00 2001 From: flavis Date: Thu, 24 Sep 2020 15:44:10 +0200 Subject: [PATCH 06/16] adapt side menu --- jdav_web/jdav_web/settings.py | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/jdav_web/jdav_web/settings.py b/jdav_web/jdav_web/settings.py index d4b8a88..dfd0f6a 100644 --- a/jdav_web/jdav_web/settings.py +++ b/jdav_web/jdav_web/settings.py @@ -210,7 +210,9 @@ JET_SIDE_MENU_ITEMS = [ {'app_label': 'members', 'items': [ {'name': 'member'}, {'name': 'group', 'permissions': ['members.group']}, - {'name': 'memberlist'}, + {'name': 'memberlist', 'permissions': ['members.memberlist']}, + {'name': 'membernotelist'}, + {'name': 'freizeit'}, {'name': 'klettertreff'}, {'name': 'activitycategory', 'permissions': ['members.activitycategory'] }, ]}, From 70a9d50da5a4e5179fca233a56ffb5bce08f0f0c Mon Sep 17 00:00:00 2001 From: flavis Date: Thu, 24 Sep 2020 17:42:38 +0200 Subject: [PATCH 07/16] use new participant lists in mailer application, allow custom email addresses as reply to --- jdav_web/locale/de/LC_MESSAGES/django.po | 37 +++--- .../mailer/locale/de/LC_MESSAGES/django.po | 95 ++++++++------- .../mailer/management/commands/reply_addrs.py | 4 +- jdav_web/mailer/models.py | 82 ++++++++----- .../material/locale/de/LC_MESSAGES/django.po | 2 +- .../members/locale/de/LC_MESSAGES/django.po | 113 +++++++++--------- jdav_web/members/models.py | 8 +- .../startpage/locale/de/LC_MESSAGES/django.po | 2 +- 8 files changed, 186 insertions(+), 157 deletions(-) diff --git a/jdav_web/locale/de/LC_MESSAGES/django.po b/jdav_web/locale/de/LC_MESSAGES/django.po index b29effc..3f5398e 100644 --- a/jdav_web/locale/de/LC_MESSAGES/django.po +++ b/jdav_web/locale/de/LC_MESSAGES/django.po @@ -8,7 +8,7 @@ msgid "" msgstr "" "Project-Id-Version: PACKAGE VERSION\n" "Report-Msgid-Bugs-To: \n" -"POT-Creation-Date: 2020-01-24 23:21+0100\n" +"POT-Creation-Date: 2020-09-24 17:31+0200\n" "PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\n" "Last-Translator: FULL NAME \n" "Language-Team: LANGUAGE \n" @@ -22,26 +22,6 @@ msgstr "" msgid "Startpage" msgstr "Startseite" -#: templates/admin/base.html:32 -msgid "Welcome," -msgstr "Willkommen" - -#: templates/admin/base.html:40 -msgid "Documentation" -msgstr "Dokumentation" - -#: templates/admin/base.html:44 -msgid "Change password" -msgstr "Passwort ändern" - -#: templates/admin/base.html:46 -msgid "Log out" -msgstr "Abmelden" - -#: templates/admin/base.html:56 -msgid "Home" -msgstr "Start" - #: utils.py:26 msgid "Filetype not supported." msgstr "Dateityp nicht unterstützt." @@ -50,6 +30,21 @@ msgstr "Dateityp nicht unterstützt." msgid "Please keep filesize under {}. Current filesize: {}" msgstr "Maximale Dateigröße {}. Aktuelle Dateigröße: {}." +#~ msgid "Welcome," +#~ msgstr "Willkommen" + +#~ msgid "Documentation" +#~ msgstr "Dokumentation" + +#~ msgid "Change password" +#~ msgstr "Passwort ändern" + +#~ msgid "Log out" +#~ msgstr "Abmelden" + +#~ msgid "Home" +#~ msgstr "Start" + #~ msgid "View site" #~ msgstr "Seite anzeigen" diff --git a/jdav_web/mailer/locale/de/LC_MESSAGES/django.po b/jdav_web/mailer/locale/de/LC_MESSAGES/django.po index f786ed7..3cbe150 100644 --- a/jdav_web/mailer/locale/de/LC_MESSAGES/django.po +++ b/jdav_web/mailer/locale/de/LC_MESSAGES/django.po @@ -8,7 +8,7 @@ msgid "" msgstr "" "Project-Id-Version: PACKAGE VERSION\n" "Report-Msgid-Bugs-To: \n" -"POT-Creation-Date: 2020-01-24 23:21+0100\n" +"POT-Creation-Date: 2020-09-24 17:31+0200\n" "PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\n" "Last-Translator: FULL NAME \n" "Language-Team: LANGUAGE \n" @@ -38,97 +38,105 @@ msgstr "Email wurde erfolgreich verschickt" msgid "mailer" msgstr "Verteiler" -#: mailer/models.py:21 +#: mailer/models.py:18 +msgid "Only alphanumeric characters are allowed" +msgstr "Nur Buchstaben und Zahlen erlaubt" + +#: mailer/models.py:23 +msgid "name" +msgstr "Name" + +#: mailer/models.py:25 +msgid "Forward to" +msgstr "Weiterleitung" + +#: mailer/models.py:35 +msgid "email address" +msgstr "Email-Adresse" + +#: mailer/models.py:36 +msgid "email addresses" +msgstr "Email-Adressen" + +#: mailer/models.py:42 msgid "subject" msgstr "Betreff" -#: mailer/models.py:22 +#: mailer/models.py:43 msgid "content" msgstr "Inhalt" -#: mailer/models.py:24 +#: mailer/models.py:45 msgid "to group" msgstr "An Gruppe" -#: mailer/models.py:27 -msgid "to member list" -msgstr "An Teilnehmerliste" +#: mailer/models.py:48 +msgid "to freizeit" +msgstr "An Freizeit" + +#: mailer/models.py:52 +msgid "to notes list" +msgstr "An Notizliste" -#: mailer/models.py:31 +#: mailer/models.py:56 msgid "to member" msgstr "An Teilnehmer" -#: mailer/models.py:34 -msgid "reply to" -msgstr "Antwort an" +#: mailer/models.py:59 +msgid "reply to participant" +msgstr "Antwort an Teilnehmer" -#: mailer/models.py:37 +#: mailer/models.py:63 +msgid "reply to custom email address" +msgstr "Antwort an Email-Adresse" + +#: mailer/models.py:66 msgid "sent" msgstr "Gesendet" -#: mailer/models.py:49 +#: mailer/models.py:80 msgid "Some other members" msgstr "Andere Teilnehmer" -#: mailer/models.py:51 +#: mailer/models.py:82 msgid "recipients" msgstr "Empfänger" -#: mailer/models.py:102 +#: mailer/models.py:138 msgid "message" msgstr "Nachricht" -#: mailer/models.py:103 +#: mailer/models.py:139 msgid "messages" msgstr "Nachrichten" -#: mailer/models.py:105 +#: mailer/models.py:141 msgid "Can submit mails" msgstr "Kann Mails verschicken" -#: mailer/models.py:120 +#: mailer/models.py:157 msgid "" "Either a group, a memberlist or at least one member is required as recipient" msgstr "" "Es muss entweder eine Gruppe, eine Teilnehmerliste oder mindestens ein " "Teilnehmer als Empfänger ausgewählt werden." -#: mailer/models.py:127 +#: mailer/models.py:164 msgid "file" msgstr "Datei" -#: mailer/models.py:133 +#: mailer/models.py:170 msgid "Empty" msgstr "Leer" -#: mailer/models.py:136 +#: mailer/models.py:173 msgid "attachment" msgstr "Anhang" -#: mailer/models.py:137 +#: mailer/models.py:174 msgid "attachments" msgstr "Anhänge" -#: mailer/models.py:140 -msgid "Only alphanumeric characters are allowed" -msgstr "Nur Buchstaben und Zahlen erlaubt" - -#: mailer/models.py:145 -msgid "name" -msgstr "Name" - -#: mailer/models.py:147 -msgid "Forward to" -msgstr "Weiterleitung" - -#: mailer/models.py:157 -msgid "email address" -msgstr "Email-Adresse" - -#: mailer/models.py:158 -msgid "email addresses" -msgstr "Email-Adressen" - #: mailer/templates/mailer/change_form.html:11 msgid "Save and send mail" msgstr "Speichern und Email senden" @@ -247,6 +255,9 @@ msgstr "Bitte jedes Feld ausfüllen!" msgid "Member already exists" msgstr "Mitglied schon vorhanden" +#~ msgid "reply to" +#~ msgstr "Antwort an" + #~ msgid "Message sent" #~ msgstr "Nachricht gesendet" diff --git a/jdav_web/mailer/management/commands/reply_addrs.py b/jdav_web/mailer/management/commands/reply_addrs.py index f665bb7..6b45897 100644 --- a/jdav_web/mailer/management/commands/reply_addrs.py +++ b/jdav_web/mailer/management/commands/reply_addrs.py @@ -20,7 +20,8 @@ class Command(BaseCommand): message_id = int(options['message_id']) message = Message.objects.get(pk=message_id) if message.reply_to: - replies = message.reply_to.all() + 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) @@ -29,6 +30,7 @@ class Command(BaseCommand): message = msgs.all()[0] if message.reply_to: replies = message.reply_to.all() + replies.extend(message.reply_to_email_address.all()) except (Message.DoesNotExist, ValueError, IndexError): pass diff --git a/jdav_web/mailer/models.py b/jdav_web/mailer/models.py index 1630b30..d05ce98 100644 --- a/jdav_web/mailer/models.py +++ b/jdav_web/mailer/models.py @@ -15,6 +15,27 @@ SENDING_ADDRESS = mail_root HOST = os.environ.get('DJANGO_ALLOWED_HOST', 'localhost:8000').split(",")[0] +alphanumeric = RegexValidator(r'^[0-9a-zA-Z]*$', _('Only alphanumeric characters are allowed')) + + +class EmailAddress(models.Model): + """Represents an email address, that is forwarded to specific members""" + name = models.CharField(_('name'), max_length=50, validators=[alphanumeric]) + to_members = models.ManyToManyField('members.Member', + verbose_name=_('Forward to')) + + @property + def email(self): + return "{0}@{1}".format(self.name, HOST) + + def __str__(self): + return self.email + + class Meta: + verbose_name = _('email address') + verbose_name_plural = _('email addresses') + + # Create your models here. class Message(models.Model): """Represents a message that can be sent to some members""" @@ -23,17 +44,25 @@ class Message(models.Model): to_groups = models.ManyToManyField('members.Group', verbose_name=_('to group'), blank=True) - to_memberlist = models.ForeignKey('members.Freizeit', - verbose_name=_('to member list'), + to_freizeit = models.ForeignKey('members.Freizeit', + verbose_name=_('to freizeit'), + blank=True, + null=True) + to_notelist = models.ForeignKey('members.MemberNoteList', + verbose_name=_('to notes list'), blank=True, null=True) to_members = models.ManyToManyField('members.Member', verbose_name=_('to member'), blank=True) reply_to = models.ManyToManyField('members.Member', - verbose_name=_('reply to'), + verbose_name=_('reply to participant'), blank=True, related_name='reply_to') + reply_to_email_address = models.ManyToManyField('mailer.EmailAddress', + verbose_name=_('reply to custom email address'), + blank=True, + related_name='reply_to_email_addr') sent = models.BooleanField(_('sent'), default=False) def __str__(self): @@ -41,8 +70,10 @@ class Message(models.Model): def get_recipients(self): recipients = [g.name for g in self.to_groups.all()] - if self.to_memberlist is not None: - recipients.append(self.to_memberlist.name) + if self.to_freizeit is not None: + recipients.append(self.to_freizeit.name) + if self.to_notelist is not None: + recipients.append(self.to_notelist.title) if 3 > self.to_members.count() > 0: recipients.extend([m.name for m in self.to_members.all()]) elif self.to_members.count() > 2: @@ -59,11 +90,15 @@ class Message(models.Model): members.update([m for gr in groups for m in gr]) # get all the individually picked members members.update(self.to_members.all()) - # get all the members of the selected member list - if self.to_memberlist is not None: + # get all the members of the selected freizeit + if self.to_freizeit is not None: + members.update([mol.member for mol in + self.to_freizeit.membersonlist.all()]) + members.update(self.to_freizeit.jugendleiter.all()) + # get all the members of the selected notes list + if self.to_notelist is not None: members.update([mol.member for mol in - self.to_memberlist.memberonlist_set.all()]) - members.update(self.to_memberlist.jugendleiter.all()) + self.to_notelist.membersonlist.all()]) filtered = [m for m in members if m.gets_newsletter] print("sending mail to", filtered) attach = [a.f.path for a in Attachment.objects.filter(msg__id=self.pk) @@ -76,8 +111,9 @@ class Message(models.Model): self.subject = self.subject.replace('_', ' ') # generate message id message_id = "<{}@jdav-ludwigsburg.de>".format(self.pk) - # reply to adresses + # reply to addresses reply_to = [jl.association_email for jl in self.reply_to.all()] + reply_to.extend([ml.email for ml in self.reply_to_email_address.all()]) try: success = send(self.subject, get_content(self.content), SENDING_ADDRESS, @@ -114,9 +150,10 @@ class MessageForm(forms.ModelForm): def clean(self): group = self.cleaned_data.get('to_groups') - memberlist = self.cleaned_data.get('to_memberlist') + freizeit = self.cleaned_data.get('to_freizeit') + notelist = self.cleaned_data.get('to_notelist') members = self.cleaned_data.get('to_members') - if not group and memberlist is None and not members: + if not group and memberlist is None and not members and notelist is None: raise ValidationError(_('Either a group, a memberlist or at least' ' one member is required as recipient')) @@ -135,24 +172,3 @@ class Attachment(models.Model): class Meta: verbose_name = _('attachment') verbose_name_plural = _('attachments') - - -alphanumeric = RegexValidator(r'^[0-9a-zA-Z]*$', _('Only alphanumeric characters are allowed')) - - -class EmailAddress(models.Model): - """Represents an email address, that is forwarded to specific members""" - name = models.CharField(_('name'), max_length=50, validators=[alphanumeric]) - to_members = models.ManyToManyField('members.Member', - verbose_name=_('Forward to')) - - @property - def email(self): - return "{0}@{1}".format(self.name, HOST) - - def __str__(self): - return self.email - - class Meta: - verbose_name = _('email address') - verbose_name_plural = _('email addresses') diff --git a/jdav_web/material/locale/de/LC_MESSAGES/django.po b/jdav_web/material/locale/de/LC_MESSAGES/django.po index f1af77f..2ab3ce9 100644 --- a/jdav_web/material/locale/de/LC_MESSAGES/django.po +++ b/jdav_web/material/locale/de/LC_MESSAGES/django.po @@ -8,7 +8,7 @@ msgid "" msgstr "" "Project-Id-Version: PACKAGE VERSION\n" "Report-Msgid-Bugs-To: \n" -"POT-Creation-Date: 2020-01-24 23:21+0100\n" +"POT-Creation-Date: 2020-09-24 17:31+0200\n" "PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\n" "Last-Translator: FULL NAME \n" "Language-Team: LANGUAGE \n" diff --git a/jdav_web/members/locale/de/LC_MESSAGES/django.po b/jdav_web/members/locale/de/LC_MESSAGES/django.po index a0de68e..31647d9 100644 --- a/jdav_web/members/locale/de/LC_MESSAGES/django.po +++ b/jdav_web/members/locale/de/LC_MESSAGES/django.po @@ -8,7 +8,7 @@ msgid "" msgstr "" "Project-Id-Version: PACKAGE VERSION\n" "Report-Msgid-Bugs-To: \n" -"POT-Creation-Date: 2020-01-24 23:21+0100\n" +"POT-Creation-Date: 2020-09-24 17:31+0200\n" "PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\n" "Last-Translator: FULL NAME \n" "Language-Team: LANGUAGE \n" @@ -18,213 +18,218 @@ msgstr "" "Content-Transfer-Encoding: 8bit\n" "Plural-Forms: nplurals=2; plural=(n != 1);\n" -#: members/admin.py:26 members/models.py:73 +#: members/admin.py:27 members/models.py:75 msgid "Registration complete" msgstr "Anmeldung vollständig" -#: members/admin.py:32 +#: members/admin.py:33 msgid "True" msgstr "Ja" -#: members/admin.py:33 +#: members/admin.py:34 msgid "False" msgstr "Nein" -#: members/admin.py:34 +#: members/admin.py:35 msgid "All" msgstr "Alle" -#: members/admin.py:94 +#: members/admin.py:95 msgid "Compose new mail to selected members" msgstr "Neue Nachricht an ausgewählte Teilnehmer verfassen" -#: members/admin.py:110 +#: members/admin.py:111 msgid "Difficulty" msgstr "Schwierigkeit" -#: members/admin.py:114 +#: members/admin.py:115 msgid "Tour type" msgstr "Art der Tour" -#: members/admin.py:257 +#: members/admin.py:401 msgid "Convert to PDF" msgstr "Kriseninterventionsliste erstellen" -#: members/admin.py:366 +#: members/admin.py:510 msgid "Generate overview" msgstr "Hinweise für Jugendleiter erstellen" -#: members/apps.py:7 members/models.py:129 +#: members/apps.py:7 members/models.py:157 msgid "members" msgstr "Teilnehmer" -#: members/models.py:20 +#: members/models.py:22 msgid "Name" msgstr "Name" -#: members/models.py:21 +#: members/models.py:23 msgid "Description" msgstr "Beschreibung" -#: members/models.py:27 members/models.py:150 +#: members/models.py:29 members/models.py:178 members/models.py:257 #: members/templates/members/change_member.html:17 msgid "Activity" msgstr "Aktivität" -#: members/models.py:28 +#: members/models.py:30 msgid "Activities" msgstr "Aktivitäten" -#: members/models.py:36 +#: members/models.py:38 msgid "name" msgstr "Name" -#: members/models.py:38 +#: members/models.py:40 msgid "minimum age (years)" msgstr "Mindestalter (Jahre)" -#: members/models.py:45 members/models.py:66 +#: members/models.py:47 members/models.py:68 msgid "group" msgstr "Gruppe" -#: members/models.py:46 +#: members/models.py:48 msgid "groups" msgstr "Gruppen" -#: members/models.py:54 +#: members/models.py:56 msgid "prename" msgstr "Vorname" -#: members/models.py:55 +#: members/models.py:57 msgid "last name" msgstr "Nachname" -#: members/models.py:56 +#: members/models.py:58 msgid "street" msgstr "Straße" -#: members/models.py:57 +#: members/models.py:59 msgid "Postcode" msgstr "PLZ" -#: members/models.py:59 +#: members/models.py:61 msgid "town" msgstr "Stadt" -#: members/models.py:60 +#: members/models.py:62 msgid "phone number" msgstr "Telefonnummer" -#: members/models.py:61 +#: members/models.py:63 msgid "parents phone number" msgstr "Telefonnummer der Eltern" -#: members/models.py:64 +#: members/models.py:66 msgid "Parents' Email" msgstr "Email der Eltern" -#: members/models.py:65 +#: members/models.py:67 msgid "birth date" msgstr "Geburtsdatum" -#: members/models.py:67 +#: members/models.py:69 msgid "receives newsletter" msgstr "Erhält den Newsletter" -#: members/models.py:71 +#: members/models.py:73 msgid "comments" msgstr "Kommentare" -#: members/models.py:72 +#: members/models.py:74 msgid "created" msgstr "erstellt" -#: members/models.py:74 +#: members/models.py:76 msgid "registration form" msgstr "Anmeldeformular" -#: members/models.py:125 members/models.py:217 +#: members/models.py:153 members/models.py:326 msgid "Group" msgstr "Gruppe" -#: members/models.py:128 +#: members/models.py:156 msgid "member" msgstr "Teilnehmer" -#: members/models.py:152 +#: members/models.py:180 members/models.py:259 msgid "Place" msgstr "Ort" -#: members/models.py:153 +#: members/models.py:181 members/models.py:260 msgid "Destination (optional)" msgstr "Ziel (optional)" -#: members/models.py:155 members/models.py:213 +#: members/models.py:183 members/models.py:262 members/models.py:304 +#: members/models.py:322 msgid "Date" msgstr "Datum" -#: members/models.py:156 +#: members/models.py:184 members/models.py:263 msgid "End (optional)" msgstr "Ende" -#: members/models.py:158 +#: members/models.py:186 members/models.py:265 msgid "Groups" msgstr "Gruppen" -#: members/models.py:166 +#: members/models.py:194 members/models.py:273 msgid "Categories" msgstr "Kategorien" -#: members/models.py:167 +#: members/models.py:195 members/models.py:274 msgid "easy" msgstr "leicht" -#: members/models.py:167 +#: members/models.py:195 members/models.py:274 msgid "medium" msgstr "mittel" -#: members/models.py:167 +#: members/models.py:195 members/models.py:274 msgid "hard" msgstr "schwer" -#: members/models.py:176 +#: members/models.py:204 msgid "Memberlist" msgstr "Teilnehmerliste" -#: members/models.py:177 +#: members/models.py:205 msgid "Memberlists" msgstr "Teilnehmerlisten" -#: members/models.py:195 members/models.py:203 members/models.py:248 -#: members/models.py:255 +#: members/models.py:223 members/models.py:231 members/models.py:239 +#: members/models.py:250 members/models.py:357 members/models.py:364 msgid "Member" msgstr "Teilnehmer" -#: members/models.py:197 +#: members/models.py:225 members/models.py:244 msgid "Comment" msgstr "Kommentar" -#: members/models.py:204 members/models.py:256 +#: members/models.py:232 members/models.py:251 members/models.py:365 msgid "Members" msgstr "Teilnehmer" -#: members/models.py:214 +#: members/models.py:303 +msgid "Title" +msgstr "" + +#: members/models.py:323 msgid "Location" msgstr "Ort" -#: members/models.py:215 +#: members/models.py:324 msgid "Topic" msgstr "Thema" -#: members/models.py:239 +#: members/models.py:348 msgid "Jugendleiter" msgstr "Jugendleiter" -#: members/models.py:242 +#: members/models.py:351 msgid "Klettertreff" msgstr "Klettertreff" -#: members/models.py:243 +#: members/models.py:352 msgid "Klettertreffs" msgstr "Klettertreffs" diff --git a/jdav_web/members/models.py b/jdav_web/members/models.py index 13b3423..852a85c 100644 --- a/jdav_web/members/models.py +++ b/jdav_web/members/models.py @@ -161,15 +161,15 @@ class Member(models.Model): # get skills by summing up all the activities taken part in skills = {} for kind in ActivityCategory.objects.all(): - lists = MemberList.objects.filter(activity=kind, - oldmemberonlist__member=self) + lists = Freizeit.objects.filter(activity=kind, + membersonlist__member=self) skills[kind.name] = sum([l.difficulty * 3 for l in lists if l.date < datetime.now().date()]) return skills def get_activities(self): # get activity overview - return MemberList.objects.filter(oldmemberonlist__member=self) + return Freizeit.objects.filter(membersonlist__member=self) class MemberList(models.Model): @@ -293,7 +293,7 @@ class Freizeit(models.Model): return "Gemeinschaftstour" def get_absolute_url(self): - return reverse('admin:members_memberlist_change', args=[str(self.id)]) + return reverse('admin:members_freizeit_change', args=[str(self.id)]) class MemberNoteList(models.Model): diff --git a/jdav_web/startpage/locale/de/LC_MESSAGES/django.po b/jdav_web/startpage/locale/de/LC_MESSAGES/django.po index 7875fb3..fa4c2a7 100644 --- a/jdav_web/startpage/locale/de/LC_MESSAGES/django.po +++ b/jdav_web/startpage/locale/de/LC_MESSAGES/django.po @@ -8,7 +8,7 @@ msgid "" msgstr "" "Project-Id-Version: PACKAGE VERSION\n" "Report-Msgid-Bugs-To: \n" -"POT-Creation-Date: 2020-01-24 23:21+0100\n" +"POT-Creation-Date: 2020-09-24 17:31+0200\n" "PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\n" "Last-Translator: FULL NAME \n" "Language-Team: LANGUAGE \n" From e521858782c1fbeda56e6c164cfefb949f53bbb0 Mon Sep 17 00:00:00 2001 From: Erich Hasl Date: Thu, 24 Sep 2020 19:17:58 +0200 Subject: [PATCH 08/16] fix wrong variable --- jdav_web/mailer/models.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/jdav_web/mailer/models.py b/jdav_web/mailer/models.py index d05ce98..7d15307 100644 --- a/jdav_web/mailer/models.py +++ b/jdav_web/mailer/models.py @@ -153,7 +153,7 @@ class MessageForm(forms.ModelForm): freizeit = self.cleaned_data.get('to_freizeit') notelist = self.cleaned_data.get('to_notelist') members = self.cleaned_data.get('to_members') - if not group and memberlist is None and not members and notelist is None: + if not group and freizeit is None and not members and notelist is None: raise ValidationError(_('Either a group, a memberlist or at least' ' one member is required as recipient')) From 14efa944fa268a57338219a0fccdecd7e1a25eee Mon Sep 17 00:00:00 2001 From: flavis Date: Thu, 24 Sep 2020 21:38:03 +0200 Subject: [PATCH 09/16] require reply-to --- jdav_web/jdav_web/settings.py | 2 +- jdav_web/mailer/admin.py | 3 +- jdav_web/mailer/models.py | 43 ++++++++++++++++--- .../management/commands/get_forward_addrs.py | 2 +- 4 files changed, 42 insertions(+), 8 deletions(-) diff --git a/jdav_web/jdav_web/settings.py b/jdav_web/jdav_web/settings.py index dfd0f6a..6ae3383 100644 --- a/jdav_web/jdav_web/settings.py +++ b/jdav_web/jdav_web/settings.py @@ -205,7 +205,7 @@ JET_SIDE_MENU_ITEMS = [ ]}, {'app_label': 'mailer', 'items': [ {'name': 'message'}, - {'name': 'emailaddress', 'permissions': ['mailer.emailaddress'] }, + {'name': 'emailaddress'}, ]}, {'app_label': 'members', 'items': [ {'name': 'member'}, diff --git a/jdav_web/mailer/admin.py b/jdav_web/mailer/admin.py index d872c83..a3e8613 100644 --- a/jdav_web/mailer/admin.py +++ b/jdav_web/mailer/admin.py @@ -7,7 +7,7 @@ from django import forms #from easy_select2 import apply_select2 import json -from .models import Message, Attachment, MessageForm, EmailAddress +from .models import Message, Attachment, MessageForm, EmailAddress, EmailAddressForm from .mailutils import NOT_SENT, PARTLY_SENT from members.models import Member @@ -24,6 +24,7 @@ class EmailAddressAdmin(admin.ModelAdmin): # models.ForeignKey: {'widget': apply_select2(forms.Select)} #} filter_horizontal = ('to_members',) + form = EmailAddressForm class MessageAdmin(admin.ModelAdmin): diff --git a/jdav_web/mailer/models.py b/jdav_web/mailer/models.py index d05ce98..f169498 100644 --- a/jdav_web/mailer/models.py +++ b/jdav_web/mailer/models.py @@ -22,12 +22,22 @@ class EmailAddress(models.Model): """Represents an email address, that is forwarded to specific members""" name = models.CharField(_('name'), max_length=50, validators=[alphanumeric]) to_members = models.ManyToManyField('members.Member', - verbose_name=_('Forward to')) + verbose_name=_('Forward to participants'), + blank=True) + to_groups = models.ManyToManyField('members.Group', + verbose_name=_('Forward to group'), + blank=True) @property def email(self): return "{0}@{1}".format(self.name, HOST) + @property + def forwards(self): + mails = set(member.email for member in self.to_members.all()) + mails.update([member.email for group in self.to_groups.all() for member in group.member_set.all()]) + return mails + def __str__(self): return self.email @@ -36,6 +46,21 @@ class EmailAddress(models.Model): verbose_name_plural = _('email addresses') +class EmailAddressForm(forms.ModelForm): + + class Meta: + model = EmailAddress + exclude = [] + + def clean(self): + group = self.cleaned_data.get('to_groups') + members = self.cleaned_data.get('to_members') + if not group and not members: + raise ValidationError(_('Either a group or at least' + ' one member is required as forward recipient.')) + + + # Create your models here. class Message(models.Model): """Represents a message that can be sent to some members""" @@ -112,8 +137,11 @@ class Message(models.Model): # generate message id message_id = "<{}@jdav-ludwigsburg.de>".format(self.pk) # reply to addresses - reply_to = [jl.association_email for jl in self.reply_to.all()] - reply_to.extend([ml.email for ml in self.reply_to_email_address.all()]) + reply_to_unfiltered = [jl.association_email for jl in self.reply_to.all()] + reply_to_unfiltered.extend([ml.email for ml in self.reply_to_email_address.all()]) + # remove sending address from reply-to field (probably unnecessary since it's removed by + # the mail provider anyways) + reply_to = [mail for mail in reply_to if mail != SENDING_ADDRESS ] try: success = send(self.subject, get_content(self.content), SENDING_ADDRESS, @@ -128,7 +156,7 @@ class Message(models.Model): os.remove(a.f.path) a.delete() except Exception as e: - print("Exception catched", e) + print("Exception caught", e) success = NOT_SENT finally: self.save() @@ -153,9 +181,14 @@ class MessageForm(forms.ModelForm): freizeit = self.cleaned_data.get('to_freizeit') notelist = self.cleaned_data.get('to_notelist') members = self.cleaned_data.get('to_members') - if not group and memberlist is None and not members and notelist is None: + if not group and freizeit is None and not members and notelist is None: raise ValidationError(_('Either a group, a memberlist or at least' ' one member is required as recipient')) + reply_to = self.cleaned_data.get('reply_to') + reply_to_email_address = self.cleaned_data.get('reply_to_email_address') + if not reply_to and not reply_to_email_address: + raise ValidationError(_('At least one reply-to recipient is required.' + 'Use the info mail if you really want no reply-to recipient.')) class Attachment(models.Model): """Represents an attachment to an email""" diff --git a/jdav_web/members/management/commands/get_forward_addrs.py b/jdav_web/members/management/commands/get_forward_addrs.py index 8dada6f..1b12266 100644 --- a/jdav_web/members/management/commands/get_forward_addrs.py +++ b/jdav_web/members/management/commands/get_forward_addrs.py @@ -21,7 +21,7 @@ class Command(BaseCommand): if addresses: forwards = [] for addr in addresses: - forwards.extend([member.email for member in addr.to_members.all()]) + forwards.extend(addr.forwards) self.stdout.write(" ".join(forwards)) return try: From 8ab2f2f3523483d913e4dff6e9c243d7719fc4a7 Mon Sep 17 00:00:00 2001 From: Erich Hasl Date: Thu, 24 Sep 2020 22:04:34 +0200 Subject: [PATCH 10/16] fix typo in message submit --- jdav_web/mailer/models.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/jdav_web/mailer/models.py b/jdav_web/mailer/models.py index f169498..0d4e429 100644 --- a/jdav_web/mailer/models.py +++ b/jdav_web/mailer/models.py @@ -141,7 +141,7 @@ class Message(models.Model): reply_to_unfiltered.extend([ml.email for ml in self.reply_to_email_address.all()]) # remove sending address from reply-to field (probably unnecessary since it's removed by # the mail provider anyways) - reply_to = [mail for mail in reply_to if mail != SENDING_ADDRESS ] + reply_to = [mail for mail in reply_to_unfiltered if mail != SENDING_ADDRESS ] try: success = send(self.subject, get_content(self.content), SENDING_ADDRESS, From 9dc524cce3811de91eee2568b00c1aac79f54fa8 Mon Sep 17 00:00:00 2001 From: flavis Date: Thu, 24 Sep 2020 22:12:34 +0200 Subject: [PATCH 11/16] add translations --- .../mailer/locale/de/LC_MESSAGES/django.po | 73 ++++++++++++------- jdav_web/mailer/models.py | 2 +- 2 files changed, 46 insertions(+), 29 deletions(-) diff --git a/jdav_web/mailer/locale/de/LC_MESSAGES/django.po b/jdav_web/mailer/locale/de/LC_MESSAGES/django.po index 3cbe150..de74673 100644 --- a/jdav_web/mailer/locale/de/LC_MESSAGES/django.po +++ b/jdav_web/mailer/locale/de/LC_MESSAGES/django.po @@ -8,7 +8,7 @@ msgid "" msgstr "" "Project-Id-Version: PACKAGE VERSION\n" "Report-Msgid-Bugs-To: \n" -"POT-Creation-Date: 2020-09-24 17:31+0200\n" +"POT-Creation-Date: 2020-09-24 22:08+0200\n" "PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\n" "Last-Translator: FULL NAME \n" "Language-Team: LANGUAGE \n" @@ -18,19 +18,19 @@ msgstr "" "Content-Transfer-Encoding: 8bit\n" "Plural-Forms: nplurals=2; plural=(n != 1);\n" -#: mailer/admin.py:55 +#: mailer/admin.py:56 msgid "Send message" msgstr "Nachricht verschicken" -#: mailer/admin.py:85 +#: mailer/admin.py:86 msgid "Failed to send message" msgstr "Fehler beim Senden der Email" -#: mailer/admin.py:87 +#: mailer/admin.py:88 msgid "Failed to send some messages" msgstr "Fehler beim Senden der Emails" -#: mailer/admin.py:89 +#: mailer/admin.py:90 msgid "Successfully sent message" msgstr "Email wurde erfolgreich verschickt" @@ -47,93 +47,110 @@ msgid "name" msgstr "Name" #: mailer/models.py:25 -msgid "Forward to" -msgstr "Weiterleitung" +msgid "Forward to participants" +msgstr "Weiterleitung an Teilnehmer" -#: mailer/models.py:35 +#: mailer/models.py:28 +msgid "Forward to group" +msgstr "Weiterleitung an Gruppe" + +#: mailer/models.py:45 msgid "email address" msgstr "Email-Adresse" -#: mailer/models.py:36 +#: mailer/models.py:46 msgid "email addresses" msgstr "Email-Adressen" -#: mailer/models.py:42 +#: mailer/models.py:59 +msgid "Either a group or at least one member is required as forward recipient." +msgstr "" +"Es muss entweder eine Gruppe oder mindestens ein Teilnehmer als Empfänger " +"ausgewählt werden." + +#: mailer/models.py:67 msgid "subject" msgstr "Betreff" -#: mailer/models.py:43 +#: mailer/models.py:68 msgid "content" msgstr "Inhalt" -#: mailer/models.py:45 +#: mailer/models.py:70 msgid "to group" msgstr "An Gruppe" -#: mailer/models.py:48 +#: mailer/models.py:73 msgid "to freizeit" msgstr "An Freizeit" -#: mailer/models.py:52 +#: mailer/models.py:77 msgid "to notes list" msgstr "An Notizliste" -#: mailer/models.py:56 +#: mailer/models.py:81 msgid "to member" msgstr "An Teilnehmer" -#: mailer/models.py:59 +#: mailer/models.py:84 msgid "reply to participant" msgstr "Antwort an Teilnehmer" -#: mailer/models.py:63 +#: mailer/models.py:88 msgid "reply to custom email address" msgstr "Antwort an Email-Adresse" -#: mailer/models.py:66 +#: mailer/models.py:91 msgid "sent" msgstr "Gesendet" -#: mailer/models.py:80 +#: mailer/models.py:105 msgid "Some other members" msgstr "Andere Teilnehmer" -#: mailer/models.py:82 +#: mailer/models.py:107 msgid "recipients" msgstr "Empfänger" -#: mailer/models.py:138 +#: mailer/models.py:166 msgid "message" msgstr "Nachricht" -#: mailer/models.py:139 +#: mailer/models.py:167 msgid "messages" msgstr "Nachrichten" -#: mailer/models.py:141 +#: mailer/models.py:169 msgid "Can submit mails" msgstr "Kann Mails verschicken" -#: mailer/models.py:157 +#: mailer/models.py:185 msgid "" "Either a group, a memberlist or at least one member is required as recipient" msgstr "" "Es muss entweder eine Gruppe, eine Teilnehmerliste oder mindestens ein " "Teilnehmer als Empfänger ausgewählt werden." -#: mailer/models.py:164 +#: mailer/models.py:190 +msgid "" +"At least one reply-to recipient is required. Use the info mail if you really " +"want no reply-to recipient." +msgstr "Es muss mindestens ein Antwort-An Empfänger angegeben werden. Nutze die info Email-Adresse " +"falls du wirklich keinen Antwort-An Empfänger haben möchtest." + +#: mailer/models.py:197 msgid "file" msgstr "Datei" -#: mailer/models.py:170 +#: mailer/models.py:203 msgid "Empty" msgstr "Leer" -#: mailer/models.py:173 +#: mailer/models.py:206 msgid "attachment" msgstr "Anhang" -#: mailer/models.py:174 +#: mailer/models.py:207 msgid "attachments" msgstr "Anhänge" diff --git a/jdav_web/mailer/models.py b/jdav_web/mailer/models.py index 0d4e429..aeee21c 100644 --- a/jdav_web/mailer/models.py +++ b/jdav_web/mailer/models.py @@ -187,7 +187,7 @@ class MessageForm(forms.ModelForm): reply_to = self.cleaned_data.get('reply_to') reply_to_email_address = self.cleaned_data.get('reply_to_email_address') if not reply_to and not reply_to_email_address: - raise ValidationError(_('At least one reply-to recipient is required.' + raise ValidationError(_('At least one reply-to recipient is required. ' 'Use the info mail if you really want no reply-to recipient.')) class Attachment(models.Model): From 4c59eacf34fe1d28ed999d7c83e19b44fbca196d Mon Sep 17 00:00:00 2001 From: flavis Date: Sat, 26 Sep 2020 00:18:45 +0200 Subject: [PATCH 12/16] add activity score and show in admin --- jdav_web/members/admin.py | 77 ++++++++++++++++++++++- jdav_web/static/admin/images/climber.png | Bin 0 -> 26306 bytes 2 files changed, 74 insertions(+), 3 deletions(-) create mode 100644 jdav_web/static/admin/images/climber.png diff --git a/jdav_web/members/admin.py b/jdav_web/members/admin.py index dbbf5ef..d17c4e9 100644 --- a/jdav_web/members/admin.py +++ b/jdav_web/members/admin.py @@ -1,4 +1,4 @@ -from datetime import datetime +from datetime import datetime, timedelta import glob import os import subprocess @@ -12,8 +12,10 @@ from django import forms from django.contrib import admin, messages from django.contrib.admin import DateFieldListFilter from django.contrib.contenttypes.admin import GenericTabularInline +from django.utils.html import format_html from django.utils.translation import ugettext_lazy as _ -from django.db.models import TextField, ManyToManyField, ForeignKey +from django.db.models import TextField, ManyToManyField, ForeignKey, Count,\ + Sum, Case, Q, F, When, Value, IntegerField, Subquery, OuterRef from django.forms import Textarea, RadioSelect, TypedChoiceField from django.shortcuts import render @@ -68,7 +70,7 @@ class MemberAdmin(admin.ModelAdmin): 'town', 'phone_number', 'phone_number_parents', 'birth_date', 'group', 'gets_newsletter', 'registered', 'registration_form', 'comments'] list_display = ('name', 'birth_date', 'get_group', 'gets_newsletter', - 'registered', 'created', 'comments') + 'registered', 'comments', 'activity_score') search_fields = ('prename', 'lastname') list_filter = ('group', 'gets_newsletter', RegistrationFilter) #formfield_overrides = { @@ -76,8 +78,61 @@ class MemberAdmin(admin.ModelAdmin): # ForeignKey: {'widget': apply_select2(forms.Select)} #} change_form_template = "members/change_member.html" + #ordering = ('activity_score',) actions = ['send_mail_to'] + def get_queryset(self, request): + queryset = super().get_queryset(request) + one_year_ago = datetime.now() - timedelta(days=365) + queryset = queryset.annotate( + _jugendleiter_klettertreff_score=Sum(Case( + When( + klettertreff__date__gte=one_year_ago, + then=1), + default=0, + output_field=IntegerField() + )), + _jugendleiter_freizeit_score=Sum(Case( + When( + freizeit__date__gte=one_year_ago, + then=1), + default=0, + 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()), + _klettertreff_score=Case( + When( + _klettertreff_score_calc=None, + then=0 + ), + default=F('_klettertreff_score_calc'), + output_field=IntegerField()), + _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() + ), + _freizeit_score=Case( + When( + _freizeit_score_calc=None, + then=0 + ), + default=F('_freizeit_score_calc'), + output_field=IntegerField()), + _activity_score=(F('_klettertreff_score') + 3 * F('_freizeit_score') + + 2 * F('_jugendleiter_klettertreff_score') + 6 * F('_jugendleiter_freizeit_score')) + ) + return queryset + def change_view(self, request, object_id, form_url="", extra_context=None): extra_context = extra_context or {} extra_context['qualities'] =\ @@ -94,6 +149,22 @@ class MemberAdmin(admin.ModelAdmin): return HttpResponseRedirect("/admin/mailer/message/add/?members={}".format(query)) send_mail_to.short_description = _('Compose new mail to selected members') + def activity_score(self, obj): + score = obj._activity_score + # show 1 to 5 climbers based on activity in last year + if score < 5: + level = 1 + elif score >= 5 and score < 10: + level = 2 + elif score >= 10 and score < 20: + level = 3 + elif score >= 20 and score < 30: + level = 4 + else: + level = 5 + return format_html(level*' '.format("/static/admin/images/climber.png")) + activity_score.admin_order_field = '_activity_score' + class GroupAdmin(admin.ModelAdmin): fields = ['name', 'min_age'] diff --git a/jdav_web/static/admin/images/climber.png b/jdav_web/static/admin/images/climber.png new file mode 100644 index 0000000000000000000000000000000000000000..73d7ae2a6c71690d99977f337855738dc07f6857 GIT binary patch literal 26306 zcmeFZXH=6}_Xiqv9A^fV(HX^pAQ(WTDJn=;Q6W?Zlqv+JNmme%8my?O$P;?ikz%9@ z0!B(u1PLvO0i;_3NN5@eNFd4GPvX4mez;%nxBsj)YmuCDcHh6V&ps#KK51sK?vHJM zU@(|Na{3g1jfmC-PmofzD)Bd0E>PY!x~a6KNP{4sCb?fkFfQONy& ze~bR*TC&rH*Vl44Z4y6sH}(3KQ**}}wjR8A%Ti1A&@T=0|6bMq`=3+ypKRISPycw` z=-7>0(szD;xYkqQnhs<6!1sjSxuP_F>5?!fcyXb_kR}n zKMVZ-Xo0=7`{pdX&dB)c36zUdcDoT_jZLhw%^LLMuID&}JwAi+qWI^apZA`q%A?fh z^NmUk$N&=<%$COnxx=dpMn;TQz$d4>j;Xx$#gP^z>(Rjz%~(%Rf10=xP@!V6}UH8!GqtWgxgIM21tg+&Ec{WthF7r5dR6;{Dm)HBWf+#OC4 zB&Qwd)Vm#<#}~f8s!UQ?jSlYXljBM_?Ko5PH#*q(b*6J>MfYHH!b)^%n**Y)OKl-m zYfzVv^mqmxD<>P`hg4!U6p8qE1LXF%+3u1!ki$M+K<)e>7paz~Qe0oIx=oYO&?c_h z+ZT-EXjN;L378*#JdH3uV3YE)R^2H>#1WUOqwKx*T6x2Gd`;OId0=_ii+6yCH)4kz zy2!0op%%&9adzWra;Iaw{RBy?DK%y-lp!&5H*bk)Zyq^gT zJ%D-u*!R%-%Be|q^i6h-d03oSYHE@b>cRui~_pesXZBM+W!slrUN%lhONND z4=X69-1_Woi?0<^-=3&B#4Q@$E{rw$b7w@sg1?t!6_izb!}|?sJa|gNQMf3VXvzPR zUSso8!t^U zLMu%9UO`pox_}p^LCMtLiYd8mol=_V`%}DVh`n;oS)t3eWfD2j$Mstsay|C&>-UWZ z7v5JE670K=D>CHP|0^~6u%R+xViCu2$X_MYIX=jODf#!hgT<-;7N7B2n4?%J+&PV= z?BDEfbUo}g|DlQ>-TpghCX|V}Hlg4$J{gSHf-&9aU#I;YBKXZSfx+ReYYUbd5ZP9N5O!!=xI{I@|=rD0lsVqmA zSaP@5`!ziD@{HhNbFv^0y5>wK)i5 zhl}TpN~PGM*X>$*#NK=J`t3VsIO(T_3iv^mojn;7^5tA=8)0H8uB%6weqY~9kqCd% zJ?9;cQBqzwbMRS9oAS{D?rWRCW7i&T4)8#72JPDqW@bh9j6KDsiTxZmu;lN2(p4R> zfjer42Mq!&gh5a{nZ$`ctTZ-_E~+r+tE{_hCk#_ZMRWfnOv#nyseYIIGVFHCC6i@v z?SNJf6K9h$s@el}RJq0PB84_~o8C$yVCeqs%pw1xv6}JVdqrlYwn9l#>{47zQAyHk zw1P7x>{1zHFs|!Zunkmq8r4NOYe0jq(@JBZ(`me(@sGmT-(QYrN@B{>jgf+{1A#@w zZ~9gI?1gLG66MG{Q8C-`!PA=Rxib-$-GuhA4RsJKdjGOAefxKVaYCWvPd;w{Q|&Y^ zXnG*fw^iXiKXmG}kfLlNR%4fdq6o-%ZxJvlpD)bMy$a^$+*J<~1I!RtQ2XBE;2g!# zPhJpqd;d}(7T{2mlm(9!mF**B@p`-{Kjr`aiLQt7)G!G4(@=jjV_;is_@6~cQ%M4y z5v+nHaI;O}^-R7HuClbxWL%}|{o<*yCJ0m;c`!$DTl3TYeO`bqPe#(aYeoj{(sG6m z+9xqK3+u1$H7-# zi$N3raF7wJ4okza+Oc_V{yV>CV8`SZaR}a3wZtKgdi=efiGL7MEVGz|eJi_Ig%!Pz zM_SYJU>AY#k)xKjJB*EnLmUjw2xN3>_fBJ&Bh|XpBxG5YyEe!gyXc9i%ZAv<#?J25 z{eK%PARtnKtJfJCDhQI%xygdHFfieMhtP3+lRI+(H9i{Q6tZ&$~*6aWiFC4Ppf`EUX zE(QTD5?BRf^>(9V4?k8jWi6yEUwu6Pr=u#Roe`JOmggsB8EV9wiT*DpkLsv%i$6^W zki6remdv6XQ}pA(*})f*`MC+W?rfg!d5th2>=3R4^K=A`?|9HgtlmS!OLa-qMn7Pu zEU!G5?~&U4Z}?tz4`rmfEUabRsb`LIQ_PLEMF|qM=VCuL%bfcv?AD?iGVBoUHGZB# z$Im7P;E+ujRc2x;IR3_3bATrBP|@`Lt5rV?7i8#AP9h{OX&Jg|Mp|JXb3o zd!BtZ7CT`1s)-$e~|d(y(|O zS1K%%RcNw)9XTXAmls-|jzZz_K4sVLIo+H;pvu~EM@e<-1uxOw+2mpu|I1B{Q;9A< z%;MAeCqKH6tdUM8*lG!!{^< zMqPQ~h2Zme4|Hg-R}QDyKha=k)A3pVh3C1cWYvjKn#F2x&)QdauzH4EDSJeOSWrDa zlB(4&N7iQd@Zk|#{Q90#g|Q$nggY?z`S{C&*G=(HJg?m-WY8Q5QPFZputJ-Y00d_Qd#w}Zz6lUi%XC(M=|B6ai$wWV5RC3dX}PTtKKUYD z0GcNU9dX?`ya!6^8B5;YD-3)qv0j!Ge&Og-tQG|L`_N;}1vAP5Yd@K~OP4Gi8HL>= zXS;3vJ}n(~*s#B2U(HBs9Um&ar4d2OoErT+U!m$J_EJxZ9X90Ojw>w)=t=1=L<-FL z6H9Xf)6t(}sXdm2roXWQ>X(4}?4c~B)^q=w z!6v)=@APsai|yO@LFnBL-^DGSkrO2U$v8Z|JT2tMrtYWonSd4u)H_9{?y}R#s$R=a zgv=N{);?now- zz-}RN|E@GF)SUP)!NNJY8ANZ$2m9QX%pUxQ4Ih-Kee@ZheA_hYLYk&Tyy`@?Iq51O zjHg`&=URnH)~@}!d3lj|`*vpg4)?_S#(b~k8ljP%W2eQo87rHWdNh9FXGfabw}+$* zNS@eHX_$f+t$lLp2v9j#Xav06lA>M3E6n{vmb&7S*xjKp`(pHTD{x z6bIeJiBKPdp&|!*DC8gVy)%Ok- z>e^ijWiAwr6ig3X8Ez5qh)E4RlHPS= zzQ)e_vItqf_WZ%L#ImPe@5j3qI{B%^_bN>RHUqv!(IsE!@Wse_Gr2UA}DoC)_{K)?-Is1Znq+x2Tey$AXxdo4f&ZR1^hTcU-=HL02{2 z;J08i|I;v|tH89>eyr$yo++}tn_JA~U=>8VP|Xi1U(AYKJF;=%y#+-~X>0^MN|QiT zy$I)o5})VOC7VH`2ONnsPQ-bQF`3@#fS&PwISV(g`4g<_-$T83dh` z4>3M8s7AdxN12D>;E6PBqyv5X*=qvE^%e{rW{kKJ&(jy+%(1#!)3uwQBiwYJ+UM{t zD09aaENMgP-^q;8fDmK~l;5aqmZI~K6$B`a)idTwIdEEDV(s(pyW-ky>)hk-jeUpW z;`dn76gK~YQ0nPcx@2b#B|R(Du{4f`6=@jQ*=uFe$IG7m!!jtbj=_$|E`sK zj|cX85huIJ6Y&>j;(J#OE1+gPEMf%-uFM^2qF=O&rtMa5Re<0*Y6f(^6$rxO`A6`0 zcMe57oh6~uFqXim#-;(f%Szq0#&(6%Z%2B!DS8*@>S{Ew#duT~1_0IHJ)b zKY9z8%?Nxp^`jnZZpf;@W*M{EOq6wM5A^V&84bAFG)6v7HWBUl8`!FEHZ$b^M<2QZ>V{2%H6LWC1{Q?fi*|_KSF%;|jQ|V&l5|;% zsO2;!V$5VH)v9XLs!VPAl=w^YoL0xn{|LmpS#LquNshdKSF3bOg;b=U%Xba6k@P;x zb+bqk9HUlGAujry;Wb`FZ|f(r7|fpj{nTJ6xqEGt4HvEZ>W@yPaE3ReI9TP}T_w`B zzdO7bj5paPUnW+4Wh2JPOk0b+Cho>)U1lie{nLy1^j6(r^OU!1tg+-5Mdv=!Nuze<0IU%Iv_qsAik^u2=;V%uwFaFGvGmp&aw zf#u;<^<0=gQ5~7P<7$IF!tf&TGagK9sBDD{1grJKa&8R(t|57 zCqHE6^1i*Zo>s5deRW{@kNh_AOS`-@yz)*z^-&CQT}ZTiWo_xECvN{l0)zRZDgwc0 z^Dv#(k=v3us%s-}bU7L=bD00EdQvu$p5!tM(fB;V!q+_h@+UyLP2sQ!Kd230wZ2^a zUSlVlB3k-Q{Lxe6A@uHv z+!l>-ev*!#tM)nF2-am zmtX2G!C!GUQKKfTg~aHH@BNEnSXzyS*x#3+%H3Jcn#C%>xz42R#&o^O@WRMcI_i;L z|7ny-iT22lfA}#0i}UYIrq`2nqUfzS%is-eUtZApx=rO82f4$K0{SpaZ;fIok@tiI zHBX5IpIHgLcYR9&X6w=Q=@oH%S1BONsF{&7@r+8pP;&0Pwa_!X;k8t6??gtPe3{A+ z9!;%3v~=0g52P6GxS-_Hq`e}+#$@CBBaLOc)CgbSN4d|XiA&|4J$?RGfj*jD+WSW4 z8#2&M=kO6@;+X`Fe$&a%{~W#f`2h&m8ela%`G@I@PlD%mV;%|gcOhcO@>**Tm7Cx& znD~l4!06G4O0LK5)(!(eiM_FllSenZ8--a5(uGUeiu8t@YM#2cMu0}I9TPa$nmYVF zkSXYVwl=BZY7x%x?U3fmPX4Q=Qta5;huw*@*TJEQGAZH3Mw2lFl~aGA(WVU9FV(oZ zWNpK|y!vumSZx-gTf{z*q+M(g;Y<~SG`?e0yL)t>FkA3ByuGS)?aOy+Un>r366+KC zhHvj1(?cdpJ65N>hKAjflfyQpYE)m?piJ?`LrktW=-uFr-aZ-P zio*GYbN`(NEj0jHoZIrNfZH_DKkSQ!klhl>Zce=F!{|}bAXA81zT;haF*&kj((&Zi z_`jce6legNktX-cm#Ig_Q8TW)qavsO`Z;wn_gW2mJoy|Mkc{DPe`t5i9XZnar9xwRuNcetYeVxTnpXH9( zjVj&eX$F>VerGZmi2}(G>ulc1PNSjPCJB9fg^|A}lb~+bnnW!4I>b6&6=ojTYOEZ) z>Q48vFS1U)OmFCn0GUHiGeu>U8XjL?+vbK_Z%RILaV=cqC~bZ}c^n#m1rp5UJnd(Y z+*3NBPq%C@NdULcHQ8&3iD>9Bui%tTxid`<%4CUQe50^wX}w|-|4_i2-YH98LUk1y zJM{V2bV)P27=uk)p7uA?*lcg9M3##iDudnynfNzI>lT~*^p6izy1Wk$sE_Z!f%-~b zHZQ94+`O$x*`o*6U;r0G4OLgkt<^!io+5Gu><>rCu%nZ@XYT;}vwf=6Rfu>Zb@*nW{E~xR7GnbEn8bL6 ztC*@BNYT(>^VgTB>q9E8*u6NBUo6-&R)qO7MO6yjeGr{0HHmFdZRxc!YAcDVMEV8JM(HWZ`yvzZ?OoD)69pw28!flFpmw60sXq zRiI&QjJ(B$c*s%`L%om%7yp!5IbwPu!xCrqVw6CzM|$K!Y-N+0hIOcFIeM4Tlh;`$ zpX(tgw~xFC1M3n5URifYlO3JBkI~?;UG%`z6FR@@y}xb;*zt{C-hgh%dDM>Q>)O#P zrM>S%A+u_nXRfGCZ6c`WQBwsz*1tdlF{f)+Vm|(QAIH7ows!FZlCvXB^tr1CQf9%Q zF>TR!5zw>sPH_$pydWUhRER7CPN(i;grE~gG)Vm5+>!J+7aa5iei1b#NBFx5CU(qP z5K6Y{L-*h7?a(Tp5BsQ_(5YPo#-#@6RA>z>#P=&KCCQ6mqO2mIAWPrJFh+IxluhTu zv1${O2C08^Tq8Zox?EuOT@RfOB=&%UT7JNDnyE=MQ!D)1!ChzD_2&^FTl zeg`y>69i3MNCZ<<*SRV6a{QlmUHA-88tY&qHqt zC`Me$4yCs$o_&E!@nH(zIoPUAq(3lG{{)15_h!XlDes>x?7asU{Tjsjhj1>?+But0 zf3!vSfq&S@2UQucQjmm9x`Wwxcj#B|G?U2F>|zpS*flcXh%A5jNZPz1xrQ}?)k;J| zDCFZmuX{(HK~RcZoH3CgD};P1ZE7sVpYL@CCssd0x0Tko${gOw12>lyiRxOfp8KXS z$T*%3d!f9^kM*jflxyKzysV0*GXQ9&cLw7vNl*Nqlw9DX&T^wHZl1>IWI=b+aWmPs z8*97O4tmIymcEn%Ws6PF7VsFROFjTsL2Qop`ykZ^sG^pHnLFN!=IfQ9UY|-ZCVHPn zLus^2%aUNYO<(z*f!qs^7DQVcwP0KLl#YVGrN%BI{#!?`5^)(jGT-vPIes;!PP<=? z+Cb7%yeIWGcv5HfY!WtFj=Y<_@TBQzsWMT$=UtI??m*b_L|&hj8{9t zXoq|<8Fg4b!U?m;_F6BhR27KiTliAMPojb(Up8lonmRW%q%1$Zw|FTJYajz1*oA+a z&iNlfMjpA!@cGLH==N1dnBDzpl2v=ZCeO#--QSjFZ9$m1ZIZ@4+A^J9y5xE~?kNWI zF;v^=P}Sx53(NEE44L03CS0zpYRrqb#SwTDGS^>#>mN@O**zBiAre|auVmBQ5~ zaKcPn5^aetuIGE}L9%5{Y7weM)b>gTNIydIHvOTlXA5iJBiMyD#)V2oLzA@3Yw5Bi0?0_2OPLVsVIu^z-RJAltqN-6Aisp3o(WKmeG=^GRi>AmOrb>pi?n zItShdfGw+BSqd*L!bXP#%1xUYaizT0YUusi@aTgQ5wEy>fW2_Tux(PzZsA6Jzlx+h zN>s6VJuWhlbFF&|tMPt~dr+dB#%|YWoj9qnA000Z^1$VRNWW0VnP<>nyeG9C87Op~ zNojY)4Z-CqiNcfC2dr1nZ>hlb;^S&_O>ChXRAXsD__|HM-L%y9?a=!+HC!gbm>^Mx zr1e`Z^(XEdfwOF73Dzqx4}Y(uGVoJgqK?i@USQc9u!9H7k)_!^HZD&zc2SXzQmvfH z7`e<-f@sR1;JBZ>)_#Xj8BnDT%W7z`drrH2wM}w~?RZ(7JCL~;Bz}1%rek;P#&%f= znKRWgIEe=g{gg9`XTp?@+uaVk+h@&@M5Y}C)O28$a`E(srI6q>tcsa`B(pff(2Km2 zSb4FtXcJD^oU9XY4)y0lJ##beS?{&LhjzavOC21lNR?9^x1Kuqm&d`S8{J&z=IIm% zG?Oq~PB>H4#kr}Hs5k+XON#c`}o+4z1J5 z|J-Vyxy^2Y9zVFBAkb>JI}WY1qttGO?;!@ zAdi=~+zcn8N-s#M{OX6!GxnDjum*3_6pwS-Ut+_qcW1==hq>t^db`ni^zZ4=pK5%N zzhC1|@w81T+P-$I^s<4Xv`9Xb<6P)seHcF7c`dcQ3B z(8zo%G`5|gr*Lf|U|I`p zkXJryJ*NJ7ZHj2`rW))Id3JpfX#g&&*R3s`gaYgEqK%8H2)G^rf=Ox&$A zX?Zugcf^4{-vmj&w+3*IypM4Qs}(pIfrqnFDpn82t-aN>?#sTC`Kc7D%D+ZaLjDVh zZ~`f79@CnSOiYx6s+%_0G`2DB(;H`}Df?7LzkHS7nR!tR27*`7k~4KO*oZZX)k}Nj zE*8g|?dy2isd8lTq#)YXsX*`Bcdadvp|&GzxJwPUzMZizyN`XR>9v4sJhk85Sje@G zt!TVxAl;a1sW~6{2p)hrBg>wcgLe!UF0U(;UfoWlF zqkeOl-8L8MfkewLUe>OaS8Ys>qptXED%1=T$j{wh1z!1NZ2{x+}FfVx)J$FR+U$+{M?&}hSEQvuYd@L{@$?Fv)!;1``j}It7p=y6L2i9 z84TJ!nZ{Z5@2#dk%LKaS5In+zWEXtkE>q`n7*q5P&}^B|Z;^Pw3Ui|g39@!Sy4A#Q zU7b?>EJ1Bhm3kZ`akR)*g}paGLgw!cWVNLmi5W=g(ufyMO&;~d17{1u`M)=iGGebM z>L|D_z_nA5Z~)sYr~^F%D(te5NWh!h(A4ewAD zTZ7Q^`X`HH*)-VA*hu`Kf){asZ0HJ|b~!={r#|PFA%5}v`L02@((L83uA-L0#Q0k< zMN_1u%i2-ZVk)SNcQj;agx_GlX9dQoBT|b>vm|`@dqaw=;|9Cu*b)BOkjU&Yfn5OotuuePuva9V3P~q=I}b?rB!1N)p@>6#I=KF7f&O@D*wRcT1PDGo8Mra z+WL~di$|MhVpqkSf)Je>RZFRkteyV`>r8>+`al@SuRJX0@i$$cKxWI`Iw}A@{V=hJ=G@+R-)_g0+rU}$} z;+_YGW8U69(w#EvOK#>h=1e8Yk^^sl20L)zHvjCYW7)>yPU~7S;Ej&!LLCRYpp0s| zcP(`N;plF$A?^l9cVRgQiLrd z{ai3fFaFLgMeO<#WJZPv_-A*E&x*cO&yl8BU#@Tac55y1J zH+y{GJ#Dlcv}u?t!X>r_FR#qMuO&m49_d+nBamqdUheeKB`sV(*k&<^IEhRb+oJMN zOqOZmjx{zTgR;bH%uX-v6t+c#4rC87b4YsG&3k9-P3GVF_qOBMzT96`P|DK?><*a`N&uG= zFX@t2IgUIXY5;LSRiqO5+Y9)MCuw>ue-KRj^1T_Iwr>00%~9F>Qwu{*GP?lo3n zNd-2;EmeT3X`E~Min>X%aFCoH?_o9vHva%~NX8nL>W=ix-oX-1KGX$pG#gNYdRNlX z^;7QyIQ|h5s8TM!=Z6&-Feu<~(R`%9bg_Kk0Yi$L1ZHkc>&fGN+oPT(vY&iCBxyUo z(Xnv)hwbr~5N_UsI&ksgu9Srr5MD3D`4a#70%Ly*BwEJyWgJ`KY#?*tC@OJTz*~6J z-L6^t_;3USr+B{{Xm8PO&icG|Y$c@O0-tgO- zzx6LaI{)&|wL7uf)~)+P^|y_V$8N$8Y+P@i7{$58osC)PdH8`Fa^zSeW@X#?Yisv3 z@7!4RNFGZNc zFm=Tnt=OVC2_hr=)$d&?IlOdw&}JL^*~d8Dq|{!nK4M(V+W<)w`SB)+(lpba*Qc?>nqi5yywXgKrr-xU}T`dT2U)5Scw-Yibb)Rv{ANS+*-m1`cX=|QP+ZOcLds!s+ z^#0Iy!pXDnTDxV+hU1GiKVospr3v~O$zUu;Fjk2v*zjTUv!>T!&KSn zJ2>4qj>wEE2Oedblsc2-=$W=m_Z+Zn--Q&cByLI(nmYgHN1g<=0$*H)pDFMid_8ii4uS5=+G^JRA5Q&qxDRISOm?x_v#TFY5SvrYSy%o8w?wrV3Bu$0#t$!!6tjtKYaF&saOdB|_FHowf?1vYN!VC(){O%G z#`#4oC4ph2KxpQ*T2MZo>}OZh^<^?-NO}v_?q3LpVfLj7N1SeA7rt%?VW@e9<~p`n z5Z+n!v**?q7|VoMGil2WLha>y6BtrBo?=VYWv+vo<4(ejgSrMZHxk7=e=5}0DI<{) zK+>BLE8-l1%*vhI2#=gubnJ|Asmj}0mnma-{|dcXe9_cCznVG@x4=vlQ{%tqaq#inOX1tSAN4YWUC-3U;-4%FSV}8UL7~BF5Q2FdxWwqHR$%r*X&l+1m!sWNmY=(CrQOc0UTdn)c zOX@V0sACp{#9?C=4Odj%nFrpCiUXKAT2zt7Svm2Yq*p()FiboXSsn{55X_hT#@w7U zKI&boyALAa+U&6f?ob-zdV=mg7`#hYWQzrq57IZ=^b@s}sg)Llpy3&^Sfnxxe(h9b zC&6I+W*ci`RvbO6cVWpFSt^*j4K{XK394l0Pvw@NKHayq$J`+!3p5Eo4=a=JfF?}O zs%I)Cjq{e)AgT(kh};|vC0CR@h+0}yv3+*s#5q@{@U2qozWBunzA8ei1C z3`L+L(JMZbZ@t(71oxgUqvQL!f}HJ~G9EBeaJ3nn0FCF@aG5V&kD)|yX*oO=5N%mG zVb*)O@)SEne5e*%dFE396e)EKaMl&;{?jTYMVX9iB)#Ze>>F4;a;Y3_XHh4>u(x4Q zsU#9h1WQy6^208HOy_A}o{;iEO);Au z6V{_mI&W1q^{7);^Kf^A>39MbV0%^tDwSkbHoEtj5V9Cg6~aQ%bsxayVX$tq&D%}7 z!`eD})Z^B|MK55{f@f8xn9Z0Rx$BRt?J)f%ppp#Ur?LnyRS?-;zhr~t7qG5?xgLsv zP3F3SYPi32zrs?2Ke5PB9F2l0|%*Q`}(oihn zsZqOnzCpE$soNk#2Gd2E=_N$_oM{$RL6}1jJ}6ldu+u^&?dsjZ6PRCrPW8FMF2+q) z-=x8b2Gi=1$XOlT=etLw;2O>mVOV(ms7C2RbmA{OL8FtChgG+$AA=misKLI8PHt7X zsHaE$t9xz{zKk+W&53;VsABsUbEheD*1y;!hkAF28cfA`;cRb_f?(|8_k-cG@TC(@ zNVu`Ij^l!CFZfgjUt)~a1S(r|*B7YBl4+^B==zeCLWNK3U177ms~>Vdd;OR}2NZ+` zs+?ZkT}jkdr@D538-^stbc5?q@iIgT0FhnS@pa%!r+Y$%-R$)R69wax{c2joq^&41vzpU;w}>sjL=3ujPZ(bx)0#yaXO1ZXA@jtvdw*g zZJ{1$(>h2MDgCY@uk5@TZ_LWWO~)}1uWL&gf%x}<+t{mxM%4E5~i| z6Rh7G3(5ILuxlilLJ=QkNf?^=T8}e3(@X{7>(Gs2EkL`Y)07cw*?T!AqHC;rS~vY( z)**0uR01HBbU}sJ#Vdh!NKX#WJAY}mPy!2tTVabfF6k@ES>0`lRBN%_HFJm3UHUh- zqd&C!6->6ms^2u?O1Tns2i)PX-8H&-PVe(jXb(sR)XT3(W38N6f#b=u-4ETErcn2z zfOrIy4etuFw0Fu#W<0~yCUeTtEQXC(U(ycw!OATVf(_O_>Itj6M-c7(=#ykO%PAsG zx%TTTkhbmt=vpJdZ!~KTLjKfUYlbqVH zPrYEox=t|Sr_=rSAW8YMAp|BZXG9k&R#@h=9z+TP2)A+aG51gMR`AuLq4v z!QiE?pahYEFGOfXX6_1CF07!rIA4UP&=`A2ujAG}*3ar*o`MV5#o@Fc)pm{x+qR(= z5}2<@kTnX}kRW8%`kT02b?lBX)aiS(KoK&)uwqG3A|n6zV@@1M>>cszuWBzv<>#_4lKxlZLw7$|pE` zb$108`i-A9gzFZ{&Afs!r}top4^OAoJ2*KUphghGLx0IKo|v%%8%3Eyj9KJYES00b z;f3C7?8n(B^E8y8;^YuccXwflyNrekyXCL01&|XeijEW#KK;CSh$wWoF?)?R+h{(2 z43VL_%uTEUw7FGg(M!kDh*ZJw^!aXp%+5kmur(-bt zvfuCUXWcCur}1y|0vhPI*^!#?EoRr*D>=JHbZpQW=V69njmH1i*XOyX(63L_tu!{6 z_yo#fwyY3Q+@U0}0p`Rj__{?X$Uh}@ozFDUU(8?{fK7n}s!P;45Xp+L?SFp?6^JjZ z>pZCkZ9GiW(akp7m}n(~+3j*A=g|-vo`Nr%1!NE94jZzLc--I`GsUpm2zjU)_ni!o z2omLmQKvp?Q-g0G=y*Gs@i5#F`s>9*t@G%Ejm|x|;g|&{AbjDMU=!|*)5!WMIdppT zkCRN5eB{!3Q53b$e4V4?Psxmb8-k-4=0!(VHt*j(xacu~VX(L$SvYaUJjL!HS%N!Lv|c9JDd)9UB=Ac>N0)Oj)wToR=`c!-^3 zflJL>gU(d0kr96CK?|noNhANAg3rR>`r1)uNsvm&KK{}Tr25+UJFeZB>v{^=JT53= zwu_o_kHaTn2xvZ2qGr4eTBhN474F%zh?C~r<3Kq>;GqMbVX})+=hK&Ht(W}#3vm*TS(b=Z7Z)_B%N|r= zCmG}HT#HeU5i7vP=qG`V_nuWZPua`M#t}#@o*4`T=!T(@+beh99KDp;R&?-!z)2zTq0{;}-;9O~&8xH_BcZzI%#FAW|1h6j!wn@e zG<&zrys~rDtN7Rp-Yfa_t&D~`JLv=|4=UFLTv6P}~*oWC_))?p`_8%9rQC3I(rvNtC@tg$pBCbDTe^pwayP$F25ML57|f<&Y?fMoJ4b zoJ1mHqpc3g)!KIQD+p_oQ)WTKm}oky#FKfanE$khj*wy z+<{=ETAJFWDlv4eyt=4VNY-1_kUMfR)pJY^wl)Q~@S$|htVd3ml0X&3-f231eGZA8 z1GSbv>26QGCBdw4?#^l&6x>lVFGH9rH#_4wc$@@s4_~vRD$9%;Qr_tFvHp^i%77J@_tW}i23Gjhm*Zmxi zHRHOT+-5u`qp{zo_RIM&WY&_^;eZ2m$1!453ExlM`B+4&FnegqUMB2^U9;#O`-juzt;rl-CB(qM>CcOg|G!8%0DQ!AH#P zT*a^AqPIP4_>(f?5jq zmMKXbP@y)we(7z_Dx}kD69h2sBvK*MOXRdWaznmp~+9c5BmB0JB)&V(to3p z!#gLpV~Akv|Ni9E?<9U%hmne{koWbhk`A={*CV5$bhm+)!=oKn;J&}=j;7WPGc#_E z8OI6{lO_9aN&E5{{Msr=X0=bpNt6!KaBU)E9H+d!aZ(3}b{bJi1+KZ|C)jv9NEF>H z#ygzZTFY;5==>Cq9KACFrY{#>(D9>m>nt<>VXW9XOMfn%-|LY>_&HjkL)9QrT1m0G z2xA^saYLRZQ7|$p0MdY$mQ0n4o)ma)aq;}CFiZXz>8BOUBw;f%^O+yS7UTRq&r^vO zeE0kW$7-QKwq&p6qu$Hk^M`L`GIeN~8bE1McJDda$rbW{G#`AIT+Xh$t7D!gI z5cu~^nw09XlhjSC0PZ3XEv$y_+~){Twa19^lk$kVCE?-(c^KzEg^i^(8b8aM`%4M_ zWtIfSeVnpH=p2L2*YK7R1m&Td@W$bDcYw9_YZGXpwZd%xLt)pCyTdbI(Bt(ATv5Pv znkm6Io}-skIHxCok|^H+1$I(JX4v4jTL?DEgcX1uB|Un7oUVz*E`GkF+cS;zYj4~* z<%o-PYp3C|a_20sInS_!nu+lYm@3{n!GoVRw2c_yijwZ(N5YOFz57Cq(8rdB$PyNw zUG`(i@JJBfIhK-|fiuhJ{rMLdyJf;mNMqI)DgirMhRz`=D<5k_%;k&REeYFY+uTVK ziLYDU86ybNs8O^~DbK8$_;$-C&I=o2i_9?A%t8aJdV3ykiEfnTC~$phg#z^o9p4=~ zHGqA)&g{0UmJ&71pP{$3*(NRcJ9f#EU*nlW+^iJ|XcfebWU6@RENrjeb?&xrGGj_( z-bAsaV)Pn`?^xcMA*KaYK$IHvtg7kw&d{l=KYou1T!bgb35%oWu9P37q;O`e6lyLA zGVpvS{Bq{V1UX4G_LkCzOa}CC78KRQL<$NQZ_o*qb{-3YknczrY~xqE1M=3I-EMnv z9tm^*BJ&|FC1!PZ_hr#;|5?jaC~L+YV~Srt<0Rr3>E1qSa1pVPMB?8E z*@QO9U4~u4O((ej(}sO{&~rVb#ykGjwY71j*FFOsyOQ`tf}Aid3U7zM9RCz zJ?z8xmZk)g7KM|tcO;F}e6@=S-b|cPMEP1lmSu7>6si68`_kF#D-O{XlyHqKV9BQ z5TcRncp!-q#U9|^GC9H6cD%hQz{DT^+&hP0@WBhm}zwgaTuh9P`YR7Zf zem-{v(6&fy2q>vb%@xh#X?-x_dkD;%d>88PzYcea6+8B4-OGw{o&d(R95U>i6tCJ5B; zTD#a{MVVS*5Ds-Ky*ruF~;Ep;OgZImo?2aHH)Yo*FGdu(Y!K$9VLr1GZ`N? z^w|K;1AEs8n{soWjtzku66;{J62}b=Y7XfEjrZER=kYAM0@Qa%Y%q4Z()BCwwd=tP zC^H@O*kVsJ`*mR1o0a)H5~tH94kIzXpR0jwbBz=`>1k%w41nH7k%AyVh*8cDxAk!B zdDvI<<8*rSysSp#1tZ0n)mB>YPs1z(-{B7Z9G=PL9fhbs={5mfF&tcH5Tf(C*Qo`3 zfZ`{?%nKPo^2URtFv4M`y4V|us@zpRM z0P#Vy{5shtu@@HS1jD6AS3vMqi46*1k<9#D-jeo>>5AS}aI${l&lW5dfu+Y`>0C3N zQ2cg4Bklib@7m*`&f2(-&9<#Ar3*>gFrzjZmCGno)@o~#Y1ee|rp&lT2~n<@p;$_& zp(rz`O_LH6Vzp_c(oS?mtK7!AW!zgsWO$$7Ioj>Bf4+adpY1r55FiYVS?j-10=mS!4DI& zgAVpm0hRsm(ldd|GeBjPYR+)@YI(DK)nO+dxA*K05g;t$JR-eLgK)}QygC1X-NVx$D3wq*XM0wnXK6XCN$%rUhlP`eA*_HbwKy%D1o^*wZ%%I@JWb z61gO(8FGoo0tbCbW71JmId_xy6<=~l&AUW^C9~ozllwweLV;P77dRYkZii3Q$+^1> zUplrfiH13}z`-bvw2{J=IX{*u$K8i(U!C@oK@~U0w(=ZvWI&;GjenZBaSNGPyoj$p z-%v#5+3JUQm~m)%WBm!=eb~>7$sHiNi(B9-w?R*!KjBc=vtnp~@PHUud82 zPn&F(sv0hk>(Tp?+iuY(R#qwk{5iY=Uc+elBxDaO^CrOgk;00w8aSX7wdqN6oxE^|fX61vXRmyQYe)*o4rv2)fTWfzCHzS@!2gXus z3PYhT>`XVd)CC^|m8>onTO+WDtB6Ed#McLk&ij<O!HXy5^9188b2;K}`KcHeiLCq`@BTtah76mIUc>&S<6M2Zw5|Yqhkvnw?&6o3H zkUo5cbE5jdbY71ZU)|LeiG1L2Gn#TGOc{lzj1Lq7S%5h#`g(4d=$&ddOFGoh2U{b1 z%z*4`M?lgC8_kNx%0v(Tm9$NNI#c|h>sIOO;K=pWQ;!bt(fnR8{{uY#LzFC2s8h*u z75FG2vWwa_Vn!!ZOWp6Eq8WF3I`U}9DY^i=9{hB)PtuqQ<5{?8u+7#8tl9Gq_Q1Ae z-OR+;85qv_79;|^bq11Qz(fTz(4`=eb<8bw$>o@V^fJ6dMqu)R*Q3c-XF4NwS3m(o z!2A$ow#Hl~s7;nar3F2fCa0<+YdGY?_i2=cofGx2*U+3ADj*OjVQJ~inTr9oJzPro zm(ModK~Q2pp6-Mbauk{yC~+DoVJLYD`)LBbKY4s(D(T8^NWyb%3lRVXkY8<(>y@c% zXSn&w0KUMlCApy84y@fY@df02T_B7x7UozZdiEIv`HTYOW-D*QaTX{s3`A5!RCg(e z03cw5ka!tBfMdcENZG0g8_qb?I(4qDw2(liVf74sv;dg5kf>rfz8@Xv?GeJ zSe(+RFeTWER=Q-vRIjM zloRga8CO{@liTvs!>R{j-(v69epuu^-W^m6qrbk8CclkD?xOvWa)lFF9k35<%fNvC z2q$I)Q71f#ao4^lq0d4g-$}Oc@!K+ly*@!2jr@WM(rm)g7^6lNq_bgz*A`S#s0%W1 zOo%su!pLJlVIe|+`ruPbHetp~;d?D$Bt<(g#?Gw$M;?wyNN}^TV$qCkM65iOROI=U zw_vW;TpohYBiQCJ#+`>scz~}y#qAL&dlo6HKME-t;!)+k-X&KA=;8kNaO_A#%W*nG zhp$dU?wuJng7S2eDRpTm@@1=$r)Y$82w0$`0^NKn%ByQEby2R!^z}s~I@FH?EY!dR zFrM1_mrIk&2*XAs&=9&Gpg8{)hyU6m)?m1+p+rW_ADruFtcD=EJ@gz28-HK#*klHh zzw!0Cc&;KBCm*vgyeI<2#!T4jK*Nz?qC03sBm&V(OeB2nC>;Ln>nXr0$&CHk1_<_m=dhokJ8Pb z`LXFp^VGu5E1dJyjzk6b$zY0*bFtd7KlQ+BktKq6>zhUP;^$pA(w>+ubkIT8BKHci zS#Uxp>{z5~I3@3JQ4QrtsBGfTc$ITg@`mk|rK~M2G?xs1ItZp;S2|Q>u}`@Y8M^E; z&0HQQ&{=!ht&_hd(tS!V4ozMu&AGU#@FzZA!lN!PKD0c&7LKK zGh3kB+SHZJ8ZNA>)L5vTgEOPudw-e6`Wr`N)^j6j(dv~s12&iC!mK87-QfK#C|N#_ zT4OIB^2)9^3LF>V{gz3KF0p$XthVmde_ix9<*Cjr&SpI{H_#hk@4xxCm`q3(HY@mBwLP;;-q`7&U=V9fpbys&zxdtc$q z*U_+EUYWT(u;T9_vf`jj!V8A)1B5tvjE77jE~ws0*v`P}6+%06FKCMgzI(rB%gHJ=>@VKdw-$xKFell#b>AZ zv|BmHBUIj2%w>vAv}`$JCzoT`SHSE3AtpaL-Kjku1=SdA!ZTMqB8^(fG&Y|Vq_w?t zq-}Q4LriZ6So0FIIA^xQxrlk)*qrj+*h*Qp2Jm*EYSxjFWOr?yob!l#5nf z3gEO9R>6n!4xEpf1_eLpC7r!%TmCwEoX~w`UT%I(btLvrzf`EYLS-61ICd%Mxxe%r zkJLp#`_0?McsEu<`FVjCmuFBRHrwooreT-Klc3X?!u(DV!0W!z@kM4b61=LbP#-QZ zoE~$py%?@1F_a|c^hhqAL%$imeN1;sZgb4uyC>XSQpxMt)7xrofxxM|T&W-8$xHMo zjl1qk3UTK#;Zm{jmq-NBG`KBU50|}b6@|4Kq>`}lYhNqymW}k`rhYLUE~ff2@(tox zm&NIDaX9^Kn-4u#^vWRO5T-I6eF5*U#kar8Bag=GC)&P~>W^&okI~=f)+#KCSb_pu ziCu&AQqnX<0x5luvMC&&Avgd3`x#u}-+%u=;QfZ)drP6JP>RT$%)62Bk6-`&^Iru1 bX9U>F+fNQ!>>pri5L3`MGHi0K*~k71dJF~= literal 0 HcmV?d00001 From 7e8fbf241edc84ba8586511521ab1b79b93dd950 Mon Sep 17 00:00:00 2001 From: Erich Hasl Date: Sat, 26 Sep 2020 14:11:10 +0200 Subject: [PATCH 13/16] solve bug related to gunicorns random module loading behaviour --- jdav_web/members/admin.py | 81 ++++++++++++++++++++++++++++----------- 1 file changed, 59 insertions(+), 22 deletions(-) diff --git a/jdav_web/members/admin.py b/jdav_web/members/admin.py index d17c4e9..1cbe701 100644 --- a/jdav_web/members/admin.py +++ b/jdav_web/members/admin.py @@ -85,26 +85,69 @@ class MemberAdmin(admin.ModelAdmin): queryset = super().get_queryset(request) one_year_ago = datetime.now() - timedelta(days=365) queryset = queryset.annotate( - _jugendleiter_klettertreff_score=Sum(Case( - When( - klettertreff__date__gte=one_year_ago, - then=1), - default=0, + _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() - )), - _jugendleiter_freizeit_score=Sum(Case( - When( - freizeit__date__gte=one_year_ago, - then=1), - default=0, + ), + # 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( @@ -113,23 +156,17 @@ class MemberAdmin(admin.ModelAdmin): ), default=F('_klettertreff_score_calc'), output_field=IntegerField()), - _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() - ), _freizeit_score=Case( When( _freizeit_score_calc=None, then=0 ), default=F('_freizeit_score_calc'), - output_field=IntegerField()), + output_field=IntegerField())) + queryset = queryset.annotate( + #_activity_score=F('_jugendleiter_freizeit_score') _activity_score=(F('_klettertreff_score') + 3 * F('_freizeit_score') - + 2 * F('_jugendleiter_klettertreff_score') + 6 * F('_jugendleiter_freizeit_score')) + + F('_jugendleiter_klettertreff_score') + 3 * F('_jugendleiter_freizeit_score')) ) return queryset From d64a5414947512f43145e953451d743b9c8c4625 Mon Sep 17 00:00:00 2001 From: flavis Date: Sat, 26 Sep 2020 14:16:12 +0200 Subject: [PATCH 14/16] add translation for activity_score --- jdav_web/locale/de/LC_MESSAGES/django.po | 2 +- .../mailer/locale/de/LC_MESSAGES/django.po | 7 ++--- .../material/locale/de/LC_MESSAGES/django.po | 2 +- jdav_web/members/admin.py | 1 + .../members/locale/de/LC_MESSAGES/django.po | 26 +++++++++++-------- .../startpage/locale/de/LC_MESSAGES/django.po | 2 +- 6 files changed, 23 insertions(+), 17 deletions(-) diff --git a/jdav_web/locale/de/LC_MESSAGES/django.po b/jdav_web/locale/de/LC_MESSAGES/django.po index 3f5398e..e895249 100644 --- a/jdav_web/locale/de/LC_MESSAGES/django.po +++ b/jdav_web/locale/de/LC_MESSAGES/django.po @@ -8,7 +8,7 @@ msgid "" msgstr "" "Project-Id-Version: PACKAGE VERSION\n" "Report-Msgid-Bugs-To: \n" -"POT-Creation-Date: 2020-09-24 17:31+0200\n" +"POT-Creation-Date: 2020-09-26 14:15+0200\n" "PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\n" "Last-Translator: FULL NAME \n" "Language-Team: LANGUAGE \n" diff --git a/jdav_web/mailer/locale/de/LC_MESSAGES/django.po b/jdav_web/mailer/locale/de/LC_MESSAGES/django.po index de74673..dfcfb75 100644 --- a/jdav_web/mailer/locale/de/LC_MESSAGES/django.po +++ b/jdav_web/mailer/locale/de/LC_MESSAGES/django.po @@ -8,7 +8,7 @@ msgid "" msgstr "" "Project-Id-Version: PACKAGE VERSION\n" "Report-Msgid-Bugs-To: \n" -"POT-Creation-Date: 2020-09-24 22:08+0200\n" +"POT-Creation-Date: 2020-09-26 14:15+0200\n" "PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\n" "Last-Translator: FULL NAME \n" "Language-Team: LANGUAGE \n" @@ -135,8 +135,9 @@ msgstr "" msgid "" "At least one reply-to recipient is required. Use the info mail if you really " "want no reply-to recipient." -msgstr "Es muss mindestens ein Antwort-An Empfänger angegeben werden. Nutze die info Email-Adresse " -"falls du wirklich keinen Antwort-An Empfänger haben möchtest." +msgstr "" +"Es muss mindestens ein Antwort-An Empfänger angegeben werden. Nutze die info " +"Email-Adresse falls du wirklich keinen Antwort-An Empfänger haben möchtest." #: mailer/models.py:197 msgid "file" diff --git a/jdav_web/material/locale/de/LC_MESSAGES/django.po b/jdav_web/material/locale/de/LC_MESSAGES/django.po index 2ab3ce9..cc4634a 100644 --- a/jdav_web/material/locale/de/LC_MESSAGES/django.po +++ b/jdav_web/material/locale/de/LC_MESSAGES/django.po @@ -8,7 +8,7 @@ msgid "" msgstr "" "Project-Id-Version: PACKAGE VERSION\n" "Report-Msgid-Bugs-To: \n" -"POT-Creation-Date: 2020-09-24 17:31+0200\n" +"POT-Creation-Date: 2020-09-26 14:15+0200\n" "PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\n" "Last-Translator: FULL NAME \n" "Language-Team: LANGUAGE \n" diff --git a/jdav_web/members/admin.py b/jdav_web/members/admin.py index 1cbe701..395f250 100644 --- a/jdav_web/members/admin.py +++ b/jdav_web/members/admin.py @@ -201,6 +201,7 @@ class MemberAdmin(admin.ModelAdmin): level = 5 return format_html(level*' '.format("/static/admin/images/climber.png")) activity_score.admin_order_field = '_activity_score' + activity_score.short_description = _('activity') class GroupAdmin(admin.ModelAdmin): diff --git a/jdav_web/members/locale/de/LC_MESSAGES/django.po b/jdav_web/members/locale/de/LC_MESSAGES/django.po index 31647d9..71ae579 100644 --- a/jdav_web/members/locale/de/LC_MESSAGES/django.po +++ b/jdav_web/members/locale/de/LC_MESSAGES/django.po @@ -8,7 +8,7 @@ msgid "" msgstr "" "Project-Id-Version: PACKAGE VERSION\n" "Report-Msgid-Bugs-To: \n" -"POT-Creation-Date: 2020-09-24 17:31+0200\n" +"POT-Creation-Date: 2020-09-26 14:15+0200\n" "PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\n" "Last-Translator: FULL NAME \n" "Language-Team: LANGUAGE \n" @@ -18,39 +18,43 @@ msgstr "" "Content-Transfer-Encoding: 8bit\n" "Plural-Forms: nplurals=2; plural=(n != 1);\n" -#: members/admin.py:27 members/models.py:75 +#: members/admin.py:29 members/models.py:75 msgid "Registration complete" msgstr "Anmeldung vollständig" -#: members/admin.py:33 +#: members/admin.py:35 msgid "True" msgstr "Ja" -#: members/admin.py:34 +#: members/admin.py:36 msgid "False" msgstr "Nein" -#: members/admin.py:35 +#: members/admin.py:37 msgid "All" msgstr "Alle" -#: members/admin.py:95 +#: members/admin.py:187 msgid "Compose new mail to selected members" msgstr "Neue Nachricht an ausgewählte Teilnehmer verfassen" -#: members/admin.py:111 +#: members/admin.py:204 +msgid "activity" +msgstr "Aktivität" + +#: members/admin.py:220 msgid "Difficulty" msgstr "Schwierigkeit" -#: members/admin.py:115 +#: members/admin.py:224 msgid "Tour type" msgstr "Art der Tour" -#: members/admin.py:401 +#: members/admin.py:510 msgid "Convert to PDF" msgstr "Kriseninterventionsliste erstellen" -#: members/admin.py:510 +#: members/admin.py:619 msgid "Generate overview" msgstr "Hinweise für Jugendleiter erstellen" @@ -211,7 +215,7 @@ msgstr "Teilnehmer" #: members/models.py:303 msgid "Title" -msgstr "" +msgstr "Titel" #: members/models.py:323 msgid "Location" diff --git a/jdav_web/startpage/locale/de/LC_MESSAGES/django.po b/jdav_web/startpage/locale/de/LC_MESSAGES/django.po index fa4c2a7..89eb720 100644 --- a/jdav_web/startpage/locale/de/LC_MESSAGES/django.po +++ b/jdav_web/startpage/locale/de/LC_MESSAGES/django.po @@ -8,7 +8,7 @@ msgid "" msgstr "" "Project-Id-Version: PACKAGE VERSION\n" "Report-Msgid-Bugs-To: \n" -"POT-Creation-Date: 2020-09-24 17:31+0200\n" +"POT-Creation-Date: 2020-09-26 14:15+0200\n" "PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\n" "Last-Translator: FULL NAME \n" "Language-Team: LANGUAGE \n" From d393e9aa87015e8ebadbde4129c2a30b4262e912 Mon Sep 17 00:00:00 2001 From: flavis Date: Mon, 28 Sep 2020 12:12:40 +0200 Subject: [PATCH 15/16] add new management command to retrieve assocation emails --- .../management/commands/get_assoc_addr.py | 26 +++++++++++++++++++ 1 file changed, 26 insertions(+) create mode 100644 jdav_web/members/management/commands/get_assoc_addr.py diff --git a/jdav_web/members/management/commands/get_assoc_addr.py b/jdav_web/members/management/commands/get_assoc_addr.py new file mode 100644 index 0000000..b477b0c --- /dev/null +++ b/jdav_web/members/management/commands/get_assoc_addr.py @@ -0,0 +1,26 @@ +from django.core.management.base import BaseCommand +from members.models import Member +from mailer.models import EmailAddress + +import re + + +class Command(BaseCommand): + help = 'Parses an email address and finds the associated jugendleiter' + requires_system_checks = False + + def add_arguments(self, parser): + parser.add_argument('--sender', default="") + # parser.add_argument('--recipient', default="") + + def handle(self, *args, **options): + #match = re.match('reply-to-(.?*)_.at._(.*)', options['recipient']) + #if not match: + # return + #name, domain = match.groups() + #address = "{}@{}".format(name, domain) + # recipient = Member.objects.filter(email=address) + sender = Member.objects.filter(group__name='Jugendleiter', email=options['sender']).first() + if not sender: + return + self.stdout.write(sender.association_email) From a99dd36707260639b093db475d8e9da0fcba9d3f Mon Sep 17 00:00:00 2001 From: flavis Date: Thu, 1 Oct 2020 22:33:36 +0200 Subject: [PATCH 16/16] set default textarea height to one line in membersonlist --- jdav_web/members/admin.py | 10 ++++------ 1 file changed, 4 insertions(+), 6 deletions(-) diff --git a/jdav_web/members/admin.py b/jdav_web/members/admin.py index 395f250..89e72fa 100644 --- a/jdav_web/members/admin.py +++ b/jdav_web/members/admin.py @@ -236,12 +236,10 @@ class FreizeitAdminForm(forms.ModelForm): class MemberOnListInline(GenericTabularInline): model = NewMemberOnList extra = 0 - #formfield_overrides = { - # TextField: {'widget': Textarea(attrs={'rows': 1, - # 'cols': 40})}, - # ManyToManyField: {'widget': forms.CheckboxSelectMultiple}, - # ForeignKey: {'widget': apply_select2(forms.Select)} - #} + formfield_overrides = { + TextField: {'widget': Textarea(attrs={'rows': 1, + 'cols': 40})} + } class OldMemberOnListInline(admin.TabularInline):