nor to a notifier application (refer to ``POSTMAN_NOTIFIER_APP``)\r
\r
*Defaults to*: False.\r
- \r
+\r
``POSTMAN_AUTO_MODERATE_AS``\r
The default moderation status when no auto-moderation functions, if any, were decisive.\r
\r
* Set this option to True\r
* Do not provide any auto-moderation functions\r
\r
+``POSTMAN_SHOW_USER_AS``\r
+ How to represent a User for display, in message properties: ``obfuscated_recipient`` and ``obfuscated_sender``,\r
+ and in the ``or_me`` filter. The value can be specified as:\r
+\r
+ * The name of a property of User. For example: 'last_name'\r
+ * The name of a method of User. For example: 'get_full_name'\r
+ * A function, receiving the User instance as the only parameter. For example: lambda u: u.get_profile().nickname\r
+ * ``None`` : the default text representation of the User (username) is used.\r
+\r
+ *Defaults to*: None.\r
+\r
+ The default behaviour is used as a fallback when: the value is a string and the result is false\r
+ (misspelled attribute name, empty result, ...), or the value is a function and an exception is raised\r
+ (but any result, even empty, is valid).\r
+\r
``POSTMAN_NOTIFIER_APP``\r
A notifier application name, used in preference to the basic emailing,\r
to notify users of their rejected or received messages.\r
:file:`settings.py`::\r
\r
INSTALLED_APPS = (\r
+ # 'pagination' # has to be before postman\r
# ...\r
'postman',\r
# ...\r
- # 'pagination'\r
# 'ajax_select'\r
# 'notification'\r
# 'mailer'\r
)\r
- # POSTMAN_DISALLOW_ANONYMOUS = True # default is False\r
- # POSTMAN_DISALLOW_MULTIRECIPIENTS = True # default is False\r
- # POSTMAN_DISALLOW_COPIES_ON_REPLY = True # default is False\r
- # POSTMAN_DISABLE_USER_EMAILING = True # default is False\r
- # POSTMAN_AUTO_MODERATE_AS = True # default is None\r
- # POSTMAN_NOTIFIER_APP = None # default is 'notification'\r
- # POSTMAN_MAILER_APP = None # default is 'mailer'\r
+ # POSTMAN_DISALLOW_ANONYMOUS = True # default is False\r
+ # POSTMAN_DISALLOW_MULTIRECIPIENTS = True # default is False\r
+ # POSTMAN_DISALLOW_COPIES_ON_REPLY = True # default is False\r
+ # POSTMAN_DISABLE_USER_EMAILING = True # default is False\r
+ # POSTMAN_AUTO_MODERATE_AS = True # default is None\r
+ # POSTMAN_SHOW_USER_AS = 'get_full_name' # default is None\r
+ # POSTMAN_NOTIFIER_APP = None # default is 'notification'\r
+ # POSTMAN_MAILER_APP = None # default is 'mailer'\r
# POSTMAN_AUTOCOMPLETER_APP = {\r
- # 'name': '', # default is 'ajax_select'\r
- # 'field': '', # default is 'AutoCompleteField'\r
- # 'arg_name': '', # default is 'channel'\r
- # 'arg_default': 'postman_friends', # no default, mandatory to enable the feature\r
- # } # default is {}\r
+ # 'name': '', # default is 'ajax_select'\r
+ # 'field': '', # default is 'AutoCompleteField'\r
+ # 'arg_name': '', # default is 'channel'\r
+ # 'arg_default': 'postman_friends', # no default, mandatory to enable the feature\r
+ # } # default is {}\r
\r
:file:`urls.py`::\r
\r
from django.utils.text import truncate_words
from django.utils.translation import ugettext, ugettext_lazy as _
try:
- from django.utils.timezone import now # Django 1.4 aware datetimes
+ from django.utils.timezone import now # Django 1.4 aware datetimes
except ImportError:
from datetime import datetime
now = datetime.now
(STATUS_REJECTED, _('Rejected')),
)
# ordering constants
-ORDER_BY_KEY = 'o' # as 'order'
+ORDER_BY_KEY = 'o' # as 'order'
ORDER_BY_FIELDS = {
- 'f': 'sender__username', # as 'from'
- 't': 'recipient__username', # as 'to'
+ 'f': 'sender__username', # as 'from'
+ 't': 'recipient__username', # as 'to'
's': 'subject', # as 'subject'
'd': 'sent_at', # as 'date'
}
-ORDER_BY_MAPPER = {'sender': 'f', 'recipient': 't', 'subject': 's', 'date': 'd'} # for templatetags usage
+ORDER_BY_MAPPER = {'sender': 'f', 'recipient': 't', 'subject': 's', 'date': 'd'} # for templatetags usage
-dbms = settings.DATABASES['default']['ENGINE'].rsplit('.',1)[-1]
+dbms = settings.DATABASES['default']['ENGINE'].rsplit('.', 1)[-1]
QUOTE_CHAR = '`' if dbms == 'mysql' else '"'
+
def get_order_by(query_dict):
"""
Return a field name, optionally prefixed for descending order, or None if not found.
``query_dict``: a dictionary to look for a key dedicated to ordering purpose
>>> get_order_by({})
-
+
>>> get_order_by({ORDER_BY_KEY: 'f'})
'sender__username'
>>> get_order_by({ORDER_BY_KEY: 'D'})
'-sent_at'
"""
if ORDER_BY_KEY in query_dict:
- code = query_dict[ORDER_BY_KEY] # code may be uppercase or lowercase
+ code = query_dict[ORDER_BY_KEY] # code may be uppercase or lowercase
order_by_field = ORDER_BY_FIELDS.get(code.lower())
if order_by_field:
if code.isupper():
order_by_field = '-' + order_by_field
return order_by_field
+
+def get_user_representation(user):
+ """
+ Return a User representation for display, configurable through an optional setting.
+ """
+ show_user_as = getattr(settings, 'POSTMAN_SHOW_USER_AS', None)
+ if isinstance(show_user_as, (unicode, str)):
+ attr = getattr(user, show_user_as, None)
+ if callable(attr):
+ attr = attr()
+ if attr:
+ return unicode(attr)
+ elif callable(show_user_as):
+ try:
+ return unicode(show_user_as(user))
+ except:
+ pass
+ return unicode(user) # default value, or in case of empty attribute or exception
+
+
class MessageManager(models.Manager):
"""The manager for Message."""
qs = self.all()
if order_by:
qs = qs.order_by(order_by)
- if isinstance(filters, (list,tuple)):
+ if isinstance(filters, (list, tuple)):
lookups = models.Q()
for filter in filters:
lookups |= models.Q(**filter)
"""
return self.inbox(user, related=False, option=OPTION_MESSAGES).filter(read_at__isnull=True).count()
-
+
def sent(self, user, **kwargs):
"""
Return all messages sent by a user but not marked as archived or deleted.
"""
Return messages belonging to a user and marked as archived.
"""
- related = ('sender','recipient')
+ related = ('sender', 'recipient')
filters = ({
'recipient': user,
'recipient_archived': True,
"""
Return messages belonging to a user and marked as deleted.
"""
- related = ('sender','recipient')
+ related = ('sender', 'recipient')
filters = ({
'recipient': user,
'recipient_deleted_at__isnull': False,
"""
Return message/conversation for display.
"""
- return self.select_related('sender','recipient').filter(
+ return self.select_related('sender', 'recipient').filter(
filter,
(models.Q(recipient=user) & models.Q(moderation_status=STATUS_ACCEPTED)) | models.Q(sender=user),
).order_by('sent_at')
"""
Return messages matching a filter AND being visible to a user as the sender.
"""
- return self.filter(filter, sender=user) # any status is fine
+ return self.filter(filter, sender=user) # any status is fine
def perms(self, user):
"""
read_at__isnull=True,
).update(read_at=now())
+
class Message(models.Model):
"""
A message between a User and another User or an AnonymousUser.
body = models.TextField(_("body"), blank=True)
sender = models.ForeignKey(User, related_name='sent_messages', null=True, blank=True, verbose_name=_("sender"))
recipient = models.ForeignKey(User, related_name='received_messages', null=True, blank=True, verbose_name=_("recipient"))
- email = models.EmailField(_("visitor"), blank=True) # instead of either sender or recipient, for an AnonymousUser
+ email = models.EmailField(_("visitor"), blank=True) # instead of either sender or recipient, for an AnonymousUser
parent = models.ForeignKey('self', related_name='next_messages', null=True, blank=True, verbose_name=_("parent message"))
thread = models.ForeignKey('self', related_name='child_messages', null=True, blank=True, verbose_name=_("root message"))
sent_at = models.DateTimeField(_("sent at"), default=now)
ordering = ['-sent_at', '-id']
def __unicode__(self):
- return u"{0}>{1}:{2}".format(self.obfuscated_sender, self.obfuscated_recipient, truncate_words(self.subject,5))
+ return u"{0}>{1}:{2}".format(self.obfuscated_sender, self.obfuscated_recipient, truncate_words(self.subject, 5))
@models.permalink
def get_absolute_url(self):
"""
email = self.email
digest = hashlib.md5(email + settings.SECRET_KEY).hexdigest()
- shrunken_digest = '..'.join((digest[:4], digest[-4:])) # 32 characters is too long and is useless
+ shrunken_digest = '..'.join((digest[:4], digest[-4:])) # 32 characters is too long and is useless
bits = email.split('@')
- if len(bits) <> 2:
+ if len(bits) != 2:
return u''
domain = bits[1]
- return '@'.join((shrunken_digest, domain.rsplit('.',1)[0])) # leave off the TLD to gain some space
+ return '@'.join((shrunken_digest, domain.rsplit('.', 1)[0])) # leave off the TLD to gain some space
def admin_sender(self):
"""
def obfuscated_sender(self):
"""Return the sender either as a username or as an undisclosed email."""
if self.sender:
- return unicode(self.sender)
+ return get_user_representation(self.sender)
else:
return self._obfuscated_email()
def obfuscated_recipient(self):
"""Return the recipient either as a username or as an undisclosed email."""
if self.recipient:
- return unicode(self.recipient)
+ return get_user_representation(self.recipient)
else:
return self._obfuscated_email()
def clean_moderation(self, initial_status, user=None):
"""Adjust automatically some fields, according to status workflow."""
- if self.moderation_status <> initial_status:
+ if self.moderation_status != initial_status:
self.moderation_date = now()
self.moderation_by = user
if self.is_rejected():
def update_parent(self, initial_status):
"""Update the parent to actualize its response state."""
- if self.moderation_status <> initial_status:
+ if self.moderation_status != initial_status:
parent = self.parent
if self.is_accepted():
# keep the very first date; no need to do differently
reasons.append(reason)
if auto is None and percents:
average = float(sum(percents)) / len(percents)
- final_reason = ', '.join([r for i,r in enumerate(reasons) if r and not r.isspace() and percents[i] < 50])
+ final_reason = ', '.join([r for i, r in enumerate(reasons) if r and not r.isspace() and percents[i] < 50])
auto = average >= 50
if auto is None:
auto = getattr(settings, 'POSTMAN_AUTO_MODERATE_AS', None)
self.moderation_status = STATUS_REJECTED
self.moderation_reason = final_reason
+
class PendingMessageManager(models.Manager):
"""The manager for PendingMessage."""
"""Filter to get only pending objects."""
return super(PendingMessageManager, self).get_query_set().filter(moderation_status=STATUS_PENDING)
+
class PendingMessage(Message):
"""
A proxy to Message, focused on pending objects to accept or reject.
import datetime
from django import VERSION
+from django.contrib.auth.models import User
from django.http import QueryDict
from django.template import Node
from django.template import TemplateSyntaxError
from django.template.defaultfilters import date
from django.utils.translation import ugettext_lazy as _
-from postman.models import ORDER_BY_MAPPER, ORDER_BY_KEY, Message
+from postman.models import ORDER_BY_KEY, ORDER_BY_MAPPER, Message,\
+ get_user_representation
register = Library()
return value
sub.is_safe = True
+
@register.filter
def or_me(value, arg):
"""
Replace the value by a fixed pattern, if it equals the argument.
- Typical usage: sender|or_me:user
+ Typical usage: message.obfuscated_sender|or_me:user
"""
if not isinstance(value, (unicode, str)):
- value = unicode(value)
+ value = (get_user_representation if isinstance(value, User) else unicode)(value)
if not isinstance(arg, (unicode, str)):
- arg = unicode(arg)
+ arg = (get_user_representation if isinstance(arg, User) else unicode)(arg)
return _('<me>') if value == arg else value
+
@register.filter(**({'expects_localtime': True, 'is_safe': False} if VERSION >= (1, 4) else {}))
def compact_date(value, arg):
"""
"""
bits = arg.split(u',')
if len(bits) < 3:
- return value # Invalid arg.
+ return value # Invalid arg.
today = datetime.date.today()
return date(value, bits[0] if value.date() == today else bits[1] if value.year == today.year else bits[2])
gets[ORDER_BY_KEY] = self.code if self.code <> code else self.code.upper()
return '?'+gets.urlencode() if gets else ''
+
class InboxCountNode(Node):
"For use in the postman_unread tag"
def __init__(self, asvar=None):
return ''
return count
+
@register.tag
def postman_order_by(parser, token):
"""
" Must be one of: {2}".format(field_name, tag_name, ORDER_BY_MAPPER.keys()))
return OrderByNode(field_code)
+
@register.tag
def postman_unread(parser, token):
"""
from django.http import QueryDict
from django.template import Template, Context, TemplateSyntaxError, TemplateDoesNotExist
from django.test import TestCase
+from django.utils.encoding import force_unicode
+from django.utils.formats import localize
from django.utils.translation import deactivate
try:
- from django.utils.timezone import now # Django 1.4 aware datetimes
+ from django.utils.timezone import now # Django 1.4 aware datetimes
except ImportError:
from datetime import datetime
now = datetime.now
+from postman.api import pm_broadcast, pm_write
from postman.fields import CommaSeparatedUserField
# because of reload()'s, do "from postman.forms import xxForm" just before needs
from postman.models import ORDER_BY_KEY, ORDER_BY_MAPPER, Message, PendingMessage,\
- STATUS_PENDING, STATUS_ACCEPTED, STATUS_REJECTED
+ STATUS_PENDING, STATUS_ACCEPTED, STATUS_REJECTED,\
+ get_user_representation
from postman.urls import OPTION_MESSAGES
# because of reload()'s, do "from postman.utils import notification" just before needs
from postman.utils import format_body, format_subject
+
class GenericTest(TestCase):
"""
Usual generic tests.
def test_version(self):
self.assertEqual(sys.modules['postman'].__version__, "2.1.0a1")
+
class BaseTest(TestCase):
"""
Common configuration and helper functions for all tests.
urls = 'postman.test_urls'
def setUp(self):
- deactivate() # necessary for 1.4 to consider a new settings.LANGUAGE_CODE; 1.3 is fine with or without
+ deactivate() # necessary for 1.4 to consider a new settings.LANGUAGE_CODE; 1.3 is fine with or without
settings.LANGUAGE_CODE = 'en' # do not bother about translation
for a in (
'POSTMAN_DISALLOW_ANONYMOUS',
'POSTMAN_DISALLOW_COPIES_ON_REPLY',
'POSTMAN_DISABLE_USER_EMAILING',
'POSTMAN_AUTO_MODERATE_AS',
+ 'POSTMAN_SHOW_USER_AS',
):
if hasattr(settings, a):
delattr(settings, a)
except KeyError: # happens once at the setUp
pass
reload(get_resolver(get_urlconf()).urlconf_module)
-
+
+
class ViewTest(BaseTest):
"""
Test the views.
m1.replied_at = m2.sent_at; m1.save()
self.check_update_conversation('postman_undelete', m1, 'deleted_at')
+
class FieldTest(BaseTest):
"""
Test the CommaSeparatedUserField.
self.assertEqual(f.clean('foo'), [self.user1])
self.assertRaises(ValidationError, f.clean, 'foo, bar')
+
class MessageManagerTest(BaseTest):
"""
Test the Message manager.
|<------| x x
|------>
------>
- ------> x
- <------
+ ------> x
+ <------
...---
x X---
"""
- m1 = self.c12(moderation_status=STATUS_PENDING);
+ m1 = self.c12(moderation_status=STATUS_PENDING);
m2 = self.c12(moderation_status=STATUS_REJECTED, recipient_deleted_at=now())
m3 = self.c12()
m3.read_at, m3.thread = now(), m3
|<------| X X x x
|------>
X ------> X
- ------> X x
- X <------
+ ------> X x
+ X <------
...--- X
x X--- X
"""
self.check_status(m, status=STATUS_ACCEPTED, is_new=False, recipient_deleted_at=True)
self.check_now(m.read_at)
+
class MessageTest(BaseTest):
"""
Test the Message model.
self.check_status(r.parent, status=STATUS_ACCEPTED, thread=parent, is_replied=True)
# accepted -> rejected: parent is no more replied
r.update_parent(STATUS_ACCEPTED)
- p = Message.objects.get(pk=parent.pk)
+ p = Message.objects.get(pk=parent.pk)
self.check_status(p, status=STATUS_ACCEPTED, thread=parent)
# note: accepted -> rejected, with the existence of another suitable reply
# is covered in the accepted -> pending case
# pending -> pending: no change. In real case, parent.replied_at would be from another reply object
r.update_parent(STATUS_PENDING)
self.check_status(r.parent, status=STATUS_ACCEPTED, thread=parent, is_replied=True)
- # rejected -> pending: no change. In real case, parent.replied_at would be from another reply object
+ # rejected -> pending: no change. In real case, parent.replied_at would be from another reply object
r.update_parent(STATUS_REJECTED)
self.check_status(r.parent, status=STATUS_ACCEPTED, thread=parent, is_replied=True)
# accepted -> pending: parent is still replied but by another object
self.assertEqual(len(mail.outbox), mail_number)
if mail_number:
self.assertEqual(mail.outbox[0].to, [email])
- from utils import notification
+ from postman.utils import notification
if notification and notice_label:
notice = notification.Notice.objects.get()
self.assertEqual(notice.notice_type.label, notice_label)
settings.POSTMAN_AUTO_MODERATE_AS = False
self.check_auto_moderation(msg, seq, STATUS_REJECTED)
+
class PendingMessageManagerTest(BaseTest):
"""
Test the PendingMessage manager.
msg4 = self.create()
self.assertQuerysetEqual(PendingMessage.objects.all(), [msg4.pk, msg1.pk], transform=lambda x: x.pk)
+
class PendingMessageTest(BaseTest):
"""
Test the PendingMessage model.
m.set_rejected()
self.assert_(m.is_rejected())
-from django.utils.encoding import force_unicode
-from django.utils.formats import localize
+
class FiltersTest(BaseTest):
"""
Test the filters.
self.check_sub('6', "'X'", '6')
self.check_sub("'X'", '2', 'X')
- def check_or_me(self, x, value, user=None):
+ def check_or_me(self, x, value, user=None, m=None):
t = Template("{% load postman_tags %}{{ "+x+"|or_me:user }}") # do not load i18n to be able to check the untranslated pattern
- self.assertEqual(t.render(Context({'user': user or AnonymousUser()})), value)
+ self.assertEqual(t.render(Context({'user': user or AnonymousUser(), 'message': m})), value)
def test_or_me(self):
"Test '|or_me'."
self.check_or_me("'foo'", 'foo')
self.check_or_me("'foo'", '<me>', self.user1)
self.check_or_me("'bar'", 'bar', self.user1)
+ self.check_or_me("user", '<me>', self.user1)
+ m = self.c12()
+ self.check_or_me("message.obfuscated_sender", '<me>', self.user1, m=m)
+ self.check_or_me("message.obfuscated_recipient", 'bar', self.user1, m=m)
+ settings.POSTMAN_SHOW_USER_AS = 'email'
+ self.check_or_me("message.obfuscated_sender", '<me>', self.user1, m=m)
+ self.check_or_me("message.obfuscated_recipient", 'bar@domain.com', self.user1, m=m)
def check_compact_date(self, date, value, format='H:i,d b,d/m/y'):
# use 'H', 'd', 'm' instead of 'G', 'j', 'n' because no strftime equivalents
t = Template('{% load postman_tags %}{{ date|compact_date:"'+format+'" }}')
self.assertEqual(t.render(Context({'date': date})), value)
-
+
def test_compact_date(self):
"Test '|compact_date'."
dt = now()
dt = now() - timedelta(days=365)
self.check_compact_date(dt, dt.strftime('%d/%m/%y'))
+
class TagsTest(BaseTest):
"""
Test the template tags.
self.assertEqual(ctx['var'], 1)
self.assertRaises(TemplateSyntaxError, self.check_postman_unread, '', self.user1, 'as var extra')
self.assertRaises(TemplateSyntaxError, self.check_postman_unread, '', self.user1, 'As var')
-
+
def check_order_by(self, keyword, value_list, context=None):
t = Template("{% load postman_tags %}{% postman_order_by " + keyword +" %}")
r = t.render(Context({'gets': QueryDict(context)} if context else {}))
self.assertRaises(TemplateSyntaxError, self.check_order_by, 'subject extra', None)
self.assertRaises(TemplateSyntaxError, self.check_order_by, 'unknown', None)
+
class UtilsTest(BaseTest):
"""
Test helper functions.
self.assertEqual(format_subject("Re: foo bar"), "Re: foo bar")
self.assertEqual(format_subject("rE: foo bar"), "rE: foo bar")
-from postman.api import pm_broadcast, pm_write
+ def test_get_user_representation(self):
+ "Test get_user_representation()."
+ # no setting
+ self.assertEqual(get_user_representation(self.user1), "foo")
+ # a wrong setting
+ settings.POSTMAN_SHOW_USER_AS = 'unknown_attribute'
+ self.assertEqual(get_user_representation(self.user1), "foo")
+ # a valid setting but an empty attribute
+ settings.POSTMAN_SHOW_USER_AS = 'first_name'
+ self.assertEqual(get_user_representation(self.user1), "foo")
+ # a property name
+ settings.POSTMAN_SHOW_USER_AS = 'email'
+ self.assertEqual(get_user_representation(self.user1), "foo@domain.com")
+ settings.POSTMAN_SHOW_USER_AS = b'email'
+ self.assertEqual(get_user_representation(self.user1), "foo@domain.com")
+ # a method name
+ settings.POSTMAN_SHOW_USER_AS = 'get_absolute_url' # can't use get_full_name(), an empty string in our case
+ self.assertEqual(get_user_representation(self.user1), "/users/foo/")
+ # a function
+ settings.POSTMAN_SHOW_USER_AS = lambda u: u.get_absolute_url()
+ self.assertEqual(get_user_representation(self.user1), "/users/foo/")
+
+
class ApiTest(BaseTest):
"""
Test the API functions.