diff --git a/src/gms/settings_local.example.py b/src/gms/settings_local.example.py index b170cb0b13802680f767a08224d75aeded60eb68..e4d608c7812e08d1fe996ae4181f94f481b3c47a 100644 --- a/src/gms/settings_local.example.py +++ b/src/gms/settings_local.example.py @@ -99,8 +99,12 @@ SQUARE_APP_ID = '${SQUARE_APP_ID}' SQUARE_LOCATION = '${SQUARE_LOCATION}' SQUARE_ACCESS_TOKEN = '${SQUARE_SECRET}' +# path to the OpenDispense2 client binary DISPENSE_BIN = '/usr/local/bin/dispense' +# path to the OpenDispense2 logfile +COKELOG_PATH = ROOT_DIR + '/cokelog' + # configure the email backend (see https://docs.djangoproject.com/en/2.1/topics/email/) EMAIL_HOST = "secure.ucc.asn.au" EMAIL_PORT = 465 diff --git a/src/import_members/actions.py b/src/import_members/actions.py index f44000792106a19f946839dba8d42fdd0aee0b08..88487a629a5fda3af0fa805889bf37d6b3595a3a 100644 --- a/src/import_members/actions.py +++ b/src/import_members/actions.py @@ -1,6 +1,7 @@ from django.contrib import messages from django.core.exceptions import ValidationError from django.db import IntegrityError +from django.db.models import Q from .models import OldMember from memberdb.models import Member @@ -22,24 +23,30 @@ def import_old_member(modeladmin, request, queryset): nm.display_name = om.real_name nm.is_guild = om.guild_member nm.phone_number = om.phone_number - nm.id_number = "" + nm.id_number = om.student_no + nm.id_desc = "student" nm.email_address = om.email_address if om.membership_type == 1: # O'day special # O'day special or student - #membership_type = 'oday' nm.is_student = True elif om.membership_type == 2: # student - #membership_type = 'student_and_guild' if nm.is_guild else 'student_only' nm.is_student = True else: # non-student - #membership_type = 'guild_only' if nm.is_guild else 'non_student' nm.is_student = False if (nm.username == '' or nm.username is None): raise ValidationError("username cannot be blank") + + # try to prevent creating duplicate records on import + is_dupe = Q(username=nm.username) | (Q(first_name=nm.first_name) & Q(last_name=nm.last_name)) | Q(id_number=nm.id_number) + dupes = Member.objects.filter(is_dupe) + if (dupes.count() > 0): + raise ValidationError("suspected duplicate member record") + nm.save() num_success += 1 except BaseException as e: + breakpoint() modeladmin.message_user(request, 'Could not import record (%s): %s' % (om, e), level=messages.ERROR) if (num_success > 0): diff --git a/src/memberdb/actions.py b/src/memberdb/actions.py index a6c3e5a0477a20013db1fa92fb1f07f94748ffb9..82b9b8b336cb9419702d53cd3c5b649d93cde89b 100644 --- a/src/memberdb/actions.py +++ b/src/memberdb/actions.py @@ -4,6 +4,9 @@ from functools import wraps, singledispatch from django.db.models import FieldDoesNotExist from django.http import HttpResponse +from django.contrib import messages + +from squarepay.cokelog import try_update_from_dispense def prep_field(obj, field): """ @@ -120,3 +123,21 @@ def _(description): return download_as_csv(modeladmin, request, queryset) wrapped_action.short_description = description return wrapped_action + +def refresh_dispense_payment(modeladmin, request, queryset): + """ update paid status from cokelog, for Membership model """ + num_changed = 0 + membership_list = list(queryset) + for ms in membership_list: + if ms.date_paid is not None: + continue + if try_update_from_dispense(ms): + ms.save() + num_changed += 1 + + if num_changed > 0: + messages.success(request, "Updated %d records" % num_changed) + else: + messages.warning(request, "No records updated") + +refresh_dispense_payment.short_description = "Update payment status from cokelog" \ No newline at end of file diff --git a/src/memberdb/admin.py b/src/memberdb/admin.py index 43e79fedf2123311776a452dbc8bfc4a728c52b6..ab359115c08b92e25de120b0fdd71d89608137be 100644 --- a/src/memberdb/admin.py +++ b/src/memberdb/admin.py @@ -1,3 +1,5 @@ +from datetime import datetime + from django.http import HttpResponse, HttpResponseRedirect from django.shortcuts import render from django.urls import path, reverse @@ -8,17 +10,42 @@ from django.template.loader import render_to_string from gms import admin from memberdb.models import Member, IncAssocMember, Membership -from memberdb.actions import download_as_csv +from memberdb.actions import download_as_csv, refresh_dispense_payment from memberdb.approve import MembershipApprovalForm, MembershipApprovalAdminView from memberdb.account import AccountForm, AccountView def get_model_url(pk, model_name): return reverse('admin:memberdb_%s_change' % model_name, args=[pk]) -""" -helper mixin to make the admin page display only "View" rather than "Change" or "Add" -""" +class MembershipRenewalFilter(admin.SimpleListFilter): + """ allow filtering Member records by renewal year / status """ + """ see https://docs.djangoproject.com/en/2.1/ref/contrib/admin/#django.contrib.admin.ModelAdmin.list_filter """ + title = 'Last renewal' + parameter_name = 'renewed' + + def lookups(self, request, modeladmin): + """ returns a list of tuples """ + # hardcode the starting year, since otherwise you need a fairly complex query + start_year = 2019 + year = datetime.now().year + 1 + return [ (str(x), str(x)) for x in range(start_year, year) ] + [ ('none', 'Never renewed') ] + + def queryset(self, request, queryset): + """ returns the filtered queryset based on the value passed to the request """ + if self.value() is None: + # return the original queryset when parameter not specified + return queryset + + if self.value() == 'none': + # filter by finding a condition that will never be true for reverse relation (child) + # objects that exist, since you can't filter by "has no children" + return queryset.filter(memberships__id__isnull=True) + + # filter via attributes on children / reverse relation (hurrah!) + return queryset.filter(memberships__date_submitted__year=int(self.value())) + class ReadOnlyModelAdmin(admin.ModelAdmin): + """ helper mixin to make the admin page display only "View" rather than "Change" or "Add" """ def has_add_permission(self, request): return False @@ -28,10 +55,10 @@ class ReadOnlyModelAdmin(admin.ModelAdmin): def has_change_permission(self, request, obj=None): return False -""" -Define the administrative interface for viewing member details required under the Incorporations Act -""" class IAMemberAdmin(ReadOnlyModelAdmin): + """ + Define the administrative interface for viewing member details required under the Incorporations Act + """ readonly_fields = ['__str__', 'updated', 'created'] fields = ['first_name', 'last_name', 'email_address', 'updated', 'created'] search_fields = ['first_name', 'last_name', 'email_address'] @@ -52,7 +79,7 @@ class MembershipInline(admin.TabularInline): class MemberAdmin(admin.ModelAdmin): list_display = ['first_name', 'last_name', 'display_name', 'username'] - list_filter = ['is_guild', 'is_student'] + list_filter = ['is_guild', 'is_student', MembershipRenewalFilter] readonly_fields = ['member_updated', 'updated', 'created'] search_fields = list_display actions = [download_as_csv] @@ -66,14 +93,11 @@ class MemberAdmin(admin.ModelAdmin): ] return custom_urls + urls - - # add a "go to member" URL into the template context data def change_view(self, request, object_id, form_url='', extra_context={}): extra_context['incassocmember_url'] = get_model_url(object_id, 'incassocmember') return super().change_view(request, object_id, form_url, extra_context=extra_context) - def process_account(self, request, *args, **kwargs): inst = Member.objects.get(pk=kwargs['object_id']) model_dict = { @@ -82,17 +106,16 @@ class MemberAdmin(admin.ModelAdmin): } return AccountView.as_view(object=inst,admin=self)(request, *args, **kwargs) - - -""" -Define the admin page for viewing normal Member records (all details included) and approving them -""" class MembershipAdmin(admin.ModelAdmin): + """ + Define the admin page for viewing normal Member records (all details included) and approving them + """ list_display = ['membership_info', 'membership_type', 'payment_method', 'approved', 'date_submitted', 'member_actions', ] list_display_links = None list_filter = ['approved'] readonly_fields = ['date_submitted'] radio_fields = {'payment_method': admin.VERTICAL, 'membership_type': admin.VERTICAL} + actions = [refresh_dispense_payment] # make the admin page queryset preload the parent records (Member) def get_queryset(self, request): @@ -143,10 +166,11 @@ class MembershipAdmin(admin.ModelAdmin): def process_approve(self, request, *args, **kwargs): return MembershipApprovalAdminView.as_view(admin=self)(request, *args, **kwargs) - """ -Register multiple ModelAdmins per model. See https://stackoverflow.com/questions/2223375/multiple-modeladmins-views-for-same-model-in-django-admin/2228821 -""" class ProxyMembership(Membership): + """ + Register multiple ModelAdmins per model. + See https://stackoverflow.com/questions/2223375/multiple-modeladmins-views-for-same-model-in-django-admin/2228821 + """ class Meta: proxy = True diff --git a/src/memberdb/has_paid_dispense.sh b/src/memberdb/has_paid_dispense.sh deleted file mode 100755 index a2c2eb890013b68b4b2c3b18bd13809955e5966b..0000000000000000000000000000000000000000 --- a/src/memberdb/has_paid_dispense.sh +++ /dev/null @@ -1,16 +0,0 @@ -#!/bin/bash -# script to check if a user has already paid via dispense (or purchased an item in dispense) -# usage: $0 "$USERNAME" "$DISPENSE_ITEM_ID" -# prints either a date like "Feb 25 17:25:23" or "None" - -LOG=/home/other/coke/cokelog -USER=$1 -ITEM=$2 - -PURCHASE=$(grep "for $USER" $LOG | grep ": dispense '" | grep "$ITEM") -if [ "x$PURCHASE" == "x" ] || [ $(echo $PURCHASE | wc -l) -gt 1 ]; then - echo None - exit 1 -fi - -echo $PURCHASE | cut -c1-15 diff --git a/src/memberdb/register.py b/src/memberdb/register.py index 68e3d343ce3e0b40576b37e9f2d9d2ebc3380155..0bceb6aee7a3e57051656bc9b46c6bf19368d5e7 100644 --- a/src/memberdb/register.py +++ b/src/memberdb/register.py @@ -1,6 +1,10 @@ """ This file implements the member-facing registration workflow. See ../../README.md """ +import subprocess +from subprocess import CalledProcessError, TimeoutExpired +from os import path +from datetime import datetime from django.http import HttpResponseRedirect from django.shortcuts import render @@ -10,6 +14,7 @@ from django.utils.safestring import mark_safe from django.utils import timezone from django.contrib import messages from django import forms +from django.conf import settings from squarepay.models import MembershipPayment from squarepay.dispense import get_item_price @@ -55,6 +60,8 @@ class RegisterRenewForm(MyModelForm): m = super().save(commit=False) if (m.display_name == ""): m.display_name = "%s %s" % (m.first_name, m.last_name); + m.has_account = m.get_uid() != None + # must save otherwise membership creation will fail m.save() @@ -100,7 +107,7 @@ class RenewForm(RegisterRenewForm): def save(self, commit=True): m, ms = super().save(commit=False) m.username = self.request.user.username - m.has_account = m.get_uid() != None + if (commit): m.save() ms.save() diff --git a/src/squarepay/cokelog.py b/src/squarepay/cokelog.py new file mode 100644 index 0000000000000000000000000000000000000000..e25acf45186e3d5fd3c6ae0c5c567c1324fc11fc --- /dev/null +++ b/src/squarepay/cokelog.py @@ -0,0 +1,116 @@ +""" functions to deal with the dispense cokelog, primarily to parse and determine usernames and membership type of paid members """ +import re +from datetime import datetime +from django.conf import settings + +from .payments import log + +COKELOG = getattr(settings, "COKELOG_PATH", None) +if COKELOG is None: + log.warning("COKELOG_PATH is not defined, cannot sync payment status from dispense to DB") + +ALL_REGEX = r"^(?P<date>[A-Za-z]{3}\s+\d+\s[\d:]{8})\s(\w+)\sodispense2:\sdispense '([^']+)' \((?P<item>(coke|pseudo|snack|door):(\d{1,3}))\) for (?P<for>\w+) by (?P<by>\w+) \[cost\s+(\d+), balance\s+(\d+)\]$" +MEMBERSHIP_REGEX = r"^(?P<date>[A-Za-z]{3}\s+\d+\s[\d:]{8})\s(\w+)\sodispense2:\sdispense '([^']+)' \((?P<item>(pseudo):(\d{1,3}))\) for (?P<for>\w+) by (?P<by>\w+) \[cost\s+(\d+), balance\s+(\d+)\]$" + +class CokeLog: + regex = ALL_REGEX + + # dictionary (keyed by username) of lists of dispense records (by-user, and date) + dispenses = {} + filename = COKELOG + file = None + last_offset = 0 + + def __init__(self, **kwargs): + if "filename" in kwargs: + self.filename = kwargs.pop("filename") + if "regex" in kwargs: + self.regex = kwargs.pop("regex") + + def is_loaded(self): + return self.file is not None + + def open(self): + """ loads the cokelog, trying to avoid parsing the whole thing again """ + if not self.is_loaded(): + # open the logfile if we haven't already + self.file = open(self.filename, 'r') + self.parse() + + def reload(self): + if not self.is_loaded(): + return None + self.parse() + + def parse(self): + """ read the cokelog, starting where we left off """ + pat = re.compile(self.regex) + year = datetime.now().year + + # set file offset + self.file.seek(self.last_offset) + + while True: + line = self.file.readline() + + if line == '': + # EOF + break + + m = pat.match(line) + if m is not None: + data = { + 'by': m.group("by"), + 'date': datetime.strptime(m.group("date"), "%b %d %H:%M:%S").replace(year=year), + 'item': m.group("item"), + } + user = m.group("for") + + if user in self.dispenses: + self.dispenses[user] += [data] + else: + self.dispenses[user] = [data] + #log.debug("got dispense item for user %s, item %s on date %s" % (user, data['item'], data['date'])) + # remember the latest file offset + self.last_offset = self.file.tell() + + def get_last_dispense(self, username, item_code=None, dispense_by=None): + if self.dispenses is None: + return None + + for r in reversed(self.dispenses[username]): + if item_code is not None and r["item"] != item_code: + continue + if dispense_by is not None and r["by"] != dispense_by: + continue + return r["date"] + return None + +# create a "static" instance of cokelog +member_cokelog = CokeLog(regex=MEMBERSHIP_REGEX) + +def try_update_from_dispense(membership): + """ + updates the membership with payment details from dispense, if found + Note: this WILL overwrite any existing payment information + """ + + # check if anything has happened since last time + if member_cokelog.is_loaded(): + member_cokelog.reload() + else: + member_cokelog.open() + + # look for entries like "dispense 'membership ...' (pseudo:) for <user> by <user> ..." + ms_disp = member_cokelog.get_last_dispense( + membership.member.username, + membership.get_dispense_item(), + None, + ) + + if ms_disp is not None: + membership.date_paid = ms_disp + membership.payment_method = 'dispense' + return True + return False + \ No newline at end of file diff --git a/src/squarepay/tests.py b/src/squarepay/tests.py index 7ce503c2dd97ba78597f6ff6e4393132753573f6..be54a14313af3b9a80128b971afff3eefcd9e724 100644 --- a/src/squarepay/tests.py +++ b/src/squarepay/tests.py @@ -1,3 +1,29 @@ from django.test import TestCase +from django.conf import settings +from datetime import datetime -# Create your tests here. +from .cokelog import CokeLog + +class ParseCokeLog(TestCase): + def test_parse_cokelog(self): + fn = getattr(settings, 'COKELOG_PATH', None) + if fn is None: + return + + cokelog = CokeLog() + self.assertFalse(cokelog.is_loaded()) + cokelog.open() + self.assertTrue(cokelog.is_loaded()) + + self.assertIsInstance(cokelog.dispenses, dict) + for key, val in cokelog.dispenses.items(): + self.assertIsInstance(val, (list, tuple)) + for record in val: + self.assertIsInstance(record, dict) + self.assertTrue('by' in record) + self.assertTrue('item' in record) + self.assertIsInstance(record['date'], datetime) + + n = len(cokelog.dispenses) + cokelog.reload() + self.assertTrue(n == len(cokelog.dispenses))