diff options
-rw-r--r-- | askbot/media/js/group_messaging.js | 109 | ||||
-rw-r--r-- | askbot/models/__init__.py | 9 | ||||
-rw-r--r-- | askbot/startup_procedures.py | 32 | ||||
-rw-r--r-- | askbot/templates/email/base_mail.html | 8 | ||||
-rw-r--r-- | askbot/templates/group_messaging/email_alert.html | 18 | ||||
-rw-r--r-- | askbot/templates/group_messaging/home_thread_details.html | 16 | ||||
-rw-r--r-- | askbot/templates/group_messaging/senders_list.html | 18 | ||||
-rw-r--r-- | askbot/templates/group_messaging/threads_list.html | 14 | ||||
-rw-r--r-- | askbot/templates/user_inbox/base.html | 4 | ||||
-rw-r--r-- | askbot/templates/user_inbox/messages.html | 13 | ||||
-rw-r--r-- | askbot/views/users.py | 22 | ||||
-rw-r--r-- | group_messaging/models.py | 238 | ||||
-rw-r--r-- | group_messaging/tests.py | 277 | ||||
-rw-r--r-- | group_messaging/urls.py | 5 | ||||
-rw-r--r-- | group_messaging/views.py | 90 |
15 files changed, 778 insertions, 95 deletions
diff --git a/askbot/media/js/group_messaging.js b/askbot/media/js/group_messaging.js index 2522f72e..8c7e4b91 100644 --- a/askbot/media/js/group_messaging.js +++ b/askbot/media/js/group_messaging.js @@ -265,15 +265,19 @@ NewThreadComposer.prototype.onAfterShow = function() { NewThreadComposer.prototype.onSendErrorInternal = function(data) { var missingUsers = data['missing_users'] + var errors = []; if (missingUsers) { var errorTpl = ngettext( 'user {{str}} does not exist', 'users {{str}} do not exist', missingUsers.length ) - error = errorTpl.replace('{{str}}', joinAsPhrase(missingUsers)); - this._toInputError.html(error); + errors.push(errorTpl.replace('{{str}}', joinAsPhrase(missingUsers))); } + if (data['self_message']) { + errors.push(gettext('cannot send message to yourself')); + } + this._toInputError.html(errors.join(', ')); }; NewThreadComposer.prototype.getInputData = function() { @@ -331,6 +335,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 +350,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 +377,17 @@ 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.getThreadsCount = function() { + if (self._threads) { + return self._threads.length; + } else { + return 0; + } }; ThreadsList.prototype.decorate = function(element) { @@ -374,12 +397,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'); } @@ -443,16 +468,31 @@ ThreadContainer.prototype.setReplyUrl = function(url) { this._replyUrl = url; }; +ThreadContainer.prototype.appendEditor = function() { + var editor = new ReplyComposer(); + editor.setSendUrl(this._replyUrl); + this._element.append(editor.getElement()); + this._editor = editor; + editor.show(); +}; + ThreadContainer.prototype.createDom = function() { this._element = this.makeElement('div'); var content = this.makeElement('div'); this._contentElement = content; this._element.append(content); + this.appendEditor(); +}; - var editor = new ReplyComposer(); - editor.setSendUrl(this._replyUrl); - this._element.append(editor.getElement()); - this._editor = editor; +ThreadContainer.prototype.decorate = function(element) { + this._element = element; + this._contentElement = $(element.children()[0]); + var thread = new Thread(); + thread.decorate(element.find('.thread')); + this.appendEditor(); + var postData = {parent_id: thread.getLastMessageId()}; + this._editor.setPostData(postData); + this._editor.setThread(thread); }; @@ -604,25 +644,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'); @@ -644,19 +702,30 @@ MessageCenter.prototype.decorate = function(element) { threads.setMessageCenter(this); threads.decorate($('.threads-list')); this._threadsList = threads; - //add empty thread container + //add empty thread container or decorate existing one var threadContainer = new ThreadContainer(); this._threadContainer = threadContainer; threadContainer.setReplyUrl(this._urls['reply']); - this._secondCol.append(threadContainer.getElement()); + + var threadElement = $('.thread').parent().parent(); + if (threadElement.length) { + threadContainer.decorate(threadElement); + } else { + this._secondCol.append(threadContainer.getElement()); + } var me = this; //create editor var editor = new NewThreadComposer(); this._secondCol.append(editor.getElement()); editor.setSendUrl(element.data('createThreadUrl')); - editor.onAfterCancel(function() { me.setState('show-list') }); + editor.onAfterCancel(function() { + me.setState('show-list') + }); editor.onSendSuccess(function() { + if (threads.getThreadsCount() === 0) { + me.loadThreadsForSender(-1); + } editor.cancel(); notify.show(gettext('message sent'), true); }); diff --git a/askbot/models/__init__.py b/askbot/models/__init__.py index db9674e2..ca8fd2b3 100644 --- a/askbot/models/__init__.py +++ b/askbot/models/__init__.py @@ -2234,7 +2234,7 @@ def delete_messages(self): self.message_set.all().delete() #todo: find where this is used and replace with get_absolute_url -def get_profile_url(self): +def user_get_profile_url(self): """Returns the URL for this User's profile.""" return reverse( 'user_profile', @@ -2787,7 +2787,7 @@ User.add_to_class( user_get_flag_count_posted_today ) User.add_to_class('get_flags_for_post', user_get_flags_for_post) -User.add_to_class('get_profile_url', get_profile_url) +User.add_to_class('get_profile_url', user_get_profile_url) User.add_to_class('get_profile_link', get_profile_link) User.add_to_class('get_tag_filtered_questions', user_get_tag_filtered_questions) User.add_to_class('get_messages', get_messages) @@ -2969,8 +2969,9 @@ def format_instant_notification_email( else: raise ValueError('unrecognized post type') - post_url = strip_path(site_url) + post.get_absolute_url() - user_url = strip_path(site_url) + from_user.get_absolute_url() + base_url = strip_path(site_url) + post_url = base_url + post.get_absolute_url() + user_url = base_url + from_user.get_absolute_url() user_action = user_action % { 'user': '<a href="%s">%s</a>' % (user_url, from_user.username), 'post_link': '<a href="%s">%s</a>' % (post_url, _(post.post_type)) diff --git a/askbot/startup_procedures.py b/askbot/startup_procedures.py index 706e19ad..6b28f688 100644 --- a/askbot/startup_procedures.py +++ b/askbot/startup_procedures.py @@ -686,6 +686,37 @@ def test_longerusername(): errors.append('run "python manage.py migrate longerusername"') print_errors(errors) +def test_group_messaging(): + """tests correctness of the "group_messaging" app configuration""" + errors = list() + if 'group_messaging' not in django_settings.INSTALLED_APPS: + errors.append("add to the INSTALLED_APPS:\n'group_messaging'") + + settings_sample = ("GROUP_MESSAGING = {\n" + " 'base_url_getter_function': 'askbot.models.user_get_profile_url',\n" + " 'base_url_params': {'section': 'messages', 'sort': 'inbox'}\n" + "}") + + settings = getattr(django_settings, 'GROUP_MESSAGING', {}) + if settings: + url_params = settings.get('base_url_params', {}) + have_wrong_params = not ( + url_params.get('section', None) == 'messages' and \ + url_params.get('sort', None) == 'inbox' + ) + url_getter = settings.get('base_url_getter_function', None) + if url_getter != 'askbot.models.user_get_profile_url' or have_wrong_params: + errors.append( + "make setting 'GROUP_MESSAGING to be exactly:\n" + settings_sample + ) + + url_params = settings.get('base_url_params', None) + else: + errors.append('add this to your settings.py:\n' + settings_sample) + + if errors: + print_errors(errors) + def run_startup_tests(): """function that runs all startup tests, mainly checking settings config so far @@ -705,6 +736,7 @@ def run_startup_tests(): test_new_skins() test_longerusername() test_avatar() + test_group_messaging() test_haystack() settings_tester = SettingsTester({ 'CACHE_MIDDLEWARE_ANONYMOUS_ONLY': { diff --git a/askbot/templates/email/base_mail.html b/askbot/templates/email/base_mail.html index eacbf87d..adf6d268 100644 --- a/askbot/templates/email/base_mail.html +++ b/askbot/templates/email/base_mail.html @@ -154,15 +154,15 @@ </tr> <tr> <td valign="top"> - {%block content%} - {%endblock%} + {% block content %} + {% endblock %} </td> </tr> <tr> <td valign="top" class="footer"> <hr> - {%block footer%} - {%endblock%} + {% block footer %} + {% endblock %} </td> </tr> </table> diff --git a/askbot/templates/group_messaging/email_alert.html b/askbot/templates/group_messaging/email_alert.html new file mode 100644 index 00000000..bb97f6f6 --- /dev/null +++ b/askbot/templates/group_messaging/email_alert.html @@ -0,0 +1,18 @@ +{% extends "email/base_mail.html"%} +{% from "email/macros.html" import start_quote, end_quote %} +{% block content %} + {% 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) }} + {% if loop.index == 1 %} + <p>{% trans %}To reply please <a class="thread-link" href="THREAD_URL_HOLE">visit your message inbox</a>{% endtrans %}</p> + {% endif %} + {% set level = level + 1 %} + {% endfor %} +{% endblock %} +{% block footer %} +{% include "email/footer.html" %} +{% endblock %} diff --git a/askbot/templates/group_messaging/home_thread_details.html b/askbot/templates/group_messaging/home_thread_details.html new file mode 100644 index 00000000..cde6b37c --- /dev/null +++ b/askbot/templates/group_messaging/home_thread_details.html @@ -0,0 +1,16 @@ +<div class="group-messaging" + data-create-thread-url="{% url create_thread %}" + data-get-threads-url="{% url get_threads %}" + data-reply-url="{% url post_reply %}" +> + <div class="first-col"> + <button class="submit compose">{% trans %}compose{% endtrans %}</button> + {% include "group_messaging/senders_list.html" %} + </div> + <div class="second-col"> + <div><div>{# need two nested divs to match dom of ThreadContainer #} + {% include "group_messaging/thread_details.html" %} + </div></div> + </div> + <div class="clear-fix"></div> +</div> diff --git a/askbot/templates/group_messaging/senders_list.html b/askbot/templates/group_messaging/senders_list.html index 687cacd6..b33a28d8 100644 --- a/askbot/templates/group_messaging/senders_list.html +++ b/askbot/templates/group_messaging/senders_list.html @@ -1,14 +1,18 @@ -{#<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> - <li><a class="selected" data-sender-id="-1">{% trans %}all users{% endtrans %}</a></li> + <li><a class="selected" data-sender-id="-1"> + {% trans %}inbox{% endtrans %} + {% if inbox_threads_count %}({{ inbox_threads_count }}){% endif %} + </a> + </li> + <li><a data-sender-id="{{ request_user_id }}">{% trans %}sent{% endtrans %}</a></li> {% for sender in senders %} - <li><a data-sender-id="{{ sender.id }}">{{ sender.username|escape }}</a></li> + {% if sender.id != request_user_id %} + <li><a data-sender-id="{{ sender.id }}">{{ sender.username|escape }}</a></li> + {% endif %} {% 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..335abcb9 100644 --- a/askbot/templates/group_messaging/threads_list.html +++ b/askbot/templates/group_messaging/threads_list.html @@ -1,12 +1,22 @@ -<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="subject"> + {{ thread.headline|escape }} + {% if thread_data['responses_count'] > 0 %} + <span class="messages-count"> + ({{ thread_data['responses_count'] + 1 }}) + </span> + {% endif %} + </td> <td class="timestamp">{{ thread.last_active_at|timesince }}</td> </tr> {% endfor %} 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..a620bf66 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; @@ -93,7 +104,7 @@ {% trans %}inbox - messages{% endtrans %} {% endblock %} {% block inbox_content %} - {% include "group_messaging/home.html" %} + {% include group_messaging_template_name %} {% endblock %} {% block userjs %} <script type="text/javascript" src="{{ 'js/group_messaging.js'|media }}"></script> diff --git a/askbot/views/users.py b/askbot/views/users.py index c22fd294..1e1d1dc8 100644 --- a/askbot/views/users.py +++ b/askbot/views/users.py @@ -757,12 +757,12 @@ def user_responses(request, user, context): elif section == 'messages': if request.user != user: raise Http404 - #here we take shortcut, because we don't care about - #all the extra context loaded below + from group_messaging.views import SendersList, ThreadsList context.update(SendersList().get_context(request)) context.update(ThreadsList().get_context(request)) data = { + 'inbox_threads_count': context['threads_count'],#a hackfor the inbox count 'active_tab':'users', 'page_class': 'user-profile-page', 'tab_name' : 'inbox', @@ -771,9 +771,21 @@ def user_responses(request, user, context): 'page_title' : _('profile - messages') } context.update(data) - return render_into_skin( - 'user_inbox/messages.html', context, request - ) + if 'thread_id' in request.GET: + from group_messaging.models import Message + from group_messaging.views import ThreadDetails + try: + thread_id = request.GET['thread_id'] + context.update(ThreadDetails().get_context(request, thread_id)) + context['group_messaging_template_name'] = \ + 'group_messaging/home_thread_details.html' + except Message.DoesNotExist: + raise Http404 + else: + context['group_messaging_template_name'] = 'group_messaging/home.html' + #here we take shortcut, because we don't care about + #all the extra context loaded below + return render_into_skin('user_inbox/messages.html', context, request) else: raise Http404 diff --git a/group_messaging/models.py b/group_messaging/models.py index 62f720cf..9cc12786 100644 --- a/group_messaging/models.py +++ b/group_messaging/models.py @@ -1,18 +1,40 @@ """models for the ``group_messaging`` app """ +import copy import datetime +import urllib +from askbot.mail import send_mail #todo: remove dependency? +from coffin.template.loader import get_template from django.db import models +from django.db.models import signals +from django.conf import settings as django_settings from django.contrib.auth.models import Group from django.contrib.auth.models import User +from django.contrib.sites.models import Site +from django.utils.importlib import import_module +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 GROUP_NAME_TPL = '_personal_%s' +def get_recipient_names(recipient_groups): + """returns list of user names if groups are private, + or group names, otherwise""" + names = set() + for group in recipient_groups: + if group.name.startswith('_personal_'): + names.add(group.user_set.all()[0].username) + else: + names.add(group.name) + return names + + def get_personal_group_by_user_id(user_id): return Group.objects.get(name=GROUP_NAME_TPL % user_id) @@ -87,7 +109,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 +121,68 @@ 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_sent_threads(self, sender=None): + """returns list of threads for the "sent" mailbox + this function does not deal with deleted=True + """ + responses = self.filter(sender=sender) + responded_to = models.Q(descendants__in=responses, root=None) + seen_filter = models.Q( + memos__status=MessageMemo.SEEN, + memos__user=sender ) + seen_responses = self.filter(responded_to & seen_filter) + unseen_responses = self.filter(responded_to & ~models.Q(memos__user=sender)) + return ( + self.get_threads(sender=sender) \ + | seen_responses.distinct() \ + | unseen_responses.distinct() + ).distinct() + + def get_threads(self, recipient=None, sender=None, deleted=False): + """returns query set of first messages in conversations, + based on recipient, sender and whether to + load deleted messages or not""" + + if sender and sender == recipient: + raise ValueError('sender cannot be the same as recipient') + + filter_kwargs = { + 'root': None, + 'message_type': Message.STORED + } + if recipient: + filter_kwargs['recipients__in'] = recipient.groups.all() + else: + #todo: possibly a confusing hack - for this branch - + #sender but no recipient in the args - we need "sent" origin threads + recipient = sender + + user_thread_filter = models.Q(**filter_kwargs) + + 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 +197,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) @@ -133,7 +210,6 @@ class MessageManager(models.Manager): ) return message - def create_thread(self, sender=None, recipients=None, text=None): """creates a stored message and adds recipients""" message = self.create( @@ -142,7 +218,13 @@ class MessageManager(models.Manager): senders_info=sender.username, text=text, ) + now = datetime.datetime.now() + LastVisitTime.objects.create(message=message, user=sender, at=now) + names = get_recipient_names(recipients) + message.add_recipient_names_to_senders_info(recipients) + message.save() message.add_recipients(recipients) + message.send_email_alert() return message def create_response(self, sender=None, text=None, parent=None): @@ -155,15 +237,23 @@ class MessageManager(models.Manager): #recipients are parent's recipients + sender #creator of response gets memo in the "read" status recipients = set(parent.recipients.all()) - senders_group = get_personal_group(parent.sender) - recipients.add(senders_group) + + if sender != parent.sender: + senders_group = get_personal_group(parent.sender) + parent.add_recipients([senders_group]) + recipients.add(senders_group) + 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 +295,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, @@ -223,6 +313,12 @@ class Message(models.Model): objects = MessageManager() + def add_recipient_names_to_senders_info(self, recipient_groups): + names = get_recipient_names(recipient_groups) + old_names = set(self.senders_info.split(',')) + names |= old_names + self.senders_info = ','.join(names) + def add_recipients(self, recipients): """adds recipients to the message and updates the sender lists for all recipients @@ -233,17 +329,109 @@ 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_absolute_url(self, user=None): + """returns absolute url to the thread""" + assert(user != None) + settings = django_settings.GROUP_MESSAGING + func_path = settings['base_url_getter_function'] + path_bits = func_path.split('.') + url_getter = getattr( + import_module('.'.join(path_bits[:-1])), + path_bits[-1] + ) + params = copy.copy(settings['base_url_params']) + params['thread_id'] = self.id + url = url_getter(user) + '?' + urllib.urlencode(params) + #if include_domain_name: #don't need this b/c + # site = Site.objects.get_current() + # url = 'http://' + site.domain + url + return url + + 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""" + root_message = self.get_root_message() + data = {'messages': self.get_timeline()} + template = get_template('group_messaging/email_alert.html') + body_text = template.render(data) + subject = self.get_email_subject_line() + for user in self.get_recipients_users(): + #todo change url scheme so that all users have the same + #urls within their personal areas of the user profile + #so that we don't need to have loops like this one + thread_url = root_message.get_absolute_url(user) + thread_url = thread_url.replace('&', '&') + #in the template we have a placeholder to be replaced like this: + body_text = body_text.replace('THREAD_URL_HOLE', thread_url) + send_mail( + recipient_list=[user.email,], + 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, user=None): + """unarchive message for all recipients""" + archived_filter = {'status': MessageMemo.ARCHIVED} + if user: + archived_filter['user'] = user + memos = self.memos.filter(**archived_filter) + memos.update(status=MessageMemo.SEEN) + + def set_status_for_user(self, status, user): + """set specific status to the message for the user""" + memo, created = MessageMemo.objects.get_or_create(user=user, message=self) + memo.status = status + memo.save() + + def archive(self, user): + """mark message as archived""" + self.set_status_for_user(MessageMemo.ARCHIVED, user) + + def mark_as_seen(self, user): + """mark message as seen""" + self.set_status_for_user(MessageMemo.SEEN, user) diff --git a/group_messaging/tests.py b/group_messaging/tests.py index c8401dc1..bcf764db 100644 --- a/group_messaging/tests.py +++ b/group_messaging/tests.py @@ -1,10 +1,17 @@ +import datetime +import time +import urlparse +from bs4 import BeautifulSoup from django.test import TestCase 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 MESSAGE_TEXT = 'test message text' @@ -22,27 +29,166 @@ def create_user(name): user.groups.add(group) return user -class ModelTests(TestCase): - """test cases for the `private_messaging` models""" +def get_html_message(mail_message): + """mail message is an item from the django.core.mail.outbox""" + return mail_message.alternatives[0][0] + +class GroupMessagingTests(TestCase): + """base class for the test cases in this app""" def setUp(self): self.sender = create_user('sender') self.recipient = create_user('recipient') - def create_thread(self, recipients): + def create_thread(self, sender, recipient_groups): return Message.objects.create_thread( - sender=self.sender, recipients=recipients, + sender=sender, recipients=recipient_groups, text=MESSAGE_TEXT ) - def create_thread_for_user(self, user): - group = get_personal_group(user) - return self.create_thread([group]) + def create_thread_for_user(self, sender, recipient): + group = get_personal_group(recipient) + return self.create_thread(sender, [group]) + + def setup_three_message_thread(self, original_poster=None, responder=None): + """talk in this order: sender, recipient, sender""" + original_poster = original_poster or self.sender + responder = responder or self.recipient + + root_message = self.create_thread_for_user(original_poster, responder) + response = Message.objects.create_response( + sender=responder, + text='some response', + parent=root_message + ) + response2 = Message.objects.create_response( + sender=original_poster, + text='some response2', + parent=response + ) + return root_message, response, response2 + + +class ViewsTests(GroupMessagingTests): + + 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 test_new_response_marks_thread_heading_as_new(self): + root = self.create_thread_for_user(self.sender, 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: todo - make a method + last_visit_time, created = LastVisitTime.objects.get_or_create( + user=self.sender, + message=root + ) + last_visit_time.at = datetime.datetime.now() + last_visit_time.save() + 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_answer_to_deleted_thread_undeletes_thread(self): + #setup: message, reply, responder deletes thread + root_message = self.create_thread_for_user(self.sender, 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_emailed_message_url_works_for_post_recipient(self): + root = self.create_thread_for_user(self.sender, self.recipient) + from django.core.mail import outbox + html_message = get_html_message(outbox[0]) + link = BeautifulSoup(html_message).find('a', attrs={'class': 'thread-link'}) + url = link['href'].replace('&', '&') + parsed_url = urlparse.urlparse(url) + url_data = urlparse.parse_qsl(parsed_url.query) + self.client.login(user_id=self.recipient.id, method='force') + response = self.client.get(parsed_url.path, url_data) + dom = BeautifulSoup(response.content) + threads = dom.find_all('ul', attrs={'class': 'thread'}) + self.assertEquals(len(threads), 1) + thread_lists = dom.find_all('table', attrs={'class': 'threads-list'}) + self.assertEquals(len(thread_lists), 0) + + def test_sent_thread_is_visited_by_sender(self): + root = self.create_thread_for_user(self.sender, self.recipient) + context = self.get_view_context( + ThreadsList, + data={'sender_id': str(self.sender.id)}, + user=self.sender + ) + thread_data = context['threads_data'][root.id] + self.assertEqual(thread_data['status'], 'seen') + +class ModelsTests(GroupMessagingTests): + """test cases for the `private_messaging` models""" def test_create_thread_for_user(self): """the basic create thread with one recipient tests that the recipient is there""" - message = self.create_thread_for_user(self.recipient) + message = self.create_thread_for_user(self.sender, self.recipient) #message type is stored self.assertEqual(message.message_type, Message.STORED) #recipient is in the list of recipients @@ -70,7 +216,7 @@ class ModelTests(TestCase): member of the group has updated the sender list""" group = Group.objects.create(name='somegroup') self.recipient.groups.add(group) - message = self.create_thread([group]) + message = self.create_thread(self.sender, [group]) senders = SenderList.objects.get_senders_for_user(self.recipient) self.assertEqual(set(senders), set([self.sender])) @@ -78,7 +224,7 @@ class ModelTests(TestCase): """create a thread with one response, then load thread for the user test that only the root message is retrieved""" - root_message = self.create_thread_for_user(self.recipient) + root_message = self.create_thread_for_user(self.sender, self.recipient) response = Message.objects.create_response( sender=self.recipient, text='some response', @@ -100,11 +246,11 @@ class ModelTests(TestCase): expected_recipients = set([sender_group, recipient_group]) self.assertEqual(recipients, expected_recipients) - def test_get_threads_for_user(self): - root_message = self.create_thread_for_user(self.recipient) - threads = set(Message.objects.get_threads_for_user(self.sender)) + def test_get_threads(self): + root_message = self.create_thread_for_user(self.sender, self.recipient) + 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 +258,106 @@ 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_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.sender, 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_response_updates_thread_headline(self): + root = self.create_thread_for_user(self.sender, 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.sender, 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) + html_message = get_html_message(outbox[0]) + self.assertTrue(root.text in html_message) + soup = BeautifulSoup(html_message) + links = soup.find_all('a', attrs={'class': 'thread-link'}) + self.assertEqual(len(links), 1) + parse_result = urlparse.urlparse(links[0]['href']) + query = urlparse.parse_qs(parse_result.query.replace('&', '&')) + self.assertEqual(query['thread_id'][0], str(root.id)) + + def test_get_sent_threads(self): + root1, re11, re12 = self.setup_three_message_thread() + root2, re21, re22 = self.setup_three_message_thread( + original_poster=self.recipient, responder=self.sender + ) + root3, re31, re32 = self.setup_three_message_thread() + + #mark root2 as seen + root2.mark_as_seen(self.sender) + #mark root3 as deleted + root3.archive(self.sender) + + threads = Message.objects.get_sent_threads(sender=self.sender) + self.assertEqual(threads.count(), 2) + self.assertEqual(set(threads), set([root1, root2]))#root3 is deleted + + def test_recipient_lists_are_in_senders_info(self): + thread = self.create_thread_for_user(self.sender, self.recipient) + self.assertTrue(self.recipient.username in thread.senders_info) + + def test_self_response_not_in_senders_inbox(self): + root = self.create_thread_for_user(self.sender, self.recipient) + response = Message.objects.create_response( + sender=self.sender, + text='some response', + parent=root + ) + threads = Message.objects.get_threads(recipient=self.sender) + self.assertEqual(threads.count(), 0) + + def test_sent_message_is_seen_by_the_sender(self): + root = self.create_thread_for_user(self.sender, self.recipient) + time.sleep(1.5) + last_visits = LastVisitTime.objects.filter(message=root, user=self.sender) + self.assertEqual(last_visits.count(), 1) + 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..d4d646ec 100644 --- a/group_messaging/views.py +++ b/group_messaging/views.py @@ -12,12 +12,14 @@ import copy import datetime from coffin.template.loader import get_template from django.contrib.auth.models import User +from django.db import models from django.forms import IntegerField from django.http import HttpResponse 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 @@ -104,7 +106,12 @@ class NewThread(InboxView): if missing: result['success'] = False result['missing_users'] = missing - else: + + if request.user.username in usernames: + result['success'] = False + result['self_message'] = True + + if result.get('success', True): recipients = get_personal_groups_for_users(users) message = Message.objects.create_thread( sender=request.user, @@ -139,6 +146,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 +155,24 @@ 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) + elif sender_id == request.user.id: + threads = Message.objects.get_sent_threads(sender=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 @@ -168,6 +189,17 @@ class ThreadsList(InboxView): thread_data['thread'] = thread threads_data[thread.id] = thread_data + ids = [thread.id for thread in threads] + counts = Message.objects.filter( + id__in=ids + ).annotate( + responses_count=models.Count('descendants') + ).values('id', 'responses_count') + for count in counts: + thread_id = count['id'] + responses_count = count['responses_count'] + threads_data[thread_id]['responses_count'] = responses_count + last_visit_times = LastVisitTime.objects.filter( user=request.user, message__in=threads @@ -177,9 +209,49 @@ 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_count': threads.count(), + '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): @@ -191,7 +263,7 @@ class SendersList(InboxView): """get data about senders for the user""" senders = SenderList.objects.get_senders_for_user(request.user) senders = senders.values('id', 'username') - return {'senders': senders} + return {'senders': senders, 'request_user_id': request.user.id} class ThreadDetails(InboxView): |