Hot-keys on this page
r m x p toggle line displays
j k next/prev highlighted chunk
0 (zero) top of page
1 (one) first highlighted chunk
# -*- coding: utf-8 -*-
# ATTENTION: if you change this logic, you have to change the corresponding logic in Member.is_member Q(membership_end__isnull=True) | Q(membership_end__gt=datetime.datetime.now()))
# We use shortname in part as filename, e.g. we don't want any spaces and or dots. null=False, unique=True, max_length=255) null=False, unique=True, max_length=255)
""" Basis for members and Datenschleuder subscribers. Has all the fields shared between them. """
# Counter for returned snail mail null=False, default=0) 'be considered as unreachable. Is automatically reset to 0 when modifying any part' + \ 'of the address'
if self.address_unknown >= settings.ADDRESS_RETURNS: return 'unknown'
parts = [self.address_1, self.address_2, self.address_3] return separator.join(p for p in parts if p is not None)
""" Tries to find the postal code in the address dataset. Only meant to work in German addresses and requires the postal code to be on the last line of the address. :return: 5-digit postal code as string """ last_address_line = self.address_3 if self.address_3 else self.address_2 match = re.search(r'^\d{4,5} ', str(last_address_line)) return '{:05d}'.format(int(match.group())) if match else ''
""" Returns all email addresses of this member. :return: A list with the primary email address in the first position, then the addresses with a gpg key, if there are any and lastly emails addresses without a gpg key. """ '-gpg_key_id')
else:
return not self.emailaddress_set.all().exists()
primary_mail = self.emailaddress_set.filter(is_primary=True).first() if primary_mail: return primary_mail general_mail = self.emailaddress_set.first() if general_mail: general_mail.is_primary = True general_mail.save() return general_mail
""" Should this person be sent a (certain) issue of the Datenschleuder
:param issue: Number of the Datenschleuder which should be determined to send :return: If the member has not more than one year overdue fees, is active and has not exited yet """ raise NotImplementedError("Please Implement this method")
# If the address was formerly unknown and now any part of it has been changed, but the address_unknown field has # not been unchecked, then assume it has been forgotten and uncheck that field automatically. self.get_dirty_fields()) and self._original_state['address_unknown']\ and 'address_unknown' not in self.get_dirty_fields():
(UNKNOWN_SOURCE, 'UNKNOWN SOURCE'), (IMPORT_INITIAL, 'INITIAL IMPORT'), ) choices=SUBSCRIBER_SOURCE_CHOICES)
blank=False, null=False, default=0) # inclusive last number
desc = str( self.chaos_number) + ': ' + self.first_name + ' ' + self.last_name if self.is_endless: desc += ' (endless subscription)' else: desc += ' (' + str(self.max_datenschleuder_issue) + ')' return desc
return (self.is_endless or self.max_datenschleuder_issue >= issue ) and not self.address_unknown >= settings.ADDRESS_RETURNS
# abo: x-ausgaben (def 8) # until issue number (example: 106) # 999 endless # bei export musss aktuelle datenschleuder nr angegeben werden!!!1!
"""Generates a random token for transfers to be used in payments for the CCC
The tokens are in the form of '<prefix>XXXXXX' where X denotes a random char from this alphabet: ACDEFHKLMNPRWX39 This alphabet has been chosen to be compatible with SEPA transfer character sets and to minimize mistakes through similar letters and/or numbers. <prefix> is defined in the project's settings.py or production_settings.py.
The returned token is not guaranteed to be unique. """
"""Generates a random token for Abstimmungen to verify legitimacy
The returned token is not guaranteed to be unique. """ return get_random_string(length=10, allowed_chars='abcdefghijklmnopqrstuvwxyz')
random.choice(word_list.adjective_list), random.choice(word_list.nouns) ])
""" This model represents a person, that is or was a member of the association. """
default=generate_transfer_token, unique=True, null=False, blank=False) unique=False, default=_initial_password, blank=False, null=False)
default=_unique_username, unique=True, null=False, blank=False)
"""Add dashes every 3 characters for better readability """
(MEMBERSHIP_TYPE_SUPPORTER, 'SUPPORTER'), (MEMBERSHIP_TYPE_MEMBER, 'MEMBER'), (MEMBERSHIP_TYPE_HONORARY, 'HONORARY'), ]
choices=MEMBERSHIP_TYPE_CHOICES, default=MEMBERSHIP_TYPE_SUPPORTER)
blank=False, null=False, default=get_alien, on_delete=models.CASCADE)
# only admin should change it null=False, default=datetime.date.today)
# invis null=False, default=datetime.date.today)
# other non-var stuff
if not self.is_member(): membership = 'exited' elif not self.is_active: membership = 'inactive' else: membership = 'active'
return 'Member {} {}'.format(self.chaos_number, membership)
""" Chaos Number in a machine-readable format. Useful to scan from letters and such. :return: A base64-encoded PNG of a QR code containing the Chaos Number. """ img = qrcode.make(str(self.chaos_number) + "\r", box_size=5) buffered = BytesIO() img.save(buffered, format="PNG") return base64.b64encode(buffered.getvalue()).decode('utf-8')
""" Abstimmungs-Token in a machine-readable format. Useful to scan from letters and such. :return: A base64-encoded PNG of a QR code containing the Abstimmungs-Token. """ img = qrcode.make(str(AbstimmungToken.objects.filter(member=self).first().token) + "\r", box_size=4, border=2) buffered = BytesIO() img.save(buffered, format="PNG") return base64.b64encode(buffered.getvalue()).decode('utf-8')
# ATTENTION: if you change this logic, you have to change the corresponding logic in MemberManager.members_only self.membership_end)
cut_off_date=datetime.date.today() - relativedelta(months=2)): # This is the cut off date plus one year, because members who paid recently # have a fee_paid_until set to exactly one year after their last due date.
return False else: else: # between -fee and 0 else:
""" Determines if a member will have to pay another membership fee in the future. Most likely to be True, but when a member leaves the club with an early advance notice, he or she might need to pay another fee before leaving the club.
:return: True, if the member will have to pay another fee and False if the membership is already paid until its exit date """ return False
return not self.is_payment_late(cut_off_date=datetime.date.today() - relativedelta(years=1)) \ and self.is_active and self.is_member() and not self.address_unknown >= settings.ADDRESS_RETURNS \ and self.wants_datenschleuder
if not self.is_active: return self.get_annual_fee_readable()
until_date = datetime.date(2021, 4, 24) fees_payable = -self.account_balance if self.fee_paid_until <= until_date: years_until = relativedelta(until_date, self.fee_paid_until).years + 1 fees_payable += years_until * self.get_annual_fee() return '{:0.2f}'.format(fees_payable / float(100) if fees_payable > 0 else 0)
return '{:0.2f}'.format(float(self.account_balance) / -100.0)
if self.erfa != erfa: return False erfa, created = Erfa.objects.get_or_create( short_name=settings.EMPTY_ERFA_NAME) if created: erfa.long_name = settings.EMPTY_ERFA_NAME # TODO: do we need this? please test if child objects are automatically saved erfa.safe() self.erfa = erfa
""" Resign a member. The dataset is kept until any outstanding fees are settled. The resignation confirmation email has to be sent separately. :return: """
# Members with unsettled fees are just not yet deleted if self.membership_end is None: # everything except TODAY makes problems with "members_only" and "is_member" self.membership_end = datetime.date.today() self.save() return
# ToDo: Maybe here we could send a "your account is settled, we will delete your data now" mail.
except models.deletion.ProtectedError as e: print(e) pass
new_balance, reason=None, comment='', booking_day=None, save=True): # calculates transaction and passes to increaseBalanceBy save)
increase_by, reason=None, comment='', booking_day=None, save=True, bank_transaction=None): # all booked balance changes should occur via this function self.chaos_number, increase_by)) bank_transaction)
increased_by, reason=None, comment='', booking_day=None, bank_transaction=None): self._original_state['account_balance'])) amount=increased_by, new_value=self.account_balance, comment=comment, reason=reason, booking_day=booking_day, bank_transaction=bank_transaction)
""" Checks if the next annual fee is due and processes it, if enough money is in the account_balance. :return: True, if a payment has been processed, False otherwise. """ return False
# In case the member already declared its resignation and the fees until the resignation date are paid do # nothing. return False
# Do not run on the same date because that would cause to run it the day a new member was entered and we # know for sure no money arrived yet (except in the Vereinstisch case). # Everything is okay, go ahead and "transform" money to membership years self.fee_paid_until).years + 1 # keep this order because increase_balance_by calls save() -self.get_annual_fee() * years, reason=BalanceTransactionLog.BILLING_CYCLE, save=save, comment= 'Previous due date: {:%d.%m.%Y}. New due date: {:%d.%m.%Y}.'. format(previous_due_date, self.fee_paid_until)) else: return False
# Ensure the transfer_token is unique transfer_token=self.transfer_token).exclude( pk=self.pk).exists(): self.transfer_token = generate_transfer_token()
# If a payment arrives while the member is inactive, then activate it 'account_balance'] < self.account_balance and not self.is_active:
# On reactivation of a member their payment due date should be set as if they were a new member # The account balances are set to 0 by a database migration and need no adjustment 'is_active'] and 'is_active' in self.get_dirty_fields(): self.fee_paid_until = datetime.date.today()
# If an Erfa with Doppelmitgliedschaft is chosen, but as membership type a supporter is selected raise an error if self.membership_type == self.MEMBERSHIP_TYPE_SUPPORTER and self.erfa.has_doppelmitgliedschaft: raise ValidationError( "Only Members are allowed as Doppelmitglieder")
# make sure we have only one primary email! primary_emails = list( self.emailaddress_set.filter(is_primary=True).all()) for email in primary_emails[1:]: logging.warning( "Member {} had more than one primary email. We disabled {}". format(self.pk, str(email))) email.is_primary = False email.save() if len(primary_emails) == 0: email = self.emailaddress_set.first() if email: logging.warning( "Member {} has at least one email, but no primary. We set this email {} to" "primary".format(self.pk, str(email))) email.is_primary = True email.save()
""" A token to print on a Abstimmungs-Karte, which is then being scanned to verify that an Abstimmung is legitimate. The token is made of only lower case characters to make it easier to type in case the QR code is unreadable. """ Member, on_delete=models.CASCADE, primary_key=True, )
default=generate_abstimmung_token, unique=True, null=False, blank=False)
null=False, blank=False)
(UNCOUNTED, 'uncounted'), # Has not been validated yet. (COUNTED, 'counted'), # Has been positively validated. The rest are reasons for invalidating a vote. (NOT_PAID, 'has not paid fees'), (INACTIVE, 'inactive'), (EXITED, 'exited'), (NO_RIGHTS, 'no voting rights'), )
'Person', on_delete=models.CASCADE ) # code blaming e.g. ('', related_name='emails') max_length=60, blank=True, null=False, validators=[ RegexValidator( regex= '^0x[a-fA-F0-9]{8}$|^0x[a-fA-F0-9]{16}$|^[a-fA-F0-9]{40}$', message='Enter a valid short or long ID or Fingerprint') ])
# This field should not be editable
# Make sure there is always exactly one primary email address. Either this one is going to be it, then set this # attribute to false on all other EmailAddress objects, or make this one primary, if otherwise none would be. is_primary=True).exists():
person=self.person) '-gpg_key_id').first()
or 'none')
(SEND_TYPE_DEFAULT, 'default'), (SEND_TYPE_DATA_RECORD, 'data record'), (SEND_TYPE_WELCOME, 'welcome'), (SEND_TYPE_DELAYED_PAYMENT, 'delayed payment'), (SEND_TYPE_GPG_ERROR, 'gpg error'), (SEND_TYPE_EXIT, 'member exit'), (SEND_TYPE_DOPPELERFA_EXIT, 'doppelerfa exit'), (SEND_TYPE_REACTIVATION_REMINDER, 'reactivation reminder'), (SEND_TYPE_GA_INVITATION, 'ga invitation'), (SEND_TYPE_HAND_MATCHED, 'hand matched payment'), )
Member, on_delete=models.PROTECT ) # Don't delete a member we still need to send a msg to choices=EMAIL_TO_SEND_TYPES, default=SEND_TYPE_DEFAULT)
def _render_template(template_name, country_code, context): """ Selects a template and renders it's subject and body sections.
:param template_name: The part of the mail template file name between 'mail' and the country code. :param country_code: Two letter country code. Is appended to the template file name with an underline. EN is fallback if none is found. :param context: Depends on the objects used in the template. :return: subject and body of the email as a tuple of strings. """ """ Get a named node from a template. Returns `None` if a node with the given name does not exist. taken from https://github.com/bradwhittington/django-templated-email/blob/master/templated_email/utils.py """
BlockNode) and n.name in block_lookups: node.nodelist[i] = block_lookups[n.name] lookups = dict([(n.name, n) for n in node.nodelist if isinstance(n, BlockNode)]) lookups.update(block_lookups) return _get_node(node.get_parent(context), name, lookups) return None
'mail_templates/mail_{}_{}.html'.format(template_name, country) for country in [country_code, 'EN'] ]
except KeyError as ke: raise TemplateError( 'Template is missing a {{% block {} %}}'.format(str(ke)[1:-1]))
""" A preview with the template's HTML rendered. Suitable for displaying the email in a browser. :return: Subject and body enclosed in <pre>-tags. String is safely encoded. """ from django.utils import safestring return safestring.mark_safe('<pre>{}</pre><pre>{}</pre>'.format( self.subject, self.body))
self.get_email_type_display().replace(' ', '_'), self.member.address_country, Context({'member': self.member}))
# First try the primary email address. Then try the addresses with a gpg key, if there are any. Last try # sending unencrypted
# If any email address has a gpg key id we will only send encrypted emails (or gpg error notifications) gpg_key_id='').exists()
# Important: The following assumes, that email_adresses are preordered, with all addresses that have # non-empty Key IDs at the head of the list. email_address.gpg_key_id)) '0x') and recipient_key.key.fpr: self.body, settings.GPG_HOST_USER) try: with open('beilagen.pdf', 'rb') as attachment: encrypted_attachment, result, sign_result = recipient_key.encrypt_to( attachment.read(), settings.GPG_HOST_USER) attachments.append( ('beilagen.pdf.asc', encrypted_attachment.decode('utf-8'), 'application/pdf')) except IOError: pass email_address.email_address)) ' Signature result: {}'.format(sign_result)) self._send_mail([email_address.email_address], self.subject, encrypted_body.decode('utf-8'), attachments)) except Exception as ex: # TODO: introduce appropriate logging or an actionable error message here. # At this point we have a valid, non-expired, non-revoked key in the keyring # so if encryption fails, the problem is with message or signing key. The member cannot # control either, so sending an eMail with an error message doesn't help. # # Also, there's probably no point in continuing here at all. logging.error('GPGME Exception: {}'.format(ex)) continue else:
# Don't send GPG Error message if the reason for the missing key might be unreachable servers # If an unrevoked and unexpired key is available, it will have been used, and this block # will not be reached. ' Sending GPG error message email: {}'.format( email_address.gpg_error))
# GPG error notifications will always be sent unencrypted dict(self.EMAIL_TO_SEND_TYPES)[ self.SEND_TYPE_GPG_ERROR].replace(' ', '_'), self.member.address_country, Context({ 'member': self.member, 'gpg_error': email_address.gpg_error, 'email_address': email_address })) error_body, settings.GPG_HOST_USER) ' Signature result of GPG error message email: {}'. format(sign_result)) self._send_mail([email_address.email_address], error_subject, signed_body.decode('utf-8'), [], False)) ' Failed on all encryptable email addresses for member {}'. format(self.member.get_name())) else: ' Sending unencrypted email to {}'.format(email_address)) settings.GPG_HOST_USER) try: with open('beilagen.pdf', 'rb') as attachment: attachments.append( ('beilagen.pdf', attachment.read(), 'application/pdf')) except IOError: pass self._send_mail([email_address.email_address], self.subject, signed_body.decode('utf-8'), attachments))
# For testing, return all sent mails, otherwise return last mail or the default return # value if mails couldn't be sent. else:
address, subject, body, attachments=[], should_archive=True): message_number = _unique_delivery_number() local, _, domain = settings.EMAIL_VERP_SENDER.rpartition('@') send_email = EmailMessage( subject=subject, body=body, from_email=f'{local}+{message_number}@{domain}', headers={'From': settings.EMAIL_SENDER}, to=address, bcc=[ settings.EMAIL_HOST_USER, ], attachments=attachments, ) try: send_state = send_email.send(False) except ConnectionRefusedError as e: logging.error('Sending mail to {} failed with: {}'.format( address, e)) return {'mailTo': address, 'send_state': 0} if send_state > 0 and should_archive: self._archive(address, message_number) return {'mailTo': address, 'send_state': send_state}
mail_archive = ArchivedEmail() mail_archive.created_date = self.created mail_archive.sent_date = datetime.datetime.now() mail_archive.email_type = self.email_type mail_archive.member = self.member mail_archive.body = self.body mail_archive.subject = self.subject mail_archive.email_address = str(email_address) mail_archive.save() self.delete()
return ", ".join([self.subject, str(self.member)])
# code blaming: time stamp model inheritance
# note currently executing and not just logging, could maybe refactor so actual transaction execution # was part of Member and then call this as an actual log
(MANUAL_BOOKING, 'MANUAL BOOKING'), (IMPORT_INITIAL, 'INITIAL IMPORT'), (IMPORT_BANKING, 'BANKING IMPORT'), (BILLING_CYCLE, 'BILLING CYCLE'), (ACCOUNT_CLOSED, 'ACCOUNT CLOSED'), (NO_INFO, 'NO INFORMATION'), ) choices=TRANSACTION_REASON_CHOICES) null=False, default=datetime.date.today) null=True, on_delete=models.SET_NULL)
return "{}, Amount {}, Balance {}, {}, {}, Timestamp: {}".format( self.member, self.changed_value, self.new_value, self.transaction_reason, self.comment, self.created_on)
member, amount, new_value, reason=None, comment='', booking_day=None, bank_transaction=None):
'account_balance'] # make sure for new logs that they match changes in active member objects raise LoggingConsistencyError( "balance change ({}) does not match ".format( self.changed_value) + "actual change in balance ({}) since loading".format( defacto_increased_by))
(BASIS, 'Basis'), (REPORT, 'Report'), (PLUS, 'Plus'), (FOKUS, 'Fokus'), (HYBRID, 'Hybrid'), (RETOURE, 'Retoure'), (RETOURE_EXTRA, 'Retoure Extra'), )
choices=PRODUCT_CHOICES, default=BASIS)
return self.name
""" This number is used to track the delivery of Datenschleudern. It is printed on the address label instead of the chaos number for privacy reasons. """ unique=True, default=_unique_delivery_number, blank=False, null=False)
return self.number
""" This will queue an data record email for the specified Member or refresh any already queued data record email. :param member: An instance of a Member object, of whom the data record will be sent. :return: None """ # This should only ever yield zero or one emails. But just in case we handle it like it could be any number. Q(email_type=EmailToMember.SEND_TYPE_WELCOME) | Q(email_type=EmailToMember.SEND_TYPE_DATA_RECORD), member=member)
member=member).save()
""" If a member paid their fees remove any payment reminder in the message queue :param member: An instance of a Member object, of whom the payment reminder will be checked for deletion :return: None """
# This should only ever yield zero or one emails. But just in case we handle it like it could be any number. email_type=EmailToMember.SEND_TYPE_DELAYED_PAYMENT, member=member)
email.delete()
"""All logic for sending emails on modifications of a member in one place. Using a signal makes sure that no matter which part of the object is modified and from which part inside the code the modification is done, this will always be called.""" # If a new Member is created create a welcome email # If a Member or EmailAddress is modified or an EmailAddress created, then check if either a welcome or data record # email exist. If none exists create a data record email and otherwise re-render subject and body to reflect the # most recently stored data. # Only do anything if any field of interest has been changed. # No emails for imported Members/EmailAddresses return member=member).save()
) and not member.erfa.has_doppelmitgliedschaft: EmailToMember(email_type=EmailToMember.SEND_TYPE_EXIT, member=member).save()
# When a member is exiting a Doppelmitgliedschaft, then an email is due pk=erfa_id ).has_doppelmitgliedschaft and not member.erfa.has_doppelmitgliedschaft: EmailToMember( email_type=EmailToMember.SEND_TYPE_DOPPELERFA_EXIT, member=member).save() return
'first_name', 'last_name', 'address_1', 'address_2', 'address_3', 'address_country', 'address_unknown', 'comment', 'erfa', 'fee_last_paid', 'fee_override', 'fee_paid_until', 'account_balance', 'membership_reduced', 'membership_type' ] for field in member.get_dirty_fields(check_relationship=True)): for field in instance.get_dirty_fields()): else: else:
# For a good measure check if a payment is due every time a member is modified # This will call save() again, causing a short loop until the paid until date is in the future # ToDo: think about how this is done best # member.execute_payment_if_due()
return
|