diff --git a/README.md b/README.md index 162d4fcb4b03e594f10adf79af019e3009880110..4cc8eea0326b5f6bc4b8eab548d18bbb67f36349 100644 --- a/README.md +++ b/README.md @@ -49,19 +49,18 @@ Workflow Design Environment Setup <a name="envsetup"></a> ----------------- -- This project uses Python 3.7 -- Install `python-virtualenv` +- This project uses Python version >= 3.5 +- Install packages `apt-get install python3-virtualenv python3-dev build-essential libldap2-dev libsasl2-dev sqlite3` - `git clone https://gitlab.ucc.asn.au/frekk/uccportal uccportal` - `cd uccportal` - `virtualenv env` - Every time you want to do some uccportal development, do `source env/bin/activate` to set up your environment -- Install packages needed by pip to build python dependencies: `apt-get install build-essential libldap2-dev libsasl2-dev` - Install python dependencies to local environment: `pip install -r pip-packages.txt` -- Configure django: `cp gms/gms/settings_local.example.py gms/gms/settings_local.py` - - Edit `gms/gms/settings_local.py` and check that the database backend is configured correctly. (sqlite3 is fine for development) -- Initialise the database: `gms/manage.py makemigrations && gms/manage.py migrate` - - Make sure you run this again if you make any changes to `gms/memberdb/models.py` to keep the DB schema in sync. -- Run the local development server with `gms/manage.py runserver` +- Configure django: `cp src/gms/settings_local.example.py src/gms/settings_local.py` + - Edit `src/gms/settings_local.py` and check that the database backend is configured correctly. (sqlite3 is fine for development) +- Initialise the database: `src/manage.py makemigrations memberdb squarepay && src/manage.py migrate memberdb squarepay` + - Make sure you run this again if you make any changes to `src/memberdb/models.py` to keep the DB schema in sync. +- Run the local development server with `src/manage.py runserver` ----------------------------------------------------------- @@ -97,9 +96,9 @@ is configured to run as an unprivileged user (ie. `www-data`) WSGIDaemonProcess uccportal python-home=/services/uccportal/env python-path=/services/uccportal/gms WSGIProcessGroup uccportal - WSGIScriptAlias / /services/uccportal/gms/gms/wsgi.py + WSGIScriptAlias / /services/uccportal/src/gms/wsgi.py - <Directory /services/uccportal/gms/gms> + <Directory /services/uccportal/src/gms> <Files wsgi.py> Require all granted </Files> @@ -107,11 +106,11 @@ is configured to run as an unprivileged user (ie. `www-data`) Protocols h2 http:/1.1 - <Directory /services/uccportal/gms/static> + <Directory /services/uccportal/media> Require all granted </Directory> - Alias /media /services/uccportal/gms/static + Alias /media /services/uccportal/media SSLEngine On SSLCertificateFile /etc/letsencrypt/live/portal.ucc.asn.au/cert.pem @@ -124,11 +123,11 @@ is configured to run as an unprivileged user (ie. `www-data`) ``` 4. Configure django. - Follow the steps from [Environment Setup](#envsetup) - - `chmod 640 /services/uccportal/gms/gms/settings_local.py` + - `chmod 640 /services/uccportal/src/gms/settings_local.py` - `chgrp -R www-data /services/uccportal/` - `mkdir /var/log/apache2/uccportal && chgrp www-data /var/log/apache2/uccportal && chmod 775 /var/log/apache2/uccportal && chmod o+x /var/log/apache2` - Put the static files in the correct location for apache2 to find them: - - `gms/manage.py collectstatic` + - `src/manage.py collectstatic` Configuring the database backend @@ -145,22 +144,22 @@ postgres=# CREATE USER uccportal WITH ENCRYPTED PASSWORD 'insert-password-here'; postgres=# GRANT ALL on DATABASE uccportal to uccportal; ``` -Adjust `/services/uccportal/gms/gms/settings_local.py` to point to the new database (usually +Adjust `/services/uccportal/src/gms/settings_local.py` to point to the new database (usually changing the databse name is enough). Making changes to data being collected -------------------------------------- -Edit `/service/uccportal/gms/memberdb/models.py` -In `/services/uccportal/gms`, run `./manage.py makemigrations` to prepare the databae +Edit `/service/uccportal/src/memberdb/models.py` +In `/services/uccportal/src`, run `./manage.py makemigrations` to prepare the databae updates. ``` -uccportal:~# cd /services/uccportal/gms/ -uccportal:/services/uccportal/gms# ./manage.py check +uccportal:~# cd /services/uccportal/src/ +uccportal:/services/uccportal/src# ./manage.py check System check identified no issues (0 silenced). -uccportal:/services/uccportal/gms# ./manage.py migrate --run-syncdb +uccportal:/services/uccportal/src# ./manage.py migrate --run-syncdb ... You just installed Django's auth system, which means you don't have any @@ -168,7 +167,7 @@ You just installed Django's auth system, which means you don't have any Would you like to create one now? (yes/no): no Now restart MemberDB by runing -uccportal:/services/uccportal/gms# touch gms/wsgi.py +uccportal:/services/uccportal/src# touch gms/wsgi.py ``` Now go ahead and log in to the website. It will be totally fresh, with all @@ -186,4 +185,4 @@ from the Actions menu. Credits ------- - Adapted from `Gumby Management System` written by David Adam <zanchey@ucc.gu.uwa.edu.au> -- Derived from MemberDB by Danni Madeley +- Derived from MemberDB by Danni Madeley \ No newline at end of file diff --git a/pip-packages.txt b/pip-packages.txt index 1db0727343abf1e89c77d8d19d21f4812fef8214..e0807192c82ff1cb4905e1651fc1efb0a47a8301 100644 --- a/pip-packages.txt +++ b/pip-packages.txt @@ -1,15 +1,16 @@ -certifi==2018.11.29 -Django==2.1.5 +certifi==2019.3.9 +Django==2.2 django-auth-ldap==1.7.0 django-formtools==2.1 django-sslserver==0.20 -psycopg2-binary==2.7.6.1 -pyasn1==0.4.4 -pyasn1-modules==0.2.2 -python-dateutil==2.7.5 -python-ldap==3.1.0 -pytz==2018.7 +ldap3==2.6 +psycopg2-binary==2.8.2 +pyasn1==0.4.5 +pyasn1-modules==0.2.4 +python-dateutil==2.8.0 +python-ldap==3.2.0 +pytz==2019.1 six==1.12.0 -squareconnect==2.20181212.0 -urllib3==1.24.1 -ldap3==2.5.2 +sqlparse==0.3.0 +squareconnect==2.20190410.0 +urllib3==1.25 diff --git a/src/gms/settings.py b/src/gms/settings.py index c86e13f50406aa236ac4b094909882090dd9cded..9dfb8a715859e6fdffa1eeafc675faf7976fc48c 100644 --- a/src/gms/settings.py +++ b/src/gms/settings.py @@ -129,24 +129,28 @@ LOGGING = { 'django': { 'handlers':['logfile', 'console'], 'propagate': True, - 'level': LOG_LEVEL, + 'level': LOG_LEVEL_DJANGO, }, 'django.db.backends': { 'handlers': ['logfile', 'console'], - 'level': LOG_LEVEL, + 'level': LOG_LEVEL_DJANGO, 'propagate': False, }, 'django.contrib.auth': { 'handlers': ['logfile', 'console'], - 'level': LOG_LEVEL, + 'level': LOG_LEVEL_DJANGO, }, 'django_auth_ldap': { - 'level': LOG_LEVEL, + 'level': LOG_LEVEL_DJANGO, 'handlers': ['logfile', 'console'], }, 'squarepay': { 'level': LOG_LEVEL, 'handlers': ['logfile', 'console'], + }, + 'memberdb': { + 'level': LOG_LEVEL, + 'handlers': ['logfile', 'console'], } }, } diff --git a/src/gms/settings_local.example.py b/src/gms/settings_local.example.py index b170cb0b13802680f767a08224d75aeded60eb68..9be09b5d7f01fd94a4ead187e90843a65d54a26c 100644 --- a/src/gms/settings_local.example.py +++ b/src/gms/settings_local.example.py @@ -16,7 +16,7 @@ ADMINS = ( ### Database connection options ### DATABASES = { 'default': { - 'ENGINE': '${DB_ENGINE}', # Add 'postgresql', 'mysql', 'sqlite3' or 'oracle'. + 'ENGINE': '${DB_ENGINE}', # django.db.backends.XXX where XXX is 'postgresql', 'mysql', 'sqlite3' or 'oracle'. # this should end up in uccportal/.db/members.db 'NAME': '${DB_NAME}', # Or path to database file if using sqlite3. 'USER': '${DB_USER}', # Not used with sqlite3. @@ -41,17 +41,12 @@ SECRET_KEY = '${APP_SECRET}' ALLOWED_HOSTS = ['${DEPLOY_HOST}'] LOG_LEVEL = 'DEBUG' +LOG_LEVEL_DJANGO = 'WARNING' LOG_FILENAME = os.path.join(ROOT_DIR, "django.log") import ldap from django_auth_ldap.config import LDAPSearch, ActiveDirectoryGroupType, LDAPGroupQuery -# LDAP admin settings -LDAP_BASE_DN = 'DC=ad,DC=ucc,DC=gu,DC=uwa,DC=edu,DC=au' -LDAP_USER_SEARCH_DN = 'CN=Users,DC=ad,DC=ucc,DC=gu,DC=uwa,DC=edu,DC=au' -LDAP_BIND_DN = 'CN=uccportal,CN=Users,DC=ad,DC=ucc,DC=gu,DC=uwa,DC=edu,DC=au' -LDAP_BIND_SECRET = "${LDAP_SECRET}" - # this could be ad.ucc.gu.uwa.edu.au but that doesn't resolve externally - # useful for testing, but should be changed in production so failover works AUTH_LDAP_SERVER_URI = 'ldaps://ad.ucc.gu.uwa.edu.au' @@ -61,16 +56,32 @@ AUTH_LDAP_GLOBAL_OPTIONS = { ldap.OPT_X_TLS_REQUIRE_CERT: ldap.OPT_X_TLS_NEVER, } -# directly attempt to authenticate users to bind to LDAP -AUTH_LDAP_BIND_AS_AUTHENTICATING_USER = True +# LDAP admin settings - NOT for django_auth_ldap +LDAP_BASE_DN = "DC=ad,DC=ucc,DC=gu,DC=uwa,DC=edu,DC=au" +LDAP_USER_SEARCH_DN = 'CN=Users,' + LDAP_BASE_DN + +# settings used by memberdb LDAP backend and django_auth_ldap +AUTH_LDAP_BIND_DN = "CN=uccportal,CN=Users," + LDAP_BASE_DN +AUTH_LDAP_BIND_PASSWORD = "${LDAP_SECRET}" + +# just for django_auth_ldap +AUTH_LDAP_BIND_AS_AUTHENTICATING_USER = False AUTH_LDAP_ALWAYS_UPDATE_USER = True AUTH_LDAP_MIRROR_GROUPS = False AUTH_LDAP_GROUP_TYPE = ActiveDirectoryGroupType() -AUTH_LDAP_FIND_GROUP_PERMS = False -AUTH_LDAP_USER_DN_TEMPLATE = 'CN=%(user)s,CN=Users,DC=ad,DC=ucc,DC=gu,DC=uwa,DC=edu,DC=au' +# give user permissions from Django groups corresponding to names of AD groups +AUTH_LDAP_FIND_GROUP_PERMS = True + +# speed it up by not having to search for the username, we can predict the DN +AUTH_LDAP_USER_DN_TEMPLATE = 'CN=%(user)s,CN=Users,' + LDAP_BASE_DN -AUTH_LDAP_GROUP_SEARCH = LDAPSearch("OU=Groups,DC=ad,DC=ucc,DC=gu,DC=uwa,DC=edu,DC=au", +# this is necessary where the user DN can't be predicted, ie. if the +# user object is named by full name rather than username +#AUTH_LDAP_USER_SEARCH = LDAPSearch('CN=Users,' + LDAP_BASE_DN, +# ldap.SCOPE_SUBTREE, "(&(objectClass=user)(sAMAccountName=%(user)s))") + +AUTH_LDAP_GROUP_SEARCH = LDAPSearch("OU=Groups," + LDAP_BASE_DN, ldap.SCOPE_SUBTREE, "(objectClass=group)") # Populate the Django user from the LDAP directory. @@ -81,26 +92,35 @@ AUTH_LDAP_USER_ATTR_MAP = { "email": "email", } -ADMIN_ACCESS_QUERY = \ - LDAPGroupQuery("CN=committee,OU=Groups,DC=ad,DC=ucc,DC=gu,DC=uwa,DC=edu,DC=au") | \ - LDAPGroupQuery("CN=door,OU=Groups,DC=ad,DC=ucc,DC=gu,DC=uwa,DC=edu,DC=au") | \ - LDAPGroupQuery("CN=wheel,OU=Groups,DC=ad,DC=ucc,DC=gu,DC=uwa,DC=edu,DC=au") +DOOR_GROUP_QUERY = LDAPGroupQuery("CN=door,OU=Groups," + LDAP_BASE_DN) +COMMITTEE_GROUP_QUERY = LDAPGroupQuery("CN=committee,OU=Groups," + LDAP_BASE_DN) +WHEEL_GROUP_QUERY = LDAPGroupQuery("CN=wheel,OU=Groups," + LDAP_BASE_DN) + +ADMIN_ACCESS_QUERY = COMMITTEE_GROUP_QUERY | DOOR_GROUP_QUERY | WHEEL_GROUP_QUERY +# assign user object flags based on group memberships (independent from permissions) AUTH_LDAP_USER_FLAGS_BY_GROUP = { # staff can login to the admin site "is_staff": ADMIN_ACCESS_QUERY, # superusers have all permissions (but also need staff to login to admin site) - "is_superuser": ADMIN_ACCESS_QUERY, + "is_superuser": COMMITTEE_GROUP_QUERY | WHEEL_GROUP_QUERY, } +# cache group memberships for 5 minutes +AUTH_LDAP_CACHE_TIMEOUT = 300 + # the Square app and location data (set to sandbox unless you want it to charge people) 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/account_backend.py b/src/memberdb/account_backend.py index a2174438c451734b5803145fe23c8cb680a7c997..bcfffeddfc6f2de6353e28698025e2e7edf6855a 100644 --- a/src/memberdb/account_backend.py +++ b/src/memberdb/account_backend.py @@ -29,8 +29,8 @@ log = logging.getLogger('ldap') ldap_uri = getattr(settings, 'AUTH_LDAP_SERVER_URI') ldap_user_dn = getattr(settings, 'LDAP_USER_SEARCH_DN') ldap_base_dn = getattr(settings, 'LDAP_BASE_DN') -ldap_bind_dn = getattr(settings, 'LDAP_BIND_DN') -ldap_bind_secret = getattr(settings, 'LDAP_BIND_SECRET') +ldap_bind_dn = getattr(settings, 'AUTH_LDAP_BIND_DN') +ldap_bind_secret = getattr(settings, 'AUTH_LDAP_BIND_PASSWORD') make_home_cmd = ["sudo", "/services/uccportal/src/memberdb/root_actions.py"] make_mail_cmd = 'ssh -i %s root@mooneye "/usr/local/mailman/bin/add_members" -r- ucc-announce <<< %s@ucc.asn.au' make_mail_key = './mooneye.key' diff --git a/src/memberdb/actions.py b/src/memberdb/actions.py index a6c3e5a0477a20013db1fa92fb1f07f94748ffb9..e7c0f99e31994bb92511b69b194d99e2cd9e91bd 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 of %d total" % (num_changed, len(membership_list))) + 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..5b2910531c8dbe8a8d9b87598d52c65887d13ce8 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): - list_display = ['membership_info', 'membership_type', 'payment_method', 'approved', 'date_submitted', 'member_actions', ] + """ + 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'] + list_filter = ['approved', 'payment_method', 'membership_type', 'member__is_student', 'member__is_guild'] 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/models.py b/src/memberdb/models.py index b8cb469caed55544aa75c28e40c3fd0aad0aeffc..eb6b28096dab64875bb54a38daeeecc0eba57acd 100644 --- a/src/memberdb/models.py +++ b/src/memberdb/models.py @@ -11,52 +11,53 @@ import subprocess import ldap """ -dictionary of membership types & descriptions, should be updated if these are changed in dispense. +list of membership types & descriptions, should be updated if these are changed in dispense. +note: this is a list of tuples of key and value as dict, if it was a dict the database models get confused """ -MEMBERSHIP_TYPES = { - 'oday': { +MEMBERSHIP_TYPES = [ + ('oday', { 'dispense':'pseudo:11', 'desc':'O\' Day Special - first time members only', 'is_guild':True, 'is_student':True, 'must_be_fresh':True, - }, - 'student_and_guild': { + }), + ('student_and_guild', { 'dispense':'pseudo:10', 'desc':'Student and UWA Guild member', 'is_guild':True, 'is_student':True, 'must_be_fresh':False, - }, - 'student_only': { + }), + ('student_only', { 'dispense':'pseudo:9', 'desc':'Student and not UWA Guild member', 'is_guild':False, 'is_student':True, 'must_be_fresh':False, - }, - 'guild_only': { + }), + ('guild_only', { 'dispense':'pseudo:8', 'desc':'Non-Student and UWA Guild member', 'is_guild':True, 'is_student':False, 'must_be_fresh':False, - }, - 'non_student': { + }), + ('non_student', { 'dispense':'pseudo:7', 'desc':'Non-Student and not UWA Guild member', 'is_guild':False, 'is_student':False, 'must_be_fresh':False, - }, - 'lifer': { + }), + ('lifer', { 'dispense':'', 'desc':'Life member', 'is_guild':False, 'is_student':False, 'must_be_fresh':False, - } -} + }) +] def get_membership_choices(is_renew=None, get_prices=True): """ @@ -64,7 +65,7 @@ def get_membership_choices(is_renew=None, get_prices=True): also dynamically fetch the prices from dispense (if possible) """ choices = [] - for key, val in MEMBERSHIP_TYPES.items(): + for key, val in MEMBERSHIP_TYPES: if (val['must_be_fresh'] and is_renew == True): # if you have an account already, you don't qualify for the fresher special continue @@ -90,7 +91,7 @@ def get_membership_choices(is_renew=None, get_prices=True): def get_membership_type(member): best = 'non_student' is_fresh = member.memberships.all().count() == 0 - for i, t in MEMBERSHIP_TYPES.items(): + for i, t in MEMBERSHIP_TYPES: if (t['must_be_fresh'] == is_fresh and t['is_student'] == member.is_student and t['is_guild'] == member.is_guild): best = i break @@ -225,11 +226,16 @@ class Membership (models.Model): def __str__ (self): return "Member [%s] (%s) renewed membership on %s" % (self.member.username, self.member.display_name, self.date_submitted.strftime("%Y-%m-%d")) + def get_membership_type(self): + for key, val in MEMBERSHIP_TYPES: + if key == self.membership_type: + return val + def get_dispense_item(self): - return MEMBERSHIP_TYPES[self.membership_type]['dispense'] + return self.get_membership_type()['dispense'] def get_pretty_type(self): - return MEMBERSHIP_TYPES[self.membership_type]['desc'] + return self.get_membership_type()['desc'] class Meta: verbose_name = "Membership renewal record" diff --git a/src/memberdb/register.py b/src/memberdb/register.py index 68e3d343ce3e0b40576b37e9f2d9d2ebc3380155..99ba5d15d136a699eaa5b079593c0d0bf5b3cf06 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,11 +14,12 @@ 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 -from .models import Member, Membership, get_membership_choices, make_pending_membership, MEMBERSHIP_TYPES +from .models import Member, Membership, get_membership_choices, make_pending_membership from .forms import MyModelForm from .views import MyUpdateView @@ -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/memberdb/views.py b/src/memberdb/views.py index 334daf71bb53d08422bed4c0ebb09e381370e8b8..19e40304f5648e01fa55512b1409ca8d0db3ebcb 100644 --- a/src/memberdb/views.py +++ b/src/memberdb/views.py @@ -11,7 +11,7 @@ from django.contrib.auth.mixins import AccessMixin from django.utils import timezone from formtools.wizard.views import SessionWizardView -from .models import Member, IncAssocMember, Membership, MEMBERSHIP_TYPES, TokenConfirmation +from .models import Member, IncAssocMember, Membership, TokenConfirmation from .forms import MemberHomeForm class MemberMiddleware: @@ -39,6 +39,12 @@ class MemberMiddleware: request.member.token = None request.member.save() + if request.user.ldap_user is not None: + # copy the LDAP groups so templates can access them + request.member.groups = list(request.user.ldap_user.group_names) + else: + request.member.groups = [ "gumby" ] + # request.session is a dictionary-like object, its content is saved in the database # and only a session ID is stored as a browser cookie (by default, but is configurable) if 'member_id' in request.session: diff --git a/src/squarepay/cokelog.py b/src/squarepay/cokelog.py new file mode 100644 index 0000000000000000000000000000000000000000..0921dae57df52ef57bb019cf58a4d5869e558e06 --- /dev/null +++ b/src/squarepay/cokelog.py @@ -0,0 +1,125 @@ +""" 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 '(membership [^']+)' \((?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 or not username in self.dispenses: + 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 + 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 + """ + + if membership.member.username == '' or membership.member.username is None: + # can't do anything with empty usernames + return False + + # 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) + + if ms_disp is not None: + if ms_disp['item'] != membership.get_dispense_item(): + log.warn("user '%s': paid incorrect item '%s', not '%s' in dispense." % ( + membership.member.username, ms_disp['item'], membership.get_dispense_item() + )) + else: + membership.date_paid = ms_disp['date'] + membership.payment_method = 'dispense' + log.debug("user '%s': paid in cokelog" % membership.member.username) + return True + else: + log.info("user '%s': no paid membership in cokelog" % membership.member.username) + + return False + diff --git a/src/squarepay/migrations/0001_initial.py b/src/squarepay/migrations/0001_initial.py deleted file mode 100644 index 2bcf8bd51021f2f26872c739baef515a90eaa06c..0000000000000000000000000000000000000000 --- a/src/squarepay/migrations/0001_initial.py +++ /dev/null @@ -1,38 +0,0 @@ -# Generated by Django 2.1.5 on 2019-01-29 04:02 - -from django.db import migrations, models -import django.db.models.deletion -import uuid - - -class Migration(migrations.Migration): - - initial = True - - dependencies = [ - ('memberdb', '__first__'), - ] - - operations = [ - migrations.CreateModel( - name='CardPayment', - fields=[ - ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), - ('description', models.CharField(max_length=255, verbose_name='Description')), - ('amount', models.IntegerField(verbose_name='Amount in cents')), - ('idempotency_key', models.CharField(default=uuid.uuid1, max_length=64, verbose_name='Square Transactions API idempotency key')), - ('is_paid', models.BooleanField(blank=True, default=False, verbose_name='Has been paid')), - ('dispense_synced', models.BooleanField(blank=True, default=False, verbose_name='Payment logged in dispense')), - ('date_created', models.DateTimeField(auto_now_add=True, verbose_name='Date created')), - ('date_paid', models.DateTimeField(blank=True, null=True, verbose_name='Date paid (payment captured)')), - ], - ), - migrations.CreateModel( - name='MembershipPayment', - fields=[ - ('cardpayment_ptr', models.OneToOneField(auto_created=True, on_delete=django.db.models.deletion.CASCADE, parent_link=True, primary_key=True, serialize=False, to='squarepay.CardPayment')), - ('membership', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='payments', to='memberdb.Membership')), - ], - bases=('squarepay.cardpayment',), - ), - ] diff --git a/src/squarepay/migrations/__init__.py b/src/squarepay/migrations/__init__.py deleted file mode 100644 index e69de29bb2d1d6434b8b29ae775ad8c2e48c5391..0000000000000000000000000000000000000000 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)) diff --git a/src/squarepay/views.py b/src/squarepay/views.py index 2e62b3cb500978d796c1777e2ced2ab1d72f26c7..c80825f531d6848bf3eff53cb27033cfbb58d9e6 100644 --- a/src/squarepay/views.py +++ b/src/squarepay/views.py @@ -9,7 +9,7 @@ from django.urls import reverse from django.utils import timezone from memberdb.views import MemberAccessMixin -from memberdb.models import Membership, MEMBERSHIP_TYPES +from memberdb.models import Membership from .models import MembershipPayment, CardPayment from . import payments diff --git a/src/static/admin_custom.css b/src/static/admin_custom.css index 00ce3db898ca8871eb330fc763d319be52f92bc7..bd13a8a1b80c494dea139c81b9ae7c459e47f488 100644 --- a/src/static/admin_custom.css +++ b/src/static/admin_custom.css @@ -24,17 +24,17 @@ #header { padding: 0; height: auto; + display: block; } #admin-header { - padding: 0px 40px; + padding: 10px 40px; } #admin-header, #container { overflow: auto; } - .button { display: inline-block; } diff --git a/src/static/memberdb.css b/src/static/memberdb.css index 004be23a6dae2550e8c7fd41ee4fd8d666860070..a5059159e31511c445593d3d2687baa63919e287 100644 --- a/src/static/memberdb.css +++ b/src/static/memberdb.css @@ -105,7 +105,7 @@ nav { overflow: auto; /* make navbar expand in case the tab buttons flow to a second row */ } -.navtab { +.navtab, .userinfo { float: left; text-decoration: none; color: white; @@ -131,6 +131,47 @@ nav { border-right: 1.5px solid #555; } +.userinfo { + float: right; + border-right: 0; + margin-right: 0; + font-variant-caps: normal; + border: none; + background-color: none; + padding: 19px 20px; +} + +.userinfo.groups { + padding: 7px 20px; +} + +.userinfo .username { + display: block; + line-height: 22px; + font-size: 16px; + font-weight: 600; +} + +/* height of group tags comes to 22px */ +.userinfo .group { + display: inline-block; + border-radius: 11px; + font-size: 12px; + line-height: 14px; + padding: 5px 8px 3px 8px; + background-color: #f6b9d8; + color: #000; + margin: 0 4px; +} + +.userinfo .group.door { + background-color: #b9c6f6; +} + +.userinfo .group.wheel { + background-color: #b9f6c8; +} + .member-details, .membership-details { width: 100%; text-align: left; diff --git a/src/templates/base.html b/src/templates/base.html index 06849d276ae778da669496f2e8a8167ad4a7ae83..1710d6a2daf95b22e08de6dc1024c4853b882a7f 100644 --- a/src/templates/base.html +++ b/src/templates/base.html @@ -50,6 +50,17 @@ <a class="navtab {% if url_name == 'logout' %}active{% endif %}" href="{% url "memberdb:logout" %}">Logout</a> {% endif %} {% endwith %} + + {# add the groups class only if we have groups to display #} + <div class="userinfo{% if request.user.is_authenticated %}{% if request.member %} groups{% endif %}"> + <span class="username">Welcome, {{ request.user.username }}</span> + {% if request.member %} + {% for g in request.member.groups %}<span class="group {{ g }}">{{ g }}</span>{% endfor %} + {% endif %} + {% else %}"> + <span class="username">Not logged in</span> + {% endif %} + </div> </nav> {% endblock %} diff --git a/src/templates/home.html b/src/templates/home.html index e3f690a69a9be572bcd98190d461ef707bd448c9..63e2e520426e1116a19b0bbe08ec0ff36c2a8f58 100644 --- a/src/templates/home.html +++ b/src/templates/home.html @@ -15,6 +15,8 @@ {% if request.user.is_authenticated %} <b>You have no member record associated with this account.</b><br> Please <a href="{% url 'memberdb:renew' %}">renew your membership</a> to get started. + <br><br> + Note: you may have already paid for this year, but unless the username you entered is the same as the one you are using now, it will not work. {% else %} <b>Something went wrong and your membership details could not be retrieved.</b> Please try <a href="{% url 'memberdb:login' %}">logging in</a>. {% endif %}