diff --git a/src/account/actions.py b/src/account/actions.py new file mode 100644 index 0000000000000000000000000000000000000000..119db34d61e608fb8c6a8a9c4e8e932c3ee4d859 --- /dev/null +++ b/src/account/actions.py @@ -0,0 +1,100 @@ + +import logging + +from django.conf import settings +from django.core.exceptions import ImproperlyConfigured + +import ldap +from datetime import date +from squarepay import dispense + +log = logging.getLogger('ldap') +# load config + +ldap_uri = getattr(settings, 'AUTH_LDAP_SERVER_URI') +ldap_search_dn = getattr(settings, 'REPLACE_ME') +ldap_bind_dn = getattr() +ldap_bind_secret = getattr() + + +#initalise ldap instace +_ldap_inst = ldap.initialize(ldap_uri) + +def get_ldap_instance(): + return _ldap_inst + +def get_user_attrs(username, attrs): + # TODO verify bind + ld = get_ldap_instance() + filter = "cn=" + username + try: + result = ld.search_s(ldap_search_dn, ldap.SCOPE_SUBTREE, filter, attrs) + if result.size > 1: + # multiple accounts matched, this is a problem + return None + if result.size == 0: + # account does not exist + return None + return result[0]; + +def get_account_lock_status(username): + ld = get_ldap_instance() + try: + ld.bind(ldap_bind_dn, ldap_bind_secret) + result = get_user_attrs(username, ['userAccountControl']) + finally: + ld.unbind() + return bool(result[1]['userAccountControl'] & 0x002) + +# locks the specified User Account by performing the following actions: +# 1. set UAC ACCOUNTDISABLE flag (0x002) via ldap +# 2. set user shell to `/etc/locked20xx` via ldap +# 3. do `dispense user type disabled <username> <reason>` +def lock_account(username): + # TODO: error handling + ld = get_ldap_instance() + today = date.today() + try: + ld.bind(ldap_bind_dn, ldap_bind_secret) + # fetch current uac + result = get_user_attrs(username, ['userAccountControl']) + + dn = result[0] + uac = result[1]['userAccountControl'] | 0x002 # set ACCOUNTDISABLE + actions = [ + (ldap.MOD_REPLACE, "userAccountControl", uac), + (ldap.MOD_REPLACE, "userShell", "/etc/locked" + str(today.year)) + ] + # write record + ld.modify(dn, actions) + + finally: + ld.unbind() + + reason = "account locked by uccportal on %s" % str(today) + dispense.set_dispense_flag(username, 'disabled', reason) + +def unlock_account(username): + # TODO: error handling + ld = get_ldap_instance() + today = date.today() + try: + ld.bind(ldap_bind_dn, ldap_bind_secret) + # fetch current uac + result = get_user_attrs(username, ['userAccountControl']) + + dn = result[0] + uac = result[1]['userAccountControl'] & ~0x002 # clear ACCOUNTDISABLE + actions = [ + (ldap.MOD_REPLACE, "userAccountControl",uac), + (ldap.MOD_REPLACE, "userShell", "/bin/zsh") + ] + # write record + ld.modify(dn, actions) + + finally: + ld.unbind() + + reason = "account unlocked by uccportal on %s" % str(today) + dispense.set_dispense_flag(username, '!disabled', reason) + diff --git a/src/gms/wsgi.py b/src/gms/wsgi.py deleted file mode 120000 index 398090eba8b8439bdd32ad2086d0f9090cfe9c32..0000000000000000000000000000000000000000 --- a/src/gms/wsgi.py +++ /dev/null @@ -1 +0,0 @@ -wsgi.wsgi \ No newline at end of file diff --git a/src/gms/wsgi.py b/src/gms/wsgi.py new file mode 100644 index 0000000000000000000000000000000000000000..58d5ef649f8aa6b238968e0e94dcd07f61049f79 --- /dev/null +++ b/src/gms/wsgi.py @@ -0,0 +1,15 @@ +""" +WSGI config for gms project. + +It exposes the WSGI callable as a module-level variable named ``application``. + +For more information on this file, see +https://docs.djangoproject.com/en/1.7/howto/deployment/wsgi/ +""" + +import os +os.environ.setdefault("DJANGO_SETTINGS_MODULE", "gms.settings") + +from django.core.wsgi import get_wsgi_application +application = get_wsgi_application() + diff --git a/src/memberdb/models.py b/src/memberdb/models.py index 8dd7e3851b07750ce64ce39c297c2ed575385a4d..cfb8f33d508a147d1aa2dc4c7a9d6346879e4dd1 100644 --- a/src/memberdb/models.py +++ b/src/memberdb/models.py @@ -6,200 +6,211 @@ from django.urls import reverse from squarepay.dispense import get_item_price +import ldap + """ dictionary of membership types & descriptions, should be updated if these are changed in dispense. """ 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': { - 'dispense':'pseudo:10', - 'desc':'Student and UWA Guild member', - 'is_guild':True, - 'is_student':True, - 'must_be_fresh':False, - }, - 'student_only': { - 'dispense':'pseudo:9', - 'desc':'Student and not UWA Guild member', - 'is_guild':False, - 'is_student':True, - 'must_be_fresh':False, - }, - 'guild_only': { - 'dispense':'pseudo:8', - 'desc':'Non-Student and UWA Guild member', - 'is_guild':True, - 'is_student':False, - 'must_be_fresh':False, - }, - 'non_student': { - 'dispense':'pseudo:7', - 'desc':'Non-Student and not UWA Guild member', - 'is_guild':False, - 'is_student':False, - 'must_be_fresh':False, - }, - 'lifer': { - 'dispense':'', - 'desc':'Life member', - 'is_guild':False, - 'is_student':False, - 'must_be_fresh':False, - } + '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': { + 'dispense':'pseudo:10', + 'desc':'Student and UWA Guild member', + 'is_guild':True, + 'is_student':True, + 'must_be_fresh':False, + }, + 'student_only': { + 'dispense':'pseudo:9', + 'desc':'Student and not UWA Guild member', + 'is_guild':False, + 'is_student':True, + 'must_be_fresh':False, + }, + 'guild_only': { + 'dispense':'pseudo:8', + 'desc':'Non-Student and UWA Guild member', + 'is_guild':True, + 'is_student':False, + 'must_be_fresh':False, + }, + 'non_student': { + 'dispense':'pseudo:7', + 'desc':'Non-Student and not UWA Guild member', + 'is_guild':False, + 'is_student':False, + 'must_be_fresh':False, + }, + '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): - """ - turn MEMBERSHIP_TYPES into a list of choices used by Django - also dynamically fetch the prices from dispense (if possible) - """ - choices = [] - for key, val in MEMBERSHIP_TYPES.items(): - if (val['must_be_fresh'] and is_renew == True): - # if you have an account already, you don't qualify for the fresher special - continue - if (val['dispense'] == '' and is_renew == False): - # free memberships can only apply to life members, and they will have an existing membership somewhere - # so this option is only displayed on the renewal form - continue - else: - if get_prices: - price = get_item_price(val['dispense']) - else: - price = None - - if price is not None: - desc = "%s ($%1.2f)" % (val['desc'], price / 100.0) - choices += [(key, desc)] - else: - # don't display the price - choices += [(key, val['desc'])] - - return choices + """ + turn MEMBERSHIP_TYPES into a list of choices used by Django + also dynamically fetch the prices from dispense (if possible) + """ + choices = [] + for key, val in MEMBERSHIP_TYPES.items(): + if (val['must_be_fresh'] and is_renew == True): + # if you have an account already, you don't qualify for the fresher special + continue + if (val['dispense'] == '' and is_renew == False): + # free memberships can only apply to life members, and they will have an existing membership somewhere + # so this option is only displayed on the renewal form + continue + else: + if get_prices: + price = get_item_price(val['dispense']) + else: + price = None + + if price is not None: + desc = "%s ($%1.2f)" % (val['desc'], price / 100.0) + choices += [(key, desc)] + else: + # don't display the price + choices += [(key, val['desc'])] + + return choices def get_membership_type(member): - best = 'non_student' - is_fresh = member.memberships.all().count() == 0 - for i, t in MEMBERSHIP_TYPES.items(): - if (t['must_be_fresh'] == is_fresh and t['is_student'] == member.is_student and t['is_guild'] == member.is_guild): - best = i - break - elif (t['is_student'] == member.is_student and t['is_guild'] == member.is_guild): - best = i - break - return best + best = 'non_student' + is_fresh = member.memberships.all().count() == 0 + for i, t in MEMBERSHIP_TYPES.items(): + if (t['must_be_fresh'] == is_fresh and t['is_student'] == member.is_student and t['is_guild'] == member.is_guild): + best = i + break + elif (t['is_student'] == member.is_student and t['is_guild'] == member.is_guild): + best = i + break + return best def make_token(): - return get_random_string(128) + return get_random_string(128) PAYMENT_METHODS = [ - ('dispense', 'Existing dispense credit'), - ('cash', 'Cash (in person)'), - ('card', 'Tap-n-Go via Square (in person)'), - ('online', 'Online payment via Square'), - ('eft', 'Bank transfer'), - ('', 'No payment') + ('dispense', 'Existing dispense credit'), + ('cash', 'Cash (in person)'), + ('card', 'Tap-n-Go via Square (in person)'), + ('online', 'Online payment via Square'), + ('eft', 'Bank transfer'), + ('', 'No payment') ] + + + class IncAssocMember (models.Model): - """ - Member record for data we are legally required to keep under Incorporations Act (and make available to members upon request) - Note: these data should only be changed administratively or with suitable validation since it must be up to date & accurate. - """ - - first_name = models.CharField ('First name', max_length=200) - last_name = models.CharField ('Surname', max_length=200) - email_address = models.EmailField ('Email address', blank=False) - updated = models.DateTimeField ('IncA. info last updated', auto_now=True) - created = models.DateTimeField ('When created', auto_now_add=True) - - def __str__ (self): - return "%s %s <%s>" % (self.first_name, self.last_name, self.email_address) - - class Meta: - verbose_name = "Incorporations Act member data" - verbose_name_plural = verbose_name + """ + Member record for data we are legally required to keep under Incorporations Act (and make available to members upon request) + Note: these data should only be changed administratively or with suitable validation since it must be up to date & accurate. + """ + + first_name = models.CharField ('First name', max_length=200) + last_name = models.CharField ('Surname', max_length=200) + email_address = models.EmailField ('Email address', blank=False) + updated = models.DateTimeField ('IncA. info last updated', auto_now=True) + created = models.DateTimeField ('When created', auto_now_add=True) + + def __str__ (self): + return "%s %s <%s>" % (self.first_name, self.last_name, self.email_address) + + class Meta: + verbose_name = "Incorporations Act member data" + verbose_name_plural = verbose_name class Member (IncAssocMember): - """ - Member table: only latest information, one record per member - Some of this data may be required by the UWA Student Guild. Other stuff is just good to know, - and we don't _need_ to keep historical data for every current/past member. - 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=False, blank=False, unique=True, validators=[RegexValidator(regex='^[a-z0-9._-]+$')]) - phone_number = models.CharField ('Phone number', max_length=20, blank=False, validators=[RegexValidator(regex='^\+?[0-9() -]+$')]) - is_student = models.BooleanField ('Student', default=True, blank=True, help_text="Tick this box if you are a current student at a secondary or tertiary institution in WA") - is_guild = models.BooleanField ('UWA Guild member', default=True, blank=True) - id_number = models.CharField ('Student email or Drivers License', max_length=255, blank=False, help_text="Student emails should end with '@student.*.edu.au' and drivers licences should be in the format '<AU state> 1234567'") - - # data used internally by the system, not to be touched, seen or heard (except when it is) - member_updated = models.DateTimeField ('Internal UCC info last updated', auto_now=True) - login_token = models.CharField ('Temporary access key', max_length=128, null=True, editable=False, default=make_token) - email_confirm = models.BooleanField ('Email address confirmed', null=False, editable=False, default=False) - studnt_confirm = models.BooleanField ('Student status confirmed', null=False, editable=False, default=False) - guild_confirm = models.BooleanField ('Guild status confirmed', null=False, editable=False, default=False) - - def __str__ (self): - if (self.display_name != "%s %s" % (self.first_name, self.last_name)): - name = "%s (%s %s)" % (self.display_name, self.first_name, self.last_name) - else: - name = self.display_name - return "[%s] %s" % (self.username, name) - - class Meta: - verbose_name = "Internal UCC member record" + """ + Member table: only latest information, one record per member + Some of this data may be required by the UWA Student Guild. Other stuff is just good to know, + and we don't _need_ to keep historical data for every current/past member. + 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=False, blank=False, unique=True, validators=[RegexValidator(regex='^[a-z0-9._-]+$')]) + phone_number = models.CharField ('Phone number', max_length=20, blank=False, validators=[RegexValidator(regex='^\+?[0-9() -]+$')]) + is_student = models.BooleanField ('Student', default=True, blank=True, help_text="Tick this box if you are a current student at a secondary or tertiary institution in WA") + is_guild = models.BooleanField ('UWA Guild member', default=True, blank=True) + id_number = models.CharField ('Student email or Drivers License', max_length=255, blank=False, help_text="Student emails should end with '@student.*.edu.au' and drivers licences should be in the format '<AU state> 1234567'") + + # data used internally by the system, not to be touched, seen or heard (except when it is) + member_updated = models.DateTimeField ('Internal UCC info last updated', auto_now=True) + login_token = models.CharField ('Temporary access key', max_length=128, null=True, editable=False, default=make_token) + email_confirm = models.BooleanField ('Email address confirmed', null=False, editable=False, default=False) + studnt_confirm = models.BooleanField ('Student status confirmed', null=False, editable=False, default=False) + guild_confirm = models.BooleanField ('Guild status confirmed', null=False, editable=False, default=False) + + # account info + def get_account_status(self): + + + return; + + def __str__ (self): + if (self.display_name != "%s %s" % (self.first_name, self.last_name)): + name = "%s (%s %s)" % (self.display_name, self.first_name, self.last_name) + else: + name = self.display_name + return "[%s] %s" % (self.username, name) + + class Meta: + verbose_name = "Internal UCC member record" class Membership (models.Model): - """ - Membership table: store information related to individual (successful/accepted) signups/renewals - """ + """ + Membership table: store information related to individual (successful/accepted) signups/renewals + """ - member = models.ForeignKey (Member, on_delete=models.CASCADE, related_name='memberships') - membership_type = models.CharField ('Membership type', max_length=20, blank=True, null=False, choices=get_membership_choices(get_prices=False)) - 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_paid = models.DateTimeField ('Date of payment', blank=True, null=True) - date_approved = models.DateTimeField ('Date approved', blank=True, null=True) + member = models.ForeignKey (Member, on_delete=models.CASCADE, related_name='memberships') + membership_type = models.CharField ('Membership type', max_length=20, blank=True, null=False, choices=get_membership_choices(get_prices=False)) + 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_paid = models.DateTimeField ('Date of payment', blank=True, null=True) + date_approved = models.DateTimeField ('Date approved', blank=True, null=True) - 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 __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_dispense_item(self): - return MEMBERSHIP_TYPES[self.membership_type]['dispense'] + def get_dispense_item(self): + return MEMBERSHIP_TYPES[self.membership_type]['dispense'] - class Meta: - verbose_name = "Membership renewal record" - ordering = ['approved', '-date_submitted'] + class Meta: + verbose_name = "Membership renewal record" + ordering = ['approved', '-date_submitted'] class TokenConfirmation(models.Model): - """ keep track of email confirmation tokens etc. and which field to update """ - member = models.ForeignKey (Member, on_delete=models.CASCADE, related_name='token_confirmations') - confirm_token = models.CharField ('unique confirmation URL token', max_length=128, null=False, default=make_token) - model_field = models.CharField ('name of BooleanField to update on parent when confirmed', max_length=40, null=False, blank=False) - created = models.DateTimeField ('creation date', auto_now_add=True) - - def mark_confirmed(self): - """ try to mark as confirmed, if error then silently fail """ - try: - m = self.member - setattr(m, self.model_field) - m.save() - self.delete() - except Member.DoesNotExist as e: - pass - - def get_absolute_url(self): - return reverse('memberdb:email_confirm', kwargs={'pk': self.id, 'token': self.confirm_token}) + """ keep track of email confirmation tokens etc. and which field to update """ + member = models.ForeignKey (Member, on_delete=models.CASCADE, related_name='token_confirmations') + confirm_token = models.CharField ('unique confirmation URL token', max_length=128, null=False, default=make_token) + model_field = models.CharField ('name of BooleanField to update on parent when confirmed', max_length=40, null=False, blank=False) + created = models.DateTimeField ('creation date', auto_now_add=True) + + def mark_confirmed(self): + """ try to mark as confirmed, if error then silently fail """ + try: + m = self.member + setattr(m, self.model_field) + m.save() + self.delete() + except Member.DoesNotExist as e: + pass + + def get_absolute_url(self): + return reverse('memberdb:email_confirm', kwargs={'pk': self.id, 'token': self.confirm_token}) diff --git a/src/squarepay/dispense.py b/src/squarepay/dispense.py index c0ffdfd932f8ae578e47d24de345cfd979f82d16..9cd4d089f73223583b637ccba4004e137ee5be2d 100644 --- a/src/squarepay/dispense.py +++ b/src/squarepay/dispense.py @@ -12,40 +12,54 @@ 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)!") + log.warning("DISPENSE_BIN is not defined! Lookups for prices will fallback to weird numbers (for testing)!") def run_dispense(*args): - 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 + 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) + """ 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; diff --git a/src/templates/admin/memberdb/membership_actions.html b/src/templates/admin/memberdb/membership_actions.html index 6073fe9412c12194dc85e258f034a478713d78a6..4d5c55b6260f06697a7b52f2e1f54462d599a608 100644 --- a/src/templates/admin/memberdb/membership_actions.html +++ b/src/templates/admin/memberdb/membership_actions.html @@ -4,4 +4,5 @@ {% if not ms.approved %} <a class="button" href="{{ member_approve }}">Approve</a> {% endif %} + <a class="button" href="{{ account_create }}">Create Account</a> </div>