diff options
-rw-r--r-- | askbot/media/js/group_messaging.js | 53 | ||||
-rw-r--r-- | askbot/templates/group_messaging/email_alert.html | 13 | ||||
-rw-r--r-- | askbot/templates/group_messaging/senders_list.html | 7 | ||||
-rw-r--r-- | askbot/templates/group_messaging/threads_list.html | 5 | ||||
-rw-r--r-- | askbot/templates/user_inbox/base.html | 4 | ||||
-rw-r--r-- | askbot/templates/user_inbox/messages.html | 11 | ||||
-rw-r--r-- | group_messaging/models.py | 124 | ||||
-rw-r--r-- | group_messaging/tests.py | 167 | ||||
-rw-r--r-- | group_messaging/urls.py | 5 | ||||
-rw-r--r-- | group_messaging/views.py | 66 |
10 files changed, 402 insertions, 53 deletions
diff --git a/askbot/media/js/group_messaging.js b/askbot/media/js/group_messaging.js index 2522f72e..08d9056e 100644 --- a/askbot/media/js/group_messaging.js +++ b/askbot/media/js/group_messaging.js @@ -331,6 +331,14 @@ var ThreadHeading = function() { }; inherits(ThreadHeading, SimpleControl); +ThreadHeading.prototype.setParent = function(elem) { + this._threadsList = elem; +}; + +ThreadHeading.prototype.getParent = function() { + return this._threadsList; +}; + ThreadHeading.prototype.getId = function() { return this._id; }; @@ -338,6 +346,12 @@ ThreadHeading.prototype.getId = function() { ThreadHeading.prototype.decorate = function(element) { this._element = element; this._id = element.data('threadId'); + var deleter = element.find('.delete-or-restore'); + var me = this; + setupButtonEventHandlers($(deleter), function() { + me.getParent().deleteOrRestoreThread(me.getId()); + return false; + }); }; /** @@ -359,12 +373,9 @@ ThreadsList.prototype.getOpenThreadHandler = function(threadId) { }; }; -ThreadsList.prototype.setHTML = function(html) { - $.each(this._threads, function(idx, thread) { - thread.dispose(); - }); - this._element.html(html); - this.decorate(this._element); +ThreadsList.prototype.deleteOrRestoreThread = function(threadId) { + var ctr = this._messageCenter; + ctr.deleteOrRestoreThread(threadId, this._senderId); }; ThreadsList.prototype.decorate = function(element) { @@ -374,12 +385,14 @@ ThreadsList.prototype.decorate = function(element) { var threads = []; $.each(headingElements, function(idx, headingElement) { var heading = new ThreadHeading(); + heading.setParent(me); heading.decorate($(headingElement)); var threadId = heading.getId(); heading.setHandler(me.getOpenThreadHandler(threadId)); threads.push(heading); }); this._threads = threads; + this._senderId = element.data('senderId'); } @@ -604,25 +617,43 @@ MessageCenter.prototype.openThread = function(threadId) { }); }; -MessageCenter.prototype.loadThreadsForSender = function(senderId) { +MessageCenter.prototype.setThreadsList = function(list) { + this._threadsList = list; + this._secondCol.prepend(list.getElement()); +}; + +MessageCenter.prototype.hitThreadsList = function(url, senderId, requestMethod) { var threadsList = this._threadsList; - var url = this._urls['getThreads']; - me = this; + var me = this; $.ajax({ - type: 'GET', + type: requestMethod, dataType: 'json', url: url, cache: false, data: {sender_id: senderId}, success: function(data) { if (data['success']) { - threadsList.setHTML(data['html']); + threadsList.dispose(); + var threads = new ThreadsList(); + threads.setMessageCenter(me); + threads.decorate($(data['html'])); + me.setThreadsList(threads); me.setState('show-list'); } } }); }; +MessageCenter.prototype.deleteOrRestoreThread = function(threadId, senderId) { + var url = this._urls['getThreads'] + threadId + '/delete-or-restore/'; + this.hitThreadsList(url, senderId, 'POST'); +}; + +MessageCenter.prototype.loadThreadsForSender = function(senderId) { + var url = this._urls['getThreads']; + this.hitThreadsList(url, senderId, 'GET'); +}; + MessageCenter.prototype.decorate = function(element) { this._element = element; this._firstCol = element.find('.first-col'); diff --git a/askbot/templates/group_messaging/email_alert.html b/askbot/templates/group_messaging/email_alert.html new file mode 100644 index 00000000..90fea52b --- /dev/null +++ b/askbot/templates/group_messaging/email_alert.html @@ -0,0 +1,13 @@ +{% extends "email/base_mail.html"%} +{% from "email/macros.html" import start_quote, end_quote %} +{% set level = 0 %} +{% for message in messages %} + {{ start_quote(level) }} + <p>{% trans author=message.sender %}{{ author }} wrote:{% endtrans %}</p> + {{ message.html|escape }} + {{ end_quote(level) }} + {% set level = level + 1 %} +{% endfor %} +{% block footer %} +{% include "email/footer.html" %} +{% endblock %} diff --git a/askbot/templates/group_messaging/senders_list.html b/askbot/templates/group_messaging/senders_list.html index 687cacd6..4bee2626 100644 --- a/askbot/templates/group_messaging/senders_list.html +++ b/askbot/templates/group_messaging/senders_list.html @@ -1,8 +1,3 @@ -{#<ul class="mailboxes"> - <li><a class="inbox selected">{% trans %}Inbox{% endtrans %}</a></li> - <li><a class="sent">{% trans %}Sent{% endtrans %}</a></li> - <li><a class="trash">{% trans %}Trash{% endtrans %}</a></li> -</ul>#} {% if senders %} <ul class="senders-list"> <li>{% trans %}Messages by sender:{% endtrans %}</li> @@ -10,5 +5,7 @@ {% for sender in senders %} <li><a data-sender-id="{{ sender.id }}">{{ sender.username|escape }}</a></li> {% endfor %} + {# -2 is deleted messages #} + <li><a class="trash" data-sender-id="-2">{% trans %}trash{% endtrans %}</a></li> </ul> {% endif %} diff --git a/askbot/templates/group_messaging/threads_list.html b/askbot/templates/group_messaging/threads_list.html index bc0af802..43492402 100644 --- a/askbot/templates/group_messaging/threads_list.html +++ b/askbot/templates/group_messaging/threads_list.html @@ -1,10 +1,13 @@ -<table class="threads-list"> +<table class="threads-list {% if sender_id == -2 %}trash{% endif %}" + data-sender-id="{{ sender_id }}" +> {% if threads %} {% for thread in threads %} {% set thread_data = threads_data[thread.id] %} <tr class="thread-heading {{ thread_data['status'] }}" data-thread-id="{{ thread.id }}" > + <td class="delete-or-restore"></td> <td class="senders">{{ thread_data['senders_info']|escape }}</td> <td class="subject">{{ thread.headline|escape }}</td> <td class="timestamp">{{ thread.last_active_at|timesince }}</td> diff --git a/askbot/templates/user_inbox/base.html b/askbot/templates/user_inbox/base.html index 8beababc..890cb0f7 100644 --- a/askbot/templates/user_inbox/base.html +++ b/askbot/templates/user_inbox/base.html @@ -13,10 +13,10 @@ <div id="re_sections"> {% trans %}Sections:{% endtrans %} {% set sep = joiner('|') %} - {#{ sep() }} + {{ sep() }} <a href="{{request.user.get_absolute_url()}}?sort=inbox§ion=messages" {% if inbox_section == 'messages' %}class="on"{% endif %} - >{% trans %}messages{% endtrans %}</a>#} + >{% trans %}messages{% endtrans %}</a> {% if re_count > 0 %}{{ sep() }} <a href="{{request.user.get_absolute_url()}}?sort=inbox§ion=forum" {% if inbox_section == 'forum' %}class="on"{% endif %} diff --git a/askbot/templates/user_inbox/messages.html b/askbot/templates/user_inbox/messages.html index 5108d15e..8c731401 100644 --- a/askbot/templates/user_inbox/messages.html +++ b/askbot/templates/user_inbox/messages.html @@ -22,6 +22,7 @@ } table.threads-list { width: 100%; + border-spacing: 0px; } .threads-list tr { height: 2em; @@ -36,6 +37,16 @@ .threads-list tr:hover { background-color: #eff5f6; } + .threads-list td.delete-or-restore { + width: 15px; + background-image: none; + } + .threads-list tr:hover td.delete-or-restore { + background: url({{"/images/delete.png"|media}}) no-repeat center center; + } + .threads-list.trash tr:hover td.delete-or-restore { + background-image: url({{"/images/delete.png"|media}}); + } td.empty { line-height: 30px; vertical-align: middle; diff --git a/group_messaging/models.py b/group_messaging/models.py index 62f720cf..12fe3620 100644 --- a/group_messaging/models.py +++ b/group_messaging/models.py @@ -1,12 +1,16 @@ """models for the ``group_messaging`` app """ import datetime +from coffin.template.loader import get_template from django.db import models +from django.db.models import signals from django.contrib.auth.models import Group from django.contrib.auth.models import User +from django.utils.translation import ugettext as _ -MAX_TITLE_LENGTH = 80 +MAX_HEADLINE_LENGTH = 80 MAX_SENDERS_INFO_LENGTH = 64 +MAX_SUBJECT_LINE_LENGTH = 30 #dummy parse message function parse_message = lambda v: v @@ -87,7 +91,7 @@ class MessageMemo(models.Model): (ARCHIVED, 'archived') ) user = models.ForeignKey(User) - message = models.ForeignKey('Message') + message = models.ForeignKey('Message', related_name='memos') status = models.SmallIntegerField( choices=STATUS_CHOICES, default=SEEN ) @@ -99,13 +103,37 @@ class MessageMemo(models.Model): class MessageManager(models.Manager): """model manager for the :class:`Message`""" - def get_threads_for_user(self, user): - user_groups = user.groups.all() - return self.filter( - root=None, - message_type=Message.STORED, - recipients__in=user_groups - ) + def get_threads(self, recipient=None, sender=None, deleted=False): + user_groups = recipient.groups.all() + user_thread_filter = models.Q( + root=None, + message_type=Message.STORED, + recipients__in=user_groups + ) + + filter = user_thread_filter + if sender: + filter = filter & models.Q(sender=sender) + + if deleted: + deleted_filter = models.Q( + memos__status=MessageMemo.ARCHIVED, + memos__user=recipient + ) + return self.filter(filter & deleted_filter) + else: + #rather a tricky query (may need to change the idea to get rid of this) + #select threads that have a memo for the user, but the memo is not ARCHIVED + #in addition, select threads that have zero memos for the user + marked_as_non_deleted_filter = models.Q( + memos__status=MessageMemo.SEEN, + memos__user=recipient + ) + #part1 - marked as non-archived + part1 = self.filter(filter & marked_as_non_deleted_filter) + #part2 - messages for the user without an attached memo + part2 = self.filter(filter & ~models.Q(memos__user=recipient)) + return (part1 | part2).distinct() def create(self, **kwargs): """creates a message""" @@ -120,7 +148,7 @@ class MessageManager(models.Manager): kwargs['root'] = root headline = kwargs.get('headline', kwargs['text']) - kwargs['headline'] = headline[:MAX_TITLE_LENGTH] + kwargs['headline'] = headline[:MAX_HEADLINE_LENGTH] kwargs['html'] = parse_message(kwargs['text']) message = super(MessageManager, self).create(**kwargs) @@ -143,6 +171,7 @@ class MessageManager(models.Manager): text=text, ) message.add_recipients(recipients) + message.send_email_alert() return message def create_response(self, sender=None, text=None, parent=None): @@ -160,10 +189,15 @@ class MessageManager(models.Manager): message.add_recipients(recipients) #add author of the parent as a recipient to parent parent.add_recipients([senders_group]) + #update headline + message.root.headline = text[:MAX_HEADLINE_LENGTH] #mark last active timestamp for the root message - #so that we know that this thread was most recently - #updated - message.update_root_info() + message.root.last_active_at = datetime.datetime.now() + #update senders info - stuff that is shown in the thread heading + message.root.update_senders_info() + #unarchive the thread for all recipients + message.root.unarchive() + message.send_email_alert() return message @@ -205,7 +239,7 @@ class Message(models.Model): blank=True, related_name='children' ) - headline = models.CharField(max_length=MAX_TITLE_LENGTH) + headline = models.CharField(max_length=MAX_HEADLINE_LENGTH) text = models.TextField( null=True, blank=True, @@ -233,17 +267,63 @@ class Message(models.Model): sender_list, created = SenderList.objects.get_or_create(recipient=recipient) sender_list.senders.add(self.sender) - def update_root_info(self): - """Update the last active at timestamp and - the contributors info, if relevant. - Root object will be saved to the database. + def get_email_subject_line(self): + """forms subject line based on the root message + and prepends 'Re': if message is non-root + """ + subject = self.get_root_message().text[:MAX_SUBJECT_LINE_LENGTH] + if self.root: + subject = _('Re: ') + subject + return subject + + def get_root_message(self): + """returns root message or self + if current message is root + """ + return self.root or self + + def get_recipients_users(self): + """returns query set of users""" + groups = self.recipients.all() + return User.objects.filter( + groups__in=groups + ).exclude( + id=self.sender.id + ).distinct() + + def get_timeline(self): + """returns ordered query set of messages in the thread + with the newest first""" + root = self.get_root_message() + root_qs = Message.objects.filter(id=root.id) + return (root.descendants.all() | root_qs).order_by('-sent_at') + + + def send_email_alert(self): + """signal handler for the message post-save""" + subject = self.get_email_subject_line() + template = get_template('group_messaging/email_alert.html') + data = {'messages': self.get_timeline()} + body_text = template.render(data) + recipients = map(lambda v: v.email, self.get_recipients_users()) + from askbot.mail import send_mail + send_mail(recipient_list=recipients, subject_line=subject, body_text=body_text) + + + def update_senders_info(self): + """update the contributors info, + meant to be used on a root message only """ - self.root.last_active_at = datetime.datetime.now() - senders_names = self.root.senders_info.split(',') + senders_names = self.senders_info.split(',') if self.sender.username in senders_names: senders_names.remove(self.sender.username) senders_names.insert(0, self.sender.username) - self.root.senders_info = (','.join(senders_names))[:64] - self.root.save() + self.senders_info = (','.join(senders_names))[:64] + self.save() + + def unarchive(self): + """unarchive message for all recipients""" + memos = self.memos.filter(status=MessageMemo.ARCHIVED) + memos.update(status=MessageMemo.SEEN) diff --git a/group_messaging/tests.py b/group_messaging/tests.py index c8401dc1..9cc69fb8 100644 --- a/group_messaging/tests.py +++ b/group_messaging/tests.py @@ -3,8 +3,12 @@ from django.contrib.auth.models import User, Group from group_messaging.models import Message from group_messaging.models import MessageMemo from group_messaging.models import SenderList +from group_messaging.models import LastVisitTime from group_messaging.models import get_personal_group from group_messaging.models import create_personal_group +from group_messaging.views import ThreadsList +from mock import Mock +import time MESSAGE_TEXT = 'test message text' @@ -39,6 +43,32 @@ class ModelTests(TestCase): group = get_personal_group(user) return self.create_thread([group]) + + def get_view_context(self, view_class, data=None, user=None, method='GET'): + spec = ['REQUEST', 'user'] + assert(method in ('GET', 'POST')) + spec.append(method) + request = Mock(spec=spec) + request.REQUEST = data + setattr(request, method, data) + request.user = user + return view_class().get_context(request) + + def setup_three_message_thread(self): + """talk in this order: sender, recipient, sender""" + root_message = self.create_thread_for_user(self.recipient) + response = Message.objects.create_response( + sender=self.recipient, + text='some response', + parent=root_message + ) + response2 = Message.objects.create_response( + sender=self.sender, + text='some response2', + parent=response + ) + return root_message, response, response2 + def test_create_thread_for_user(self): """the basic create thread with one recipient tests that the recipient is there""" @@ -100,11 +130,11 @@ class ModelTests(TestCase): expected_recipients = set([sender_group, recipient_group]) self.assertEqual(recipients, expected_recipients) - def test_get_threads_for_user(self): + def test_get_threads(self): root_message = self.create_thread_for_user(self.recipient) - threads = set(Message.objects.get_threads_for_user(self.sender)) + threads = set(Message.objects.get_threads(recipient=self.sender)) self.assertEqual(threads, set([])) - threads = set(Message.objects.get_threads_for_user(self.recipient)) + threads = set(Message.objects.get_threads(recipient=self.recipient)) self.assertEqual(threads, set([root_message])) response = Message.objects.create_response( @@ -112,7 +142,134 @@ class ModelTests(TestCase): text='some response', parent=root_message ) - threads = set(Message.objects.get_threads_for_user(self.sender)) + threads = set(Message.objects.get_threads(recipient=self.sender)) self.assertEqual(threads, set([root_message])) - threads = set(Message.objects.get_threads_for_user(self.recipient)) + threads = set(Message.objects.get_threads(recipient=self.recipient)) self.assertEqual(threads, set([root_message])) + + def test_answer_to_deleted_thread_undeletes_thread(self): + #setup: message, reply, responder deletes thread + root_message = self.create_thread_for_user(self.recipient) + response = Message.objects.create_response( + sender=self.recipient, + text='some response', + parent=root_message + ) + memo1, created = MessageMemo.objects.get_or_create( + message=root_message, + user=self.recipient, + status=MessageMemo.ARCHIVED + ) + #OP sends reply to reply + response2 = Message.objects.create_response( + sender=self.sender, + text='some response2', + parent=response + ) + + context = self.get_view_context( + ThreadsList, + data={'sender_id': '-1'}, + user=self.recipient + ) + + self.assertEqual(len(context['threads']), 1) + thread_id = context['threads'][0].id + thread_data = context['threads_data'][thread_id] + self.assertEqual(thread_data['status'], 'new') + + def test_deleting_thread_is_user_specific(self): + """when one user deletes thread, that same thread + should not end up deleted by another user + """ + root, response, response2 = self.setup_three_message_thread() + + threads = Message.objects.get_threads(recipient=self.sender) + self.assertEquals(threads.count(), 1) + threads = Message.objects.get_threads(recipient=self.recipient) + self.assertEquals(threads.count(), 1) + + memo1, created = MessageMemo.objects.get_or_create( + message=root, + user=self.recipient, + status=MessageMemo.ARCHIVED + ) + + threads = Message.objects.get_threads(recipient=self.sender) + self.assertEquals(threads.count(), 1) + threads = Message.objects.get_threads(recipient=self.recipient) + self.assertEquals(threads.count(), 0) + threads = Message.objects.get_threads( + recipient=self.recipient, deleted=True + ) + self.assertEquals(threads.count(), 1) + + def test_user_specific_inboxes(self): + self.create_thread_for_user(self.recipient) + + threads = Message.objects.get_threads( + recipient=self.recipient, sender=self.sender + ) + self.assertEqual(threads.count(), 1) + threads = Message.objects.get_threads( + recipient=self.sender, sender=self.recipient + ) + self.assertEqual(threads.count(), 0) + + def test_new_response_marks_thread_heading_as_new(self): + root = self.create_thread_for_user(self.recipient) + response = Message.objects.create_response( + sender=self.recipient, + text='some response', + parent=root + ) + #response must show as "new" to the self.sender + context = self.get_view_context( + ThreadsList, + data={'sender_id': '-1'}, + user=self.sender + ) + self.assertEqual(context['threads_data'][root.id]['status'], 'new') + #"visit" the thread + last_visit_time = LastVisitTime.objects.create( + user=self.sender, + message=root + ) + time.sleep(1.5) + + #response must show as "seen" + context = self.get_view_context( + ThreadsList, + data={'sender_id': '-1'}, + user=self.sender + ) + self.assertEqual(context['threads_data'][root.id]['status'], 'seen') + #self.recipient makes another response + response = Message.objects.create_response( + sender=self.recipient, + text='some response', + parent=response + ) + #thread must be "new" again + context = self.get_view_context( + ThreadsList, + data={'sender_id': '-1'}, + user=self.sender + ) + self.assertEqual(context['threads_data'][root.id]['status'], 'new') + + def test_response_updates_thread_headline(self): + root = self.create_thread_for_user(self.recipient) + response = Message.objects.create_response( + sender=self.recipient, + text='some response', + parent=root + ) + self.assertEqual(root.headline, 'some response') + + def test_email_alert_sent(self): + root = self.create_thread_for_user(self.recipient) + from django.core.mail import outbox + self.assertEqual(len(outbox), 1) + self.assertEqual(len(outbox[0].recipients()), 1) + self.assertEqual(outbox[0].recipients()[0], self.recipient.email) diff --git a/group_messaging/urls.py b/group_messaging/urls.py index 30002bf3..19ee35bb 100644 --- a/group_messaging/urls.py +++ b/group_messaging/urls.py @@ -15,6 +15,11 @@ urlpatterns = patterns('', name='thread_details' ), url( + '^threads/(?P<thread_id>\d+)/delete-or-restore/$', + views.DeleteOrRestoreThread().as_view(), + name='delete_or_restore_thread' + ), + url( '^threads/create/$', views.NewThread().as_view(), name='create_thread' diff --git a/group_messaging/views.py b/group_messaging/views.py index 289961ff..3d973dbe 100644 --- a/group_messaging/views.py +++ b/group_messaging/views.py @@ -18,6 +18,7 @@ from django.http import HttpResponseNotAllowed from django.http import HttpResponseForbidden from django.utils import simplejson from group_messaging.models import Message +from group_messaging.models import MessageMemo from group_messaging.models import SenderList from group_messaging.models import LastVisitTime from group_messaging.models import get_personal_group_by_user_id @@ -139,6 +140,7 @@ class PostReply(InboxView): template_name='group_messaging/stored_message.html' ) + class ThreadsList(InboxView): """shows list of threads for a given user""" template_name = 'group_messaging/threads_list.html' @@ -147,11 +149,22 @@ class ThreadsList(InboxView): def get_context(self, request): """returns thread list data""" #get threads and the last visit time - threads = Message.objects.get_threads_for_user(request.user) + sender_id = IntegerField().clean(request.REQUEST.get('sender_id', '-1')) + if sender_id == -2: + threads = Message.objects.get_threads( + recipient=request.user, + deleted=True + ) + elif sender_id == -1: + threads = Message.objects.get_threads(recipient=request.user) + else: + sender = User.objects.get(id=sender_id) + threads = Message.objects.get_threads( + recipient=request.user, + sender=sender + ) - sender_id = IntegerField().clean(request.GET.get('sender_id', '-1')) - if sender_id != -1: - threads = threads.filter(sender__id=sender_id) + threads = threads.order_by('-last_active_at') #for each thread we need to know if there is something #unread for the user - to mark "new" threads as bold @@ -177,9 +190,48 @@ class ThreadsList(InboxView): if thread_data['thread'].last_active_at <= last_visit.at: thread_data['status'] = 'seen' - #after we have all the data - update the last visit time - last_visit_times.update(at=datetime.datetime.now()) - return {'threads': threads, 'threads_data': threads_data} + return { + 'threads': threads, + 'threads_data': threads_data, + 'sender_id': sender_id + } + + +class DeleteOrRestoreThread(ThreadsList): + """subclassing :class:`ThreadsList`, because deletion + or restoring of thread needs subsequent refreshing + of the threads list""" + + http_method_list = ('POST',) + + def post(self, request, thread_id=None): + """process the post request: + * delete or restore thread + * recalculate the threads list and return it for display + by reusing the threads list "get" function + """ + #part of the threads list context + sender_id = IntegerField().clean(request.POST['sender_id']) + + #a little cryptic, but works - sender_id==-2 means deleted post + if sender_id == -2: + action = 'restore' + else: + action = 'delete' + + thread = Message.objects.get(id=thread_id) + memo, created = MessageMemo.objects.get_or_create( + user=request.user, + message=thread + ) + if action == 'delete': + memo.status = MessageMemo.ARCHIVED + else: + memo.status = MessageMemo.SEEN + memo.save() + + context = self.get_context(request) + return self.render_to_response(context) class SendersList(InboxView): |