Commit 340b7b39 authored by frekk's avatar frekk

Merge branch 'frekk-testing' into 'master'

Bug fixes & features

See merge request frekk/uccportal!7
parents bf5def24 8aab44da
......@@ -99,8 +99,12 @@ SQUARE_APP_ID = '${SQUARE_APP_ID}'
# 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
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")
num_success += 1
except BaseException as e:
modeladmin.message_user(request, 'Could not import record (%s): %s' % (om, e), level=messages.ERROR)
if (num_success > 0):
......@@ -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:
if try_update_from_dispense(ms):
num_changed += 1
if num_changed > 0:
messages.success(request, "Updated %d records" % num_changed)
messages.warning(request, "No records updated")
refresh_dispense_payment.short_description = "Update payment status from cokelog"
\ No newline at end of file
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 """
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 = + 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
class ProxyMembership(Membership):
Register multiple ModelAdmins per model.
class Meta:
proxy = True
# script to check if a user has already paid via dispense (or purchased an item in dispense)
# prints either a date like "Feb 25 17:25:23" or "None"
PURCHASE=$(grep "for $USER" $LOG | grep ": dispense '" | grep "$ITEM")
if [ "x$PURCHASE" == "x" ] || [ $(echo $PURCHASE | wc -l) -gt 1 ]; then
echo None
exit 1
echo $PURCHASE | cut -c1-15
This file implements the member-facing registration workflow. See ../../
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
......@@ -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):
""" 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')
def reload(self):
if not self.is_loaded():
return None
def parse(self):
""" read the cokelog, starting where we left off """
pat = re.compile(self.regex)
year =
# set file offset
while True:
line = self.file.readline()
if line == '':
m = pat.match(line)
if m is not None:
data = {
'date': datetime.strptime("date"), "%b %d %H:%M:%S").replace(year=year),
user ="for")
if user in self.dispenses:
self.dispenses[user] += [data]
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:
if dispense_by is not None and r["by"] != dispense_by:
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():
# look for entries like "dispense 'membership ...' (pseudo:) for <user> by <user> ..."
ms_disp = member_cokelog.get_last_dispense(
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
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:
cokelog = CokeLog()
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)
self.assertTrue(n == len(cokelog.dispenses))
Markdown is supported
0% or .
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment