Commit 81cc295d authored by frekk's avatar frekk

renewals: update existing member details without creating multiple membership records per year

parent 9e9387fb
......@@ -8,110 +8,102 @@ from django.urls import reverse
from django.utils import timezone
from django import forms
from memberdb.models import Member, Membership, get_membership_type
from memberdb.models import Member, Membership, get_membership_choices
from memberdb.forms import MyModelForm
from memberdb.views import MyUpdateView
"""
inline admin change list action buttons
see https://medium.com/@hakibenita/how-to-add-custom-action-buttons-to-django-admin-8d266f5b0d41
and have a look at .admin.MembershipAdmin
"""
class MembershipApprovalForm(MyModelForm):
payment_confirm = forms.BooleanField(label='Confirm payment', required=False)
class Meta:
model = Membership
fields = ['membership_type', 'payment_method']
widgets = {
'membership_type': forms.RadioSelect(),
'payment_method': forms.RadioSelect(),
}
"""
Called to validate the data on the form.
here we fill out some fields automatically (ie. approver, date paid / approved, etc.)
TODO: deal with account activation/creation, etc.
"""
def clean(self):
# get the cleaned data from the form API and do something with it
data = super().clean()
now = timezone.now()
#breakpoint()
# find a Member matching our current username
approver = Member.objects.filter(username__exact=self.request.user.username).first()
if (approver == None):
self.add_error(None, 'Cannot set approver: no Member record with username %s' % self.request.user.username)
data['approver'] = approver
data['approved'] = True
data['date_approved'] = now
if (data['payment_confirm'] == True):
if (data['payment_method'] == ''):
self.add_error('payment_method', 'Please select a payment method')
data['date_paid'] = now
else:
data['date_paid'] = None
# make sure "no payment" is recorded for Life Members.
# XXX this might not actually be the case, since some life members may want to also be financial members (ie. for constitutional voting rights)
# and so this is probably more annoying than helpful
if (data['membership_type'] == ''):
if (data['payment_method'] != ''):
self.add_error('payment_method', 'Life members shall not pay membership fees!')
data['payment_method'] = ''
return data
"""
do the stuff, approve the things
"""
def save(self, commit=True):
ms = super().save(commit=False)
# copy attributes not specified in fields
ms.approver = self.cleaned_data['approver']
ms.approved = self.cleaned_data['approved']
ms.date_approved = self.cleaned_data['date_approved']
ms.date_paid = self.cleaned_data['date_paid']
# do something
if (commit):
ms.save()
return ms
payment_confirm = forms.BooleanField(
label = 'Confirm payment',
required = False
)
class Meta:
model = Membership
fields = ['membership_type', 'payment_method']
widgets = {
'membership_type': forms.RadioSelect(),
'payment_method': forms.RadioSelect(),
}
def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
# override the model membership_type field so we display all the options with prices
self.fields['membership_type'].choices = get_membership_choices()
def clean(self):
"""
Called to validate the data on the form.
here we fill out some fields automatically (ie. approver, date paid / approved, etc.)
TODO: deal with account activation/creation, etc.
"""
data = super().clean()
now = timezone.now()
approver = self.request.member
if (approver == None):
self.add_error(None, 'Cannot set approver: no Member record associated with current session. (username %s)' % self.request.user.username)
data['approver'] = approver
data['approved'] = True
data['date_approved'] = now
if (data['payment_confirm'] == True):
if (data['payment_method'] == ''):
self.add_error('payment_method', 'Please select a payment method')
data['date_paid'] = now
else:
data['date_paid'] = None
return data
def save(self, commit=True):
""" save the data into a Membership object """
ms = super().save(commit=False)
# copy attributes not specified in editable form fields
ms.approver = self.cleaned_data['approver']
ms.approved = self.cleaned_data['approved']
ms.date_approved = self.cleaned_data['date_approved']
ms.date_paid = self.cleaned_data['date_paid']
if (commit):
ms.save()
return ms
class MembershipApprovalAdminView(MyUpdateView):
template_name = 'admin/memberdb/membership_approve.html'
form_class = MembershipApprovalForm
model = Membership
pk_url_kwarg = 'object_id'
# override with the instance of ModelAdmin
admin = None
def get_context_data(self, **kwargs):
ms = self.get_object()
context = super().get_context_data(**kwargs)
context.update(self.admin.admin_site.each_context(self.request))
context.update({
'opts': self.admin.model._meta,
'ms': ms,
'member': ms.member,
'show_member_summary': True,
})
return context
"""
called when the approval form is submitted and valid data (according to the form's field types and defined validators) is given
"""
def form_valid(self, form):
ms = form.save()
self.admin.message_user(self.request, 'Approve success')
url = reverse(
'admin:memberdb_membership_changelist',
args=[],
current_app=self.admin.admin_site.name,
)
return HttpResponseRedirect(url)
template_name = 'admin/memberdb/membership_approve.html'
form_class = MembershipApprovalForm
model = Membership
pk_url_kwarg = 'object_id'
# override with the instance of ModelAdmin
admin = None
def get_context_data(self, **kwargs):
ms = self.get_object()
context = super().get_context_data(**kwargs)
context.update(self.admin.admin_site.each_context(self.request))
context.update({
'opts': self.admin.model._meta,
'ms': ms,
'member': ms.member,
'show_member_summary': True,
})
return context
"""
called when the approval form is submitted and valid data (according to the form's field types and defined validators) is given
"""
def form_valid(self, form):
ms = form.save()
self.admin.message_user(self.request, 'Approve success')
url = reverse(
'admin:memberdb_membership_changelist',
args=[],
current_app=self.admin.admin_site.name,
)
return HttpResponseRedirect(url)
......@@ -100,13 +100,18 @@ def get_membership_type(member):
return best
def make_pending_membership(member):
# check if this member already has a pending membership
ms = Membership.objects.filter(member=member, approved__exact=False).first()
if (ms is None):
ms = Membership(member=member, approved=False)
ms.date_submitted = timezone.now()
ms.membership_type = get_membership_type(member)
return ms
""" creates or updates and returns a pending membership for the given member """
latest = member.get_last_renewal()
if latest is None or latest.date_submitted.year != timezone.now().year:
# create a Membership if none exists already for this year
latest = Membership(member=member)
latest.membership_type = get_membership_type(member)
# otherwise update the existing membership and mark as pending
latest.approved = False
latest.date_submitted = timezone.now()
return latest
def make_token():
return get_random_string(128)
......@@ -162,8 +167,6 @@ class Member (IncAssocMember):
Note: Privacy laws are a thing, unless people allow it then we cannot provide this info to members.
"""
# data to be entered by user and validated (mostly) manually
display_name = models.CharField ('Display name', max_length=200)
username = models.SlugField ('Username', max_length=32, null=True, blank=True, unique=False, validators=[RegexValidator(regex='^[a-z0-9._-]*$')])
......@@ -182,6 +185,10 @@ class Member (IncAssocMember):
has_account = models.BooleanField ('Has AD account', null=False, editable=False, default=False)
def get_last_renewal(self):
""" returns the most recently submitted Membership object """
return self.memberships.order_by('-date_submitted').first()
# account info
def get_uid(self):
result, uid = subprocess.getstatusoutput(["id", "-u", self.username])
......@@ -210,7 +217,7 @@ class Membership (models.Model):
payment_method = models.CharField ('Payment method', max_length=10, blank=True, null=True, choices=PAYMENT_METHODS, default=None)
approved = models.BooleanField ('Membership approved', default=False)
approver = models.ForeignKey (Member, on_delete=models.SET_NULL, null=True, blank=True, related_name='approved_memberships')
date_submitted = models.DateTimeField ('Date signed up')
date_submitted = models.DateTimeField ('Date signed up', default=timezone.now)
date_paid = models.DateTimeField ('Date of payment', blank=True, null=True)
date_approved = models.DateTimeField ('Date approved', blank=True, null=True)
......@@ -220,6 +227,9 @@ class Membership (models.Model):
def get_dispense_item(self):
return MEMBERSHIP_TYPES[self.membership_type]['dispense']
def get_pretty_type(self):
return MEMBERSHIP_TYPES[self.membership_type]['desc']
class Meta:
verbose_name = "Membership renewal record"
ordering = ['approved', '-date_submitted']
......
......@@ -7,6 +7,7 @@ from django.shortcuts import render
from django.urls import reverse
from django.contrib.auth.mixins import LoginRequiredMixin
from django.utils.safestring import mark_safe
from django.utils import timezone
from django.contrib import messages
from django import forms
......@@ -44,7 +45,7 @@ class RegisterRenewForm(MyModelForm):
if (self['email_address'].value() != self['confirm_email'].value()):
self.add_error('email_address', 'Email addresses must match.')
if (self['email_address'].value().lower().split('@')[1] in ["ucc.asn.au", "ucc.gu.uwa.edu.au"]):
self.add_error('email_address', 'Contact address cannot be an UCC address.')
self.add_error('email_address', 'Contact address cannot be an UCC address.')
except:
pass
super().clean();
......@@ -57,8 +58,8 @@ class RegisterRenewForm(MyModelForm):
# must save otherwise membership creation will fail
m.save()
# now create a corresponding Membership (marked as pending / not accepted, mostly default values)
ms = make_pending_membership(m)
ms.membership_type = self.cleaned_data['membership_type']
if (commit):
ms.save();
......@@ -112,13 +113,18 @@ class RegisterView(MyUpdateView):
template_name = 'register.html'
form_class = RegisterForm
model = Member
can_create = False
"""
called when valid form data has been POSTed
invalid form data simply redisplays the form with validation errors
"""
def get_context_data(self, **kwargs):
""" update view context with current renewal year """
context = super().get_context_data(**kwargs)
context['year'] = timezone.now().year
return context
def form_valid(self, form):
"""
called when valid form data has been POSTed
invalid form data simply redisplays the form with validation errors
"""
# save the member data and get the Member instance
m, ms = form.save()
messages.success(self.request, 'Your registration has been submitted.')
......@@ -139,29 +145,37 @@ def thanks_view(request, member, ms):
}
return render(request, 'thanks.html', context)
class RenewView(LoginRequiredMixin, MyUpdateView):
class RenewView(LoginRequiredMixin, RegisterView):
template_name = 'renew.html'
form_class = RenewForm
model = Member
def get_object(self):
""" try to get a pending renewal for this year (to edit & resubmit) otherwise create a new one """
u = self.request.user
m = self.request.member
obj = Member.objects.filter(username__exact=u.username).first()
if (obj is None):
# make a new Member object and prefill some data
obj = Member(username=u.username)
obj.first_name = u.first_name
obj.last_name = u.last_name
obj.email_address = u.email
obj.login_token = None # renewing members won't need this
return obj
if m is None:
# this member is not in the DB yet - make a new Member object and prefill some data
m = Member(username=u.username)
m.first_name = u.first_name
m.last_name = u.last_name
m.email_address = u.email
m.login_token = None # renewing members won't need this, make sure it is null for security
return m
def get_context_data(self, **kwargs):
context = super().get_context_data(**kwargs)
context.update({
'is_new': Member.objects.filter(username__exact=self.request.user.username).count() == 0,
})
last_renewal = self.object.get_last_renewal()
# renew.html says whether a record exists in the DB or not
context['is_new'] = self.request.member is None
# let the template check if user has already renewed this year so it displays a warning
if last_renewal is not None:
context['last_renewal'] = last_renewal.date_submitted.year
context['memberships'] = [last_renewal]
return context
def form_valid(self, form):
......
......@@ -108,24 +108,13 @@ class MemberHomeView(MemberAccessMixin, MyUpdateView):
def get_object(self):
return self.request.member
def get_membership_context(self, ms):
""" gets the per-membership-record context data """
return {
'id': ms.id,
'type': MEMBERSHIP_TYPES[ms.membership_type]['desc'],
'submitted': ms.date_submitted.strftime('%Y-%m-%d %H:%M'),
'paid': ms.date_paid.strftime('%Y-%m-%d %H:%M') if ms.date_paid is not None else None,
'approved': ms.date_approved.strftime('%Y-%m-%d %H:%M') if ms.approved else None,
'is_approved': ms.approved,
}
def get_context_data(self):
d = super().get_context_data()
m = self.get_object()
if m is not None:
# get a list of all the membership records associated with this member
ms_list = [ self.get_membership_context(ms) for ms in m.memberships.all() ]
ms_list = m.memberships.all()
d.update({
'memberships': ms_list,
})
......
"""
this file contains utilities for wrapping the opendispense2 CLI utility `dispense`
It is essentially a hack to avoid having to write an actual dispense client here.
"""
import subprocess
from subprocess import CalledProcessError, TimeoutExpired
from django.conf import settings
from .payments import log
DISPENSE_BIN = getattr(settings, 'DISPENSE_BIN', None)
if DISPENSE_BIN is None:
log.warning("DISPENSE_BIN is not defined! Lookups for prices will fallback to weird numbers (for testing)!")
def run_dispense(*args):
<<<<<<< HEAD
if DISPENSE_BIN is None:
return None
cmd = [DISPENSE_BIN] + args
log.info("run_dispense: " + cmd)
try:
# get a string containing the output of the program
res = subprocess.check_output(cmd, timeout=4, universal_newlines=True)
except CalledProcessError as e:
log.warning("dispense returned error code %d, output: '%s'" % (e.returncode, e.output))
return None
except TimeoutExpired as e:
log.error(e)
return None
return res
def get_item_price(itemid):
""" gets the price of the given dispense item in cents """
if (itemid is None or itemid == ""):
return None
if DISPENSE_BIN is None:
return 2223
out = run_dispense('iteminfo', itemid)
if out is None:
return None
s = out.split() # get something like ['pseudo:7', '25.00', 'membership', '(non-student', 'and', 'non-guild)']
if (s[0] != itemid):
log.warning("get_item_price: got result for incorrect item: %s" + s)
return None
else:
# return the price as a number of cents
return int(float(s[0]) * 100)
def set_dispense_flag(user, flag, reason):
if DISPENSE_BIN is None:
log.warning("DISPENSE_BIN is not defined, user will not be disabled")
return False
cmd = [DISPENSE_BIN] + args
out = run_dispense('user', 'type', user, flag, reason)
s = out.split()
if s[2] != "updated":
# user was not updated
log.warning("set_dispense_flag: user was not updated with error: " + out)
return False;
return True;
=======
if DISPENSE_BIN is None:
return None
cmd = (DISPENSE_BIN, ) + args
log.info("run_dispense: " + str(cmd))
try:
# get a string containing the output of the program
res = subprocess.check_output(cmd, timeout=4, universal_newlines=True)
except CalledProcessError as e:
log.warning("dispense returned error code %d, output: '%s'" % (e.returncode, e.output))
return None
except TimeoutExpired as e:
log.error(e)
return None
return res
def get_item_price(itemid):
""" gets the price of the given dispense item in cents """
if (itemid is None or itemid == ""):
return None
if DISPENSE_BIN is None:
return 2223
out = run_dispense('iteminfo', itemid)
if out is None:
return None
s = out.split() # get something like ['pseudo:7', '25.00', 'membership', '(non-student', 'and', 'non-guild)']
if (s[0] != itemid):
log.warning("get_item_price: got result for incorrect item: %s" + s)
return None
else:
# return the price as a number of cents
return int(float(s[1]) * 100)
>>>>>>> origin/frekk-testing
......@@ -31,45 +31,7 @@
<h4>Membership renewals on record</h4>
<!-- stuff from the membership record itself -->
{% if memberships %}
<table class="membership-details">
<tr class="{% cycle 'row1' 'row2' as rcl %}">
<th>Membership type</th>
<th>Submitted</th>
<th>Paid</th>
<th>Approved</th>
<th>Actions</th>
</tr>
{% for ms in memberships %}
<tr class="{% cycle rcl %}">
<td>
{{ ms.type }}
</td>
<td>
{{ ms.submitted }}
</td>
<td>
{% if ms.paid %}
<img src="{% static 'admin/img/icon-yes.svg' %}" alt="yes">&nbsp; {{ ms.paid }}
{% else %}
<img src="{% static 'admin/img/icon-no.svg' %}" alt="no">&nbsp; Not paid yet
{% endif %}
</td>
<td>
{% if ms.approved and ms.is_approved %}
<img src="{% static 'admin/img/icon-yes.svg' %}" alt="yes">&nbsp; {{ ms.approved }}
{% elif ms.approved and not ms.is_approved %}
<img src="{% static 'admin/img/icon-no.svg' %}" alt="no">&nbsp; Rejected: {{ ms.approved }}
{% else %}
<img src="{% static 'admin/img/icon-unknown.svg' %}" alt="?">&nbsp; Not approved yet
{% endif %}
</td>
<td>
<!-- membership actions -->
{% if not ms.paid %}<a class="button" href="{% url 'squarepay:pay_membership' ms.id %}">Pay now</a>{% endif %}
</td>
</tr>
{% endfor %}
</table>
{% include 'membership_list.html' %}
{% else %}
No membership renewal records for your account exist yet. Please <a href="{% url 'memberdb:renew' %}">renew your membership</a> to get started.
{% endif %}
......
{% load static %}
<table class="membership-details">
<tr class="{% cycle 'row1' 'row2' as rcl %}">
<th>Membership type</th>
<th>Submitted / Updated</th>
<th>Paid</th>
<th>Approved</th>
<th>Actions</th>
</tr>
{% for ms in memberships %}
<tr class="{% cycle rcl %}">
<td>
{{ ms.get_pretty_type }}
</td>
<td>
{{ ms.date_submitted |date:"Y-m-d H:i" }}
</td>
<td>
{% if ms.date_paid %}
<img src="{% static 'admin/img/icon-yes.svg' %}" alt="yes">&nbsp; {{ ms.date_paid |date:"Y-m-d H:i" }}
{% else %}
<img src="{% static 'admin/img/icon-no.svg' %}" alt="no">&nbsp; Not paid yet
{% endif %}
</td>
<td>
{% if ms.date_approved and ms.approved %}
<img src="{% static 'admin/img/icon-yes.svg' %}" alt="yes">&nbsp; {{ ms.date_approved |date:"Y-m-d H:i" }}
{% elif ms.date_approved and not ms.approved %}
<img src="{% static 'admin/img/icon-no.svg' %}" alt="no">&nbsp; Rejected: {{ ms.date_approved |date:"Y-m-d H:i" }}
{% else %}
<img src="{% static 'admin/img/icon-unknown.svg' %}" alt="?">&nbsp; Not approved yet
{% endif %}
</td>
<td>
<!-- membership actions -->
{% if not ms.date_paid %}
<a class="button" href="{% url 'squarepay:pay_membership' ms.id %}">Pay now</a>
{% else %}
-
{% endif %}
</td>
</tr>
{% endfor %}
</table>
......@@ -2,7 +2,7 @@
{% block title %}UCC Registration{% endblock %}
{% block content_title %}<h1>Register as a new member</h1>{% endblock %}
{% block content_title %}<h1>Register as a new member for {{ year }}</h1>{% endblock %}
{% block tips %}
Enter your details, and press "Register" when you are done.<br>
......
{% extends "base_form.html" %}
{% block title %}UCC Membership Renewal{% endblock %}
{% block content_title %}
<h1>Renew your membership</h1>
<h1>Renew your membership for {{ year }}</h1>
{% endblock %}
{% block tips %}
......@@ -9,17 +9,26 @@
<b>Your account exists already but no membership information has yet been recorded in this system.</b>
<br>Please update/correct the details below as necessary. Some have probably been filled for you.
{% else %}
Please confirm that the details below have not changed since your last registration or membership renewal.
{% if last_renewal == year %}
You have already submitted a membership renewal for {{ year }}. You may update your details below if they have changed, although any changes you make will need to be approved.
{% else %}
Please confirm that the details below have not changed since your last registration or membership renewal.
{% endif %}
{% endif %}
{% endblock %}
{% block extra_preform %}
{% if memberships and last_renewal == year %}