Browse Source

#610: Avatar model, image fields on User model, updated store_avatar and delete_avatar

Rafał Pitoń 8 years ago
parent
commit
35ab5574fe

+ 1 - 27
misago/users/avatars/store.py

@@ -11,10 +11,7 @@ from misago.conf import settings
 from .paths import AVATARS_STORE
 from .paths import AVATARS_STORE
 
 
 
 
-def normalize_image(image):
-    """if image is gif, strip it of animation"""
-    image.seek(0)
-    return image.copy().convert('RGBA')
+from .store_ import *
 
 
 
 
 def get_avatar_hash(user, suffix=None):
 def get_avatar_hash(user, suffix=None):
@@ -35,29 +32,6 @@ def get_avatar_hash(user, suffix=None):
     return md5_hash.hexdigest()[:8]
     return md5_hash.hexdigest()[:8]
 
 
 
 
-def store_avatar(user, image):
-    avatars_dir = get_existing_avatars_dir(user)
-
-    normalize_image(image)
-    for size in sorted(settings.MISAGO_AVATARS_SIZES, reverse=True):
-        avatar_file = '%s_%s.png' % (user.pk, size)
-        avatar_file = Path(os.path.join(avatars_dir, avatar_file))
-
-        image = image.resize((size, size), Image.ANTIALIAS)
-        image.save(avatar_file, "PNG")
-
-
-def delete_avatar(user):
-    avatars_dir = get_existing_avatars_dir(user)
-    suffixes_to_delete = settings.MISAGO_AVATARS_SIZES + ('org', 'tmp')
-
-    for size in suffixes_to_delete:
-        avatar_file = '%s_%s.png' % (user.pk, size)
-        avatar_file = Path(os.path.join(avatars_dir, avatar_file))
-        if avatar_file.exists():
-            avatar_file.remove()
-
-
 def get_user_avatar_tokens(user):
 def get_user_avatar_tokens(user):
     token_seeds = (user.email, user.avatar_hash, settings.SECRET_KEY)
     token_seeds = (user.email, user.avatar_hash, settings.SECRET_KEY)
 
 

+ 56 - 0
misago/users/avatars/store_.py

@@ -0,0 +1,56 @@
+import os
+from hashlib import md5
+from io import BytesIO
+
+from PIL import Image
+
+from django.core.files.base import ContentFile
+from django.db import transaction
+from django.utils.crypto import get_random_string
+
+from misago.conf import settings
+
+
+def normalize_image(image):
+    """strip image of animation, convert to RGBA"""
+    image.seek(0)
+    return image.copy().convert('RGBA')
+
+
+def store_avatar(user, image):
+    from ..models import Avatar
+    image = normalize_image(image)
+
+    avatars = []
+    for size in sorted(settings.MISAGO_AVATARS_SIZES, reverse=True):
+        image_stream = BytesIO()
+
+        image = image.resize((size, size), Image.ANTIALIAS)
+        image.save(image_stream, "PNG")
+
+        avatars.append(Avatar.objects.create(
+            user=user,
+            size=size,
+            image=ContentFile(image_stream.getvalue(), 'avatar')
+        ))
+
+    with transaction.atomic():
+        user.avatars = [{'size': a.size, 'url': a.url} for a in avatars]
+        user.save(update_fields=['avatars'])
+        delete_avatar(user, exclude=[a.id for a in avatars])
+
+
+def delete_avatar(user, exclude=None):
+    exclude = exclude or []
+    for avatar in user.avatar_set.exclude(id__in=exclude):
+        avatar.image.delete(False)
+    user.avatar_set.exclude(id__in=exclude).delete()
+
+
+def upload_to(instance, filename):
+    spread_path = md5(get_random_string(64).encode()).hexdigest()
+    secret = get_random_string(32)
+    filename_clean = '%s.png' % get_random_string(32)
+
+    return os.path.join(
+        'avatars', spread_path[:2], spread_path[2:4], secret, filename_clean)

+ 18 - 5
misago/users/migrations/0001_initial.py

@@ -1,12 +1,14 @@
 # -*- coding: utf-8 -*-
 # -*- coding: utf-8 -*-
 from __future__ import unicode_literals
 from __future__ import unicode_literals
 
 
-import django.db.models.deletion
-import django.utils.timezone
 from django.conf import settings
 from django.conf import settings
+from django.contrib.postgres.fields import JSONField
 from django.db import migrations, models
 from django.db import migrations, models
+import django.db.models.deletion
+import django.utils.timezone
 
 
 from misago.core.pgutils import CreatePartialIndex
 from misago.core.pgutils import CreatePartialIndex
+import misago.users.avatars.store
 
 
 
 
 class Migration(migrations.Migration):
 class Migration(migrations.Migration):
