Skip to content

Commit

Permalink
Add initial Terms of Service support.
Browse files Browse the repository at this point in the history
- New TermsOfService that represents a ToS, with a name, date, URL and
  an "active" flag.
- New middleware like the existing one for enforcing active emails that
  requires users which haven't agreed to all "active" ToSes to agree.
- Sync support for syncing ToSes to groups, for export to Discourse and
  Ore. At the moment, this only happens when a user logs in again.
- Migration to create the initial SpongePowered 2018-03-10 ToS.
- Updated registration form to add checkboxes to agree to the ToSes
  at registration time. These are all mandatory.
  • Loading branch information
lukegb committed Apr 30, 2018
1 parent b64a69c commit df5f688
Show file tree
Hide file tree
Showing 20 changed files with 372 additions and 24 deletions.
31 changes: 31 additions & 0 deletions spongeauth/accounts/admin.py
Original file line number Diff line number Diff line change
Expand Up @@ -128,9 +128,40 @@ class GroupAdmin(admin.ModelAdmin):
form = GroupAdminForm


class TermsOfServiceAdminForm(forms.ModelForm):
class Meta:
model = models.TermsOfService
fields = (
'name', 'tos_url', 'tos_date',
'current_tos')

def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
if self.instance.pk:
for f in self.fields.values():
f.readonly = True


class TermsOfServiceAdmin(admin.ModelAdmin):
list_display = ('name', 'tos_date', 'current_tos')
list_filter = ('current_tos',)
search_filters = ('name',)

form = TermsOfServiceAdminForm

fields = ('name', 'tos_date', 'tos_url', 'current_tos')

def get_readonly_fields(self, request, obj=None):
if not obj:
return self.readonly_fields
return self.readonly_fields + (
'name', 'tos_date', 'tos_url')


admin.site.register(models.User, UserAdmin)
admin.site.register(models.Group, GroupAdmin)
admin.site.register(models.Avatar, AvatarAdmin)
admin.site.register(models.ExternalAuthenticator, ExternalAuthenticatorAdmin)
admin.site.register(models.TermsOfService, TermsOfServiceAdmin)

admin.site.unregister(django.contrib.auth.models.Group)
23 changes: 20 additions & 3 deletions spongeauth/accounts/forms.py
Original file line number Diff line number Diff line change
Expand Up @@ -47,6 +47,18 @@ class ProfileFieldsMixin(forms.Form):


class RegistrationMixin:
def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
toses = models.TermsOfService.objects.filter(current_tos=True)
self.tos_fields = {}
for tos in toses:
field_name = 'accept_tos_{}'.format(tos.id)
self.tos_fields[field_name] = tos
self.fields[field_name] = forms.BooleanField(
required=True,
label='I agree to the <a href="{}">{}</a>'.format(
tos.tos_url, tos.name))

def clean_username(self):
username = self.cleaned_data['username']
if models.User.objects.filter(
Expand Down Expand Up @@ -105,21 +117,26 @@ def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)

self.helper = FormHelper()
self.helper.layout = Layout(
fields = [
Field('username'),
Field('password'),
Field('email'),
Field('mc_username'),
Field('irc_nick'),
Field('gh_username'),
]
for field in getattr(self, 'tos_fields', {}).keys():
fields.append(Field(field))
fields += [
FormActions(
HTML("""<a href="{{% url 'accounts:login' %}}" """
"""class="btn btn-default">{}</a> """.format(
_("Log in"))),
Submit('sign up', _("Sign up")),
css_class="pull-right"
)
)
),
]
self.helper.layout = Layout(*fields)


class AuthenticationForm(forms.Form):
Expand Down
41 changes: 36 additions & 5 deletions spongeauth/accounts/middleware.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,30 +4,61 @@
import django.urls.exceptions


class EnforceVerifiedEmails:
class RedirectIfConditionUnmet:
REDIRECT_TO = None

def __init__(self, get_response):
self.get_response = get_response

def __call__(self, request):
if self.must_verify(request.user) and not self.may_pass(request.path):
return redirect('accounts:verify')
return redirect(self.REDIRECT_TO)

response = self.get_response(request)
return response

@staticmethod
def must_verify(user):
return user.is_authenticated and not user.email_verified and settings.REQUIRE_EMAIL_CONFIRM
raise NotImplementedError

@staticmethod
def may_pass(url):
try:
return getattr(
resolve(url).func, 'allow_without_verified_email', False)
func = resolve(url).func
except django.urls.exceptions.Resolver404:
return False
for f in [
'allow_without_verified_email',
'allow_without_agreed_tos',
]:
if getattr(func, f, False):
return True
return False


class EnforceVerifiedEmails(RedirectIfConditionUnmet):
REDIRECT_TO = 'accounts:verify'

@staticmethod
def must_verify(user):
return user.is_authenticated and not user.email_verified and settings.REQUIRE_EMAIL_CONFIRM


def allow_without_verified_email(f):
f.allow_without_verified_email = True
return f


class EnforceToSAccepted(RedirectIfConditionUnmet):
REDIRECT_TO = 'accounts:agree-tos'

@staticmethod
def must_verify(user):
if not user.is_authenticated:
return False
return user.must_agree_tos().exists()


def allow_without_agreed_tos(f):
f.allow_without_agreed_tos = True
return f
39 changes: 39 additions & 0 deletions spongeauth/accounts/migrations/0008_auto_20180428_1214.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,39 @@
# Generated by Django 2.0.3 on 2018-04-28 12:14

from django.conf import settings
from django.db import migrations, models
import django.db.models.deletion


