Browse Source

Add/Remove participants

Rafał Pitoń 10 years ago
parent
commit
3b0b88ae72

+ 7 - 0
misago/static/misago/js/misago-posting-participants.js

@@ -43,6 +43,13 @@ Misago.Participants = function($e) {
 
   }
 
+  this.clear = function() {
+
+    this.$users.find('li').remove();
+    this.$value.val("");
+
+  }
+
   this.$input.typeahead({
       minLength: 2,
       hint: false,

+ 84 - 1
misago/static/misago/js/misago-thread-participants.js

@@ -6,6 +6,7 @@ MisagoThreadParticipants = function() {
 
       this.$container = null;
       this.max_participants = 1;
+      this.deleted_ids = [];
 
     }
 
@@ -15,7 +16,8 @@ MisagoThreadParticipants = function() {
 
       this.$users = $container.find('ul');
       this.$message = $container.find('.message');
-      this.$form = $container.find('.invite-form');
+      this.$error = $container.find('.text-danger');
+      this.$form = $container.find('form');
 
       this.max_participants = parseInt($container.data('max-participants'));
 
@@ -29,13 +31,43 @@ MisagoThreadParticipants = function() {
       }
 
       // suppress default submission handling
+
       this.$container.find('form').submit(function(e) {
+
         e.preventDefault();
         return false;
+
       })
 
       this.participants = new Misago.Participants(this.$form.find('.thread-participants-input'));
 
+      // buttons
+
+      this.$container.find('.btn-add-participants').click(function() {
+
+        _this.add($(this).data('add-url'));
+
+      })
+
+      this.$container.find('.btn-remove-participant').click(function() {
+
+        var $participant = $(this).parents('li.participant');
+        _this.remove($participant, $(this).data('remove-url'));
+
+      })
+
+    }
+
+    this.update_list = function(new_html) {
+
+      this.$users.html(new_html);
+      this.$container.find('.btn-remove-participant').click(function() {
+
+        var $participant = $(this).parents('li.participant');
+        _this.remove($participant, $(this).data('remove-url'));
+
+      })
+
     }
 
     this.update_form_visibility = function() {
@@ -62,6 +94,57 @@ MisagoThreadParticipants = function() {
 
     }
 
+    this.add = function(api_url) {
+
+      $.post(api_url, this.$form.serialize(), function(data) {
+
+        if (data.is_error) {
+          _this.$error.text(data.message);
+          _this.$error.addClass('in');
+        } else {
+          _this.$error.removeClass('in');
+          $('.participants-message').text(data.message);
+          _this.update_list(data.list_html);
+          _this.participants.clear();
+        }
+
+        _this.update_form_visibility();
+
+      });
+
+    }
+
+    this.remove = function($participant, api_url) {
+
+      var participant_id = $participant.data('participant-id');
+
+      if (this.deleted_ids.indexOf(participant_id) == -1) {
+
+        this.deleted_ids.push(participant_id);
+
+        $participant.slideUp();
+
+        $.post(api_url, this.$form.serialize(), function(data) {
+
+          $participant.remove();
+          if (data.is_error) {
+            alert(data.message);
+          } else {
+            $('.participants-message').text(data.message);
+          }
+
+        }).fail(function() {
+
+          var deleted_id_pos = _this.deleted_ids.indexOf(participant_id);
+          _this.deleted_ids.splice(deleted_id_pos, 1);
+          $participant.slideDown();
+
+        });
+
+      }
+
+    }
+
 }
 
 

+ 6 - 14
misago/templates/misago/privatethreads/participants_modal.html

@@ -1,4 +1,4 @@
-{% load i18n misago_avatars %}
+{% load i18n %}
 <div class="modal-header">
   <button type="button" class="close" data-dismiss="modal"><span aria-hidden="true">&times;</span><span class="sr-only">{% trans "Close" %}</span></button>
   <h4 class="modal-title" id="ajaxModalLabel">
@@ -11,17 +11,7 @@
   <h4>{% trans "Thread participants" %}</h4>
 
   <ul class="list-unstyled users-list-compact">
-    {% for participant in participants %}
-    <li>
-      <a href="{{ participant.user.get_absolute_url }}"><img src="{{ participant.user|avatar:100 }}" alt="{% trans "Avatar" %}" class="user-avatar"></a>
-      <a href="{{ participant.user.get_absolute_url }}" class="item-title">{{ participant.user }}</a>
-      {% if participant.user != user %}
-      <button type="button" class="btn btn-default btn-sm pull-right btn-remove-participant" data-remove-url="">
-        {% trans "Remove" %}
-      </button>
-      {% endif %}
-    </li>
-    {% endfor %}
+    {% include "misago/privatethreads/participants_modal_list.html" %}
   </ul>
 
   <h4>{% trans "Add participants" %}</h4>
@@ -30,20 +20,22 @@
     {% trans "You can't add more participants to this thread." %}
   </p>
 
+  <p class="text-danger fade"></p>
+
   <form method="POST">
     {% csrf_token %}
     <div class="invite-form">
 
       <div class="input">
         <div class="thread-participants-input">
-          <input type="hidden">
+          <input type="hidden" name="users">
           <ul class="list-unstyled users-list"></ul>
           <input class="textinput textInput form-control user-input" type="text" placeholder="{% trans "User to message..." %}" data-api-url="{% url 'misago:api_suggestion_engine' %}">
         </div>
       </div>
 
       <div class="button">
-        <button type="button" class="btn btn-primary">
+        <button type="button" class="btn btn-primary btn-add-participants" data-add-url="{% url 'misago:private_thread_add_participants' thread_slug=thread.slug thread_id=thread.id %}">
           {% trans "Add" %}
         </button>
       </div>

+ 12 - 0
misago/templates/misago/privatethreads/participants_modal_list.html

@@ -0,0 +1,12 @@
+{% load i18n misago_avatars %}
+{% for participant in participants %}
+<li class="participant" data-participant-id="{{ participant.id }}" data-user-id="{{ participant.user.id }}">
+  <a href="{{ participant.user.get_absolute_url }}"><img src="{{ participant.user|avatar:100 }}" alt="{% trans "Avatar" %}" class="user-avatar"></a>
+  <a href="{{ participant.user.get_absolute_url }}" class="item-title">{{ participant.user }}</a>
+  {% if participant.user != user %}
+  <button type="button" class="btn btn-default btn-sm pull-right btn-remove-participant" data-remove-url="{% url 'misago:private_thread_remove_participant' thread_slug=thread.slug thread_id=thread.id user_id=participant.user.id %}">
+    {% trans "Remove" %}
+  </button>
+  {% endif %}
+</li>
+{% endfor %}

+ 28 - 2
misago/templates/misago/privatethreads/thread.html

@@ -6,11 +6,11 @@
 {{ block.super }}
 <li>
   <button type="button" class="btn-show-participants" data-participants-url="{% url 'misago:private_thread_participants' thread_slug=thread.slug thread_id=thread.id %}">
-    <span class="fa fa-users"></span> {% blocktrans trimmed count users=thread.participants_list|length %}
+    <span class="fa fa-users"></span> <span class="participants-message">{% blocktrans trimmed count users=thread.participants_list|length %}
       {{ users }} participant
     {% plural %}
       {{ users }} participants
-    {% endblocktrans %}
+    {% endblocktrans %}</span>
   </button>
 <li>
 {% endblock page-details %}
@@ -28,6 +28,21 @@
 {% endblock thread-actions %}
 
 
+{% block post-actions %}
+{{ block.super }}
+
+{% if thread.participant %}
+<form action="{% url 'misago:private_thread_leave' thread_slug=thread.slug thread_id=thread.id %}" method="POST" class="leave-thread-form pull-right">
+  {% csrf_token %}
+  <button class="btn btn-default" type="submit">
+    <span class="fa fa-sign-out"></span>
+    {% trans "Leave thread" %}
+  </button>
+</form>
+{% endif %}
+{% endblock post-actions %}
+
+
 {% block javascripts %}
 {{ block.super }}
 <script lang="JavaScript">
@@ -58,6 +73,17 @@
 
     });
 
