From d68b28679974c211db7f6ee3f50f864844d7e3db Mon Sep 17 00:00:00 2001 From: marius <47218379+mariusrklein@users.noreply.github.com> Date: Fri, 5 Dec 2025 00:17:29 +0100 Subject: [PATCH] refactor(members/*): reorganize and format using `ruff` (#10) I tried out some refactoring with the members models. They are separated into individual files. --- jdav_web/members/models.py | 2140 ----------------- jdav_web/members/models/__init__.py | 139 ++ jdav_web/members/models/activity.py | 26 + jdav_web/members/models/base.py | 139 ++ jdav_web/members/models/constants.py | 15 + jdav_web/members/models/emergency_contact.py | 27 + jdav_web/members/models/excursion.py | 542 +++++ jdav_web/members/models/group.py | 83 + jdav_web/members/models/invitation.py | 109 + jdav_web/members/models/klettertreff.py | 56 + jdav_web/members/models/ljp.py | 92 + jdav_web/members/models/member.py | 618 +++++ jdav_web/members/models/member_note_list.py | 21 + jdav_web/members/models/member_on_list.py | 48 + jdav_web/members/models/member_unconfirmed.py | 28 + jdav_web/members/models/permission.py | 65 + jdav_web/members/models/registration.py | 11 + jdav_web/members/models/training.py | 66 + jdav_web/members/models/waiting_list.py | 161 ++ jdav_web/members/tests/basic.py | 16 + 20 files changed, 2262 insertions(+), 2140 deletions(-) delete mode 100644 jdav_web/members/models.py create mode 100644 jdav_web/members/models/__init__.py create mode 100644 jdav_web/members/models/activity.py create mode 100644 jdav_web/members/models/base.py create mode 100644 jdav_web/members/models/constants.py create mode 100644 jdav_web/members/models/emergency_contact.py create mode 100644 jdav_web/members/models/excursion.py create mode 100644 jdav_web/members/models/group.py create mode 100644 jdav_web/members/models/invitation.py create mode 100644 jdav_web/members/models/klettertreff.py create mode 100644 jdav_web/members/models/ljp.py create mode 100644 jdav_web/members/models/member.py create mode 100644 jdav_web/members/models/member_note_list.py create mode 100644 jdav_web/members/models/member_on_list.py create mode 100644 jdav_web/members/models/member_unconfirmed.py create mode 100644 jdav_web/members/models/permission.py create mode 100644 jdav_web/members/models/registration.py create mode 100644 jdav_web/members/models/training.py create mode 100644 jdav_web/members/models/waiting_list.py diff --git a/jdav_web/members/models.py b/jdav_web/members/models.py deleted file mode 100644 index 8a5a6f1..0000000 --- a/jdav_web/members/models.py +++ /dev/null @@ -1,2140 +0,0 @@ -from datetime import datetime, timedelta, date -import uuid -import math -import pytz -import unicodedata -import re -from django.db import models -from django.db.models import TextField, ManyToManyField, ForeignKey, Count,\ - Sum, Case, Q, F, When, Value, IntegerField, Subquery, OuterRef -from django.db.models.functions import Cast -from django.utils.translation import gettext_lazy as _ -from django.utils import timezone -from django.utils.html import format_html -from django.urls import reverse -from django.contrib.contenttypes.fields import GenericForeignKey, GenericRelation -from django.contrib.contenttypes.models import ContentType -from utils import RestrictedFileField, normalize_name -import os -from mailer.mailutils import send as send_mail, get_mail_confirmation_link,\ - prepend_base_url, get_registration_link, get_wait_confirmation_link,\ - get_invitation_reject_link, get_invite_as_user_key, get_leave_waitinglist_link,\ - get_invitation_confirm_link -from django.contrib.auth.models import User -from django.conf import settings -from django.core.validators import MinValueValidator - -from .rules import may_view, may_change, may_delete, is_own_training, is_oneself, is_leader, is_leader_of_excursion,\ - is_leader_of_relevant_invitation -from .pdf import render_tex -import rules -from contrib.models import CommonModel -from contrib.media import media_path -from contrib.rules import memberize_user, has_global_perm -from utils import cvt_to_decimal, coming_midnight - -from dateutil.relativedelta import relativedelta -from schwifty import IBAN - - -GEMEINSCHAFTS_TOUR = MUSKELKRAFT_ANREISE = MALE = 0 -FUEHRUNGS_TOUR = OEFFENTLICHE_ANREISE = FEMALE = 1 -AUSBILDUNGS_TOUR = FAHRGEMEINSCHAFT_ANREISE = DIVERSE = 2 - -WEEKDAYS = ( - (0, _('Monday')), - (1, _('Tuesday')), - (2, _('Wednesday')), - (3, _('Thursday')), - (4, _('Friday')), - (5, _('Saturday')), - (6, _('Sunday')), -) - - -class ActivityCategory(models.Model): - """ - Describes one kind of activity - """ - LJP_CATEGORIES = [('Winter', _('winter')), - ('Skibergsteigen', _('ski mountaineering')), - ('Klettern', _('climbing')), - ('Bergsteigen', _('mountaineering')), - ('Theorie', _('theory')), - ('Sonstiges', _('others'))] - name = models.CharField(max_length=20, verbose_name=_('Name')) - ljp_category = models.CharField(choices=LJP_CATEGORIES, - verbose_name=_('LJP category'), - max_length=20, - help_text=_('The official category for LJP applications associated with this activity.')) - description = models.TextField(_('Description')) - - def __str__(self): - return self.name - - class Meta: - verbose_name = _('Activity') - verbose_name_plural = _('Activities') - - -class Group(models.Model): - """ - Represents one group of the association - e.g: J1, J2, Jugendleiter, etc. - """ - name = models.CharField(max_length=50, verbose_name=_('name')) # e.g: J1 - description = models.TextField(verbose_name=_('description'), default='', blank=True) - show_website = models.BooleanField(verbose_name=_('show on website'), default=False) - year_from = models.IntegerField(verbose_name=_('lowest year'), default=2010) - year_to = models.IntegerField(verbose_name=_('highest year'), default=2011) - leiters = models.ManyToManyField('members.Member', verbose_name=_('youth leaders'), - related_name='leited_groups', blank=True) - weekday = models.IntegerField(verbose_name=_('week day'), choices=WEEKDAYS, null=True, blank=True) - start_time = models.TimeField(verbose_name=_('Starting time'), null=True, blank=True) - end_time = models.TimeField(verbose_name=_('Ending time'), null=True, blank=True) - contact_email = models.ForeignKey('mailer.EmailAddress', - verbose_name=_('Contact email'), - null=True, - blank=True, - on_delete=models.SET_NULL) - - def __str__(self): - """String representation""" - return self.name - - class Meta: - verbose_name = _('group') - verbose_name_plural = _('groups') - - @property - def sorted_members(self): - """Returns the members of this group sorted by their last name.""" - return self.member_set.all().order_by('lastname') - - def has_time_info(self): - # return if the group has all relevant time slot information filled - return self.weekday and self.start_time and self.end_time - - def get_time_info(self): - if self.has_time_info(): - return settings.GROUP_TIME_AVAILABLE_TEXT.format(weekday=WEEKDAYS[self.weekday][1], - start_time=self.start_time.strftime('%H:%M'), - end_time=self.end_time.strftime('%H:%M')) - else: - return "" - - def has_age_info(self): - return self.year_from and self.year_to - - def get_age_info(self): - if self.has_age_info(): - return _("years %(from)s to %(to)s") % {'from':self.year_from, 'to':self.year_to} - else: - return "" - - def get_invitation_text_template(self): - """The text template used to invite waiters to this group. This contains - placeholders for the name of the waiter and personalized links.""" - if self.show_website: - group_link = '({url}) '.format(url=prepend_base_url(reverse('startpage:gruppe_detail', args=[self.name]))) - else: - group_link = '' - if self.has_time_info(): - group_time = self.get_time_info() - else: - group_time = settings.GROUP_TIME_UNAVAILABLE_TEXT.format(contact_email=self.contact_email) - if self.has_age_info(): - group_age = self.get_age_info() - else: - group_age = _("no information available") - - return settings.INVITE_TEXT.format(group_time=group_time, - group_name=self.name, - group_age=group_age, - group_link=group_link, - contact_email=self.contact_email) - - -class MemberManager(models.Manager): - def get_queryset(self): - return super().get_queryset().filter(confirmed=True) - - -class Contact(CommonModel): - """ - Represents an abstract person with only absolutely necessary contact information. - """ - prename = models.CharField(max_length=20, verbose_name=_('prename')) - lastname = models.CharField(max_length=20, verbose_name=_('last name')) - - email = models.EmailField(max_length=100, default="") - confirmed_mail = models.BooleanField(default=False, verbose_name=_('Email confirmed')) - confirm_mail_key = models.CharField(max_length=32, default="") - - class Meta(CommonModel.Meta): - abstract = True - - def __str__(self): - """String representation""" - return self.name - - @property - def name(self): - """Returning whole name (prename + lastname)""" - return "{0} {1}".format(self.prename, self.lastname) - - def phone_number_tel_link(self): - """Returns the phone number as tel link.""" - return format_html('{tel}'.format(tel=self.phone_number)) - phone_number_tel_link.short_description = _('phone number') - phone_number_tel_link.admin_order_field = 'phone_number' - - def email_mailto_link(self): - """Returns the emails as a mailto link.""" - return format_html('{email}'.format(email=self.email)) - email_mailto_link.short_description = 'Email' - email_mailto_link.admin_order_field = 'email' - - @property - def email_fields(self): - """Returns all tuples of emails and confirmation data related to this contact. - By default, this is only the principal email field, but extending classes can add - more email fields and then override this method.""" - return [('email', 'confirmed_mail', 'confirm_mail_key')] - - def request_mail_confirmation(self, rerequest=True): - """Request mail confirmation for every mail field. If `rerequest` is false, then only - confirmation is requested for currently unconfirmed emails. - - Returns true if any mail confirmation was requested, false otherwise.""" - requested_confirmation = False - for email_fd, confirmed_email_fd, confirm_mail_key_fd in self.email_fields: - if getattr(self, confirmed_email_fd) and not rerequest: - continue - if not getattr(self, email_fd): # pragma: no cover - # Only reachable with misconfigured `email_fields` - continue - requested_confirmation = True - setattr(self, confirmed_email_fd, False) - confirm_mail_key = uuid.uuid4().hex - setattr(self, confirm_mail_key_fd, confirm_mail_key) - send_mail(_('Email confirmation needed'), - settings.CONFIRM_MAIL_TEXT.format(name=self.prename, - link=get_mail_confirmation_link(confirm_mail_key), - whattoconfirm='deiner Emailadresse'), - settings.DEFAULT_SENDING_MAIL, - getattr(self, email_fd)) - self.save() - return requested_confirmation - - def confirm_mail(self, key): - for email_fd, confirmed_email_fd, confirm_mail_key_fd in self.email_fields: - if getattr(self, confirm_mail_key_fd) == key: - setattr(self, confirmed_email_fd, True) - setattr(self, confirm_mail_key_fd, "") - self.save() - return getattr(self, email_fd) - return None - - def send_mail(self, subject, content, cc=None): - send_mail(subject, content, settings.DEFAULT_SENDING_MAIL, - [getattr(self, email_fd) for email_fd, _, _ in self.email_fields], cc=cc) - - -def confirm_mail_by_key(key): - matching_unconfirmed = MemberUnconfirmedProxy.objects.filter(confirm_mail_key=key) \ - | MemberUnconfirmedProxy.objects.filter(confirm_alternative_mail_key=key) - matching_waiter = MemberWaitingList.objects.filter(confirm_mail_key=key) - matching_emergency_contact = EmergencyContact.objects.filter(confirm_mail_key=key) - matches = list(matching_unconfirmed) + list(matching_waiter) + list(matching_emergency_contact) - # if not exactly one match, return None. The case > 1 match should not occur! - if len(matches) != 1: - return None - person = matches[0] - return person, person.confirm_mail(key) - - -class ContactWithPhoneNumber(Contact): - """ - A contact with a phone number. - """ - phone_number = models.CharField(max_length=100, verbose_name=_('phone number')) - - class Meta(CommonModel.Meta): - abstract = True - - -class Person(Contact): - """ - Represents an abstract person. Not necessarily a member of any group. - """ - birth_date = models.DateField(_('birth date'), null=True, blank=True) # to determine the age - gender_choices = ((MALE, 'Männlich'), - (FEMALE, 'Weiblich'), - (DIVERSE, 'Divers')) - gender = models.IntegerField(choices=gender_choices, - verbose_name=_('Gender')) - comments = models.TextField(_('comments'), default='', blank=True) - - class Meta(CommonModel.Meta): - abstract = True - - def age(self): - """Age of member""" - return relativedelta(datetime.today(), self.birth_date).years - age.admin_order_field = 'birth_date' - age.short_description = _('age') - - def age_at(self, date: date): - """Age of member at a given date""" - return relativedelta(date.replace(tzinfo=None), self.birth_date).years - - @property - def birth_date_str(self): - if self.birth_date is None: - return "---" - return self.birth_date.strftime("%d.%m.%Y") - - @property - def gender_str(self): - return self.gender_choices[self.gender][1] - - -class Member(Person): - """ - Represents a member of the association - Might be a member of different groups: e.g. J1, J2, Jugendleiter, etc. - """ - alternative_email = models.EmailField(max_length=100, default=None, blank=True, null=True) - confirmed_alternative_mail = models.BooleanField(default=True, - verbose_name=_('Alternative email confirmed')) - confirm_alternative_mail_key = models.CharField(max_length=32, default="") - - phone_number = models.CharField(max_length=100, verbose_name=_('phone number'), default='', blank=True) - street = models.CharField(max_length=30, verbose_name=_('street and house number'), default='', blank=True) - plz = models.CharField(max_length=10, verbose_name=_('Postcode'), - default='', blank=True) - town = models.CharField(max_length=30, verbose_name=_('town'), default='', blank=True) - address_extra = models.CharField(max_length=100, verbose_name=_('Address extra'), default='', blank=True) - country = models.CharField(max_length=30, verbose_name=_('Country'), default='', blank=True) - - good_conduct_certificate_presented_date = models.DateField(_('Good conduct certificate presented on'), default=None, blank=True, null=True) - join_date = models.DateField(_('Joined on'), default=None, blank=True, null=True) - leave_date = models.DateField(_('Left on'), default=None, blank=True, null=True) - has_key = models.BooleanField(_('Has key'), default=False) - has_free_ticket_gym = models.BooleanField(_('Has a free ticket for the climbing gym'), default=False) - dav_badge_no = models.CharField(max_length=20, verbose_name=_('DAV badge number'), default='', blank=True) - - # use this to store a climbing gym customer or membership id, used to print on meeting checklists - ticket_no = models.CharField(max_length=20, verbose_name=_('entrance ticket number'), default='', blank=True) - swimming_badge = models.BooleanField(verbose_name=_('Knows how to swim'), default=False) - climbing_badge = models.CharField(max_length=100, verbose_name=_('Climbing badge'), default='', blank=True) - alpine_experience = models.TextField(verbose_name=_('Alpine experience'), default='', blank=True) - allergies = models.TextField(verbose_name=_('Allergies'), default='', blank=True) - medication = models.TextField(verbose_name=_('Medication'), default='', blank=True) - tetanus_vaccination = models.CharField(max_length=50, verbose_name=_('Tetanus vaccination'), default='', blank=True) - photos_may_be_taken = models.BooleanField(verbose_name=_('Photos may be taken'), default=False) - legal_guardians = models.CharField(max_length=100, verbose_name=_('Legal guardians'), default='', blank=True) - may_cancel_appointment_independently =\ - models.BooleanField(verbose_name=_('May cancel a group appointment independently'), null=True, - blank=True, default=None) - - group = models.ManyToManyField(Group, verbose_name=_('group')) - - iban = models.CharField(max_length=30, blank=True, verbose_name='IBAN') - - gets_newsletter = models.BooleanField(_('receives newsletter'), - default=True) - unsubscribe_key = models.CharField(max_length=32, default="") - unsubscribe_expire = models.DateTimeField(default=timezone.now) - created = models.DateField(default=timezone.now, verbose_name=_('created')) - active = models.BooleanField(default=True, verbose_name=_('Active')) - registration_form = RestrictedFileField(verbose_name=_('registration form'), - upload_to='registration_forms', - blank=True, - max_upload_size=5, - content_types=['application/pdf', - 'image/jpeg', - 'image/png', - 'image/gif']) - upload_registration_form_key = models.CharField(max_length=32, default="") - image = RestrictedFileField(verbose_name=_('image'), - upload_to='people', - blank=True, - max_upload_size=5, - content_types=['image/jpeg', - 'image/png', - 'image/gif']) - echo_key = models.CharField(max_length=32, default="") - echo_expire = models.DateTimeField(default=timezone.now) - echoed = models.BooleanField(default=True, verbose_name=_('Echoed')) - confirmed = models.BooleanField(default=True, verbose_name=_('Confirmed')) - user = models.OneToOneField(User, blank=True, null=True, on_delete=models.SET_NULL, - verbose_name=_('Login data')) - invite_as_user_key = models.CharField(max_length=32, default="") - waitinglist_application_date = models.DateTimeField(verbose_name=_('waitinglist application date'), - null=True, blank=True, - help_text=_('If the person registered from the waitinglist, this is their application date.')) - - objects = MemberManager() - all_objects = models.Manager() - - @property - def email_fields(self): - return [('email', 'confirmed_mail', 'confirm_mail_key'), - ('alternative_email', 'confirmed_alternative_mail', 'confirm_alternative_mail_key')] - - @property - def place(self): - """Returning the whole place (plz + town)""" - return "{0} {1}".format(self.plz, self.town) - - @property - def ticket_tag(self): - """Returning the ticket number stripped of strings and spaces""" - return "{" + ''.join(re.findall(r'\d', self.ticket_no)) + "}" - - @property - def iban_valid(self): - return IBAN(self.iban, allow_invalid=True).is_valid - - @property - def address(self): - """Returning the whole address""" - if not self.street and not self.town and not self.plz: - return "---" - else: - return "{0}, {1}".format(self.street, self.place) - - @property - def address_multiline(self): - """Returning the whole address with a linebreak between street and town""" - if not self.street and not self.town and not self.plz: - return "---" - else: - return "{0},\\linebreak[1] {1}".format(self.street, self.place) - - def good_conduct_certificate_valid(self): - """Returns if a good conduct certificate is still valid, depending on the last presentation.""" - if not self.good_conduct_certificate_presented_date: - return False - delta = datetime.now().date() - self.good_conduct_certificate_presented_date - return delta.days // 30 <= settings.MAX_AGE_GOOD_CONDUCT_CERTIFICATE_MONTHS - good_conduct_certificate_valid.boolean = True - good_conduct_certificate_valid.short_description = _('Good conduct certificate valid') - - def generate_key(self): - self.unsubscribe_key = uuid.uuid4().hex - self.unsubscribe_expire = timezone.now() + timezone.timedelta(days=1) - self.save() - return self.unsubscribe_key - - def generate_echo_key(self): - self.echo_key = uuid.uuid4().hex - self.echo_expire = timezone.now() + timezone.timedelta(days=settings.ECHO_GRACE_PERIOD) - self.echoed = False - self.save() - return self.echo_key - - def confirm(self): - if not self.confirmed_mail or not self.confirmed_alternative_mail: - return False - self.confirmed = True - self.save() - return True - - def unconfirm(self): - self.confirmed = False - self.save() - - def unsubscribe(self, key): - if self.unsubscribe_key == key and timezone.now() <\ - self.unsubscribe_expire: - for member in Member.objects.filter(email=self.email): - member.gets_newsletter = False - member.save() - self.unsubscribe_key, self.unsubscribe_expire = "", timezone.now() - return True - else: - return False - - def may_echo(self, key): - return self.echo_key == key and timezone.now() < self.echo_expire - - @property - def echo_password(self): - return self.birth_date.strftime(settings.ECHO_PASSWORD_BIRTHDATE_FORMAT) - - @property - def contact_phone_number(self): - """Synonym for phone number field.""" - if self.phone_number: - return str(self.phone_number) - else: - return "---" - - @property - def contact_email(self): - """A synonym for the email field.""" - return self.email - - @property - def username(self): - """Return the username. Either this the name of the linked user, or - it is the suggested username.""" - if not self.user: - return self.suggested_username() - else: - return self.user.username - - @property - def association_email(self): - """Returning the association email of the member""" - return "{username}@{domain}".format(username=self.username, domain=settings.DOMAIN) - - def registration_complete(self): - """Check if all necessary fields are set.""" - # TODO: implement a proper predicate here - return True - registration_complete.boolean = True - registration_complete.short_description = _('Registration complete') - - def get_group(self): - """Returns a string of groups in which the member is.""" - groupstring = ''.join(g.name + ',\n' for g in self.group.all()) - groupstring = groupstring[:-2] - return groupstring - get_group.short_description = _('Group') - - class Meta(CommonModel.Meta): - verbose_name = _('member') - verbose_name_plural = _('members') - permissions = ( - ('may_see_qualities', 'Is allowed to see the quality overview'), - ('may_set_auth_user', 'Is allowed to set auth user member connections.'), - ('may_change_member_group', 'Can change the group field'), - ('may_invite_as_user', 'Is allowed to invite a member to set login data.'), - ('may_change_organizationals', 'Is allowed to set organizational settings on members.'), - ) - rules_permissions = { - 'members': rules.always_allow, - 'add_obj': has_global_perm('members.add_global_member'), - 'view_obj': may_view | has_global_perm('members.view_global_member'), - 'change_obj': may_change | has_global_perm('members.change_global_member'), - 'delete_obj': may_delete | has_global_perm('members.delete_global_member'), - } - - def get_skills(self): - # get skills by summing up all the activities taken part in - skills = {} - for kind in ActivityCategory.objects.all(): - lists = Freizeit.objects.filter(activity=kind, - membersonlist__member=self) - skills[kind.name] = sum([l.difficulty * 3 for l in lists - if l.date < timezone.now()]) - return skills - - def get_activities(self): - # get activity overview - return Freizeit.objects.filter(membersonlist__member=self) - - def generate_upload_registration_form_key(self): - self.upload_registration_form_key = uuid.uuid4().hex - self.save() - - def create_from_registration(self, waiter, group): - """Given a member, a corresponding waiting-list object and a group, this completes - the registration and requests email confirmations if necessary. - Returns if any mail confirmation requests have been sent out.""" - self.group.add(group) - self.confirmed = False - if waiter: - if self.email == waiter.email: - self.confirmed_mail = waiter.confirmed_mail - self.confirm_mail_key = waiter.confirm_mail_key - # store waitinglist application date in member, this will be used - # if the member is later demoted to waiter again - self.waitinglist_application_date = waiter.application_date - if self.alternative_email: - self.confirmed_alternative_mail = False - self.upload_registration_form_key = uuid.uuid4().hex - self.save() - - if self.registration_ready(): - self.notify_jugendleiters_about_confirmed_mail() - if waiter: - waiter.delete() - return self.request_mail_confirmation(rerequest=False) - - def registration_form_uploaded(self): - print(self.registration_form.name) - return not self.registration_form.name is None and self.registration_form.name != "" - registration_form_uploaded.boolean = True - registration_form_uploaded.short_description = _('Registration form') - - def registration_ready(self): - """Returns if the member is currently unconfirmed and all email addresses - are confirmed.""" - return not self.confirmed and self.confirmed_alternative_mail and self.confirmed_mail\ - and self.registration_form - - def confirm_mail(self, key): - ret = super().confirm_mail(key) - if self.registration_ready(): - self.notify_jugendleiters_about_confirmed_mail() - return ret - - def validate_registration_form(self): - self.upload_registration_form_key = '' - self.save() - if self.registration_ready(): - self.notify_jugendleiters_about_confirmed_mail() - - def get_upload_registration_form_link(self): - return prepend_base_url(reverse('members:upload_registration_form') + "?key="\ - + self.upload_registration_form_key) - - def send_upload_registration_form_link(self): - if not self.upload_registration_form_key: - return - link = self.get_upload_registration_form_link() - self.send_mail(_('Upload registration form'), - settings.UPLOAD_REGISTRATION_FORM_TEXT.format(name=self.prename, - link=link)) - - def request_registration_form(self): - """Ask the member to upload a registration form via email.""" - self.generate_upload_registration_form_key() - self.send_upload_registration_form_link() - - def notify_jugendleiters_about_confirmed_mail(self): - group = ", ".join([g.name for g in self.group.all()]) - # notify jugendleiters of group of registration - jls = [jl for group in self.group.all() for jl in group.leiters.all()] - for jl in jls: - link = prepend_base_url(reverse('admin:members_memberunconfirmedproxy_change', - args=[str(self.id)])) - send_mail(_('New unconfirmed registration for group %(group)s') % {'group': group}, - settings.NEW_UNCONFIRMED_REGISTRATION.format(name=jl.prename, - group=group, - link=link), - settings.DEFAULT_SENDING_MAIL, - jl.email) - - def filter_queryset_by_permissions(self, queryset=None, annotate=False, model=None): # pragma: no cover - """ - Filter the given queryset of objects of type `model` by the permissions of `self`. - For example, only returns `Message`s created by `self`. - - This method is used by the `FilteredMemberFieldMixin` to filter the selection - in `ForeignKey` and `ManyToMany` fields. - """ - # This method is not used by all models listed below, so covering all cases in tests - # is hard and not useful. It is therefore exempt from testing. - name = model._meta.object_name - if queryset is None: - queryset = Member.objects.all() - - if name == "Message": - return self.filter_messages_by_permissions(queryset, annotate) - elif name == "Member": - return self.filter_members_by_permissions(queryset, annotate) - elif name == "StatementUnSubmitted": - return self.filter_statements_by_permissions(queryset, annotate) - elif name == "Freizeit": - return self.filter_excursions_by_permissions(queryset, annotate) - elif name == "MemberWaitingList": - return self.filter_waiters_by_permissions(queryset, annotate) - elif name == "LJPProposal": - return queryset - elif name == "MemberTraining": - return queryset - elif name == "NewMemberOnList": - return queryset - elif name == "Statement": - return self.filter_statements_by_permissions(queryset, annotate) - elif name == "StatementOnExcursionProxy": - return self.filter_statements_by_permissions(queryset, annotate) - elif name == "BillOnExcursionProxy": - return queryset - elif name == "Intervention": - return queryset - elif name == "BillOnStatementProxy": - return queryset - elif name == "Attachment": - return queryset - elif name == "Group": - return queryset - elif name == "EmergencyContact": - return queryset - elif name == "MemberUnconfirmedProxy": - return queryset - elif name == "InvitationToGroup": - return queryset - else: - raise ValueError(name) - - def filter_members_by_permissions(self, queryset, annotate=False): - #mems = Member.objects.all().prefetch_related('group') - - #list_pks = [ m.pk for m in mems if self.may_list(m) ] - #view_pks = [ m.pk for m in mems if self.may_view(m) ] - - ## every member may list themself - pks = [self.pk] - view_pks = [self.pk] - - - if hasattr(self, 'permissions'): - pks += [ m.pk for m in self.permissions.list_members.all() ] - view_pks += [ m.pk for m in self.permissions.view_members.all() ] - - for group in self.permissions.list_groups.all(): - pks += [ m.pk for m in group.member_set.all() ] - - for group in self.permissions.view_groups.all(): - view_pks += [ m.pk for m in group.member_set.all() ] - - for group in self.group.all(): - if hasattr(group, 'permissions'): - pks += [ m.pk for m in group.permissions.list_members.all() ] - view_pks += [ m.pk for m in group.permissions.view_members.all() ] - - for gr in group.permissions.list_groups.all(): - pks += [ m.pk for m in gr.member_set.all()] - - for gr in group.permissions.view_groups.all(): - view_pks += [ m.pk for m in gr.member_set.all()] - - filtered = queryset.filter(pk__in=pks) - if not annotate: - return filtered - - return filtered.annotate(_viewable=Case(When(pk__in=view_pks, then=Value(True)), default=Value(False), output_field=models.BooleanField())) - - def annotate_view_permission(self, queryset, model): - name = model._meta.object_name - if name != 'Member': - return queryset - view_pks = [self.pk] - - if hasattr(self, 'permissions'): - view_pks += [ m.pk for m in self.permissions.view_members.all() ] - - for group in self.permissions.view_groups.all(): - view_pks += [ m.pk for m in group.member_set.all() ] - - for group in self.group.all(): - if hasattr(group, 'permissions'): - view_pks += [ m.pk for m in group.permissions.view_members.all() ] - - for gr in group.permissions.view_groups.all(): - view_pks += [ m.pk for m in gr.member_set.all()] - - return queryset.annotate(_viewable=Case(When(pk__in=view_pks, then=Value(True)), default=Value(False), output_field=models.BooleanField())) - - - def filter_messages_by_permissions(self, queryset, annotate=False): - # ignores annotate - return queryset.filter(created_by=self) - - def filter_statements_by_permissions(self, queryset, annotate=False): - # ignores annotate - return queryset.filter(Q(created_by=self) | Q(excursion__jugendleiter=self)) - - def filter_excursions_by_permissions(self, queryset, annotate=False): - # ignores annotate - groups = self.leited_groups.all() - # one may view all excursions by leited groups and leited excursions - queryset = queryset.filter(Q(groups__in=groups) | Q(jugendleiter=self)).distinct() - return queryset - - def filter_waiters_by_permissions(self, queryset, annotate=False): - # ignores annotate - # return waiters that have a pending, expired or rejected group invitation for a group - # led by the member - return queryset.filter(invitationtogroup__group__leiters=self) - - def may_list(self, other): - if self.pk == other.pk: - return True - - if hasattr(self, 'permissions'): - if other in self.permissions.list_members.all(): - return True - - if any([gr in other.group.all() for gr in self.permissions.list_groups.all()]): - return True - - for group in self.group.all(): - if hasattr(group, 'permissions'): - if other in group.permissions.list_members.all(): - return True - - if any([gr in other.group.all() for gr in group.permissions.list_groups.all()]): - return True - - return False - - def may_view(self, other): - if self.pk == other.pk: - return True - - if hasattr(self, 'permissions'): - if other in self.permissions.view_members.all(): - return True - - if any([gr in other.group.all() for gr in self.permissions.view_groups.all()]): - return True - - for group in self.group.all(): - if hasattr(group, 'permissions'): - if other in group.permissions.view_members.all(): - return True - - if any([gr in other.group.all() for gr in group.permissions.view_groups.all()]): - return True - - return False - - def may_change(self, other): - if self.pk == other.pk: - return True - - if hasattr(self, 'permissions'): - if other in self.permissions.change_members.all(): - return True - - if any([gr in other.group.all() for gr in self.permissions.change_groups.all()]): - return True - - for group in self.group.all(): - if hasattr(group, 'permissions'): - if other in group.permissions.change_members.all(): - return True - - if any([gr in other.group.all() for gr in group.permissions.change_groups.all()]): - return True - - return False - - def may_delete(self, other): - if self.pk == other.pk: - return True - - if hasattr(self, 'permissions'): - if other in self.permissions.delete_members.all(): - return True - - if any([gr in other.group.all() for gr in self.permissions.delete_groups.all()]): - return True - - for group in self.group.all(): - if hasattr(group, 'permissions'): - if other in group.permissions.delete_members.all(): - return True - - if any([gr in other.group.all() for gr in group.permissions.delete_groups.all()]): - return True - - return False - - def suggested_username(self): - """Returns a suggested username given by {prename}.{lastname}.""" - raw = "{0}.{1}".format(self.prename.lower(), self.lastname.lower()) - return normalize_name(raw) - - def has_internal_email(self): - """Returns if the configured e-mail address is a DAV360 email address.""" - match = re.match('(^[^@]*)@(.*)$', self.email) - if not match: - return False - return match.group(2) in settings.ALLOWED_EMAIL_DOMAINS_FOR_INVITE_AS_USER or\ - "*" in settings.ALLOWED_EMAIL_DOMAINS_FOR_INVITE_AS_USER - - def invite_as_user(self): - """Invites the member to join Kompass as a user.""" - if not self.has_internal_email(): - # dont invite if the email address is not an internal one - return False - if self.user: - # don't reinvite if there is already userdata attached - return False - self.invite_as_user_key = uuid.uuid4().hex - self.save() - self.send_mail(_('Set login data for Kompass'), - settings.INVITE_AS_USER_TEXT.format(name=self.prename, - link=get_invite_as_user_key(self.invite_as_user_key))) - return True - - def led_groups(self): - """Returns a queryset of groups that this member is a youth leader of.""" - return Group.objects.filter(leiters__pk=self.pk) - - def led_freizeiten(self, limit=5): - """Returns a queryset of freizeiten that this member is a youth leader of.""" - return Freizeit.objects.filter(jugendleiter__pk=self.pk)[:limit] - - def demote_to_waiter(self): - """Demote this member to a waiter by creating a waiter from the data and removing - this member.""" - waiter = MemberWaitingList(prename=self.prename, - lastname=self.lastname, - email=self.email, - birth_date=self.birth_date, - gender=self.gender, - comments=self.comments, - confirmed_mail=self.confirmed_mail, - confirm_mail_key=self.confirm_mail_key) - # if this member was created from the waitinglist, keep the original application date - if self.waitinglist_application_date: - waiter.application_date = self.waitinglist_application_date - waiter.save() - self.delete() - - -class EmergencyContact(ContactWithPhoneNumber): - """ - Emergency contact of a member - """ - member = models.ForeignKey(Member, verbose_name=_('Member'), on_delete=models.CASCADE) - email = models.EmailField(max_length=100, default='', blank=True) - - def __str__(self): - return str(self.member) - - class Meta(CommonModel.Meta): - verbose_name = _('Emergency contact') - verbose_name_plural = _('Emergency contacts') - rules_permissions = { - 'add_obj': may_change | has_global_perm('members.change_global_member'), - 'view_obj': may_view | has_global_perm('members.view_global_member'), - 'change_obj': may_change | has_global_perm('members.change_global_member'), - 'delete_obj': may_delete | has_global_perm('members.delete_global_member'), - } - - -class MemberUnconfirmedManager(models.Manager): - def get_queryset(self): - return super().get_queryset().filter(confirmed=False) - - -class MemberUnconfirmedProxy(Member): - """Proxy to show unconfirmed members seperately in admin""" - objects = MemberUnconfirmedManager() - - class Meta: - proxy = True - verbose_name = _('Unconfirmed registration') - verbose_name_plural = _('Unconfirmed registrations') - permissions = (('may_manage_all_registrations', 'Can view and manage all unconfirmed registrations.'),) - rules_permissions = { - 'view_obj': may_view | has_global_perm('members.may_manage_all_registrations'), - 'change_obj': may_change | has_global_perm('members.may_manage_all_registrations'), - 'delete_obj': may_delete | has_global_perm('members.may_manage_all_registrations'), - } - - def __str__(self): - """String representation""" - return self.name - - -def gen_key(): - return uuid.uuid4().hex - - -class InvitationToGroup(CommonModel): - """An invitation of a waiter to a group.""" - waiter = models.ForeignKey('MemberWaitingList', verbose_name=_('Waiter'), on_delete=models.CASCADE) - group = models.ForeignKey(Group, verbose_name=_('Group'), on_delete=models.CASCADE) - date = models.DateField(default=timezone.now, verbose_name=_('Invitation date')) - rejected = models.BooleanField(verbose_name=_('Invitation rejected'), default=False) - key = models.CharField(max_length=32, default=gen_key) - created_by = models.ForeignKey(Member, verbose_name=_('Created by'), - blank=True, - null=True, - on_delete=models.SET_NULL, - related_name='created_group_invitations') - - class Meta(CommonModel.Meta): - verbose_name = _('Invitation to group') - verbose_name_plural = _('Invitations to groups') - rules_permissions = { - 'add_obj': has_global_perm('members.add_global_memberwaitinglist'), - 'view_obj': is_leader_of_relevant_invitation | has_global_perm('members.view_global_memberwaitinglist'), - 'change_obj': has_global_perm('members.change_global_memberwaitinglist'), - 'delete_obj': has_global_perm('members.delete_global_memberwaitinglist'), - } - - def is_expired(self): - return self.date < (timezone.now() - timezone.timedelta(days=30)).date() - - def status(self): - if self.rejected: - return _('Rejected') - elif self.is_expired(): - return _('Expired') - else: - return _('Undecided') - status.short_description = _('Status') - - def send_left_waitinglist_notification_to(self, recipient): - send_mail(_('%(waiter)s left the waiting list') % {'waiter': self.waiter}, - settings.GROUP_INVITATION_LEFT_WAITINGLIST.format(name=recipient.prename, - waiter=self.waiter, - group=self.group), - settings.DEFAULT_SENDING_MAIL, - recipient.email) - - def send_reject_notification_to(self, recipient): - send_mail(_('Group invitation rejected by %(waiter)s') % {'waiter': self.waiter}, - settings.GROUP_INVITATION_REJECTED.format(name=recipient.prename, - waiter=self.waiter, - group=self.group), - settings.DEFAULT_SENDING_MAIL, - recipient.email) - - def send_confirm_notification_to(self, recipient): - send_mail(_('Group invitation confirmed by %(waiter)s') % {'waiter': self.waiter}, - settings.GROUP_INVITATION_CONFIRMED_TEXT.format(name=recipient.prename, - waiter=self.waiter, - group=self.group), - settings.DEFAULT_SENDING_MAIL, - recipient.email) - - def send_confirm_confirmation(self): - self.waiter.send_mail(_('Trial group meeting confirmed'), - settings.TRIAL_GROUP_MEETING_CONFIRMED_TEXT.format(name=self.waiter.prename, - group=self.group, - contact_email=self.group.contact_email, - timeinfo=self.group.get_time_info())) - - def notify_left_waitinglist(self): - """ - Inform youth leaders of the group and the inviter that the waiter left the waitinglist, - prompted by this group invitation. - """ - if self.created_by: - self.send_left_waitinglist_notification_to(self.created_by) - for jl in self.group.leiters.all(): - self.send_left_waitinglist_notification_to(jl) - - def reject(self): - """Reject this invitation. Informs the youth leaders of the group of the rejection.""" - self.rejected = True - self.save() - # send notifications - if self.created_by: - self.send_reject_notification_to(self.created_by) - for jl in self.group.leiters.all(): - self.send_reject_notification_to(jl) - - def confirm(self): - """Confirm this invitation. Informs the youth leaders of the group of the invitation.""" - self.rejected = False - self.save() - # confirm the confirmation - self.send_confirm_confirmation() - # send notifications - if self.created_by: - self.send_confirm_notification_to(self.created_by) - for jl in self.group.leiters.all(): - self.send_confirm_notification_to(jl) - - -class MemberWaitingList(Person): - """A participant on the waiting list""" - - WAITING_CONFIRMATION_SUCCESS = 0 - WAITING_CONFIRMATION_INVALID = 1 - WAITING_CONFIRMATION_EXPIRED = 1 - WAITING_CONFIRMED = 2 - - application_text = models.TextField(_('Do you want to tell us something else?'), default='', blank=True) - application_date = models.DateTimeField(verbose_name=_('application date'), default=timezone.now) - - last_wait_confirmation = models.DateField(default=timezone.now, verbose_name=_('Last wait confirmation')) - wait_confirmation_key = models.CharField(max_length=32, default="") - wait_confirmation_key_expire = models.DateTimeField(default=timezone.now) - - leave_key = models.CharField(max_length=32, default="") - - last_reminder = models.DateTimeField(default=timezone.now, verbose_name=_('Last reminder')) - sent_reminders = models.IntegerField(default=0, verbose_name=_('Missed reminders')) - - registration_key = models.CharField(max_length=32, default="") - registration_expire = models.DateTimeField(default=timezone.now) - - class Meta(CommonModel.Meta): - verbose_name = _('Waiter') - verbose_name_plural = _('Waiters') - permissions = (('may_manage_waiting_list', 'Can view and manage the waiting list.'),) - rules_permissions = { - 'add_obj': has_global_perm('members.add_global_memberwaitinglist'), - 'view_obj': is_leader_of_relevant_invitation | has_global_perm('members.view_global_memberwaitinglist'), - 'change_obj': has_global_perm('members.change_global_memberwaitinglist'), - 'delete_obj': has_global_perm('members.delete_global_memberwaitinglist'), - } - - def latest_group_invitation(self): - gi = self.invitationtogroup_set.order_by('-pk').first() - if gi: - return "{group}: {status}".format(group=gi.group.name, status=gi.status()) - else: - return "-" - latest_group_invitation.short_description = _('Latest group invitation') - - @property - def waiting_confirmation_needed(self): - """Returns if person should be asked to confirm waiting status.""" - return not self.wait_confirmation_key \ - and self.last_wait_confirmation < timezone.now() -\ - timezone.timedelta(days=settings.WAITING_CONFIRMATION_FREQUENCY) - - def waiting_confirmed(self): - """Returns if the persons waiting status is considered to be confirmed.""" - if self.sent_reminders > 0: - # there was sent at least one wait confirmation request - if timezone.now() < self.wait_confirmation_key_expire: - # the request has not expired yet - return None - else: - # we sent a request that has expired - return False - else: - # if there exist no pending or expired reminders, the waiter remains confirmed - return True - waiting_confirmed.admin_order_field = 'last_wait_confirmation' - waiting_confirmed.boolean = True - waiting_confirmed.short_description = _('Waiting status confirmed') - - def ask_for_wait_confirmation(self): - """Sends an email to the person asking them to confirm their intention to wait.""" - self.last_reminder = timezone.now() - self.sent_reminders += 1 - self.leave_key = gen_key() - self.save() - self.send_mail(_('Waiting confirmation needed'), - settings.WAIT_CONFIRMATION_TEXT.format(name=self.prename, - link=get_wait_confirmation_link(self), - leave_link=get_leave_waitinglist_link(self.leave_key), - reminder=self.sent_reminders, - max_reminder_count=settings.MAX_REMINDER_COUNT)) - - def confirm_waiting(self, key): - # if a wrong key is supplied, we return invalid - if not self.wait_confirmation_key == key: - return self.WAITING_CONFIRMATION_INVALID - - # if the current wait confirmation key is not expired, return sucess - if timezone.now() < self.wait_confirmation_key_expire: - self.last_wait_confirmation = timezone.now() - self.wait_confirmation_key_expire = timezone.now() - self.sent_reminders = 0 - self.leave_key = '' - self.save() - return self.WAITING_CONFIRMATION_SUCCESS - - # if the waiting is already confirmed, return success - # this might happen if both parents and member mail are used for communication - if self.waiting_confirmed(): - return self.WAITING_CONFIRMED - - # otherwise the link is too old and the person was not confirmed in time - return self.WAITING_CONFIRMATION_EXPIRED - - def generate_wait_confirmation_key(self): - self.wait_confirmation_key = uuid.uuid4().hex - self.wait_confirmation_key_expire = timezone.now() \ - + timezone.timedelta(days=settings.GRACE_PERIOD_WAITING_CONFIRMATION) - self.save() - return self.wait_confirmation_key - - def may_register(self, key): - try: - invitation = InvitationToGroup.objects.get(key=key) - return self.pk == invitation.waiter.pk and timezone.now().date() < invitation.date + timezone.timedelta(days=30) - except InvitationToGroup.DoesNotExist: - return False - - def invite_to_group(self, group, text_template=None, creator=None): - """ - Invite waiter to given group. Stores a new group invitation - and sends a personalized e-mail based on the passed template. - """ - self.invited_for_group = group - self.save() - if not text_template: - text_template = group.get_invitation_text_template() - invitation = InvitationToGroup(group=group, waiter=self, created_by=creator) - invitation.save() - self.send_mail(_("Invitation to trial group meeting"), - text_template.format(name=self.prename, - link=get_registration_link(invitation.key), - invitation_reject_link=get_invitation_reject_link(invitation.key), - invitation_confirm_link=get_invitation_confirm_link(invitation.key)), - cc=group.contact_email.email) - - def unregister(self): - """Delete the waiter and inform them about the deletion via email.""" - self.send_mail(_("Unregistered from waiting list"), - settings.LEAVE_WAITINGLIST_TEXT.format(name=self.prename)) - self.delete() - - def confirm_mail(self, key): - ret = super().confirm_mail(key) - if ret: - self.send_mail(_("Successfully registered for the waitinglist"), - settings.JOIN_WAITINGLIST_CONFIRMATION_TEXT.format(name=self.prename)) - return ret - - -class NewMemberOnList(CommonModel): - """ - Connects members to a list of members. - """ - member = models.ForeignKey(Member, verbose_name=_('Member'), on_delete=models.CASCADE) - 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(CommonModel.Meta): - verbose_name = _('Member') - verbose_name_plural = _('Members') - rules_permissions = { - 'add_obj': is_leader, - 'view_obj': is_leader | has_global_perm('members.view_global_freizeit'), - 'change_obj': is_leader, - 'delete_obj': is_leader, - } - - @property - def comments_tex(self): - raw = ". ".join(c for c in (self.member.comments, self.comments) if c).replace("..", ".") - if not raw: - return "---" - else: - return raw - - @property - def skills(self): - activities = [a.name for a in self.memberlist.activity.all()] - return {k: v for k, v in self.member.get_skills().items() if k in activities} - - @property - def qualities_tex(self): - qualities = [] - for activity, value in self.skills.items(): - qualities.append("\\textit{%s:} %s" % (activity, value)) - return ", ".join(qualities) - - -class Freizeit(CommonModel): - """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) - postcode = models.CharField(verbose_name=_('Postcode'), default='', max_length=30) - destination = models.CharField(verbose_name=_('Destination (optional)'), - default='', max_length=50, blank=True, - help_text=_('e.g. a peak')) - date = models.DateTimeField(default=timezone.now, verbose_name=_('Begin')) - end = models.DateTimeField(verbose_name=_('End (optional)'), default=timezone.now) - description = models.TextField(verbose_name=_('Description'), blank=True, default='') - # comment = models.TextField(_('Comments'), default='', blank=True) - groups = models.ManyToManyField(Group, verbose_name=_('Groups')) - jugendleiter = models.ManyToManyField(Member) - approved_extra_youth_leader_count = models.PositiveIntegerField(verbose_name=_('Number of additional approved youth leaders'), - default=0, - help_text=_('The number of approved youth leaders per excursion is determined by the number of participants. In special circumstances, e.g. in case of a technically demanding excursion, more youth leaders may be approved.')) - 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) - tour_approach_choices = ((MUSKELKRAFT_ANREISE, 'Muskelkraft'), - (OEFFENTLICHE_ANREISE, 'ÖPNV'), - (FAHRGEMEINSCHAFT_ANREISE, 'Fahrgemeinschaften')) - tour_approach = models.IntegerField(choices=tour_approach_choices, - default=MUSKELKRAFT_ANREISE, - verbose_name=_('Means of transportation')) - kilometers_traveled = models.IntegerField(verbose_name=_('Kilometers traveled'), - validators=[MinValueValidator(0)]) - 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) - - # approval: None means no decision taken, False means rejected - approved = models.BooleanField(verbose_name=_('Approved'), - null=True, - default=None, - help_text=_('Choose no in case of rejection or yes in case of approval. Leave empty, if not yet decided.')) - approval_comments = models.TextField(verbose_name=_('Approval comments'), - blank=True, default='') - - # automatic sending of crisis intervention list - crisis_intervention_list_sent = models.BooleanField(default=False) - notification_crisis_intervention_list_sent = models.BooleanField(default=False) - - def __str__(self): - """String represenation""" - return self.name - - class Meta(CommonModel.Meta): - verbose_name = _('Excursion') - verbose_name_plural = _('Excursions') - permissions = ( - ('manage_approval_excursion', 'Can edit the approval status of excursions.'), - ('view_approval_excursion', 'Can view the approval status of excursions.'), - ) - rules_permissions = { - 'add_obj': has_global_perm('members.add_global_freizeit'), - 'view_obj': is_leader | has_global_perm('members.view_global_freizeit'), - 'change_obj': is_leader | has_global_perm('members.change_global_freizeit'), - 'delete_obj': is_leader | has_global_perm('members.delete_global_freizeit'), - } - - @property - def code(self): - return f"B{self.date:%y}-{self.pk}" - - @staticmethod - def filter_queryset_date_next_n_hours(hours, queryset=None): - if queryset is None: - queryset = Freizeit.objects.all() - return queryset.filter(date__lte=timezone.now() + timezone.timedelta(hours=hours), - date__gte=timezone.now()) - - @staticmethod - def to_notify_crisis_intervention_list(): - qs = Freizeit.objects.filter(notification_crisis_intervention_list_sent=False) - return Freizeit.filter_queryset_date_next_n_hours(48, queryset=qs) - - @staticmethod - def to_send_crisis_intervention_list(): - qs = Freizeit.objects.filter(crisis_intervention_list_sent=False) - return Freizeit.filter_queryset_date_next_n_hours(24, queryset=qs) - - 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_tour_approach(self): - if self.tour_approach == MUSKELKRAFT_ANREISE: - return "Muskelkraft" - elif self.tour_approach == OEFFENTLICHE_ANREISE: - return "ÖPNV" - else: - return "Fahrgemeinschaften" - - def get_absolute_url(self): - return reverse('admin:members_freizeit_change', args=[str(self.id)]) - - @property - def night_count(self): - # convert to date first, since we might start at 11pm and end at 1am, which is one night - return (self.end.date() - self.date.date()).days - - @property - def duration(self): - # number of nights is number of full days + 1 - full_days = max(self.night_count - 1, 0) - extra_days = 0 - - if self.date.date() == self.end.date(): - # excursion starts and ends on the same day - hours = max(self.end.hour - self.date.hour, 0) - # at least 6 hours counts as full day - if hours >= 6: - extra_days = 1.0 - # otherwise half day - else: - extra_days = 0.5 - else: - if self.date.hour <= 12: - extra_days += 1.0 - else: - extra_days += 0.5 - - if self.end.hour >= 12: - extra_days += 1.0 - else: - extra_days += 0.5 - - return full_days + extra_days - - @property - def total_intervention_hours(self): - if hasattr(self, 'ljpproposal'): - return sum([i.duration for i in self.ljpproposal.intervention_set.all()]) - else: - return 0 - - @property - def total_seminar_days(self): - """calculate seminar days based on intervention hours in every day""" - # TODO: add tests for this - if hasattr(self, 'ljpproposal'): - hours_per_day = self.seminar_time_per_day - # Calculate the total number of seminar days - # Each day is counted as 1 if total_duration is >= 5 hours, as 0.5 if total_duration is >= 2.5 - # otherwise 0 - sum_days = sum([h['sum_days'] for h in hours_per_day]) - - return sum_days - else: - return 0 - - - @property - def seminar_time_per_day(self): - if hasattr(self, 'ljpproposal'): - return ( - self.ljpproposal.intervention_set - .annotate(day=Cast('date_start', output_field=models.DateField())) # Force it to date - .values('day') # Group by day - .annotate(total_duration=Sum('duration'))# Sum durations for each day - .annotate( - sum_days=Case( - When(total_duration__gte=5.0, then=Value(1.0)), - When(total_duration__gte=2.5, then=Value(0.5)), - default=Value(0.0),) - ) - .order_by('day') # Sort results by date - ) - else: - return [] - - @property - def ljp_duration(self): - """calculate the duration in days for the LJP""" - return min(self.duration, self.total_seminar_days) - - @property - def staff_count(self): - return self.jugendleiter.count() - - @property - def staff_on_memberlist(self): - ps = set(map(lambda x: x.member, self.membersonlist.distinct())) - jls = set(self.jugendleiter.distinct()) - return ps.intersection(jls) - - @property - def staff_on_memberlist_count(self): - return len(self.staff_on_memberlist) - - @property - def participant_count(self): - return len(self.participants) - - @property - def participants(self): - ps = set(map(lambda x: x.member, self.membersonlist.distinct())) - jls = set(self.jugendleiter.distinct()) - return list(ps - jls) - - @property - def old_participant_count(self): - old_ps = [m for m in self.participants if m.age() >= 27] - return len(old_ps) - - @property - def head_count(self): - return self.staff_on_memberlist_count + self.participant_count - - @property - def approved_staff_count(self): - """Number of approved youth leaders for this excursion. The base number is calculated - from the participant count. To this, the number of additional approved youth leaders is added.""" - participant_count = self.participant_count - if participant_count < 4: - base_count = 0 - elif 4 <= participant_count <= 7: - base_count = 2 - else: - base_count = 2 + math.ceil((participant_count - 7) / 7) - return base_count + self.approved_extra_youth_leader_count - - @property - def theoretic_ljp_participant_count(self): - """ - Calculate the participant count in the sense of the LJP regulations. This means - that all youth leaders are counted and all participants which are at least 6 years old and - strictly less than 27 years old. Additionally, up to 20% of the participants may violate the - age restrictions. - - This is the theoretic value, ignoring the cutoff at 5 participants. - """ - # participants (possibly including youth leaders) - ps = {x.member for x in self.membersonlist.distinct()} - # youth leaders - jls = set(self.jugendleiter.distinct()) - # non-youth leader participants - ps_only = ps - jls - # participants of the correct age - ps_correct_age = {m for m in ps_only if m.age_at(self.date) >= 6 and m.age_at(self.date) < 27} - # m = the official non-youth-leader participant count - # and, assuming there exist enough participants, unrounded m satisfies the equation - # len(ps_correct_age) + 1/5 * m = m - # if there are not enough participants, - # m = len(ps_only) - m = min(len(ps_only), math.floor(5/4 * len(ps_correct_age))) - return m + len(jls) - - @property - def ljp_participant_count(self): - """ - The number of participants in the sense of LJP regulations. If the total - number of participants (including youth leaders and too old / young ones) is less - than 5, this is zero, otherwise it is `theoretic_ljp_participant_count`. - """ - # participants (possibly including youth leaders) - ps = {x.member for x in self.membersonlist.distinct()} - # youth leaders - jls = set(self.jugendleiter.distinct()) - if len(ps.union(jls)) < 5: - return 0 - else: - return self.theoretic_ljp_participant_count - - @property - def maximal_ljp_contributions(self): - """This is the maximal amount of LJP contributions that can be requested given participants and length - This calculation if intended for the LJP application, not for the payout.""" - return cvt_to_decimal(settings.LJP_CONTRIBUTION_PER_DAY * self.ljp_participant_count * self.duration) - - @property - def potential_ljp_contributions(self): - """The maximal amount can be reduced if the actual costs are lower than the maximal amount - This calculation if intended for the LJP application, not for the payout.""" - if not hasattr(self, 'statement'): - return cvt_to_decimal(0) - return cvt_to_decimal(min(self.maximal_ljp_contributions, - 0.9 * float(self.statement.total_bills_theoretic) + float(self.statement.total_staff))) - - @property - def payable_ljp_contributions(self): - """the payable contributions can differ from potential contributions if a tax is deducted for risk reduction. - the actual payout depends on more factors, e.g. the actual costs that had to be paid by the trip organisers.""" - if hasattr(self, 'statement') and self.statement.ljp_to: - return self.statement.paid_ljp_contributions - return cvt_to_decimal(self.potential_ljp_contributions * cvt_to_decimal(1 - settings.LJP_TAX)) - - @property - def total_relative_costs(self): - if not hasattr(self, 'statement'): - return 0 - total_costs = self.statement.total_bills_theoretic - total_contributions = self.statement.total_subsidies + self.payable_ljp_contributions - return total_costs - total_contributions - - @property - def time_period_str(self): - time_period = self.date.strftime('%d.%m.%Y') - if self.end != self.date: - time_period += " - " + self.end.strftime('%d.%m.%Y') - return time_period - - @property - def groups_str(self): - return ', '.join(g.name for g in self.groups.all()) - - @property - def staff_str(self): - return ', '.join(yl.name for yl in self.jugendleiter.all()) - - @property - def skill_summary(self): - activities = [a.name for a in self.activity.all()] - skills = {a: [] for a in activities} - people = [] - for memberonlist in self.membersonlist.all(): - m = memberonlist.member - qualities = [] - for activity, value in m.get_skills().items(): - if activity not in activities: - continue - skills[activity].append(value) - qualities.append("\\textit{%s:} %s" % (activity, value)) - people.append(dict(name=m.name, qualities=", ".join(qualities), comments=memberonlist.comments_tex)) - - sks = [] - for activity in activities: - skill_avg = 0 if len(skills[activity]) == 0 else\ - sum(skills[activity]) / len(skills[activity]) - skill_min = 0 if len(skills[activity]) == 0 else\ - min(skills[activity]) - skill_max = 0 if len(skills[activity]) == 0 else\ - max(skills[activity]) - sks.append(dict(name=activity, skill_avg=skill_avg, skill_min=skill_min, skill_max=skill_max)) - return (people, sks) - - def sjr_application_numbers(self): - members = set(map(lambda x: x.member, self.membersonlist.distinct())) - jls = set(self.jugendleiter.distinct()) - participants = members - jls - b27_local = len([m for m in participants - if m.age_at(self.date) <= 27 and settings.SEKTION in m.town]) - b27_non_local = len([m for m in participants - if m.age_at(self.date) <= 27 and not settings.SEKTION in m.town]) - staff = len(jls) - total = b27_local + b27_non_local + len(jls) - relevant_b27 = min(b27_local + b27_non_local, math.floor(3/2 * b27_local)) - subsidizable = relevant_b27 + min(math.ceil(relevant_b27 / 7), staff) - duration = self.night_count + 1 - return { - 'b27_local': b27_local, - 'b27_non_local': b27_non_local, - 'staff': staff, - 'total': total, - 'relevant_b27': relevant_b27, - 'subsidizable': subsidizable, - 'subsidized_days': duration * subsidizable, - 'duration': duration - } - - def sjr_application_fields(self): - members = set(map(lambda x: x.member, self.membersonlist.distinct())) - jls = set(self.jugendleiter.distinct()) - numbers = self.sjr_application_numbers() - title = self.ljpproposal.title if hasattr(self, 'ljpproposal') else self.name - base = {'Haushaltsjahr': str(datetime.now().year), - 'Art / Thema / Titel': title, - 'Ort': self.place, - 'Datum von': self.date.strftime('%d.%m.%Y'), - 'Datum bis': self.end.strftime('%d.%m.%Y'), - 'Dauer': str(numbers['duration']), - 'Teilnehmenden gesamt': str(numbers['total']), - 'bis 27 aus HD': str(numbers['b27_local']), - 'bis 27 nicht aus HD': str(numbers['b27_non_local']), - 'Verpflegungstage': str(numbers['subsidized_days']).replace('.', ','), - 'Betreuer/in': str(numbers['staff']), - 'Auswahl Veranstaltung': 'Auswahl2', - 'Ort, Datum': '{p}, {d}'.format(p=settings.SEKTION, d=datetime.now().strftime('%d.%m.%Y'))} - for i, m in enumerate(members): - suffix = str(' {}'.format(i + 1)) - # indexing starts at zero, but the listing in the pdf starts at 1 - if i + 1 == 1: - suffix = '' - elif i + 1 >= 13: - suffix = str(i + 1) - base['Vor- und Nachname' + suffix] = m.name - base['Anschrift' + suffix] = m.address - base['Alter' + suffix] = str(m.age_at(self.date)) - base['Status' + str(i+1)] = '2' if m in jls else 'Auswahl1' if settings.SEKTION in m.address else 'Auswahl2' - return base - - def v32_fields(self): - title = self.ljpproposal.title if hasattr(self, 'ljpproposal') else self.name - base = { - # AntragstellerIn - 'Textfeld 2': settings.ADDRESS, - # Dachorganisation - 'Textfeld 3': settings.V32_HEAD_ORGANISATION, - # Datum der Maßnahme am/vom - 'Textfeld 20': self.date.strftime('%d.%m.%Y'), - # bis - 'Textfeld 28': self.end.strftime('%d.%m.%Y'), - # Thema der Maßnahme - 'Textfeld 22': title, - # IBAN - 'Textfeld 36': settings.SEKTION_IBAN, - # Kontoinhaber - 'Textfeld 37': settings.SEKTION_ACCOUNT_HOLDER, - # Zahl der zuwendungsfähigen Teilnehemr - 'Textfeld 43': str(self.ljp_participant_count), - # Teilnahmetage - 'Textfeld 46': str(round(self.duration * self.ljp_participant_count, 1)).replace('.', ','), - # Euro Tagessatz - 'Textfeld 48': str(settings.LJP_CONTRIBUTION_PER_DAY), - # Erbetener Zuschuss - 'Textfeld 50': str(self.maximal_ljp_contributions).replace('.', ','), - # Stunden Bildungsprogramm - 'Textfeld 52': '??', - # Tage - 'Textfeld 53': str(round(self.duration, 1)).replace('.', ','), - # Haushaltsjahr - 'Textfeld 54': str(datetime.now().year), - # nicht anrechenbare Teilnahmetage - 'Textfeld 55': '0', - # Gesamt-Teilnahmetage - 'Textfeld 56': str(round(self.duration * self.ljp_participant_count, 1)).replace('.', ','), - # Ort, Datum - 'DatumOrt 2': '{place}, {date}'.format(place=settings.SEKTION, - date=datetime.now().strftime('%d.%m.%Y')) - } - if hasattr(self, 'statement'): - possible_contributions = self.maximal_ljp_contributions - total_contributions = min(self.statement.total_theoretic, possible_contributions) - self_participation = max(cvt_to_decimal(0), self.statement.total_theoretic - possible_contributions) - # Gesamtkosten von - base['Textfeld 62'] = str(self.statement.total_theoretic).replace('.', ',') - # Eigenmittel und Teilnahmebeiträge - base['Textfeld 59'] = str(self_participation).replace('.', ',') - # Drittmittel - base['Textfeld 60'] = '0,00' - # Erbetener Zuschuss - base['Textfeld 61'] = str(total_contributions).replace('.', ',') - # Ergibt wieder - base['Textfeld 58'] = base['Textfeld 62'] - return base - - def get_ljp_activity_category(self): - """ - The official LJP activity category associated with this excursion. This is deduced - from the `activity` field. - """ - return ", ".join([a.ljp_category for a in self.activity.all()]) - - @staticmethod - def filter_queryset_by_permissions(member, queryset=None): - if queryset is None: - queryset = Freizeit.objects.all() - - groups = member.leited_groups.all() - # one may view all leited groups and oneself - queryset = queryset.filter(Q(groups__in=groups) | Q(jugendleiter__pk=member.pk)).distinct() - return queryset - - def send_crisis_intervention_list(self, sending_time=None): - """ - Send the crisis intervention list to the crisis invervention email, the - responsible and the youth leaders of this excursion. - """ - context = dict(memberlist=self, settings=settings) - start_date= timezone.localtime(self.date).strftime('%d.%m.%Y') - filename = render_tex(f"{self.code}_{self.name}_Krisenliste", - 'members/crisis_intervention_list.tex', context, - date=self.date, save_only=True) - leaders = ", ".join([yl.name for yl in self.jugendleiter.all()]) - start_date = timezone.localtime(self.date).strftime('%d.%m.%Y') - end_date = timezone.localtime(self.end).strftime('%d.%m.%Y') - # create email with attachment - send_mail(_('Crisis intervention list for %(excursion)s from %(start)s to %(end)s') %\ - { 'excursion': self.name, - 'start': start_date, - 'end': end_date }, - settings.SEND_EXCURSION_CRISIS_LIST.format(excursion=self.name, leaders=leaders, - excursion_start=start_date, - excursion_end=end_date), - sender=settings.DEFAULT_SENDING_MAIL, - recipients=[settings.SEKTION_CRISIS_INTERVENTION_MAIL], - cc=[settings.RESPONSIBLE_MAIL] + [yl.email for yl in self.jugendleiter.all()], - attachments=[media_path(filename)]) - self.crisis_intervention_list_sent = True - self.save() - - def notify_leaders_crisis_intervention_list(self, sending_time=None): - """ - Send an email to the youth leaders of this excursion with a list of currently - registered participants and a heads-up that the crisis intervention list - will be automatically sent on the night of this day. - """ - participants = "\n".join([f"- {p.member.name}" for p in self.membersonlist.all()]) - if not sending_time: - sending_time = coming_midnight().strftime("%d.%m.%y %H:%M") - elif not isinstance(sending_time, str): - sending_time = sending_time.strftime("%d.%m.%y %H:%M") - start_date = timezone.localtime(self.date).strftime('%d.%m.%Y') - end_date = timezone.localtime(self.end).strftime('%d.%m.%Y') - excursion_link = prepend_base_url(self.get_absolute_url()) - for yl in self.jugendleiter.all(): - yl.send_mail(_('Participant list for %(excursion)s from %(start)s to %(end)s') %\ - { 'excursion': self.name, - 'start': start_date, - 'end': end_date }, - settings.NOTIFY_EXCURSION_PARTICIPANT_LIST.format(name=yl.prename, - excursion=self.name, - participants=participants, - sending_time=sending_time, - excursion_link=excursion_link)) - self.notification_crisis_intervention_list_sent = True - self.save() - - -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. - - A Klettertreff can take a date, location, Jugendleiter, attending members - as input. - """ - date = models.DateField(_('Date'), default=datetime.today) - location = models.CharField(_('Location'), default='', max_length=60) - topic = models.CharField(_('Topic'), default='', max_length=60) - jugendleiter = models.ManyToManyField(Member) - group = models.ForeignKey(Group, default='', verbose_name=_('Group'), on_delete=models.CASCADE) - - def __str__(self): - return self.location + ' ' + self.date.strftime('%d.%m.%Y') - - def get_jugendleiter(self): - jl_string = ', '.join(j.name for j in self.jugendleiter.all()) - return jl_string - - def has_attendee(self, member): - queryset = KlettertreffAttendee.objects.filter( - member__id__contains=member.id, - klettertreff__id__contains=self.id) - if queryset: - return True - return False - - def has_jugendleiter(self, jugendleiter): - if jugendleiter in self.jugendleiter.all(): - return True - return False - - get_jugendleiter.short_description = _('Jugendleiter') - - class Meta: - verbose_name = _('Klettertreff') - verbose_name_plural = _('Klettertreffs') - - -class KlettertreffAttendee(models.Model): - """Connects members to Klettertreffs.""" - member = models.ForeignKey(Member, verbose_name=_('Member'), on_delete=models.CASCADE) - klettertreff = models.ForeignKey(Klettertreff, on_delete=models.CASCADE) - - def __str__(self): - return str(self.member) - - class Meta: - verbose_name = _('Member') - verbose_name_plural = _('Members') - - -class RegistrationPassword(models.Model): - group = models.ForeignKey(Group, on_delete=models.CASCADE) - password = models.CharField(_('Password'), default='', max_length=20, unique=True) - - class Meta: - verbose_name = _('registration password') - verbose_name_plural = _('registration passwords') - - -class LJPProposal(CommonModel): - """A proposal for LJP""" - title = models.CharField(verbose_name=_('Title'), max_length=100, - blank=True, default='', - help_text=_('Official title of your seminar, this can differ from the informal title. Use e.g. sports climbing course instead of climbing weekend for fun.')) - - LJP_STAFF_TRAINING, LJP_EDUCATIONAL = 1, 2 - LJP_CATEGORIES = [ - (LJP_EDUCATIONAL, _('Educational programme')), - (LJP_STAFF_TRAINING, _('Staff training')) - ] - category = models.IntegerField(verbose_name=_('Category'), - choices=LJP_CATEGORIES, - default=2, - help_text=_('Type of seminar. Usually the correct choice is educational programme.')) - LJP_QUALIFICATION, LJP_PARTICIPATION, LJP_DEVELOPMENT, LJP_ENVIRONMENT = 1, 2, 3, 4 - LJP_GOALS = [ - (LJP_QUALIFICATION, _('Qualification')), - (LJP_PARTICIPATION, _('Participation')), - (LJP_DEVELOPMENT, _('Personality development')), - (LJP_ENVIRONMENT, _('Environment')), - ] - goal = models.IntegerField(verbose_name=_('Learning goal'), - choices=LJP_GOALS, - default=1, - help_text=_('Official learning goal according to LJP regulations.')) - goal_strategy = models.TextField(verbose_name=_('Strategy'), - help_text=_('How do you want to reach the learning goal? Has the goal been reached? If not, why not? If yes, what helped you to reach the goal?'), - blank=True, default='') - - NOT_BW_CONTENT, NOT_BW_ROOMS, NOT_BW_CLOSE_BORDER, NOT_BW_ECONOMIC = 1, 2, 3, 4 - NOT_BW_REASONS = [ - (NOT_BW_CONTENT, _('Course content')), - (NOT_BW_ROOMS, _('Available rooms')), - (NOT_BW_CLOSE_BORDER, _('Close to the border')), - (NOT_BW_ECONOMIC, _('Economic reasons')), - ] - not_bw_reason = models.IntegerField(verbose_name=_('Explanation if excursion not in Baden-Württemberg'), - choices=NOT_BW_REASONS, - default=None, - blank=True, - null=True, - help_text=_('If the excursion takes place outside of Baden-Württemberg, please explain. Otherwise, leave this empty.')) - - excursion = models.OneToOneField(Freizeit, - verbose_name=_('Excursion'), - blank=True, - null=True, - on_delete=models.SET_NULL) - - class Meta(CommonModel.Meta): - verbose_name = _('LJP Proposal') - verbose_name_plural = _('LJP Proposals') - rules_permissions = { - 'add_obj': is_leader, - 'view_obj': is_leader | has_global_perm('members.view_global_freizeit'), - 'change_obj': is_leader, - 'delete_obj': is_leader, - } - - def __str__(self): - return self.title - -class Intervention(CommonModel): - """An intervention during a seminar as part of a LJP proposal""" - date_start = models.DateTimeField(verbose_name=_('Starting time')) - duration = models.DecimalField(verbose_name=_('Duration in hours'), - max_digits=4, - decimal_places=2) - activity = models.TextField(verbose_name=_('Activity and method')) - - ljp_proposal = models.ForeignKey(LJPProposal, - verbose_name=_('LJP Proposal'), - blank=False, - on_delete=models.CASCADE) - - class Meta: - verbose_name = _('Intervention') - verbose_name_plural = _('Interventions') - rules_permissions = { - 'add_obj': is_leader_of_excursion, - 'view_obj': is_leader_of_excursion | has_global_perm('members.view_global_freizeit'), - 'change_obj': is_leader_of_excursion, - 'delete_obj': is_leader_of_excursion, - } - - -def annotate_activity_score(queryset): - one_year_ago = timezone.now() - timedelta(days=365) - queryset = queryset.annotate( - _jugendleiter_freizeit_score_calc=Subquery( - Freizeit.objects.filter(jugendleiter=OuterRef('pk'), - date__gte=one_year_ago) - .values('jugendleiter') - .annotate(cnt=Count('pk', distinct=True)) - .values('cnt'), - output_field=IntegerField() - ), - # better solution but does not work in production apparently - #_jugendleiter_freizeit_score=Sum(Case( - # When( - # freizeit__date__gte=one_year_ago, - # then=1), - # default=0, - # output_field=IntegerField() - # ), - # distinct=True), - _jugendleiter_klettertreff_score_calc=Subquery( - Klettertreff.objects.filter(jugendleiter=OuterRef('pk'), - date__gte=one_year_ago) - .values('jugendleiter') - .annotate(cnt=Count('pk', distinct=True)) - .values('cnt'), - output_field=IntegerField() - ), - # better solution but does not work in production apparently - #_jugendleiter_klettertreff_score=Sum(Case( - # When( - # klettertreff__date__gte=one_year_ago, - # then=1), - # default=0, - # output_field=IntegerField() - # ), - # distinct=True), - _freizeit_score_calc=Subquery( - Freizeit.objects.filter(membersonlist__member=OuterRef('pk'), - date__gte=one_year_ago) - .values('membersonlist__member') - .annotate(cnt=Count('pk', distinct=True)) - .values('cnt'), - output_field=IntegerField() - ), - _klettertreff_score_calc=Subquery( - KlettertreffAttendee.objects.filter(member=OuterRef('pk'), - klettertreff__date__gte=one_year_ago) - .values('member') - .annotate(cnt=Count('pk', distinct=True)) - .values('cnt'), - output_field=IntegerField())) - queryset = queryset.annotate( - _jugendleiter_freizeit_score=Case( - When( - _jugendleiter_freizeit_score_calc=None, - then=0 - ), - default=F('_jugendleiter_freizeit_score_calc'), - output_field=IntegerField()), - _jugendleiter_klettertreff_score=Case( - When( - _jugendleiter_klettertreff_score_calc=None, - then=0 - ), - default=F('_jugendleiter_klettertreff_score_calc'), - output_field=IntegerField()), - _klettertreff_score=Case( - When( - _klettertreff_score_calc=None, - then=0 - ), - default=F('_klettertreff_score_calc'), - output_field=IntegerField()), - _freizeit_score=Case( - When( - _freizeit_score_calc=None, - then=0 - ), - default=F('_freizeit_score_calc'), - output_field=IntegerField())) - queryset = queryset.annotate( - #_activity_score=F('_jugendleiter_freizeit_score') - _activity_score=(F('_klettertreff_score') + 3 * F('_freizeit_score') - + F('_jugendleiter_klettertreff_score') + 3 * F('_jugendleiter_freizeit_score')) - ) - return queryset - - -class PermissionMember(models.Model): - member = models.OneToOneField(Member, on_delete=models.CASCADE, related_name='permissions') - # every member of view_members may view this member - list_members = models.ManyToManyField(Member, related_name='listable_by', blank=True, - verbose_name=_('May list members')) - view_members = models.ManyToManyField(Member, related_name='viewable_by', blank=True, - verbose_name=_('May view members')) - change_members = models.ManyToManyField(Member, related_name='changeable_by', blank=True, - verbose_name=_('May change members')) - delete_members = models.ManyToManyField(Member, related_name='deletable_by', blank=True, - verbose_name=_('May delete members')) - - # every member in any view_group may view this member - list_groups = models.ManyToManyField(Group, related_name='listable_by', blank=True, - verbose_name=_('May list members of groups')) - view_groups = models.ManyToManyField(Group, related_name='viewable_by', blank=True, - verbose_name=_('May view members of groups')) - change_groups = models.ManyToManyField(Group, related_name='changeable_by', blank=True, - verbose_name=_('May change members of groups')) - delete_groups = models.ManyToManyField(Group, related_name='deletable_by', blank=True, - verbose_name=_('May delete members of groups')) - - class Meta: - verbose_name = _('Permissions') - verbose_name_plural = _('Permissions') - - def __str__(self): - return str(_('Permissions')) - - -class PermissionGroup(models.Model): - group = models.OneToOneField(Group, on_delete=models.CASCADE, related_name='permissions') - # every member of view_members may view all members of group - list_members = models.ManyToManyField(Member, related_name='group_members_listable_by', blank=True, - verbose_name=_('May list members')) - view_members = models.ManyToManyField(Member, related_name='group_members_viewable_by', blank=True, - verbose_name=_('May view members')) - change_members = models.ManyToManyField(Member, related_name='group_members_changeable_by_group', blank=True, - verbose_name=_('May change members')) - delete_members = models.ManyToManyField(Member, related_name='group_members_deletable_by', blank=True, - verbose_name=_('May delete members')) - - # every member in any view_group may view all members of group - list_groups = models.ManyToManyField(Group, related_name='group_members_listable_by', blank=True, - verbose_name=_('May list members of groups')) - view_groups = models.ManyToManyField(Group, related_name='group_members_viewable_by', blank=True, - verbose_name=_('May view members of groups')) - change_groups = models.ManyToManyField(Group, related_name='group_members_changeable_by', blank=True, - verbose_name=_('May change members of groups')) - delete_groups = models.ManyToManyField(Group, related_name='group_members_deletable_by', blank=True, - verbose_name=_('May delete members of groups')) - - class Meta: - verbose_name = _('Group permissions') - verbose_name_plural = _('Group permissions') - - def __str__(self): - return str(_('Group permissions')) - - -class TrainingCategory(models.Model): - """Represents a type of training, e.g. Grundausbildung, Fortbildung, Aufbaumodul, etc.""" - name = models.CharField(verbose_name=_('Name'), max_length=50) - permission_needed = models.BooleanField(verbose_name=_('Permission needed')) - - class Meta: - verbose_name = _('Training category') - verbose_name_plural = _('Training categories') - - def __str__(self): - return self.name - - -class MemberTraining(CommonModel): - """Represents a training planned or attended by a member.""" - member = models.ForeignKey(Member, on_delete=models.CASCADE, related_name='traininigs', verbose_name=_('Member')) - title = models.CharField(verbose_name=_('Title'), max_length=150) - date = models.DateField(verbose_name=_('Date'), null=True, blank=True) - category = models.ForeignKey(TrainingCategory, on_delete=models.PROTECT, verbose_name=_('Category')) - activity = models.ManyToManyField(ActivityCategory, verbose_name=_('Activity')) - comments = models.TextField(verbose_name=_('Comments'), blank=True) - participated = models.BooleanField(verbose_name=_('Participated'), null=True) - passed = models.BooleanField(verbose_name=_('Passed'), null=True) - certificate = RestrictedFileField(verbose_name=_('certificate of attendance'), - upload_to='training_forms', - blank=True, - max_upload_size=5, - content_types=['application/pdf', - 'image/jpeg', - 'image/png', - 'image/gif']) - - def __str__(self): - if self.date: - return self.title + ' ' + self.date.strftime('%d.%m.%Y') - return self.title + ' ' + str(_('(no date)')) - - def get_activities(self): - activity_string = ', '.join(a.name for a in self.activity.all()) - return activity_string - - get_activities.short_description = _('Activities') - - - class Meta(CommonModel.Meta): - verbose_name = _('Training') - verbose_name_plural = _('Trainings') - - permissions = ( - ('manage_success_trainings', 'Can edit the success status of trainings.'), - ) - rules_permissions = { - # sine this is used in an inline, the member and not the training is passed - 'add_obj': is_oneself | has_global_perm('members.add_global_membertraining'), - 'view_obj': is_oneself | has_global_perm('members.view_global_membertraining'), - 'change_obj': is_oneself | has_global_perm('members.change_global_membertraining'), - 'delete_obj': is_oneself | has_global_perm('members.delete_global_membertraining'), - } diff --git a/jdav_web/members/models/__init__.py b/jdav_web/members/models/__init__.py new file mode 100644 index 0000000..a372587 --- /dev/null +++ b/jdav_web/members/models/__init__.py @@ -0,0 +1,139 @@ +from django.db.models import F, When, IntegerField, Case, Subquery, OuterRef, Count +from datetime import timedelta +from django.utils import timezone +from .constants import * +from .activity import ActivityCategory +from .group import Group +from .member import Member, MemberManager +from .emergency_contact import EmergencyContact +from .member_unconfirmed import MemberUnconfirmedProxy, MemberUnconfirmedManager +from .invitation import InvitationToGroup +from .waiting_list import MemberWaitingList +from .member_on_list import NewMemberOnList +from .excursion import Freizeit +from .member_note_list import MemberNoteList +from .klettertreff import Klettertreff, KlettertreffAttendee +from .registration import RegistrationPassword +from .ljp import LJPProposal, Intervention +from .permission import PermissionMember, PermissionGroup +from .training import TrainingCategory, MemberTraining +from .base import Contact, ContactWithPhoneNumber, Person, gen_key + +__all__ = [ + 'ActivityCategory', + 'Group', + 'Member', 'MemberManager', 'Contact', 'ContactWithPhoneNumber', 'Person', + 'EmergencyContact', + 'MemberUnconfirmedProxy', 'MemberUnconfirmedManager', + 'InvitationToGroup', + 'MemberWaitingList', + 'NewMemberOnList', + 'Freizeit', + 'MemberNoteList', + 'Klettertreff', 'KlettertreffAttendee', + 'RegistrationPassword', + 'LJPProposal', 'Intervention', + 'PermissionMember', 'PermissionGroup', + 'TrainingCategory', 'MemberTraining', 'gen_key', +] + + +def annotate_activity_score(queryset): + one_year_ago = timezone.now() - timedelta(days=365) + queryset = queryset.annotate( + _jugendleiter_freizeit_score_calc=Subquery( + Freizeit.objects.filter(jugendleiter=OuterRef('pk'), + date__gte=one_year_ago) + .values('jugendleiter') + .annotate(cnt=Count('pk', distinct=True)) + .values('cnt'), + output_field=IntegerField() + ), + # better solution but does not work in production apparently + #_jugendleiter_freizeit_score=Sum(Case( + # When( + # freizeit__date__gte=one_year_ago, + # then=1), + # default=0, + # output_field=IntegerField() + # ), + # distinct=True), + _jugendleiter_klettertreff_score_calc=Subquery( + Klettertreff.objects.filter(jugendleiter=OuterRef('pk'), + date__gte=one_year_ago) + .values('jugendleiter') + .annotate(cnt=Count('pk', distinct=True)) + .values('cnt'), + output_field=IntegerField() + ), + # better solution but does not work in production apparently + #_jugendleiter_klettertreff_score=Sum(Case( + # When( + # klettertreff__date__gte=one_year_ago, + # then=1), + # default=0, + # output_field=IntegerField() + # ), + # distinct=True), + _freizeit_score_calc=Subquery( + Freizeit.objects.filter(membersonlist__member=OuterRef('pk'), + date__gte=one_year_ago) + .values('membersonlist__member') + .annotate(cnt=Count('pk', distinct=True)) + .values('cnt'), + output_field=IntegerField() + ), + _klettertreff_score_calc=Subquery( + KlettertreffAttendee.objects.filter(member=OuterRef('pk'), + klettertreff__date__gte=one_year_ago) + .values('member') + .annotate(cnt=Count('pk', distinct=True)) + .values('cnt'), + output_field=IntegerField())) + queryset = queryset.annotate( + _jugendleiter_freizeit_score=Case( + When( + _jugendleiter_freizeit_score_calc=None, + then=0 + ), + default=F('_jugendleiter_freizeit_score_calc'), + output_field=IntegerField()), + _jugendleiter_klettertreff_score=Case( + When( + _jugendleiter_klettertreff_score_calc=None, + then=0 + ), + default=F('_jugendleiter_klettertreff_score_calc'), + output_field=IntegerField()), + _klettertreff_score=Case( + When( + _klettertreff_score_calc=None, + then=0 + ), + default=F('_klettertreff_score_calc'), + output_field=IntegerField()), + _freizeit_score=Case( + When( + _freizeit_score_calc=None, + then=0 + ), + default=F('_freizeit_score_calc'), + output_field=IntegerField())) + queryset = queryset.annotate( + #_activity_score=F('_jugendleiter_freizeit_score') + _activity_score=(F('_klettertreff_score') + 3 * F('_freizeit_score') + + F('_jugendleiter_klettertreff_score') + 3 * F('_jugendleiter_freizeit_score')) + ) + return queryset + +def confirm_mail_by_key(key): + matching_unconfirmed = MemberUnconfirmedProxy.objects.filter(confirm_mail_key=key) \ + | MemberUnconfirmedProxy.objects.filter(confirm_alternative_mail_key=key) + matching_waiter = MemberWaitingList.objects.filter(confirm_mail_key=key) + matching_emergency_contact = EmergencyContact.objects.filter(confirm_mail_key=key) + matches = list(matching_unconfirmed) + list(matching_waiter) + list(matching_emergency_contact) + # if not exactly one match, return None. The case > 1 match should not occur! + if len(matches) != 1: + return None + person = matches[0] + return person, person.confirm_mail(key) \ No newline at end of file diff --git a/jdav_web/members/models/activity.py b/jdav_web/members/models/activity.py new file mode 100644 index 0000000..c10d282 --- /dev/null +++ b/jdav_web/members/models/activity.py @@ -0,0 +1,26 @@ +from django.db import models +from django.utils.translation import gettext_lazy as _ + +class ActivityCategory(models.Model): + """ + Describes one kind of activity + """ + LJP_CATEGORIES = [('Winter', _('winter')), + ('Skibergsteigen', _('ski mountaineering')), + ('Klettern', _('climbing')), + ('Bergsteigen', _('mountaineering')), + ('Theorie', _('theory')), + ('Sonstiges', _('others'))] + name = models.CharField(max_length=20, verbose_name=_('Name')) + ljp_category = models.CharField(choices=LJP_CATEGORIES, + verbose_name=_('LJP category'), + max_length=20, + help_text=_('The official category for LJP applications associated with this activity.')) + description = models.TextField(_('Description')) + + class Meta: + verbose_name = _('Activity') + verbose_name_plural = _('Activities') + + def __str__(self): + return self.name diff --git a/jdav_web/members/models/base.py b/jdav_web/members/models/base.py new file mode 100644 index 0000000..b3365f8 --- /dev/null +++ b/jdav_web/members/models/base.py @@ -0,0 +1,139 @@ +from django.db import models +from django.utils.translation import gettext_lazy as _ +from django.utils.html import format_html +from django.conf import settings +from contrib.models import CommonModel +from mailer.mailutils import send as send_mail, get_mail_confirmation_link +from datetime import datetime, date +from dateutil.relativedelta import relativedelta +import uuid +from .constants import MALE, FEMALE, DIVERSE + +def gen_key(): + return uuid.uuid4().hex + +class Contact(CommonModel): + """ + Represents an abstract person with only absolutely necessary contact information. + """ + prename = models.CharField(max_length=20, verbose_name=_('prename')) + lastname = models.CharField(max_length=20, verbose_name=_('last name')) + + email = models.EmailField(max_length=100, default="") + confirmed_mail = models.BooleanField(default=False, verbose_name=_('Email confirmed')) + confirm_mail_key = models.CharField(max_length=32, default="") + + class Meta(CommonModel.Meta): + abstract = True + + def __str__(self): + """String representation""" + return self.name + + @property + def name(self): + """Returning whole name (prename + lastname)""" + return "{0} {1}".format(self.prename, self.lastname) + + def phone_number_tel_link(self): + """Returns the phone number as tel link.""" + return format_html('{tel}'.format(tel=self.phone_number)) + phone_number_tel_link.short_description = _('phone number') + phone_number_tel_link.admin_order_field = 'phone_number' + + def email_mailto_link(self): + """Returns the emails as a mailto link.""" + return format_html('{email}'.format(email=self.email)) + email_mailto_link.short_description = 'Email' + email_mailto_link.admin_order_field = 'email' + + @property + def email_fields(self): + """Returns all tuples of emails and confirmation data related to this contact. + By default, this is only the principal email field, but extending classes can add + more email fields and then override this method.""" + return [('email', 'confirmed_mail', 'confirm_mail_key')] + + def request_mail_confirmation(self, rerequest=True): + """Request mail confirmation for every mail field. If `rerequest` is false, then only + confirmation is requested for currently unconfirmed emails. + + Returns true if any mail confirmation was requested, false otherwise.""" + requested_confirmation = False + for email_fd, confirmed_email_fd, confirm_mail_key_fd in self.email_fields: + if getattr(self, confirmed_email_fd) and not rerequest: + continue + if not getattr(self, email_fd): # pragma: no cover + # Only reachable with misconfigured `email_fields` + continue + requested_confirmation = True + setattr(self, confirmed_email_fd, False) + confirm_mail_key = uuid.uuid4().hex + setattr(self, confirm_mail_key_fd, confirm_mail_key) + send_mail(_('Email confirmation needed'), + settings.CONFIRM_MAIL_TEXT.format(name=self.prename, + link=get_mail_confirmation_link(confirm_mail_key), + whattoconfirm='deiner Emailadresse'), + settings.DEFAULT_SENDING_MAIL, + getattr(self, email_fd)) + self.save() + return requested_confirmation + + def confirm_mail(self, key): + for email_fd, confirmed_email_fd, confirm_mail_key_fd in self.email_fields: + if getattr(self, confirm_mail_key_fd) == key: + setattr(self, confirmed_email_fd, True) + setattr(self, confirm_mail_key_fd, "") + self.save() + return getattr(self, email_fd) + return None + + def send_mail(self, subject, content, cc=None): + send_mail(subject, content, settings.DEFAULT_SENDING_MAIL, + [getattr(self, email_fd) for email_fd, _, _ in self.email_fields], cc=cc) + + +class ContactWithPhoneNumber(Contact): + """ + A contact with a phone number. + """ + phone_number = models.CharField(max_length=100, verbose_name=_('phone number')) + + class Meta(CommonModel.Meta): + abstract = True + + +class Person(Contact): + """ + Represents an abstract person. Not necessarily a member of any group. + """ + birth_date = models.DateField(_('birth date'), null=True, blank=True) # to determine the age + gender_choices = ((MALE, 'Männlich'), + (FEMALE, 'Weiblich'), + (DIVERSE, 'Divers')) + gender = models.IntegerField(choices=gender_choices, + verbose_name=_('Gender')) + comments = models.TextField(_('comments'), default='', blank=True) + + class Meta(CommonModel.Meta): + abstract = True + + def age(self): + """Age of member""" + return relativedelta(datetime.today(), self.birth_date).years + age.admin_order_field = 'birth_date' + age.short_description = _('age') + + def age_at(self, date: date): + """Age of member at a given date""" + return relativedelta(date.replace(tzinfo=None), self.birth_date).years + + @property + def birth_date_str(self): + if self.birth_date is None: + return "---" + return self.birth_date.strftime("%d.%m.%Y") + + @property + def gender_str(self): + return self.gender_choices[self.gender][1] diff --git a/jdav_web/members/models/constants.py b/jdav_web/members/models/constants.py new file mode 100644 index 0000000..69bb546 --- /dev/null +++ b/jdav_web/members/models/constants.py @@ -0,0 +1,15 @@ +from django.utils.translation import gettext_lazy as _ + +GEMEINSCHAFTS_TOUR = MUSKELKRAFT_ANREISE = MALE = 0 +FUEHRUNGS_TOUR = OEFFENTLICHE_ANREISE = FEMALE = 1 +AUSBILDUNGS_TOUR = FAHRGEMEINSCHAFT_ANREISE = DIVERSE = 2 + +WEEKDAYS = ( + (0, _('Monday')), + (1, _('Tuesday')), + (2, _('Wednesday')), + (3, _('Thursday')), + (4, _('Friday')), + (5, _('Saturday')), + (6, _('Sunday')), +) diff --git a/jdav_web/members/models/emergency_contact.py b/jdav_web/members/models/emergency_contact.py new file mode 100644 index 0000000..58e98aa --- /dev/null +++ b/jdav_web/members/models/emergency_contact.py @@ -0,0 +1,27 @@ +from django.db import models +from django.utils.translation import gettext_lazy as _ +from contrib.models import CommonModel +from members.rules import may_view, may_change, may_delete +from contrib.rules import has_global_perm +from .base import ContactWithPhoneNumber +from .member import Member + +class EmergencyContact(ContactWithPhoneNumber): + """ + Emergency contact of a member + """ + member = models.ForeignKey(Member, verbose_name=_('Member'), on_delete=models.CASCADE) + email = models.EmailField(max_length=100, default='', blank=True) + + def __str__(self): + return str(self.member) + + class Meta(CommonModel.Meta): + verbose_name = _('Emergency contact') + verbose_name_plural = _('Emergency contacts') + rules_permissions = { + 'add_obj': may_change | has_global_perm('members.change_global_member'), + 'view_obj': may_view | has_global_perm('members.view_global_member'), + 'change_obj': may_change | has_global_perm('members.change_global_member'), + 'delete_obj': may_delete | has_global_perm('members.delete_global_member'), + } diff --git a/jdav_web/members/models/excursion.py b/jdav_web/members/models/excursion.py new file mode 100644 index 0000000..814af7e --- /dev/null +++ b/jdav_web/members/models/excursion.py @@ -0,0 +1,542 @@ +from datetime import datetime, timedelta +import math +from django.db import models +from django.utils import timezone +from django.utils.translation import gettext_lazy as _ +from django.urls import reverse +from django.conf import settings +from django.db.models import Q, F, When, Value, IntegerField, Case, Sum +from django.db.models.functions import Cast +from django.core.validators import MinValueValidator +from django.contrib.contenttypes.fields import GenericRelation +from contrib.models import CommonModel +from members.rules import is_leader +from contrib.rules import has_global_perm +from utils import cvt_to_decimal, coming_midnight +from mailer.mailutils import send as send_mail, prepend_base_url +from contrib.media import media_path +from members.pdf import render_tex +from .constants import (GEMEINSCHAFTS_TOUR, FUEHRUNGS_TOUR, AUSBILDUNGS_TOUR, + MUSKELKRAFT_ANREISE, OEFFENTLICHE_ANREISE, FAHRGEMEINSCHAFT_ANREISE) +from .group import Group +from .member_on_list import NewMemberOnList +from .activity import ActivityCategory + + +class Freizeit(CommonModel): + """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) + postcode = models.CharField(verbose_name=_('Postcode'), default='', max_length=30) + destination = models.CharField(verbose_name=_('Destination (optional)'), + default='', max_length=50, blank=True, + help_text=_('e.g. a peak')) + date = models.DateTimeField(default=timezone.now, verbose_name=_('Begin')) + end = models.DateTimeField(verbose_name=_('End (optional)'), default=timezone.now) + description = models.TextField(verbose_name=_('Description'), blank=True, default='') + # comment = models.TextField(_('Comments'), default='', blank=True) + groups = models.ManyToManyField(Group, verbose_name=_('Groups')) + jugendleiter = models.ManyToManyField('Member') + approved_extra_youth_leader_count = models.PositiveIntegerField(verbose_name=_('Number of additional approved youth leaders'), + default=0, + help_text=_('The number of approved youth leaders per excursion is determined by the number of participants. In special circumstances, e.g. in case of a technically demanding excursion, more youth leaders may be approved.')) + 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) + tour_approach_choices = ((MUSKELKRAFT_ANREISE, 'Muskelkraft'), + (OEFFENTLICHE_ANREISE, 'ÖPNV'), + (FAHRGEMEINSCHAFT_ANREISE, 'Fahrgemeinschaften')) + tour_approach = models.IntegerField(choices=tour_approach_choices, + default=MUSKELKRAFT_ANREISE, + verbose_name=_('Means of transportation')) + kilometers_traveled = models.IntegerField(verbose_name=_('Kilometers traveled'), + validators=[MinValueValidator(0)]) + 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) + + # approval: None means no decision taken, False means rejected + approved = models.BooleanField(verbose_name=_('Approved'), + null=True, + default=None, + help_text=_('Choose no in case of rejection or yes in case of approval. Leave empty, if not yet decided.')) + approval_comments = models.TextField(verbose_name=_('Approval comments'), + blank=True, default='') + + # automatic sending of crisis intervention list + crisis_intervention_list_sent = models.BooleanField(default=False) + notification_crisis_intervention_list_sent = models.BooleanField(default=False) + + def __str__(self): + """String represenation""" + return self.name + + class Meta(CommonModel.Meta): + verbose_name = _('Excursion') + verbose_name_plural = _('Excursions') + permissions = ( + ('manage_approval_excursion', 'Can edit the approval status of excursions.'), + ('view_approval_excursion', 'Can view the approval status of excursions.'), + ) + rules_permissions = { + 'add_obj': has_global_perm('members.add_global_freizeit'), + 'view_obj': is_leader | has_global_perm('members.view_global_freizeit'), + 'change_obj': is_leader | has_global_perm('members.change_global_freizeit'), + 'delete_obj': is_leader | has_global_perm('members.delete_global_freizeit'), + } + + @property + def code(self): + return f"B{self.date:%y}-{self.pk}" + + @staticmethod + def filter_queryset_date_next_n_hours(hours, queryset=None): + if queryset is None: + queryset = Freizeit.objects.all() + return queryset.filter(date__lte=timezone.now() + timezone.timedelta(hours=hours), + date__gte=timezone.now()) + + @staticmethod + def to_notify_crisis_intervention_list(): + qs = Freizeit.objects.filter(notification_crisis_intervention_list_sent=False) + return Freizeit.filter_queryset_date_next_n_hours(48, queryset=qs) + + @staticmethod + def to_send_crisis_intervention_list(): + qs = Freizeit.objects.filter(crisis_intervention_list_sent=False) + return Freizeit.filter_queryset_date_next_n_hours(24, queryset=qs) + + def get_tour_type(self): + if self.tour_type == FUEHRUNGS_TOUR: + return "Führungstour" + elif self.tour_type == AUSBILDUNGS_TOUR: + return "Ausbildung" + return "Gemeinschaftstour" + + def get_tour_approach(self): + if self.tour_approach == MUSKELKRAFT_ANREISE: + return "Muskelkraft" + elif self.tour_approach == OEFFENTLICHE_ANREISE: + return "ÖPNV" + return "Fahrgemeinschaften" + + def get_absolute_url(self): + return reverse('admin:members_freizeit_change', args=[str(self.id)]) + + @property + def night_count(self): + # convert to date first, since we might start at 11pm and end at 1am, which is one night + return (self.end.date() - self.date.date()).days + + @property + def duration(self): + # number of nights is number of full days + 1 + full_days = max(self.night_count - 1, 0) + extra_days = 0 + + if self.date.date() == self.end.date(): + # excursion starts and ends on the same day + hours = max(self.end.hour - self.date.hour, 0) + # at least 6 hours counts as full day + extra_days = 1.0 if hours >= 6 else 0.5 + else: + extra_days += 1.0 if self.date.hour <= 12 else 0.5 + extra_days += 1.0 if self.end.hour >= 12 else 0.5 + + return full_days + extra_days + + @property + def total_intervention_hours(self): + if hasattr(self, 'ljpproposal'): + return sum([i.duration for i in self.ljpproposal.intervention_set.all()]) + else: + return 0 + + @property + def total_seminar_days(self): + """calculate seminar days based on intervention hours in every day""" + # TODO: add tests for this + if hasattr(self, 'ljpproposal'): + hours_per_day = self.seminar_time_per_day + # Calculate the total number of seminar days + # Each day is counted as 1 if total_duration is >= 5 hours, as 0.5 if total_duration is >= 2.5 + # otherwise 0 + sum_days = sum([h['sum_days'] for h in hours_per_day]) + + return sum_days + else: + return 0 + + + @property + def seminar_time_per_day(self): + if hasattr(self, 'ljpproposal'): + return ( + self.ljpproposal.intervention_set + .annotate(day=Cast('date_start', output_field=models.DateField())) # Force it to date + .values('day') # Group by day + .annotate(total_duration=Sum('duration'))# Sum durations for each day + .annotate( + sum_days=Case( + When(total_duration__gte=5.0, then=Value(1.0)), + When(total_duration__gte=2.5, then=Value(0.5)), + default=Value(0.0),) + ) + .order_by('day') # Sort results by date + ) + else: + return [] + + @property + def ljp_duration(self): + """calculate the duration in days for the LJP""" + return min(self.duration, self.total_seminar_days) + + @property + def staff_count(self): + return self.jugendleiter.count() + + @property + def staff_on_memberlist(self): + ps = set(map(lambda x: x.member, self.membersonlist.distinct())) + jls = set(self.jugendleiter.distinct()) + return ps.intersection(jls) + + @property + def staff_on_memberlist_count(self): + return len(self.staff_on_memberlist) + + @property + def participant_count(self): + return len(self.participants) + + @property + def participants(self): + ps = set(map(lambda x: x.member, self.membersonlist.distinct())) + jls = set(self.jugendleiter.distinct()) + return list(ps - jls) + + @property + def old_participant_count(self): + old_ps = [m for m in self.participants if m.age() >= 27] + return len(old_ps) + + @property + def head_count(self): + return self.staff_on_memberlist_count + self.participant_count + + @property + def approved_staff_count(self): + """Number of approved youth leaders for this excursion. The base number is calculated + from the participant count. To this, the number of additional approved youth leaders is added.""" + participant_count = self.participant_count + if participant_count < 4: + base_count = 0 + elif 4 <= participant_count <= 7: + base_count = 2 + else: + base_count = 2 + math.ceil((participant_count - 7) / 7) + return base_count + self.approved_extra_youth_leader_count + + @property + def theoretic_ljp_participant_count(self): + """ + Calculate the participant count in the sense of the LJP regulations. This means + that all youth leaders are counted and all participants which are at least 6 years old and + strictly less than 27 years old. Additionally, up to 20% of the participants may violate the + age restrictions. + + This is the theoretic value, ignoring the cutoff at 5 participants. + """ + # participants (possibly including youth leaders) + ps = {x.member for x in self.membersonlist.distinct()} + # youth leaders + jls = set(self.jugendleiter.distinct()) + # non-youth leader participants + ps_only = ps - jls + # participants of the correct age + ps_correct_age = {m for m in ps_only if m.age_at(self.date) >= 6 and m.age_at(self.date) < 27} + # m = the official non-youth-leader participant count + # and, assuming there exist enough participants, unrounded m satisfies the equation + # len(ps_correct_age) + 1/5 * m = m + # if there are not enough participants, + # m = len(ps_only) + m = min(len(ps_only), math.floor(5/4 * len(ps_correct_age))) + return m + len(jls) + + @property + def ljp_participant_count(self): + """ + The number of participants in the sense of LJP regulations. If the total + number of participants (including youth leaders and too old / young ones) is less + than 5, this is zero, otherwise it is `theoretic_ljp_participant_count`. + """ + # participants (possibly including youth leaders) + ps = {x.member for x in self.membersonlist.distinct()} + # youth leaders + jls = set(self.jugendleiter.distinct()) + if len(ps.union(jls)) < 5: + return 0 + return self.theoretic_ljp_participant_count + + @property + def maximal_ljp_contributions(self): + """This is the maximal amount of LJP contributions that can be requested given participants and length + This calculation if intended for the LJP application, not for the payout.""" + return cvt_to_decimal(settings.LJP_CONTRIBUTION_PER_DAY * self.ljp_participant_count * self.duration) + + @property + def potential_ljp_contributions(self): + """The maximal amount can be reduced if the actual costs are lower than the maximal amount + This calculation if intended for the LJP application, not for the payout.""" + if not hasattr(self, 'statement'): + return cvt_to_decimal(0) + return cvt_to_decimal(min(self.maximal_ljp_contributions, + 0.9 * float(self.statement.total_bills_theoretic) + float(self.statement.total_staff))) + + @property + def payable_ljp_contributions(self): + """the payable contributions can differ from potential contributions if a tax is deducted for risk reduction. + the actual payout depends on more factors, e.g. the actual costs that had to be paid by the trip organisers.""" + if hasattr(self, 'statement') and self.statement.ljp_to: + return self.statement.paid_ljp_contributions + return cvt_to_decimal(self.potential_ljp_contributions * cvt_to_decimal(1 - settings.LJP_TAX)) + + @property + def total_relative_costs(self): + if not hasattr(self, 'statement'): + return 0 + total_costs = self.statement.total_bills_theoretic + total_contributions = self.statement.total_subsidies + self.payable_ljp_contributions + return total_costs - total_contributions + + @property + def time_period_str(self): + time_period = self.date.strftime('%d.%m.%Y') + if self.end != self.date: + time_period += " - " + self.end.strftime('%d.%m.%Y') + return time_period + + @property + def groups_str(self): + return ', '.join(g.name for g in self.groups.all()) + + @property + def staff_str(self): + return ', '.join(yl.name for yl in self.jugendleiter.all()) + + @property + def skill_summary(self): + activities = [a.name for a in self.activity.all()] + skills = {a: [] for a in activities} + people = [] + for memberonlist in self.membersonlist.all(): + m = memberonlist.member + qualities = [] + for activity, value in m.get_skills().items(): + if activity not in activities: + continue + skills[activity].append(value) + qualities.append("\\textit{%s:} %s" % (activity, value)) + people.append(dict(name=m.name, qualities=", ".join(qualities), comments=memberonlist.comments_tex)) + + sks = [] + for activity in activities: + skill_avg = 0 if len(skills[activity]) == 0 else\ + sum(skills[activity]) / len(skills[activity]) + skill_min = 0 if len(skills[activity]) == 0 else\ + min(skills[activity]) + skill_max = 0 if len(skills[activity]) == 0 else\ + max(skills[activity]) + sks.append(dict(name=activity, skill_avg=skill_avg, skill_min=skill_min, skill_max=skill_max)) + return (people, sks) + + def sjr_application_numbers(self): + members = set(map(lambda x: x.member, self.membersonlist.distinct())) + jls = set(self.jugendleiter.distinct()) + participants = members - jls + b27_local = len([m for m in participants + if m.age_at(self.date) <= 27 and settings.SEKTION in m.town]) + b27_non_local = len([m for m in participants + if m.age_at(self.date) <= 27 and not settings.SEKTION in m.town]) + staff = len(jls) + total = b27_local + b27_non_local + len(jls) + relevant_b27 = min(b27_local + b27_non_local, math.floor(3/2 * b27_local)) + subsidizable = relevant_b27 + min(math.ceil(relevant_b27 / 7), staff) + duration = self.night_count + 1 + return { + 'b27_local': b27_local, + 'b27_non_local': b27_non_local, + 'staff': staff, + 'total': total, + 'relevant_b27': relevant_b27, + 'subsidizable': subsidizable, + 'subsidized_days': duration * subsidizable, + 'duration': duration + } + + def sjr_application_fields(self): + members = set(map(lambda x: x.member, self.membersonlist.distinct())) + jls = set(self.jugendleiter.distinct()) + numbers = self.sjr_application_numbers() + title = self.ljpproposal.title if hasattr(self, 'ljpproposal') else self.name + base = {'Haushaltsjahr': str(datetime.now().year), + 'Art / Thema / Titel': title, + 'Ort': self.place, + 'Datum von': self.date.strftime('%d.%m.%Y'), + 'Datum bis': self.end.strftime('%d.%m.%Y'), + 'Dauer': str(numbers['duration']), + 'Teilnehmenden gesamt': str(numbers['total']), + 'bis 27 aus HD': str(numbers['b27_local']), + 'bis 27 nicht aus HD': str(numbers['b27_non_local']), + 'Verpflegungstage': str(numbers['subsidized_days']).replace('.', ','), + 'Betreuer/in': str(numbers['staff']), + 'Auswahl Veranstaltung': 'Auswahl2', + 'Ort, Datum': '{p}, {d}'.format(p=settings.SEKTION, d=datetime.now().strftime('%d.%m.%Y'))} + for i, m in enumerate(members): + suffix = str(' {}'.format(i + 1)) + # indexing starts at zero, but the listing in the pdf starts at 1 + if i + 1 == 1: + suffix = '' + elif i + 1 >= 13: + suffix = str(i + 1) + base['Vor- und Nachname' + suffix] = m.name + base['Anschrift' + suffix] = m.address + base['Alter' + suffix] = str(m.age_at(self.date)) + base['Status' + str(i+1)] = '2' if m in jls else 'Auswahl1' if settings.SEKTION in m.address else 'Auswahl2' + return base + + def v32_fields(self): + title = self.ljpproposal.title if hasattr(self, 'ljpproposal') else self.name + base = { + # AntragstellerIn + 'Textfeld 2': settings.ADDRESS, + # Dachorganisation + 'Textfeld 3': settings.V32_HEAD_ORGANISATION, + # Datum der Maßnahme am/vom + 'Textfeld 20': self.date.strftime('%d.%m.%Y'), + # bis + 'Textfeld 28': self.end.strftime('%d.%m.%Y'), + # Thema der Maßnahme + 'Textfeld 22': title, + # IBAN + 'Textfeld 36': settings.SEKTION_IBAN, + # Kontoinhaber + 'Textfeld 37': settings.SEKTION_ACCOUNT_HOLDER, + # Zahl der zuwendungsfähigen Teilnehemr + 'Textfeld 43': str(self.ljp_participant_count), + # Teilnahmetage + 'Textfeld 46': str(round(self.duration * self.ljp_participant_count, 1)).replace('.', ','), + # Euro Tagessatz + 'Textfeld 48': str(settings.LJP_CONTRIBUTION_PER_DAY), + # Erbetener Zuschuss + 'Textfeld 50': str(self.maximal_ljp_contributions).replace('.', ','), + # Stunden Bildungsprogramm + 'Textfeld 52': '??', + # Tage + 'Textfeld 53': str(round(self.duration, 1)).replace('.', ','), + # Haushaltsjahr + 'Textfeld 54': str(datetime.now().year), + # nicht anrechenbare Teilnahmetage + 'Textfeld 55': '0', + # Gesamt-Teilnahmetage + 'Textfeld 56': str(round(self.duration * self.ljp_participant_count, 1)).replace('.', ','), + # Ort, Datum + 'DatumOrt 2': '{place}, {date}'.format(place=settings.SEKTION, + date=datetime.now().strftime('%d.%m.%Y')) + } + if hasattr(self, 'statement'): + possible_contributions = self.maximal_ljp_contributions + total_contributions = min(self.statement.total_theoretic, possible_contributions) + self_participation = max(cvt_to_decimal(0), self.statement.total_theoretic - possible_contributions) + # Gesamtkosten von + base['Textfeld 62'] = str(self.statement.total_theoretic).replace('.', ',') + # Eigenmittel und Teilnahmebeiträge + base['Textfeld 59'] = str(self_participation).replace('.', ',') + # Drittmittel + base['Textfeld 60'] = '0,00' + # Erbetener Zuschuss + base['Textfeld 61'] = str(total_contributions).replace('.', ',') + # Ergibt wieder + base['Textfeld 58'] = base['Textfeld 62'] + return base + + def get_ljp_activity_category(self): + """ + The official LJP activity category associated with this excursion. This is deduced + from the `activity` field. + """ + return ", ".join([a.ljp_category for a in self.activity.all()]) + + @staticmethod + def filter_queryset_by_permissions(member, queryset=None): + if queryset is None: + queryset = Freizeit.objects.all() + + groups = member.leited_groups.all() + # one may view all leited groups and oneself + queryset = queryset.filter(Q(groups__in=groups) | Q(jugendleiter__pk=member.pk)).distinct() + return queryset + + def send_crisis_intervention_list(self, sending_time=None): + """ + Send the crisis intervention list to the crisis invervention email, the + responsible and the youth leaders of this excursion. + """ + context = dict(memberlist=self, settings=settings) + start_date= timezone.localtime(self.date).strftime('%d.%m.%Y') + filename = render_tex(f"{self.code}_{self.name}_Krisenliste", + 'members/crisis_intervention_list.tex', context, + date=self.date, save_only=True) + leaders = ", ".join([yl.name for yl in self.jugendleiter.all()]) + start_date = timezone.localtime(self.date).strftime('%d.%m.%Y') + end_date = timezone.localtime(self.end).strftime('%d.%m.%Y') + # create email with attachment + send_mail(_('Crisis intervention list for %(excursion)s from %(start)s to %(end)s') %\ + { 'excursion': self.name, + 'start': start_date, + 'end': end_date }, + settings.SEND_EXCURSION_CRISIS_LIST.format(excursion=self.name, leaders=leaders, + excursion_start=start_date, + excursion_end=end_date), + sender=settings.DEFAULT_SENDING_MAIL, + recipients=[settings.SEKTION_CRISIS_INTERVENTION_MAIL], + cc=[settings.RESPONSIBLE_MAIL] + [yl.email for yl in self.jugendleiter.all()], + attachments=[media_path(filename)]) + self.crisis_intervention_list_sent = True + self.save() + + def notify_leaders_crisis_intervention_list(self, sending_time=None): + """ + Send an email to the youth leaders of this excursion with a list of currently + registered participants and a heads-up that the crisis intervention list + will be automatically sent on the night of this day. + """ + participants = "\n".join([f"- {p.member.name}" for p in self.membersonlist.all()]) + if not sending_time: + sending_time = coming_midnight().strftime("%d.%m.%y %H:%M") + elif not isinstance(sending_time, str): + sending_time = sending_time.strftime("%d.%m.%y %H:%M") + start_date = timezone.localtime(self.date).strftime('%d.%m.%Y') + end_date = timezone.localtime(self.end).strftime('%d.%m.%Y') + excursion_link = prepend_base_url(self.get_absolute_url()) + for yl in self.jugendleiter.all(): + yl.send_mail(_('Participant list for %(excursion)s from %(start)s to %(end)s') %\ + { 'excursion': self.name, + 'start': start_date, + 'end': end_date }, + settings.NOTIFY_EXCURSION_PARTICIPANT_LIST.format(name=yl.prename, + excursion=self.name, + participants=participants, + sending_time=sending_time, + excursion_link=excursion_link)) + self.notification_crisis_intervention_list_sent = True + self.save() + diff --git a/jdav_web/members/models/group.py b/jdav_web/members/models/group.py new file mode 100644 index 0000000..184b305 --- /dev/null +++ b/jdav_web/members/models/group.py @@ -0,0 +1,83 @@ +from django.db import models +from django.utils.translation import gettext_lazy as _ +from django.conf import settings +from django.urls import reverse +from mailer.mailutils import prepend_base_url +from .constants import WEEKDAYS + +class Group(models.Model): + """ + Represents one group of the association + e.g: J1, J2, Jugendleiter, etc. + """ + name = models.CharField(max_length=50, verbose_name=_('name')) # e.g: J1 + description = models.TextField(verbose_name=_('description'), default='', blank=True) + show_website = models.BooleanField(verbose_name=_('show on website'), default=False) + year_from = models.IntegerField(verbose_name=_('lowest year'), default=2010) + year_to = models.IntegerField(verbose_name=_('highest year'), default=2011) + leiters = models.ManyToManyField('members.Member', verbose_name=_('youth leaders'), + related_name='leited_groups', blank=True) + weekday = models.IntegerField(verbose_name=_('week day'), choices=WEEKDAYS, null=True, blank=True) + start_time = models.TimeField(verbose_name=_('Starting time'), null=True, blank=True) + end_time = models.TimeField(verbose_name=_('Ending time'), null=True, blank=True) + contact_email = models.ForeignKey('mailer.EmailAddress', + verbose_name=_('Contact email'), + null=True, + blank=True, + on_delete=models.SET_NULL) + + def __str__(self): + """String representation""" + return self.name + + class Meta: + verbose_name = _('group') + verbose_name_plural = _('groups') + + @property + def sorted_members(self): + """Returns the members of this group sorted by their last name.""" + return self.member_set.all().order_by('lastname') + + def has_time_info(self): + # return if the group has all relevant time slot information filled + return self.weekday and self.start_time and self.end_time + + def get_time_info(self): + if self.has_time_info(): + return settings.GROUP_TIME_AVAILABLE_TEXT.format(weekday=WEEKDAYS[self.weekday][1], + start_time=self.start_time.strftime('%H:%M'), + end_time=self.end_time.strftime('%H:%M')) + else: + return "" + + def has_age_info(self): + return self.year_from and self.year_to + + def get_age_info(self): + if self.has_age_info(): + return _("years %(from)s to %(to)s") % {'from':self.year_from, 'to':self.year_to} + return "" + + def get_invitation_text_template(self): + """The text template used to invite waiters to this group. This contains + placeholders for the name of the waiter and personalized links.""" + if self.show_website: + group_link = '({url}) '.format(url=prepend_base_url(reverse('startpage:gruppe_detail', args=[self.name]))) + else: + group_link = '' + if self.has_time_info(): + group_time = self.get_time_info() + else: + group_time = settings.GROUP_TIME_UNAVAILABLE_TEXT.format(contact_email=self.contact_email) + if self.has_age_info(): + group_age = self.get_age_info() + else: + group_age = _("no information available") + + return settings.INVITE_TEXT.format(group_time=group_time, + group_name=self.name, + group_age=group_age, + group_link=group_link, + contact_email=self.contact_email) + diff --git a/jdav_web/members/models/invitation.py b/jdav_web/members/models/invitation.py new file mode 100644 index 0000000..eca816c --- /dev/null +++ b/jdav_web/members/models/invitation.py @@ -0,0 +1,109 @@ +import uuid +from django.db import models +from django.utils import timezone +from django.utils.translation import gettext_lazy as _ +from contrib.models import CommonModel +from members.rules import is_leader_of_relevant_invitation +from contrib.rules import has_global_perm +from mailer.mailutils import send as send_mail +from django.conf import settings +from .base import gen_key +from .group import Group + +class InvitationToGroup(CommonModel): + """An invitation of a waiter to a group.""" + waiter = models.ForeignKey('MemberWaitingList', verbose_name=_('Waiter'), on_delete=models.CASCADE) + group = models.ForeignKey(Group, verbose_name=_('Group'), on_delete=models.CASCADE) + date = models.DateField(default=timezone.now, verbose_name=_('Invitation date')) + rejected = models.BooleanField(verbose_name=_('Invitation rejected'), default=False) + key = models.CharField(max_length=32, default=gen_key) + created_by = models.ForeignKey('Member', verbose_name=_('Created by'), + blank=True, + null=True, + on_delete=models.SET_NULL, + related_name='created_group_invitations') + + class Meta(CommonModel.Meta): + verbose_name = _('Invitation to group') + verbose_name_plural = _('Invitations to groups') + rules_permissions = { + 'add_obj': has_global_perm('members.add_global_memberwaitinglist'), + 'view_obj': is_leader_of_relevant_invitation | has_global_perm('members.view_global_memberwaitinglist'), + 'change_obj': has_global_perm('members.change_global_memberwaitinglist'), + 'delete_obj': has_global_perm('members.delete_global_memberwaitinglist'), + } + + def is_expired(self): + return self.date < (timezone.now() - timezone.timedelta(days=30)).date() + + def status(self): + if self.rejected: + return _('Rejected') + elif self.is_expired(): + return _('Expired') + return _('Undecided') + status.short_description = _('Status') + + def send_left_waitinglist_notification_to(self, recipient): + send_mail(_('%(waiter)s left the waiting list') % {'waiter': self.waiter}, + settings.GROUP_INVITATION_LEFT_WAITINGLIST.format(name=recipient.prename, + waiter=self.waiter, + group=self.group), + settings.DEFAULT_SENDING_MAIL, + recipient.email) + + def send_reject_notification_to(self, recipient): + send_mail(_('Group invitation rejected by %(waiter)s') % {'waiter': self.waiter}, + settings.GROUP_INVITATION_REJECTED.format(name=recipient.prename, + waiter=self.waiter, + group=self.group), + settings.DEFAULT_SENDING_MAIL, + recipient.email) + + def send_confirm_notification_to(self, recipient): + send_mail(_('Group invitation confirmed by %(waiter)s') % {'waiter': self.waiter}, + settings.GROUP_INVITATION_CONFIRMED_TEXT.format(name=recipient.prename, + waiter=self.waiter, + group=self.group), + settings.DEFAULT_SENDING_MAIL, + recipient.email) + + def send_confirm_confirmation(self): + self.waiter.send_mail(_('Trial group meeting confirmed'), + settings.TRIAL_GROUP_MEETING_CONFIRMED_TEXT.format(name=self.waiter.prename, + group=self.group, + contact_email=self.group.contact_email, + timeinfo=self.group.get_time_info())) + + def notify_left_waitinglist(self): + """ + Inform youth leaders of the group and the inviter that the waiter left the waitinglist, + prompted by this group invitation. + """ + if self.created_by: + self.send_left_waitinglist_notification_to(self.created_by) + for jl in self.group.leiters.all(): + self.send_left_waitinglist_notification_to(jl) + + def reject(self): + """Reject this invitation. Informs the youth leaders of the group of the rejection.""" + self.rejected = True + self.save() + # send notifications + if self.created_by: + self.send_reject_notification_to(self.created_by) + for jl in self.group.leiters.all(): + self.send_reject_notification_to(jl) + + def confirm(self): + """Confirm this invitation. Informs the youth leaders of the group of the invitation.""" + self.rejected = False + self.save() + # confirm the confirmation + self.send_confirm_confirmation() + # send notifications + if self.created_by: + self.send_confirm_notification_to(self.created_by) + for jl in self.group.leiters.all(): + self.send_confirm_notification_to(jl) + diff --git a/jdav_web/members/models/klettertreff.py b/jdav_web/members/models/klettertreff.py new file mode 100644 index 0000000..f72c385 --- /dev/null +++ b/jdav_web/members/models/klettertreff.py @@ -0,0 +1,56 @@ +from django.db import models +from django.utils.translation import gettext_lazy as _ +from datetime import datetime +from .member import Member +from .group import Group + +class Klettertreff(models.Model): + """ This model represents a Klettertreff event. + + A Klettertreff can take a date, location, Jugendleiter, attending members + as input. + """ + date = models.DateField(_('Date'), default=datetime.today) + location = models.CharField(_('Location'), default='', max_length=60) + topic = models.CharField(_('Topic'), default='', max_length=60) + jugendleiter = models.ManyToManyField(Member) + group = models.ForeignKey(Group, default='', verbose_name=_('Group'), on_delete=models.CASCADE) + + def __str__(self): + return self.location + ' ' + self.date.strftime('%d.%m.%Y') + + def get_jugendleiter(self): + jl_string = ', '.join(j.name for j in self.jugendleiter.all()) + return jl_string + + def has_attendee(self, member): + queryset = KlettertreffAttendee.objects.filter( + member__id__contains=member.id, + klettertreff__id__contains=self.id) + if queryset: + return True + return False + + def has_jugendleiter(self, jugendleiter): + if jugendleiter in self.jugendleiter.all(): + return True + return False + + get_jugendleiter.short_description = _('Jugendleiter') + + class Meta: + verbose_name = _('Klettertreff') + verbose_name_plural = _('Klettertreffs') + +class KlettertreffAttendee(models.Model): + """Connects members to Klettertreffs.""" + member = models.ForeignKey(Member, verbose_name=_('Member'), on_delete=models.CASCADE) + klettertreff = models.ForeignKey(Klettertreff, on_delete=models.CASCADE) + + def __str__(self): + return str(self.member) + + class Meta: + verbose_name = _('Member') + verbose_name_plural = _('Members') + diff --git a/jdav_web/members/models/ljp.py b/jdav_web/members/models/ljp.py new file mode 100644 index 0000000..ec9e46b --- /dev/null +++ b/jdav_web/members/models/ljp.py @@ -0,0 +1,92 @@ +from django.db import models +from django.utils.translation import gettext_lazy as _ +from contrib.models import CommonModel +from members.rules import is_leader, is_leader_of_excursion +from contrib.rules import has_global_perm +from .excursion import Freizeit + +class LJPProposal(CommonModel): + """A proposal for LJP""" + title = models.CharField(verbose_name=_('Title'), max_length=100, + blank=True, default='', + help_text=_('Official title of your seminar, this can differ from the informal title. Use e.g. sports climbing course instead of climbing weekend for fun.')) + + LJP_STAFF_TRAINING, LJP_EDUCATIONAL = 1, 2 + LJP_CATEGORIES = [ + (LJP_EDUCATIONAL, _('Educational programme')), + (LJP_STAFF_TRAINING, _('Staff training')) + ] + category = models.IntegerField(verbose_name=_('Category'), + choices=LJP_CATEGORIES, + default=2, + help_text=_('Type of seminar. Usually the correct choice is educational programme.')) + LJP_QUALIFICATION, LJP_PARTICIPATION, LJP_DEVELOPMENT, LJP_ENVIRONMENT = 1, 2, 3, 4 + LJP_GOALS = [ + (LJP_QUALIFICATION, _('Qualification')), + (LJP_PARTICIPATION, _('Participation')), + (LJP_DEVELOPMENT, _('Personality development')), + (LJP_ENVIRONMENT, _('Environment')), + ] + goal = models.IntegerField(verbose_name=_('Learning goal'), + choices=LJP_GOALS, + default=1, + help_text=_('Official learning goal according to LJP regulations.')) + goal_strategy = models.TextField(verbose_name=_('Strategy'), + help_text=_('How do you want to reach the learning goal? Has the goal been reached? If not, why not? If yes, what helped you to reach the goal?'), + blank=True, default='') + + NOT_BW_CONTENT, NOT_BW_ROOMS, NOT_BW_CLOSE_BORDER, NOT_BW_ECONOMIC = 1, 2, 3, 4 + NOT_BW_REASONS = [ + (NOT_BW_CONTENT, _('Course content')), + (NOT_BW_ROOMS, _('Available rooms')), + (NOT_BW_CLOSE_BORDER, _('Close to the border')), + (NOT_BW_ECONOMIC, _('Economic reasons')), + ] + not_bw_reason = models.IntegerField(verbose_name=_('Explanation if excursion not in Baden-Württemberg'), + choices=NOT_BW_REASONS, + default=None, + blank=True, + null=True, + help_text=_('If the excursion takes place outside of Baden-Württemberg, please explain. Otherwise, leave this empty.')) + + excursion = models.OneToOneField(Freizeit, + verbose_name=_('Excursion'), + blank=True, + null=True, + on_delete=models.SET_NULL) + + class Meta(CommonModel.Meta): + verbose_name = _('LJP Proposal') + verbose_name_plural = _('LJP Proposals') + rules_permissions = { + 'add_obj': is_leader, + 'view_obj': is_leader | has_global_perm('members.view_global_freizeit'), + 'change_obj': is_leader, + 'delete_obj': is_leader, + } + + def __str__(self): + return self.title + +class Intervention(CommonModel): + """An intervention during a seminar as part of a LJP proposal""" + date_start = models.DateTimeField(verbose_name=_('Starting time')) + duration = models.DecimalField(verbose_name=_('Duration in hours'), + max_digits=4, + decimal_places=2) + activity = models.TextField(verbose_name=_('Activity and method')) + + ljp_proposal = models.ForeignKey(LJPProposal, + verbose_name=_('LJP Proposal'), + blank=False, + on_delete=models.CASCADE) + + class Meta: + verbose_name = _('Intervention') + verbose_name_plural = _('Interventions') + rules_permissions = { + 'add_obj': is_leader_of_excursion, + 'view_obj': is_leader_of_excursion | has_global_perm('members.view_global_freizeit'), + 'change_obj': is_leader_of_excursion, + 'delete_obj': is_leader_of_excursion, + } diff --git a/jdav_web/members/models/member.py b/jdav_web/members/models/member.py new file mode 100644 index 0000000..93c41ac --- /dev/null +++ b/jdav_web/members/models/member.py @@ -0,0 +1,618 @@ +from datetime import datetime, timedelta +import uuid +import re +import math +from django.db import models +from django.utils.translation import gettext_lazy as _ +from django.utils import timezone +from django.utils.html import format_html +from django.urls import reverse +from django.conf import settings +from django.contrib.auth.models import User +from django.db.models import Q, Case, When, Value +from dateutil.relativedelta import relativedelta +from schwifty import IBAN +from mailer.mailutils import send as send_mail, prepend_base_url, get_mail_confirmation_link, get_invite_as_user_key +from utils import RestrictedFileField, normalize_name +from contrib.models import CommonModel +from contrib.rules import has_global_perm +from members.rules import may_view, may_change, may_delete +import rules +from .constants import MALE, FEMALE, DIVERSE +from .group import Group +from .activity import ActivityCategory +from .waiting_list import MemberWaitingList +from .excursion import Freizeit +from .base import Person + +class MemberManager(models.Manager): + def get_queryset(self): + return super().get_queryset().filter(confirmed=True) + + +class Member(Person): + """ + Represents a member of the association + Might be a member of different groups: e.g. J1, J2, Jugendleiter, etc. + """ + alternative_email = models.EmailField(max_length=100, default=None, blank=True, null=True) + confirmed_alternative_mail = models.BooleanField(default=True, + verbose_name=_('Alternative email confirmed')) + confirm_alternative_mail_key = models.CharField(max_length=32, default="") + + phone_number = models.CharField(max_length=100, verbose_name=_('phone number'), default='', blank=True) + street = models.CharField(max_length=30, verbose_name=_('street and house number'), default='', blank=True) + plz = models.CharField(max_length=10, verbose_name=_('Postcode'), + default='', blank=True) + town = models.CharField(max_length=30, verbose_name=_('town'), default='', blank=True) + address_extra = models.CharField(max_length=100, verbose_name=_('Address extra'), default='', blank=True) + country = models.CharField(max_length=30, verbose_name=_('Country'), default='', blank=True) + + good_conduct_certificate_presented_date = models.DateField(_('Good conduct certificate presented on'), default=None, blank=True, null=True) + join_date = models.DateField(_('Joined on'), default=None, blank=True, null=True) + leave_date = models.DateField(_('Left on'), default=None, blank=True, null=True) + has_key = models.BooleanField(_('Has key'), default=False) + has_free_ticket_gym = models.BooleanField(_('Has a free ticket for the climbing gym'), default=False) + dav_badge_no = models.CharField(max_length=20, verbose_name=_('DAV badge number'), default='', blank=True) + + # use this to store a climbing gym customer or membership id, used to print on meeting checklists + ticket_no = models.CharField(max_length=20, verbose_name=_('entrance ticket number'), default='', blank=True) + swimming_badge = models.BooleanField(verbose_name=_('Knows how to swim'), default=False) + climbing_badge = models.CharField(max_length=100, verbose_name=_('Climbing badge'), default='', blank=True) + alpine_experience = models.TextField(verbose_name=_('Alpine experience'), default='', blank=True) + allergies = models.TextField(verbose_name=_('Allergies'), default='', blank=True) + medication = models.TextField(verbose_name=_('Medication'), default='', blank=True) + tetanus_vaccination = models.CharField(max_length=50, verbose_name=_('Tetanus vaccination'), default='', blank=True) + photos_may_be_taken = models.BooleanField(verbose_name=_('Photos may be taken'), default=False) + legal_guardians = models.CharField(max_length=100, verbose_name=_('Legal guardians'), default='', blank=True) + may_cancel_appointment_independently =\ + models.BooleanField(verbose_name=_('May cancel a group appointment independently'), null=True, + blank=True, default=None) + + group = models.ManyToManyField(Group, verbose_name=_('group')) + + iban = models.CharField(max_length=30, blank=True, verbose_name='IBAN') + + gets_newsletter = models.BooleanField(_('receives newsletter'), + default=True) + unsubscribe_key = models.CharField(max_length=32, default="") + unsubscribe_expire = models.DateTimeField(default=timezone.now) + created = models.DateField(default=timezone.now, verbose_name=_('created')) + active = models.BooleanField(default=True, verbose_name=_('Active')) + registration_form = RestrictedFileField(verbose_name=_('registration form'), + upload_to='registration_forms', + blank=True, + max_upload_size=5, + content_types=['application/pdf', + 'image/jpeg', + 'image/png', + 'image/gif']) + upload_registration_form_key = models.CharField(max_length=32, default="") + image = RestrictedFileField(verbose_name=_('image'), + upload_to='people', + blank=True, + max_upload_size=5, + content_types=['image/jpeg', + 'image/png', + 'image/gif']) + echo_key = models.CharField(max_length=32, default="") + echo_expire = models.DateTimeField(default=timezone.now) + echoed = models.BooleanField(default=True, verbose_name=_('Echoed')) + confirmed = models.BooleanField(default=True, verbose_name=_('Confirmed')) + user = models.OneToOneField(User, blank=True, null=True, on_delete=models.SET_NULL, + verbose_name=_('Login data')) + invite_as_user_key = models.CharField(max_length=32, default="") + waitinglist_application_date = models.DateTimeField(verbose_name=_('waitinglist application date'), + null=True, blank=True, + help_text=_('If the person registered from the waitinglist, this is their application date.')) + + objects = MemberManager() + all_objects = models.Manager() + + class Meta(CommonModel.Meta): + verbose_name = _('member') + verbose_name_plural = _('members') + permissions = ( + ('may_see_qualities', 'Is allowed to see the quality overview'), + ('may_set_auth_user', 'Is allowed to set auth user member connections.'), + ('may_change_member_group', 'Can change the group field'), + ('may_invite_as_user', 'Is allowed to invite a member to set login data.'), + ('may_change_organizationals', 'Is allowed to set organizational settings on members.'), + ) + rules_permissions = { + 'members': rules.always_allow, + 'add_obj': has_global_perm('members.add_global_member'), + 'view_obj': may_view | has_global_perm('members.view_global_member'), + 'change_obj': may_change | has_global_perm('members.change_global_member'), + 'delete_obj': may_delete | has_global_perm('members.delete_global_member'), + } + + @property + def email_fields(self): + return [('email', 'confirmed_mail', 'confirm_mail_key'), + ('alternative_email', 'confirmed_alternative_mail', 'confirm_alternative_mail_key')] + + @property + def place(self): + """Returning the whole place (plz + town)""" + return "{0} {1}".format(self.plz, self.town) + + @property + def ticket_tag(self): + """Returning the ticket number stripped of strings and spaces""" + return "{" + ''.join(re.findall(r'\d', self.ticket_no)) + "}" + + @property + def iban_valid(self): + return IBAN(self.iban, allow_invalid=True).is_valid + + @property + def address(self): + """Returning the whole address""" + if not self.street and not self.town and not self.plz: + return "---" + return "{0}, {1}".format(self.street, self.place) + + @property + def address_multiline(self): + """Returning the whole address with a linebreak between street and town""" + if not self.street and not self.town and not self.plz: + return "---" + return "{0},\\linebreak[1] {1}".format(self.street, self.place) + + def good_conduct_certificate_valid(self): + """Returns if a good conduct certificate is still valid, depending on the last presentation.""" + if not self.good_conduct_certificate_presented_date: + return False + delta = datetime.now().date() - self.good_conduct_certificate_presented_date + return delta.days // 30 <= settings.MAX_AGE_GOOD_CONDUCT_CERTIFICATE_MONTHS + good_conduct_certificate_valid.boolean = True + good_conduct_certificate_valid.short_description = _('Good conduct certificate valid') + + def generate_key(self): + self.unsubscribe_key = uuid.uuid4().hex + self.unsubscribe_expire = timezone.now() + timezone.timedelta(days=1) + self.save() + return self.unsubscribe_key + + def generate_echo_key(self): + self.echo_key = uuid.uuid4().hex + self.echo_expire = timezone.now() + timezone.timedelta(days=settings.ECHO_GRACE_PERIOD) + self.echoed = False + self.save() + return self.echo_key + + def confirm(self): + if not self.confirmed_mail or not self.confirmed_alternative_mail: + return False + self.confirmed = True + self.save() + return True + + def unconfirm(self): + self.confirmed = False + self.save() + + def unsubscribe(self, key): + if self.unsubscribe_key == key and timezone.now() < self.unsubscribe_expire: + for member in Member.objects.filter(email=self.email): + member.gets_newsletter = False + member.save() + self.unsubscribe_key, self.unsubscribe_expire = "", timezone.now() + return True + return False + + def may_echo(self, key): + return self.echo_key == key and timezone.now() < self.echo_expire + + @property + def echo_password(self): + return self.birth_date.strftime(settings.ECHO_PASSWORD_BIRTHDATE_FORMAT) + + @property + def contact_phone_number(self): + """Synonym for phone number field.""" + if self.phone_number: + return str(self.phone_number) + else: + return "---" + + @property + def contact_email(self): + """A synonym for the email field.""" + return self.email + + @property + def username(self): + """Return the username. Either this the name of the linked user, or + it is the suggested username.""" + if not self.user: + return self.suggested_username() + else: + return self.user.username + + @property + def association_email(self): + """Returning the association email of the member""" + return "{username}@{domain}".format(username=self.username, domain=settings.DOMAIN) + + def registration_complete(self): + """Check if all necessary fields are set.""" + # TODO: implement a proper predicate here + return True + registration_complete.boolean = True + registration_complete.short_description = _('Registration complete') + + def get_group(self): + """Returns a string of groups in which the member is.""" + groupstring = ''.join(g.name + ',\n' for g in self.group.all()) + groupstring = groupstring[:-2] + return groupstring + get_group.short_description = _('Group') + + def get_skills(self): + # get skills by summing up all the activities taken part in + skills = {} + for kind in ActivityCategory.objects.all(): + lists = Freizeit.objects.filter(activity=kind, membersonlist__member=self) + skills[kind.name] = sum([l.difficulty * 3 for l in lists if l.date < timezone.now()]) + return skills + + def get_activities(self): + # get activity overview + return Freizeit.objects.filter(membersonlist__member=self) + + def generate_upload_registration_form_key(self): + self.upload_registration_form_key = uuid.uuid4().hex + self.save() + + def create_from_registration(self, waiter, group): + """Given a member, a corresponding waiting-list object and a group, this completes + the registration and requests email confirmations if necessary. + Returns if any mail confirmation requests have been sent out.""" + self.group.add(group) + self.confirmed = False + if waiter: + if self.email == waiter.email: + self.confirmed_mail = waiter.confirmed_mail + self.confirm_mail_key = waiter.confirm_mail_key + # store waitinglist application date in member, this will be used + # if the member is later demoted to waiter again + self.waitinglist_application_date = waiter.application_date + if self.alternative_email: + self.confirmed_alternative_mail = False + self.upload_registration_form_key = uuid.uuid4().hex + self.save() + + if self.registration_ready(): + self.notify_jugendleiters_about_confirmed_mail() + if waiter: + waiter.delete() + return self.request_mail_confirmation(rerequest=False) + + def registration_form_uploaded(self): + print(self.registration_form.name) + return not self.registration_form.name is None and self.registration_form.name != "" + registration_form_uploaded.boolean = True + registration_form_uploaded.short_description = _('Registration form') + + def registration_ready(self): + """Returns if the member is currently unconfirmed and all email addresses + are confirmed.""" + return not self.confirmed and self.confirmed_alternative_mail and self.confirmed_mail\ + and self.registration_form + + def confirm_mail(self, key): + ret = super().confirm_mail(key) + if self.registration_ready(): + self.notify_jugendleiters_about_confirmed_mail() + return ret + + def validate_registration_form(self): + self.upload_registration_form_key = '' + self.save() + if self.registration_ready(): + self.notify_jugendleiters_about_confirmed_mail() + + def get_upload_registration_form_link(self): + return prepend_base_url(reverse('members:upload_registration_form') + "?key="\ + + self.upload_registration_form_key) + + def send_upload_registration_form_link(self): + if not self.upload_registration_form_key: + return + link = self.get_upload_registration_form_link() + self.send_mail(_('Upload registration form'), + settings.UPLOAD_REGISTRATION_FORM_TEXT.format(name=self.prename, + link=link)) + + def request_registration_form(self): + """Ask the member to upload a registration form via email.""" + self.generate_upload_registration_form_key() + self.send_upload_registration_form_link() + + def notify_jugendleiters_about_confirmed_mail(self): + group = ", ".join([g.name for g in self.group.all()]) + # notify jugendleiters of group of registration + jls = [jl for group in self.group.all() for jl in group.leiters.all()] + for jl in jls: + link = prepend_base_url(reverse('admin:members_memberunconfirmedproxy_change', + args=[str(self.id)])) + send_mail(_('New unconfirmed registration for group %(group)s') % {'group': group}, + settings.NEW_UNCONFIRMED_REGISTRATION.format(name=jl.prename, + group=group, + link=link), + settings.DEFAULT_SENDING_MAIL, + jl.email) + + def filter_queryset_by_permissions(self, queryset=None, annotate=False, model=None): # pragma: no cover + """ + Filter the given queryset of objects of type `model` by the permissions of `self`. + For example, only returns `Message`s created by `self`. + + This method is used by the `FilteredMemberFieldMixin` to filter the selection + in `ForeignKey` and `ManyToMany` fields. + """ + # This method is not used by all models listed below, so covering all cases in tests + # is hard and not useful. It is therefore exempt from testing. + name = model._meta.object_name + if queryset is None: + queryset = Member.objects.all() + + if name == "Message": + return self.filter_messages_by_permissions(queryset, annotate) + elif name == "Member": + return self.filter_members_by_permissions(queryset, annotate) + elif name == "StatementUnSubmitted": + return self.filter_statements_by_permissions(queryset, annotate) + elif name == "Freizeit": + return self.filter_excursions_by_permissions(queryset, annotate) + elif name == "MemberWaitingList": + return self.filter_waiters_by_permissions(queryset, annotate) + elif name == "LJPProposal": + return queryset + elif name == "MemberTraining": + return queryset + elif name == "NewMemberOnList": + return queryset + elif name == "Statement": + return self.filter_statements_by_permissions(queryset, annotate) + elif name == "StatementOnExcursionProxy": + return self.filter_statements_by_permissions(queryset, annotate) + elif name == "BillOnExcursionProxy": + return queryset + elif name == "Intervention": + return queryset + elif name == "BillOnStatementProxy": + return queryset + elif name == "Attachment": + return queryset + elif name == "Group": + return queryset + elif name == "EmergencyContact": + return queryset + elif name == "MemberUnconfirmedProxy": + return queryset + elif name == "InvitationToGroup": + return queryset + else: + raise ValueError(name) + + def filter_members_by_permissions(self, queryset, annotate=False): + #mems = Member.objects.all().prefetch_related('group') + + #list_pks = [ m.pk for m in mems if self.may_list(m) ] + #view_pks = [ m.pk for m in mems if self.may_view(m) ] + + ## every member may list themself + pks = [self.pk] + view_pks = [self.pk] + + + if hasattr(self, 'permissions'): + pks += [ m.pk for m in self.permissions.list_members.all() ] + view_pks += [ m.pk for m in self.permissions.view_members.all() ] + + for group in self.permissions.list_groups.all(): + pks += [ m.pk for m in group.member_set.all() ] + + for group in self.permissions.view_groups.all(): + view_pks += [ m.pk for m in group.member_set.all() ] + + for group in self.group.all(): + if hasattr(group, 'permissions'): + pks += [ m.pk for m in group.permissions.list_members.all() ] + view_pks += [ m.pk for m in group.permissions.view_members.all() ] + + for gr in group.permissions.list_groups.all(): + pks += [ m.pk for m in gr.member_set.all()] + + for gr in group.permissions.view_groups.all(): + view_pks += [ m.pk for m in gr.member_set.all()] + + filtered = queryset.filter(pk__in=pks) + if not annotate: + return filtered + + return filtered.annotate(_viewable=Case(When(pk__in=view_pks, then=Value(True)), default=Value(False), output_field=models.BooleanField())) + + def annotate_view_permission(self, queryset, model): + name = model._meta.object_name + if name != 'Member': + return queryset + view_pks = [self.pk] + + if hasattr(self, 'permissions'): + view_pks += [ m.pk for m in self.permissions.view_members.all() ] + + for group in self.permissions.view_groups.all(): + view_pks += [ m.pk for m in group.member_set.all() ] + + for group in self.group.all(): + if hasattr(group, 'permissions'): + view_pks += [ m.pk for m in group.permissions.view_members.all() ] + + for gr in group.permissions.view_groups.all(): + view_pks += [ m.pk for m in gr.member_set.all()] + + return queryset.annotate(_viewable=Case(When(pk__in=view_pks, then=Value(True)), default=Value(False), output_field=models.BooleanField())) + + + def filter_messages_by_permissions(self, queryset, annotate=False): + # ignores annotate + return queryset.filter(created_by=self) + + def filter_statements_by_permissions(self, queryset, annotate=False): + # ignores annotate + return queryset.filter(Q(created_by=self) | Q(excursion__jugendleiter=self)) + + def filter_excursions_by_permissions(self, queryset, annotate=False): + # ignores annotate + groups = self.leited_groups.all() + # one may view all excursions by leited groups and leited excursions + queryset = queryset.filter(Q(groups__in=groups) | Q(jugendleiter=self)).distinct() + return queryset + + def filter_waiters_by_permissions(self, queryset, annotate=False): + # ignores annotate + # return waiters that have a pending, expired or rejected group invitation for a group + # led by the member + return queryset.filter(invitationtogroup__group__leiters=self) + + def may_list(self, other): + if self.pk == other.pk: + return True + + if hasattr(self, 'permissions'): + if other in self.permissions.list_members.all(): + return True + + if any([gr in other.group.all() for gr in self.permissions.list_groups.all()]): + return True + + for group in self.group.all(): + if hasattr(group, 'permissions'): + if other in group.permissions.list_members.all(): + return True + + if any([gr in other.group.all() for gr in group.permissions.list_groups.all()]): + return True + + return False + + def may_view(self, other): + if self.pk == other.pk: + return True + + if hasattr(self, 'permissions'): + if other in self.permissions.view_members.all(): + return True + + if any([gr in other.group.all() for gr in self.permissions.view_groups.all()]): + return True + + for group in self.group.all(): + if hasattr(group, 'permissions'): + if other in group.permissions.view_members.all(): + return True + + if any([gr in other.group.all() for gr in group.permissions.view_groups.all()]): + return True + + return False + + def may_change(self, other): + if self.pk == other.pk: + return True + + if hasattr(self, 'permissions'): + if other in self.permissions.change_members.all(): + return True + + if any([gr in other.group.all() for gr in self.permissions.change_groups.all()]): + return True + + for group in self.group.all(): + if hasattr(group, 'permissions'): + if other in group.permissions.change_members.all(): + return True + + if any([gr in other.group.all() for gr in group.permissions.change_groups.all()]): + return True + + return False + + def may_delete(self, other): + if self.pk == other.pk: + return True + + if hasattr(self, 'permissions'): + if other in self.permissions.delete_members.all(): + return True + + if any([gr in other.group.all() for gr in self.permissions.delete_groups.all()]): + return True + + for group in self.group.all(): + if hasattr(group, 'permissions'): + if other in group.permissions.delete_members.all(): + return True + + if any([gr in other.group.all() for gr in group.permissions.delete_groups.all()]): + return True + + return False + + def suggested_username(self): + """Returns a suggested username given by {prename}.{lastname}.""" + raw = "{0}.{1}".format(self.prename.lower(), self.lastname.lower()) + return normalize_name(raw) + + def has_internal_email(self): + """Returns if the configured e-mail address is a DAV360 email address.""" + match = re.match('(^[^@]*)@(.*)$', self.email) + if not match: + return False + return match.group(2) in settings.ALLOWED_EMAIL_DOMAINS_FOR_INVITE_AS_USER or\ + "*" in settings.ALLOWED_EMAIL_DOMAINS_FOR_INVITE_AS_USER + + def invite_as_user(self): + """Invites the member to join Kompass as a user.""" + if not self.has_internal_email(): + # dont invite if the email address is not an internal one + return False + if self.user: + # don't reinvite if there is already userdata attached + return False + self.invite_as_user_key = uuid.uuid4().hex + self.save() + self.send_mail(_('Set login data for Kompass'), + settings.INVITE_AS_USER_TEXT.format(name=self.prename, + link=get_invite_as_user_key(self.invite_as_user_key))) + return True + + def led_groups(self): + """Returns a queryset of groups that this member is a youth leader of.""" + return Group.objects.filter(leiters__pk=self.pk) + + def led_freizeiten(self, limit=5): + """Returns a queryset of freizeiten that this member is a youth leader of.""" + return Freizeit.objects.filter(jugendleiter__pk=self.pk)[:limit] + + def demote_to_waiter(self): + """Demote this member to a waiter by creating a waiter from the data and removing + this member.""" + waiter = MemberWaitingList(prename=self.prename, + lastname=self.lastname, + email=self.email, + birth_date=self.birth_date, + gender=self.gender, + comments=self.comments, + confirmed_mail=self.confirmed_mail, + confirm_mail_key=self.confirm_mail_key) + # if this member was created from the waitinglist, keep the original application date + if self.waitinglist_application_date: + waiter.application_date = self.waitinglist_application_date + waiter.save() + self.delete() + diff --git a/jdav_web/members/models/member_note_list.py b/jdav_web/members/models/member_note_list.py new file mode 100644 index 0000000..b0c03f0 --- /dev/null +++ b/jdav_web/members/models/member_note_list.py @@ -0,0 +1,21 @@ +from django.db import models +from django.utils.translation import gettext_lazy as _ +from datetime import datetime +from django.contrib.contenttypes.fields import GenericRelation +from .member_on_list import NewMemberOnList +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" + diff --git a/jdav_web/members/models/member_on_list.py b/jdav_web/members/models/member_on_list.py new file mode 100644 index 0000000..f76eeac --- /dev/null +++ b/jdav_web/members/models/member_on_list.py @@ -0,0 +1,48 @@ +from django.db import models +from django.utils.translation import gettext_lazy as _ +from django.contrib.contenttypes.fields import GenericForeignKey +from django.contrib.contenttypes.models import ContentType +from contrib.models import CommonModel +from members.rules import is_leader +from contrib.rules import has_global_perm + +class NewMemberOnList(CommonModel): + """ + Connects members to a list of members. + """ + member = models.ForeignKey('Member', verbose_name=_('Member'), on_delete=models.CASCADE) + 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(CommonModel.Meta): + verbose_name = _('Member') + verbose_name_plural = _('Members') + rules_permissions = { + 'add_obj': is_leader, + 'view_obj': is_leader | has_global_perm('members.view_global_freizeit'), + 'change_obj': is_leader, + 'delete_obj': is_leader, + } + + @property + def comments_tex(self): + raw = ". ".join(c for c in (self.member.comments, self.comments) if c).replace("..", ".") + return raw if raw else "---" + + @property + def skills(self): + activities = [a.name for a in self.memberlist.activity.all()] + return {k: v for k, v in self.member.get_skills().items() if k in activities} + + @property + def qualities_tex(self): + qualities = [] + for activity, value in self.skills.items(): + qualities.append("\\textit{%s:} %s" % (activity, value)) + return ", ".join(qualities) diff --git a/jdav_web/members/models/member_unconfirmed.py b/jdav_web/members/models/member_unconfirmed.py new file mode 100644 index 0000000..28d297f --- /dev/null +++ b/jdav_web/members/models/member_unconfirmed.py @@ -0,0 +1,28 @@ +from django.db import models +from django.utils.translation import gettext_lazy as _ +from members.rules import may_view, may_change, may_delete +from contrib.rules import has_global_perm +from .member import Member + +class MemberUnconfirmedManager(models.Manager): + def get_queryset(self): + return super().get_queryset().filter(confirmed=False) + +class MemberUnconfirmedProxy(Member): + """Proxy to show unconfirmed members seperately in admin""" + objects = MemberUnconfirmedManager() + + class Meta: + proxy = True + verbose_name = _('Unconfirmed registration') + verbose_name_plural = _('Unconfirmed registrations') + permissions = (('may_manage_all_registrations', 'Can view and manage all unconfirmed registrations.'),) + rules_permissions = { + 'view_obj': may_view | has_global_perm('members.may_manage_all_registrations'), + 'change_obj': may_change | has_global_perm('members.may_manage_all_registrations'), + 'delete_obj': may_delete | has_global_perm('members.may_manage_all_registrations'), + } + + def __str__(self): + """String representation""" + return self.name diff --git a/jdav_web/members/models/permission.py b/jdav_web/members/models/permission.py new file mode 100644 index 0000000..ca6bd97 --- /dev/null +++ b/jdav_web/members/models/permission.py @@ -0,0 +1,65 @@ +from django.db import models +from django.utils.translation import gettext_lazy as _ + + +from .member import Member +from .group import Group + +class PermissionMember(models.Model): + member = models.OneToOneField(Member, on_delete=models.CASCADE, related_name='permissions') + # every member of view_members may view this member + list_members = models.ManyToManyField(Member, related_name='listable_by', blank=True, + verbose_name=_('May list members')) + view_members = models.ManyToManyField(Member, related_name='viewable_by', blank=True, + verbose_name=_('May view members')) + change_members = models.ManyToManyField(Member, related_name='changeable_by', blank=True, + verbose_name=_('May change members')) + delete_members = models.ManyToManyField(Member, related_name='deletable_by', blank=True, + verbose_name=_('May delete members')) + + # every member in any view_group may view this member + list_groups = models.ManyToManyField(Group, related_name='listable_by', blank=True, + verbose_name=_('May list members of groups')) + view_groups = models.ManyToManyField(Group, related_name='viewable_by', blank=True, + verbose_name=_('May view members of groups')) + change_groups = models.ManyToManyField(Group, related_name='changeable_by', blank=True, + verbose_name=_('May change members of groups')) + delete_groups = models.ManyToManyField(Group, related_name='deletable_by', blank=True, + verbose_name=_('May delete members of groups')) + + class Meta: + verbose_name = _('Permissions') + verbose_name_plural = _('Permissions') + + def __str__(self): + return str(_('Permissions')) + + +class PermissionGroup(models.Model): + group = models.OneToOneField(Group, on_delete=models.CASCADE, related_name='permissions') + # every member of view_members may view all members of group + list_members = models.ManyToManyField(Member, related_name='group_members_listable_by', blank=True, + verbose_name=_('May list members')) + view_members = models.ManyToManyField(Member, related_name='group_members_viewable_by', blank=True, + verbose_name=_('May view members')) + change_members = models.ManyToManyField(Member, related_name='group_members_changeable_by_group', blank=True, + verbose_name=_('May change members')) + delete_members = models.ManyToManyField(Member, related_name='group_members_deletable_by', blank=True, + verbose_name=_('May delete members')) + + # every member in any view_group may view all members of group + list_groups = models.ManyToManyField(Group, related_name='group_members_listable_by', blank=True, + verbose_name=_('May list members of groups')) + view_groups = models.ManyToManyField(Group, related_name='group_members_viewable_by', blank=True, + verbose_name=_('May view members of groups')) + change_groups = models.ManyToManyField(Group, related_name='group_members_changeable_by', blank=True, + verbose_name=_('May change members of groups')) + delete_groups = models.ManyToManyField(Group, related_name='group_members_deletable_by', blank=True, + verbose_name=_('May delete members of groups')) + + class Meta: + verbose_name = _('Group permissions') + verbose_name_plural = _('Group permissions') + + def __str__(self): + return str(_('Group permissions')) diff --git a/jdav_web/members/models/registration.py b/jdav_web/members/models/registration.py new file mode 100644 index 0000000..31e2cbc --- /dev/null +++ b/jdav_web/members/models/registration.py @@ -0,0 +1,11 @@ +from django.db import models +from django.utils.translation import gettext_lazy as _ +from .group import Group + +class RegistrationPassword(models.Model): + group = models.ForeignKey(Group, on_delete=models.CASCADE) + password = models.CharField(_('Password'), default='', max_length=20, unique=True) + + class Meta: + verbose_name = _('registration password') + verbose_name_plural = _('registration passwords') diff --git a/jdav_web/members/models/training.py b/jdav_web/members/models/training.py new file mode 100644 index 0000000..73d2098 --- /dev/null +++ b/jdav_web/members/models/training.py @@ -0,0 +1,66 @@ +from django.db import models +from django.utils.translation import gettext_lazy as _ +from utils import RestrictedFileField +from contrib.models import CommonModel +from members.rules import is_oneself +from contrib.rules import has_global_perm +from .member import Member +from .activity import ActivityCategory + +class TrainingCategory(models.Model): + """Represents a type of training, e.g. Grundausbildung, Fortbildung, Aufbaumodul, etc.""" + name = models.CharField(verbose_name=_('Name'), max_length=50) + permission_needed = models.BooleanField(verbose_name=_('Permission needed')) + + class Meta: + verbose_name = _('Training category') + verbose_name_plural = _('Training categories') + + def __str__(self): + return self.name + +class MemberTraining(CommonModel): + """Represents a training planned or attended by a member.""" + member = models.ForeignKey(Member, on_delete=models.CASCADE, related_name='traininigs', verbose_name=_('Member')) + title = models.CharField(verbose_name=_('Title'), max_length=150) + date = models.DateField(verbose_name=_('Date'), null=True, blank=True) + category = models.ForeignKey(TrainingCategory, on_delete=models.PROTECT, verbose_name=_('Category')) + activity = models.ManyToManyField(ActivityCategory, verbose_name=_('Activity')) + comments = models.TextField(verbose_name=_('Comments'), blank=True) + participated = models.BooleanField(verbose_name=_('Participated'), null=True) + passed = models.BooleanField(verbose_name=_('Passed'), null=True) + certificate = RestrictedFileField(verbose_name=_('certificate of attendance'), + upload_to='training_forms', + blank=True, + max_upload_size=5, + content_types=['application/pdf', + 'image/jpeg', + 'image/png', + 'image/gif']) + + def __str__(self): + if self.date: + return self.title + ' ' + self.date.strftime('%d.%m.%Y') + return self.title + ' ' + str(_('(no date)')) + + def get_activities(self): + activity_string = ', '.join(a.name for a in self.activity.all()) + return activity_string + + get_activities.short_description = _('Activities') + + + class Meta(CommonModel.Meta): + verbose_name = _('Training') + verbose_name_plural = _('Trainings') + + permissions = ( + ('manage_success_trainings', 'Can edit the success status of trainings.'), + ) + rules_permissions = { + # sine this is used in an inline, the member and not the training is passed + 'add_obj': is_oneself | has_global_perm('members.add_global_membertraining'), + 'view_obj': is_oneself | has_global_perm('members.view_global_membertraining'), + 'change_obj': is_oneself | has_global_perm('members.change_global_membertraining'), + 'delete_obj': is_oneself | has_global_perm('members.delete_global_membertraining'), + } diff --git a/jdav_web/members/models/waiting_list.py b/jdav_web/members/models/waiting_list.py new file mode 100644 index 0000000..e0e66e2 --- /dev/null +++ b/jdav_web/members/models/waiting_list.py @@ -0,0 +1,161 @@ +from django.db import models +from django.utils import timezone +from django.utils.translation import gettext_lazy as _ +from django.conf import settings +from contrib.models import CommonModel +from members.rules import is_leader_of_relevant_invitation +from contrib.rules import has_global_perm +from mailer.mailutils import send as send_mail +from .base import Person, gen_key +from .invitation import InvitationToGroup +from mailer.mailutils import get_registration_link, get_wait_confirmation_link,\ + get_invitation_reject_link, get_leave_waitinglist_link,\ + get_invitation_confirm_link +import uuid + + +class MemberWaitingList(Person): + """A participant on the waiting list""" + + WAITING_CONFIRMATION_SUCCESS = 0 + WAITING_CONFIRMATION_INVALID = 1 + WAITING_CONFIRMATION_EXPIRED = 1 + WAITING_CONFIRMED = 2 + + application_text = models.TextField(_('Do you want to tell us something else?'), default='', blank=True) + application_date = models.DateTimeField(verbose_name=_('application date'), default=timezone.now) + + last_wait_confirmation = models.DateField(default=timezone.now, verbose_name=_('Last wait confirmation')) + wait_confirmation_key = models.CharField(max_length=32, default="") + wait_confirmation_key_expire = models.DateTimeField(default=timezone.now) + + leave_key = models.CharField(max_length=32, default="") + + last_reminder = models.DateTimeField(default=timezone.now, verbose_name=_('Last reminder')) + sent_reminders = models.IntegerField(default=0, verbose_name=_('Missed reminders')) + + registration_key = models.CharField(max_length=32, default="") + registration_expire = models.DateTimeField(default=timezone.now) + + class Meta(CommonModel.Meta): + verbose_name = _('Waiter') + verbose_name_plural = _('Waiters') + permissions = (('may_manage_waiting_list', 'Can view and manage the waiting list.'),) + rules_permissions = { + 'add_obj': has_global_perm('members.add_global_memberwaitinglist'), + 'view_obj': is_leader_of_relevant_invitation | has_global_perm('members.view_global_memberwaitinglist'), + 'change_obj': has_global_perm('members.change_global_memberwaitinglist'), + 'delete_obj': has_global_perm('members.delete_global_memberwaitinglist'), + } + + def latest_group_invitation(self): + gi = self.invitationtogroup_set.order_by('-pk').first() + if gi: + return "{group}: {status}".format(group=gi.group.name, status=gi.status()) + else: + return "-" + latest_group_invitation.short_description = _('Latest group invitation') + + @property + def waiting_confirmation_needed(self): + """Returns if person should be asked to confirm waiting status.""" + return not self.wait_confirmation_key \ + and self.last_wait_confirmation < timezone.now() -\ + timezone.timedelta(days=settings.WAITING_CONFIRMATION_FREQUENCY) + + def waiting_confirmed(self): + """Returns if the persons waiting status is considered to be confirmed.""" + if self.sent_reminders > 0: + # there was sent at least one wait confirmation request + if timezone.now() < self.wait_confirmation_key_expire: + # the request has not expired yet + return None + else: + # we sent a request that has expired + return False + else: + # if there exist no pending or expired reminders, the waiter remains confirmed + return True + waiting_confirmed.admin_order_field = 'last_wait_confirmation' + waiting_confirmed.boolean = True + waiting_confirmed.short_description = _('Waiting status confirmed') + + def ask_for_wait_confirmation(self): + """Sends an email to the person asking them to confirm their intention to wait.""" + self.last_reminder = timezone.now() + self.sent_reminders += 1 + self.leave_key = gen_key() + self.save() + self.send_mail(_('Waiting confirmation needed'), + settings.WAIT_CONFIRMATION_TEXT.format(name=self.prename, + link=get_wait_confirmation_link(self), + leave_link=get_leave_waitinglist_link(self.leave_key), + reminder=self.sent_reminders, + max_reminder_count=settings.MAX_REMINDER_COUNT)) + + def confirm_waiting(self, key): + # if a wrong key is supplied, we return invalid + if not self.wait_confirmation_key == key: + return self.WAITING_CONFIRMATION_INVALID + + # if the current wait confirmation key is not expired, return sucess + if timezone.now() < self.wait_confirmation_key_expire: + self.last_wait_confirmation = timezone.now() + self.wait_confirmation_key_expire = timezone.now() + self.sent_reminders = 0 + self.leave_key = '' + self.save() + return self.WAITING_CONFIRMATION_SUCCESS + + # if the waiting is already confirmed, return success + # this might happen if both parents and member mail are used for communication + if self.waiting_confirmed(): + return self.WAITING_CONFIRMED + + # otherwise the link is too old and the person was not confirmed in time + return self.WAITING_CONFIRMATION_EXPIRED + + def generate_wait_confirmation_key(self): + self.wait_confirmation_key = uuid.uuid4().hex + self.wait_confirmation_key_expire = timezone.now() \ + + timezone.timedelta(days=settings.GRACE_PERIOD_WAITING_CONFIRMATION) + self.save() + return self.wait_confirmation_key + + def may_register(self, key): + try: + invitation = InvitationToGroup.objects.get(key=key) + return self.pk == invitation.waiter.pk and timezone.now().date() < invitation.date + timezone.timedelta(days=30) + except InvitationToGroup.DoesNotExist: + return False + + def invite_to_group(self, group, text_template=None, creator=None): + """ + Invite waiter to given group. Stores a new group invitation + and sends a personalized e-mail based on the passed template. + """ + self.invited_for_group = group + self.save() + if not text_template: + text_template = group.get_invitation_text_template() + invitation = InvitationToGroup(group=group, waiter=self, created_by=creator) + invitation.save() + self.send_mail(_("Invitation to trial group meeting"), + text_template.format(name=self.prename, + link=get_registration_link(invitation.key), + invitation_reject_link=get_invitation_reject_link(invitation.key), + invitation_confirm_link=get_invitation_confirm_link(invitation.key)), + cc=group.contact_email.email) + + def unregister(self): + """Delete the waiter and inform them about the deletion via email.""" + self.send_mail(_("Unregistered from waiting list"), + settings.LEAVE_WAITINGLIST_TEXT.format(name=self.prename)) + self.delete() + + def confirm_mail(self, key): + ret = super().confirm_mail(key) + if ret: + self.send_mail(_("Successfully registered for the waitinglist"), + settings.JOIN_WAITINGLIST_CONFIRMATION_TEXT.format(name=self.prename)) + return ret diff --git a/jdav_web/members/tests/basic.py b/jdav_web/members/tests/basic.py index 821aa70..cad84dd 100644 --- a/jdav_web/members/tests/basic.py +++ b/jdav_web/members/tests/basic.py @@ -347,6 +347,13 @@ class MemberTestCase(BasicMemberTestCase): self.lisa.waitinglist_application_date = timezone.now() self.lisa.demote_to_waiter() + def test_filter_queryset_by_permissions_message(self): + """Test filtering of Message objects via filter_queryset_by_permissions""" + message = Message.objects.create(subject='Test Message', content='Content', created_by=self.fritz) + queryset = Message.objects.all() + filtered = self.fritz.filter_queryset_by_permissions(queryset=queryset, model=Message) + self.assertQuerysetEqual(filtered, [message], ordered=False) + class PDFTestCase(TestCase): def setUp(self): @@ -1665,6 +1672,15 @@ class MailConfirmationTestCase(BasicMemberTestCase): self.assertTrue(em.confirmed_mail, msg='Mail of every emergency contact should be confirmed after manually confirming.') + def test_request_mail_confirmation_skips_empty_email(self): + """Ensure request_mail_confirmation continues when email field is empty.""" + # set emergency contact email to empty -> should be skipped + self.father.email = '' + self.father.save() + requested = self.father.request_mail_confirmation() + self.assertFalse(requested) + # no key should have been generated + self.assertEqual(getattr(self.father, 'confirm_mail_key', ''), '') class RegisterWaitingListViewTestCase(BasicMemberTestCase): def test_register_waiting_list_get(self):