Browse Source

Superbasic "new thread" view.

Rafał Pitoń 10 years ago
parent
commit
f6e8e4b832

+ 1 - 0
misago/acl/providers.py

@@ -1,4 +1,5 @@
 from importlib import import_module
+
 from django.conf import settings
 
 

+ 5 - 0
misago/conf/defaults.py

@@ -171,6 +171,11 @@ MISAGO_ACL_EXTENSIONS = (
 MISAGO_MARKUP_EXTENSIONS = ()
 
 
+MISAGO_POSTING_MIDDLEWARE = (
+    'misago.threads.forms.reply.ReplyFormMiddleware',
+)
+
+
 # Register Misago directories
 
 LOCALE_PATHS = (

+ 9 - 1
misago/forums/migrations/0001_initial.py

@@ -1,6 +1,7 @@
 # -*- coding: utf-8 -*-
 from __future__ import unicode_literals
 
+from django.conf import settings
 from django.db import models, migrations
 import django.db.models.deletion
 import mptt.fields
@@ -10,6 +11,7 @@ class Migration(migrations.Migration):
 
     dependencies = [
         ('misago_acl', '0001_initial'),
+        migrations.swappable_dependency(settings.AUTH_USER_MODEL),
     ]
 
     operations = [
@@ -20,13 +22,18 @@ class Migration(migrations.Migration):
                 ('special_role', models.CharField(max_length=255, null=True, blank=True)),
                 ('role', models.CharField(max_length=255, null=True, blank=True)),
                 ('name', models.CharField(max_length=255)),
-                ('slug', models.SlugField(max_length=255)),
+                ('slug', models.CharField(max_length=255)),
                 ('description', models.TextField(null=True, blank=True)),
                 ('is_closed', models.BooleanField(default=False)),
                 ('redirect_url', models.CharField(max_length=255, null=True, blank=True)),
                 ('redirects', models.PositiveIntegerField(default=0)),
                 ('threads', models.PositiveIntegerField(default=0)),
                 ('posts', models.PositiveIntegerField(default=0)),
+                ('last_thread_title', models.CharField(max_length=255, null=True, blank=True)),
+                ('last_thread_slug', models.CharField(max_length=255, null=True, blank=True)),
+                ('last_poster_name', models.CharField(max_length=255, null=True, blank=True)),
+                ('last_poster_slug', models.SlugField(max_length=255, null=True, blank=True)),
+                ('last_post_on', models.DateTimeField(null=True, blank=True)),
                 ('prune_started_after', models.PositiveIntegerField(default=0)),
                 ('prune_replied_after', models.PositiveIntegerField(default=0)),
                 ('css_class', models.CharField(max_length=255, null=True, blank=True)),
@@ -35,6 +42,7 @@ class Migration(migrations.Migration):
                 ('tree_id', models.PositiveIntegerField(editable=False, db_index=True)),
                 ('level', models.PositiveIntegerField(editable=False, db_index=True)),
                 ('archive_pruned_in', models.ForeignKey(related_name=b'pruned_archive', on_delete=django.db.models.deletion.SET_NULL, blank=True, to='misago_forums.Forum', null=True)),
+                ('last_poster', models.ForeignKey(related_name=b'+', on_delete=django.db.models.deletion.SET_NULL, blank=True, to=settings.AUTH_USER_MODEL, null=True)),
                 ('parent', mptt.fields.TreeForeignKey(related_name=b'children', blank=True, to='misago_forums.Forum', null=True)),
             ],
             options={

+ 22 - 0
misago/forums/migrations/0004_forum_last_thread.py

@@ -0,0 +1,22 @@
+# -*- coding: utf-8 -*-
+from __future__ import unicode_literals
+
+from django.db import models, migrations
+import django.db.models.deletion
+
+
+class Migration(migrations.Migration):
+
+    dependencies = [
+        ('misago_threads', '0001_initial'),
+        ('misago_forums', '0003_forums_roles'),
+    ]
+
+    operations = [
+        migrations.AddField(
+            model_name='forum',
+            name='last_thread',
+            field=models.ForeignKey(related_name=b'+', on_delete=django.db.models.deletion.SET_NULL, blank=True, to='misago_threads.Thread', null=True),
+            preserve_default=True,
+        ),
+    ]

+ 23 - 3
misago/forums/models.py

@@ -10,6 +10,7 @@ from mptt.models import MPTTModel, TreeForeignKey
 
 from misago.acl import version as acl_version
 from misago.acl.models import BaseRole
+from misago.conf import settings
 from misago.core import serializer
 from misago.core.cache import cache
 from misago.core.signals import secret_key_changed
@@ -56,19 +57,29 @@ class Forum(MPTTModel):
     special_role = models.CharField(max_length=255, null=True, blank=True)
     role = models.CharField(max_length=255, null=True, blank=True)
     name = models.CharField(max_length=255)
-    slug = models.SlugField(max_length=255)
+    slug = models.CharField(max_length=255)
     description = models.TextField(null=True, blank=True)
     is_closed = models.BooleanField(default=False)
     redirect_url = models.CharField(max_length=255, null=True, blank=True)
     redirects = models.PositiveIntegerField(default=0)
     threads = models.PositiveIntegerField(default=0)
     posts = models.PositiveIntegerField(default=0)
+    last_post_on = models.DateTimeField(null=True, blank=True)
+    last_thread = models.ForeignKey('misago_threads.Thread', related_name='+',
+                                    null=True, blank=True,
+                                    on_delete=models.SET_NULL)
+    last_thread_title = models.CharField(max_length=255, null=True, blank=True)
+    last_thread_slug = models.CharField(max_length=255, null=True, blank=True)
+    last_poster = models.ForeignKey(settings.AUTH_USER_MODEL, related_name='+',
+                                    null=True, blank=True,
+                                    on_delete=models.SET_NULL)
+    last_poster_name = models.CharField(max_length=255, null=True, blank=True)
+    last_poster_slug = models.SlugField(max_length=255, null=True, blank=True)
     prune_started_after = models.PositiveIntegerField(default=0)
     prune_replied_after = models.PositiveIntegerField(default=0)
     archive_pruned_in = models.ForeignKey('self',
                                           related_name='pruned_archive',
-                                          null=True,
-                                          blank=True,
+                                          null=True, blank=True,
                                           on_delete=models.SET_NULL)
     css_class = models.CharField(max_length=255, null=True, blank=True)
 
@@ -112,6 +123,15 @@ class Forum(MPTTModel):
         self.name = name
         self.slug = slugify(name)
 
+    def set_last_thread(self, thread):
+        self.last_post_on = thread.last_post_on
+        self.last_thread = thread
+        self.last_thread_title = thread.title
+        self.last_thread_slug = thread.slug
+        self.last_poster = thread.last_poster
+        self.last_poster_name = thread.last_poster_name
+        self.last_poster_slug = thread.last_poster_slug
+
     def has_child(self, child):
         return child.lft > self.lft and child.rght < self.rght
 

+ 4 - 0
misago/markup/parser.py

@@ -26,6 +26,10 @@ def parse(text, author=None, allow_mentions=True, allow_links=True,
         'original_text': text,
         'parsed_text': '',
         'markdown': md,
+        'mentions': [],
+        'images': [],
+        'outgoing_links': [],
+        'inside_links': []
     }
 
     # Parse text

+ 8 - 0
misago/static/misago/css/misago/editor.less

@@ -60,6 +60,14 @@
   }
 }
 
+.form-panel>div:first-child, .form-panel>form:first-child {
+  .misago-editor {
+    .editor-toolbar {
+      border-top: none;
+    }
+  }
+}
+
 
 //== Textarea
 //

+ 1 - 0
misago/static/misago/css/misago/forms.less

@@ -11,6 +11,7 @@
   border: 1px solid @form-panel-border;
   border-radius: @border-radius-large;
   box-shadow: 0px 0px 0px 3px @form-panel-shadow;
+  overflow: hidden;
 
   form {
     margin: 0px;

+ 10 - 1
misago/static/misago/js/misago-editor.js

@@ -92,8 +92,17 @@ function enable_editor(name) {
   var $upload = $editor.find('.editor-upload');
 
   $textarea.autosize();
-
   var textarea_id = $textarea.attr('id');
+
+  $editor.find('.btn-strong').click(function() {
+    makeWrap(textarea_id, '**', '**');
+    return false;
+  });
+
+  $editor.find('.btn-emphasis').click(function() {
+    makeWrap(textarea_id, '*', '*');
+    return false;
+  });
   $editor.find('.btn-bold').click(function() {
     makeWrap(textarea_id, '[b]', '[/b]');
     return false;

+ 2 - 7
misago/templates/misago/editor/body.html

@@ -3,20 +3,15 @@
   <div class="editor-toolbar">
     <ul class="list-unstyled">
       <li>
-        <button type="button" class="btn btn-default tooltip-top btn-bold" title="{% trans "Bold" %}">
+        <button type="button" class="btn btn-default tooltip-top btn-strong" title="{% trans "Bold" %}">
           <span class="fa fa-bold fa-fw fa-lg"></span>
         </button>
       </li>
       <li>
-        <button type="button" class="btn btn-default tooltip-top btn-italic" title="{% trans "Italic" %}">
+        <button type="button" class="btn btn-default tooltip-top btn-emphasis" title="{% trans "Italic" %}">
           <span class="fa fa-italic fa-fw fa-lg"></span>
         </button>
       </li>
-      <li>
-        <button type="button" class="btn btn-default tooltip-top btn-underline" title="{% trans "Underline" %}">
-          <span class="fa fa-underline fa-fw fa-lg"></span>
-        </button>
-      </li>
       {% if editor.allow_links or editor.allow_links or editor.allow_links %}
       <li class="separator"></li>
       {% endif %}

+ 22 - 4
misago/templates/misago/forums/forums.html

@@ -46,10 +46,28 @@
         {% endblocktrans %}
       </div>
       {% elif forum.acl.can_browse %}
-      <a href="#" class="item-title">Lorem ipsum dolor met sit amet</a>
-      <div class="text-muted">
-        <a href="#" class="item-title">somebody</a>, <abbr>32 minutes ago</abbr>
-      </div>
+        {% if forum.last_thread_title %}
+        <a href="#" class="item-title">{{ forum.last_thread_title }}</a>
+        <div class="text-muted">
+          {% capture trimmed as last_poster %}
+            {% if forum.last_poster_id %}
+            <a href="{% url USER_PROFILE_URL user_id=forum.last_poster_id user_slug=forum.last_poster_slug %}" class="item-title">{{ forum.last_poster_name }}</a>
+            {% else %}
+            <strong class="item-title">{{ forum.last_poster_name }}</strong>
+            {% endif %}
+          {% endcapture %}
+          {% capture trimmed as last_post %}
+          <abbr class="tooltip-top dynamic time-ago" title="{{ forum.last_post_on }}" data-timestamp="{{ forum.last_post_on|date:"c" }}">
+            {{ forum.last_post_on|date }}
+          </abbr>
+          {% endcapture %}
+          {% blocktrans trimmed with last_poster=last_poster|safe last_post=last_post|safe %}
+          {{ last_poster }}, {{ last_post }}
+          {% endblocktrans %}
+        </div>
+        {% else %}
+        <em class="text-muted">{% trans "Forum is empty" %}</em>
+        {% endif %}
       {% else %}
       <em class="text-muted">{% trans "Can't be browsed" %}</em>
       {% endif %}

+ 77 - 0
misago/templates/misago/threads/editor.html

@@ -0,0 +1,77 @@
+{% extends "misago/base.html" %}
+{% load i18n %}
+
+
+{% block title %}{% trans "Start new thread" %} | {{ forum.name }} | {{ block.super }}{% endblock title %}
+
+
+{% block content %}
+<div{% if forum.css %} class="page-{{ forum.css_class }}"{% endif %}>
+  <div class="page-header">
+    <div class="container">
+      {% if path %}
+      <ol class="breadcrumb">
+        {% for crumb in path %}
+        <li>
+          <a href="{{ crumb.get_absolute_url }}">{{ crumb.name }}</a>{% if not forloop.last %}<span class="fa fa-chevron-right"></span>{% endif %}
+        </li>
+        {% endfor %}
+      </ol>
+      {% endif %}
+
+      <h1>
+        <span class="main">
+          <a href="{{ forum.get_absolute_url }}">{{ forum.name }}</a>
+        </span>
+        <span class="sub">
+          <span class="fa fa-chevron-right"></span>
+          {% trans "Start new thread" %}
+        </span>
+      </h1>
+    </div>
+  </div>
+  <div class="container">
+    <form method="POST">
+      {% csrf_token %}
+
+      <div class="row">
+      {% if supporting_forms %}
+        <div class="col-md-9">
+      {% else %}
+        <div class="col-md-10 col-md-offset-1">
+      {% endif %}
+
+          <div class="form-panel">
+            {% for form in main_forms %}
+            {% include form.template %}
+            {% endfor %}
+
+            <div class="form-footer text-right">
+
+              {% if formset.start_form %}
+              <button class="btn btn-primary" name="submit">{% trans "Post thread" %}</button>
+              {% elif formset.reply_form %}
+              <button class="btn btn-primary" name="submit">{% trans "Post reply" %}</button>
+              {% elif formset.edit_form %}
+              <button class="btn btn-primary" name="submit">{% trans "Save changes" %}</button>
+              {% endif %}
+
+            </div>
+          </div>
+
+        </div>
+      </div><!-- /.row -->
+
+    </form>
+  </div>
+</div>
+{% endblock content %}
+
+
+{% block javascripts %}
+{% for form in main_forms %}
+  {% if form.js_template %}
+  {% include form.js_template %}
+  {% endif %}
+{% endfor %}
+{% endblock javascripts %}

+ 1 - 1
misago/templates/misago/threads/list.html

@@ -51,7 +51,7 @@
             {% trans "Sign in to start thread" %}
           </a>
           {% elif forum.acl.can_start_threads %}
-          <a href="{% url LOGIN_URL %}" class="btn btn-success pull-right">
+          <a href="{% url 'misago:start_thread' forum_slug=forum.slug forum_id=forum.id %}" class="btn btn-success pull-right">
             <span class="fa fa-plus-circle"></span>
             {% trans "Start thread" %}
           </a>

+ 12 - 0
misago/templates/misago/threads/replyform.html

@@ -0,0 +1,12 @@
+{% load i18n misago_editor misago_forms %}
+{% include 'misago/form_errors.html' %}
+
+{% if form.title %}
+<div class="form-header">
+  <input class="textinput textInput form-control input-lg" id="{{ form.title.auto_id }}" name="title" type="text" {% if form.title.value %}value="{{ form.title.value }}"{% endif %} placeholder="{% trans "Thread title..." %}"/>
+</div>
+{% endif %}
+
+<div class="form-body">
+  {% editor_body form.post_editor %}
+</div>

+ 2 - 0
misago/templates/misago/threads/replyform_js.html

@@ -0,0 +1,2 @@
+{% load misago_editor %}
+{% editor_js form.post_editor %}

+ 11 - 0
misago/threads/checksums.py

@@ -0,0 +1,11 @@
+from misago.markup import checksums
+
+
+def is_post_valid(post):
+    valid_checksum = make_post_checksum(post)
+    return post.post_checksum == valid_checksum
+
+
+def update_post_checksum(post):
+    post_seeds = [unicode(v) for v in (post.id, post.poster_ip)]
+    return checksums.make_checksum(post.post_parsed, post_seeds)

+ 148 - 0
misago/threads/forms/posting.py

@@ -0,0 +1,148 @@
+from importlib import import_module
+
+from django.utils import timezone
+
+from misago.conf import settings
+from misago.core import forms
+
+from misago.threads.models import Prefix
+
+
+START = 0
+REPLY = 1
+EDIT = 2
+
+
+class EditorFormset(object):
+    """
+    This is gigantozaurus that handles entire posting process
+
+    * It stores context in which we are acting
+    * It inits forms for posting view
+    *
+    """
+    def __init__(self, **kwargs):
+        self.errors = []
+
+        self._forms_list = []
+        self._forms_dict = {}
+
+        self.kwargs = kwargs
+        self.__dict__.update(kwargs)
+
+        self.datetime = timezone.now()
+
+        self.middlewares = []
+        self._load_middlewares()
+
+    @property
+    def start_form(self):
+        return self.mode == START
+
+    @property
+    def reply_form(self):
+        return self.mode == REPLY
+
+    @property
+    def edit_form(self):
+        return self.mode == EDIT
+
+    def _load_middlewares(self):
+        kwargs = self.kwargs.copy()
+        kwargs['datetime'] = self.datetime
+
+        for middleware in settings.MISAGO_POSTING_MIDDLEWARE:
+            module_name = '.'.join(middleware.split('.')[:-1])
+            class_name = middleware.split('.')[-1]
+
+            middleware_module = import_module(module_name)
+            middleware_class = getattr(middleware_module, class_name)
+
+            middleware_obj = middleware_class(prefix=middleware, **kwargs)
+            self.middlewares.append((middleware, middleware_obj))
+
+    def get_forms_list(self):
+        """return list of forms belonging to formset"""
+        if not self._forms_list:
+            self._build_forms_cache()
+        return self._forms_list
+
+    def get_forms_dict(self):
+        """return list of forms belonging to formset"""
+        if not self._forms_dict:
+            self._build_forms_cache()
+        return self._forms_dict
+
+    def _build_forms_cache(self):
+        for middleware, obj in self.middlewares:
+            form = obj.make_form()
+            if form:
+                self._forms_dict[middleware] = form
+                self._forms_list.append(form)
+
+    def get_main_forms(self):
+        """return list of main forms"""
+        main_forms = []
+        for form in self.get_forms_list():
+            try:
+                if form.is_main and form.legend:
+                    main_forms.append(form)
+            except AttributeError:
+                pass
+        return main_forms
+
+    def get_supporting_forms(self):
+        """return list of supporting forms"""
+        supporting_forms = []
+        for form in self.get_forms_list():
+            try:
+                if form.is_supporting and form.legend:
+                    supporting_forms.append(form)
+            except AttributeError:
+                pass
+        return supporting_forms
+
+    def is_valid(self):
+        """validate all forms"""
+        all_forms_valid = True
+        for form in self.get_forms_list():
+            if not form.is_valid():
+                all_forms_valid = False
+        return all_forms_valid
+
+    def save(self):
+        """change state"""
+        forms_dict = self.get_forms_dict()
+        for middleware, obj in self.middlewares:
+            obj.pre_save(forms_dict.get(middleware))
+        for middleware, obj in self.middlewares:
+            obj.save(forms_dict.get(middleware))
+        for middleware, obj in self.middlewares:
+            obj.post_save(forms_dict.get(middleware))
+
+    def update(self):
+        """handle POST that shouldn't result in state change"""
+        forms_dict = self.get_forms_dict()
+        for middleware, obj in self.middlewares:
+            obj.pre_save(forms_dict.get(middleware))
+
+
+class EditorFormsetMiddleware(object):
+    """
+    Abstract middleware classes
+    """
+    def __init__(self, **kwargs):
+        self.kwargs = kwargs
+        self.__dict__.update(kwargs)
+
+    def make_form(self):
+        pass
+
+    def pre_save(self, form):
+        pass
+
+    def save(self, form):
+        pass
+
+    def post_save(self, form):
+        pass

+ 181 - 0
misago/threads/forms/reply.py

@@ -0,0 +1,181 @@
+from django.utils.translation import ugettext_lazy as _, ungettext
+
+from misago.conf import settings
+from misago.core import forms
+from misago.core.validators import validate_sluggable
+from misago.markup import Editor, common_flavour
+
+from misago.threads.checksums import update_post_checksum
+from misago.threads.forms.posting import (EditorFormsetMiddleware,
+                                          START, REPLY, EDIT)
+
+
+class ReplyForm(forms.Form):
+    is_main = True
+    legend = _("Reply")
+    template = "misago/threads/replyform.html"
+    js_template = "misago/threads/replyform_js.html"
+
+    post = forms.CharField(label=_("Message body"), required=False)
+
+    def __init__(self, post=None, *args, **kwargs):
+        self.post_instance = post
+        self.parsing_result = {}
+
+        super(ReplyForm, self).__init__(*args, **kwargs)
+
+    def validate_post(self, post):
+        post_len = len(post)
+        if not post_len:
+            raise forms.ValidationError(_("Enter message."))
+
+        if post_len < settings.post_length_min:
+            message = ungettext(
+                "Posted message should be at least %(limit)s character long.",
+                "Posted message should be at least %(limit)s characters long.",
+                settings.post_length_min)
+            message = message % {'limit': settings.post_length_min}
+            raise forms.ValidationError(message)
+
+        if settings.post_length_max and post_len > settings.post_length_max:
+            message = ungettext(
+                "Posted message can't be longer than %(limit)s character.",
+                "Posted message can't be longer than %(limit)s characters.",
+                settings.post_length_max,)
+            message = message % {'limit': settings.post_length_max}
+            raise forms.ValidationError(message)
+
+        self.parsing_result = common_flavour(post, self.post_instance.poster)
+
+        self.post_instance.post = self.parsing_result['original_text']
+        self.post_instance.post_parsed = self.parsing_result['parsed_text']
+
+    def validate_data(self, data):
+        self.validate_post(data.get('post', ''))
+
+    def clean(self):
+        data = super(ReplyForm, self).clean()
+        self.validate_data(data)
+        return data
+
+
+class ThreadForm(ReplyForm):
+    legend = _("Thread ")
+
+    title = forms.CharField(label=_("Thread title"), required=False)
+
+    def __init__(self, thread=None, *args, **kwargs):
+        self.thread_instance = thread
+        super(ThreadForm, self).__init__(*args, **kwargs)
+
+    def validate_title(self, title):
+        title_len = len(title)
+
+        if not title_len:
+            raise forms.ValidationError(_("Enter thread title."))
+
+        if title_len < settings.thread_title_length_min:
+            message = ungettext(
+                "Thread title should be at least %(limit)s character long.",
+                "Thread title should be at least %(limit)s characters long.",
+                settings.thread_title_length_min)
+            message = message % {'limit': settings.thread_title_length_min}
+            raise forms.ValidationError(message)
+
+        if title_len > settings.thread_title_length_max:
+            message = ungettext(
+                "Thread title can't be longer than %(limit)s character.",
+                "Thread title can't be longer than %(limit)s characters.",
+                settings.thread_title_length_max,)
+            message = message % {'limit': settings.thread_title_length_max}
+            raise forms.ValidationError(message)
+
+        error_not_sluggable = _("Thread title should contain "
+                                "alpha-numeric characters.")
+        error_slug_too_long = _("Thread title is too long.")
+        slug_validator = validate_sluggable(error_not_sluggable,
+                                            error_slug_too_long)
+        slug_validator(title)
+
+    def validate_data(self, data):
+        errors = []
+
+        if not data.get('title') and not data.get('post'):
+            raise forms.ValidationError(_("Enter thread title and message."))
+
+        try:
+            self.validate_title(data.get('title', ''))
+        except forms.ValidationError as e:
+            errors.append(e)
+
+        try:
+            self.validate_post(data.get('post', ''))
+        except forms.ValidationError as e:
+            errors.append(e)
+
+        if errors:
+            raise forms.ValidationError(errors)
+
+
+class PrefixedThreadForm(ThreadForm):
+    pass
+
+
+class ReplyFormMiddleware(EditorFormsetMiddleware):
+    def make_form(self):
+        initial_data = {'title': self.thread.title, 'post': self.post.post}
+
+        if self.mode == START:
+            if self.request.method == 'POST':
+                form = ThreadForm(self.thread, self.post, self.request.POST)
+            else:
+                form = ThreadForm(self.thread, self.post, initial=initial_data)
+        else:
+            if self.request.method == 'POST':
+                form = ReplyForm(self.post, self.request.POST)
+            else:
+                form = ReplyForm(self.post, initial=initial_data)
+
+        form.post_editor = Editor(form['post'])
+        return form
+
+    def save(self, form):
+        if self.mode == START:
+            self.thread.set_title(form.cleaned_data['title'])
+            self.thread.starter_name = '-'
+            self.thread.starter_slug = '-'
+            self.thread.last_poster_name = '-'
+            self.thread.last_poster_slug = '-'
+            self.thread.started_on = self.datetime
+            self.thread.last_post_on = self.datetime
+            self.thread.save()
+
+        self.post.updated_on = self.datetime
+        if self.mode == EDIT:
+            self.post.last_editor_name = self.user
+            self.post.poster_name = self.user.username
+            self.post.poster_slug = self.user.slug
+        else:
+            self.post.thread = self.thread
+            self.post.poster = self.user
+            self.post.poster_name = self.user.username
+            self.post.poster_ip = self.request._misago_real_ip
+            self.post.posted_on = self.datetime
+
+        self.post.post_checksum = update_post_checksum(self.post)
+        self.post.save()
+
+        if self.mode == START:
+            self.forum.threads += 1
+            self.thread.set_first_post(self.post)
+
+        if self.mode != EDIT:
+            self.thread.set_last_post(self.post)
+        if self.mode != REPLY:
+            self.thread.replies += 1
+        self.thread.save()
+
+        if self.mode != EDIT:
+            self.forum.set_last_thread(self.thread)
+            self.forum.posts += 1
+            self.forum.save()

+ 1 - 1
misago/threads/migrations/0001_initial.py

@@ -61,7 +61,7 @@ class Migration(migrations.Migration):
             fields=[
                 ('id', models.AutoField(verbose_name='ID', serialize=False, auto_created=True, primary_key=True)),
                 ('weight', models.PositiveIntegerField(default=0)),
-                ('name', models.CharField(max_length=255)),
+                ('title', models.CharField(max_length=255)),
                 ('slug', models.SlugField(max_length=255)),
                 ('replies', models.PositiveIntegerField(default=0)),
                 ('has_reported_posts', models.BooleanField(default=False)),

+ 75 - 0
misago/threads/migrations/0002_threads_settings.py

@@ -0,0 +1,75 @@
+# -*- coding: utf-8 -*-
+from __future__ import unicode_literals
+
+from django.db import models, migrations
+from django.utils.translation import ugettext as _
+
+from misago.conf.migrationutils import migrate_settings_group
+
+
+def create_threads_settings_group(apps, schema_editor):
+    migrate_settings_group(
+        apps,
+        {
+            'key': 'threads',
+            'name': _("Threads"),
+            'description': _("Those settings control threads and posts."),
+            'settings': (
+                {
+                    'setting': 'thread_title_length_min',
+                    'name': _("Minimum length"),
+                    'description': _("Minimum allowed thread title length."),
+                    'legend': _("Thread titles"),
+                    'python_type': 'int',
+                    'value': 8,
+                    'field_extra': {
+                        'min_value': 2,
+                        'max_value': 255,
+                    },
+                },
+                {
+                    'setting': 'thread_title_length_max',
+                    'name': _("Maximum length"),
+                    'description': _("Maximum allowed thread length."),
+                    'python_type': 'int',
+                    'value': 40,
+                    'field_extra': {
+                        'min_value': 2,
+                        'max_value': 255,
+                    },
+                },
+                {
+                    'setting': 'post_length_min',
+                    'name': _("Minimum length"),
+                    'description': _("Minimum allowed user post length."),
+                    'legend': _("Posts"),
+                    'python_type': 'int',
+                    'value': 5,
+                    'field_extra': {
+                        'min_value': 1,
+                    },
+                },
+                {
+                    'setting': 'post_length_max',
+                    'name': _("Maximum length"),
+                    'description': _("Maximum allowed user post length. Enter zero to disable"),
+                    'python_type': 'int',
+                    'value': 60000,
+                    'field_extra': {
+                        'min_value': 0,
+                    },
+                },
+            )
+        })
+
+
+class Migration(migrations.Migration):
+
+    dependencies = [
+        ('misago_threads', '0001_initial'),
+        ('misago_conf', '0001_initial'),
+    ]
+
+    operations = [
+        migrations.RunPython(create_threads_settings_group),
+    ]

+ 6 - 0
misago/threads/models/post.py

@@ -2,6 +2,8 @@ from django.db import models
 
 from misago.conf import settings
 
+from misago.threads.checksums import is_post_valid
+
 
 class Post(models.Model):
     forum = models.ForeignKey('misago_forums.Forum')
@@ -29,3 +31,7 @@ class Post(models.Model):
     is_moderated = models.BooleanField(default=False)
     is_hidden = models.BooleanField(default=False)
     is_protected = models.BooleanField(default=False)
+
+    @property
+    def is_valid(self):
+        return is_post_valid(self)

+ 28 - 2
misago/threads/models/thread.py

@@ -1,14 +1,16 @@
 from django.db import models
 
 from misago.conf import settings
+from misago.core.utils import slugify
 
 
 class Thread(models.Model):
     forum = models.ForeignKey('misago_forums.Forum')
     weight = models.PositiveIntegerField(default=0)
-    prefix = models.ForeignKey('misago_threads.Prefix', null=True, blank=True,
+    prefix = models.ForeignKey('misago_threads.Prefix',
+                               null=True, blank=True,
                                on_delete=models.SET_NULL)
-    name = models.CharField(max_length=255)
+    title = models.CharField(max_length=255)
     slug = models.SlugField(max_length=255)
     replies = models.PositiveIntegerField(default=0)
     has_reported_posts = models.BooleanField(default=False)
@@ -36,3 +38,27 @@ class Thread(models.Model):
     is_moderated = models.BooleanField(default=False)
     is_hidden = models.BooleanField(default=False)
     is_closed = models.BooleanField(default=False)
+
+    def set_title(self, title):
+        self.title = title
+        self.slug = slugify(title)
+
+    def set_first_post(self, post):
+        self.started_on = post.posted_on
+        self.first_post = post
+        self.starter = post.poster
+        self.starter_name = post.poster_name
+        if post.poster:
+            self.starter_slug = post.poster.slug
+        else:
+            self.starter_slug = slugify(post.poster_name)
+
+    def set_last_post(self, post):
+        self.last_post_on = post.posted_on
+        self.last_post = post
+        self.last_poster = post.poster
+        self.last_poster_name = post.poster_name
+        if post.poster:
+            self.last_poster_slug = post.poster.slug
+        else:
+            self.last_poster_slug = slugify(post.poster_name)

+ 13 - 7
misago/threads/permissions.py

@@ -113,11 +113,17 @@ def add_acl_to_post(user, post):
 ACL tests
 """
 def allow_see_thread(user, target):
-    try:
-        forum_id = target.pk
-    except AttributeError:
-        forum_id = int(target)
-
-    if not forum_id in user.acl['visible_forums']:
-        raise Http404()
+    raise NotImplementedError()
 can_see_thread = return_boolean(allow_see_thread)
+
+
+def allow_start_thread(user, target):
+    if target.is_closed:
+        message = _("This forum is closed. You can't start new threads in it.")
+        raise PermissionDenied(message)
+    if user.is_anonymous():
+        raise PermissionDenied(_("You have to sign in to start new thread."))
+    if not user.acl['forums'].get(target.id, {'can_start_threads': False}):
+        raise PermissionDenied(_("You don't have permission to start "
+                                 "new threads in this forum."))
+can_start_thread = return_boolean(allow_start_thread)

+ 3 - 1
misago/threads/urls.py

@@ -1,9 +1,11 @@
 from django.conf.urls import patterns, include, url
 
-from misago.threads.views.threads import ForumView, ThreadView
+from misago.threads.views.threads import (ForumView, ThreadView, StartThreadView,
+                                          ReplyView, EditView)
 
 
 urlpatterns = patterns('',
     url(r'^forum/(?P<forum_slug>[\w\d-]+)-(?P<forum_id>\d+)/$', ForumView.as_view(), name='forum'),
     url(r'^forum/(?P<forum_slug>[\w\d-]+)-(?P<forum_id>\d+)/(?P<page>\d+)/$', ForumView.as_view(), name='forum'),
+    url(r'^forum/(?P<forum_slug>[\w\d-]+)-(?P<forum_id>\d+)/start-thread/$', StartThreadView.as_view(), name='start_thread'),
 )

+ 120 - 6
misago/threads/views/generic.py

@@ -1,7 +1,8 @@
 """
 Module with basic views for use by inheriting actions
 """
-from django.shortcuts import render
+from django.db.transaction import atomic
+from django.shortcuts import redirect, render
 from django.views.generic import View
 
 from misago.acl import add_acl
@@ -10,13 +11,17 @@ from misago.forums.lists import get_forums_list, get_forum_path
 from misago.forums.models import Forum
 from misago.forums.permissions import allow_see_forum, allow_browse_forum
 
+from misago.threads.forms.posting import EditorFormset, START, REPLY, EDIT
+from misago.threads.models import Thread, Post
+from misago.threads.permissions import allow_see_thread, allow_start_thread
+
 
 class ForumMixin(object):
     """
     Mixin for getting forums
     """
-    def get_forum(self, request, **kwargs):
-        forum = self.fetch_forum(request, **kwargs)
+    def get_forum(self, request, lock=False, **kwargs):
+        forum = self.fetch_forum(request, lock, **kwargs)
         self.check_forum_permissions(request, forum)
 
         if kwargs.get('forum_slug'):
@@ -24,9 +29,13 @@ class ForumMixin(object):
 
         return forum
 
-    def fetch_forum(self, request, **kwargs):
+    def fetch_forum(self, request, lock=False, **kwargs):
+        queryset = Forum.objects
+        if lock:
+            queryset = queryset.select_for_update()
+
         return get_object_or_404(
-            Forum, id=kwargs.get('forum_id'), role='forum')
+            queryset, id=kwargs.get('forum_id'), role='forum')
 
     def check_forum_permissions(self, request, forum):
         add_acl(request.user, forum)
@@ -34,6 +43,31 @@ class ForumMixin(object):
         allow_browse_forum(request.user, forum)
 
 
+class ThreadMixin(object):
+    """
+    Mixin for getting thread
+    """
+    def get_thread(self, request, lock=False, **kwargs):
+        thread = self.fetch_thread(request, lock, **kwargs)
+        self.check_thread_permissions(request, thread)
+
+        if kwargs.get('thread_slug'):
+            validate_slug(thread, kwargs.get('thread_slug'))
+
+        return thread
+
+    def fetch_thread(self, request, lock=False, **kwargs):
+        queryset = Thread.objects
+        if lock:
+            queryset = queryset.select_for_update()
+
+        return get_object_or_404(queryset, id=kwargs.get('thread_id'))
+
+    def check_thread_permissions(self, request, thread):
+        add_acl(request.user, thread)
+        allow_see_thread(request.user, thread)
+
+
 class ViewBase(ForumMixin, View):
     templates_dir = ''
     template = ''
@@ -99,4 +133,84 @@ class EditorView(ViewBase):
     """
     Basic view for starting/replying/editing
     """
-    pass
+    template = 'editor.html'
+
+    def find_mode(self, request, *args, **kwargs):
+        """
+        First step: guess from request what kind of view we are
+        """
+        is_post = request.method == 'POST'
+
+        if 'forum_id' in kwargs:
+            mode = START
+            user = request.user
+
+            forum = self.get_forum(request, lock=is_post, **kwargs)
+            thread = Thread(forum=forum, starter=user, last_poster=user)
+            post = Post(forum=forum, thread=thread, poster=user)
+            quote = Post(0)
+        elif 'thread_id' in kwargs:
+            thread = self.get_thread(request, lock=is_post, **kwargs)
+            forum = thread.forum
+
+        return mode, forum, thread, post, quote
+
+    def allow_mode(self, user, mode, forum, thread, post, quote):
+        """
+        Second step: check start/reply/edit permissions
+        """
+        if mode == START:
+            self.allow_start(user, forum)
+        if mode == REPLY:
+            self.allow_reply(user, forum, thread, quote)
+        if mode == EDIT:
+            self.allow_edit(user, forum, thread, post)
+
+    def allow_start(self, user, forum):
+        allow_start_thread(user, forum)
+
+    def allow_reply(self, user, forum, thread, quote):
+        raise NotImplementedError()
+
+    def allow_edit(self, user, forum, thread, post):
+        raise NotImplementedError()
+
+    def dispatch(self, request, *args, **kwargs):
+        if request.method == 'POST':
+            with atomic():
+                return self.real_dispatch(request, *args, **kwargs)
+        else:
+            return self.real_dispatch(request, *args, **kwargs)
+
+    def real_dispatch(self, request, *args, **kwargs):
+        mode_context = self.find_mode(request, *args, **kwargs)
+        self.allow_mode(request.user, *mode_context)
+
+        mode, forum, thread, post, quote = mode_context
+        formset = EditorFormset(request=request,
+                                mode=mode,
+                                user=request.user,
+                                forum=forum,
+                                thread=thread,
+                                post=post,
+                                quote=quote)
+
+        if request.method == 'POST':
+            if 'submit' in request.POST and formset.is_valid():
+                formset.save()
+                return redirect('misago:index')
+            else:
+                formset.update()
+
+        return self.render(request, {
+            'mode': mode,
+            'formset': formset,
+            'forms': formset.get_forms_list(),
+            'main_forms': formset.get_main_forms(),
+            'supporting_forms': formset.get_supporting_forms(),
+            'forum': forum,
+            'path': get_forum_path(forum),
+            'thread': thread,
+            'post': post,
+            'quote': quote
+        })

+ 12 - 0
misago/threads/views/threads.py

@@ -11,3 +11,15 @@ class ForumView(ThreadsMixin, generic.ForumView):
 
 class ThreadView(ThreadsMixin, generic.ThreadView):
     pass
+
+
+class StartThreadView(ThreadsMixin, generic.EditorView):
+    pass
+
+
+class ReplyView(ThreadsMixin, generic.EditorView):
+    pass
+
+
+class EditView(ThreadsMixin, generic.EditorView):
+    pass