+    {% if thread.participant %}
+    $('.leave-thread-form').submit(function() {
+      {% if thread.participants_list|length == 1 %}
+      var prompt = confirm("{% trans "Are you sure you want to leave this thread? It will be deleted after you leave it." %}");
+      {% else %}
+      var prompt = confirm("{% trans "Are you sure you want to leave this thread?" %}");
+      {% endif %}
+      return prompt
+    });
+    {% endif %}
+
     {% if 'participants' in thread_actions %}
     $('.action-participants').click(function() {
       Misago.ParticipantsEditor.open({

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

@@ -2,4 +2,5 @@
 from misago.threads.models.label import *
 from misago.threads.models.post import *
 from misago.threads.models.thread import *
+from misago.threads.models.threadparticipant import *
 from misago.threads.models.event import *

+ 1 - 46
misago/threads/models/thread.py

@@ -1,23 +1,10 @@
-from django.core.urlresolvers import reverse
 from django.db import models, transaction
-from django.dispatch import receiver
 
 from misago.conf import settings
-from misago.core.shortcuts import paginate
 from misago.core.utils import slugify
 
 
-__all__ = [
-    'Thread',
-    'ThreadParticipant'
-]
-
-
-class PrivateThreadMixin(object):
-    pass
-
-
-class Thread(models.Model, PrivateThreadMixin):
+class Thread(models.Model):
     forum = models.ForeignKey('misago_forums.Forum')
     label = models.ForeignKey('misago_threads.Label',
                               null=True, blank=True,
@@ -176,35 +163,3 @@ class Thread(models.Model, PrivateThreadMixin):
             self.last_poster_slug = post.poster.slug
         else:
             self.last_poster_slug = slugify(post.poster_name)
-
-
-class ThreadParticipantManager(models.Manager):
-    def remove_participant(self, thread, user):
-        ThreadParticipant.objects.filter(thread=thread, user=user).delete()
-
-    @transaction.atomic
-    def set_owner(self, thread, user):
-        thread_owner = ThreadParticipant.objects.filter(
-            thread=thread, is_owner=True)
-        thread_owner.update(is_owner=False)
-
-        self.remove_participant(thread, user)
-        ThreadParticipant.objects.create(
-            thread=thread,
-            user=user,
-            is_owner=True)
-
-    @transaction.atomic
-    def add_participant(self, thread, user):
-        self.remove_participant(thread, user)
-        ThreadParticipant.objects.create(
-            thread=thread,
-            user=user,)
-
-
-class ThreadParticipant(models.Model):
-    thread = models.ForeignKey(Thread)
-    user = models.ForeignKey(settings.AUTH_USER_MODEL)
-    is_owner = models.BooleanField(default=False)
-
-    objects = ThreadParticipantManager()

+ 35 - 0
misago/threads/models/threadparticipant.py

@@ -0,0 +1,35 @@
+from django.db import models, transaction
+
+from misago.conf import settings
+
+
+class ThreadParticipantManager(models.Manager):
+    def remove_participant(self, thread, user):
+        ThreadParticipant.objects.filter(thread=thread, user=user).delete()
+
+    @transaction.atomic
+    def set_owner(self, thread, user):
+        thread_owner = ThreadParticipant.objects.filter(
+            thread=thread, is_owner=True)
+        thread_owner.update(is_owner=False)
+
+        self.remove_participant(thread, user)
+        ThreadParticipant.objects.create(
+            thread=thread,
+            user=user,
+            is_owner=True)
+
+    @transaction.atomic
+    def add_participant(self, thread, user, is_owner=False):
+        ThreadParticipant.objects.create(
+            thread=thread,
+            user=user,
+            is_owner=is_owner)
+
+
+class ThreadParticipant(models.Model):
+    thread = models.ForeignKey('misago_threads.Thread')
+    user = models.ForeignKey(settings.AUTH_USER_MODEL)
+    is_owner = models.BooleanField(default=False)
+
+    objects = ThreadParticipantManager()

+ 45 - 0
misago/threads/participants.py

@@ -0,0 +1,45 @@
+from misago.threads.models import ThreadParticipant
+
+
+def make_thread_participants_aware(user, thread):
+    thread.participants_list = []
+    thread.participant = None
+
+    participants_qs = ThreadParticipant.objects.filter(thread=thread)
+    participants_qs = participants_qs.select_related('user')
+    for participant in participants_qs.order_by('-is_owner', 'user__slug'):
+        participant.thread = thread
+        thread.participants_list.append(participant)
+        if participant.user == user:
+            thread.participant = participant
+    return thread.participants_list
+
+
+def thread_has_participants(thread):
+    return thread.threadparticipant_set.exists()
+
+
+def set_thread_owner(thread, user):
+    ThreadParticipant.objects.set_thread_owner(thread, user)
+
+
+def sync_user_unread_private_threads(user):
+    user.sync_unread_private_threads = True
+    user.save(update_fields=['sync_unread_private_threads'])
+
+
+def add_participant(request, thread, user, is_owner=False):
+    """
+    Add participant to thread, set "recound private threads" flag on user,
+    notify user about being added to thread and mail him about it
+    """
+    ThreadParticipant.objects.add_participant(thread, user, is_owner)
+    sync_user_unread_private_threads(user)
+
+
+def remove_participant(thread, user):
+    """
+    Remove thread participant, set "recound private threads" flag on user
+    """
+    thread.threadparticipant_set.filter(user=user).delete()
+    sync_user_unread_private_threads(user)

+ 3 - 3
misago/threads/posting/participants.py

@@ -1,6 +1,6 @@
 from misago.threads.forms.posting import ThreadParticipantsForm
 from misago.threads.posting import PostingMiddleware, START
-from misago.threads.models import ThreadParticipant
+from misago.threads.participants import add_participant
 
 
 class ThreadParticipantsFormMiddleware(PostingMiddleware):
@@ -15,6 +15,6 @@ class ThreadParticipantsFormMiddleware(PostingMiddleware):
             return ThreadParticipantsForm(prefix=self.prefix)
 
     def save(self, form):
-        ThreadParticipant.objects.set_owner(self.thread, self.user)
+        add_participant(self.request, self.thread, self.user, True)
         for user in form.users_cache:
-            ThreadParticipant.objects.add_participant(self.thread, user)
+            add_participant(self.request, self.thread, user)

+ 7 - 1
misago/threads/urls/privatethreads.py

@@ -41,10 +41,16 @@ urlpatterns += patterns('',
 
 # participants views
 from misago.threads.views.privatethreads import (ThreadParticipantsView,
-                                                 EditThreadParticipantsView)
+                                                 EditThreadParticipantsView,
+                                                 AddThreadParticipantsView,
+                                                 RemoveThreadParticipantView,
+                                                 LeaveThreadView)
 urlpatterns += patterns('',
     url(r'^private-thread/(?P<thread_slug>[\w\d-]+)-(?P<thread_id>\d+)/participants/$', ThreadParticipantsView.as_view(), name='private_thread_participants'),
     url(r'^private-thread/(?P<thread_slug>[\w\d-]+)-(?P<thread_id>\d+)/edit-participants/$', EditThreadParticipantsView.as_view(), name='private_thread_edit_participants'),
+    url(r'^private-thread/(?P<thread_slug>[\w\d-]+)-(?P<thread_id>\d+)/remove-participant/(?P<user_id>\d+)/$', RemoveThreadParticipantView.as_view(), name='private_thread_remove_participant'),
+    url(r'^private-thread/(?P<thread_slug>[\w\d-]+)-(?P<thread_id>\d+)/add-participants/$', AddThreadParticipantsView.as_view(), name='private_thread_add_participants'),
+    url(r'^private-thread/(?P<thread_slug>[\w\d-]+)-(?P<thread_id>\d+)/leave/$', LeaveThreadView.as_view(), name='private_thread_leave'),
 )
 
 

+ 191 - 42
misago/threads/views/privatethreads.py

@@ -1,11 +1,17 @@
 from django.contrib import messages
-from django.http import Http404
-from django.shortcuts import get_object_or_404, render
-from django.utils.translation import ugettext as _
+from django.contrib.auth import get_user_model
+from django.db.transaction import atomic
+from django.http import Http404, JsonResponse
+from django.shortcuts import get_object_or_404, redirect, render
+from django.utils.translation import ugettext as _, ungettext
 
 from misago.acl import add_acl
+from misago.core.exceptions import AjaxError
 from misago.forums.models import Forum
 
+from misago.threads import participants
+from misago.threads.events import record_event
+from misago.threads.forms.posting import ThreadParticipantsForm
 from misago.threads.models import Thread, ThreadParticipant
 from misago.threads.permissions import (allow_use_private_threads,
                                         allow_see_private_thread,
@@ -57,24 +63,11 @@ class PrivateThreadsMixin(object):
             raise Http404()
         return thread
 
-    def fetch_thread_participants(self, user, thread):
-        thread.participants_list = []
-        thread.participant = None
-
-        participants_qs = ThreadParticipant.objects.filter(thread=thread)
-        participants_qs = participants_qs.select_related('user')
-        for participant in participants_qs:
-            participant.thread = thread
-            thread.participants_list.append(participant)
-            if participant.user == user:
-                thread.participant = participant
-        return thread.participants_list
-
     def check_thread_permissions(self, request, thread):
         add_acl(request.user, thread.forum)
         add_acl(request.user, thread)
 
-        self.fetch_thread_participants(request.user, thread)
+        participants.make_thread_participants_aware(request.user, thread)
 
         allow_see_private_thread(request.user, thread)
         allow_use_private_threads(request.user)
@@ -84,7 +77,7 @@ class PrivateThreadsMixin(object):
         add_acl(request.user, post.thread)
         add_acl(request.user, post)
 
-        self.fetch_thread_participants(request.user, post.thread)
+        participants.make_thread_participants_aware(request.user, thread)
 
         allow_see_private_post(request.user, post)
         allow_see_private_thread(request.user, post.thread)
@@ -123,26 +116,6 @@ class PrivateThreadsView(generic.ThreadsView):
     Filtering = PrivateThreadsFiltering
 
 
-@private_threads_view
-class ThreadParticipantsView(PrivateThreadsMixin, generic.ViewBase):
-    template = 'misago/privatethreads/participants.html'
-
-    def dispatch(self, request, *args, **kwargs):
-        thread = self.get_thread(request, **kwargs)
-
-        if not request.is_ajax():
-            response = render(request, 'misago/errorpages/wrong_way.html')
-            response.status_code = 405
-            return response
-
-        participants_qs = thread.threadparticipant_set
-        participants_qs = participants_qs.select_related('user')
-
-        return self.render(request, {
-            'participants': participants_qs.order_by('-is_owner', 'user__slug')
-        })
-
-
 class PrivateThreadActions(generic.ThreadActions):
     def get_available_actions(self, kwargs):
         user = kwargs['user']
@@ -196,7 +169,7 @@ class PrivateThreadActions(generic.ThreadActions):
         return actions
 
     def action_takeover(self, request, thread):
-        ThreadParticipant.objects.set_owner(thread, request.user)
+        participants.set_thread_owner(thread, request.user)
         messages.success(request, _("You are now owner of this thread."))
 
 
@@ -207,8 +180,8 @@ class ThreadView(PrivateThreadsMixin, generic.ThreadView):
 
 
 @private_threads_view
-class EditThreadParticipantsView(PrivateThreadsMixin, generic.ViewBase):
-    template = 'misago/privatethreads/participants_modal.html'
+class ThreadParticipantsView(PrivateThreadsMixin, generic.ViewBase):
+    template = 'misago/privatethreads/participants.html'
 
     def dispatch(self, request, *args, **kwargs):
         thread = self.get_thread(request, **kwargs)
@@ -219,12 +192,188 @@ class EditThreadParticipantsView(PrivateThreadsMixin, generic.ViewBase):
             return response
 
         participants_qs = thread.threadparticipant_set
-        participants_qs = participants_qs.select_related('user')
+        participants_qs = participants_qs.select_related('user', 'user__rank')
 
         return self.render(request, {
+            'forum': thread.forum,
+            'thread': thread,
             'participants': participants_qs.order_by('-is_owner', 'user__slug')
         })
 
+
+@private_threads_view
+class EditThreadParticipantsView(ThreadParticipantsView):
+    template = 'misago/privatethreads/participants_modal.html'
+
+
+@private_threads_view
+class BaseEditThreadParticipantView(PrivateThreadsMixin, generic.ViewBase):
+    @atomic
+    def dispatch(self, request, *args, **kwargs):
+        thread = self.get_thread(request, lock=True, **kwargs)
+
+        if not request.is_ajax():
+            response = render(request, 'misago/errorpages/wrong_way.html')
+            response.status_code = 405
+            return response
+
+        if not request.method == "POST":
+            raise AjaxError(_("Wrong action received."))
+
+        if not thread.participant or not thread.participant.is_owner:
+            raise AjaxError(_("Only thread owner can add or "
+                              "remove participants from thread."))
+
+        return self.action(request, thread, kwargs)
+
+    def action(self, request, thread, kwargs):
+        raise NotImplementedError("views extending EditThreadParticipantView "
+                                  "need to define custom action method")
+
+
+@private_threads_view
+class AddThreadParticipantsView(BaseEditThreadParticipantView):
+    template = 'misago/privatethreads/participants_modal_list.html'
+
+    def action(self, request, thread, kwargs):
+        form = ThreadParticipantsForm(request.POST, user=request.user)
+        if not form.is_valid():
+            errors = []
+            for field_errors in form.errors.as_data().values():
+                errors.extend([unicode(e[0]) for e in field_errors])
+            return JsonResponse({'message': errors[0], 'is_error': True})
+
+        event_message = _("%(user)s added %(participant)s to this thread.")
+        participants_list = [p.user for p in thread.participants_list]
+        for user in form.users_cache:
+            if user not in participants_list:
+                participants.add_participant(request, thread, user)
+                record_event(request.user, thread, 'user', event_message, {
+                    'user': request.user,
+                    'participant': user
+                })
+                thread.save(update_fields=['has_events'])
+
+        participants_qs = thread.threadparticipant_set
+        participants_qs = participants_qs.select_related('user', 'user__rank')
+        participants_qs = participants_qs.order_by('-is_owner', 'user__slug')
+
+        participants_list = [p for p in participants_qs]
+
+        participants_list_html = self.render(request, {
+            'forum': thread.forum,
+            'thread': thread,
+            'participants': participants_list,
+        }).content
+
+        message = ungettext("%(users)s participant",
+                            "%(users)s participants",
+                            len(participants_list))
+        message = message % {'users': len(participants_list)}
+
+        return JsonResponse({
+            'is_error': False,
+            'message': message,
+            'list_html': participants_list_html
+        })
+
+
+@private_threads_view
+class RemoveThreadParticipantView(BaseEditThreadParticipantView):
+    def action(self, request, thread, kwargs):
+        user_qs = thread.threadparticipant_set.select_related('user')
+        try:
+            participant = user_qs.get(user_id=kwargs['user_id'])
+        except ThreadParticipant.DoesNotExist:
+            return JsonResponse({
+                'message': _("Requested participant couldn't be found."),
+                'is_error': True,
+            })
+
+        if participant.user == request.user:
+            return JsonResponse({
+                'message': _('To leave thread use "Leave thread" option.'),
+                'is_error': True,
+            })
+
+        participants_count = len(thread.participants_list) - 1
+        if participants_count == 0:
+            return JsonResponse({
+                'message': _("You can't remove last thread participant."),
+                'is_error': True,
+            })
+
+        participants.remove_participant(thread, participant.user)
+        if not participants.thread_has_participants(thread):
+            thread.delete()
+        else:
+            message = _("%(user)s removed %(participant)s from this thread.")
+            record_event(request.user, thread, 'user', message, {
+                'user': request.user,
+                'participant': participant.user
+            })
+            thread.save(update_fields=['has_events'])
+
+        participants_count = len(thread.participants_list) - 1
+        message = ungettext("%(users)s participant",
+                            "%(users)s participants",
+                            participants_count)
+        message = message % {'users': participants_count}
+
+        return JsonResponse({'is_error': False, 'message': message})
+
+
+@private_threads_view
+class LeaveThreadView(BaseEditThreadParticipantView):
+    @atomic
+    def dispatch(self, request, *args, **kwargs):
+        thread = self.get_thread(request, lock=True, **kwargs)
+
+        try:
+            if not request.method == "POST":
+                raise RuntimeError(_("Wrong action received."))
+            if not thread.participant:
+                raise RuntimeError(_("You have to be thread participant in "
+                                  "order to be able to leave thread."))
+
+            user_qs = thread.threadparticipant_set.select_related('user')
+            try:
+                participant = user_qs.get(user_id=request.user.id)
+            except ThreadParticipant.DoesNotExist:
+                raise RuntimeError(_("You need to be thread "
+                                     "participant to leave it."))
+        except RuntimeError as e:
+            messages.error(request, unicode(e))
+            return redirect(thread.get_absolute_url())
+
+        participants.remove_participant(thread, user)
+        if not thread.threadparticipant_set.exists():
+            thread.delete()
+        elif thread.participant.is_owner:
+            new_owner = user_qs.order_by('id')[:1][0].user
+
+            message = _("%(user)s left this thread. "
+                        "%(new_owner)s is now thread owner.")
+            record_event(request.user, thread, 'user', message, {
+                'user': request.user,
+                'new_owner': new_owner
+            })
+            thread.save(update_fields=['has_events'])
+
+            participants.set_thread_owner(thread, request.user)
+        else:
+            message = _("%(user)s left this thread.")
+            record_event(request.user, thread, 'user', message, {
+                'user': request.user,
+            })
+            thread.save(update_fields=['has_events'])
+
+        message = _('You have left "%(thread)s" thread.')
+        message = message % {'thread': thread.title}
+        messages.info(request, message)
+        return redirect('misago:private_threads')
+
+
 """
 Generics
 """

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

@@ -57,7 +57,7 @@ class Migration(migrations.Migration):
                 ('new_notifications', models.PositiveIntegerField(default=0)),
                 ('limits_private_thread_invites_to', models.PositiveIntegerField(default=0)),
                 ('unread_private_threads', models.PositiveIntegerField(default=0)),
-                ('sync_unred_private_threads', models.BooleanField(default=False)),
+                ('sync_unread_private_threads', models.BooleanField(default=False)),
                 ('subscribe_to_started_threads', models.PositiveIntegerField(default=0)),
                 ('subscribe_to_replied_threads', models.PositiveIntegerField(default=0)),
                 ('threads', models.PositiveIntegerField(default=0)),

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

@@ -226,7 +226,7 @@ class User(AbstractBaseUser, PermissionsMixin):
     limits_private_thread_invites_to = models.PositiveIntegerField(
         default=LIMITS_PRIVATE_THREAD_INVITES_TO_NONE)
     unread_private_threads = models.PositiveIntegerField(default=0)
-    sync_unred_private_threads = models.BooleanField(default=False)
+    sync_unread_private_threads = models.BooleanField(default=False)
 
     subscribe_to_started_threads = models.PositiveIntegerField(
         default=AUTO_SUBSCRIBE_NONE)