From a76076e58a1ba58fb561a4f5762ba70e91973b08 Mon Sep 17 00:00:00 2001 From: Michael Hall Date: Sun, 4 Mar 2018 14:10:37 -0500 Subject: [PATCH] Add new user setup workflow to walk the user through confirming their profile information, choosing categories, finding teams and attending events. Fixes #23 --- accounts/admin.py | 3 +- accounts/decorators.py | 27 ++++ .../migrations/0003_auto_20180304_1649.py | 23 ++++ accounts/models.py | 8 ++ events/admin.py | 11 +- events/forms.py | 5 + events/migrations/0013_auto_20180304_1649.py | 23 ++++ events/models/profiles.py | 5 +- get_together/settings.py | 1 + .../bad_email_confirmation.html | 0 .../confirm_notifications.html | 0 .../sent_email_confirmation.html | 0 .../new_user/setup_1_confirm_profile.html | 30 +++++ .../new_user/setup_2_pick_categories.html | 52 +++++++ .../new_user/setup_3_find_teams.html | 55 ++++++++ .../new_user/setup_4_attend_events.html | 55 ++++++++ get_together/urls.py | 6 + get_together/views/__init__.py | 20 +-- get_together/views/new_user.py | 127 ++++++++++++++++-- get_together/views/utils.py | 68 ++++++++++ 20 files changed, 488 insertions(+), 31 deletions(-) create mode 100644 accounts/decorators.py create mode 100644 accounts/migrations/0003_auto_20180304_1649.py create mode 100644 events/migrations/0013_auto_20180304_1649.py rename get_together/templates/get_together/{users => new_user}/bad_email_confirmation.html (100%) rename get_together/templates/get_together/{users => new_user}/confirm_notifications.html (100%) rename get_together/templates/get_together/{users => new_user}/sent_email_confirmation.html (100%) create mode 100644 get_together/templates/get_together/new_user/setup_1_confirm_profile.html create mode 100644 get_together/templates/get_together/new_user/setup_2_pick_categories.html create mode 100644 get_together/templates/get_together/new_user/setup_3_find_teams.html create mode 100644 get_together/templates/get_together/new_user/setup_4_attend_events.html create mode 100644 get_together/views/utils.py diff --git a/accounts/admin.py b/accounts/admin.py index 3df2525..aa47805 100644 --- a/accounts/admin.py +++ b/accounts/admin.py @@ -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' diff --git a/accounts/decorators.py b/accounts/decorators.py new file mode 100644 index 0000000..1140f00 --- /dev/null +++ b/accounts/decorators.py @@ -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) diff --git a/accounts/migrations/0003_auto_20180304_1649.py b/accounts/migrations/0003_auto_20180304_1649.py new file mode 100644 index 0000000..580fdf6 --- /dev/null +++ b/accounts/migrations/0003_auto_20180304_1649.py @@ -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), + ), + ] diff --git a/accounts/models.py b/accounts/models.py index a1a6e82..5476e09 100644 --- a/accounts/models.py +++ b/accounts/models.py @@ -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) diff --git a/events/admin.py b/events/admin.py index a5d0ab4..b5416c8 100644 --- a/events/admin.py +++ b/events/admin.py @@ -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') diff --git a/events/forms.py b/events/forms.py index 11fa813..4ea1808 100644 --- a/events/forms.py +++ b/events/forms.py @@ -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 diff --git a/events/migrations/0013_auto_20180304_1649.py b/events/migrations/0013_auto_20180304_1649.py new file mode 100644 index 0000000..62cc865 --- /dev/null +++ b/events/migrations/0013_auto_20180304_1649.py @@ -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'), + ), + ] diff --git a/events/models/profiles.py b/events/models/profiles.py index 7e0867d..dee5a85 100644 --- a/events/models/profiles.py +++ b/events/models/profiles.py @@ -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',) diff --git a/get_together/settings.py b/get_together/settings.py index 425564b..232f771 100644 --- a/get_together/settings.py +++ b/get_together/settings.py @@ -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', diff --git a/get_together/templates/get_together/users/bad_email_confirmation.html b/get_together/templates/get_together/new_user/bad_email_confirmation.html similarity index 100% rename from get_together/templates/get_together/users/bad_email_confirmation.html rename to get_together/templates/get_together/new_user/bad_email_confirmation.html diff --git a/get_together/templates/get_together/users/confirm_notifications.html b/get_together/templates/get_together/new_user/confirm_notifications.html similarity index 100% rename from get_together/templates/get_together/users/confirm_notifications.html rename to get_together/templates/get_together/new_user/confirm_notifications.html diff --git a/get_together/templates/get_together/users/sent_email_confirmation.html b/get_together/templates/get_together/new_user/sent_email_confirmation.html similarity index 100% rename from get_together/templates/get_together/users/sent_email_confirmation.html rename to get_together/templates/get_together/new_user/sent_email_confirmation.html diff --git a/get_together/templates/get_together/new_user/setup_1_confirm_profile.html b/get_together/templates/get_together/new_user/setup_1_confirm_profile.html new file mode 100644 index 0000000..edade4a --- /dev/null +++ b/get_together/templates/get_together/new_user/setup_1_confirm_profile.html @@ -0,0 +1,30 @@ +{% extends "get_together/base.html" %} + +{% block content %} +
+
+
+
+

