summaryrefslogtreecommitdiffstats
diff options
context:
space:
mode:
-rw-r--r--askbot/media/js/group_messaging.js110
-rw-r--r--askbot/models/__init__.py9
-rw-r--r--askbot/startup_procedures.py33
-rw-r--r--askbot/templates/email/base_mail.html8
-rw-r--r--askbot/templates/group_messaging/email_alert.html18
-rw-r--r--askbot/templates/group_messaging/home_thread_details.html16
-rw-r--r--askbot/templates/group_messaging/senders_list.html18
-rw-r--r--askbot/templates/group_messaging/threads_list.html14
-rw-r--r--askbot/templates/user_inbox/base.html4
-rw-r--r--askbot/templates/user_inbox/messages.html13
-rw-r--r--askbot/views/users.py18
-rw-r--r--group_messaging/__init__.py3
-rw-r--r--group_messaging/models.py239
-rw-r--r--group_messaging/tests.py277
-rw-r--r--group_messaging/urls.py5
-rw-r--r--group_messaging/views.py92
16 files changed, 784 insertions, 93 deletions
diff --git a/askbot/media/js/group_messaging.js b/askbot/media/js/group_messaging.js
index 2522f72e..c4d5ee74 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,31 @@ 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());
+ threadContainer.hide();
+ }
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 0da80c4c..3a46f119 100644
--- a/askbot/models/__init__.py
+++ b/askbot/models/__init__.py
@@ -2285,7 +2285,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',
@@ -2857,7 +2857,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)
@@ -3041,8 +3041,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 acb99b50..1fa1b3e7 100644
--- a/askbot/startup_procedures.py
+++ b/askbot/startup_procedures.py
@@ -767,6 +767,38 @@ see outdated content on your site.
askbot_warning(message)
+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
@@ -787,6 +819,7 @@ def run_startup_tests():
test_new_skins()
test_longerusername()
test_avatar()
+ test_group_messaging()
test_haystack()
test_cache_backend()
settings_tester = SettingsTester({
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&section=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&section=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 6c2a34fd..414ac8b0 100644
--- a/askbot/views/users.py
+++ b/askbot/views/users.py
@@ -755,12 +755,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',
@@ -769,6 +769,20 @@ def user_responses(request, user, context):
'page_title' : _('profile - messages')
}
context.update(data)
+ 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(request, 'user_inbox/messages.html', context)
else:
raise Http404
diff --git a/group_messaging/__init__.py b/group_messaging/__init__.py
index 642ad5c8..ed3d73ff 100644
--- a/group_messaging/__init__.py
+++ b/group_messaging/__init__.py
@@ -11,4 +11,7 @@ the group should be named `'_personal_1'`.
Only one person must be a member of a personal group and
each user must have such group.
+
+TODO: decouple this application
+first step is to package send_mail separately
"""
diff --git a/group_messaging/models.py b/group_messaging/models.py
index 62f720cf..5d43baf8 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 django.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,110 @@ 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('&', '&amp;')
+ #in the template we have a placeholder to be replaced like this:
+ body_text = body_text.replace('THREAD_URL_HOLE', thread_url)
+ send_mail(
+ subject,
+ body_text,
+ django_settings.DEFAULT_FROM_EMAIL,
+ [user.email,],
+ )
+
+
+ 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('&amp;', '&')
+ 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('&amp;', '&'))
+ 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..244762d1 100644
--- a/group_messaging/views.py
+++ b/group_messaging/views.py
@@ -10,14 +10,16 @@ and turns them into complete views
"""
import copy
import datetime
-from coffin.template.loader import get_template
+from django.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):