diff --git a/jdav_web/jdav_web/settings.py b/jdav_web/jdav_web/settings.py index a6271a8..6ae3383 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 = [ @@ -205,17 +205,19 @@ JET_SIDE_MENU_ITEMS = [ ]}, {'app_label': 'mailer', 'items': [ {'name': 'message'}, - {'name': 'emailaddress', 'permissions': ['mailer.emailaddress'] }, + {'name': 'emailaddress'}, ]}, {'app_label': 'members', 'items': [ {'name': 'member'}, - {'name': 'group'}, - {'name': 'memberlist'}, + {'name': 'group', 'permissions': ['members.group']}, + {'name': 'memberlist', 'permissions': ['members.memberlist']}, + {'name': 'membernotelist'}, + {'name': 'freizeit'}, {'name': 'klettertreff'}, {'name': 'activitycategory', 'permissions': ['members.activitycategory'] }, ]}, {'app_label': 'material', 'items': [ - {'name': 'materialcategory'}, + {'name': 'materialcategory', 'permissions': ['material.materialcategory']}, {'name': 'materialpart'}, ]}, {'label': 'Externe Links', 'items' : [ diff --git a/jdav_web/locale/de/LC_MESSAGES/django.po b/jdav_web/locale/de/LC_MESSAGES/django.po index b29effc..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-01-24 23:21+0100\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" @@ -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/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/locale/de/LC_MESSAGES/django.po b/jdav_web/mailer/locale/de/LC_MESSAGES/django.po index f786ed7..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-01-24 23:21+0100\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,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" @@ -38,97 +38,123 @@ 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 participants" +msgstr "Weiterleitung an Teilnehmer" + +#: 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:46 +msgid "email addresses" +msgstr "Email-Adressen" + +#: 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:22 +#: mailer/models.py:68 msgid "content" msgstr "Inhalt" -#: mailer/models.py:24 +#: mailer/models.py:70 msgid "to group" msgstr "An Gruppe" -#: mailer/models.py:27 -msgid "to member list" -msgstr "An Teilnehmerliste" +#: mailer/models.py:73 +msgid "to freizeit" +msgstr "An Freizeit" + +#: mailer/models.py:77 +msgid "to notes list" +msgstr "An Notizliste" -#: mailer/models.py:31 +#: mailer/models.py:81 msgid "to member" msgstr "An Teilnehmer" -#: mailer/models.py:34 -msgid "reply to" -msgstr "Antwort an" +#: mailer/models.py:84 +msgid "reply to participant" +msgstr "Antwort an Teilnehmer" -#: mailer/models.py:37 +#: mailer/models.py:88 +msgid "reply to custom email address" +msgstr "Antwort an Email-Adresse" + +#: mailer/models.py:91 msgid "sent" msgstr "Gesendet" -#: mailer/models.py:49 +#: mailer/models.py:105 msgid "Some other members" msgstr "Andere Teilnehmer" -#: mailer/models.py:51 +#: mailer/models.py:107 msgid "recipients" msgstr "Empfänger" -#: mailer/models.py:102 +#: mailer/models.py:166 msgid "message" msgstr "Nachricht" -#: mailer/models.py:103 +#: mailer/models.py:167 msgid "messages" msgstr "Nachrichten" -#: mailer/models.py:105 +#: mailer/models.py:169 msgid "Can submit mails" msgstr "Kann Mails verschicken" -#: mailer/models.py:120 +#: 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:127 +#: 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:133 +#: mailer/models.py:203 msgid "Empty" msgstr "Leer" -#: mailer/models.py:136 +#: mailer/models.py:206 msgid "attachment" msgstr "Anhang" -#: mailer/models.py:137 +#: mailer/models.py:207 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 +273,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 2a8b7ee..aeee21c 100644 --- a/jdav_web/mailer/models.py +++ b/jdav_web/mailer/models.py @@ -15,6 +15,52 @@ 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 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 + + class Meta: + verbose_name = _('email address') + 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""" @@ -23,17 +69,25 @@ class Message(models.Model): to_groups = models.ManyToManyField('members.Group', verbose_name=_('to group'), blank=True) - to_memberlist = models.ForeignKey('members.MemberList', - 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 +95,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 +115,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_memberlist.memberonlist_set.all()]) - members.update(self.to_memberlist.jugendleiter.all()) + 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_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 +136,12 @@ 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 = [jl.association_email for jl in self.reply_to.all()] + # reply to addresses + 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_unfiltered if mail != SENDING_ADDRESS ] try: success = send(self.subject, get_content(self.content), SENDING_ADDRESS, @@ -92,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() @@ -114,11 +178,17 @@ 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 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""" @@ -135,24 +205,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..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-01-24 23:21+0100\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 e67dcde..89e72fa 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 @@ -9,15 +9,18 @@ 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.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 -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 @@ -67,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 = { @@ -75,8 +78,98 @@ 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_freizeit_score_calc=Subquery( + Freizeit.objects.filter(jugendleiter=OuterRef('pk'), + date__gte=one_year_ago) + .values('jugendleiter') + .annotate(cnt=Count('pk', distinct=True)) + .values('cnt'), + output_field=IntegerField() + ), + # better solution but does not work in production apparently + #_jugendleiter_freizeit_score=Sum(Case( + # When( + # freizeit__date__gte=one_year_ago, + # then=1), + # default=0, + # output_field=IntegerField() + # ), + # distinct=True), + _jugendleiter_klettertreff_score_calc=Subquery( + Klettertreff.objects.filter(jugendleiter=OuterRef('pk'), + date__gte=one_year_ago) + .values('jugendleiter') + .annotate(cnt=Count('pk', distinct=True)) + .values('cnt'), + output_field=IntegerField() + ), + # better solution but does not work in production apparently + #_jugendleiter_klettertreff_score=Sum(Case( + # When( + # klettertreff__date__gte=one_year_ago, + # then=1), + # default=0, + # output_field=IntegerField() + # ), + # distinct=True), + _freizeit_score_calc=Subquery( + Freizeit.objects.filter(membersonlist__member=OuterRef('pk'), + date__gte=one_year_ago) + .values('membersonlist__member') + .annotate(cnt=Count('pk', distinct=True)) + .values('cnt'), + output_field=IntegerField() + ), + _klettertreff_score_calc=Subquery( + KlettertreffAttendee.objects.filter(member=OuterRef('pk'), + klettertreff__date__gte=one_year_ago) + .values('member') + .annotate(cnt=Count('pk', distinct=True)) + .values('cnt'), + output_field=IntegerField())) + queryset = queryset.annotate( + _jugendleiter_freizeit_score=Case( + When( + _jugendleiter_freizeit_score_calc=None, + then=0 + ), + default=F('_jugendleiter_freizeit_score_calc'), + output_field=IntegerField()), + _jugendleiter_klettertreff_score=Case( + When( + _jugendleiter_klettertreff_score_calc=None, + then=0 + ), + default=F('_jugendleiter_klettertreff_score_calc'), + output_field=IntegerField()), + _klettertreff_score=Case( + When( + _klettertreff_score_calc=None, + then=0 + ), + default=F('_klettertreff_score_calc'), + output_field=IntegerField()), + _freizeit_score=Case( + When( + _freizeit_score_calc=None, + then=0 + ), + default=F('_freizeit_score_calc'), + output_field=IntegerField())) + queryset = queryset.annotate( + #_activity_score=F('_jugendleiter_freizeit_score') + _activity_score=(F('_klettertreff_score') + 3 * F('_freizeit_score') + + F('_jugendleiter_klettertreff_score') + 3 * F('_jugendleiter_freizeit_score')) + ) + return queryset + def change_view(self, request, object_id, form_url="", extra_context=None): extra_context = extra_context or {} extra_context['qualities'] =\ @@ -93,6 +186,23 @@ 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' + activity_score.short_description = _('activity') + class GroupAdmin(admin.ModelAdmin): fields = ['name', 'min_age'] @@ -103,42 +213,177 @@ 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, + 'cols': 40})} + } + + +class OldMemberOnListInline(admin.TabularInline): + model = OldMemberOnList + extra = 0 + + +class MemberNoteListAdmin(admin.ModelAdmin): + inlines = [MemberOnListInline] + list_display = ['__str__', 'date'] + search_fields = ('name',) + ordering = ('-date',) + 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 = { - # TextField: {'widget': Textarea(attrs={'rows': 1, - # 'cols': 40})}, # ManyToManyField: {'widget': forms.CheckboxSelectMultiple}, # ForeignKey: {'widget': apply_select2(forms.Select)} #} + class Media: + css = {'all': ('admin/css/tabular_hide_original.css',)} -class MemberListAdmin(admin.ModelAdmin): + 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',) + ordering = ('-date',) actions = ['convert_to_pdf', 'generate_notes'] #formfield_overrides = { # ManyToManyField: {'widget': forms.CheckboxSelectMultiple}, @@ -149,7 +394,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 +412,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 +523,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 +685,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/locale/de/LC_MESSAGES/django.po b/jdav_web/members/locale/de/LC_MESSAGES/django.po index a0de68e..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-01-24 23:21+0100\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,213 +18,222 @@ 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:29 members/models.py:75 msgid "Registration complete" msgstr "Anmeldung vollständig" -#: members/admin.py:32 +#: members/admin.py:35 msgid "True" msgstr "Ja" -#: members/admin.py:33 +#: members/admin.py:36 msgid "False" msgstr "Nein" -#: members/admin.py:34 +#: members/admin.py:37 msgid "All" msgstr "Alle" -#: members/admin.py:94 +#: members/admin.py:187 msgid "Compose new mail to selected members" msgstr "Neue Nachricht an ausgewählte Teilnehmer verfassen" -#: members/admin.py:110 +#: members/admin.py:204 +msgid "activity" +msgstr "Aktivität" + +#: members/admin.py:220 msgid "Difficulty" msgstr "Schwierigkeit" -#: members/admin.py:114 +#: members/admin.py:224 msgid "Tour type" msgstr "Art der Tour" -#: members/admin.py:257 +#: members/admin.py:510 msgid "Convert to PDF" msgstr "Kriseninterventionsliste erstellen" -#: members/admin.py:366 +#: members/admin.py:619 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 "Titel" + +#: 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/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) 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: diff --git a/jdav_web/members/models.py b/jdav_web/members/models.py index 7548307..852a85c 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 @@ -159,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, - memberonlist__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(memberonlist__member=self) + return Freizeit.objects.filter(membersonlist__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_freizeit_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. diff --git a/jdav_web/startpage/locale/de/LC_MESSAGES/django.po b/jdav_web/startpage/locale/de/LC_MESSAGES/django.po index 7875fb3..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-01-24 23:21+0100\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/static/admin/images/climber.png b/jdav_web/static/admin/images/climber.png new file mode 100644 index 0000000..73d7ae2 Binary files /dev/null and b/jdav_web/static/admin/images/climber.png differ 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}