diff --git a/account/conf.py b/account/conf.py index 4e620af2..b79cd327 100644 --- a/account/conf.py +++ b/account/conf.py @@ -38,6 +38,8 @@ class AccountAppConf(AppConf): LOGOUT_REDIRECT_URL = "/" 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/forms.py b/account/forms.py index efbf24a3..9bf5b1e6 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 1247691d..11960548 100644 --- a/account/tests/test_views.py +++ b/account/tests/test_views.py @@ -7,6 +7,7 @@ from django.contrib.auth.models import User from account.compat import reverse +from account.conf import AccountAppConf from account.models import SignupCode, EmailConfirmation @@ -352,6 +353,65 @@ def test_post_authenticated_success_no_mail(self): 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']) + + class PasswordResetTokenViewTestCase(TestCase): def signup(self): diff --git a/account/tests/urls.py b/account/tests/urls.py index 679e33aa..a78c433a 100644 --- a/account/tests/urls.py +++ b/account/tests/urls.py @@ -1,6 +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/", admin.site.urls) urlpatterns = [ + admin_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 bdf8e09f..a482ade0 100644 --- a/account/views.py +++ b/account/views.py @@ -1,5 +1,7 @@ from __future__ import unicode_literals +import uuid + from django.http import Http404, HttpResponseForbidden from django.shortcuts import redirect, get_object_or_404 from django.utils.decorators import method_decorator @@ -13,6 +15,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 @@ -20,7 +23,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 @@ -249,8 +252,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") @@ -831,3 +843,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 settings.ACCOUNT_INVITE_USER_STAFF_ONLY: + 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",