Browse Source

Forums "CRU".

Rafał Pitoń 11 years ago
parent
commit
af404fe09c

+ 1 - 0
misago/admin/urls.py

@@ -17,6 +17,7 @@ urlpatterns = patterns('misago.admin.views',
 # Import admin urls
 import misago.conf.adminurls
 import misago.acl.adminurls
+import misago.forums.urls
 import misago.users.urls.admin
 
 # Register discovered patterns

+ 0 - 16
misago/admin/views/generic.py

@@ -36,22 +36,6 @@ class AdminView(View):
     def final_template(self):
         return '%s/%s' % (self.templates_dir, self.template)
 
-    def get_target(self, target):
-        """
-        get_target is called by view to fetch item from DB
-        """
-        Model = self.get_model()
-        return Model.objects.get(id=target)
-
-    def _get_target(self, request, kwargs):
-        Model = self.get_model()
-
-        try:
-            return self.get_target(target)
-        except Model.DoesNotExist:
-            messages.error(request, self.message_404)
-            return redirect(self.root_link)
-
     def current_link(self, request):
         matched_url = request.resolver_match.url_name
         return '%s:%s' % (request.resolver_match.namespace, matched_url)

+ 1 - 0
misago/conf/defaults.py

@@ -92,6 +92,7 @@ INSTALLED_APPS = (
     'south',
     'pipeline',
     'crispy_forms',
+    'mptt',
     'misago.admin',
     'misago.acl',
     'misago.core',

+ 6 - 1
misago/core/forms.py

@@ -3,6 +3,11 @@ from django.forms import *  # noqa
 from django.forms import Form as BaseForm, ModelForm as BaseModelForm
 
 
+TEXT_BASED_FIELDS = (
+    CharField, EmailField, FilePathField, URLField
+)
+
+
 def YesNoSwitch(**kwargs):
     if 'initial' not in kwargs:
         kwargs['initial'] = 0
@@ -20,7 +25,7 @@ class AutoStripWhitespacesMixin(object):
     def full_clean(self):
         self.data = self.data.copy()
         for name, field in self.fields.iteritems():
-            if (field.__class__ == CharField and
+            if (field.__class__ in TEXT_BASED_FIELDS and
                     not name in self.autostrip_exclude):
                 try:
                     self.data[name] = self.data[name].strip()

+ 40 - 0
misago/core/utils.py

@@ -1,3 +1,5 @@
+import bleach
+from markdown import Markdown
 from unidecode import unidecode
 from django.core.urlresolvers import reverse
 from django.template.defaultfilters import slugify as django_slugify
@@ -25,3 +27,41 @@ def slugify(string):
     string = unicode(string)
     string = unidecode(string)
     return django_slugify(string.replace('_', ' '))
+
+
+MD_SUBSET_FORBID_SYNTAX = (
+    # References are evil
+    'reference', 'reference', 'image_reference', 'short_reference',
+
+    # Blocks are evil too
+    'hashheader', 'setextheader', 'code', 'quote', 'hr', 'olist', 'ulist',
+)
+
+
+def subset_markdown(text):
+    if not text:
+        return ''
+
+    md = Markdown(safe_mode='escape', extensions=['nl2br'])
+
+    for key in md.preprocessors.keys():
+        if key in MD_SUBSET_FORBID_SYNTAX:
+            del md.preprocessors[key]
+
+    for key in md.inlinePatterns.keys():
+        if key in MD_SUBSET_FORBID_SYNTAX:
+            del md.inlinePatterns[key]
+
+    for key in md.parser.blockprocessors.keys():
+        if key in MD_SUBSET_FORBID_SYNTAX:
+            del md.parser.blockprocessors[key]
+
+    for key in md.treeprocessors.keys():
+        if key in MD_SUBSET_FORBID_SYNTAX:
+            del md.treeprocessors[key]
+
+    for key in md.postprocessors.keys():
+        if key in MD_SUBSET_FORBID_SYNTAX:
+            del md.postprocessors[key]
+
+    return bleach.linkify(md.convert(text))

+ 149 - 0
misago/forums/forms.py

@@ -0,0 +1,149 @@
+from django.db import models
+from django.core.validators import URLValidator
+from django.utils.html import conditional_escape, mark_safe
+from django.utils.translation import ugettext_lazy as _
+from mptt.forms import TreeNodeChoiceField as TreeNodeChoiceField
+from misago.core import forms
+from misago.core.validators import validate_sluggable
+from misago.forums.models import Forum
+
+
+class ForumChoiceField(TreeNodeChoiceField):
+    def __init__(self, *args, **kwargs):
+        self.base_level = kwargs.pop('base_level', 1)
+        kwargs['level_indicator'] = kwargs.get('level_indicator', '- - ')
+        super(ForumChoiceField, self).__init__(*args, **kwargs)
+
+    def _get_level_indicator(self, obj):
+        level = getattr(obj, obj._mptt_meta.level_attr) - self.base_level
+        if level > 0:
+            return mark_safe(conditional_escape(self.level_indicator) * level)
+        else:
+            return ''
+
+
+FORUM_ROLES = (
+    ('category', _('Category')),
+    ('forum', _('Forum')),
+    ('redirect', _('Redirect')),
+)
+
+
+class ForumFormBase(forms.ModelForm):
+    role = forms.ChoiceField(label=_("Type"), choices=FORUM_ROLES)
+    name = forms.CharField(
+        label=_("Name"),
+        validators=[validate_sluggable()],
+        help_text=_('Short and descriptive name of all users with this rank. '
+                    '"The Team" or "Game Masters" are good examples.'))
+    description = forms.CharField(
+        label=_("Description"), max_length=2048, required=False,
+        widget=forms.Textarea(attrs={'rows': 3}),
+        help_text=_("Optional description explaining forum's intented "
+                    "purpose."))
+    redirect_url = forms.URLField(
+        label=_("Redirect URL"),
+        validators=[validate_sluggable()],
+        help_text=_('If forum type is redirect, enter here its URL.'),
+        required=False)
+    css_class = forms.CharField(
+        label=_("CSS class"), required=False,
+        help_text=_("Optional CSS class used to customize this forum "
+                    "appearance from templates."))
+    is_closed = forms.YesNoSwitch(
+        label=_("Closed forum"), required=False,
+        help_text=_("Only members with valid permissions can post in "
+                    "closed forums."))
+    css_class = forms.CharField(
+        label=_("CSS class"), required=False,
+        help_text=_("Optional CSS class used to customize this forum "
+                    "appearance from templates."))
+    prune_started_after = forms.IntegerField(
+        label=_("Prune thread if number of days since its creation is "
+                "greater than"), min_value=0,
+        help_text=_("Enter 0 to disable this pruning criteria."))
+    prune_replied_after = forms.IntegerField(
+        label=_("Prune thread if number of days since last reply is greater "
+                "than"), min_value=0,
+        help_text=_("Enter 0 to disable this pruning criteria."))
+
+    class Meta:
+        model = Forum
+        fields = [
+            'role',
+            'name',
+            'description',
+            'redirect_url',
+            'css_class',
+            'is_closed',
+            'prune_started_after',
+            'prune_replied_after',
+            'archive_pruned_in',
+        ]
+
+    def clean_copy_permissions(self):
+        data = self.cleaned_data['copy_permissions']
+        if data and data.pk == self.instance.pk:
+            message = _("Permissions cannot be copied from forum into itself.")
+            raise forms.ValidationError(message)
+        return data
+
+    def clean_archive_pruned_in(self):
+        data = self.cleaned_data['archive_pruned_in']
+        if data and data.pk == self.instance.pk:
+            message = _("Forum cannot act as archive for itself.")
+            raise forms.ValidationError(message)
+        return data
+
+    def clean(self):
+        data = super(ForumFormBase, self).clean()
+
+        self.instance.set_name(data.get('name'))
+        self.instance.set_description(data.get('description'))
+
+        if data['role'] != 'category':
+            if not data['new_parent'].level:
+                message = _("Only categories can have no parent category.")
+                raise forms.ValidationError(message)
+
+        if data['role'] == 'redirect':
+            if not data.get('redirect'):
+                message = _("This forum is redirect, yet you haven't "
+                            "specified URL to which it should redirect "
+                            "after click.")
+                raise forms.ValidationError(message)
+
+        return data
+
+
+def ForumFormFactory(instance):
+    parent_queryset = Forum.objects.all_forums(True).order_by('lft')
+    if instance.pk:
+        not_siblings = models.Q(lft__lt=instance.lft)
+        not_siblings = not_siblings | models.Q(rght__gt=instance.rght)
+        parent_queryset = parent_queryset.filter(not_siblings)
+
+    return type('TreeNodeChoiceField', (ForumFormBase,), {
+        'new_parent': ForumChoiceField(
+            label=_("Parent forum"),
+            queryset=parent_queryset,
+            initial=instance.parent,
+            empty_label=None),
+        'copy_permissions': ForumChoiceField(
+            label=_("Copy permissions"),
+            help_text=_("You can override this forum permissions with "
+                        "permissions of other forum selected here."),
+            queryset=Forum.objects.all_forums(),
+            empty_label=_("Don't copy permissions"),
+            base_level=1,
+            required=False),
+        'archive_pruned_in': ForumChoiceField(
+            label=_("Pruned threads archive"),
+            help_text=_("Instead of being deleted, pruned threads can be "
+                        "moved to designated forum."),
+            queryset=Forum.objects.all_forums(),
+            empty_label=_("Don't archive pruned threads"),
+            base_level=1,
+            required=False),
+        })
+

+ 59 - 0
misago/forums/migrations/0002_auto__del_field_forum_description_preparsed__add_field_forum_descripti.py

@@ -0,0 +1,59 @@
+# -*- coding: utf-8 -*-
+from south.utils import datetime_utils as datetime
+from south.db import db
+from south.v2 import SchemaMigration
+from django.db import models
+
+
+class Migration(SchemaMigration):
+
+    def forwards(self, orm):
+        # Deleting field 'Forum.description_preparsed'
+        db.delete_column(u'forums_forum', 'description_preparsed')
+
+        # Adding field 'Forum.description_as_html'
+        db.add_column(u'forums_forum', 'description_as_html',
+                      self.gf('django.db.models.fields.TextField')(null=True, blank=True),
+                      keep_default=False)
+
+
+    def backwards(self, orm):
+        # Adding field 'Forum.description_preparsed'
+        db.add_column(u'forums_forum', 'description_preparsed',
+                      self.gf('django.db.models.fields.TextField')(null=True, blank=True),
+                      keep_default=False)
+
+        # Deleting field 'Forum.description_as_html'
+        db.delete_column(u'forums_forum', 'description_as_html')
+
+
+    models = {
+        u'forums.forum': {
+            'Meta': {'object_name': 'Forum'},
+            'archive_pruned_in': ('django.db.models.fields.related.ForeignKey', [], {'blank': 'True', 'related_name': "'pruned_archive'", 'null': 'True', 'on_delete': 'models.SET_NULL', 'to': u"orm['forums.Forum']"}),
+            'css_class': ('django.db.models.fields.CharField', [], {'max_length': '255', 'null': 'True', 'blank': 'True'}),
+            'description': ('django.db.models.fields.TextField', [], {'null': 'True', 'blank': 'True'}),
+            'description_as_html': ('django.db.models.fields.TextField', [], {'null': 'True', 'blank': 'True'}),
+            u'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}),
+            'is_closed': ('django.db.models.fields.BooleanField', [], {'default': 'False'}),
+            u'level': ('django.db.models.fields.PositiveIntegerField', [], {'db_index': 'True'}),
+            u'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': u"orm['forums.Forum']"}),
+            'posts': ('django.db.models.fields.PositiveIntegerField', [], {'default': '0'}),
+            'posts_count': ('django.db.models.fields.PositiveIntegerField', [], {'default': '0'}),
+            'prune_replied_after': ('django.db.models.fields.PositiveIntegerField', [], {'default': '0'}),
+            'prune_started_after': ('django.db.models.fields.PositiveIntegerField', [], {'default': '0'}),
+            'redirect_url': ('django.db.models.fields.CharField', [], {'max_length': '255', 'null': 'True', 'blank': 'True'}),
+            'redirects_count': ('django.db.models.fields.PositiveIntegerField', [], {'default': '0'}),
+            u'rght': ('django.db.models.fields.PositiveIntegerField', [], {'db_index': 'True'}),
+            'role': ('django.db.models.fields.CharField', [], {'max_length': '255', 'null': 'True', 'blank': 'True'}),
+            'slug': ('django.db.models.fields.SlugField', [], {'max_length': '255'}),
+            'special_role': ('django.db.models.fields.CharField', [], {'max_length': '255', 'null': 'True', 'blank': 'True'}),
+            'threads': ('django.db.models.fields.PositiveIntegerField', [], {'default': '0'}),
+            'threads_count': ('django.db.models.fields.PositiveIntegerField', [], {'default': '0'}),
+            u'tree_id': ('django.db.models.fields.PositiveIntegerField', [], {'db_index': 'True'})
+        }
+    }
+
+    complete_apps = ['forums']

+ 66 - 0
misago/forums/migrations/0003_set_root_nodes.py

@@ -0,0 +1,66 @@
+# -*- coding: utf-8 -*-
+from south.utils import datetime_utils as datetime
+from south.db import db
+from south.v2 import DataMigration
+from django.db import models
+
+class Migration(DataMigration):
+
+    def forwards(self, orm):
+        orm.Forum(
+            special_role='private_threads',
+            role='forum',
+            name='Private',
+            slug='private',
+            lft=1,
+            rght=2,
+            tree_id=0,
+            level=0,
+        ).save()
+
+        orm.Forum(
+            special_role='root_category',
+            role='category',
+            name='Root',
+            slug='root',
+            lft=3,
+            rght=4,
+            tree_id=1,
+            level=0,
+        ).save()
+
+
+    def backwards(self, orm):
+        "Write your backwards methods here."
+
+    models = {
+        u'forums.forum': {
+            'Meta': {'object_name': 'Forum'},
+            'archive_pruned_in': ('django.db.models.fields.related.ForeignKey', [], {'blank': 'True', 'related_name': "'pruned_archive'", 'null': 'True', 'on_delete': 'models.SET_NULL', 'to': u"orm['forums.Forum']"}),
+            'css_class': ('django.db.models.fields.CharField', [], {'max_length': '255', 'null': 'True', 'blank': 'True'}),
+            'description': ('django.db.models.fields.TextField', [], {'null': 'True', 'blank': 'True'}),
+            'description_as_html': ('django.db.models.fields.TextField', [], {'null': 'True', 'blank': 'True'}),
+            u'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}),
+            'is_closed': ('django.db.models.fields.BooleanField', [], {'default': 'False'}),
+            u'level': ('django.db.models.fields.PositiveIntegerField', [], {'db_index': 'True'}),
+            u'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': u"orm['forums.Forum']"}),
+            'posts': ('django.db.models.fields.PositiveIntegerField', [], {'default': '0'}),
+            'posts_count': ('django.db.models.fields.PositiveIntegerField', [], {'default': '0'}),
+            'prune_replied_after': ('django.db.models.fields.PositiveIntegerField', [], {'default': '0'}),
+            'prune_started_after': ('django.db.models.fields.PositiveIntegerField', [], {'default': '0'}),
+            'redirect_url': ('django.db.models.fields.CharField', [], {'max_length': '255', 'null': 'True', 'blank': 'True'}),
+            'redirects_count': ('django.db.models.fields.PositiveIntegerField', [], {'default': '0'}),
+            u'rght': ('django.db.models.fields.PositiveIntegerField', [], {'db_index': 'True'}),
+            'role': ('django.db.models.fields.CharField', [], {'max_length': '255', 'null': 'True', 'blank': 'True'}),
+            'slug': ('django.db.models.fields.SlugField', [], {'max_length': '255'}),
+            'special_role': ('django.db.models.fields.CharField', [], {'max_length': '255', 'null': 'True', 'blank': 'True'}),
+            'threads': ('django.db.models.fields.PositiveIntegerField', [], {'default': '0'}),
+            'threads_count': ('django.db.models.fields.PositiveIntegerField', [], {'default': '0'}),
+            u'tree_id': ('django.db.models.fields.PositiveIntegerField', [], {'db_index': 'True'})
+        }
+    }
+
+    complete_apps = ['forums']
+    symmetrical = True

