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>&nbsp;
-    {% if not ms.approved %}
-    <a class="button" href="{{ member_approve }}">Approve</a>&nbsp;
-    {% endif %}
+	<a class="button" href="{{ member_url }}">Edit</a>&nbsp;
+	{% if not ms.approved %}
+	<a class="button" href="{{ member_approve }}">Approve</a>&nbsp;
+	{% endif %}
+	{% if not member.has_account %}
+	<a class="button" href="{{ create_account }}">Create Account</a>&nbsp;
+	{% 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>&#8226;
-            <a href="https://wiki.ucc.asn.au">UCC wiki</a>&#8226;
-            <a href="https://www.ucc.asn.au/aboutucc/contact.ucc">contact us</a>&#8226;
-            <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>&#8226;
+			<a href="https://wiki.ucc.asn.au">UCC wiki</a>&#8226;
+			<a href="https://www.ucc.asn.au/aboutucc/contact.ucc">contact us</a>&#8226;
+			<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 %}