From 635fee0ceccc897b56499aafddd3cae69c1666fd Mon Sep 17 00:00:00 2001 From: Cezary Statkiewicz Date: Wed, 10 May 2017 19:03:08 +0200 Subject: [PATCH 1/4] invite other users (ported from geonode-user-accounts) #252 optional staff membership required with ACCOUNT_INVITE_USER_STAFF_ONLY switch tests improved invite email subject without newlines missing templates flake8 style fixes --- account/conf.py | 1 + account/forms.py | 8 ++- .../migrations/0005_signupcode_username.py | 19 ++++++ account/models.py | 4 ++ account/templates/account/invite_user.html | 19 ++++++ .../templates/account/email/invite_user.txt | 0 .../account/email/invite_user_subject.txt | 1 + account/tests/templates/site_base.html | 11 ++++ account/tests/test_views.py | 60 +++++++++++++++++++ account/tests/urls.py | 3 + account/urls.py | 4 +- account/views.py | 53 +++++++++++++++- docs/settings.rst | 8 +++ runtests.py | 1 + setup.py | 3 + 15 files changed, 189 insertions(+), 6 deletions(-) create mode 100644 account/migrations/0005_signupcode_username.py create mode 100644 account/templates/account/invite_user.html create mode 100644 account/tests/templates/account/email/invite_user.txt create mode 100644 account/tests/templates/account/email/invite_user_subject.txt create mode 100644 account/tests/templates/site_base.html diff --git a/account/conf.py b/account/conf.py index 4e620af2..dd30c14a 100644 --- a/account/conf.py +++ b/account/conf.py @@ -38,6 +38,7 @@ class AccountAppConf(AppConf): LOGOUT_REDIRECT_URL = "/" PASSWORD_CHANGE_REDIRECT_URL = "account_password" PASSWORD_RESET_REDIRECT_URL = "account_login" + INVITE_USER_URL = "account_invite_user" PASSWORD_EXPIRY = 0 PASSWORD_USE_HISTORY = False PASSWORD_STRIP = True diff --git a/account/forms.py b/account/forms.py index 4aa0cb92..0462cfec 100644 --- a/account/forms.py +++ b/account/forms.py @@ -16,7 +16,7 @@ from account.conf import settings from account.hooks import hookset -from account.models import EmailAddress +from account.models import EmailAddress, SignupCode from account.utils import get_user_lookup_kwargs @@ -233,3 +233,9 @@ def clean_email(self): if not qs.exists() or not settings.ACCOUNT_EMAIL_UNIQUE: return value raise forms.ValidationError(_("A user is registered with this email address.")) + + +class SignupCodeForm(forms.ModelForm): + class Meta: + model = SignupCode + fields = ('email', 'username',) diff --git a/account/migrations/0005_signupcode_username.py b/account/migrations/0005_signupcode_username.py new file mode 100644 index 00000000..98aa4769 --- /dev/null +++ b/account/migrations/0005_signupcode_username.py @@ -0,0 +1,19 @@ +# -*- coding: utf-8 -*- +from __future__ import unicode_literals + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('account', '0004_auto_20170416_1821'), + ] + + operations = [ + migrations.AddField( + model_name='signupcode', + name='username', + field=models.CharField(default=None, max_length=30, null=True, blank=True), + ), + ] diff --git a/account/models.py b/account/models.py index ef3e08bc..11a97bbf 100644 --- a/account/models.py +++ b/account/models.py @@ -146,6 +146,7 @@ class InvalidCode(Exception): sent = models.DateTimeField(_("sent"), null=True, blank=True) created = models.DateTimeField(_("created"), default=timezone.now, editable=False) use_count = models.PositiveIntegerField(_("use count"), editable=False, default=0) + username = models.CharField(max_length=30, null=True, default=None, blank=True) class Meta: verbose_name = _("signup code") @@ -185,6 +186,9 @@ def create(cls, **kwargs): } if email: params["email"] = email + + params['username'] = kwargs.get("username") + return cls(**params) @classmethod diff --git a/account/templates/account/invite_user.html b/account/templates/account/invite_user.html new file mode 100644 index 00000000..77a81857 --- /dev/null +++ b/account/templates/account/invite_user.html @@ -0,0 +1,19 @@ +{% extends "site_base.html" %} +{% load i18n %} + +{% block body %} + +

{% trans "Invite User" %}