+ 48 - 2
misago/forums/models.py

@@ -1,10 +1,23 @@
 from django.db import models
+from django.utils.translation import ugettext_lazy as _
 from mptt.managers import TreeManager
 from mptt.models import MPTTModel, TreeForeignKey
+from misago.admin import site
+from misago.core.utils import subset_markdown, slugify
 
 
 class ForumManager(TreeManager):
-    pass
+    def private_threads(self):
+        return self.get(special_role='private_threads')
+
+    def root_category(self):
+        return self.get(special_role='root_category')
+
+    def all_forums(self, include_root=False):
+        qs = self.filter(tree_id=1)
+        if not include_root:
+            qs = self.filter(lft__gt=3)
+        return qs
 
 
 class Forum(MPTTModel):
@@ -15,7 +28,7 @@ class Forum(MPTTModel):
     name = models.CharField(max_length=255)
     slug = models.SlugField(max_length=255)
     description = models.TextField(null=True, blank=True)
-    description_preparsed = models.TextField(null=True, blank=True)
+    description_as_html = models.TextField(null=True, blank=True)
     is_closed = models.BooleanField(default=False)
     redirect_url = models.CharField(max_length=255, null=True, blank=True)
     redirects_count = models.PositiveIntegerField(default=0)
@@ -34,3 +47,36 @@ class Forum(MPTTModel):
     css_class = models.CharField(max_length=255, null=True, blank=True)
 
     objects = ForumManager()
