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
 
 
-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):
@@ -35,29 +32,6 @@ def get_avatar_hash(user, suffix=None):
     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):
     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 -*-
 from __future__ import unicode_literals
 
-import django.db.models.deletion
-import django.utils.timezone
 from django.conf import settings
+from django.contrib.postgres.fields import JSONField
 from django.db import migrations, models
+import django.db.models.deletion
+import django.utils.timezone
 
 from misago.core.pgutils import CreatePartialIndex
+import misago.users.avatars.store
 
 
 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')),
                 ('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')),
-                ('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)),
+                ('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_staff_message', models.TextField(null=True, blank=True)),
-                ('is_signature_locked', models.BooleanField(default=False)),
                 ('signature', 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)),
+                ('is_signature_locked', models.BooleanField(default=False)),
                 ('signature_lock_user_message', models.TextField(null=True, blank=True)),
                 ('signature_lock_staff_message', models.TextField(null=True, blank=True)),
                 ('warning_level', models.PositiveIntegerField(default=0)),
@@ -163,6 +167,15 @@ class Migration(migrations.Migration):
             bases=(models.Model,),
         ),
         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',
             fields=[
                 ('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 .user import *
 from .activityranking import *
+from .avatar import *
 from .ban 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 AbstractBaseUser, PermissionsMixin
 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.db import IntegrityError, models, transaction
 from django.dispatch import receiver
@@ -128,16 +129,13 @@ class UserManager(BaseUserManager):
 
         if set_default_avatar:
             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')
         if authenticated_role not in user.roles.all():
             user.roles.add(authenticated_role)
         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
         Online.objects.create(
@@ -227,16 +225,18 @@ class User(AbstractBaseUser, PermissionsMixin):
     )
     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)
+    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_staff_message = models.TextField(null=True, blank=True)
 
-    is_signature_locked = models.BooleanField(default=False)
     signature = 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)
+    is_signature_locked = models.BooleanField(default=False)
     signature_lock_user_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 ..avatars import dynamic, gallery, gravatar, store, uploaded
+from ..models import Avatar
 
 
 class AvatarsStoreTests(TestCase):
     def test_store(self):
         """store successfully stores and deletes avatar"""
         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)
-        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:
-            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:
-            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):

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