diff --git a/.gitlab-ci.yml b/.gitlab-ci.yml new file mode 100644 index 0000000000000000000000000000000000000000..f99432f7b8e23c77fe87d5814ca1c4caef6a076a --- /dev/null +++ b/.gitlab-ci.yml @@ -0,0 +1,86 @@ +stages: + - test + - deploy + +variables: + DB_ENGINE : django.db.backends.sqlite3 + +before_script: + - echo "preparing environment" + - virtualenv env -p `which python3` + - . env/bin/activate + - pip install -r pip-packages.txt + + +run_tests: + stage: test + script: + - echo "Running tests" + - envsubst < src/gms/settings_local.example.py > src/gms/settings_local.py + - python src/manage.py check + +deploy_testing: + stage: deploy + tags: + - test + variables: + DB_NAME : /var/www/test/$CI_COMMIT_REF_NAME/.db/members.db + DEPLOY_HOST : $CI_COMMIT_REF_NAME.test.ucc.asn.au + SHORT_ENV_NAME : TEST + script: + - echo "Deploy to test" + - rm -f /var/www/test/$CI_COMMIT_REF_NAME + - rm -f /etc/uwsgi/vassals/$CI_COMMIT_REF_NAME.ini + - mkdir .db + - envsubst < src/gms/settings_local.example.py > src/gms/settings_local.py + - chmod 600 src/gms/settings_local.py + - ln -srT ./ /var/www/test/$CI_COMMIT_REF_NAME + - python src/manage.py collectstatic + - python src/manage.py makemigrations + - python src/manage.py migrate --run-syncdb + - ln -s /etc/uwsgi/vassals/test.skel /etc/uwsgi/vassals/$CI_COMMIT_REF_NAME.ini + environment: + name: test/$CI_COMMIT_REF_NAME + url: https://$CI_COMMIT_REF_NAME.test.ucc.asn.au + on_stop: stop_testing + except: + - master + +deploy_staging: + stage: deploy + tags: + - stage + variables: + DB_NAME : /var/www/stage/.db/members.db + DEPLOY_HOST : stage.test.ucc.asn.au + SHORT_ENV_NAME : STAGE + script: + - echo "Deploy to staging" + - rm -f /services/$CI_PROJECT_NAME + - rm -f /etc/uwsgi/vassals/stage.ini + - ln -srT ./ /services/$CI_PROJECT_NAME + - envsubst < src/gms/settings_local.example.py > src/gms/settings_local.py + - chmod 600 src/gms/settings_local.py + - python src/manage.py collectstatic + - python src/manage.py makemigrations + - python src/manage.py migrate --run-syncdb + - ln -s /etc/uwsgi/vassals/stage.skel /etc/uwsgi/vassals/stage.ini + environment: + name: stage + url: https://stage.test.ucc.asn.au + only: + - master + +stop_testing: + stage: deploy + variables: + GIT_STRATEGY: none + when: manual + environment: + name: test/$CI_COMMIT_REF_NAME + action: stop + except: + - master + script: + - rm -f /var/www/test/$CI_COMMIT_REF_NAME + - rm -f /etc/uwsgi/vassals/$CI_COMMIT_REF_NAME.ini diff --git a/pip-packages.txt b/pip-packages.txt index 09a141725a6371bf28a4624f0eb7a19cff2b0a25..1db0727343abf1e89c77d8d19d21f4812fef8214 100644 --- a/pip-packages.txt +++ b/pip-packages.txt @@ -1,6 +1,7 @@ certifi==2018.11.29 Django==2.1.5 django-auth-ldap==1.7.0 +django-formtools==2.1 django-sslserver==0.20 psycopg2-binary==2.7.6.1 pyasn1==0.4.4 @@ -11,3 +12,4 @@ pytz==2018.7 six==1.12.0 squareconnect==2.20181212.0 urllib3==1.24.1 +ldap3==2.5.2 diff --git a/src/gms/__init__.py b/src/gms/__init__.py new file mode 100644 index 0000000000000000000000000000000000000000..2f14918d980c537c7dfb7e8986faa2d2464dce60 --- /dev/null +++ b/src/gms/__init__.py @@ -0,0 +1 @@ +default_app_config = 'gms.apps.GmsConfig' diff --git a/src/gms/context_processors.py b/src/gms/context_processors.py new file mode 100644 index 0000000000000000000000000000000000000000..c041f717eb94834ca461e0d48c4c4ca349a2ff58 --- /dev/null +++ b/src/gms/context_processors.py @@ -0,0 +1,6 @@ +from django.conf import settings + +def global_settings(request): + return { + 'DEPLOYMENT_ENV' : settings.ENV + } diff --git a/src/gms/settings.py b/src/gms/settings.py index 63a7f9dcbec63b0abf9a4cf65990c6b8e6988468..c86e13f50406aa236ac4b094909882090dd9cded 100644 --- a/src/gms/settings.py +++ b/src/gms/settings.py @@ -14,26 +14,27 @@ from gms.settings_local import * # Application definition INSTALLED_APPS = ( - 'sslserver', - 'django.contrib.admin', - 'django.contrib.auth', - 'django.contrib.contenttypes', - 'django.contrib.sessions', - 'django.contrib.messages', - 'django.contrib.staticfiles', - 'memberdb', - 'import_members', - 'squarepay', + 'sslserver', + 'django.contrib.admin', + 'django.contrib.auth', + 'django.contrib.contenttypes', + 'django.contrib.sessions', + 'django.contrib.messages', + 'django.contrib.staticfiles', + 'memberdb', + 'import_members', + 'squarepay', + 'formtools', ) MIDDLEWARE = [ - 'django.contrib.sessions.middleware.SessionMiddleware', - 'django.middleware.common.CommonMiddleware', - 'django.middleware.csrf.CsrfViewMiddleware', - 'django.contrib.auth.middleware.AuthenticationMiddleware', - 'django.contrib.messages.middleware.MessageMiddleware', - 'django.middleware.clickjacking.XFrameOptionsMiddleware', - 'memberdb.views.MemberMiddleware', + 'django.contrib.sessions.middleware.SessionMiddleware', + 'django.middleware.common.CommonMiddleware', + 'django.middleware.csrf.CsrfViewMiddleware', + 'django.contrib.auth.middleware.AuthenticationMiddleware', + 'django.contrib.messages.middleware.MessageMiddleware', + 'django.middleware.clickjacking.XFrameOptionsMiddleware', + 'memberdb.views.MemberMiddleware', ] ROOT_URLCONF = 'gms.urls' @@ -63,33 +64,34 @@ DATABASE_ROUTERS = ['import_members.db.MemberDbRouter'] # Static files (CSS, JavaScript, Images) # https://docs.djangoproject.com/en/1.7/howto/static-files/ STATICFILES_DIRS = [ - os.path.join(BASE_DIR, 'static'), + os.path.join(BASE_DIR, 'static'), ] STATIC_URL = '/media/' STATIC_ROOT = os.path.join(ROOT_DIR, 'media') AUTHENTICATION_BACKENDS = [ - # see https://django-auth-ldap.readthedocs.io/en/latest for configuration info - 'django_auth_ldap.backend.LDAPBackend', - 'django.contrib.auth.backends.ModelBackend', + # see https://django-auth-ldap.readthedocs.io/en/latest for configuration info + 'django_auth_ldap.backend.LDAPBackend', + 'django.contrib.auth.backends.ModelBackend', ] # see settings_local.py for LDAP settings TEMPLATES = [ - { - 'BACKEND': 'django.template.backends.django.DjangoTemplates', - 'DIRS': [os.path.join(BASE_DIR, 'templates')], - 'APP_DIRS': True, - 'OPTIONS': { - 'context_processors': [ - 'django.template.context_processors.debug', - 'django.template.context_processors.request', - 'django.contrib.auth.context_processors.auth', - 'django.contrib.messages.context_processors.messages', - ], - }, - }, + { + 'BACKEND': 'django.template.backends.django.DjangoTemplates', + 'DIRS': [os.path.join(BASE_DIR, 'templates')], + 'APP_DIRS': True, + 'OPTIONS': { + 'context_processors': [ + 'django.template.context_processors.debug', + 'django.template.context_processors.request', + 'django.contrib.auth.context_processors.auth', + 'django.contrib.messages.context_processors.messages', + 'gms.context_processors.global_settings' + ], + }, + }, ] TEMPLATE_DEBUG = DEBUG @@ -100,51 +102,51 @@ MESSAGE_LEVEL = message_constants.DEBUG ### Logging configuration ### import logging LOGGING = { - 'version': 1, - 'disable_existing_loggers': False, - 'formatters': { - 'standard': { - 'format' : "[%(asctime)s] %(levelname)s [%(name)s:%(lineno)s] %(message)s", - 'datefmt' : "%d/%b/%Y %H:%M:%S" - }, - }, - 'handlers': { - 'logfile': { - 'level': LOG_LEVEL, - 'class':'logging.handlers.RotatingFileHandler', - 'filename': LOG_FILENAME, - 'maxBytes': 500000, - 'backupCount': 2, - 'formatter': 'standard', - }, - 'console':{ - 'level': LOG_LEVEL, - 'class':'logging.StreamHandler', - 'formatter': 'standard' - }, - }, - 'loggers': { - 'django': { - 'handlers':['logfile', 'console'], - 'propagate': True, - 'level': LOG_LEVEL, - }, - 'django.db.backends': { - 'handlers': ['logfile', 'console'], - 'level': LOG_LEVEL, - 'propagate': False, - }, - 'django.contrib.auth': { - 'handlers': ['logfile', 'console'], - 'level': LOG_LEVEL, - }, - 'django_auth_ldap': { - 'level': LOG_LEVEL, - 'handlers': ['logfile', 'console'], - }, - 'squarepay': { - 'level': LOG_LEVEL, - 'handlers': ['logfile', 'console'], - } - }, + 'version': 1, + 'disable_existing_loggers': False, + 'formatters': { + 'standard': { + 'format' : "[%(asctime)s] %(levelname)s [%(name)s:%(lineno)s] %(message)s", + 'datefmt' : "%d/%b/%Y %H:%M:%S" + }, + }, + 'handlers': { + 'logfile': { + 'level': LOG_LEVEL, + 'class':'logging.handlers.RotatingFileHandler', + 'filename': LOG_FILENAME, + 'maxBytes': 500000, + 'backupCount': 2, + 'formatter': 'standard', + }, + 'console':{ + 'level': LOG_LEVEL, + 'class':'logging.StreamHandler', + 'formatter': 'standard' + }, + }, + 'loggers': { + 'django': { + 'handlers':['logfile', 'console'], + 'propagate': True, + 'level': LOG_LEVEL, + }, + 'django.db.backends': { + 'handlers': ['logfile', 'console'], + 'level': LOG_LEVEL, + 'propagate': False, + }, + 'django.contrib.auth': { + 'handlers': ['logfile', 'console'], + 'level': LOG_LEVEL, + }, + 'django_auth_ldap': { + 'level': LOG_LEVEL, + 'handlers': ['logfile', 'console'], + }, + 'squarepay': { + 'level': LOG_LEVEL, + 'handlers': ['logfile', 'console'], + } + }, } diff --git a/src/gms/settings_local.example.py b/src/gms/settings_local.example.py index 89de59e13e31173bb65d2d114a6d06f7f5bd7b62..b170cb0b13802680f767a08224d75aeded60eb68 100644 --- a/src/gms/settings_local.example.py +++ b/src/gms/settings_local.example.py @@ -7,36 +7,38 @@ ROOT_DIR = os.path.dirname(BASE_DIR) DEBUG = True +ENV = '${SHORT_ENV_NAME}' + ADMINS = ( - ('UCC Committee', 'committee-only@ucc.asn.au'), + ('UCC Committee', 'committee-only@ucc.asn.au'), ) ### Database connection options ### DATABASES = { - 'default': { - 'ENGINE': 'django.db.backends.sqlite3', # Add 'postgresql', 'mysql', 'sqlite3' or 'oracle'. - # this should end up in uccportal/.db/members.db - 'NAME': os.path.join(ROOT_DIR, '.db', 'members.db'), # Or path to database file if using sqlite3. - 'USER': '', # Not used with sqlite3. - 'PASSWORD': '', # Not used with sqlite3. - 'HOST': '', # Set to empty string for localhost. Not used with sqlite3. - 'PORT': '', # Set to empty string for default. Not used with sqlite3. - }, - 'memberdb_old': { - 'ENGINE': 'django.db.backends.postgresql', - 'NAME': 'uccmemberdb_2018', - 'USER': 'uccmemberdb', - 'PASSWORD': 'something-secret-here', - 'HOST': 'mussel.ucc.gu.uwa.edu.au', - 'PORT': '', - } + 'default': { + 'ENGINE': '${DB_ENGINE}', # Add '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. + 'PASSWORD': '${DB_SECRET}', # Not used with sqlite3. + 'HOST': '${DB_HOST}', # Set to empty string for localhost. Not used with sqlite3. + 'PORT': '', # Set to empty string for default. Not used with sqlite3. + }, + 'memberdb_old': { + 'ENGINE': 'django.db.backends.postgresql', + 'NAME': 'uccmemberdb_2018', + 'USER': 'uccmemberdb', + 'PASSWORD': '${OLDDB_SECRET}', + 'HOST': 'mussel.ucc.gu.uwa.edu.au', + 'PORT': '', + } } # Make this unique, and don't share it with anybody. -SECRET_KEY = 'something-unique-here' +SECRET_KEY = '${APP_SECRET}' # Set this to whatever your ServerName/ServerAlias(es) are -ALLOWED_HOSTS = [] +ALLOWED_HOSTS = ['${DEPLOY_HOST}'] LOG_LEVEL = 'DEBUG' LOG_FILENAME = os.path.join(ROOT_DIR, "django.log") @@ -44,13 +46,19 @@ 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://samson.ucc.gu.uwa.edu.au/' +AUTH_LDAP_SERVER_URI = 'ldaps://ad.ucc.gu.uwa.edu.au' # This is also a bad idea, should be changed in production AUTH_LDAP_GLOBAL_OPTIONS = { - ldap.OPT_X_TLS_REQUIRE_CERT: ldap.OPT_X_TLS_NEVER, + ldap.OPT_X_TLS_REQUIRE_CERT: ldap.OPT_X_TLS_NEVER, } # directly attempt to authenticate users to bind to LDAP @@ -63,33 +71,33 @@ 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' AUTH_LDAP_GROUP_SEARCH = LDAPSearch("OU=Groups,DC=ad,DC=ucc,DC=gu,DC=uwa,DC=edu,DC=au", - ldap.SCOPE_SUBTREE, "(objectClass=group)") + ldap.SCOPE_SUBTREE, "(objectClass=group)") # Populate the Django user from the LDAP directory. # note: somehow the LDAP/AD users don't have firstName/sn, rather the full name is in name or displayName AUTH_LDAP_USER_ATTR_MAP = { - "first_name": "givenName", - "last_name": "sn", - "email": "email", + "first_name": "givenName", + "last_name": "sn", + "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") + 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") AUTH_LDAP_USER_FLAGS_BY_GROUP = { - # staff can login to the admin site - "is_staff": ADMIN_ACCESS_QUERY, + # 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, + # superusers have all permissions (but also need staff to login to admin site) + "is_superuser": ADMIN_ACCESS_QUERY, } # the Square app and location data (set to sandbox unless you want it to charge people) -SQUARE_APP_ID = 'maybe-sandbox-something-something-here' -SQUARE_LOCATION = 'CBASEDE-this-is-probably-somewhere-in-Sydney' -SQUARE_ACCESS_TOKEN = 'keep-this-very-secret' +SQUARE_APP_ID = '${SQUARE_APP_ID}' +SQUARE_LOCATION = '${SQUARE_LOCATION}' +SQUARE_ACCESS_TOKEN = '${SQUARE_SECRET}' DISPENSE_BIN = '/usr/local/bin/dispense' @@ -98,4 +106,4 @@ EMAIL_HOST = "secure.ucc.asn.au" EMAIL_PORT = 465 EMAIL_USE_SSL = True EMAIL_HOST_USER = "uccportal" -EMAIL_HOST_PASSWORD = "changeme" +EMAIL_HOST_PASSWORD = "${EMAIL_SECRET}" 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/account.py b/src/memberdb/account.py new file mode 100644 index 0000000000000000000000000000000000000000..216358c0b7800039f28efb10d317affe828a34ff --- /dev/null +++ b/src/memberdb/account.py @@ -0,0 +1,151 @@ +from django.http import HttpResponse, HttpResponseRedirect +from django.shortcuts import render +from django.urls import reverse +from django.utils import timezone +from django.contrib import messages +from django import forms +from formtools.wizard.views import SessionWizardView + +from .models import Member +from .forms import MyModelForm, MyForm +from .views import MyUpdateView, MyWizardView +from memberdb.account_backend import validate_username, create_ad_user + + + +class AccountForm(MyModelForm): + # form fields + username = forms.SlugField( + validators=[validate_username], + max_length=19, + ) + password = forms.CharField( + min_length=10, + max_length=127, + widget=forms.PasswordInput, + strip=False, + help_text="Password must be between 10 and 127 characters long" + ) + confirm_password = forms.CharField( + min_length=10, + max_length=127, + widget=forms.PasswordInput, + strip=False, + ) + + class Meta: + model = Member + fields = ['username'] + + def clean(self): + try: + if (self['password'].value() != self['confirm_password'].value()): + self.add_error('confirm_password', 'Passwords must match.') + except: + pass + super().clean(); + + + +class EmailForm(MyModelForm): + forward = forms.BooleanField(required=False) + email_address = forms.EmailField( + label='Forwarding address (optional)', + required=False, + help_text="Your club email will be forwarded to this address. Leave blank if email forwarding is not required" + ) + + class Meta: + model = Member + fields = ['forward', 'email_address'] + + def clean(self): + if self['forward'].value() == True: + try: + if (len(self['email_address'].value()) == 0): + self.add_error('email_address', 'Email field cannot be left blank.') + if (self['email_address'].value().split('@')[1] in ["ucc.asn.au", "ucc.gu.uwa.edu.au"]): + self.add_error('email_address', 'Forwarding address cannot be the same as your account address.') + except: + pass + super().clean(); + +class DispenseForm(MyForm): + pin = forms.CharField( + min_length=0, + max_length=4, + widget=forms.PasswordInput, + strip=False, + required=False, + help_text="PIN must be 4 digits long") + + confirm_pin = forms.CharField( + min_length=0, + max_length=4, + widget=forms.PasswordInput, + required=False, + strip=False, + ) + def clean(self): + try: + if len(self['pin'].value()) != 4 : + self.add_error('pin', 'PIN must be excatly 4 digits.') + if not self['pin'].value().isdigit(): + self.add_error('pin', 'PIN can only contain numbers.') + if (self['pin'].value() != self['confirm_pin'].value()): + self.add_error('confirm_pin', 'PINs must match.') + except: + pass + super().clean(); + + +class AccountView(MyWizardView): + form_list = [AccountForm,EmailForm,DispenseForm] + template_name = 'admin/memberdb/account_create.html' + admin = None + + def get_form_instance(self, step): + return self.object + + def get_context_data(self, **kwargs): + m = self.object + context = super().get_context_data(**kwargs) + context.update(self.admin.admin_site.each_context(self.request)) + context.update({ + 'opts': self.admin.model._meta, + 'member': m, + }) + return context + + + def done(self, form_list, form_dict, **kwargs): + + + # create the user and save their username if successfull + try: + if create_ad_user(self.get_cleaned_data_for_step('0'), self.object): + form_dict['0'].save() + + make_home(self.get_cleaned_data_for_step('1'), self.object) + make_dispense_account(self.object.username, self.get_cleaned_data_for_step('2')['pin']) + subscribe_to_list(self.object) + except Exception as e: + messages.error(self.request,'Account creation failed for %s', self.object) + messages.error(self.request, e) + raise #DEBUG + + else: + messages.success(self.request, 'An account has been successfully created for %s.' % self.object) + return HttpResponseRedirect(reverse("admin:memberdb_membership_changelist")) + + #return accountProgressView(self.request, m) + + +def accountProgressView(request, member): + return + + + + +def accountFinalView(): + return render(request, 'accountfinal.html', context) diff --git a/src/memberdb/account_backend.py b/src/memberdb/account_backend.py new file mode 100644 index 0000000000000000000000000000000000000000..a2174438c451734b5803145fe23c8cb680a7c997 --- /dev/null +++ b/src/memberdb/account_backend.py @@ -0,0 +1,295 @@ + +import logging + +from django.conf import settings +from django.core.exceptions import ImproperlyConfigured, ValidationError +from django.utils.translation import gettext_lazy as _ + +import re +import socket +from ldap3 import Server, Connection, MODIFY_REPLACE,MODIFY_ADD +from ldap3.core.results import RESULT_SUCCESS +from ldap3.core.exceptions import * + + +import subprocess +from subprocess import CalledProcessError, TimeoutExpired + +import memberdb.models +from datetime import date +from squarepay import dispense + +import shutil +import os + + +log = logging.getLogger('ldap') + +# load config +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') +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' + + +maxuid_dn = "CN=uccdomayne,CN=ypservers,CN=ypServ30,CN=RpcServices,CN=System,"+ldap_base_dn + +#initalise ldap instace +_ldap_inst = Connection( + Server(ldap_uri), + client_strategy='SYNC', + user=ldap_bind_dn, + password=ldap_bind_secret, + raise_exceptions=True, + + ) + +# get the ldap instance and bind if required +def get_ldap_instance(): + if not _ldap_inst.bound: + try: + _ldap_inst.bind() + except LDAPInvalidCredentialsResult: + log.error("LDAP: Invalid bind credentials") + raise + return _ldap_inst + +def get_ldap_attrs(dn, filter, limit, attrs): + ld = get_ldap_instance() + + ld.search(dn, filter, size_limit=limit, attributes=attrs) + result = ld.result + # fetch matched objects on success + if (result['result'] == RESULT_SUCCESS): + entries = ld.entries + else: + # otherwise raise an exception + raise LDAPOperationResult( + result=result['result'], + description=result['description'], + dn=result['dn'], + message=result['message'], + response_type=result['type']) + + if len(entries) == 0: + raise LDAPNoSuchObjectResult() + + return entries; + +def get_user_attrs(username, attrs): + # find the user + filter = "(cn=" + username + ')' + + result = get_ldap_attrs(ldap_user_dn, filter, 1, attrs) + + return result[0]; + +def get_maxuid(): + ld = get_ldap_instance() + filter = "(cn=*)" + attrs = ['msSFU30MaxUidNumber'] + result = get_ldap_attrs(maxuid_dn, filter, 1, attrs) + + return result[0] + +def get_account_lock_status(username): + ld = get_ldap_instance() + try: + result = get_user_attrs(username, ['userAccountControl']) + # user does not exist + except LDAPNoSuchObjectResult: + return None + # return UAC flag 0x002 ('ACCOUNT_DISABLE') + return bool(result[1]['userAccountControl'] & 0x002) + +def validate_username(value : str): + # note: slug validator ensures that username only contains [a-z0-9_-] + # usernames can't begin with a numeric + if not value[0].isalpha(): + raise ValidationError(_('Username must begin with a letter')) + # ensure username is lowercase + if not value.islower(): + raise ValidationError(_('Username cannot contain uppercase characters')) + # check if the user exists, this test should catch *most* cases + if subprocess.call(["id", value], stderr=subprocess.DEVNULL) == 0: + raise ValidationError(_('Username already taken (passwd)')) + + # usernames cannot conflict with hostnames + try: + socket.gethostbyname(value) + raise ValidationError( + _('Username already taken (CNAME)') + ) + except socket.gaierror: + pass + # lookup user in ldap, required because not all users are mapped to *nix users + try: + get_user_attrs(value, None) + except LDAPNoSuchObjectResult: + pass + else: + raise ValidationError(_('Username already taken (AD)')) + + +# 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: + # fetch current uac + result = get_user_attrs(username, ['userAccountControl']) + + dn = result.entry_dn + uac = result['userAccountControl'] | 0x002 # set ACCOUNTDISABLE + actions = { + "userAccountControl": [(MODIFY_REPLACE,[uac])], + "userShell": [(MODIFY_REPLACE,["/etc/locked"+str(today.year)])] + } + # write record + ld.modify(dn, actions) + except LDAPOperationResult: + raise + 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: + # fetch current uac + result = get_user_attrs(username, ['userAccountControl']) + + dn = result[0] + uac = result[1]['userAccountControl'] & ~0x002 # clear ACCOUNTDISABLE + actions = { + "userAccountControl": [(MODIFY_REPLACE,[uac])], + "userShell": [(MODIFY_REPLACE,["/bin/zsh"])] + } + # write record + ld.modify(dn, actions) + except LDAPOperationResult: + raise + finally: + ld.unbind() + reason = "account unlocked by uccportal on %s" % str(today) + dispense.set_dispense_flag(username, '!disabled', reason) + +# Account creation +def create_ad_user(form_data, member): + log.info("I: creating new account for %s (%s)") + + # store user details + # TODO add overides + username=form_data['username'] + displayName = member.first_name + ' ' + member.last_name + dn = 'CN=' + username +','+ ldap_user_dn + + # enclose password in quotes and convert to utf16 as required: + # https://msdn.microsoft.com/en-us/library/cc223248.aspx + quotedpass = '"'+ form_data['password']+'"' + utf16pass = quotedpass.encode('utf-16-le') + + # generate uid + try: + result = get_maxuid() + except: + log.error("LDAP: cannot find base uid") + raise + + + maxuid = int(result.msSFU30MaxUidNumber.value) + + # gets all uids >= maxuid + # this is done so that we don't encounter the 1000 item limit to ad queries + entries = get_ldap_attrs(ldap_user_dn,"(uidNumber>=%s)" % maxuid, 100, ['uidNumber']) + + # generate a new uid + uids = [] + for user in entries: + uids.append(int(user.uidNumber.value)) + + uids.sort() + # use max uid if it is free + if uids[0] != maxuid: + newuid = str(maxuid) + else: + prev = uids[0] + for uid in uids: + if uid - prev > 1: + newuid = uid + 1 + break; + prev = uid + #increment uid + newuid = str(prev + 1) + + # sanity check: make sure the uid is free + if subprocess.call(["id", newuid], stderr=subprocess.DEVNULL) == 0: + log.error("LDAP: uid already taken") + raise ValueError + + # create the new user struct + objclass = ['top','posixAccount','person','organizationalPerson','user'] + attrs = { + 'cn' : username, + 'sAMAccountName' : username, + 'givenName' : member.first_name, + 'sn': member.last_name, + 'displayName': displayName, + 'userAccountControl' : '512', + 'unixHomeDirectory' : "/home/ucc/" + username, + 'loginShell' : '/bin/zsh', + 'gidNumber' : '20021', + 'uidNumber' : newuid, + 'gecos' : displayName, + 'mail' : username + '@ucc.gu.uwa.edu.au', + 'unicodePwd': utf16pass + } + + # commit the new user to AD + ld = get_ldap_instance() + result = ld.add(dn, objclass, attrs) + if not result: + log.error("LDAP: user add failed") + raise LDAPOperationsErrorResult + + # set maxuid + result = ld.modify(maxuid_dn, {'msSFU30MaxUidNumber': [(MODIFY_REPLACE, newuid)]}) + if not result: + log.warning("LDAP: user created but msSFU30MaxUidNumber not updated") + + ld.unbind(); + return True; + +def make_home(formdata, member): + user = member.username + mail = formdata['email_address'] if formdata['forward'] else "" + result = subprocess.call(make_home_cmd + [user, mail]) + if result == 0: + return True + else: + raise CalledProcessError + +def subscribe_to_list(member): + result = os.system(make_mail_cmd % (make_mail_key, member.username)) + if result == 0: + return True + else: + raise CalledProcessError + +def set_pin(member, pin): + return + + + diff --git a/src/memberdb/admin.py b/src/memberdb/admin.py index 25d3698ee1fceae61b8ddd7b38dcb43d23460184..060ce356b3d0547a6c5fd9c4ac2e3530f81362f9 100644 --- a/src/memberdb/admin.py +++ b/src/memberdb/admin.py @@ -10,122 +10,144 @@ from gms import admin from memberdb.models import Member, IncAssocMember, Membership from memberdb.actions import download_as_csv 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]) + 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 ReadOnlyModelAdmin(admin.ModelAdmin): - def has_add_permission(self, request): - return False - - def has_delete_permission(self, request, obj=None): - return True + def has_add_permission(self, request): + return False + + def has_delete_permission(self, request, obj=None): + return True - def has_change_permission(self, request, obj=None): - return False + 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): - readonly_fields = ['__str__', 'updated', 'created'] - fields = ['first_name', 'last_name', 'email_address', 'updated', 'created'] - search_fields = ['first_name', 'last_name', 'email_address'] - list_display = readonly_fields - actions = [download_as_csv] - - # add a "go to member" URL into the template context data - def change_view(self, request, object_id, form_url='', extra_context={}): - extra_context['member_edit_url'] = get_model_url(object_id, 'member') - return super().change_view(request, object_id, form_url, extra_context=extra_context) - + readonly_fields = ['__str__', 'updated', 'created'] + fields = ['first_name', 'last_name', 'email_address', 'updated', 'created'] + search_fields = ['first_name', 'last_name', 'email_address'] + list_display = readonly_fields + actions = [download_as_csv] + + # add a "go to member" URL into the template context data + def change_view(self, request, object_id, form_url='', extra_context={}): + extra_context['member_edit_url'] = get_model_url(object_id, 'member') + return super().change_view(request, object_id, form_url, extra_context=extra_context) + class MembershipInline(admin.TabularInline): - model = Membership - readonly_fields = ['member', 'date_submitted'] - radio_fields = {'payment_method': admin.VERTICAL, 'membership_type': admin.VERTICAL} - extra = 0 - fk_name = 'member' + model = Membership + readonly_fields = ['member', 'date_submitted'] + radio_fields = {'payment_method': admin.VERTICAL, 'membership_type': admin.VERTICAL} + extra = 0 + fk_name = 'member' class MemberAdmin(admin.ModelAdmin): - list_display = ['first_name', 'last_name', 'display_name', 'username'] - list_filter = ['is_guild', 'is_student'] - readonly_fields = ['member_updated', 'updated', 'created'] - search_fields = list_display - actions = [download_as_csv] - inlines = [MembershipInline] - - # 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) + list_display = ['first_name', 'last_name', 'display_name', 'username'] + list_filter = ['is_guild', 'is_student'] + readonly_fields = ['member_updated', 'updated', 'created'] + search_fields = list_display + actions = [download_as_csv] + inlines = [MembershipInline] + + # add custom URLs to this model in the admin site + def get_urls(self): + urls = super().get_urls() + custom_urls = [ + path('<object_id>/create/', self.admin_site.admin_view(self.process_account), name='create-account'), + ] + 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 = { + '0': inst, + '1': inst + } + 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', 'member_actions', 'membership_type', 'payment_method', 'approved', 'date_submitted', ] - list_display_links = None - list_filter = ['approved'] - readonly_fields = ['date_submitted'] - radio_fields = {'payment_method': admin.VERTICAL, 'membership_type': admin.VERTICAL} - - # make the admin page queryset preload the parent records (Member) - def get_queryset(self, request): - qs = super().get_queryset(request) - return qs.select_related('member') - - # add custom URLs to this model in the admin site - def get_urls(self): - urls = super().get_urls() - custom_urls = [ - path('<object_id>/approve/', self.admin_site.admin_view(self.process_approve), name='membership-approve'), - ] - return custom_urls + urls - - # display a short summary of relevant member / membership info for pending memberships - def membership_info(self, ms): - context = { - 'ms': ms, - 'member': ms.member, - 'member_url': get_model_url(ms.member.pk, 'member'), - } - html = render_to_string('admin/memberdb/membership_summary.html', context) - return mark_safe(html) - - membership_info.short_description = 'Membership info' - membership_info.allow_tags = True - - # called per record, returns HTML to display under the "Actions" column - def member_actions(self, ms): - context = { - 'ms': ms, - 'member': ms.member, - 'member_url': get_model_url(ms.member.pk, 'member'), - 'member_approve': reverse('admin:membership-approve', args=[ms.pk]) - } - html = render_to_string('admin/memberdb/membership_actions.html', context) - return mark_safe(html) - - member_actions.short_description = 'Actions' - member_actions.allow_tags = True - - def process_approve(self, request, *args, **kwargs): - return MembershipApprovalAdminView.as_view(admin=self)(request, *args, **kwargs) - -""" + list_display = ['membership_info', 'membership_type', 'payment_method', 'approved', 'date_submitted', 'member_actions', ] + list_display_links = None + list_filter = ['approved'] + readonly_fields = ['date_submitted'] + radio_fields = {'payment_method': admin.VERTICAL, 'membership_type': admin.VERTICAL} + + # make the admin page queryset preload the parent records (Member) + def get_queryset(self, request): + qs = super().get_queryset(request) + return qs.select_related('member') + + # add custom URLs to this model in the admin site + def get_urls(self): + urls = super().get_urls() + custom_urls = [ + path('<object_id>/approve/', self.admin_site.admin_view(self.process_approve), name='membership-approve'), + ] + return custom_urls + urls + + # display a short summary of relevant member / membership info for pending memberships + def membership_info(self, ms): + context = { + 'ms': ms, + 'member': ms.member, + 'member_url': get_model_url(ms.member.pk, 'member'), + } + html = render_to_string('admin/memberdb/membership_summary.html', context) + return mark_safe(html) + + membership_info.short_description = 'Membership info' + membership_info.allow_tags = True + + # called per record, returns HTML to display under the "Actions" column + def member_actions(self, ms): + context = { + 'ms': ms, + 'member': ms.member, + 'member_url': get_model_url(ms.member.pk, 'member'), + 'member_approve': reverse('admin:membership-approve', args=[ms.pk]), + 'create_account': reverse('admin:create-account', args=[ms.member.pk]) + } + html = render_to_string('admin/memberdb/membership_actions.html', context) + return mark_safe(html) + + member_actions.short_description = 'Actions' + member_actions.allow_tags = True + + 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): - class Meta: - proxy = True + class Meta: + proxy = True class PendingMembershipAdmin(MembershipAdmin): - def get_queryset(self, request): - return self.model.objects.filter(approved__exact=False) + def get_queryset(self, request): + return self.model.objects.filter(approved__exact=False) # Register the other models with either default admin site pages or with optional customisations admin.site.register(Member, MemberAdmin) diff --git a/src/memberdb/approve.py b/src/memberdb/approve.py index 36bf5171a66f8390213faa565cab00946a14916b..8a4e3271a5fe05bc5b4586a7bdf4b8a0bea52eac 100644 --- a/src/memberdb/approve.py +++ b/src/memberdb/approve.py @@ -117,8 +117,8 @@ class MembershipApprovalAdminView(MyUpdateView): self.admin.message_user(self.request, 'Approve success') url = reverse( - 'admin:memberdb_membership_change', - args=[ms.pk], + 'admin:memberdb_membership_changelist', + args=[], current_app=self.admin.admin_site.name, ) return HttpResponseRedirect(url) diff --git a/src/memberdb/forms.py b/src/memberdb/forms.py index 5f6657a42d5e55a05e43335673198313d537f80f..340c30739fe878eb5518703ee94632ec3708aaba 100644 --- a/src/memberdb/forms.py +++ b/src/memberdb/forms.py @@ -9,6 +9,13 @@ class MyModelForm(forms.ModelForm): # this must be passed by kwargs upon instantiating the form request = None + def __init__(self, *args, **kwargs): + self.request = kwargs.pop("request") + super().__init__(*args, **kwargs) +class MyForm(forms.Form): + # this must be passed by kwargs upon instantiating the form + request = None + def __init__(self, *args, **kwargs): self.request = kwargs.pop("request") super().__init__(*args, **kwargs) @@ -16,4 +23,4 @@ class MyModelForm(forms.ModelForm): class MemberHomeForm(MyModelForm): class Meta: model = Member - fields = ['display_name', 'email_address', 'phone_number'] + fields = [ 'email_address', 'phone_number'] diff --git a/src/memberdb/makehomes.sh b/src/memberdb/makehomes.sh new file mode 100644 index 0000000000000000000000000000000000000000..e69de29bb2d1d6434b8b29ae775ad8c2e48c5391 diff --git a/src/memberdb/models.py b/src/memberdb/models.py index 8dd7e3851b07750ce64ce39c297c2ed575385a4d..d200ad930a9b0174c3a911e06a79e61033978eaa 100644 --- a/src/memberdb/models.py +++ b/src/memberdb/models.py @@ -5,201 +5,232 @@ from django.core.management.utils import get_random_string from django.urls import reverse from squarepay.dispense import get_item_price +import subprocess + +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') +] + +ID_TYPES = [ + ('student', 'Student ID'), + ('drivers', 'Drivers licence'), + ('passport', 'Passport'), + ('Other', 'Other ID'), ] +ACCOUNT_STATUS = [ + 'enabled', + 'disabled', + 'no account' + ] + + + 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 ('Given Name', max_length=200) + last_name = models.CharField ('Other Names', max_length=200) + email_address = models.EmailField ('Contact email', 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=True, blank=True, unique=False, 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 number or other ID', max_length=255, blank=False, help_text="") + id_desc = models.CharField ('Form of ID provided', max_length=255, blank=False, help_text="Please describe the type of identification provided", choices=ID_TYPES, default="student") + + # 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) + + has_account = models.BooleanField ('Has AD account', null=False, editable=False, default=False) + + # account info + def get_uid(self): + result, uid = subprocess.getstatusoutput(["id", "-u", self.username]) + if (result == 0): + return uid; + else: + return None; + + 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/memberdb/register.py b/src/memberdb/register.py index 60f1ba38e8820a922afe2098a1089eea8baca7e0..dbaa8d6bf5e3ba1ee6168223d6df7e4be94ae748 100644 --- a/src/memberdb/register.py +++ b/src/memberdb/register.py @@ -23,119 +23,152 @@ First step: enter an email address and some details (to fill at least a Member m see https://docs.djangoproject.com/en/2.1/ref/models/fields/#error-messages and https://docs.djangoproject.com/en/2.1/ref/forms/fields/#error-messages """ -class RegisterForm(MyModelForm): - confirm_email = forms.EmailField(label='Confirm your email address', required=False) - agree_tnc = forms.BooleanField(label='I agree to the terms & conditions', required=True, help_text=mark_safe( - "You agree to abide by the UCC Constitution, rulings of the UCC Committee, UCC and " - "UWA’s Network Usage Guidelines and that you will be subscribed to the UCC Mailing List. <br>" - '<b>Policies can be found <a href="https://www.ucc.asn.au/infobase/policies.ucc">here</a>.</b>')) - membership_type = forms.ChoiceField(label='Select your membership type', required=True, choices=get_membership_choices(is_renew=False)) - - class Meta: - model = Member - fields = ['first_name', 'last_name', 'username', 'phone_number', 'is_student', 'is_guild', 'id_number', 'email_address'] - error_messages = { - 'username': { - 'unique': 'This username is already taken, please pick another one.', - 'invalid': 'Please pick a username with only lowercase letters and numbers' - } - } - - def clean(self): - try: - if (self['email_address'].value() != self['confirm_email'].value()): - self.add_error('email_address', 'Email addresses must match.') - except: - pass - super().clean(); - - def save(self, commit=True): - # get the Member model instance (ie. a record in the Members table) based on submitted form data - m = super().save(commit=False) - if (m.display_name == ""): - m.display_name = "%s %s" % (m.first_name, m.last_name); - # 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) - - if (commit): - ms.save(); - return m, ms - -class RenewForm(RegisterForm): - confirm_email = None - membership_type = forms.ChoiceField(label='Select your membership type', required=True, choices=get_membership_choices(is_renew=True)) - - class Meta(RegisterForm.Meta): - fields = ['first_name', 'last_name', 'phone_number', 'is_student', 'is_guild', 'id_number', 'email_address'] - - def save(self, commit=True): - m, ms = super().save(commit=False) - m.username = self.request.user.username - if (commit): - m.save() - ms.save() - return m, ms +class RegisterRenewForm(MyModelForm): + confirm_email = forms.EmailField(label='Confirm your email address', required=False) + agree_tnc = forms.BooleanField(label='I agree to the terms & conditions', required=True, help_text=mark_safe( + "You agree to abide by the UCC Constitution, rulings of the UCC Committee, UCC and " + "UWA’s Network Usage Guidelines and that you will be subscribed to the UCC Mailing List. <br>" + '<b>Policies can be found <a href="https://www.ucc.asn.au/infobase/policies.ucc">here</a>.</b>')) + membership_type = forms.ChoiceField(label='Select your membership type', required=True, choices=get_membership_choices(is_renew=False)) + + class Meta: + model = Member + fields = ['first_name', 'last_name', 'phone_number', 'is_student', 'is_guild', 'id_number', 'id_desc', 'email_address'] + error_messages = { + 'username': { + 'invalid': 'Please pick a username with only lowercase letters and numbers' + } + } + + def clean(self): + try: + 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.') + except: + pass + super().clean(); + + def save(self, commit=True): + # get the Member model instance (ie. a record in the Members table) based on submitted form data + m = super().save(commit=False) + if (m.display_name == ""): + m.display_name = "%s %s" % (m.first_name, m.last_name); + # 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) + + if (commit): + ms.save(); + return m, ms + +class RegisterForm(RegisterRenewForm): + username = forms.CharField( + label='Preferred Username (optional)', + required=False, + help_text="This will be the username you use to access club systems. You may leave this blank to choose a username later" + ) + + class Meta(): + model = Member + fields = ['first_name', 'last_name', 'username', 'phone_number', 'is_student', 'is_guild', 'id_number', 'id_desc', 'email_address'] + + + def clean(self): + try: + if (self['email_address'].value() != self['confirm_email'].value()): + self.add_error('email_address', 'Email addresses must match.') + if (self['email_address'].value().split('@')[1] in ["ucc.asn.au", "ucc.gu.uwa.edu.au"]): + self.add_error('email_address', 'Contact address cannot be an UCC address.') + except: + pass + super().clean(); + + +class RenewForm(RegisterRenewForm): + confirm_email = None + membership_type = forms.ChoiceField(label='Select your membership type', required=True, choices=get_membership_choices(is_renew=True)) + + class Meta: + model = Member + fields = ['first_name', 'last_name', 'phone_number', 'is_student', 'is_guild', 'id_number', 'id_desc', 'email_address'] + exclude = ['username'] + + 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() + return m, ms """ simple FormView which displays registration form and handles template rendering & form submission """ 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 form_valid(self, form): - # save the member data and get the Member instance - m, ms = form.save() - messages.success(self.request, 'Your registration has been submitted.') - - # don't set the member session info - user can click on the link - #self.request.session['member_id'] = m.id - return thanks_view(self.request, m, ms) + 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 form_valid(self, form): + # save the member data and get the Member instance + m, ms = form.save() + messages.success(self.request, 'Your registration has been submitted.') + + # don't set the member session info - user can click on the link + #self.request.session['member_id'] = m.id + if self.request.user.is_staff: + return HttpResponseRedirect(reverse("admin:membership-approve",args=[ms.pk])) + else: + return thanks_view(self.request, m, ms) def thanks_view(request, member, ms): - """ display a thankyou page after registration is completed """ - context = { - 'member': member, - 'ms': ms, - 'login_url': reverse('memberdb:login_member', kwargs={'username': member.username, 'member_token': member.login_token}), - } - return render(request, 'thanks.html', context) + """ display a thankyou page after registration is completed """ + context = { + 'member': member, + 'ms': ms, + 'login_url': reverse('memberdb:login_member', kwargs={'id' : member.id, 'member_token': member.login_token}), + } + return render(request, 'thanks.html', context) class RenewView(LoginRequiredMixin, MyUpdateView): - template_name = 'renew.html' - form_class = RenewForm - model = Member - - def get_object(self): - u = self.request.user - - 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 - - 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, - }) - return context - - def form_valid(self, form): - m, ms = form.save() - messages.success(self.request, 'Your membership renewal has been submitted.') - return HttpResponseRedirect(reverse("memberdb:home")) + template_name = 'renew.html' + form_class = RenewForm + model = Member + + def get_object(self): + u = self.request.user + + 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 + + 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, + }) + return context + + def form_valid(self, form): + m, ms = form.save() + messages.success(self.request, 'Your membership renewal has been submitted.') + if self.request.user.is_staff: + return HttpResponseRedirect(reverse("admin:membership-approve",args=[ms.pk])) + else: + return HttpResponseRedirect(reverse("memberdb:home")) diff --git a/src/memberdb/root_actions.py b/src/memberdb/root_actions.py new file mode 100644 index 0000000000000000000000000000000000000000..5fffc687013caaa8922214fa54e4440f64c3f74a --- /dev/null +++ b/src/memberdb/root_actions.py @@ -0,0 +1,68 @@ +#!/bin/python3 +import sys +import os +import shutil +import subprocess + + + ### ###### ## ## ######## ## ## ## ## ###### + ## ## ## ## ## ## ## ## ## ### ## ## ## + ## ## ## ## ## ## ## ## #### ## ## +## ## ## ######### ## ## ## ## ## ## ## #### +######### ## ## ## ## ## ## ## #### ## ## +## ## ## ## ## ## ## ## ## ## ### ## ## +## ## ###### ## ## ## ####### ## ## ###### + + # this script runs with elevated permissions # + # be very careful with what you do # + +def main(): + + os.umask(0o077) + + if len(sys.argv) != 3: + exit(1) + user = sys.argv[1] + mail = sys.argv[2] + + # abort if user does not exist + if subprocess.call(["id", user], stderr=subprocess.DEVNULL) != 0: + exit(1) + + homes = { + ('/home/ucc/%s' % user, '/home/wheel/bin/skel/ucc'), + ('/away/ucc/%s' % user, '/home/wheel/bin/skel/away') + } + # make homes + try: + for home,skel in homes: + shutil.copytree(skel,home,copy_function=copy) + os.system('chown -R %s:gumby %s' % (user, home)) + + home = homes[0][0] + # set world writable (for webpage) + os.system('chmod a+x %s' % home) + os.system('chmod a+rX %s/public-html' % home) + except: + exit(1) + + # write .forward + try: + if (mailaddr != ""): + forward = '%s/.forward' % home + f = open(forward,"w") + f.write(mailaddr) + f.close() + shutil.chown(forward,user,"gumby") + os.chmod(forward, 0o644) + except: + exit(1) + + +if __name__ == "__main__": + main() + exit(0) + + + + diff --git a/src/memberdb/urls.py b/src/memberdb/urls.py index 5c2f6545e5d61021af8d700ba7459fabbf4015fb..4dce9419bb1a1fd0a5142b6eac0a3a65d3f3f52d 100644 --- a/src/memberdb/urls.py +++ b/src/memberdb/urls.py @@ -19,7 +19,7 @@ urlpatterns = [ path('admin/logout/', auth_views.LogoutView.as_view(template_name='logout.html')), # for members to "login" before having created a user account - path('login/<username>/<member_token>/', MemberTokenView.as_view(), name='login_member'), + path('login/<id>/<member_token>/', MemberTokenView.as_view(), name='login_member'), # email confirmation path('confirm/<int:pk>/<str:token>/', EmailConfirmView.as_view(), name='email_confirm'), diff --git a/src/memberdb/views.py b/src/memberdb/views.py index a1c067039bec3e19244e17bb18f195a29961d801..32ce1762d16d3600082f85af132c44d7dedc60a3 100644 --- a/src/memberdb/views.py +++ b/src/memberdb/views.py @@ -9,6 +9,7 @@ from django.views.generic.base import View from django.views.generic.edit import UpdateView 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 .forms import MemberHomeForm @@ -80,6 +81,25 @@ class MyUpdateView(UpdateView): kwargs.update({'request': self.request}) return kwargs +class MyWizardView(SessionWizardView): + object = None + + def get_object(self): + if (not self.object is None): + return self.object + try: + sobj = super().get_object() + if (not sobj is None): + return sobj + except: + pass + return None + + def get_form_kwargs(self, step, **kwargs): + kwargs.update(super().get_form_kwargs()) + kwargs.update({'request': self.request}) + return kwargs + class MemberHomeView(MemberAccessMixin, MyUpdateView): model = Member template_name = 'home.html' @@ -128,7 +148,7 @@ class MemberTokenView(View): try: member = Member.objects.get( login_token=kwargs['member_token'], - username=kwargs['username'], + id=kwargs['id'], created__gte=week_ago ) except Member.DoesNotExist: diff --git a/src/squarepay/dispense.py b/src/squarepay/dispense.py index 379913237be9f06eac810c5525ece3b400c2ce0d..e32393578c39716e0fbff21c9ba9254b37a9caaa 100644 --- a/src/squarepay/dispense.py +++ b/src/squarepay/dispense.py @@ -12,40 +12,73 @@ 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: " + 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 + 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) + """ 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) + +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 + + 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; + +def make_dispense_account(user, pin): + if DISPENSE_BIN is None: + log.warning("DISPENSE_BIN is not defined") + return False + + cmdargs = [ + ("user","add", user), + ("acct",user,"+500","treasurer: new user"), + ("-u", user, "pinset", pin) + ] + + + for args in cmdargs: + cmd = [DISPENSE_BIN] + args + out = run_dispense('user', 'type', user, flag, reason) + if out == None: + raise CalledProcessError + + return True; diff --git a/src/squarepay/dispense.py.orig b/src/squarepay/dispense.py.orig new file mode 100644 index 0000000000000000000000000000000000000000..fbce946c21781aeb4ab90fd60527ed758afbf57e --- /dev/null +++ b/src/squarepay/dispense.py.orig @@ -0,0 +1,102 @@ +""" +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 diff --git a/src/squarepay/migrations/0001_initial.py b/src/squarepay/migrations/0001_initial.py new file mode 100644 index 0000000000000000000000000000000000000000..2bcf8bd51021f2f26872c739baef515a90eaa06c --- /dev/null +++ b/src/squarepay/migrations/0001_initial.py @@ -0,0 +1,38 @@ +# 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/static/account_form.js b/src/static/account_form.js new file mode 100644 index 0000000000000000000000000000000000000000..4460b2b5312d059de71407396b026a03987f9148 --- /dev/null +++ b/src/static/account_form.js @@ -0,0 +1,13 @@ + +var conditional_fields = $("#1-email_address"); +if (!$("#id_1-forward").prop('checked') === true) { + conditional_fields.hide(); +} + +$("#id_1-forward").change(function() { + if ($(this).prop('checked') === true) { + conditional_fields.show(); + } else { + conditional_fields.hide(); + } +}); \ No newline at end of file diff --git a/src/static/admin_custom.css b/src/static/admin_custom.css index 629d6c09b024c7598d1fecd17bd3941a52af0a9b..00ce3db898ca8871eb330fc763d319be52f92bc7 100644 --- a/src/static/admin_custom.css +++ b/src/static/admin_custom.css @@ -34,6 +34,7 @@ overflow: auto; } + .button { display: inline-block; } diff --git a/src/static/shared.css b/src/static/shared.css index dc9e6ceb24e1fcbb0c03e83f3fa8938e690ae982..f138c8f769bbf51e74c5249e8de59a73337c504e 100644 --- a/src/static/shared.css +++ b/src/static/shared.css @@ -115,7 +115,7 @@ ul.messagelist li.error { /* FORM BUTTONS */ -.button, input[type=submit], input[type=button], .submit-row input, a.button { +.button, input[type=submit], input[type=button], .submit-row input,.submit-row button, a.button { background: #79aec8; padding: 10px 15px; border: none; diff --git a/src/templates/admin/memberdb/account_create.html b/src/templates/admin/memberdb/account_create.html new file mode 100644 index 0000000000000000000000000000000000000000..cebe02f1209314d1045efb52c0f73f3d8485ee6e --- /dev/null +++ b/src/templates/admin/memberdb/account_create.html @@ -0,0 +1,63 @@ +{% extends "admin/change_form.html" %} +{% load i18n admin_static admin_modify admin_urls %} + +{% load static %} + +{% block extrahead %} +{{ block.super }} +{# This makes use of some hacky javascript to replace the default "actions" dropdown list with more user-friendly buttons for each action. #} +<script type="text/javascript" src="{% static 'admin/js/vendor/jquery/jquery.js' %}"></script> +{% endblock %} + + +{% block content %} +<div id="content-main"> +<h1>Create Account for <i>{{ member.first_name }} {{ member.last_name }}</i></h1> + <div class="ms-approve-summary"> + {% include "admin/memberdb/membership_summary.html" %} + </div> +<form action="" method="POST"> + {{ wizard.management_form }} + {% csrf_token %} + + {% if form.non_field_errors|length > 0 %} + <p class="errornote"> + Please correct the errors below. + </p> + {{ form.non_field_errors }} + {% endif %} + + {{ wizard.form.management_form }} + <fieldset class="module aligned"> + {% for field in wizard.form %} + <div class="form-row" id="{{ field.html_name}}"> + {{ field.label_tag }} + {{ field }} + {{ field.errors }} + {% if field.field.help_text %} + <p class="help"> + {{ field.field.help_text|safe }} + </p> + {% endif %} + </div> + {% endfor %} + </fieldset> + <div class="submit-row"> + {% if wizard.steps.prev %} + <button name="wizard_goto_step" class="button" type="submit" value="{{ wizard.steps.prev }}"> prev </button> + {% endif %} + + {% if wizard.steps.next %} + <input type="submit" value="{% trans "next" %}"/> + {% else %} + <input type="submit" value="{% trans "finish" %}"/> + {%endif%} + </div> + + {% if wizard.steps.index == 1 %} + <script type="text/javascript" src="{% static 'account_form.js' %}"></script> + {% endif %} +</form> +</div> + +{% endblock %} diff --git a/src/templates/admin/memberdb/membership_actions.html b/src/templates/admin/memberdb/membership_actions.html index 6073fe9412c12194dc85e258f034a478713d78a6..8bced93e869e7e28a0dc950bfff6e72455cd54a6 100644 --- a/src/templates/admin/memberdb/membership_actions.html +++ b/src/templates/admin/memberdb/membership_actions.html @@ -1,7 +1,10 @@ {% load static %}{# this template is used to generate the member action buttons, see memberdb/admin.py #} <div class="member-actions"> - <a class="button" href="{{ member_url }}">Edit</a> - {% if not ms.approved %} - <a class="button" href="{{ member_approve }}">Approve</a> - {% endif %} + <a class="button" href="{{ member_url }}">Edit</a> + {% if not ms.approved %} + <a class="button" href="{{ member_approve }}">Approve</a> + {% endif %} + {% if not member.has_account %} + <a class="button" href="{{ create_account }}">Create Account</a> + {% endif %} </div> diff --git a/src/templates/base.html b/src/templates/base.html index e152ae96e194ed63a3408ef0a6671bcb832e0049..06849d276ae778da669496f2e8a8167ad4a7ae83 100644 --- a/src/templates/base.html +++ b/src/templates/base.html @@ -2,96 +2,115 @@ <!DOCTYPE html> <html> <head> - <!-- MemberDB base template --> - <title>{% block title %}UCC MemberDB{% endblock %}</title> - <link rel="shortcut icon" type="image/png" href="{% static 'ucc-logo.png' %}"/> - <link rel="stylesheet" type="text/css" href="{% static "memberdb.css" %}"> - {% block prestyle %}{% endblock %} - <link rel="stylesheet" type="text/css" href="{% static "shared.css" %}"> - {% block extrahead %}{% endblock %} - {% block extrastyle %}{% endblock %} - {% block blockbots %}<meta name="robots" content="NONE,NOARCHIVE">{% endblock %} + <!-- MemberDB base template --> + <title>{% block title %}UCC MemberDB{% endblock %}</title> + <link rel="shortcut icon" type="image/png" href="{% static 'ucc-logo.png' %}"/> + <link rel="stylesheet" type="text/css" href="{% static "memberdb.css" %}"> + {% block prestyle %}{% endblock %} + <link rel="stylesheet" type="text/css" href="{% static "shared.css" %}"> + {% block extrahead %}{% endblock %} + {% block extrastyle %}{% endblock %} + {% block blockbots %}<meta name="robots" content="NONE,NOARCHIVE">{% endblock %} </head> <body {% block bodyattrs %}{% endblock %}> + <!-- Container --> <div id="container"> + {% block header_div %}<!-- Header --> + <div id="header"> + {% block navbar %} + <nav> + {% block branding %} + <a class="logo" title="UCCPortal homepage"> + <span></span> + </a> + {% endblock %} + + {# fancy automatic navbar thing from https://stackoverflow.com/questions/39639264 #} + {% with url_name=request.resolver_match.url_name %} + {% if request.member %} + <a class="navtab {% if url_name == 'home' %}active{% endif %}" href="{% url "memberdb:home" %}">Member home</a> + {% endif %} + <a class="navtab {% if url_name == 'register' %}active{% endif %}" href="{% url "memberdb:register" %}">Register</a> - {% block header_div %}<!-- Header --> - <div id="header"> - {% block navbar %} - <nav> - {% block branding %} - <a class="logo" title="UCCPortal homepage"> - <span></span> - </a> - {% endblock %} - - {# fancy automatic navbar thing from https://stackoverflow.com/questions/39639264 #} - {% with url_name=request.resolver_match.url_name %} - {% if request.member %} - <a class="navtab {% if url_name == 'home' %}active{% endif %}" href="{% url "memberdb:home" %}">Member home</a> - {% endif %} - <a class="navtab {% if url_name == 'register' %}active{% endif %}" href="{% url "memberdb:register" %}">Register</a> + {% if not request.user.is_authenticated %} + <a class="navtab {% if url_name == 'login' %}active{% endif %}" href="{% url "memberdb:login" %}">Login</a> + {% else %} + <a class="navtab {% if url_name == 'renew' %}active{% endif %}" href="{% url "memberdb:renew" %}">Renew membership</a> - {% if not request.user.is_authenticated %} - <a class="navtab {% if url_name == 'login' %}active{% endif %}" href="{% url "memberdb:login" %}">Login</a> - {% else %} - <a class="navtab {% if url_name == 'renew' %}active{% endif %}" href="{% url "memberdb:renew" %}">Renew membership</a> + {% if request.user.is_staff %} + <a class="navtab {% block adminactive %}{% endblock %}" href="{% url "admin:index" %}">Admin site</a> + {% endif %} - {% if request.user.is_staff %} - <a class="navtab {% block adminactive %}{% endblock %}" href="{% url "admin:index" %}">Admin site</a> - {% endif %} + {% endif %} - {% endif %} + {% if request.user.is_authenticated or request.member %} + <a class="navtab {% if url_name == 'logout' %}active{% endif %}" href="{% url "memberdb:logout" %}">Logout</a> + {% endif %} + {% endwith %} + </nav> + {% endblock %} - {% if request.user.is_authenticated or request.member %} - <a class="navtab {% if url_name == 'logout' %}active{% endif %}" href="{% url "memberdb:logout" %}">Logout</a> - {% endif %} - {% endwith %} - </nav> - {% endblock %} + {% block header %}{% endblock %} - {% block header %}{% endblock %} - - </div> - {% endblock %}<!-- END Header --> - {% block breadcrumbs %}{% endblock %} - {% block messages %}{% if messages %} - <ul class="messagelist">{% for message in messages %} - <li{% if message.tags %} class="{{ message.tags }}"{% endif %}>{{ message|capfirst }}</li> - {% endfor %}</ul> - {% endif %}{% endblock messages %} + </div> + {% endblock %}<!-- END Header --> + {% block breadcrumbs %}{% endblock %} + {% block messages %}{% if messages %} + <ul class="messagelist">{% for message in messages %} + <li{% if message.tags %} class="{{ message.tags }}"{% endif %}>{{ message|capfirst }}</li> + {% endfor %}</ul> + {% endif %}{% endblock messages %} - <!-- Content --> - <div id="content"> - {% block pretitle %}{% endblock %} - {% block content_title %}<h1></h1>{% endblock %} - {% block content %} - {% endblock %} - <!-- sidebar --> - {% block sidebar %}{% endblock %} - <br class="clear"> - </div> - <!-- END Content --> + <!-- Content --> + <div id="content"> + {% block pretitle %}{% endblock %} + {% block content_title %}<h1></h1>{% endblock %} + {% block content %} + {% endblock %} + <!-- sidebar --> + {% block sidebar %}{% endblock %} + <br class="clear"> + </div> + <!-- END Content --> - <!-- Footer --> - {% block footer %} - <div id="footer"> - <p> - The University Computer Club Inc. - ABN: 98 843 368 069 - </p> - <div class="linkslist"> - <a href="https://www.ucc.asn.au">main website</a>• - <a href="https://wiki.ucc.asn.au">UCC wiki</a>• - <a href="https://www.ucc.asn.au/aboutucc/contact.ucc">contact us</a>• - <a href="https://gitlab.ucc.asn.au/frekk/uccportal">source code</a> - </div> - </div> - {% endblock %} - <!-- END Footer --> + <!-- Footer --> + {% block footer %} + <div id="footer"> + <p> + The University Computer Club Inc. + ABN: 98 843 368 069 + </p> + <div class="linkslist"> + <a href="https://www.ucc.asn.au">main website</a>• + <a href="https://wiki.ucc.asn.au">UCC wiki</a>• + <a href="https://www.ucc.asn.au/aboutucc/contact.ucc">contact us</a>• + <a href="https://gitlab.ucc.asn.au/frekk/uccportal">source code</a> + </div> + </div> + {% endblock %} + <!-- END Footer --> +{% if DEPLOYMENT_ENV != "PROD" %} +<div class="watermark">{{DEPLOYMENT_ENV}}</div> +<style> + .watermark { + position: fixed; + transform: translate(-50%, -50%); + opacity: 0.3; + {% if DEPLOYMENT_ENV == "STAGE" %} + color: RED; + {% else %} + color: GREEN; + {% endif %} + font-size: 15em; + top: 50%; + left: 50%; + pointer-events: none; + } +</style> +{%endif %} </div> <!-- END Container --> diff --git a/src/templates/membership_summary.html b/src/templates/membership_summary.html index aeffa35b3f30715546aab9fd453ebb67de50e41e..29d69251a8f0afc62df4c2cdd54132c969a19dd2 100644 --- a/src/templates/membership_summary.html +++ b/src/templates/membership_summary.html @@ -19,7 +19,7 @@ {% block email %} <tr class="{% cycle rcl %}"> - <th>Email</th> + <th>Contact email</th> <td> {% if member.email_address %} <a href="mailto:{{ member.email_address }}">{{ member.email_address }}</a> @@ -47,9 +47,13 @@ <td>{% if member.is_guild %}<img src="{% static 'admin/img/icon-yes.svg' %}" alt="yes">{% else %}<img src="{% static 'admin/img/icon-no.svg' %}" alt="no">{% endif %}</td> </tr> <tr class="{% cycle rcl %}"> - <th>Student email / Drivers license</th> + <th>Student number or Other ID</th> <td>{% if member.id_number %}{{ member.id_number }}{% else %}<i>not provided</i>{% endif %}</td> </tr> + <tr class="{% cycle rcl %}"> + <th>ID type</th> + <td>{% if member.id_desc %}{{ member.id_desc }}{% else %}<i>not provided</i>{% endif %}</td> + </tr> {% endblock %} </table> {% endblock %}