+
+    def __unicode__(self):
+        if self.special_role == 'root_category':
+            return unicode(_('No parent'))
+        elif self.special_role == 'private_threads':
+            return unicode(_('Private Threads'))
+        else:
+            return self.name
+
+    def set_name(self, name):
+        self.name = name
+        self.slug = slugify(name)
+
+    def set_description(self, description):
+        self.description = description
+        self.description_as_html = subset_markdown(description)
+
+
+"""register model in misago admin"""
+site.add_node(
+    parent='misago:admin',
+    before='misago:admin:permissions:users:index',
+    namespace='misago:admin:forums',
+    link='misago:admin:forums:nodes:index',
+    name=_("Forums"),
+    icon='fa fa-comment')
+
+site.add_node(
+    parent='misago:admin:forums',
+    namespace='misago:admin:forums:nodes',
+    link='misago:admin:forums:nodes:index',
+    name=_("Forums Hierarchy"),
+    icon='fa fa-comment')

+ 20 - 0
misago/forums/urls.py

@@ -0,0 +1,20 @@
+from django.conf.urls import url
+from misago.admin import urlpatterns
+from misago.forums.views import (ForumsList, NewForum, EditForum, DeleteForum,
+                                 MoveUpForum, MoveDownForum)
+
+
+# Forums section
+urlpatterns.namespace(r'^forums/', 'forums')
+
+
+# Nodes
+urlpatterns.namespace(r'^nodes/', 'nodes', 'forums')
+urlpatterns.patterns('forums:nodes',
+    url(r'^$', ForumsList.as_view(), name='index'),
+    url(r'^new/$', NewForum.as_view(), name='new'),
+    url(r'^edit/(?P<forum_id>\d+)/$', EditForum.as_view(), name='edit'),
+    url(r'^move/up/(?P<forum_id>\d+)/$', MoveUpForum.as_view(), name='up'),
+    url(r'^move/down/(?P<forum_id>\d+)/$', MoveDownForum.as_view(), name='down'),
+    url(r'^delete/(?P<forum_id>\d+)/$', DeleteForum.as_view(), name='delete'),
+)

