Browse Source

Post attachments, check their permissions. #29

Rafał Pitoń 11 years ago
parent
commit
66a926c6c5

+ 6 - 4
.gitignore

@@ -66,7 +66,7 @@ local.properties
 **/*.dotCover
 
 ## TODO: If you have NuGet Package Restore enabled, uncomment this
-#**/packages/ 
+#**/packages/
 
 # Visual C++ cache files
 ipch/
@@ -82,7 +82,7 @@ ipch/
 # ReSharper is a .NET coding add-in
 _ReSharper*
 
-# Installshield output folder 
+# Installshield output folder
 [Ee]xpress
 
 # DocProject is a documentation generator add-in
@@ -123,7 +123,7 @@ UpgradeLog*.XML
 ############
 
 # Windows image file caches
-Thumbs.db 
+Thumbs.db
 
 # Folder config file
 Desktop.ini
@@ -176,6 +176,7 @@ django_jinja/**
 floppyforms/**
 haystack/**
 jinja2/**
+attachments/**
 markdown/**
 mptt/**
 pytz/**
@@ -205,4 +206,5 @@ templates/debug_toolbar/**
 
 .vagrant
 database.db
-Icon

+Icon
+

BIN
attachments/11-8-2013/a6f3e785c3956fc653b93856281b70b8.jpg


BIN
attachments/11-8-2013/a6f3e785c3956fc653b93856281b70b8_thumb.jpg


BIN
attachments/11-8-2013/afae7e85ad9df01cab508124e8040545.jpg


BIN
attachments/11-8-2013/afae7e85ad9df01cab508124e8040545_thumb.jpg


+ 19 - 0
misago/acl/permissions/threads.py

@@ -675,6 +675,25 @@ class ThreadsACL(BaseACL):
         except KeyError:
             return -1
 
+    def can_delete_attachment(self, user, forum, attachment):
+        if user.pk == attachment.pk:
+            return True
+        try:
+            forum_role = self.get_role(forum)
+            return forum_role['can_delete_attachments']
+        except KeyError:
+            return False
+
+    def allow_attachment_delete(self, user, forum, attachment):
+        if user.pk == attachment.pk:
+            return True
+        try:
+            forum_role = self.get_role(forum)
+            if not forum_role['can_delete_attachments']:
+                raise ACLError403(_("You don't have permission to remove this attachment."))
+        except KeyError:
+            raise ACLError403(_("You don't have permission to remove this attachment."))
+
     def can_see_all_checkpoints(self, forum):
         try:
             forum_role = self.get_role(forum)

+ 2 - 0
misago/apps/attachments.py

@@ -13,6 +13,8 @@ def server(self, attachment, thumb=False):
         else:
             response = StreamingHttpResponse(open(attachment.file_path), content_type=attachment.content_type)
             response['Cache-Control'] = 'no-cache'
+        if not attachment.is_image:
+            response['Content-Disposition'] = 'attachment;filename="%s"' % attachment.name
         return response
     except Attachment.DoesNotExist:
         pass

+ 68 - 17
misago/apps/threadtype/posting/base.py

@@ -109,24 +109,33 @@ class PostingBaseView(ViewBase):
             post_pk = 0
 
         self.attachments_token = 'attachments_%s_%s_%s_%s' % (self.request.user.pk, forum_pk, thread_pk, post_pk)
+        self.attachments_removed_token = 'removed_%s' % self.attachments_token
 
     def session_attachments_queryset(self):
-        self.make_attachments_token()
         session_pks = self.request.session.get(self.attachments_token, 'nada')
         if session_pks == 'nada':
             session_pks = [a.pk for a in Attachment.objects.filter(session=self.attachments_token).iterator()]
             self.request.session[self.attachments_token] = session_pks
 
         self.session_attachments = session_pks
-        return Attachment.objects.filter(id__in=session_pks).iterator()
+        return Attachment.objects.filter(id__in=session_pks).order_by('-id').iterator()
+
+    def fetch_removed_attachments(self):
+        self.attachments_removed = self.request.session.get(self.attachments_removed_token, 'nada')
+        if self.attachments_removed == 'nada':
+            self.attachments_removed = []
+            self.request.session[self.attachments_removed_token] = []
 
     def fetch_attachments(self):
         self.attachments = []
         self.user_attachments = 0
 
+        self.attachments_removed = []
+        self.fetch_removed_attachments()
+
         for attachment in self.session_attachments_queryset():
             self.attachments.append(attachment)
-            if attachment.user_id == self.request.user.pk:
+            if attachment.user_id == self.request.user.pk and not attachment.pk in self.attachments_removed:
                 self.user_attachments += 1
 
     def _upload_file(self, uploaded_file):
@@ -153,6 +162,7 @@ class PostingBaseView(ViewBase):
             new_attachment.filetype = attachment_type
             new_attachment.user = self.request.user
             new_attachment.user_name = self.request.user.username
+            new_attachment.user_name_slug = self.request.user.username_slug
             new_attachment.ip = self.request.session.get_ip(self.request)
             new_attachment.agent = self.request.META.get('HTTP_USER_AGENT')
             new_attachment.use_file(uploaded_file)
@@ -160,7 +170,7 @@ class PostingBaseView(ViewBase):
 
             self.session_attachments.append(new_attachment.pk)
             self.request.session[self.attachments_token] = self.session_attachments
-            self.attachments.append(new_attachment)
+            self.attachments.insert(0, new_attachment)
             self.message = Message(_('File "%(filename)s" has been attached successfully.') % {'filename': new_attachment.name})
         except ACLError403 as e:
             self.message = Message(unicode(e), messages.ERROR)
@@ -176,20 +186,55 @@ class PostingBaseView(ViewBase):
                     break
             else:
                 raise ValidationError(_('Requested attachment could not be found.'))
-            deleted_pks = self.request.session.get('delete_%s' % self.attachments_token, [])
-            deleted_pks.append(attachment_pk)
-            del(self.attachments[index])
-            self.message = Message(_('File "%(filename)s" has been removed.') % {'filename': attachment.name})
+            self.request.acl.threads.allow_attachment_delete(self.request.user, self.forum, attachment)
+
+            if not attachment.pk in self.attachments_removed:
+                self.attachments_removed.append(attachment.pk)
+                self.request.session[self.attachments_removed_token] = self.attachments_removed
+                self.message = Message(_('File "%(filename)s" has been removed.') % {'filename': attachment.name})
         except ACLError403 as e:
             self.message = Message(unicode(e), messages.ERROR)
         except ValidationError as e:
             self.message = Message(unicode(e.messages[0]), messages.ERROR)
 
-    def validate_attachments(self, form):
-        return True
+    def restore_attachment(self, attachment_pk):
+        try:
+            index = None
+            attachment = None
+            for index, attachment in enumerate(self.attachments):
+                if attachment.pk == attachment_pk:
+                    break
+            else:
+                raise ValidationError(_('Requested attachment could not be found.'))
+
+            if attachment.pk in self.attachments_removed:
+                self.attachments_removed.remove(attachment.pk)
+                self.request.session[self.attachments_removed_token] = self.attachments_removed
+                self.message = Message(_('File "%(filename)s" has been restored.') % {'filename': attachment.name})
+        except ACLError403 as e:
+            self.message = Message(unicode(e), messages.ERROR)
+        except ValidationError as e:
+            self.message = Message(unicode(e.messages[0]), messages.ERROR)
 
     def finalize_attachments(self):
-        pass
+        del self.request.session[self.attachments_token]
+        del self.request.session[self.attachments_removed_token]
+        self.make_attachments_token()
+
+        post_attachments = []
+        for attachment in self.attachments:
+            if attachment.pk in self.attachments_removed:
+                attachment.delete()
+            else:
+                attachment.forum = self.forum
+                attachment.thread = self.thread
+                attachment.post = self.post
+                attachment.session = self.attachments_token
+                attachment.save()
+
+        if post_attachments:
+            self.post.attachments = post_attachments
+            self.post.save(force_update=True)
 
     def __call__(self, request, **kwargs):
         self.request = request
@@ -210,6 +255,7 @@ class PostingBaseView(ViewBase):
             self.check_forum_type()
             self._check_permissions()
             request.block_flood_requests = self.block_flood_requests
+            self.make_attachments_token()
             self.fetch_attachments()
             if request.method == 'POST':
                 # Create correct form instance
@@ -222,27 +268,30 @@ class PostingBaseView(ViewBase):
                     except AttributeError:
                         form = self.form_type(request.POST, request=request, forum=self.forum, thread=self.thread)
                 # Handle specific submit
-                if list(set(request.POST.keys()) - set(('preview', 'upload', 'remove_attachment'))):
+                if list(set(request.POST.keys()) & set(('preview', 'upload', 'remove_attachment', 'restore_attachment'))):
                     form.empty_errors()
                     if form['post'].value():
                         md, post_preview = post_markdown(form['post'].value())
                     else:
                         md, post_preview = None, None
                     if 'upload' in request.POST:
-                        uploaded_file = None
                         try:
                             uploaded_file = form['new_file'].value()
                         except KeyError:
-                            pass
-                        if uploaded_file:
-                            self._upload_file(uploaded_file)
+                            uploaded_file = None
+                        self._upload_file(uploaded_file)
                     if 'remove_attachment' in request.POST:
                         try:
                             self.remove_attachment(int(request.POST.get('remove_attachment')))
                         except ValueError:
                             self.message = Message(_("Requested attachment could not be found."), messages.ERROR)
+                    if 'restore_attachment' in request.POST:
+                        try:
+                            self.restore_attachment(int(request.POST.get('restore_attachment')))
+                        except ValueError:
+                            self.message = Message(_("Requested attachment could not be found."), messages.ERROR)
                 else:
-                    if form.is_valid() and validate_attachments(form):
+                    if form.is_valid():
                         self.post_form(form)
                         self.watch_thread()
                         self.after_form(form)
@@ -264,6 +313,8 @@ class PostingBaseView(ViewBase):
                                   self._template_vars({
                                         'action': self.action,
                                         'attachments': self.attachments,
+                                        'attachments_types': AttachmentType.objects.all_types(),
+                                        'attachments_removed': self.attachments_removed,
                                         'attachments_number': self.user_attachments,
                                         'message': self.message,
                                         'forum': self.forum,

+ 6 - 0
misago/fixtures/attachmenttypes.py

@@ -12,4 +12,10 @@ def load():
         name=_('Archive').message,
         extensions='rar,zip,7z,tar.gz',
         size_limit=0,
+    )
+
+    AttachmentType.objects.create(
+        name=_('Documents').message,
+        extensions='pdf,txt,doc,docx,xls,xlsx,xlsm,xlsb',
+        size_limit=0,
     )

+ 486 - 0
misago/migrations/0033_auto__add_field_post_has_attachments__add_field_post__attachments.py

@@ -0,0 +1,486 @@
+# -*- coding: utf-8 -*-
+import datetime
+from south.db import db
+from south.v2 import SchemaMigration
+from django.db import models
+
+
+class Migration(SchemaMigration):
+
+    def forwards(self, orm):
+        # Adding field 'Post.has_attachments'
+        db.add_column(u'misago_post', 'has_attachments',
+                      self.gf('django.db.models.fields.BooleanField')(default=False),
+                      keep_default=False)
+
+        # Adding field 'Post._attachments'
+        db.add_column(u'misago_post', '_attachments',
+                      self.gf('django.db.models.fields.TextField')(null=True, db_column='attachments', blank=True),
+                      keep_default=False)
+
+
+    def backwards(self, orm):
+        # Deleting field 'Post.has_attachments'
+        db.delete_column(u'misago_post', 'has_attachments')
+
+        # Deleting field 'Post._attachments'
+        db.delete_column(u'misago_post', 'attachments')
+
+
+    models = {
+        'misago.alert': {
+            'Meta': {'object_name': 'Alert'},
+            'date': ('django.db.models.fields.DateTimeField', [], {}),
+            u'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}),
+            'message': ('django.db.models.fields.TextField', [], {}),
+            'user': ('django.db.models.fields.related.ForeignKey', [], {'to': "orm['misago.User']"}),
+            'variables': ('django.db.models.fields.TextField', [], {'null': 'True', 'blank': 'True'})
+        },
+        'misago.attachment': {
+            'Meta': {'object_name': 'Attachment'},
+            'agent': ('django.db.models.fields.CharField', [], {'max_length': '255'}),
+            'content_type': ('django.db.models.fields.CharField', [], {'max_length': '255'}),
+            'date': ('django.db.models.fields.DateTimeField', [], {'default': 'datetime.datetime.now'}),
+            'filetype': ('django.db.models.fields.related.ForeignKey', [], {'to': "orm['misago.AttachmentType']"}),
+            'forum': ('django.db.models.fields.related.ForeignKey', [], {'to': "orm['misago.Forum']", 'null': 'True', 'on_delete': 'models.SET_NULL', 'blank': 'True'}),
+            u'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}),
+            'ip': ('django.db.models.fields.GenericIPAddressField', [], {'max_length': '39'}),
+            'name': ('django.db.models.fields.CharField', [], {'max_length': '255'}),
+            'path': ('django.db.models.fields.CharField', [], {'max_length': '255'}),
+            'post': ('django.db.models.fields.related.ForeignKey', [], {'to': "orm['misago.Post']", 'null': 'True', 'on_delete': 'models.SET_NULL', 'blank': 'True'}),
+            'session': ('django.db.models.fields.CharField', [], {'max_length': '255', 'db_index': 'True'}),
+            'size': ('django.db.models.fields.PositiveIntegerField', [], {'max_length': '255'}),
+            'thread': ('django.db.models.fields.related.ForeignKey', [], {'to': "orm['misago.Thread']", 'null': 'True', 'on_delete': 'models.SET_NULL', 'blank': 'True'}),
+            'user': ('django.db.models.fields.related.ForeignKey', [], {'to': "orm['misago.User']", 'null': 'True', 'on_delete': 'models.SET_NULL', 'blank': 'True'}),
+            'user_name': ('django.db.models.fields.CharField', [], {'max_length': '255'}),
+            'user_name_slug': ('django.db.models.fields.CharField', [], {'max_length': '255'})
+        },
+        'misago.attachmenttype': {
+            'Meta': {'object_name': 'AttachmentType'},
+            'extensions': ('django.db.models.fields.CharField', [], {'max_length': '255'}),
+            u'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}),
+            'name': ('django.db.models.fields.CharField', [], {'max_length': '255', 'db_index': 'True'}),
+            'roles': ('django.db.models.fields.related.ManyToManyField', [], {'to': "orm['misago.Role']", 'symmetrical': 'False'}),
+            'size_limit': ('django.db.models.fields.PositiveIntegerField', [], {'default': '0'})
+        },
+        'misago.ban': {
+            'Meta': {'object_name': 'Ban'},
+            'ban': ('django.db.models.fields.CharField', [], {'max_length': '255'}),
+            'expires': ('django.db.models.fields.DateTimeField', [], {'null': 'True', 'blank': 'True'}),
+            u'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}),
+            'reason_admin': ('django.db.models.fields.TextField', [], {'null': 'True', 'blank': 'True'}),
+            'reason_user': ('django.db.models.fields.TextField', [], {'null': 'True', 'blank': 'True'}),
+            'test': ('django.db.models.fields.PositiveIntegerField', [], {'default': '0'})
+        },
+        'misago.change': {
+            'Meta': {'object_name': 'Change'},
+            'agent': ('django.db.models.fields.CharField', [], {'max_length': '255'}),
+            'change': ('django.db.models.fields.IntegerField', [], {'default': '0'}),
+            'date': ('django.db.models.fields.DateTimeField', [], {}),
+            'forum': ('django.db.models.fields.related.ForeignKey', [], {'to': "orm['misago.Forum']"}),
+            u'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}),
+            'ip': ('django.db.models.fields.GenericIPAddressField', [], {'max_length': '39'}),
+            'post': ('django.db.models.fields.related.ForeignKey', [], {'to': "orm['misago.Post']"}),
+            'post_content': ('django.db.models.fields.TextField', [], {}),
+            'reason': ('django.db.models.fields.CharField', [], {'max_length': '255', 'null': 'True', 'blank': 'True'}),
+            'size': ('django.db.models.fields.IntegerField', [], {'default': '0'}),
+            'thread': ('django.db.models.fields.related.ForeignKey', [], {'to': "orm['misago.Thread']"}),
+            'thread_name_new': ('django.db.models.fields.CharField', [], {'max_length': '255', 'null': 'True', 'blank': 'True'}),
+            'thread_name_old': ('django.db.models.fields.CharField', [], {'max_length': '255', 'null': 'True', 'blank': 'True'}),
+            'user': ('django.db.models.fields.related.ForeignKey', [], {'to': "orm['misago.User']", 'null': 'True', 'on_delete': 'models.SET_NULL', 'blank': 'True'}),
+            'user_name': ('django.db.models.fields.CharField', [], {'max_length': '255'}),
+            'user_slug': ('django.db.models.fields.CharField', [], {'max_length': '255'})
+        },
+        'misago.checkpoint': {
+            'Meta': {'object_name': 'Checkpoint'},
+            'action': ('django.db.models.fields.CharField', [], {'max_length': '255'}),
+            'agent': ('django.db.models.fields.CharField', [], {'max_length': '255'}),
+            'date': ('django.db.models.fields.DateTimeField', [], {}),
+            'deleted': ('django.db.models.fields.BooleanField', [], {'default': 'False'}),
+            'extra': ('django.db.models.fields.CharField', [], {'max_length': '255', 'null': 'True', 'blank': 'True'}),
+            'forum': ('django.db.models.fields.related.ForeignKey', [], {'to': "orm['misago.Forum']"}),
+            u'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}),
+            'ip': ('django.db.models.fields.GenericIPAddressField', [], {'max_length': '39'}),
+            'old_forum': ('django.db.models.fields.related.ForeignKey', [], {'blank': 'True', 'related_name': "'+'", 'null': 'True', 'to': "orm['misago.Forum']"}),
+            'old_forum_name': ('django.db.models.fields.CharField', [], {'max_length': '255', 'null': 'True', 'blank': 'True'}),
+            'old_forum_slug': ('django.db.models.fields.CharField', [], {'max_length': '255', 'null': 'True', 'blank': 'True'}),
+            'target_user': ('django.db.models.fields.related.ForeignKey', [], {'blank': 'True', 'related_name': "'+'", 'null': 'True', 'on_delete': 'models.SET_NULL', 'to': "orm['misago.User']"}),
+            'target_user_name': ('django.db.models.fields.CharField', [], {'max_length': '255', 'null': 'True', 'blank': 'True'}),
+            'target_user_slug': ('django.db.models.fields.CharField', [], {'max_length': '255', 'null': 'True', 'blank': 'True'}),
+            'thread': ('django.db.models.fields.related.ForeignKey', [], {'to': "orm['misago.Thread']"}),
+            'user': ('django.db.models.fields.related.ForeignKey', [], {'to': "orm['misago.User']", 'null': 'True', 'on_delete': 'models.SET_NULL', 'blank': 'True'}),
+            'user_name': ('django.db.models.fields.CharField', [], {'max_length': '255'}),
+            'user_slug': ('django.db.models.fields.CharField', [], {'max_length': '255'})
+        },
+        'misago.fixture': {
+            'Meta': {'object_name': 'Fixture'},
+            u'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}),
+            'name': ('django.db.models.fields.CharField', [], {'max_length': '255'})
+        },
+        'misago.forum': {
+            'Meta': {'object_name': 'Forum'},
+            'attrs': ('django.db.models.fields.CharField', [], {'max_length': '255', 'null': 'True', 'blank': 'True'}),
+            'closed': ('django.db.models.fields.BooleanField', [], {'default': 'False'}),
+            'description': ('django.db.models.fields.TextField', [], {'null': 'True', 'blank': 'True'}),
+            'description_preparsed': ('django.db.models.fields.TextField', [], {'null': 'True', 'blank': 'True'}),
+            u'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}),
+            'last_poster': ('django.db.models.fields.related.ForeignKey', [], {'blank': 'True', 'related_name': "'+'", 'null': 'True', 'on_delete': 'models.SET_NULL', 'to': "orm['misago.User']"}),
+            'last_poster_name': ('django.db.models.fields.CharField', [], {'max_length': '255', 'null': 'True', 'blank': 'True'}),
+            'last_poster_slug': ('django.db.models.fields.SlugField', [], {'max_length': '255', 'null': 'True', 'blank': 'True'}),
+            'last_poster_style': ('django.db.models.fields.CharField', [], {'max_length': '255', 'null': 'True', 'blank': 'True'}),
+            'last_thread': ('django.db.models.fields.related.ForeignKey', [], {'blank': 'True', 'related_name': "'+'", 'null': 'True', 'on_delete': 'models.SET_NULL', 'to': "orm['misago.Thread']"}),
+            'last_thread_date': ('django.db.models.fields.DateTimeField', [], {'null': 'True', 'blank': 'True'}),
+            'last_thread_name': ('django.db.models.fields.CharField', [], {'max_length': '255', 'null': 'True', 'blank': 'True'}),
+            'last_thread_slug': ('django.db.models.fields.SlugField', [], {'max_length': '255', 'null': 'True', 'blank': 'True'}),
+            'level': ('django.db.models.fields.PositiveIntegerField', [], {'db_index': 'True'}),
+            'lft': ('django.db.models.fields.PositiveIntegerField', [], {'db_index': 'True'}),
+            'name': ('django.db.models.fields.CharField', [], {'max_length': '255'}),
+            'parent': ('mptt.fields.TreeForeignKey', [], {'blank': 'True', 'related_name': "'children'", 'null': 'True', 'to': "orm['misago.Forum']"}),
+            'posts': ('django.db.models.fields.PositiveIntegerField', [], {'default': '0'}),
+            'posts_delta': ('django.db.models.fields.IntegerField', [], {'default': '0'}),
+            'prune_last': ('django.db.models.fields.PositiveIntegerField', [], {'default': '0'}),
+            'prune_start': ('django.db.models.fields.PositiveIntegerField', [], {'default': '0'}),
+            'pruned_archive': ('django.db.models.fields.related.ForeignKey', [], {'blank': 'True', 'related_name': "'+'", 'null': 'True', 'on_delete': 'models.SET_NULL', 'to': "orm['misago.Forum']"}),
+            'redirect': ('django.db.models.fields.CharField', [], {'max_length': '255', 'null': 'True', 'blank': 'True'}),
+            'redirects': ('django.db.models.fields.PositiveIntegerField', [], {'default': '0'}),
+            'redirects_delta': ('django.db.models.fields.IntegerField', [], {'default': '0'}),
+            'rght': ('django.db.models.fields.PositiveIntegerField', [], {'db_index': 'True'}),
+            'show_details': ('django.db.models.fields.BooleanField', [], {'default': 'True'}),
+            'slug': ('django.db.models.fields.SlugField', [], {'max_length': '255'}),
+            'special': ('django.db.models.fields.CharField', [], {'max_length': '255', 'null': 'True', 'blank': 'True'}),
+            'style': ('django.db.models.fields.CharField', [], {'max_length': '255', 'null': 'True', 'blank': 'True'}),
+            'threads': ('django.db.models.fields.PositiveIntegerField', [], {'default': '0'}),
+            'threads_delta': ('django.db.models.fields.PositiveIntegerField', [], {'default': '0'}),
+            'tree_id': ('django.db.models.fields.PositiveIntegerField', [], {'db_index': 'True'}),
+            'type': ('django.db.models.fields.CharField', [], {'max_length': '12'})
+        },
+        'misago.forumread': {
+            'Meta': {'object_name': 'ForumRead'},
+            'cleared': ('django.db.models.fields.DateTimeField', [], {}),
+            'forum': ('django.db.models.fields.related.ForeignKey', [], {'to': "orm['misago.Forum']"}),
+            u'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}),
+            'updated': ('django.db.models.fields.DateTimeField', [], {}),
+            'user': ('django.db.models.fields.related.ForeignKey', [], {'to': "orm['misago.User']"})
+        },
+        'misago.forumrole': {
+            'Meta': {'object_name': 'ForumRole'},
+            '_permissions': ('django.db.models.fields.TextField', [], {'null': 'True', 'db_column': "'permissions'", 'blank': 'True'}),
+            u'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}),
+            'name': ('django.db.models.fields.CharField', [], {'max_length': '255'})
+        },
+        'misago.karma': {
+            'Meta': {'object_name': 'Karma'},
+            'agent': ('django.db.models.fields.CharField', [], {'max_length': '255'}),
+            'date': ('django.db.models.fields.DateTimeField', [], {}),
+            'forum': ('django.db.models.fields.related.ForeignKey', [], {'to': "orm['misago.Forum']"}),
+            u'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}),
+            'ip': ('django.db.models.fields.GenericIPAddressField', [], {'max_length': '39'}),
+            'post': ('django.db.models.fields.related.ForeignKey', [], {'to': "orm['misago.Post']"}),
+            'score': ('django.db.models.fields.IntegerField', [], {'default': '0'}),
+            'thread': ('django.db.models.fields.related.ForeignKey', [], {'to': "orm['misago.Thread']"}),
+            'user': ('django.db.models.fields.related.ForeignKey', [], {'to': "orm['misago.User']", 'null': 'True', 'on_delete': 'models.SET_NULL', 'blank': 'True'}),
+            'user_name': ('django.db.models.fields.CharField', [], {'max_length': '255'}),
+            'user_slug': ('django.db.models.fields.CharField', [], {'max_length': '255'})
+        },
+        'misago.monitoritem': {
+            'Meta': {'object_name': 'MonitorItem'},
+            '_value': ('django.db.models.fields.TextField', [], {'null': 'True', 'db_column': "'value'", 'blank': 'True'}),
+            'id': ('django.db.models.fields.CharField', [], {'max_length': '255', 'primary_key': 'True'}),
+            'type': ('django.db.models.fields.CharField', [], {'default': "'int'", 'max_length': '255'}),
+            'updated': ('django.db.models.fields.DateTimeField', [], {'null': 'True', 'blank': 'True'})
+        },
+        'misago.newsletter': {
+            'Meta': {'object_name': 'Newsletter'},
+            'content_html': ('django.db.models.fields.TextField', [], {'null': 'True', 'blank': 'True'}),
+            'content_plain': ('django.db.models.fields.TextField', [], {'null': 'True', 'blank': 'True'}),
+            u'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}),
+            'ignore_subscriptions': ('django.db.models.fields.BooleanField', [], {'default': 'False'}),
+            'name': ('django.db.models.fields.CharField', [], {'max_length': '255'}),
+            'progress': ('django.db.models.fields.PositiveIntegerField', [], {'default': '0'}),
+            'ranks': ('django.db.models.fields.related.ManyToManyField', [], {'to': "orm['misago.Rank']", 'symmetrical': 'False'}),
+            'step_size': ('django.db.models.fields.PositiveIntegerField', [], {'default': '0'}),
+            'token': ('django.db.models.fields.CharField', [], {'max_length': '32'})
+        },
+        'misago.poll': {
+            'Meta': {'object_name': 'Poll'},
+            '_choices_cache': ('django.db.models.fields.TextField', [], {'db_column': "'choices_cache'"}),
+            'forum': ('django.db.models.fields.related.ForeignKey', [], {'to': "orm['misago.Forum']"}),
+            'length': ('django.db.models.fields.PositiveIntegerField', [], {'default': '0'}),
+            'max_choices': ('django.db.models.fields.PositiveIntegerField', [], {'default': '0'}),
+            'public': ('django.db.models.fields.BooleanField', [], {'default': 'False'}),
+            'question': ('django.db.models.fields.CharField', [], {'max_length': '255'}),
+            'start_date': ('django.db.models.fields.DateTimeField', [], {}),
+            'thread': ('django.db.models.fields.related.OneToOneField', [], {'related_name': "'poll_of'", 'unique': 'True', 'primary_key': 'True', 'to': "orm['misago.Thread']"}),
+            'user': ('django.db.models.fields.related.ForeignKey', [], {'to': "orm['misago.User']", 'null': 'True', 'on_delete': 'models.SET_NULL', 'blank': 'True'}),
+            'user_name': ('django.db.models.fields.CharField', [], {'max_length': '255', 'null': 'True', 'blank': 'True'}),
+            'user_slug': ('django.db.models.fields.SlugField', [], {'max_length': '255', 'null': 'True', 'blank': 'True'}),
+            'vote_changing': ('django.db.models.fields.BooleanField', [], {'default': 'False'}),
+            'votes': ('django.db.models.fields.PositiveIntegerField', [], {'default': '0'})
+        },
+        'misago.polloption': {
+            'Meta': {'object_name': 'PollOption'},
+            'forum': ('django.db.models.fields.related.ForeignKey', [], {'to': "orm['misago.Forum']"}),
+            u'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}),
+            'name': ('django.db.models.fields.CharField', [], {'max_length': '255'}),
+            'poll': ('django.db.models.fields.related.ForeignKey', [], {'related_name': "'option_set'", 'to': "orm['misago.Poll']"}),
+            'thread': ('django.db.models.fields.related.ForeignKey', [], {'to': "orm['misago.Thread']"}),
+            'votes': ('django.db.models.fields.PositiveIntegerField', [], {'default': '0'})
+        },
+        'misago.pollvote': {
+            'Meta': {'object_name': 'PollVote'},
+            'agent': ('django.db.models.fields.CharField', [], {'max_length': '255'}),
+            'date': ('django.db.models.fields.DateTimeField', [], {}),
+            'forum': ('django.db.models.fields.related.ForeignKey', [], {'to': "orm['misago.Forum']"}),
+            u'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}),
+            'ip': ('django.db.models.fields.GenericIPAddressField', [], {'max_length': '39'}),
+            'option': ('django.db.models.fields.related.ForeignKey', [], {'to': "orm['misago.PollOption']", 'null': 'True', 'blank': 'True'}),
+            'poll': ('django.db.models.fields.related.ForeignKey', [], {'related_name': "'vote_set'", 'to': "orm['misago.Poll']"}),
+            'thread': ('django.db.models.fields.related.ForeignKey', [], {'to': "orm['misago.Thread']"}),
+            'user': ('django.db.models.fields.related.ForeignKey', [], {'to': "orm['misago.User']", 'null': 'True', 'on_delete': 'models.SET_NULL', 'blank': 'True'}),
+            'user_name': ('django.db.models.fields.CharField', [], {'max_length': '255', 'null': 'True', 'blank': 'True'}),
+            'user_slug': ('django.db.models.fields.SlugField', [], {'max_length': '255', 'null': 'True', 'blank': 'True'})
+        },
+        'misago.post': {
+            'Meta': {'object_name': 'Post'},
+            '_attachments': ('django.db.models.fields.TextField', [], {'null': 'True', 'db_column': "'attachments'", 'blank': 'True'}),
+            'agent': ('django.db.models.fields.CharField', [], {'max_length': '255'}),
+            'current_date': ('django.db.models.fields.DateTimeField', [], {'db_index': 'True'}),
+            'date': ('django.db.models.fields.DateTimeField', [], {}),
+            'delete_date': ('django.db.models.fields.DateTimeField', [], {'null': 'True', 'blank': 'True'}),
+            'deleted': ('django.db.models.fields.BooleanField', [], {'default': 'False'}),
+            'downvotes': ('django.db.models.fields.PositiveIntegerField', [], {'default': '0'}),
+            'edit_reason': ('django.db.models.fields.CharField', [], {'max_length': '255', 'null': 'True', 'blank': 'True'}),
+            'edit_user': ('django.db.models.fields.related.ForeignKey', [], {'blank': 'True', 'related_name': "'+'", 'null': 'True', 'on_delete': 'models.SET_NULL', 'to': "orm['misago.User']"}),
+            'edit_user_name': ('django.db.models.fields.CharField', [], {'max_length': '255', 'null': 'True', 'blank': 'True'}),
+            'edit_user_slug': ('django.db.models.fields.SlugField', [], {'max_length': '255', 'null': 'True', 'blank': 'True'}),
+            'edits': ('django.db.models.fields.PositiveIntegerField', [], {'default': '0'}),
+            'forum': ('django.db.models.fields.related.ForeignKey', [], {'to': "orm['misago.Forum']"}),
+            'has_attachments': ('django.db.models.fields.BooleanField', [], {'default': 'False'}),
+            u'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}),
+            'ip': ('django.db.models.fields.GenericIPAddressField', [], {'max_length': '39'}),
+            'mentions': ('django.db.models.fields.related.ManyToManyField', [], {'related_name': "'mention_set'", 'symmetrical': 'False', 'to': "orm['misago.User']"}),
+            'moderated': ('django.db.models.fields.BooleanField', [], {'default': 'False'}),
+            'post': ('django.db.models.fields.TextField', [], {}),
+            'post_preparsed': ('django.db.models.fields.TextField', [], {}),
+            'protected': ('django.db.models.fields.BooleanField', [], {'default': 'False'}),
+            'reported': ('django.db.models.fields.BooleanField', [], {'default': 'False', 'db_index': 'True'}),
+            'reports': ('django.db.models.fields.CharField', [], {'max_length': '255', 'null': 'True', 'blank': 'True'}),
+            'thread': ('django.db.models.fields.related.ForeignKey', [], {'to': "orm['misago.Thread']"}),
+            'upvotes': ('django.db.models.fields.PositiveIntegerField', [], {'default': '0'}),
+            'user': ('django.db.models.fields.related.ForeignKey', [], {'to': "orm['misago.User']", 'null': 'True', 'on_delete': 'models.SET_NULL', 'blank': 'True'}),
+            'user_name': ('django.db.models.fields.CharField', [], {'max_length': '255'})
+        },
+        'misago.pruningpolicy': {
+            'Meta': {'object_name': 'PruningPolicy'},
+            'email': ('django.db.models.fields.CharField', [], {'max_length': '255', 'null': 'True', 'blank': 'True'}),
+            u'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}),
+            'last_visit': ('django.db.models.fields.PositiveIntegerField', [], {'default': '0'}),
+            'name': ('django.db.models.fields.CharField', [], {'max_length': '255'}),
+            'posts': ('django.db.models.fields.PositiveIntegerField', [], {'default': '0'}),
+            'registered': ('django.db.models.fields.PositiveIntegerField', [], {'default': '0'})
+        },
+        'misago.rank': {
+            'Meta': {'object_name': 'Rank'},
+            'as_tab': ('django.db.models.fields.BooleanField', [], {'default': 'False'}),
+            'criteria': ('django.db.models.fields.CharField', [], {'max_length': '255', 'null': 'True', 'blank': 'True'}),
+            'description': ('django.db.models.fields.TextField', [], {'null': 'True', 'blank': 'True'}),
+            u'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}),
+            'name': ('django.db.models.fields.CharField', [], {'max_length': '255'}),
+            'on_index': ('django.db.models.fields.BooleanField', [], {'default': 'False'}),
+            'order': ('django.db.models.fields.IntegerField', [], {'default': '0'}),
+            'roles': ('django.db.models.fields.related.ManyToManyField', [], {'to': "orm['misago.Role']", 'symmetrical': 'False'}),
+            'slug': ('django.db.models.fields.CharField', [], {'max_length': '255', 'null': 'True', 'blank': 'True'}),
+            'special': ('django.db.models.fields.BooleanField', [], {'default': 'False'}),
+            'style': ('django.db.models.fields.CharField', [], {'max_length': '255', 'null': 'True', 'blank': 'True'}),
+            'title': ('django.db.models.fields.CharField', [], {'max_length': '255', 'null': 'True', 'blank': 'True'})
+        },
+        'misago.role': {
+            'Meta': {'object_name': 'Role'},
+            '_permissions': ('django.db.models.fields.TextField', [], {'null': 'True', 'db_column': "'permissions'", 'blank': 'True'}),
+            '_special': ('django.db.models.fields.CharField', [], {'max_length': '255', 'null': 'True', 'db_column': "'special'", 'blank': 'True'}),
+            u'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}),
+            'name': ('django.db.models.fields.CharField', [], {'max_length': '255'}),
+            'protected': ('django.db.models.fields.BooleanField', [], {'default': 'False'})
+        },
+        'misago.session': {
+            'Meta': {'object_name': 'Session'},
+            'admin': ('django.db.models.fields.BooleanField', [], {'default': 'False'}),
+            'agent': ('django.db.models.fields.CharField', [], {'max_length': '255'}),
+            'crawler': ('django.db.models.fields.CharField', [], {'max_length': '255', 'null': 'True', 'blank': 'True'}),
+            'data': ('django.db.models.fields.TextField', [], {'db_column': "'session_data'"}),
+            'id': ('django.db.models.fields.CharField', [], {'max_length': '42', 'primary_key': 'True'}),
+            'ip': ('django.db.models.fields.GenericIPAddressField', [], {'max_length': '39'}),
+            'last': ('django.db.models.fields.DateTimeField', [], {}),
+            'matched': ('django.db.models.fields.BooleanField', [], {'default': 'False'}),
+            'rank': ('django.db.models.fields.related.ForeignKey', [], {'related_name': "'sessions'", 'null': 'True', 'on_delete': 'models.SET_NULL', 'to': "orm['misago.Rank']"}),
+            'start': ('django.db.models.fields.DateTimeField', [], {}),
+            'team': ('django.db.models.fields.BooleanField', [], {'default': 'False'}),
+            'user': ('django.db.models.fields.related.ForeignKey', [], {'related_name': "'sessions'", 'null': 'True', 'on_delete': 'models.SET_NULL', 'to': "orm['misago.User']"})
+        },
+        'misago.setting': {
+            'Meta': {'object_name': 'Setting'},
+            '_value': ('django.db.models.fields.TextField', [], {'null': 'True', 'db_column': "'value'", 'blank': 'True'}),
+            'description': ('django.db.models.fields.TextField', [], {'null': 'True', 'blank': 'True'}),
+            'extra': ('django.db.models.fields.TextField', [], {'null': 'True', 'blank': 'True'}),
+            'field': ('django.db.models.fields.CharField', [], {'max_length': '255'}),
+            'group': ('django.db.models.fields.related.ForeignKey', [], {'to': "orm['misago.SettingsGroup']", 'to_field': "'key'"}),
+            'name': ('django.db.models.fields.CharField', [], {'max_length': '255'}),
+            'normalize_to': ('django.db.models.fields.CharField', [], {'max_length': '255'}),
+            'position': ('django.db.models.fields.IntegerField', [], {'default': '0'}),
+            'separator': ('django.db.models.fields.CharField', [], {'max_length': '255', 'null': 'True', 'blank': 'True'}),
+            'setting': ('django.db.models.fields.CharField', [], {'max_length': '255', 'primary_key': 'True'}),
+            'value_default': ('django.db.models.fields.TextField', [], {'null': 'True', 'blank': 'True'})
+        },
+        'misago.settingsgroup': {
+            'Meta': {'object_name': 'SettingsGroup'},
+            'description': ('django.db.models.fields.TextField', [], {'null': 'True', 'blank': 'True'}),
+            u'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}),
+            'key': ('django.db.models.fields.CharField', [], {'unique': 'True', 'max_length': '255'}),
+            'name': ('django.db.models.fields.CharField', [], {'max_length': '255'})
+        },
+        'misago.signinattempt': {
+            'Meta': {'object_name': 'SignInAttempt'},
+            'date': ('django.db.models.fields.DateTimeField', [], {}),
+            u'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}),
+            'ip': ('django.db.models.fields.GenericIPAddressField', [], {'max_length': '39'})
+        },
+        'misago.thread': {
+            'Meta': {'object_name': 'Thread'},
+            'closed': ('django.db.models.fields.BooleanField', [], {'default': 'False'}),
+            'deleted': ('django.db.models.fields.BooleanField', [], {'default': 'False'}),
+            'downvotes': ('django.db.models.fields.PositiveIntegerField', [], {'default': '0'}),
+            'forum': ('django.db.models.fields.related.ForeignKey', [], {'to': "orm['misago.Forum']"}),
+            'has_poll': ('django.db.models.fields.BooleanField', [], {'default': 'False'}),
+            u'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}),
+            'last': ('django.db.models.fields.DateTimeField', [], {}),
+            'last_post': ('django.db.models.fields.related.ForeignKey', [], {'blank': 'True', 'related_name': "'+'", 'null': 'True', 'on_delete': 'models.SET_NULL', 'to': "orm['misago.Post']"}),
+            'last_poster': ('django.db.models.fields.related.ForeignKey', [], {'blank': 'True', 'related_name': "'+'", 'null': 'True', 'on_delete': 'models.SET_NULL', 'to': "orm['misago.User']"}),
+            'last_poster_name': ('django.db.models.fields.CharField', [], {'max_length': '255', 'null': 'True', 'blank': 'True'}),
+            'last_poster_slug': ('django.db.models.fields.SlugField', [], {'max_length': '255', 'null': 'True', 'blank': 'True'}),
+            'last_poster_style': ('django.db.models.fields.CharField', [], {'max_length': '255', 'null': 'True', 'blank': 'True'}),
+            'moderated': ('django.db.models.fields.BooleanField', [], {'default': 'False'}),
+            'name': ('django.db.models.fields.CharField', [], {'max_length': '255'}),
+            'participants': ('django.db.models.fields.related.ManyToManyField', [], {'related_name': "'private_thread_set'", 'symmetrical': 'False', 'to': "orm['misago.User']"}),
+            'prefix': ('django.db.models.fields.related.ForeignKey', [], {'to': "orm['misago.ThreadPrefix']", 'null': 'True', 'on_delete': 'models.SET_NULL', 'blank': 'True'}),
+            'replies': ('django.db.models.fields.PositiveIntegerField', [], {'default': '0'}),
+            'replies_deleted': ('django.db.models.fields.PositiveIntegerField', [], {'default': '0'}),
+            'replies_moderated': ('django.db.models.fields.PositiveIntegerField', [], {'default': '0'}),
+            'replies_reported': ('django.db.models.fields.PositiveIntegerField', [], {'default': '0'}),
+            'report_for': ('django.db.models.fields.related.ForeignKey', [], {'blank': 'True', 'related_name': "'report_set'", 'null': 'True', 'on_delete': 'models.SET_NULL', 'to': "orm['misago.Post']"}),
+            'score': ('django.db.models.fields.PositiveIntegerField', [], {'default': '30'}),
+            'slug': ('django.db.models.fields.SlugField', [], {'max_length': '255'}),
+            'start': ('django.db.models.fields.DateTimeField', [], {}),
+            'start_post': ('django.db.models.fields.related.ForeignKey', [], {'blank': 'True', 'related_name': "'+'", 'null': 'True', 'on_delete': 'models.SET_NULL', 'to': "orm['misago.Post']"}),
+            'start_poster': ('django.db.models.fields.related.ForeignKey', [], {'to': "orm['misago.User']", 'null': 'True', 'on_delete': 'models.SET_NULL', 'blank': 'True'}),
+            'start_poster_name': ('django.db.models.fields.CharField', [], {'max_length': '255'}),
+            'start_poster_slug': ('django.db.models.fields.SlugField', [], {'max_length': '255'}),
+            'start_poster_style': ('django.db.models.fields.CharField', [], {'max_length': '255', 'null': 'True', 'blank': 'True'}),
+            'upvotes': ('django.db.models.fields.PositiveIntegerField', [], {'default': '0'}),
+            'weight': ('django.db.models.fields.PositiveIntegerField', [], {'default': '0'})
+        },
+        'misago.threadprefix': {
+            'Meta': {'object_name': 'ThreadPrefix'},
+            'forums': ('django.db.models.fields.related.ManyToManyField', [], {'to': "orm['misago.Forum']", 'symmetrical': 'False'}),
+            u'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}),
+            'name': ('django.db.models.fields.CharField', [], {'max_length': '255'}),
+            'slug': ('django.db.models.fields.SlugField', [], {'max_length': '255'}),
+            'style': ('django.db.models.fields.CharField', [], {'max_length': '255'})
+        },
+        'misago.threadread': {
+            'Meta': {'object_name': 'ThreadRead'},
+            'forum': ('django.db.models.fields.related.ForeignKey', [], {'to': "orm['misago.Forum']"}),
+            u'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}),
+            'thread': ('django.db.models.fields.related.ForeignKey', [], {'to': "orm['misago.Thread']"}),
+            'updated': ('django.db.models.fields.DateTimeField', [], {}),
+            'user': ('django.db.models.fields.related.ForeignKey', [], {'to': "orm['misago.User']"})
+        },
+        'misago.token': {
+            'Meta': {'object_name': 'Token'},
+            'accessed': ('django.db.models.fields.DateTimeField', [], {}),
+            'created': ('django.db.models.fields.DateTimeField', [], {}),
+            'id': ('django.db.models.fields.CharField', [], {'max_length': '42', 'primary_key': 'True'}),
+            'user': ('django.db.models.fields.related.ForeignKey', [], {'related_name': "'signin_tokens'", 'to': "orm['misago.User']"})
+        },
+        'misago.user': {
+            'Meta': {'object_name': 'User'},
+            '_avatar_crop': ('django.db.models.fields.CharField', [], {'max_length': '255', 'null': 'True', 'db_column': "'avatar_crop'", 'blank': 'True'}),
+            'acl_key': ('django.db.models.fields.CharField', [], {'max_length': '12', 'null': 'True', 'blank': 'True'}),
+            'activation': ('django.db.models.fields.IntegerField', [], {'default': '0'}),
+            'alerts': ('django.db.models.fields.PositiveIntegerField', [], {'default': '0'}),
+            'alerts_date': ('django.db.models.fields.DateTimeField', [], {'null': 'True', 'blank': 'True'}),
+            'allow_pds': ('django.db.models.fields.PositiveIntegerField', [], {'default': '0'}),
+            'avatar_ban': ('django.db.models.fields.BooleanField', [], {'default': 'False'}),
+            'avatar_ban_reason_admin': ('django.db.models.fields.TextField', [], {'null': 'True', 'blank': 'True'}),
+            'avatar_ban_reason_user': ('django.db.models.fields.TextField', [], {'null': 'True', 'blank': 'True'}),
+            'avatar_image': ('django.db.models.fields.CharField', [], {'max_length': '255', 'null': 'True', 'blank': 'True'}),
+            'avatar_original': ('django.db.models.fields.CharField', [], {'max_length': '255', 'null': 'True', 'blank': 'True'}),
+            'avatar_temp': ('django.db.models.fields.CharField', [], {'max_length': '255', 'null': 'True', 'blank': 'True'}),
+            'avatar_type': ('django.db.models.fields.CharField', [], {'max_length': '10', 'null': 'True', 'blank': 'True'}),
+            'email': ('django.db.models.fields.EmailField', [], {'max_length': '255'}),
+            'email_hash': ('django.db.models.fields.CharField', [], {'unique': 'True', 'max_length': '32'}),
+            'followers': ('django.db.models.fields.PositiveIntegerField', [], {'default': '0'}),
+            'following': ('django.db.models.fields.PositiveIntegerField', [], {'default': '0'}),
+            'follows': ('django.db.models.fields.related.ManyToManyField', [], {'related_name': "'follows_set'", 'symmetrical': 'False', 'to': "orm['misago.User']"}),
+            'hide_activity': ('django.db.models.fields.PositiveIntegerField', [], {'default': '0'}),
+            u'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}),
+            'ignores': ('django.db.models.fields.related.ManyToManyField', [], {'related_name': "'ignores_set'", 'symmetrical': 'False', 'to': "orm['misago.User']"}),
+            'is_team': ('django.db.models.fields.BooleanField', [], {'default': 'False'}),
+            'join_agent': ('django.db.models.fields.TextField', [], {'null': 'True', 'blank': 'True'}),
+            'join_date': ('django.db.models.fields.DateTimeField', [], {}),
+            'join_ip': ('django.db.models.fields.GenericIPAddressField', [], {'max_length': '39'}),
+            'karma_given_n': ('django.db.models.fields.PositiveIntegerField', [], {'default': '0'}),
+            'karma_given_p': ('django.db.models.fields.PositiveIntegerField', [], {'default': '0'}),
+            'karma_n': ('django.db.models.fields.PositiveIntegerField', [], {'default': '0'}),
+            'karma_p': ('django.db.models.fields.PositiveIntegerField', [], {'default': '0'}),
+            'last_agent': ('django.db.models.fields.TextField', [], {'null': 'True', 'blank': 'True'}),
+            'last_date': ('django.db.models.fields.DateTimeField', [], {'null': 'True', 'blank': 'True'}),
+            'last_ip': ('django.db.models.fields.GenericIPAddressField', [], {'max_length': '39', 'null': 'True', 'blank': 'True'}),
+            'last_post': ('django.db.models.fields.DateTimeField', [], {'null': 'True', 'blank': 'True'}),
+            'last_search': ('django.db.models.fields.DateTimeField', [], {'null': 'True', 'blank': 'True'}),
+            'last_sync': ('django.db.models.fields.DateTimeField', [], {'null': 'True', 'blank': 'True'}),
+            'last_vote': ('django.db.models.fields.DateTimeField', [], {'null': 'True', 'blank': 'True'}),
+            'password': ('django.db.models.fields.CharField', [], {'max_length': '255'}),
+            'password_date': ('django.db.models.fields.DateTimeField', [], {}),
+            'posts': ('django.db.models.fields.PositiveIntegerField', [], {'default': '0'}),
+            'rank': ('django.db.models.fields.related.ForeignKey', [], {'to': "orm['misago.Rank']", 'null': 'True', 'on_delete': 'models.SET_NULL', 'blank': 'True'}),
+            'ranking': ('django.db.models.fields.PositiveIntegerField', [], {'default': '0'}),
+            'receive_newsletters': ('django.db.models.fields.BooleanField', [], {'default': 'True'}),
+            'roles': ('django.db.models.fields.related.ManyToManyField', [], {'to': "orm['misago.Role']", 'symmetrical': 'False'}),
+            'score': ('django.db.models.fields.IntegerField', [], {'default': '0'}),
+            'signature': ('django.db.models.fields.TextField', [], {'null': 'True', 'blank': 'True'}),
+            'signature_ban': ('django.db.models.fields.BooleanField', [], {'default': 'False'}),
+            'signature_ban_reason_admin': ('django.db.models.fields.TextField', [], {'null': 'True', 'blank': 'True'}),
+            'signature_ban_reason_user': ('django.db.models.fields.TextField', [], {'null': 'True', 'blank': 'True'}),
+            'signature_preparsed': ('django.db.models.fields.TextField', [], {'null': 'True', 'blank': 'True'}),
+            'subscribe_reply': ('django.db.models.fields.PositiveIntegerField', [], {'default': '0'}),
+            'subscribe_start': ('django.db.models.fields.PositiveIntegerField', [], {'default': '0'}),
+            'sync_pds': ('django.db.models.fields.BooleanField', [], {'default': 'False'}),
+            'threads': ('django.db.models.fields.PositiveIntegerField', [], {'default': '0'}),
+            'timezone': ('django.db.models.fields.CharField', [], {'default': "'utc'", 'max_length': '255'}),
+            'title': ('django.db.models.fields.CharField', [], {'max_length': '255', 'null': 'True', 'blank': 'True'}),
+            'token': ('django.db.models.fields.CharField', [], {'max_length': '12', 'null': 'True', 'blank': 'True'}),
+            'unread_pds': ('django.db.models.fields.PositiveIntegerField', [], {'default': '0'}),
+            'username': ('django.db.models.fields.CharField', [], {'max_length': '255'}),
+            'username_slug': ('django.db.models.fields.SlugField', [], {'unique': 'True', 'max_length': '255'}),
+            'votes': ('django.db.models.fields.PositiveIntegerField', [], {'default': '0'})
+        },
+        'misago.usernamechange': {
+            'Meta': {'object_name': 'UsernameChange'},
+            'date': ('django.db.models.fields.DateTimeField', [], {}),
+            u'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}),
+            'old_username': ('django.db.models.fields.CharField', [], {'max_length': '255'}),
+            'user': ('django.db.models.fields.related.ForeignKey', [], {'related_name': "'namechanges'", 'to': "orm['misago.User']"})
+        },
+        'misago.watchedthread': {
+            'Meta': {'object_name': 'WatchedThread'},
+            'email': ('django.db.models.fields.BooleanField', [], {'default': 'False'}),
+            'forum': ('django.db.models.fields.related.ForeignKey', [], {'to': "orm['misago.Forum']"}),
+            u'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}),
+            'last_read': ('django.db.models.fields.DateTimeField', [], {}),
+            'starter': ('django.db.models.fields.related.ForeignKey', [], {'blank': 'True', 'related_name': "'+'", 'null': 'True', 'to': "orm['misago.User']"}),
+            'thread': ('django.db.models.fields.related.ForeignKey', [], {'to': "orm['misago.Thread']"}),
+            'user': ('django.db.models.fields.related.ForeignKey', [], {'to': "orm['misago.User']"})
+        }
+    }
+
+    complete_apps = ['misago']

+ 8 - 1
misago/models/attachmentmodel.py

@@ -71,12 +71,19 @@ class Attachment(models.Model):
         return path(unicode(self.file_path).replace('.', '_thumb.'))
 
     def use_file(self, uploaded_file):
-        self.name = uploaded_file.name
+        self.name = self.clean_name(uploaded_file.name)
         self.content_type = uploaded_file.content_type
         self.size = uploaded_file.size
 
         self.store_file(uploaded_file)
 
+    def clean_name(self, filename):
+        for char in '=[]()<>\\/"\'':
+            filename = filename.replace(char, '')
+        if len(filename) > 100:
+            filename = filename[-100:]
+        return filename
+
     def store_file(self, uploaded_file):
         datenow = date.today()
         current_dir = '%s-%s-%s' % (datenow.month, datenow.day, datenow.year)

+ 57 - 3
misago/models/postmodel.py

@@ -1,3 +1,4 @@
+import copy
 from django.db import models
 from django.db.models import F
 from django.db.models.signals import pre_save, pre_delete
@@ -8,6 +9,11 @@ from misago.signals import (delete_user_content, merge_post, merge_thread,
                             move_forum_content, move_post, move_thread,
                             rename_user, sync_user_profile)
 from misago.utils.translation import ugettext_lazy
+import base64
+try:
+    import cPickle as pickle
+except ImportError:
+    import pickle
 
 class PostManager(models.Manager):
     def filter_stats(self, start, end):
@@ -39,6 +45,8 @@ class Post(models.Model):
     moderated = models.BooleanField(default=False)
     deleted = models.BooleanField(default=False)
     protected = models.BooleanField(default=False)
+    has_attachments = models.BooleanField(default=False)
+    _attachments = models.TextField(db_column='attachments', null=True, blank=True)
 
     objects = PostManager()
 
@@ -48,6 +56,52 @@ class Post(models.Model):
         app_label = 'misago'
 
     @property
+    def attachments(self):
+        if not self.has_attachments:
+            return []
+
+        try:
+            return self._attachments_cache
+        except AttributeError:
+            pass
+
+        try:
+            self._attachments_cache = pickle.loads(base64.decodestring(self._attachments))
+        except Exception:
+            self._attachments_cache = []
+        return self._attachments_cache
+
+
+    @attachments.setter
+    def attachments(self, new_attachments):
+        if new_attachments:
+            self._update_attachments_store(new_attachments)
+        else:
+            self._empty_attachments_store()
+
+    def _empty_attachments_store(self):
+        self.has_attachments = False
+        self._attachments = None
+
+    def _update_attachments_store(self, new_attachments):
+        self.has_attachments = True
+        clean_attachments = []
+        for attachment in new_attachments:
+            attachment = copy.copy(attachment)
+            attachment_user_pk = attachment.user_id
+            attachment_filetype_pk = attachment.filetype_id
+            attachment.filetype = None
+            attachment.filetype_id = attachment_filetype_pk
+            attachment.user = None
+            attachment.user_id = attachment_user_pk
+            attachment.forum = None
+            attachment.thread = None
+            attachment.post = None
+
+            clean_attachments.append(attachment)
+        self._attachments = base64.encodestring(pickle.dumps(clean_attachments, pickle.HIGHEST_PROTOCOL))
+
+    @property
     def timeline_date(self):
         return self.date
 
@@ -83,7 +137,7 @@ class Post(models.Model):
         move_post.send(sender=self, move_to=thread)
         self.thread = thread
         self.forum = thread.forum
-        
+
     def merge_with(self, post):
         post.post = '%s\n- - -\n%s' % (post.post, self.post)
         merge_post.send(sender=self, new_post=post)
@@ -91,12 +145,12 @@ class Post(models.Model):
     def notify_mentioned(self, request, thread_type, users):
         from misago.acl.builder import acl
         from misago.acl.exceptions import ACLError403, ACLError404
-        
+
         mentioned = self.mentions.all()
         for slug, user in users.items():
             if user.pk != request.user.pk and user not in mentioned:
                 self.mentions.add(user)
-                try:                    
+                try:
                     acl = acl(request, user)
                     acl.forums.allow_forum_view(self.forum)
                     acl.threads.allow_thread_view(user, self.thread)

+ 11 - 0
misago/templatetags/utils.py

@@ -19,6 +19,17 @@ def make_short(string, length=16):
     return short_string(string, length)
 
 
+@register.filter(name='filesize')
+def format_filesize(size):
+    try:
+        for u in ('B','KB','MB','GB','TB'):
+            if size < 1024.0:
+                return "%3.1f %s" % (size, u)
+            size /= 1024.0
+    except ValueError:
+        return '0 B'
+
+
 @register.filter(name='highlight')
 def highlight_result(text, query, length=500):
     hl = Highlighter(query, html_tag='strong', max_length=length)

+ 9 - 2
static/cranefly/css/cranefly.css

@@ -829,8 +829,15 @@ a.btn-link:hover,a.btn-link:active,a.btn-link:focus{color:#333;text-decoration:n
 .editor .editor-input{padding:10.5px}.editor .editor-input>div{margin-right:22px;position:relative}.editor .editor-input>div textarea{border:none;box-shadow:none;margin:-10.5px;padding:10.5px;padding-right:32px;width:100%;font-family:Monaco,Menlo,Consolas,"Courier New",monospace}
 .editor .editor-input>div .editor-zen-on{display:none;position:absolute;top:-4px;right:-26px;padding-left:8px}.editor .editor-input>div .editor-zen-on a{border-radius:4px;display:block;opacity:.5;filter:alpha(opacity=50);padding:2px 0;padding-bottom:3px;width:29px;color:#333;font-size:24px;text-align:center}.editor .editor-input>div .editor-zen-on a:hover,.editor .editor-input>div .editor-zen-on a:active{box-shadow:0 0 3px #999;text-decoration:none}
 .editor .editor-upload{border-top:1px solid #e6e6e6;overflow:auto;padding:10.5px}
-.editor .editor-attachments{margin:0}.editor .editor-attachments li{border-top:1px solid #f0f0f0;overflow:auto;padding:10.5px}.editor .editor-attachments li img{background-size:cover;border-radius:3px;margin-right:6px;height:32px;width:32px}
-.editor .editor-attachments li .attachment-name{color:#333;font-weight:bold}
+.editor .editor-attachments{margin:0}.editor .editor-attachments li{border-top:1px solid #f0f0f0;overflow:auto;padding:10.5px}.editor .editor-attachments li .attachment-image{float:left}.editor .editor-attachments li .attachment-image i{display:block;height:40px;width:40px;color:#999;font-size:42px;text-align:center}
+.editor .editor-attachments li .attachment-image img{background-size:cover;border-radius:3px;height:40px;width:40px}
+.editor .editor-attachments li .attachment-body{margin-left:52px}.editor .editor-attachments li .attachment-body h4{margin:0;padding:0;font-size:14px}
+.editor .editor-attachments li .attachment-body .attachment-details{color:#999;font-size:11.9px}.editor .editor-attachments li .attachment-body .attachment-details a:link,.editor .editor-attachments li .attachment-body .attachment-details a:visited{color:#555}
+.editor .editor-attachments li .attachment-body .attachment-details a:hover,.editor .editor-attachments li .attachment-body .attachment-details a:active{color:#222}
+.editor .editor-attachments li .attachment-body .attachment-details .attachment-removed-message{color:#cf402e}
+.editor .editor-attachments li .attachment-actions{float:right;margin-top:3px}.editor .editor-attachments li .attachment-actions .btn{margin-left:8px}.editor .editor-attachments li .attachment-actions .btn.btn-insert{color:#3bf}.editor .editor-attachments li .attachment-actions .btn.btn-insert:hover,.editor .editor-attachments li .attachment-actions .btn.btn-insert:active{color:#08c}
+.editor .editor-attachments li .attachment-actions .btn.btn-remove{color:#e38b80}.editor .editor-attachments li .attachment-actions .btn.btn-remove:hover,.editor .editor-attachments li .attachment-actions .btn.btn-remove:active{color:#cf402e}
+.editor .editor-attachments li.attachment-removed{opacity:.7;filter:alpha(opacity=70)}
 .editor .editor-actions{border-top:1px solid #e6e6e6;overflow:auto;padding:10.5px}.editor .editor-actions>.btn{margin-left:14px}
 .editor .editor-actions .editor-tools{margin:0;margin-right:10.5px}.editor .editor-actions .editor-tools li{float:left;margin-right:10.5px}.editor .editor-actions .editor-tools li .btn{display:block;padding-left:0;padding-right:0;width:28px;text-align:center}.editor .editor-actions .editor-tools li .btn i{margin:0}
 .editor .editor-actions .editor-help{line-height:30.5px}

+ 74 - 9
static/cranefly/css/cranefly/editor.less

@@ -79,17 +79,82 @@
       overflow: auto;
       padding: @editorPadding;
 
-      img {
-        background-size: cover;
-        border-radius: 3px;
-        margin-right: 6px;
-        height: 32px;
-        width: 32px;
+      .attachment-image {
+        float: left;
+
+        i {
+          display: block;
+          height: 40px;
+          width: 40px;
+
+          color: @grayLight;
+          font-size: 42px;
+          text-align: center;
+        }
+
+        img {
+          background-size: cover;
+          border-radius: 3px;
+          height: 40px;
+          width: 40px;
+        }
+      }
+
+      .attachment-body {
+        margin-left: 52px;
+
+        h4 {
+          margin: 0px;
+          padding: 0px;
+
+          font-size: @baseFontSize;
+        }
+
+        .attachment-details {
+          color: @grayLight;
+          font-size: @fontSizeSmall;
+
+          a:link, a:visited {
+            color: @gray;
+          }
+
+          a:hover, a:active {
+            color: @grayDarker;
+          }
+
+          .attachment-removed-message {
+            color: @red;
+          }
+        }
+      }
+
+      .attachment-actions {
+        float: right;
+        margin-top: 3px;
+
+        .btn {
+          margin-left: 8px;
+
+          &.btn-insert {
+            color: lighten(@linkColor, 20%);
+
+            &:hover, &:active {
+              color: @linkColor;
+            }
+          }
+
+          &.btn-remove {
+            color: lighten(@red, 20%);
+
+            &:hover, &:active {
+              color: @red;
+            }
+          }
+        }
       }
 
-      .attachment-name {
-        color: @textColor;
-        font-weight: bold;
+      &.attachment-removed {
+        .opacity(70);
       }
     }
   }

+ 5 - 0
static/cranefly/js/editor.js

@@ -197,4 +197,9 @@ $(function() {
     overlay.hide();
     return false;
   });
+
+  $('.editor-insert-attachment').click(function() {
+    var insertion_code = $(this).data('attachment-md');
+    makeReplace(textareaId(this), insertion_code);
+  });
 });

+ 27 - 6
templates/cranefly/editor.html

@@ -66,12 +66,33 @@
 {% if attachments %}
 <ul class="unstyled editor-attachments">
   {% for attachment in attachments %}
-  <li>
-    {% if attachment.is_image %}
-    <a href="{{ url('attachments_server', attachment=attachment.pk) }}"><img src="{{ url('attachments_thumbs_server', attachment=attachment.pk) }}" alt=""></a>
-    {% endif %}
-    <strong class="attachment-name">{{ attachment.name }}</strong>
-    <button name="remove_attachment" value="{{ attachment.pk }}" type="submit" class="btn pull-right">{% trans %}Remove{% endtrans %}</button>
+  <li{% if attachment.pk in attachments_removed %} class="attachment-removed"{% endif %}>
+    <div class="attachment-image">
+      <a href="{{ url('attachments_server', attachment=attachment.pk) }}">
+        {% if attachment.is_image %}
+        <img src="{{ url('attachments_thumbs_server', attachment=attachment.pk) }}" alt="">
+        {% else %}
+        <i class="icon-file"></i>
+        {% endif %}
+      </a>
+    </div>
+    <div class="attachment-actions">
+      {% if attachment.pk in attachments_removed %}
+      <button name="restore_attachment" value="{{ attachment.pk }}" type="submit" class="btn pull-right"><i class="icon-plus"></i> {% trans %}Restore{% endtrans %}</button>
+      {% else %}
+      <button type="button" class="btn btn-insert pull-right editor-insert-attachment" data-attachment-md="{% if attachment.is_image %}[![{{ attachment.name }}]({{ url('attachments_thumbs_server', attachment=attachment.pk) }})]({{ url('attachments_server', attachment=attachment.pk) }}){% else %}[{{ attachment.name }}]({{ url('attachments_server', attachment=attachment.pk) }}){% endif %}"><i class="icon-share"></i> {% trans %}Insert{% endtrans %}</button>
+      {% if acl.threads.can_delete_attachment(user, forum, attachment) %}
+      <button name="remove_attachment" value="{{ attachment.pk }}" type="submit" class="btn btn-remove pull-right"><i class="icon-remove"></i> {% trans %}Remove{% endtrans %}</button>
+      {% endif %}
+      {% endif %}
+    </div>
+    <div class="attachment-body">
+      <h4>{{ attachment.name }}</h4>
+      <div class="attachment-details">
+        {% if attachment.pk in attachments_removed %}<strong class="attachment-removed-message">{% trans %}Removed{% endtrans %}</strong>{% endif %}
+        {% if attachment.user_id %}<a href="{{ url('user', user=attachment.user_id, username=attachment.user_name_slug) }}">{{ attachment.user_name }}</a>{% else %}{{ attachment.user_name }}{% endif %}, {{ attachment.date|date }}, {{ _(attachments_types[attachment.filetype_id].name) }}, {{ attachment.size|filesize }}
+      </div>
+    </div>
   </li>
   {% endfor %}
 </ul>