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 ...@@ -8,110 +8,102 @@ from django.urls import reverse
from django.utils import timezone from django.utils import timezone
from django import forms 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.forms import MyModelForm
from memberdb.views import MyUpdateView 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): class MembershipApprovalForm(MyModelForm):
payment_confirm = forms.BooleanField(label='Confirm payment', required=False) payment_confirm = forms.BooleanField(
label = 'Confirm payment',
class Meta: required = False
model = Membership )
fields = ['membership_type', 'payment_method']
widgets = { class Meta:
'membership_type': forms.RadioSelect(), model = Membership
'payment_method': forms.RadioSelect(), 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 __init__(self, *args, **kwargs):
""" super().__init__(*args, **kwargs)
def clean(self):
# get the cleaned data from the form API and do something with it # override the model membership_type field so we display all the options with prices
data = super().clean() self.fields['membership_type'].choices = get_membership_choices()
now = timezone.now()
#breakpoint() def clean(self):
# find a Member matching our current username """
approver = Member.objects.filter(username__exact=self.request.user.username).first() Called to validate the data on the form.
if (approver == None): here we fill out some fields automatically (ie. approver, date paid / approved, etc.)
self.add_error(None, 'Cannot set approver: no Member record with username %s' % self.request.user.username) TODO: deal with account activation/creation, etc.
data['approver'] = approver """
data['approved'] = True data = super().clean()
data['date_approved'] = now now = timezone.now()
approver = self.request.member
if (data['payment_confirm'] == True): if (approver == None):
if (data['payment_method'] == ''): self.add_error(None, 'Cannot set approver: no Member record associated with current session. (username %s)' % self.request.user.username)
self.add_error('payment_method', 'Please select a payment method')
data['date_paid'] = now data['approver'] = approver
else: data['approved'] = True
data['date_paid'] = None data['date_approved'] = now
# make sure "no payment" is recorded for Life Members. if (data['payment_confirm'] == True):
# XXX this might not actually be the case, since some life members may want to also be financial members (ie. for constitutional voting rights) if (data['payment_method'] == ''):
# and so this is probably more annoying than helpful self.add_error('payment_method', 'Please select a payment method')
if (data['membership_type'] == ''): data['date_paid'] = now
if (data['payment_method'] != ''): else:
self.add_error('payment_method', 'Life members shall not pay membership fees!') data['date_paid'] = None
data['payment_method'] = ''
return data
return data
def save(self, commit=True):
""" """ save the data into a Membership object """
do the stuff, approve the things ms = super().save(commit=False)
"""
def save(self, commit=True): # copy attributes not specified in editable form fields
ms = super().save(commit=False) ms.approver = self.cleaned_data['approver']
ms.approved = self.cleaned_data['approved']
# copy attributes not specified in fields ms.date_approved = self.cleaned_data['date_approved']
ms.approver = self.cleaned_data['approver'] ms.date_paid = self.cleaned_data['date_paid']
ms.approved = self.cleaned_data['approved']
ms.date_approved = self.cleaned_data['date_approved'] if (commit):
ms.date_paid = self.cleaned_data['date_paid'] ms.save()
return ms
# do something
if (commit):
ms.save()
return ms
class MembershipApprovalAdminView(MyUpdateView): class MembershipApprovalAdminView(MyUpdateView):
template_name = 'admin/memberdb/membership_approve.html' template_name = 'admin/memberdb/membership_approve.html'
form_class = MembershipApprovalForm form_class = MembershipApprovalForm
model = Membership model = Membership
pk_url_kwarg = 'object_id' pk_url_kwarg = 'object_id'
# override with the instance of ModelAdmin # override with the instance of ModelAdmin
admin = None admin = None
def get_context_data(self, **kwargs): def get_context_data(self, **kwargs):
ms = self.get_object() ms = self.get_object()
context = super().get_context_data(**kwargs) context = super().get_context_data(**kwargs)
context.update(self.admin.admin_site.each_context(self.request)) context.update(self.admin.admin_site.each_context(self.request))
context.update({ context.update({
'opts': self.admin.model._meta, 'opts': self.admin.model._meta,
'ms': ms, 'ms': ms,
'member': ms.member, 'member': ms.member,
'show_member_summary': True, 'show_member_summary': True,
}) })
return context return context
""" """
called when the approval form is submitted and valid data (according to the form's field types and defined validators) is given 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): def form_valid(self, form):
ms = form.save() ms = form.save()
self.admin.message_user(self.request, 'Approve success') self.admin.message_user(self.request, 'Approve success')
url = reverse( url = reverse(
'admin:memberdb_membership_changelist', 'admin:memberdb_membership_changelist',
args=[], args=[],
current_app=self.admin.admin_site.name, current_app=self.admin.admin_site.name,
) )
return HttpResponseRedirect(url) return HttpResponseRedirect(url)
...@@ -100,13 +100,18 @@ def get_membership_type(member): ...@@ -100,13 +100,18 @@ def get_membership_type(member):
return best return best
def make_pending_membership(member): def make_pending_membership(member):
# check if this member already has a pending membership """ creates or updates and returns a pending membership for the given member """
ms = Membership.objects.filter(member=member, approved__exact=False).first() latest = member.get_last_renewal()
if (ms is None): if latest is None or latest.date_submitted.year != timezone.now().year:
ms = Membership(member=member, approved=False) # create a Membership if none exists already for this year
ms.date_submitted = timezone.now() latest = Membership(member=member)
ms.membership_type = get_membership_type(member) latest.membership_type = get_membership_type(member)
return ms
# otherwise update the existing membership and mark as pending
latest.approved = False
latest.date_submitted = timezone.now()
return latest
def make_token(): def make_token():
return get_random_string(128) return get_random_string(128)
...@@ -162,8 +167,6 @@ class Member (IncAssocMember): ...@@ -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. 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 # data to be entered by user and validated (mostly) manually
display_name = models.CharField ('Display name', max_length=200) 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._-]*$')]) 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): ...@@ -182,6 +185,10 @@ class Member (IncAssocMember):
has_account = models.BooleanField ('Has AD account', null=False, editable=False, default=False) 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 # account info
def get_uid(self): def get_uid(self):
result, uid = subprocess.getstatusoutput(["id", "-u", self.username]) result, uid = subprocess.getstatusoutput(["id", "-u", self.username])
...@@ -210,7 +217,7 @@ class Membership (models.Model): ...@@ -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) 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) approved = models.BooleanField ('Membership approved', default=False)
approver = models.ForeignKey (Member, on_delete=models.SET_NULL, null=True, blank=True, related_name='approved_memberships') 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_paid = models.DateTimeField ('Date of payment', blank=True, null=True)
date_approved = models.DateTimeField ('Date approved', blank=True, null=True) date_approved = models.DateTimeField ('Date approved', blank=True, null=True)
...@@ -220,6 +227,9 @@ class Membership (models.Model): ...@@ -220,6 +227,9 @@ class Membership (models.Model):
def get_dispense_item(self): def get_dispense_item(self):
return MEMBERSHIP_TYPES[self.membership_type]['dispense'] return MEMBERSHIP_TYPES[self.membership_type]['dispense']
def get_pretty_type(self):
return MEMBERSHIP_TYPES[self.membership_type]['desc']
class Meta: class Meta:
verbose_name = "Membership renewal record" verbose_name = "Membership renewal record"
ordering = ['approved', '-date_submitted'] ordering = ['approved', '-date_submitted']
......
...@@ -7,6 +7,7 @@ from django.shortcuts import render ...@@ -7,6 +7,7 @@ from django.shortcuts import render
from django.urls import reverse from django.urls import reverse
from django.contrib.auth.mixins import LoginRequiredMixin from django.contrib.auth.mixins import LoginRequiredMixin
from django.utils.safestring import mark_safe from django.utils.safestring import mark_safe
from django.utils import timezone
from django.contrib import messages from django.contrib import messages
from django import forms from django import forms
...@@ -44,7 +45,7 @@ class RegisterRenewForm(MyModelForm): ...@@ -44,7 +45,7 @@ class RegisterRenewForm(MyModelForm):
if (self['email_address'].value() != self['confirm_email'].value()): if (self['email_address'].value() != self['confirm_email'].value()):
self.add_error('email_address', 'Email addresses must match.') 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"]): 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: except:
pass pass
super().clean(); super().clean();
...@@ -57,8 +58,8 @@ class RegisterRenewForm(MyModelForm): ...@@ -57,8 +58,8 @@ class RegisterRenewForm(MyModelForm):
# must save otherwise membership creation will fail # must save otherwise membership creation will fail
m.save() m.save()
# now create a corresponding Membership (marked as pending / not accepted, mostly default values)
ms = make_pending_membership(m) ms = make_pending_membership(m)
ms.membership_type = self.cleaned_data['membership_type']
if (commit): if (commit):
ms.save(); ms.save();
...@@ -112,13 +113,18 @@ class RegisterView(MyUpdateView): ...@@ -112,13 +113,18 @@ class RegisterView(MyUpdateView):
template_name = 'register.html' template_name = 'register.html'
form_class = RegisterForm form_class = RegisterForm
model = Member model = Member
can_create = False
""" def get_context_data(self, **kwargs):
called when valid form data has been POSTed """ update view context with current renewal year """
invalid form data simply redisplays the form with validation errors context = super().get_context_data(**kwargs)
""" context['year'] = timezone.now().year
return context
def form_valid(self, form): 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 # save the member data and get the Member instance
m, ms = form.save() m, ms = form.save()
messages.success(self.request, 'Your registration has been submitted.') messages.success(self.request, 'Your registration has been submitted.')
...@@ -139,29 +145,37 @@ def thanks_view(request, member, ms): ...@@ -139,29 +145,37 @@ def thanks_view(request, member, ms):
} }
return render(request, 'thanks.html', context) return render(request, 'thanks.html', context)
class RenewView(LoginRequiredMixin, MyUpdateView): class RenewView(LoginRequiredMixin, RegisterView):
template_name = 'renew.html' template_name = 'renew.html'
form_class = RenewForm form_class = RenewForm
model = Member model = Member
def get_object(self): 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 u = self.request.user
m = self.request.member
obj = Member.objects.filter(username__exact=u.username).first() if m is None:
if (obj is None): # this member is not in the DB yet - make a new Member object and prefill some data
# make a new Member object and prefill some data m = Member(username=u.username)
obj = Member(username=u.username) m.first_name = u.first_name
obj.first_name = u.first_name m.last_name = u.last_name
obj.last_name = u.last_name m.email_address = u.email
obj.email_address = u.email m.login_token = None # renewing members won't need this, make sure it is null for security
obj.login_token = None # renewing members won't need this return m
return obj
def get_context_data(self, **kwargs): def get_context_data(self, **kwargs):
context = super().get_context_data(**kwargs) context = super().get_context_data(**kwargs)
context.update({ last_renewal = self.object.get_last_renewal()
'is_new': Member.objects.filter(username__exact=self.request.user.username).count() == 0,
}) # 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 return context
def form_valid(self, form): def form_valid(self, form):
......
...@@ -108,24 +108,13 @@ class MemberHomeView(MemberAccessMixin, MyUpdateView): ...@@ -108,24 +108,13 @@ class MemberHomeView(MemberAccessMixin, MyUpdateView):
def get_object(self): def get_object(self):
return self.request.member 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): def get_context_data(self):
d = super().get_context_data() d = super().get_context_data()
m = self.get_object() m = self.get_object()
if m is not None: if m is not None:
# get a list of all the membership records associated with this member # 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({ d.update({
'memberships': ms_list, '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 @@ ...@@ -31,45 +31,7 @@
<h4>Membership renewals on record</h4> <h4>Membership renewals on record</h4>
<!-- stuff from the membership record itself --> <!-- stuff from the membership record itself -->
{% if memberships %} {% if memberships %}
<table class="membership-details"> {% include 'membership_list.html' %}
<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>
{% else %} {% else %}
No membership renewal records for your account exist yet. Please <a href="{% url 'memberdb:renew' %}">renew your membership</a> to get started. No membership renewal records for your account exist yet. Please <a href="{% url 'memberdb:renew' %}">renew your membership</a> to get started.
{% endif %} {% 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 }}