+ 101 - 0
misago/forums/views.py

@@ -0,0 +1,101 @@
+from django.contrib import messages
+from django.utils.translation import ugettext_lazy as _
+from misago.acl import cachebuster
+from misago.admin.views import generic
+from misago.forums.models import Forum
+from misago.forums.forms import ForumFormFactory
+
+
+class ForumAdmin(generic.AdminBaseMixin):
+    root_link = 'misago:admin:forums:nodes:index'
+    Model = Forum
+    templates_dir = 'misago/admin/forums'
+    message_404 = _("Requested forum does not exist.")
+
+    def get_target(self, kwargs):
+        target = super(ForumAdmin, self).get_target(kwargs)
+        if target.pk and target.tree_id != 1:
+            raise Forum.DoesNotExist()
+        else:
+            return target
+
+
+class ForumsList(ForumAdmin, generic.ListView):
+    ordering = (('lft', None),)
+
+    def get_queryset(self):
+        return Forum.objects.all_forums()
+
+    def process_context(self, request, context):
+        context['items'] = [f for f in context['items']]
+
+        levels_lists = {}
+
+        for i, item in enumerate(context['items']):
+            item.level_range = range(item.level - 1)
+            item.first = False
+            item.last = False
+            levels_lists.setdefault(item.level, []).append(item)
+
+        for level_items in levels_lists.values():
+            level_items[0].first = True
+            level_items[-1].last = True
+
+        return context
+
+
+class ForumFormMixin(object):
+    def create_form_type(self, request, target):
+        return ForumFormFactory(target)
+
+    def handle_form(self, form, request, target):
+        if form.instance.pk:
+            if form.instance.parent_id != form.cleaned_data['new_parent'].pk:
+                form.instance.move_to(form.cleaned_data['new_parent'],
+                                      position='last-child')
+            form.instance.save()
+        else:
+            form.instance.insert_at(form.cleaned_data['new_parent'],
+                                    position='last-child',
+                                    save=True)
+
+        cachebuster.invalidate()
+        messages.success(request, self.message_submit % target.name)
+
+
+class NewForum(ForumFormMixin, ForumAdmin, generic.ModelFormView):
+    message_submit = _('New forum "%s" has been saved.')
+
+
+class EditForum(ForumFormMixin, ForumAdmin, generic.ModelFormView):
+    message_submit = _('Forum "%s" has been edited.')
+
+
+class EditForum(ForumFormMixin, ForumAdmin, generic.ModelFormView):
+    message_submit = _('Forum "%s" has been deleted.')
+
+
+class MoveUpForum(ForumAdmin, generic.ButtonView):
+    def button_action(self, request, target):
+        try:
+            other_target = target.get_previous_sibling()
+        except Forum.DoesNotExist:
+            other_target = None
+
+        if other_target:
+            Forum.objects.move_node(target, other_target, 'left')
+            message = _('Forum "%s" has been moved up.') % target.name
+            messages.success(request, message)
+
+
+class MoveDownForum(ForumAdmin, generic.ButtonView):
+    def button_action(self, request, target):
+        try:
+            other_target = target.get_next_sibling()
+        except Forum.DoesNotExist:
+            other_target = None
+
+        if other_target:
+            Forum.objects.move_node(target, other_target, 'right')
+            message = _('Forum "%s" has been moved down.') % target.name
+            messages.success(request, message)