class Migration(migrations.Migration):

dependencies = [
('accounts', '0007_group_add_internal_name'),
]

operations = [
migrations.CreateModel(
name='TermsOfService',
fields=[
('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('name', models.CharField(max_length=60, unique=True)),
('tos_date', models.DateField()),
('tos_url', models.URLField(unique=True)),
('current_tos', models.BooleanField(default=False)),
],
),
migrations.CreateModel(
name='TermsOfServiceAcceptance',
fields=[
('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('accepted_at', models.DateTimeField(auto_now_add=True)),
('tos', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='accounts.TermsOfService')),
('user', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to=settings.AUTH_USER_MODEL)),
],
),
migrations.AddField(
model_name='user',
name='tos_accepted',
field=models.ManyToManyField(blank=True, related_name='agreed_users', related_query_name='agreed_users', through='accounts.TermsOfServiceAcceptance', to='accounts.TermsOfService', verbose_name='terms of service'),
),
]
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
# Generated by Django 2.0.3 on 2018-04-28 12:53

import datetime

from django.db import migrations



def create_tos(apps, schema_editor):
TermsOfService = apps.get_model('accounts', 'TermsOfService')
db_alias = schema_editor.connection.alias
tos = TermsOfService(
name='SpongePowered Terms of Service (2018-03-10)',
tos_date=datetime.date(2018, 3, 10),
tos_url='https://docs.spongepowered.org/stable/en/about/tos.html',
current_tos=True)
tos.save(using=db_alias)


class Migration(migrations.Migration):

dependencies = [
('accounts', '0008_auto_20180428_1214'),
]

operations = [
migrations.RunPython(create_tos),
]
40 changes: 40 additions & 0 deletions spongeauth/accounts/migrations/0010_termsofservice_group.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,40 @@
# Generated by Django 2.0.3 on 2018-04-28 15:05

from django.db import migrations, models
import django.db.models.deletion


def create_group_for_each_tos(apps, schema_editor):
TermsOfService = apps.get_model('accounts', 'TermsOfService')
Group = apps.get_model('accounts', 'Group')
db_alias = schema_editor.connection.alias
for tos in TermsOfService.objects.using(db_alias).all():
group = Group(
name='Accepted ToS: ' + tos.name,
internal_name='accepted_tos_{}'.format(tos.pk),
internal_only=True)
group.save(using=db_alias)
group.user_set.set(tos.agreed_users.all())
tos.group = group
tos.save(using=db_alias)


class Migration(migrations.Migration):

dependencies = [
('accounts', '0009_add_spongepowered_tos_2018-03-10'),
]

operations = [
migrations.AddField(
model_name='termsofservice',
name='group',
field=models.ForeignKey(null=True, on_delete=django.db.models.deletion.CASCADE, to='accounts.Group'),
),
migrations.RunPython(create_group_for_each_tos),
migrations.AlterField(
model_name='termsofservice',
name='group',
field=models.ForeignKey(null=False, on_delete=django.db.models.deletion.CASCADE, to='accounts.Group'),
),
]
45 changes: 45 additions & 0 deletions spongeauth/accounts/models.py
Original file line number Diff line number Diff line change
Expand Up @@ -123,6 +123,14 @@ class User(AbstractBaseUser):
related_name='user_set',
related_query_name='user')

tos_accepted = models.ManyToManyField(
'TermsOfService',
verbose_name='terms of service',
blank=True,
related_name='agreed_users',
related_query_name='agreed_users',
through='TermsOfServiceAcceptance')

USERNAME_FIELD = 'username'
REQUIRED_FIELDS = ['email']

Expand Down Expand Up @@ -163,6 +171,18 @@ def has_module_perms(self, app_label):
return True
return False

def must_agree_tos(self):
return TermsOfService.objects.filter(current_tos=True).exclude(
agreed_users=self)

def _test_agree_all_tos(self):
if not self.pk:
return
for tos in self.must_agree_tos():
TermsOfServiceAcceptance(
tos=tos,
user=self).save()


def _avatar_upload_path(instance, filename):
instance.image_file.open('rb')
Expand Down Expand Up @@ -237,3 +257,28 @@ def __str__(self):
return "{} credential for user {}".format(
self.get_source_display(),
self.user_id)


class TermsOfService(models.Model):
name = models.CharField(max_length=60, blank=False, null=False, unique=True)
tos_date = models.DateField(blank=False, null=False)
tos_url = models.URLField(blank=False, null=False, unique=True)
current_tos = models.BooleanField(default=False, null=False)
group = models.ForeignKey(
Group, blank=False, null=False, on_delete=models.CASCADE)

def __str__(self):
return "TermsOfService: {}".format(self.name)


class TermsOfServiceAcceptance(models.Model):
user = models.ForeignKey(
User, null=False, blank=False, on_delete=models.CASCADE)
tos = models.ForeignKey(
TermsOfService, null=False, blank=False, on_delete=models.CASCADE)
accepted_at = models.DateTimeField(
auto_now_add=True, null=False, blank=False)

def __str__(self):
return "TermsOfServiceAcceptance: {} accepted {} at {}".format(
self.user, self.tos, self.accepted_at)
2 changes: 2 additions & 0 deletions spongeauth/accounts/tests/factories.py
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,8 @@ class Meta:

joined_at = factory.Faker('date_time_this_decade')

tos_accepted = factory.PostGenerationMethodCall('_test_agree_all_tos')


class GroupFactory(factory.django.DjangoModelFactory):
class Meta:
Expand Down
Loading

0 comments on commit df5f688

Please sign in to comment.