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" %}
+
+
+
+{% 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",