+ 3 - 1
misago/project_template/requirements.txt

@@ -1,9 +1,11 @@
+bleach==1.4
 django==1.6.1
 django-debug-toolbar==1.0.1
 django-crispy-forms==1.4.0
 django-mptt==0.6.1
 django-pipeline==1.3.20
 fake-factory
+markdown==2.4.1
+pytz
 south==0.8.4
 unidecode
-pytz

+ 37 - 0
misago/static/misago/admin/css/misago/forms.less

@@ -45,6 +45,43 @@
 }
 
 
+//== Form non-field errors
+//
+//**
+.form-panel {
+  .form-errors-block {
+    background-color: @alert-danger-bg;
+    border-top: 1px solid @form-panel-border;
+    border-bottom: 6px solid fadeOut(#000, 85%);
+    border-radius: 0;
+    margin-bottom: 0px;
+    padding: @form-panel-padding;
+    overflow: auto;
+
+    color: @alert-danger-text;
+    font-size: @font-size-large;
+    text-shadow: 0px -1px 0px darken(@alert-danger-bg, 20%);
+
+    .fa {
+      float: right;
+      font-size: 32px;
+    }
+
+    ul {
+      float: left;
+      margin-right: 30px;
+      margin-bottom: 0px;
+      padding: 0px;
+
+      li {
+        margin: 0px;
+        padding: (@line-height-computed / 5) 0px;
+      }
+    }
+  }
+}
+
+
 //== Form body
 //
 //**

+ 26 - 0
misago/static/misago/admin/css/style.css

@@ -6558,6 +6558,32 @@ body {
 .form-panel .form-header p {
   margin: 10px 0px 0px 0px;
 }
+.form-panel .form-errors-block {
+  background-color: #c0392b;
+  border-top: 1px solid #d4d4d4;
+  border-bottom: 6px solid rgba(0, 0, 0, 0.15);
+  border-radius: 0;
+  margin-bottom: 0px;
+  padding: 15px 16px;
+  overflow: auto;
+  color: #ffffff;
+  font-size: 18px;
+  text-shadow: 0px -1px 0px #6d2018;
+}
+.form-panel .form-errors-block .fa {
+  float: right;
+  font-size: 32px;
+}
+.form-panel .form-errors-block ul {
+  float: left;
+  margin-right: 30px;
+  margin-bottom: 0px;
+  padding: 0px;
+}
+.form-panel .form-errors-block ul li {
+  margin: 0px;
+  padding: 4px 0px;
+}
 .form-panel .form-body fieldset,
 .form-panel .form-body.no-fieldsets {
   margin: 0px;

+ 0 - 0
misago/templates/misago/admin/forums/delete.html


+ 68 - 0
misago/templates/misago/admin/forums/form.html

@@ -0,0 +1,68 @@
+{% extends "misago/admin/generic/form.html" %}
+{% load crispy_forms_filters i18n %}
+
+
+{% block title %}
+{% if target.pk %}
+{% trans target.name %}
+{% else %}
+{% trans "New forum" %}
+{% endif %} | {{ active_link.name }} | {{ block.super }}
+{% endblock title %}
+
+
+{% block page-target %}
+{% if target.pk %}
+{% trans target.name %}
+{% else %}
+{% trans "New forum" %}
+{% endif %}
+{% endblock page-target %}
+
+
+{% block form-header %}
+<h1>
+  {% if target.pk %}
+  {% trans target.name %}
+  {% else %}
+  {% trans "New forum" %}
+  {% endif %}
+</h1>
+{% endblock %}
+
+
+{% block form-body %}
+<div class="form-body">
+  <fieldset>
+    <legend>{% trans "Role and position" %}</legend>
+
+    {{ form.new_parent|as_crispy_field }}
+    {{ form.role|as_crispy_field }}
+
+  </fieldset>
+  <fieldset>
+    <legend>{% trans "Display" %}</legend>
+
+    {{ form.name|as_crispy_field }}
+    {{ form.description|as_crispy_field }}
+    {{ form.css_class|as_crispy_field }}
+
+  </fieldset>
+  <fieldset>
+    <legend>{% trans "Behaviour" %}</legend>
+
+    {{ form.copy_permissions|as_crispy_field }}
+    {{ form.redirect_url|as_crispy_field }}
+    {{ form.is_closed|as_crispy_field }}
+
+  </fieldset>
+  <fieldset>
+    <legend>{% trans "Pruning" %}</legend>
+
+    {{ form.prune_started_after|as_crispy_field }}
+    {{ form.prune_replied_after|as_crispy_field }}
+    {{ form.archive_pruned_in|as_crispy_field }}
+
+  </fieldset>
+</div>
+{% endblock form-body %}

+ 81 - 0
misago/templates/misago/admin/forums/list.html

@@ -0,0 +1,81 @@
+{% extends "misago/admin/generic/list.html" %}
+{% load i18n %}
+
+
+{% block page-actions %}
+<div class="page-actions">
+  <a href="{% url 'misago:admin:forums:nodes:new' %}" class="btn btn-success">
+    <span class="fa fa-plus-circle"></span>
+    {% trans "New forum" %}
+  </a>
+</div>
+{% endblock %}
+
+
+{% block table-header %}
+<th>{% trans "Forum" %}</th>
+<th>Role</th>
+<th>Left</th>
+<th>Right</th>
+<th>Level</th>
+<th>&nbsp;</th>
+<th>&nbsp;</th>
+<th>&nbsp;</th>
+{% endblock table-header %}
+
+
+{% block table-row %}
+<td class="lead">
+  {% for i in item.level_range %}
+  &nbsp;&nbsp;&nbsp;&nbsp;
+  {% endfor %}
+  {% if item.role == 'category' %}
+  <span class="fa fa-folder-open tooltip-top" title="{% trans "Category" %}"></span>
+  {% elif item.role == 'forum' %}
+  <span class="fa fa-comments-o tooltip-top" title="{% trans "Forum" %}"></span>
+  {% elif item.role == 'redirect' %}
+  <span class="fa fa-link tooltip-top" title="{% trans "Redirect" %}"></span>
+  {% endif %}
+  {{ item.name }}
+</td>
+<td>{{ item.role }}</td>
+<td>{{ item.lft }}</td>
+<td>{{ item.rght }}</td>
+<td>{{ item.level }}</td>
+<td class="row-action">
+  {% if not item.last %}
+  <form action="{% url 'misago:admin:forums:nodes:down' forum_id=item.id %}" method="post">
+    <button class="btn btn-default tooltip-top" title="{% trans "Move down" %}">
+      {% csrf_token %}
+      <span class="fa fa-chevron-down"></span>
+    </button>
+  </form>
+  {% else %}
+  &nbsp;
+  {% endif %}
+</td>
+<td class="row-action">
+  {% if not item.first %}
+  <form action="{% url 'misago:admin:forums:nodes:up' forum_id=item.id %}" method="post">
+    <button class="btn btn-default tooltip-top" title="{% trans "Move up" %}">
+      {% csrf_token %}
+      <span class="fa fa-chevron-up"></span>
+    </button>
+  </form>
+  {% else %}
+  &nbsp;
+  {% endif %}
+</td>
+<td class="row-action">
+  <a href="{% url 'misago:admin:forums:nodes:edit' forum_id=item.id %}" class="btn btn-primary tooltip-top" title="{% trans "Edit" %}">
+    <span class="fa fa-pencil"></span>
+  </a>
+</td>
+{% endblock %}
+
+
+{% block emptylist %}
+<td colspan="2">
+  <p>{% trans "No forums are currently defined." %}</p>
+</td>
+{% endblock emptylist %}

+ 11 - 0
misago/templates/misago/admin/generic/form.html

@@ -23,6 +23,17 @@
           {% block form-header %}{% endblock %}
         </div>
 
+        {% if form.non_field_errors %}
+        <div class="form-errors-block">
+          <span class="fa fa-exclamation-triangle"></span>
+          <ul class="list-unstyled">
+            {% for error in form.non_field_errors %}
+            <li>{{ error }}</li>
+            {% endfor %}
+          </ul>
+        </div>
+        {% endif %}
+
         {% block form-body %}{% endblock %}
 
         <div class="form-footer">

+ 4 - 53
misago/templates/misago/admin/generic/list.html

@@ -48,61 +48,11 @@
       {% block table-header %}
       {% endblock table-header %}
     </tr>
+
+    {% block table-items %}
     {% for item in items %}
     <tr>
-      {% block table-row %}
-      <td class="lead">Lorem</td>
-      <td>Ipsum</td>
-      <td>
-        <select class="form-control">
-          <option>Pacem</option>
-          <option>Para</option>
-          <option>Bellum</option>
-        </select>
-      </td>
-      <td class="row-action">
-        <button type="button" class="btn btn-success tooltip-top" title="Activate">
-          <span class="fa fa-check"></span>
-        </button>
-      </td>
-      <td class="row-action">
-        <div class="btn-group pull-right">
-          <button type="button" class="btn btn-default dropdown-toggle tooltip-top" data-toggle="dropdown" title="Item options">
-            <span class="fa fa-gear"></span>
-          </button>
-          <ul class="dropdown-menu" role="menu">
-            <li>
-              <a href="#">
-                <span class="fa fa-sort-numeric-desc"></span>
-                Newest
-              </a>
-              <a href="#">
-                <span class="fa fa-sort-numeric-asc"></span>
-                Oldest
-              </a>
-              <a href="#">
-                <span class="fa fa-sort-numeric-desc"></span>
-                Most posts
-              </a>
-              <a href="#">
-                <span class="fa fa-sort-numeric-asc"></span>
-                Least posts
-              </a>
-            </li>
-          </ul>
-        </div>
-      </td>
-      <td class="row-action">
-        <button type="button" class="btn btn-danger tooltip-top" title="Activate">
-          <span class="fa fa-times"></span>
-        </button>
-      </td>
-      <td class="row-select">
-        <label>
-          <input type="checkbox">
-        </label>
-      </td>
-      {% endblock table-row %}
+      {% block table-row %}{% endblock table-row %}
     </tr>
     {% empty %}
     <tr class="message-row">
@@ -110,6 +60,7 @@
       {% endblock emptylist %}
     </tr>
     {% endfor %}
+    {% endblock table-items %}
   </table>
 </div><!-- /.table-panel -->