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 -*-
EMAIL_HOST_USER, EMAIL_MANAGING_DIRECTOR, GPG_HOST_USER, GPG_MANAGING_DIRECTOR, GPG_HOME, COUNTRY ) DeliveryNumber, EmailAddress, Subscriber, AbstimmungToken
return_as = RETURN_TYPE_JSON if 'return_as' in i_request(request): return_as = i_request(request)['return_as'] return return_as
return_as = _get_return_type(request) country_dict = {}
for member in Member.objects.members_only(): if member.address_country not in country_dict:
country_name = '' for countryPair in COUNTRIES: if countryPair[0] == member.address_country: country_name = countryPair[1] country_dict[member.address_country] = { 'country': member.address_country, 'country_name': country_name, 'members': 0 } country_dict[member.address_country]['members'] += 1
if return_as == RETURN_TYPE_JSON: json_list = [] for countryKey in sorted(country_dict): json_list.append([ countryKey, country_dict[countryKey]['country_name'], country_dict[countryKey]['members'] ])
return HttpResponse(json.dumps(json_list), content_type='application/json')
return HttpResponse('BAD FORMAT', content_type='text/html')
national_members = Member.objects.filter(address_country=COUNTRY, address_unknown=0) postal_codes = (member.get_plz() for member in national_members)
# sort out members without address, but no address_unknown flag postal_codes = (postal_code for postal_code in postal_codes if postal_code != '')
postal_codes_count = groupby(sorted(postal_codes), lambda x: str(x)[:2]) postal_codes_count_dict = {k: len(list(v)) for k, v in postal_codes_count}
result = {'total_members': national_members.count(), 'zip_codes': postal_codes_count_dict}
return JsonResponse(result)
""" Send a data record email to the specified Member(s) no matter if anything in their data set changed. Will refresh already queued emails instead of queueing multiple emails. :param request: Should be a GET request with one or more parameters 'chaos_number', being the chaos_number of a Member :return: A JSON string with a dictionary of each chaos number paired with either 'queued' or 'not found' """ response = {} for chaos_number in i_request(request).getlist('chaos_number'): try: member = Member.objects.members_only().get(chaos_number=chaos_number) send_or_refresh_data_record(member) response[chaos_number] = 'queued' except Member.DoesNotExist: response[chaos_number] = 'not found' return HttpResponse(json.dumps(response), content_type='application/json')
response = {} for chaos_number in i_request(request).getlist('chaos_number'): try: member = Member.objects.members_only().get(chaos_number=chaos_number, is_active=False) member.is_active = True # Changing the is_active state and saving sets also the last_paid_date member.save() response[chaos_number] = 'reactivated' except Member.DoesNotExist: response[chaos_number] = 'not found or already active' return HttpResponse(json.dumps(response), content_type='application/json')
""" :param request: :return: HttpResponse (json) """ del request
booked = map(lambda member: {'member': str(member), 'memberName': member.get_name(), 'balance': member.account_balance} if member.execute_payment_if_due() else None, Member.objects.all()) booked = list(filter(lambda x: x is not None, booked))
return HttpResponse(json.dumps(booked), content_type='application/json')
def billing_cycle_cron(): for member in Member.objects.all(): member.execute_payment_if_due()
def remove_run_out_subscribers(): for subscriber in Subscriber.objects.all(): if not subscriber.will_receive_issue(settings.NEXT_DATENSCHLEUDER_ISSUE): subscriber.delete()
mail_to_send = EmailToMember.objects.first() if mail_to_send: response = mail_to_send.send() else: response = {'state': 'nothing to send'} return HttpResponse(json.dumps(response), content_type='application/json')
response = [mail.send() for mail in EmailToMember.objects.all()]
if len(response) == 0: response = {'state': 'nothing to send'}
return HttpResponse(json.dumps(response), content_type='application/json')
with gpg.Context(armor=True) as c: c.set_engine_info(gpg.constants.protocol.OpenPGP, home_dir=settings.GPG_HOME)
# Sender-Pub-Key verfügbar? try: pub_key = c.get_key(settings.GPG_HOST_USER) except Exception as ex: error = """The sender key cannot be loaded: {} Please check: * Is the fingerprint '{}' set in GPG_HOST_USER correct? * Is the key imported into the keyring stored under '{}' as defined in GPG_HOME? GPG_HOST_USER and GPG_HOME are defined in ROOT/ROOT/settings.py or ROOT/ROOT/settings_production.py A key can be imported to the correct keyring like this: 'gpg --homedir {} --import key.asc' If the path to your GPG homedir is relative, make sure you're calling gpg in the correct working directory""".format( ex, settings.GPG_HOST_USER, settings.GPG_HOME, settings.GPG_HOME) return JsonResponse({'error': error})
# Sender-Private-Key verfügbar? try: priv_key = c.get_key(settings.GPG_HOST_USER, secret=True) except Exception as ex: error = """The sender private key cannot be loaded: {} Please check: * Does the key with the fingerprint '{}' as set in GPG_HOST_USER have a private key? * Is that private key also present in the keyring stored under '{}' as defined in GPG_HOME? GPG_HOST_USER and GPG_HOME are defined in ROOT/ROOT/settings.py or ROOT/ROOT/settings_production.py A key can be imported to the correct keyring like this: 'gpg --homedir {} --import key.asc' If the path to your GPG homedir is relative, make sure you're calling gpg in the correct working directory""".format( ex, settings.GPG_HOST_USER, settings.GPG_HOME, settings.GPG_HOME) return JsonResponse({'error': error})
if pub_key.expired: error = """The public key '{}' has expired.""".format(settings.GPG_HOST_USER) return JsonResponse({'error': error})
if priv_key.expired: error = """The private key '{}' has expired.""".format(settings.GPG_HOST_USER) return JsonResponse({'error': error})
if pub_key.disabled: error = """The public key '{}' is disabled.""".format(settings.GPG_HOST_USER) return JsonResponse({'error': error})
if priv_key.disabled: error = """The private key '{}' is disabled.""".format(settings.GPG_HOST_USER) return JsonResponse({'error': error})
if pub_key.revoked: error = """The public key '{}' has been revoked.""".format(settings.GPG_HOST_USER) return JsonResponse({'error': error})
if priv_key.revoked: error = """The private key '{}' has been revoked.""".format(settings.GPG_HOST_USER) return JsonResponse({'error': error})
if pub_key.invalid: error = """The public key '{}' is invalid.""".format(settings.GPG_HOST_USER) return JsonResponse({'error': error})
if priv_key.invalid: error = """The private key '{}' is invalid.""".format(settings.GPG_HOST_USER) return JsonResponse({'error': error})
# Signieren mit dem Sender-Key möglich? c.signers = [priv_key] try: _, sign_result = c.sign('T€st'.encode()) except Exception as ex: error = """The sender private key failed at signing a message: {} {}""".format(ex, sign_result) return JsonResponse({'error': error})
# Verschlüsseln an den Sender-Key möglich? try: _, result, _ = c.encrypt('T€st'.encode(), recipients=[pub_key], sign=False, always_trust=True) except Exception as ex: error = """The sender public key failed at encrypting a message: {} {}""".format(ex, result) return JsonResponse({'error': error})
# Herunterladen eines Keys vom Keyserver möglich? c.set_keylist_mode(gpg.constants.keylist.mode.EXTERN) try: fetched_key = c.get_key(settings.GPG_HOST_USER) except Exception as ex: error = """The sender public key could not be fetched from the keyservers: {}: {}""".format(type(ex), ex) return JsonResponse({'error': error})
# Importieren des Keys vom Keyserver möglich? try: c.op_import_keys([fetched_key]) except Exception as ex: error = """The key fetched from the keyserver could not be imported: {}""".format(ex) return JsonResponse({'error': error})
return JsonResponse({'error': None})
if request.method == 'POST': form = AddressLabelForm(request.POST) if form.is_valid(): pa_label = form.cleaned_data['pa_label'] return generate_letters(pa_label=pa_label) else: form = AddressLabelForm() return render(request, 'pa_select.html', {'form': form, 'title': 'Generate letters to members'})
""" Creates a zip file of undeliverable messages in the mail queue as pdf files. The pdfs are sorted by national or international mail and then concatenated by the number of pages to print. Messages to a member who's address is unknown are skipped.
:return: A zip file containing messages from the mail queue as pdfs. """ data = {'national': [], 'international': []}
for message in EmailToMember.objects.filter(Q(member__emailaddress__isnull=True) | Q(email_type=EmailToMember.SEND_TYPE_GA_INVITATION), member__address_unknown__lt=settings.ADDRESS_RETURNS).distinct(): del_num = DeliveryNumber(recipient=message.member, shipment='Letter on {}'.format(str(date.today()))) del_num.save()
# Easy way to sneak it into the template message.member.this_delivery_number = del_num
country = message.member.address_country template_paths = ['deadtree_templates/deadtree_base_{}.html'.format(country) for country in [country, 'EN']]
html = render_to_string(template_paths, context={'mail': message, 'pa_label': pa_label}) document = HTML(string=html).render()
if country == settings.COUNTRY: data['national'].append(document) else: data['international'].append(document)
message._archive('sent as letter on {}'.format(date.today().strftime('%d.%m.%Y')), del_num.number)
for letters in (l for l in data if len(l) > 0): d_l = data[letters] data[letters] = {} d_l.sort(key=lambda d: len(d.pages)) for pages, documents in groupby(d_l, key=lambda d: len(d.pages)): all_pages = (p for doc in documents for p in doc.pages) data[letters][pages] = d_l[0].copy(all_pages).write_pdf()
zip_buffer = BytesIO() zip_file = zipfile.ZipFile(zip_buffer, 'a', zipfile.ZIP_DEFLATED, False)
for destination in data: for pages in data[destination]: zip_file.writestr('{}/{}_pages.pdf'.format(destination, pages), data[destination][pages])
zip_file.close() zip_buffer.seek(0) response = HttpResponse(zip_buffer, content_type='application/zip') response['Content-Disposition'] = 'attachment; filename="letters.zip"' return response
stats = {} msgs = EmailToMember.objects.all() stats['msgInQ'] = msgs.count()
stats['mail'] = msgs.filter(member__emailaddress__isnull=False).distinct().count()
stats['letters'] = msgs.filter(member__emailaddress__isnull=True, member__address_unknown__lt=settings.ADDRESS_RETURNS).distinct().count()
stats['unreachable'] = msgs.filter(member__emailaddress__isnull=True, member__address_unknown__gte=settings.ADDRESS_RETURNS).distinct().count()
return HttpResponse(json.dumps(stats), content_type='application/json')
"""Handles member DB search requests returning HTML-table-rows""" context = {} if request.method == 'POST': params = request.POST check_empty_first_name = params.get('check_empty_first_name') check_empty_last_name = params.get('check_empty_last_name') check_empty_address = params.get('check_empty_address') check_empty_country = params.get('check_empty_country') check_empty_email = params.get('check_empty_email_address') if 'true' in (check_empty_address, check_empty_country, check_empty_first_name, check_empty_last_name, check_empty_email): # Search for empty fields q = Member.objects.all() if check_empty_first_name == 'true': q = q.filter(Q(first_name__exact='') | Q(first_name__isnull=True)) if check_empty_last_name == 'true': q = q.filter(Q(last_name__exact='') | Q(last_name__isnull=True)) if check_empty_address == 'true': # Filter for datasets with all three address field empty q = q.filter( ( (Q(address_1__exact='') | Q(address_1__isnull=True)) & (Q(address_2__exact='') | Q(address_2__isnull=True)) & (Q(address_3__exact='') | Q(address_3__isnull=True)) ) | Q(address_unknown__gte=settings.ADDRESS_RETURNS) ) if check_empty_country == 'true': q = q.filter(Q(address_country__exact='') | Q(address_country__isnull=True)) if check_empty_email == 'true': q = q.exclude(emailaddress__isnull=False)
context['results'] = q else: first_name = params.get('first_name', '') last_name = params.get('last_name', '') chaos_number = params.get('chaos_id', '') address = params.get('address', '') email = params.get('email_address', '') fee_reduced = str2bool(params.get('fee_is_reduced', 'false')) is_active = str2bool(params.get('is_active', 'false')) apply_filters = str2bool(params.get('apply_filters', 'false')) q = Member.objects.all() if first_name != '': q = q.filter(first_name__icontains=first_name) if last_name != '': q = q.filter(last_name__icontains=last_name) if chaos_number != '': # maybe check for illegal characters in the future q = q.filter(chaos_number=chaos_number) if address != '': q = q.filter(Q(address_1__icontains=address) | Q(address_2__icontains=address) | Q(address_3__icontains=address)) if email != '': q = q.filter(Q(emailaddress__email_address__icontains=email)) if apply_filters: q = q.filter(membership_reduced=fee_reduced, is_active=is_active)
context['results'] = q return render(request, 'api/member_search_result.html', context) return HttpResponse('Neither Get nor Post') # HttpResponse('BAD FORMAT', content_type='text/html')
['Erfa', 'Mitglied', 'Förderm.', 'Ehrenm.', 'Vollzahler', 'Ermäßigt', 'Spezial', 'Vollzahler €', 'Ermäßigt €', 'Spezial €', 'Summe €']]
# Name of the Erfa
# Count of different membership types Member.MEMBERSHIP_TYPE_HONORARY]:
# Count of members by fee types
# Expected fees by membership type
# Total expected fees per Erfa
# Since the fee_paid_until field is set to when the next payment would be due, it is one year ahead of your last # due date. Thus we take two months off of this date (12 - 2) to get all members 2 months over their last due date.
(Member.MEMBERSHIP_TYPE_SUPPORTER, 'Fördermitglieder')]: (o.filter(fee_override__isnull=True, membership_reduced=True), 'Ermäßigt'), (o.filter(fee_override__isnull=False), 'Spezial')]: # Name and count
# inactive
# Count overdue >2 months erfa__has_doppelmitgliedschaft=False)
# fees overdue >2 months
['Ehrenmitglieder', m.filter(membership_type=Member.MEMBERSHIP_TYPE_HONORARY).count(), 0, 0, 0])
datenschleuder_stats = [['Datenschleuder-Empfänger', 'Anzahl']]
eligible_members = Member.objects.filter(Q(membership_end__isnull=True) | Q(membership_end__gt=date.today())) datenschleuder_stats.append(['Mitglieder', eligible_members.count()])
datenschleuder_stats.append(['- unbekannt verzogen', eligible_members.filter(address_unknown__gte=settings.ADDRESS_RETURNS).count()])
inactive_members_count = eligible_members.filter(address_unknown__lt=settings.ADDRESS_RETURNS, is_active=False).count() datenschleuder_stats.append(['- ruhend', inactive_members_count])
unpaid_fees_count = eligible_members.filter(address_unknown__lt=settings.ADDRESS_RETURNS, is_active=True).filter( Q(membership_reduced=True, account_balance__lt=-settings.FEE_REDUCED) | Q(membership_reduced=False, account_balance__lt=-settings.FEE)).count() datenschleuder_stats.append(['- Ausstände >1 Jahr', unpaid_fees_count])
sum_eligible_members = eligible_members.filter(address_unknown__lt=settings.ADDRESS_RETURNS, is_active=True).exclude( account_balance__lt=-settings.FEE_REDUCED, membership_reduced=True).exclude(account_balance__lt=-settings.FEE, membership_reduced=False).count() # \n\0 is a tricky way to get a spacer in. \n will cause a 2-row multi-row cell and \0 is a non-printable char, # which is not whitespace (whitespace is automatically removed), thus creating an empty row following this row datenschleuder_stats.append(['Teilsumme Mitglieder\n'+u"\u200B", sum_eligible_members])
eligible_subscribers = Subscriber.objects.all() datenschleuder_stats.append(['DS-Abonnenten', eligible_subscribers.count()])
address_unknown_subscribers_count = eligible_subscribers.filter(address_unknown__gte=settings.ADDRESS_RETURNS).count() datenschleuder_stats.append(['- unbekannt verzogen', address_unknown_subscribers_count])
run_out_subscribers_count = eligible_subscribers.exclude(address_unknown__gte=settings.ADDRESS_RETURNS).exclude( Q(max_datenschleuder_issue__gte=settings.NEXT_DATENSCHLEUDER_ISSUE) | Q(is_endless=True)).count() datenschleuder_stats.append( ['- abgelaufen vor Ausgabe {}'.format(settings.NEXT_DATENSCHLEUDER_ISSUE), run_out_subscribers_count])
sum_eligible_subscribers = eligible_subscribers.exclude(address_unknown__gte=settings.ADDRESS_RETURNS).filter( Q(max_datenschleuder_issue__gte=settings.NEXT_DATENSCHLEUDER_ISSUE) | Q(is_endless=True)).count() datenschleuder_stats.append(['Teilsumme Abonnenten\n'+u"\u200B", sum_eligible_subscribers])
datenschleuder_stats.append(['Teilsumme Mitglieder', sum_eligible_members]) datenschleuder_stats.append(['Teilsumme Abonnenten', sum_eligible_subscribers]) datenschleuder_stats.append(['Postempfänger gesamt\n'+u"\u200B", sum_eligible_subscribers + sum_eligible_members])
datenschleuder_stats.append(['Postempfänger', sum_eligible_subscribers + sum_eligible_members]) sum_erfa_issues = (Erfa.objects.all().count() - 1) * 30 # "Alien" als Erfa abziehen datenschleuder_stats.append(['30 Stück je Erfa', sum_erfa_issues]) datenschleuder_stats.append( ['Druckexemplare gesamt', sum_eligible_subscribers + sum_eligible_members + sum_erfa_issues])
return datenschleuder_stats
def _format_erfa_statistics(): context_dict = {'erfa_stats': tabulate(_erfa_statistics(), headers='firstrow'), 'payment_stats': tabulate(_payment_stats(), headers='firstrow', floatfmt=['', '', '', '.2f']), 'datenschleuder_stats': tabulate(_datenschleuder_statistics(), headers='firstrow'), }
template = select_template(['mail_templates/mail_erfa_statistic.html', ]) parsed_template = template.render(context_dict)
with gpg.Context(armor=True) as c: c.set_engine_info(gpg.constants.protocol.OpenPGP, home_dir=GPG_HOME) sender_key = c.get_key(GPG_HOST_USER, secret=True) c.signers = [sender_key]
recipient_key = c.get_key(GPG_MANAGING_DIRECTOR) encrypted_body, _, _ = c.encrypt(parsed_template.encode(), recipients=[sender_key, recipient_key], always_trust=True, sign=True)
send_email = EmailMessage( subject='Monatliche Statistik', body=encrypted_body.decode('utf-8'), from_email=EMAIL_HOST_USER, to=[EMAIL_MANAGING_DIRECTOR, ], bcc=[EMAIL_HOST_USER, ] ) send_email.send(False)
return parsed_template
return HttpResponse('<pre>' + _format_erfa_statistics() + '</pre>')
def exit_members(): """ A daily check to remove all resigned members from the database. Resignation confirmation emails should have been sent already, because this routine touches every row in the database with a membership_end date in the past on every run. This may be inefficient, but simple. :return: """ for member in Member.objects.filter(Q(membership_end__isnull=False) & Q(membership_end__lte=date.today())): member.exit()
def send_delayed_payment_reminders(): for member in Member.objects.filter(is_active=True): if member.is_payment_late(): emails_in_queue = EmailToMember.objects.filter(email_type=EmailToMember.SEND_TYPE_DELAYED_PAYMENT, member=member)
for email in emails_in_queue: email.render_subject_and_body() email.save()
if not emails_in_queue: EmailToMember(email_type=EmailToMember.SEND_TYPE_DELAYED_PAYMENT, member=member).save()
def remove_unread_email_addresses(): """ Removes the primary email address of all members who did not pay their membership fee and had no change in their data set since EMAIL_REMOVE_TIME months as defined in the settings. The assumption is that these members do not react to their primary email address and the next one should be tried. :return: None """ for member in Member.objects.filter(is_active=True): if member.account_balance < 0 and member.last_update < date.today() - relativedelta( months=settings.EMAIL_REMOVE_TIME): primary_email = member.get_emails().first() if primary_email: primary_email.delete() member.save() # save the member to set the last-update date to today
# http://localhost:8000/api/drop_transactions?reallydroptransactions=yes _request = i_request(request) response = {'errorMessage': '', 'wasCleared': False, 'transactionsDropped': 0} if 'reallydroptransactions' in _request and _request['reallydroptransactions'] == 'yes': try: from import_app.models import Transaction all_transactions = Transaction.objects.all() count_transactions = all_transactions.count() all_transactions.delete() response['transactionsDropped'] = count_transactions response['wasCleared'] = True except Exception as ex: response['errorMessage'] = str(ex)
return JsonResponse(response)
""" Mass removal of unreachable members. :param request: Should contain the POST parameter 'chaos_numbers' as a string representing Chaos Numbers separated by newlines :return: A JSON object with a 'success' and an 'error' list of Chaos Numbers """ changes = {'success': [], 'error': []}
cut_off_date = date.today() - relativedelta(months=24)
if request.method == 'POST': lines = request.POST['chaos_numbers'].split('\n') for chaos_number in (line.strip() for line in lines): try: m = Member.objects.get(chaos_number__exact=chaos_number) if m.account_balance < 0 and not m.emailaddress_set.exists() and m.address_unknown >= settings.ADDRESS_RETURNS and m.is_payment_late( cut_off_date=cut_off_date) and m.fee_last_paid < cut_off_date: m.account_balance = 0 m.save() m.emailtomember_set.all().delete() m.exit() changes['success'].append(chaos_number) continue else: raise ValueError('Member is still reachable or paying membership fees') except Exception as e: changes['error'].append('{}: {}'.format(chaos_number, e)) return JsonResponse(changes)
""" Takes a list of Chaos Numbers or Datenschleuder delivery numbers, one per line, and increments these members' address_unknown counter. :param request: Should contain the POST parameter 'chaos_numbers' as a string representing Chaos Numbers or Datenschleuder delivery numbers separated by newlines :return: A JSON object with a 'success' and an 'error' list of Chaos Numbers and Datenschleuder delivery numbers, including error messages for the latter. """ del_num = DeliveryNumber.objects.get(number__exact=input_number.upper()) if del_num.returned: raise ValueError('Delivery number has been processed previously') chaos_number = del_num.recipient.chaos_number del_num.returned = True del_num.save() else:
""" Sends an e-mail to all inactive members asking them to either end their membership or activating again. This method is meant to be removed when no inactive members are left. :return: JSON list of Chaos Numbers and comments of inactive members who were mailed """ response = {'reminded members': []} for member in Member.objects.filter(is_active=False, membership_end=None, erfa__has_doppelmitgliedschaft=False): EmailToMember(email_type=EmailToMember.SEND_TYPE_REACTIVATION_REMINDER, member=member).save() response['reminded members'].append("{}: {}".format(member.chaos_number, member.comment))
return JsonResponse(response)
""" Find all inactive members. :return: Text file with a table holding the full dataset of the members without contact information. """ inactive_members = Member.objects.filter(is_active=False, emailaddress=None).annotate( erfa_name=F("erfa_id__short_name")).annotate(konto=Cast(F("account_balance"), FloatField()) / 100).annotate( address=Concat(F("address_1"), Value(', '), F("address_2"), Value(', '), F("address_3")) )
inactive_members = inactive_members.values_list("chaos_number", "erfa_name", "first_name", "last_name", "address", "address_country", "address_unknown", "is_active", "membership_reduced", "membership_start", "fee_last_paid", "konto", "comment")
headers = ["Cnr", "Erfa", "Vorname", "Nachname", "Adresse", "Land", "Zust.vers.", "aktiv", "ermäßigt", "eingetreten am", "zuletzt gezahlt am", "Guthaben", "Kommentar"] table = tabulate(inactive_members, headers=headers, floatfmt=".2f")
response = HttpResponse(table, content_type='text/plain') response['Content-Disposition'] = 'attachment; filename="unreachable members with overdue fees.txt"' return response
""" Find all members who neither have a known address nor any e-mail address and are overdue with their fees. :return: Text file with a table holding the full dataset of the members without contact information. """ members_no_contact = Member.objects.filter(address_unknown__gte=settings.ADDRESS_RETURNS, emailaddress=None, erfa__has_doppelmitgliedschaft=False).annotate( erfa_name=F("erfa_id__short_name")).annotate(konto=Cast(F("account_balance"), FloatField()) / 100).annotate( address=Concat(F("address_1"), Value(', '), F("address_2"), Value(', '), F("address_3")) )
# Filter for all members who are overdue by the supplied number of months and also did not do any payments during # that period as well. 12 is default. # The trick is to get all those members' chaosnumbers from the database and then filter by these months = int(request.GET.get('months_overdue', default=12)) cut_off_date = date.today() - relativedelta(months=months) overdue_member_cnr = [member.chaos_number for member in members_no_contact if member.is_payment_late(cut_off_date=cut_off_date) and member.fee_last_paid < cut_off_date] members_no_contact = members_no_contact.filter(chaos_number__in=overdue_member_cnr).order_by('chaos_number')
members_no_contact = members_no_contact.values_list("chaos_number", "erfa_name", "first_name", "last_name", "address", "address_country", "is_active", "membership_reduced", "membership_start", "fee_last_paid", "konto", "comment")
headers = ["Cnr", "Erfa", "Vorname", "Nachname", "Adresse", "Land", "aktiv", "ermäßigt", "eingetreten am", "zuletzt gezahlt am", "Guthaben", "Kommentar"] table = tabulate(members_no_contact, headers=headers, floatfmt=".2f")
response = HttpResponse(table, content_type='text/plain') response['Content-Disposition'] = 'attachment; filename="unreachable members with overdue fees.txt"' return response
def render_template(context): """ Selects a template and renders it's subject and body sections.
: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. """ template_paths = ['mail_templates/mail_ga_invitation_html_DE.html']
nodes = dict((n.name, n) for n in select_template(template_paths).template.nodelist if n.__class__.__name__ == 'BlockNode')
try: return nodes['subject'].render(context), nodes['body'].render(context) except KeyError as ke: raise TemplateError('Template is missing a {{% block {} %}}'.format(str(ke)[1:-1]))
def generate_pdf(member): class Message: def __init__(self): self.member = None self.subject = None self.body = None
print('Generating invitation for member {}.'.format(member.chaos_number))
del_num = DeliveryNumber(recipient=member, shipment='MV 2021') del_num.save() # Simple method to bring this number to the template member.this_delivery_number = del_num.number
abst_token = AbstimmungToken(member=member, abstimmung='MV 2021') abst_token.save() # Simple method to bring this token to the template member.this_abstimmung_token = abst_token.token
template_paths = ['deadtree_templates/ga_invite_DE.html']
message = Message() message.member = member message.subject, message.body = render_template( Context({'member': member, 'verein_user': settings.VEREIN_USER, 'verein_pass': settings.VEREIN_PASS}))
html = render_to_string(template_paths, context={'mail': message, 'pa_label': pa_label}) document = HTML(string=html).render() pages = len(document.pages) pdf = document.write_pdf()
return pdf, pages, member.address_country == settings.COUNTRY
pool = Pool() pdfs = pool.map(generate_pdf, Member.objects.members_only().exclude(membership_type=Member.MEMBERSHIP_TYPE_SUPPORTER))
data = {'national': [], 'international': []}
for pdf, pages, is_national in pdfs: if is_national: data['national'].append((pdf, pages)) else: data['international'].append((pdf, pages))
for letters in (l for l in data if len(l) > 0): d_l = data[letters] data[letters] = {} d_l.sort(key=lambda d: d[1]) for pages, documents in groupby(d_l, key=lambda d: d[1]): pages_pdf_buffer = BytesIO() merger = PdfFileMerger()
for doc in documents: pdf_buffer = BytesIO() pdf_buffer.write(doc[0]) pdf_buffer.seek(0)
merger.append(pdf_buffer)
merger.write(pages_pdf_buffer) pages_pdf_buffer.seek(0) data[letters][pages] = pages_pdf_buffer.read()
zip_buffer = BytesIO() zip_file = zipfile.ZipFile(zip_buffer, 'a', zipfile.ZIP_DEFLATED, False)
for destination in data: for pages in data[destination]: zip_file.writestr('{}/{}_pages.pdf'.format(destination, pages), data[destination][pages])
zip_file.close() zip_buffer.seek(0) response = HttpResponse(zip_buffer, content_type='application/zip') response['Content-Disposition'] = 'attachment; filename="ga_invitations.zip"' return response
filled_in_template = render_to_string(html_template, context=context) rendered_html = HTML(string=filled_in_template).render() return rendered_html.write_pdf()
length = max(1, length) return [lst[i:i + length] for i in range(0, len(lst), length)]
""" This method generates the address information as CSV file for all recipients of a Datenschleuder. This is determined by the config setting NEXT_DATENSCHLEUDER_ISSUE and the return value of will_receive_issue() on every Person in the database.
:return: A CSV file containing the Datenschleuder address information for all recipients """
recipients = (person for person in Person.objects.all() if person.will_receive_issue(settings.NEXT_DATENSCHLEUDER_ISSUE))
csv_file = StringIO() writer = csv.writer(csv_file, quoting=csv.QUOTE_MINIMAL, delimiter=',')
for r in recipients: # DeliveryNumbers are stored for checking returned mail later on del_num = DeliveryNumber(recipient=r, shipment='DS{}'.format(settings.NEXT_DATENSCHLEUDER_ISSUE)) del_num.save()
writer.writerow([del_num.number, r.get_name(), r.address_1, r.address_2, r.address_3, r.get_address_country_display().upper()])
csv_file.seek(0) response = HttpResponse(csv_file, content_type='text/csv') csv_file.close()
response['Content-Disposition'] = 'attachment; filename="ds_versand.csv"' return response
""" This method generates the address labels for all recipients of a Datenschleuder. This is determined by the config setting NEXT_DATENSCHLEUDER_ISSUE and the return value of will_receive_issue() on every Person in the database. The resulting PDF will be ready for printing on DIN-A4 label sheets with 3x7 labels. The addresses are sorted by German recipients and rest of the world. Within the German recipients they are sorted by the first two numbers of the postal code as returned by get_plz() from 00 to 99.
:return: A PDF file containing the Datenschleuder address labels """ recipients = (person for person in Person.objects.all() if person.will_receive_issue(settings.NEXT_DATENSCHLEUDER_ISSUE))
germany, world = [], [] for r in recipients: # DeliveryNumbers are stored for checking returned mail later on del_num = DeliveryNumber(recipient=r, shipment='DS{}'.format(settings.NEXT_DATENSCHLEUDER_ISSUE)) del_num.save() # Simple method to bring this number to the template r.this_delivery_number = del_num.number
germany.append(r) if r.address_country == 'DE' else world.append(r)
germany = sorted(germany, key=lambda x: x.get_plz())
plz_regions = [] for plz_region, recipients_in_plz_region in groupby(germany, key=lambda x: x.get_plz()[:2]): plz_regions.append(list(recipients_in_plz_region))
# Calculating some information about the printed magazine for delivery # Weights taken from http://papiergewichtrechner.de/ # Thickness calculation taken from https://print-assistant.de/tools/press/papierstaerke-berechnen/ # Post container size taken from https://shop.deutschepost.de/postbehaelter-typ-2-grau
grams_inner_page = 3.7296 # DIN-A4 at 120g/m² grams_outer_page = 9.3240 # DIN-A4 at 300g/m² grams_envelope = 8.9035 # DIN-C5 envelope, i. e. two pages at 120g/m² grams_issue = ceil((num_pages / 2 - 2) * grams_inner_page + 2 * grams_outer_page + grams_envelope)
# Volume factors provided by Pinguin Druck mm_thickness_120 = 0.14 # 1.2 times volume mm_thickness_300 = 0.312 # 1.04 times volume
# Page 25 on the bottom says maximum weight of 10kg per box: # https://www.direktmarketingcenter.de/fileadmin/Download-Center/DIALOGPOST_National_-_Produktbroschuere_-_Stand_April_2017.pdf # # Information about Postbehälter Typ 2: https://shop.deutschepost.de/postbehaelter-typ-2-grau mm_box_length = 470 grams_box_weight = 1600 grams_box_max_weight = 10000 grams_payload_weight = grams_box_max_weight - grams_box_weight
boxes_per_cart = 20 # And 32 if empty. Asked the Deutsche Post at 0800 999 8888 about it.
# All inner pages + envelope + outer pages mm_thickness_issue = num_pages / 2 * mm_thickness_120 + 2 * mm_thickness_300
max_issues_per_box = min(floor(mm_box_length / mm_thickness_issue), floor(grams_payload_weight / grams_issue))
boxes_nat = [] for plz_region in plz_regions: plz = plz_region[0].get_plz()[:2] for chunk in chunks(plz_region, max_issues_per_box): box = { 'recipients': chunk, 'plz': plz, } boxes_nat.append(box)
boxes_national = len(boxes_nat)
boxes_nat_enriched = [] for num, box in enumerate(boxes_nat):
box['box_num'] = num + 1 box['num_issues'] = len(box['recipients']) box['action_name'] = 'Datenschleuder {} national'.format(settings.NEXT_DATENSCHLEUDER_ISSUE) box['date'] = pickup_date.strftime('%d.%m.%Y') box['total_boxes'] = boxes_national box['cart_num'] = floor(box['box_num'] / boxes_per_cart) + 1
boxes_nat_enriched.append(box)
boxes_int = [] for chunk in chunks(world, max_issues_per_box): box = { 'recipients': chunk, } boxes_int.append(box)
boxes_international = len(boxes_int)
boxes_int_enriched = [] for num, box in enumerate(boxes_int): box['box_num'] = num + 1 box['num_issues'] = len(box['recipients']) box['action_name'] = 'Datenschleuder {} international'.format(settings.NEXT_DATENSCHLEUDER_ISSUE) box['date'] = pickup_date.strftime('%d.%m.%Y') box['total_boxes'] = boxes_international box['cart_num'] = floor(box['box_num'] / boxes_per_cart) + 1
boxes_int_enriched.append(box)
delivery_paper_info = { 'issue': settings.NEXT_DATENSCHLEUDER_ISSUE, 'weight_grams': grams_issue, 'thickness_mm': round(mm_thickness_issue, 1), 'carts': ceil(boxes_national / boxes_per_cart) + ceil(boxes_international / boxes_per_cart), 'action_nat': 'Datenschleuder {} national'.format(settings.NEXT_DATENSCHLEUDER_ISSUE), 'recipients_national': len(germany), 'boxes_total_nat': boxes_national, 'action_int': 'Datenschleuder {} international'.format(settings.NEXT_DATENSCHLEUDER_ISSUE), 'recipients_international': len(world), 'boxes_total_int': boxes_international, }
# Append the world recipients here so they are not part of the box label generation for national recipients, # but are part of the print label generation plz_regions.append(world)
fv_path = os.path.join(os.path.dirname(os.path.realpath(__file__)), "../../static/images/DP_VerkVermerk_DiP_NAT.jpg")
address_labels_pdf = generate_pdf_from_html(html_template='deadtree_templates/ds_addresses_DE.html', context={'plz_regions': plz_regions, 'pa_label': pa_label, 'fv_path': fv_path})
delivery_info_pdf = generate_pdf_from_html(html_template='deadtree_templates/ds_delivery_info.html', context={'delivery_info': delivery_paper_info})
box_labels_nat_pdf = generate_pdf_from_html(html_template='deadtree_templates/ds_box_labels.html', context={'box_labels': boxes_nat_enriched})
box_labels_int_pdf = generate_pdf_from_html(html_template='deadtree_templates/ds_box_labels.html', context={'box_labels': boxes_int_enriched})
zip_buffer = BytesIO() zip_file = zipfile.ZipFile(zip_buffer, 'a', zipfile.ZIP_DEFLATED, False)
zip_file.writestr('Lieferscheininformationen.pdf', delivery_info_pdf) zip_file.writestr('Behälterbeschriftungen national.pdf', box_labels_nat_pdf) zip_file.writestr('Behälterbeschriftungen international.pdf', box_labels_int_pdf) zip_file.writestr('Adressaufkleber.pdf', address_labels_pdf)
zip_file.close() zip_buffer.seek(0) response = HttpResponse(zip_buffer, content_type='application/zip') response['Content-Disposition'] = 'attachment; filename="ds_versand.zip"' return response
""" Send the general assembly invitation as email, or letter if no working email exists. :return: JSON list of Chaos Numbers and comments of members who an email was sent to """
chaos_numbers = []
for member in Member.objects.members_only(): if EmailToMember.objects.filter(email_type=EmailToMember.SEND_TYPE_GA_INVITATION, member=member).exists(): continue EmailToMember(email_type=EmailToMember.SEND_TYPE_GA_INVITATION, member=member).save() print('Sending GA invitation for member {}'.format(member.chaos_number)) chaos_numbers.append(member.chaos_number)
return JsonResponse({'Mails sent to': chaos_numbers}) |