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/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..142d0a425e3efe7308979d7f7632efbd2965c171 100644 --- a/src/memberdb/admin.py +++ b/src/memberdb/admin.py @@ -8,17 +8,15 @@ 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 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 +26,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'] @@ -66,14 +64,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 +77,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 +137,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/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