+

Please confirm your profile information

+ +
+ {% csrf_token %} +

{% include "events/profile_form.html" %}

+

+
+
+
+
+
+{% endblock %} + +{% block javascript %} + + +{% endblock %} diff --git a/get_together/templates/get_together/new_user/setup_2_pick_categories.html b/get_together/templates/get_together/new_user/setup_2_pick_categories.html new file mode 100644 index 0000000..26ab9ed --- /dev/null +++ b/get_together/templates/get_together/new_user/setup_2_pick_categories.html @@ -0,0 +1,52 @@ +{% extends "get_together/base.html" %} +{% load static %} + +{% block styles %} + +{% endblock %} + +{% block content %} +
+
+
+
+

Tell us what kinds of events interest you

+
+
+
+
+ {% csrf_token %} +
+ {% for category in categories %} +
+
+
+ {{category.name}} +

{{category.name}}

+
+
+

{{category.description}}

+
+
+ +
+
+
+
+
+ {% endfor %} +
+
+
+
+ +
+
+
+
+
+{% endblock %} + + diff --git a/get_together/templates/get_together/new_user/setup_3_find_teams.html b/get_together/templates/get_together/new_user/setup_3_find_teams.html new file mode 100644 index 0000000..4e3c86a --- /dev/null +++ b/get_together/templates/get_together/new_user/setup_3_find_teams.html @@ -0,0 +1,55 @@ +{% extends "get_together/base.html" %} +{% load static %} + +{% block styles %} + +{% endblock %} + +{% block content %} +
+
+
+
+

Here are some nearby teams you might want to join

+
+
+
+
+ {% csrf_token %} +
+ {% for team in teams %} +
+
+
+ {% if team.category %} + {{team.name}} + {% else %} + {{team.name}} + {% endif %} +

{{team.name}}

+
+
+

{{team.city}}

+
+
+ +
+
+
+
+
+ {% endfor %} +
+
+
+
+ +
+
+
+
+
+{% endblock %} + diff --git a/get_together/templates/get_together/new_user/setup_4_attend_events.html b/get_together/templates/get_together/new_user/setup_4_attend_events.html new file mode 100644 index 0000000..00275b7 --- /dev/null +++ b/get_together/templates/get_together/new_user/setup_4_attend_events.html @@ -0,0 +1,55 @@ +{% extends "get_together/base.html" %} +{% load static %} + +{% block styles %} + +{% endblock %} + +{% block content %} +
+
+
+
+

Now pick some events that you'd like to attend

+
+
+
+
+ {% csrf_token %} +
+ {% for event in events %} +
+
+
+ {% if event.team.category %} + {{event.name}} + {% else %} + {{event.name}} + {% endif %} +

{{event.team.name}}

+
+
+

{{event.name}}

+
+
+ +
+
+
+
+
+ {% endfor %} +
+
+
+
+ +
+
+
+
+
+{% endblock %} + diff --git a/get_together/urls.py b/get_together/urls.py index 7710256..21b0b88 100644 --- a/get_together/urls.py +++ b/get_together/urls.py @@ -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/', views.user_confirm_email, name='confirm-email'), diff --git a/get_together/views/__init__.py b/get_together/views/__init__.py index a841d27..1793320 100644 --- a/get_together/views/__init__.py +++ b/get_together/views/__init__.py @@ -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 diff --git a/get_together/views/new_user.py b/get_together/views/new_user.py index 43f2a78..30d0a2c 100644 --- a/get_together/views/new_user.py +++ b/get_together/views/new_user.py @@ -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(): diff --git a/get_together/views/utils.py b/get_together/views/utils.py new file mode 100644 index 0000000..bd18090 --- /dev/null +++ b/get_together/views/utils.py @@ -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