Commit 2da3dade authored by frekk's avatar frekk

Merge branch 'frekk-testing' into 'master'

Add features & fix bugs

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