From 789bef38cb2f24f2f0bd13c891fb238460cfb07f Mon Sep 17 00:00:00 2001
From: Eliot Berriot <contact@eliotberriot.com>
Date: Tue, 19 Jun 2018 21:47:43 +0200
Subject: [PATCH] See #248: model / migration

---
 api/config/settings/common.py                 |  4 ++
 api/funkwhale_api/users/factories.py          | 15 ++++++
 .../migrations/0009_auto_20180619_2024.py     | 31 +++++++++++++
 api/funkwhale_api/users/models.py             | 46 +++++++++++++++++++
 api/tests/users/test_models.py                | 23 ++++++++++
 5 files changed, 119 insertions(+)
 create mode 100644 api/funkwhale_api/users/migrations/0009_auto_20180619_2024.py

diff --git a/api/config/settings/common.py b/api/config/settings/common.py
index a836dfdf..b74c2bdf 100644
--- a/api/config/settings/common.py
+++ b/api/config/settings/common.py
@@ -461,3 +461,7 @@ MUSIC_DIRECTORY_PATH = env("MUSIC_DIRECTORY_PATH", default=None)
 MUSIC_DIRECTORY_SERVE_PATH = env(
     "MUSIC_DIRECTORY_SERVE_PATH", default=MUSIC_DIRECTORY_PATH
 )
+
+USERS_INVITATION_EXPIRATION_DAYS = env.int(
+    "USERS_INVITATION_EXPIRATION_DAYS", default=14
+)
diff --git a/api/funkwhale_api/users/factories.py b/api/funkwhale_api/users/factories.py
index eed8c717..5fceb57b 100644
--- a/api/funkwhale_api/users/factories.py
+++ b/api/funkwhale_api/users/factories.py
@@ -1,5 +1,6 @@
 import factory
 from django.contrib.auth.models import Permission
+from django.utils import timezone
 
 from funkwhale_api.factories import ManyToManyFromList, registry
 
@@ -28,6 +29,17 @@ class GroupFactory(factory.django.DjangoModelFactory):
             self.permissions.add(*perms)
 
 
+@registry.register
+class InvitationFactory(factory.django.DjangoModelFactory):
+    owner = factory.LazyFunction(lambda: UserFactory())
+
+    class Meta:
+        model = "users.Invitation"
+
+    class Params:
+        expired = factory.Trait(expiration_date=factory.LazyFunction(timezone.now))
+
+
 @registry.register
 class UserFactory(factory.django.DjangoModelFactory):
     username = factory.Sequence(lambda n: "user-{0}".format(n))
@@ -40,6 +52,9 @@ class UserFactory(factory.django.DjangoModelFactory):
         model = "users.User"
         django_get_or_create = ("username",)
 
+    class Params:
+        invited = factory.Trait(invitation=factory.SubFactory(InvitationFactory))
+
     @factory.post_generation
     def perms(self, create, extracted, **kwargs):
         if not create:
diff --git a/api/funkwhale_api/users/migrations/0009_auto_20180619_2024.py b/api/funkwhale_api/users/migrations/0009_auto_20180619_2024.py
new file mode 100644
index 00000000..e8204c4e
--- /dev/null
+++ b/api/funkwhale_api/users/migrations/0009_auto_20180619_2024.py
@@ -0,0 +1,31 @@
+# Generated by Django 2.0.6 on 2018-06-19 20:24
+
+from django.conf import settings
+from django.db import migrations, models
+import django.db.models.deletion
+import django.utils.timezone
+
+
+class Migration(migrations.Migration):
+
+    dependencies = [
+        ('users', '0008_auto_20180617_1531'),
+    ]
+
+    operations = [
+        migrations.CreateModel(
+            name='Invitation',
+            fields=[
+                ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
+                ('creation_date', models.DateTimeField(default=django.utils.timezone.now)),
+                ('expiration_date', models.DateTimeField()),
+                ('code', models.CharField(max_length=50, unique=True)),
+                ('owner', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='invitations', to=settings.AUTH_USER_MODEL)),
+            ],
+        ),
+        migrations.AddField(
+            model_name='user',
+            name='invitation',
+            field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='users', to='users.Invitation'),
+        ),
+    ]
diff --git a/api/funkwhale_api/users/models.py b/api/funkwhale_api/users/models.py
index 15d16db2..61f57a3c 100644
--- a/api/funkwhale_api/users/models.py
+++ b/api/funkwhale_api/users/models.py
@@ -4,6 +4,8 @@ from __future__ import absolute_import, unicode_literals
 import binascii
 import datetime
 import os
+import random
+import string
 import uuid
 
 from django.conf import settings
@@ -79,6 +81,14 @@ class User(AbstractUser):
 
     last_activity = models.DateTimeField(default=None, null=True, blank=True)
 
+    invitation = models.ForeignKey(
+        "Invitation",
+        related_name="users",
+        null=True,
+        blank=True,
+        on_delete=models.SET_NULL,
+    )
+
     def __str__(self):
         return self.username
 
@@ -138,3 +148,39 @@ class User(AbstractUser):
         if current is None or current < now - datetime.timedelta(seconds=delay):
             self.last_activity = now
             self.save(update_fields=["last_activity"])
+
+
+def generate_code(length=10):
+    return "".join(
+        random.SystemRandom().choice(string.ascii_lowercase) for _ in range(length)
+    )
+
+
+class InvitationQuerySet(models.QuerySet):
+    def open(self):
+        now = timezone.now()
+        qs = self.annotate(_users=models.Count("users"))
+        qs = qs.filter(_users=0)
+        qs = qs.exclude(expiration_date__lte=now)
+        return qs
+
+
+class Invitation(models.Model):
+    creation_date = models.DateTimeField(default=timezone.now)
+    expiration_date = models.DateTimeField()
+    owner = models.ForeignKey(
+        User, related_name="invitations", on_delete=models.CASCADE
+    )
+    code = models.CharField(max_length=50, unique=True)
+
+    objects = InvitationQuerySet.as_manager()
+
+    def save(self, **kwargs):
+        if not self.code:
+            self.code = generate_code()
+        if not self.expiration_date:
+            self.expiration_date = self.creation_date + datetime.timedelta(
+                days=settings.USERS_INVITATION_EXPIRATION_DAYS
+            )
+
+        return super().save(**kwargs)
diff --git a/api/tests/users/test_models.py b/api/tests/users/test_models.py
index 74bb091e..47569129 100644
--- a/api/tests/users/test_models.py
+++ b/api/tests/users/test_models.py
@@ -1,3 +1,4 @@
+import datetime
 import pytest
 
 from funkwhale_api.users import models
@@ -95,3 +96,25 @@ def test_record_activity_does_nothing_if_already(factories, now, mocker):
     user.record_activity()
 
     save.assert_not_called()
+
+
+def test_invitation_generates_random_code_on_save(factories):
+    invitation = factories["users.Invitation"]()
+    assert len(invitation.code) >= 6
+
+
+def test_invitation_expires_after_delay(factories, settings):
+    delay = settings.USERS_INVITATION_EXPIRATION_DAYS
+    invitation = factories["users.Invitation"]()
+    assert invitation.expiration_date == (
+        invitation.creation_date + datetime.timedelta(days=delay)
+    )
+
+
+def test_can_filter_open_invitations(factories):
+    okay = factories["users.Invitation"]()
+    factories["users.Invitation"](expired=True)
+    factories["users.User"](invited=True)
+
+    assert models.Invitation.objects.count() == 3
+    assert list(models.Invitation.objects.open()) == [okay]
-- 
GitLab