Add new user setup workflow to walk the user through confirming their profile information, choosing categories, finding teams and attending events. Fixes #23

This commit is contained in:
Michael Hall 2018-03-04 14:10:37 -05:00
parent 8d9bfa0c8d
commit a76076e58a
20 changed files with 488 additions and 31 deletions

View file

@ -5,7 +5,8 @@ from .models import Account, Badge, BadgeGrant, EmailConfirmation
# Register your models here.
class AccountAdmin(admin.ModelAdmin):
list_display = ('user', 'acctname', 'email', 'is_email_confirmed')
list_display = ('user', 'acctname', 'email', 'is_email_confirmed', 'has_completed_setup')
list_filter = ('is_email_confirmed', 'has_completed_setup')
def email(self, obj):
return obj.user.email
email.short_description = 'Email'

27
accounts/decorators.py Normal file
View file

@ -0,0 +1,27 @@
from functools import wraps
from django.contrib.auth.decorators import login_required
from django.contrib.auth.views import redirect_to_login
from django.contrib.auth import REDIRECT_FIELD_NAME
from django.shortcuts import render, redirect, resolve_url
from django.conf import settings
from .models import Account
def setup_wanted(view_func, setup_url=None, redirect_field_name=REDIRECT_FIELD_NAME):
"""
Decorator for views that checks that the user has completed the setup
process, redirecting to settings.SETUP_URL if required
"""
@wraps(view_func)
def wrap(request, *args, **kwargs):
if not request.user.is_authenticated or request.user.account.has_completed_setup:
return view_func(request, *args, **kwargs)
else:
resolved_setup_url = resolve_url(setup_url or settings.SETUP_URL)
path = request.get_full_path()
return redirect_to_login(
path, resolved_setup_url, redirect_field_name)
return wrap
setup_required = login_required(setup_wanted)

View file

@ -0,0 +1,23 @@
# Generated by Django 2.0 on 2018-03-04 16:49
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('accounts', '0002_auto_20180226_1532'),
]
operations = [
migrations.AddField(
model_name='account',
name='has_completed_setup',
field=models.BooleanField(default=False),
),
migrations.AddField(
model_name='account',
name='setup_completed_date',
field=models.DateTimeField(blank=True, null=True),
),
]

View file

@ -16,11 +16,19 @@ class Account(models.Model):
acctname = models.CharField(_("Account Name"), max_length=150, blank=True)
is_email_confirmed = models.BooleanField(default=False)
has_completed_setup = models.BooleanField(default=False)
setup_completed_date = models.DateTimeField(blank=True, null=True)
badges = models.ManyToManyField('Badge', through='BadgeGrant')
class Meta:
ordering = ('user__username',)
def setup_complete(self):
self.has_completed_setup = True
self.setup_completed_date = datetime.datetime.now()
self.save()
def new_confirmation_request(self):
valid_for = getattr(settings, 'EMAIL_CONFIRMAION_EXPIRATION_DAYS', 5)
confirmation_key=get_random_string(length=32)

View file

@ -59,8 +59,15 @@ class EventAdmin(admin.ModelAdmin):
attendee_count.short_description = 'Attendees'
admin.site.register(Event, EventAdmin)
admin.site.register(Member)
admin.site.register(Attendee)
class MemberAdmin(admin.ModelAdmin):
list_display = ('__str__', 'role')
list_filter = ('role', 'team')
admin.site.register(Member, MemberAdmin)
class AttendeeAdmin(admin.ModelAdmin):
list_display = ('__str__', 'role', 'status')
list_filter = ('role', 'status')
admin.site.register(Attendee, AttendeeAdmin)
class CategoryAdmin(admin.ModelAdmin):
list_display = ('name', 'image')

View file

@ -220,6 +220,11 @@ class UserProfileForm(forms.ModelForm):
'send_notifications': _('Send me notification emails'),
}
class ConfirmProfileForm(forms.ModelForm):
class Meta:
model = UserProfile
fields = ['realname', 'tz']
class SendNotificationsForm(forms.ModelForm):
class Meta:
model = UserProfile

View file

@ -0,0 +1,23 @@
# Generated by Django 2.0 on 2018-03-04 16:49
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('events', '0012_auto_20180227_0358'),
]
operations = [
migrations.AddField(
model_name='userprofile',
name='categories',
field=models.ManyToManyField(blank=True, to='events.Category'),
),
migrations.AddField(
model_name='userprofile',
name='topics',
field=models.ManyToManyField(blank=True, to='events.Topic'),
),
]