@@ -46,15 +48,17 @@ class Migration(migrations.Migration):
                 ('groups', models.ManyToManyField(related_query_name='user', related_name='user_set', to='auth.Group', blank=True, help_text='The groups this user belongs to. A user will get all permissions granted to each of his/her group.', verbose_name='groups')),
                 ('groups', models.ManyToManyField(related_query_name='user', related_name='user_set', to='auth.Group', blank=True, help_text='The groups this user belongs to. A user will get all permissions granted to each of his/her group.', verbose_name='groups')),
                 ('roles', models.ManyToManyField(to='misago_acl.Role')),
                 ('roles', models.ManyToManyField(to='misago_acl.Role')),
                 ('user_permissions', models.ManyToManyField(related_query_name='user', related_name='user_set', to='auth.Permission', blank=True, help_text='Specific permissions for this user.', verbose_name='user permissions')),
                 ('user_permissions', models.ManyToManyField(related_query_name='user', related_name='user_set', to='auth.Permission', blank=True, help_text='Specific permissions for this user.', verbose_name='user permissions')),
-                ('is_avatar_locked', models.BooleanField(default=False)),
-                ('avatar_hash', models.CharField(max_length=8, default='-')),
+                ('avatar_temp', models.ImageField(upload_to=misago.users.avatars.store.upload_to, null=True, blank=True)),
+                ('avatar_src', models.ImageField(upload_to=misago.users.avatars.store.upload_to, null=True, blank=True)),
                 ('avatar_crop', models.CharField(max_length=255, null=True, blank=True)),
                 ('avatar_crop', models.CharField(max_length=255, null=True, blank=True)),
+                ('avatars', JSONField(null=True, blank=True)),
+                ('is_avatar_locked', models.BooleanField(default=False)),
                 ('avatar_lock_user_message', models.TextField(null=True, blank=True)),
                 ('avatar_lock_user_message', models.TextField(null=True, blank=True)),
                 ('avatar_lock_staff_message', models.TextField(null=True, blank=True)),
                 ('avatar_lock_staff_message', models.TextField(null=True, blank=True)),
-                ('is_signature_locked', models.BooleanField(default=False)),
                 ('signature', models.TextField(null=True, blank=True)),
                 ('signature', models.TextField(null=True, blank=True)),
                 ('signature_parsed', models.TextField(null=True, blank=True)),
                 ('signature_parsed', models.TextField(null=True, blank=True)),
                 ('signature_checksum', models.CharField(max_length=64, null=True, blank=True)),
                 ('signature_checksum', models.CharField(max_length=64, null=True, blank=True)),
+                ('is_signature_locked', models.BooleanField(default=False)),
                 ('signature_lock_user_message', models.TextField(null=True, blank=True)),
                 ('signature_lock_user_message', models.TextField(null=True, blank=True)),
                 ('signature_lock_staff_message', models.TextField(null=True, blank=True)),
                 ('signature_lock_staff_message', models.TextField(null=True, blank=True)),
                 ('warning_level', models.PositiveIntegerField(default=0)),
                 ('warning_level', models.PositiveIntegerField(default=0)),