+ +
+ + {% csrf_token %} + {{ form }} +
+ +
+ +
+ +{% endblock %} + diff --git a/account/tests/templates/account/email/invite_user.txt b/account/tests/templates/account/email/invite_user.txt new file mode 100644 index 00000000..e69de29b diff --git a/account/tests/templates/account/email/invite_user_subject.txt b/account/tests/templates/account/email/invite_user_subject.txt new file mode 100644 index 00000000..7c4a013e --- /dev/null +++ b/account/tests/templates/account/email/invite_user_subject.txt @@ -0,0 +1 @@ +aaa \ No newline at end of file diff --git a/account/tests/templates/site_base.html b/account/tests/templates/site_base.html new file mode 100644 index 00000000..ae2f3e0a --- /dev/null +++ b/account/tests/templates/site_base.html @@ -0,0 +1,11 @@ + + + + + + +{% block body %} + +{% endblock %} + + diff --git a/account/tests/test_views.py b/account/tests/test_views.py index 1d64fadf..2cdd0092 100644 --- a/account/tests/test_views.py +++ b/account/tests/test_views.py @@ -5,6 +5,7 @@ from django.contrib.auth.models import User from account.compat import reverse +from account.conf import AccountAppConf from account.models import SignupCode, EmailConfirmation @@ -348,3 +349,62 @@ def test_post_authenticated_success_no_mail(self): fetch_redirect_response=False ) self.assertEqual(len(mail.outbox), 0) + + +class InviteUserViewTestCase(TestCase): + + PASSWORD = 'test' + + def test_invitation_get_anonymous(self): + url = reverse(AccountAppConf.INVITE_USER_URL) + resp = self.client.get(url) + self.assertEqual(resp.status_code, 302) + self.assertRedirects(resp, '{}?next={}'.format(reverse('account_login'), url)) + + def test_invitation_get_regular(self): + url = reverse(AccountAppConf.INVITE_USER_URL) + u = User.objects.create(username="foo", is_active=True) + u.set_password(self.PASSWORD) + u.save() + self.client.login(username=u.username, password=self.PASSWORD) + + with self.settings(ACCOUNT_INVITE_USER_STAFF_ONLY=True): + resp = self.client.get(url) + self.assertEqual(resp.status_code, 302) + self.assertRedirects(resp, '{}?next={}'.format(reverse('admin:login'), url)) + + with self.settings(ACCOUNT_INVITE_USER_STAFF_ONLY=False): + self.client.login(username=u.username, password=self.PASSWORD) + resp = self.client.get(url) + self.assertEqual(resp.status_code, 200) + self.assertEqual(resp.template_name, ['account/invite_user.html']) + + def test_invitation_get_staff(self): + url = reverse(AccountAppConf.INVITE_USER_URL) + u = User.objects.create(username="foo", is_active=True, is_staff=True) + u.set_password(self.PASSWORD) + u.save() + self.client.login(username=u.username, password=self.PASSWORD) + resp = self.client.get(url) + self.assertEqual(resp.status_code, 200) + self.assertEqual(resp.template_name, ['account/invite_user.html']) + + def test_invitation_post(self): + url = reverse(AccountAppConf.INVITE_USER_URL) + u = User.objects.create(username="foo", is_active=True, is_staff=True) + u.set_password(self.PASSWORD) + u.save() + self.client.login(username=u.username, password=self.PASSWORD) + data = {'email': 'test1@email.com'} + resp = self.client.post(url, data) + self.assertRedirects(resp, url) + q = SignupCode.objects.filter(email=data['email']) + self.assertEqual(q.count(), 1) + code = q.get().code + registration_url = '{}?code={}'.format(reverse("account_signup"), code) + + self.client.logout() + + reg = self.client.get(registration_url) + self.assertEqual(reg.status_code, 200) + self.assertEqual(reg.template_name, ['account/signup.html']) diff --git a/account/tests/urls.py b/account/tests/urls.py index 679e33aa..88c37cdd 100644 --- a/account/tests/urls.py +++ b/account/tests/urls.py @@ -1,6 +1,9 @@ from django.conf.urls import include, url +from django.contrib import admin +admin.autodiscover() urlpatterns = [ + url(r"admin/", include(admin.site.urls)), url(r"^", include("account.urls")), ] diff --git a/account/urls.py b/account/urls.py index c5c1de3e..118944a3 100644 --- a/account/urls.py +++ b/account/urls.py @@ -5,8 +5,7 @@ from account.views import SignupView, LoginView, LogoutView, DeleteView from account.views import ConfirmEmailView from account.views import ChangePasswordView, PasswordResetView, PasswordResetTokenView -from account.views import SettingsView - +from account.views import SettingsView, InviteUserView urlpatterns = [ url(r"^signup/$", SignupView.as_view(), name="account_signup"), @@ -18,4 +17,5 @@ url(r"^password/reset/(?P[0-9A-Za-z]+)-(?P.+)/$", PasswordResetTokenView.as_view(), name="account_password_reset_token"), url(r"^settings/$", SettingsView.as_view(), name="account_settings"), url(r"^delete/$", DeleteView.as_view(), name="account_delete"), + url(r"^invite_user/$", InviteUserView.as_view(), name="account_invite_user"), ] diff --git a/account/views.py b/account/views.py index 9bcd1898..4dbfd875 100644 --- a/account/views.py +++ b/account/views.py @@ -1,6 +1,9 @@ from __future__ import unicode_literals +import uuid + from django.http import Http404, HttpResponseForbidden +from django.conf import settings as dsettings from django.shortcuts import redirect, get_object_or_404 from django.utils.http import base36_to_int, int_to_base36 from django.utils.translation import ugettext_lazy as _ @@ -9,6 +12,7 @@ from django.contrib import auth, messages from django.contrib.auth import get_user_model +from django.contrib.admin.views.decorators import staff_member_required from django.contrib.auth.hashers import make_password from django.contrib.auth.tokens import default_token_generator from django.contrib.sites.shortcuts import get_current_site @@ -16,7 +20,7 @@ from account import signals from account.compat import reverse, is_authenticated from account.conf import settings -from account.forms import SignupForm, LoginUsernameForm +from account.forms import SignupForm, SignupCodeForm, LoginUsernameForm from account.forms import ChangePasswordForm, PasswordResetForm, PasswordResetTokenForm from account.forms import SettingsForm from account.hooks import hookset @@ -242,8 +246,17 @@ def create_user(self, form, commit=True, model=None, **kwargs): User = get_user_model() user = User(**kwargs) username = form.cleaned_data.get("username") - if username is None: - username = self.generate_username(form) + code = form.cleaned_data['code'] + + try: + signup_code = SignupCode.objects.get(code=code) + if not username: + username = signup_code.username + except SignupCode.DoesNotExist: + username = form.cleaned_data.get("username", '').strip() + if not username: + username = self.generate_username(form) + user.username = username user.email = form.cleaned_data["email"].strip() password = form.cleaned_data.get("password") @@ -774,3 +787,37 @@ def get_context_data(self, **kwargs): ctx.update(kwargs) ctx["ACCOUNT_DELETION_EXPUNGE_HOURS"] = settings.ACCOUNT_DELETION_EXPUNGE_HOURS return ctx + + +class InviteUserView(LoginRequiredMixin, FormView): + """ Invite a user.""" + template_name = "account/invite_user.html" + form_class = SignupCodeForm + + redirect_field_name = "next" + messages = { + "user_invited": { + "level": messages.SUCCESS, + "text": _("User successfully invited.")} + } + + def dispatch(self, *args, **kwargs): + d = super(InviteUserView, self).dispatch + # when switch is on, invitation will be available for staff only + if getattr(dsettings, 'ACCOUNT_INVITE_USER_STAFF_ONLY', False): + d = staff_member_required(d) + return d(*args, **kwargs) + + def form_valid(self, form): + code = str(uuid.uuid4()) + signup_code = form.save(commit=False) + signup_code.code = code + signup_code.save() + signup_code.send() + messages.success(self.request, _("Invitation sent to user '%s'") % signup_code.email) + return super(InviteUserView, self).form_valid(form) + + def get_success_url(self, fallback_url=None, **kwargs): + if fallback_url is None: + fallback_url = settings.ACCOUNT_INVITE_USER_URL + return default_redirect(self.request, fallback_url, **kwargs) diff --git a/docs/settings.rst b/docs/settings.rst index 6cb76db3..a1165435 100644 --- a/docs/settings.rst +++ b/docs/settings.rst @@ -148,3 +148,11 @@ Default: ``list(zip(pytz.all_timezones, pytz.all_timezones))`` ===================== See full list in: https://github.com/pinax/django-user-accounts/blob/master/account/language_list.py + +``ACCOUNT_INVITE_USER_STAFF_ONLY`` +================================== + +Default: ``False`` + +This setting restricts invitation functionality to staff members only. +By default, any user can invite other users. diff --git a/runtests.py b/runtests.py index ccd776f2..e429e2ae 100644 --- a/runtests.py +++ b/runtests.py @@ -12,6 +12,7 @@ USE_TZ=True, INSTALLED_APPS=[ "django.contrib.auth", + "django.contrib.admin", "django.contrib.contenttypes", "django.contrib.sessions", "django.contrib.sites", diff --git a/setup.py b/setup.py index 9ae8aa70..edf60da7 100644 --- a/setup.py +++ b/setup.py @@ -24,6 +24,9 @@ "locale/*/LC_MESSAGES/*", ], }, + tests_requires=[ + "pinax_theme_bootstrap", + ], test_suite="runtests.runtests", classifiers=[ "Development Status :: 5 - Production/Stable", From 6ba486385ee1917047512a274435150a6c5bc49e Mon Sep 17 00:00:00 2001 From: Cezary Statkiewicz Date: Thu, 11 May 2017 17:34:08 +0200 Subject: [PATCH 2/4] Django 2.0 compatibility #252 --- account/tests/urls.py | 10 +++++++++- 1 file changed, 9 insertions(+), 1 deletion(-) diff --git a/account/tests/urls.py b/account/tests/urls.py index 88c37cdd..30bb1bcc 100644 --- a/account/tests/urls.py +++ b/account/tests/urls.py @@ -1,9 +1,17 @@ +import django from django.conf.urls import include, url from django.contrib import admin admin.autodiscover() +# D 2.0 compatibility +if django.VERSION[0] < 2: + admin_urls = url(r"admin/", include(admin.site.urls)) +else: + admin_urls = url(r"admin/", include(admin.site.urls)) + + urlpatterns = [ - url(r"admin/", include(admin.site.urls)), + admin_urls, url(r"^", include("account.urls")), ] From 2c5dbfc0a5e6ccb076e43ba4f1d5b4c69d7a028f Mon Sep 17 00:00:00 2001 From: Cezary Statkiewicz Date: Thu, 11 May 2017 18:49:45 +0200 Subject: [PATCH 3/4] Django 2.0 compatibility #252 v2 --- account/tests/urls.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/account/tests/urls.py b/account/tests/urls.py index 30bb1bcc..a78c433a 100644 --- a/account/tests/urls.py +++ b/account/tests/urls.py @@ -8,7 +8,7 @@ if django.VERSION[0] < 2: admin_urls = url(r"admin/", include(admin.site.urls)) else: - admin_urls = url(r"admin/", include(admin.site.urls)) + admin_urls = url(r"admin/", admin.site.urls) urlpatterns = [ From 3491f7ffc230c8d4c406f78a4e1788de3835132b Mon Sep 17 00:00:00 2001 From: Cezary Statkiewicz Date: Fri, 12 May 2017 12:37:51 +0200 Subject: [PATCH 4/4] #252 default ACCOUNT_INVITE_USER_STAFF_ONLY in app conf --- account/conf.py | 1 + account/views.py | 3 +-- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/account/conf.py b/account/conf.py index dd30c14a..b79cd327 100644 --- a/account/conf.py +++ b/account/conf.py @@ -39,6 +39,7 @@ class AccountAppConf(AppConf): PASSWORD_CHANGE_REDIRECT_URL = "account_password" PASSWORD_RESET_REDIRECT_URL = "account_login" INVITE_USER_URL = "account_invite_user" + ACCOUNT_INVITE_USER_STAFF_ONLY = False PASSWORD_EXPIRY = 0 PASSWORD_USE_HISTORY = False PASSWORD_STRIP = True diff --git a/account/views.py b/account/views.py index 4dbfd875..0aab2636 100644 --- a/account/views.py +++ b/account/views.py @@ -3,7 +3,6 @@ import uuid from django.http import Http404, HttpResponseForbidden -from django.conf import settings as dsettings from django.shortcuts import redirect, get_object_or_404 from django.utils.http import base36_to_int, int_to_base36 from django.utils.translation import ugettext_lazy as _ @@ -804,7 +803,7 @@ class InviteUserView(LoginRequiredMixin, FormView): def dispatch(self, *args, **kwargs): d = super(InviteUserView, self).dispatch # when switch is on, invitation will be available for staff only - if getattr(dsettings, 'ACCOUNT_INVITE_USER_STAFF_ONLY', False): + if settings.ACCOUNT_INVITE_USER_STAFF_ONLY: d = staff_member_required(d) return d(*args, **kwargs)