View file

@ -14,7 +14,7 @@ class UserProfile(models.Model):
user = models.OneToOneField(User, on_delete=models.CASCADE)
realname = models.CharField(verbose_name=_("Real Name"), max_length=150, blank=True)
tz = models.CharField(max_length=32, verbose_name=_('Timezone'), default='UTC', choices=[(tz, tz) for tz in pytz.all_timezones], blank=False, null=False, help_text=_('The most commonly used timezone for this User.'))
tz = models.CharField(max_length=32, verbose_name=_('Timezone'), default='UTC', choices=[(tz, tz) for tz in pytz.all_timezones], blank=False, null=False)
avatar = models.URLField(verbose_name=_("Photo Image"), max_length=150, blank=True, null=True)
web_url = models.URLField(verbose_name=_('Website URL'), blank=True, null=True)
@ -23,6 +23,9 @@ class UserProfile(models.Model):
send_notifications = models.BooleanField(verbose_name=_('Send notification emails'), default=True)
categories = models.ManyToManyField('Category', blank=True)
topics = models.ManyToManyField('Topic', blank=True)
class Meta:
ordering = ('user__username',)

View file

@ -51,6 +51,7 @@ INSTALLED_APPS = [
LOGIN_URL = 'login'
LOGOUT_URL = 'logout'
SETUP_URL = 'profile/+confirm_profile'
LOGIN_REDIRECT_URL = 'home'
AUTHENTICATION_BACKENDS = (
'django.contrib.auth.backends.ModelBackend',

View file

@ -0,0 +1,30 @@
{% extends "get_together/base.html" %}
{% block content %}
<div class="container">
<div class="row">
<div class="col-12">
<center>
<p><img class="align-bottom" border="1" src="{{profile.avatar}}" height="64px"/></p>
<h3>Please confirm your profile information</h3>
<form action="{% url 'setup-1-confirm-profile' %}" method="POST" class="form">
{% csrf_token %}
<p>{% include "events/profile_form.html" %}</p>
<p><button type="submit" class="btn btn-success pl-5 pr-5">Continue</button></p>
</form>
</center>
</div>
</div>
</div>
{% endblock %}
{% block javascript %}
<script type="text/javascript">
$(document).ready(function(){
$("#id_tz").selectmenu();
});
</script>
{% endblock %}

View file

@ -0,0 +1,52 @@
{% extends "get_together/base.html" %}
{% load static %}
{% block styles %}
<link href="{% static 'css/bootstrap-album.css' %}" rel="stylesheet"/>
{% endblock %}
{% block content %}
<div class="container">
<div class="row">
<div class="col-12">
<center>
<h3>Tell us what kinds of events interest you</h3>
</center>
</div>
</div>
<form action="{% url 'setup-2-pick-categories' %}" method="POST" class="form">
{% csrf_token %}
<div class="row">
{% for category in categories %}
<div class="col-md-4">
<div class="card mb-4 box-shadow">
<div class="card-banner">
<img class="card-img-top" src="{{category.img_url}}" alt="{{category.name}}">
<p class="card-title">{{category.name}}</p>
</div>
<div class="card-body">
<p class="card-text">{{category.description}}</p>
<div class="d-flex justify-content-between align-items-center">
<div class="btn-group-toggle" data-toggle="buttons">
<label class="btn btn-outline-primary category_toggle">
<input name="category_{{category.id}}" type="checkbox" autocomplete="off"> Add
</label>
</div>
</div>
</div>
</div>
</div>
{% endfor %}
</div>
<div class="row pb-5">
<div class="col-12">
<center>
<button class="btn btn-success pl-5 pr-5" type="submit">Continue</button>
</center>
</div>
</div>
</form>
</div>
{% endblock %}

View file

@ -0,0 +1,55 @@
{% extends "get_together/base.html" %}
{% load static %}
{% block styles %}
<link href="{% static 'css/bootstrap-album.css' %}" rel="stylesheet"/>
{% endblock %}
{% block content %}
<div class="container">
<div class="row">
<div class="col-12">
<center>
<h3>Here are some nearby teams you might want to join</h3>
</center>
</div>
</div>
<form action="{% url 'setup-3-find-teams' %}" method="POST">
{% csrf_token %}
<div class="row">
{% for team in teams %}
<div class="col-md-4">
<div class="card mb-4 box-shadow">
<div class="card-banner">
{% if team.category %}
<img class="card-img-top" src="{{team.category.img_url}}" alt="{{team.name}}">
{% else %}
<img class="card-img-top" src="{% static 'img/team_placeholder.png' %}" alt="{{team.name}}">
{% endif %}
<p class="card-title">{{team.name}}</p>
</div>
<div class="card-body">
<p class="card-text"><strong>{{team.city}}</strong></p>
<div class="d-flex justify-content-between align-items-center">
<div class="btn-group btn-group-toggle" data-toggle="buttons">
<label class="btn btn-outline-primary team_toggle">
<input name="team_{{team.id}}" type="checkbox" autocomplete="off"> Join
</label>
</div>
</div>
</div>
</div>
</div>
{% endfor %}
</div>
<div class="row pb-5">
<div class="col-12">
<center>
<button class="btn btn-success pl-5 pr-5" type="submit">Continue</button>
</center>
</div>
</div>
</form>
</div>
{% endblock %}

View file

@ -0,0 +1,55 @@
{% extends "get_together/base.html" %}
{% load static %}
{% block styles %}
<link href="{% static 'css/bootstrap-album.css' %}" rel="stylesheet"/>
{% endblock %}
{% block content %}
<div class="container">
<div class="row">
<div class="col-12">
<center>
<h3>Now pick some events that you'd like to attend</h3>
</center>
</div>
</div>
<form action="{% url 'setup-4-attend-events' %}" method="POST">
{% csrf_token %}
<div class="row">
{% for event in events %}
<div class="col-md-4">
<div class="card mb-4 box-shadow">
<div class="card-banner">
{% if event.team.category %}
<img class="card-img-top" src="{{event.team.category.img_url}}" alt="{{event.name}}">
{% else %}
<img class="card-img-top" src="{% static 'img/team_placeholder.png' %}" alt="{{event.name}}">
{% endif %}
<p class="card-title">{{event.team.name}}</p>
</div>
<div class="card-body">
<p class="card-text"><strong><a class="card-link" href="{{event.get_absolute_url}}">{{event.name}}</a></strong></p>
<div class="d-flex justify-content-between align-items-center">
<div class="btn-group btn-group-toggle" data-toggle="buttons">
<label class="btn btn-outline-primary team_toggle">
<input name="event_{{event.id}}" type="checkbox" autocomplete="off"> Attend
</label>
</div>
</div>
</div>
</div>
</div>
{% endfor %}
</div>
<div class="row pb-5">
<div class="col-12">
<center>
<button class="btn btn-success pl-5 pr-5" type="submit">Continue</button>
</center>
</div>
</div>
</form>
</div>
{% endblock %}

View file

@ -35,6 +35,12 @@ urlpatterns = [
path('api/cities/', event_views.city_list),
path('api/find_city/', event_views.find_city),
path('profile/+confirm_profile', views.setup_1_confirm_profile, name='setup-1-confirm-profile'),
path('profile/+pick_categories', views.setup_2_pick_categories, name='setup-2-pick-categories'),
path('profile/+find_teams', views.setup_3_find_teams, name='setup-3-find-teams'),
path('profile/+attend_events', views.setup_4_attend_events, name='setup-4-attend-events'),
path('profile/+setup_complete', views.setup_complete, name='setup-complete'),
path('profile/+edit', views.edit_profile, name='edit-profile'),
path('profile/+send_confirmation_email', views.user_send_confirmation_email, name='send-confirm-email'),
path('profile/+confirm_email/<str:confirmation_key>', views.user_confirm_email, name='confirm-email'),

View file

@ -11,6 +11,7 @@ from events.models.profiles import Team, UserProfile, Member
from events.models.search import Searchable
from events.forms import SearchForm
from accounts.decorators import setup_wanted
from django.conf import settings
import datetime
@ -24,12 +25,14 @@ from .events import *
from .places import *
from .user import *
from .new_user import *
from .utils import *
KM_PER_DEGREE_LAT = 110.574
KM_PER_DEGREE_LNG = 111.320 # At the equator
DEFAULT_NEAR_DISTANCE = 100 # kilometeres
# Create your views here.
@setup_wanted
def home(request, *args, **kwards):
context = {}
if request.user.is_authenticated:
@ -49,14 +52,7 @@ def home(request, *args, **kwards):
else :
context['city_search'] = False
try:
client_ip = get_client_ip(request)
if client_ip == '127.0.0.1' or client_ip == 'localhost':
if settings.DEBUG:
client_ip = '8.8.8.8' # Try Google's server
else:
raise Exception("Client is localhost")
g = geocoder.ip(client_ip)
g = get_geoip(request)
if g.latlng is not None and g.latlng[0] is not None and g.latlng[1] is not None:
ll = g.latlng
context['geoip_lookup'] = True
@ -92,11 +88,3 @@ def home(request, *args, **kwards):
context['search_form'] = search_form
return render(request, 'get_together/index.html', context)
def get_client_ip(request):
x_forwarded_for = request.META.get('HTTP_X_FORWARDED_FOR')
if x_forwarded_for:
ip = x_forwarded_for.split(',')[0]
else:
ip = request.META.get('REMOTE_ADDR')
return ip

View file

@ -9,21 +9,126 @@ from django.core.mail import send_mail
from django.template.loader import get_template, render_to_string
from django.conf import settings
from events.models.profiles import Team, UserProfile, Member
from events.models.profiles import Team, UserProfile, Member, Category
from events.models.events import Event, Place, Attendee
from events.forms import SendNotificationsForm
from events.forms import SendNotificationsForm, UserForm, ConfirmProfileForm
from .utils import get_nearby_teams
import datetime
import simplejson
def new_user_confirm_profile(request):
pass
@login_required
def setup_1_confirm_profile(request):
user = request.user
profile = request.user.profile
def new_user_find_teams(request):
pass
if request.method == 'GET':
user_form = UserForm(instance=user)
profile_form = ConfirmProfileForm(instance=profile)
context = {
'user': user,
'profile': profile,
'user_form': user_form,
'profile_form': profile_form,
}
return render(request, 'get_together/new_user/setup_1_confirm_profile.html', context)
elif request.method == 'POST':
user_form = UserForm(request.POST, instance=user)
profile_form = ConfirmProfileForm(request.POST, instance=profile)
if user_form.is_valid() and profile_form.is_valid():
saved_user = user_form.save()
profile_form.save()
if saved_user.email is not None and saved_user.email != '' and not saved_user.account.is_email_confirmed:
# Call the view to trigger sending a confirmation email, but ignore it's response
user_send_confirmation_email(request)
return redirect('setup-2-pick-categories')
else:
return redirect('home')
def new_user_find_events(request):
pass
@login_required
def setup_2_pick_categories(request):
user = request.user
profile = request.user.profile
if request.method == 'GET':
categories = Category.objects.all()
context = {
'user': user,
'profile': profile,
'categories': categories,
}
return render(request, 'get_together/new_user/setup_2_pick_categories.html', context)
elif request.method == 'POST':
for entry in request.POST:
if entry.startswith('category_'):
category_id = entry.split('_')[1]
try:
profile.categories.add(category_id)
except:
pass
return redirect('setup-3-find-teams')
else:
return redirect('home')
@login_required
def setup_3_find_teams(request):
user = request.user
profile = request.user.profile
if request.method == 'GET':
teams = get_nearby_teams(request)
if (teams.count() < 1):
return redirect('setup-complete')
context = {
'user': user,
'profile': profile,
'teams': teams,
}
return render(request, 'get_together/new_user/setup_3_find_teams.html', context)
elif request.method == 'POST':
for entry in request.POST:
if entry.startswith('team_'):
team_id = entry.split('_')[1]
try:
Member.objects.get_or_create(team_id=team_id, user=profile, defaults={'role': Member.NORMAL})
except Member.MultipleObjectsReturned:
pass
return redirect('setup-4-attend-events')
else:
return redirect('home')
@login_required
def setup_4_attend_events(request):
user = request.user
profile = request.user.profile
if request.method == 'GET':
events = Event.objects.filter(team__in=profile.memberships.all(), end_time__gte=datetime.datetime.now())
if (events.count() < 1):
return redirect('setup-complete')
context = {
'user': user,
'profile': profile,
'events': events,
}
return render(request, 'get_together/new_user/setup_4_attend_events.html', context)
elif request.method == 'POST':
for entry in request.POST:
if entry.startswith('event_'):
event_id = entry.split('_')[1]
try:
Attendee.objects.get_or_create(event_id=event_id, user=profile, defaults={'role': Attendee.NORMAL, 'status': Attendee.YES})
except Attendee.MultipleObjectsReturned:
pass
return redirect('setup-complete')
else:
return redirect('home')
@login_required
def setup_complete(request):
messages.add_message(request, messages.SUCCESS, message=_('Your setup is complete, welcome to GetTogether!'))
request.user.account.setup_complete()
return redirect('home')
# These views are for confirming a user's email address before sending them mail
@login_required
@ -48,7 +153,7 @@ def user_send_confirmation_email(request):
recipient_list=email_recipients,
html_message=email_body_html
)
return render(request, 'get_together/users/sent_email_confirmation.html', context)
return render(request, 'get_together/new_user/sent_email_confirmation.html', context)
@login_required
def user_confirm_email(request, confirmation_key):
@ -56,7 +161,7 @@ def user_confirm_email(request, confirmation_key):
messages.add_message(request, messages.SUCCESS, message=_('Your email address has been confirmed.'))
return redirect('confirm-notifications')
else:
return render(request, 'get_together/users/bad_email_confirmation.html')
return render(request, 'get_together/new_user/bad_email_confirmation.html')
@login_required
def user_confirm_notifications(request):
@ -65,7 +170,7 @@ def user_confirm_notifications(request):
context = {
'notifications_form': form
}
return render(request, 'get_together/users/confirm_notifications.html', context)
return render(request, 'get_together/new_user/confirm_notifications.html', context)
elif request.method == 'POST':
form = SendNotificationsForm(request.POST, instance=request.user.profile)
if form.is_valid():

View file

@ -0,0 +1,68 @@
from django.utils.translation import ugettext_lazy as _
from django.contrib import messages
from django.contrib.auth import logout as logout_user
from django.shortcuts import render, redirect
from django.http import HttpResponse, JsonResponse
from events.models.locale import City
from events.models.events import Event, Place, Attendee
from events.models.profiles import Team, UserProfile, Member
from events.models.search import Searchable
from events.forms import SearchForm
from accounts.decorators import setup_wanted
from django.conf import settings
import datetime
import simplejson
import geocoder
import math
import traceback
from .teams import *
from .events import *
from .places import *
from .user import *
from .new_user import *
KM_PER_DEGREE_LAT = 110.574
KM_PER_DEGREE_LNG = 111.320 # At the equator
DEFAULT_NEAR_DISTANCE = 100 # kilometeres
def get_geoip(request):
client_ip = get_client_ip(request)
if client_ip == '127.0.0.1' or client_ip == 'localhost':
if settings.DEBUG:
client_ip = '8.8.8.8' # Try Google's server
print("Client is localhost, using 8.8.8.8 for geoip instead")
else:
raise Exception("Client is localhost")
g = geocoder.ip(client_ip)
return g
def get_nearby_teams(request, near_distance=DEFAULT_NEAR_DISTANCE):
g = get_geoip(request)
if g.latlng is None or g.latlng[0] is None or g.latlng[1] is None:
print("Could not identify latlng from geoip")
return Team.objects.none()
try:
minlat = g.latlng[0]-(near_distance/KM_PER_DEGREE_LAT)
maxlat = g.latlng[0]+(near_distance/KM_PER_DEGREE_LAT)
minlng = g.latlng[1]-(near_distance/(KM_PER_DEGREE_LNG*math.cos(math.radians(g.latlng[0]))))
maxlng = g.latlng[1]+(near_distance/(KM_PER_DEGREE_LNG*math.cos(math.radians(g.latlng[0]))))
near_teams = Team.objects.filter(city__latitude__gte=minlat, city__latitude__lte=maxlat, city__longitude__gte=minlng, city__longitude__lte=maxlng)
return near_teams
except Exception as e:
print("Error looking for local teams: ", e)
return Team.objects.none()
def get_client_ip(request):
x_forwarded_for = request.META.get('HTTP_X_FORWARDED_FOR')
if x_forwarded_for:
ip = x_forwarded_for.split(',')[0]
else:
ip = request.META.get('REMOTE_ADDR')
return ip