@@ -163,6 +167,15 @@ class Migration(migrations.Migration):
             bases=(models.Model,),
             bases=(models.Model,),
         ),
         ),
         migrations.CreateModel(
         migrations.CreateModel(
+            name='Avatar',
+            fields=[
+                ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
+                ('user', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to=settings.AUTH_USER_MODEL)),
+                ('size', models.PositiveIntegerField(default=0)),
+                ('image', models.ImageField(upload_to=misago.users.avatars.store.upload_to)),
+            ],
+        ),
+        migrations.CreateModel(
             name='Ban',
             name='Ban',
             fields=[
             fields=[
                 ('id', models.AutoField(verbose_name='ID', serialize=False, auto_created=True, primary_key=True)),
                 ('id', models.AutoField(verbose_name='ID', serialize=False, auto_created=True, primary_key=True)),

+ 1 - 0
misago/users/models/__init__.py

@@ -2,5 +2,6 @@
 from .rank import *
 from .rank import *
 from .user import *
 from .user import *
 from .activityranking import *
 from .activityranking import *
+from .avatar import *
 from .ban import *
 from .ban import *
 from .warnings import *
 from .warnings import *

+ 17 - 0
misago/users/models/avatar.py

@@ -0,0 +1,17 @@
+from django.conf import settings
+from django.db import models
+
+from ..avatars import store
+
+
+class Avatar(models.Model):
+    user = models.ForeignKey(
+        settings.AUTH_USER_MODEL,
+        on_delete=models.CASCADE
+    )
+    size = models.PositiveIntegerField(default=0)
+    image = models.ImageField(upload_to=store.upload_to)
+
+    @property
+    def url(self):
+        return self.image.url

+ 7 - 7
misago/users/models/user.py

@@ -3,6 +3,7 @@ from hashlib import md5
 from django.contrib.auth.models import AnonymousUser as DjangoAnonymousUser
 from django.contrib.auth.models import AnonymousUser as DjangoAnonymousUser
 from django.contrib.auth.models import AbstractBaseUser, PermissionsMixin
 from django.contrib.auth.models import AbstractBaseUser, PermissionsMixin
 from django.contrib.auth.models import UserManager as BaseUserManager
 from django.contrib.auth.models import UserManager as BaseUserManager
+from django.contrib.postgres.fields import JSONField
 from django.core.mail import send_mail
 from django.core.mail import send_mail
 from django.db import IntegrityError, models, transaction
 from django.db import IntegrityError, models, transaction
 from django.dispatch import receiver
 from django.dispatch import receiver
@@ -128,16 +129,13 @@ class UserManager(BaseUserManager):
 
 
         if set_default_avatar:
         if set_default_avatar:
             avatars.set_default_avatar(user)
             avatars.set_default_avatar(user)
-            user.avatar_hash = avatars.get_avatar_hash(user)
-        else:
-            user.avatar_hash = 'abcdef01'
 
 
         authenticated_role = Role.objects.get(special_role='authenticated')
         authenticated_role = Role.objects.get(special_role='authenticated')
         if authenticated_role not in user.roles.all():
         if authenticated_role not in user.roles.all():
             user.roles.add(authenticated_role)
             user.roles.add(authenticated_role)
         user.update_acl_key()
         user.update_acl_key()
 
 
-        user.save(update_fields=['avatar_hash', 'acl_key'])
+        user.save(update_fields=['avatars', 'acl_key'])
 
 
         # populate online tracker with default value
         # populate online tracker with default value
         Online.objects.create(
         Online.objects.create(
@@ -227,16 +225,18 @@ class User(AbstractBaseUser, PermissionsMixin):
     )
     )
     is_active_staff_message = models.TextField(null=True, blank=True)
     is_active_staff_message = models.TextField(null=True, blank=True)
 
 
-    is_avatar_locked = models.BooleanField(default=False)
-    avatar_hash = models.CharField(max_length=8, default='-')
+    avatar_temp = models.ImageField(upload_to=avatars.store.upload_to, null=True, blank=True)
+    avatar_src = models.ImageField(upload_to=avatars.store.upload_to, null=True, blank=True)
     avatar_crop = models.CharField(max_length=255, null=True, blank=True)
     avatar_crop = models.CharField(max_length=255, null=True, blank=True)
+    avatars = JSONField(null=True, blank=True)
+    is_avatar_locked = models.BooleanField(default=False)
     avatar_lock_user_message = models.TextField(null=True, blank=True)
     avatar_lock_user_message = models.TextField(null=True, blank=True)
     avatar_lock_staff_message = models.TextField(null=True, blank=True)
     avatar_lock_staff_message = models.TextField(null=True, blank=True)
 
 
-    is_signature_locked = models.BooleanField(default=False)
     signature = models.TextField(null=True, blank=True)
     signature = models.TextField(null=True, blank=True)
     signature_parsed = models.TextField(null=True, blank=True)
     signature_parsed = models.TextField(null=True, blank=True)
     signature_checksum = models.CharField(max_length=64, null=True, blank=True)
     signature_checksum = models.CharField(max_length=64, null=True, blank=True)
+    is_signature_locked = models.BooleanField(default=False)
     signature_lock_user_message = models.TextField(null=True, blank=True)
     signature_lock_user_message = models.TextField(null=True, blank=True)
     signature_lock_staff_message = models.TextField(null=True, blank=True)
     signature_lock_staff_message = models.TextField(null=True, blank=True)
 
 

+ 56 - 31
misago/users/tests/test_avatars.py

@@ -8,53 +8,78 @@ from django.test import TestCase
 from misago.conf import settings
 from misago.conf import settings
 
 
 from ..avatars import dynamic, gallery, gravatar, store, uploaded
 from ..avatars import dynamic, gallery, gravatar, store, uploaded
+from ..models import Avatar
 
 
 
 
 class AvatarsStoreTests(TestCase):
 class AvatarsStoreTests(TestCase):
     def test_store(self):
     def test_store(self):
         """store successfully stores and deletes avatar"""
         """store successfully stores and deletes avatar"""
         User = get_user_model()
         User = get_user_model()
-        test_user = User.objects.create_user('Bob', 'bob@bob.com', 'pass123')
+        user = User.objects.create_user('Bob', 'bob@bob.com', 'pass123')
 
 
         test_image = Image.new("RGBA", (100, 100), 0)
         test_image = Image.new("RGBA", (100, 100), 0)
-        store.store_avatar(test_user, test_image)
+        store.store_avatar(user, test_image)
 
 
-        # Assert that avatar was stored
-        avatar_dir = store.get_existing_avatars_dir(test_user)
-        for size in settings.MISAGO_AVATARS_SIZES:
-            avatar = Path('%s/%s_%s.png' % (avatar_dir, test_user.pk, size))
-            self.assertTrue(avatar.exists())
-            self.assertTrue(avatar.isfile())
+        # reload user
+        test_user = User.objects.get(pk=user.pk)
 
 
-        # Delete avatar and assert its gone
-        store.delete_avatar(test_user)
+        # assert that avatars were stored in media
+        avatars_dict = {}
         for size in settings.MISAGO_AVATARS_SIZES:
         for size in settings.MISAGO_AVATARS_SIZES:
-            avatar = Path('%s/%s_%s.png' % (avatar_dir, test_user.pk, size))
-            self.assertFalse(avatar.exists())
+            avatar = user.avatar_set.get(size=size)
 
 
-        # Override new avatar and test that it was changed
-        store.store_avatar(test_user, test_image)
-        store.store_new_avatar(test_user, test_image)
-        for size in settings.MISAGO_AVATARS_SIZES:
-            avatar = Path('%s/%s_%s.png' % (avatar_dir, test_user.pk, size))
-            self.assertTrue(avatar.exists())
-            self.assertTrue(avatar.isfile())
+            self.assertTrue(avatar.image.url)
+            self.assertEqual(avatar.url, avatar.image.url)
+
+            avatars_dict[size] = avatar
+
+        # asserts that user.avatars cache was set
+        self.assertEqual(len(avatars_dict), len(settings.MISAGO_AVATARS_SIZES))
+        self.assertEqual(len(user.avatars), len(settings.MISAGO_AVATARS_SIZES))
+        self.assertEqual(len(user.avatars), len(avatars_dict))
 
 
-        # Compute avatar hash
-        test_user.avatar_hash = store.get_avatar_hash(test_user)
-        self.assertEqual(len(test_user.avatar_hash), 8)
-        test_user.save(update_fields=['avatar_hash'])
+        for avatar in user.avatars:
+            self.assertIn(avatar['size'], settings.MISAGO_AVATARS_SIZES)
+            self.assertEqual(avatar['url'], avatars_dict[avatar['size']].url)
 
 
-        # Get avatar tokens
-        tokens = store.get_user_avatar_tokens(test_user)
-        self.assertEqual(tokens[tokens['org']], 'org')
-        self.assertEqual(tokens[tokens['tmp']], 'tmp')
+        # another avatar change deleted old avatars
+        store.store_avatar(user, test_image)
+        for old_avatar in avatars_dict.values():
+            avatar_path = Path(old_avatar.image.path)
+            self.assertFalse(avatar_path.exists())
+            self.assertFalse(avatar_path.isfile())
 
 
-        # Delete avatar
-        store.delete_avatar(test_user)
+            with self.assertRaises(Avatar.DoesNotExist):
+                Avatar.objects.get(pk=old_avatar.pk)
+
+        # and updated user avatars again
+        new_avatars_dict = {}
         for size in settings.MISAGO_AVATARS_SIZES:
         for size in settings.MISAGO_AVATARS_SIZES:
-            avatar = Path('%s/%s_%s.png' % (avatar_dir, test_user.pk, size))
-            self.assertFalse(avatar.exists())
+            avatar = user.avatar_set.get(size=size)
+
+            self.assertTrue(avatar.image.url)
+            self.assertEqual(avatar.url, avatar.image.url)
+
+            new_avatars_dict[size] = avatar
+
+        self.assertTrue(avatars_dict != new_avatars_dict)
+
+        # asserts that user.avatars cache was updated
+        self.assertEqual(len(user.avatars), len(settings.MISAGO_AVATARS_SIZES))
+        for avatar in user.avatars:
+            self.assertIn(avatar['size'], settings.MISAGO_AVATARS_SIZES)
+            self.assertEqual(avatar['url'], new_avatars_dict[avatar['size']].url)
+
+        # delete avatar
+        store.delete_avatar(user)
+
+        for removed_avatar in new_avatars_dict.values():
+            avatar_path = Path(removed_avatar.image.path)
+            self.assertFalse(avatar_path.exists())
+            self.assertFalse(avatar_path.isfile())
+
+            with self.assertRaises(Avatar.DoesNotExist):
+                Avatar.objects.get(pk=removed_avatar.pk)
 
 
 
 
 class AvatarSetterTests(TestCase):
 class AvatarSetterTests(TestCase):

BIN
misago/users/tests/testfiles/avatar_src.png