Commits

Patrick Samson  committed 7bb4570

Add a setting: POSTMAN_SHOW_USER_AS

  • Participants
  • Parent commits bbf231a

Comments (0)

Files changed (4)

File docs/quickstart.rst

     nor to a notifier application (refer to ``POSTMAN_NOTIFIER_APP``)
 
     *Defaults to*: False.
-    
+
 ``POSTMAN_AUTO_MODERATE_AS``
     The default moderation status when no auto-moderation functions, if any, were decisive.
 
     * Set this option to True
     * Do not provide any auto-moderation functions
 
+``POSTMAN_SHOW_USER_AS``
+    How to represent a User for display, in message properties: ``obfuscated_recipient`` and ``obfuscated_sender``,
+    and in the ``or_me`` filter. The value can be specified as:
+
+    * The name of a property of User. For example: 'last_name'
+    * The name of a method of User. For example: 'get_full_name'
+    * A function, receiving the User instance as the only parameter. For example: lambda u: u.get_profile().nickname
+    * ``None`` : the default text representation of the User (username) is used.
+
+    *Defaults to*: None.
+
+    The default behaviour is used as a fallback when: the value is a string and the result is false
+    (misspelled attribute name, empty result, ...), or the value is a function and an exception is raised
+    (but any result, even empty, is valid).
+
 ``POSTMAN_NOTIFIER_APP``
     A notifier application name, used in preference to the basic emailing,
     to notify users of their rejected or received messages.
 :file:`settings.py`::
 
     INSTALLED_APPS = (
+        # 'pagination'  # has to be before postman
         # ...
         'postman',
         # ...
-        # 'pagination'
         # 'ajax_select'
         # 'notification'
         # 'mailer'
     )
-    # POSTMAN_DISALLOW_ANONYMOUS = True # default is False
-    # POSTMAN_DISALLOW_MULTIRECIPIENTS = True # default is False
-    # POSTMAN_DISALLOW_COPIES_ON_REPLY = True # default is False
-    # POSTMAN_DISABLE_USER_EMAILING = True # default is False
-    # POSTMAN_AUTO_MODERATE_AS = True # default is None
-    # POSTMAN_NOTIFIER_APP = None # default is 'notification'
-    # POSTMAN_MAILER_APP = None # default is 'mailer'
+    # POSTMAN_DISALLOW_ANONYMOUS = True  # default is False
+    # POSTMAN_DISALLOW_MULTIRECIPIENTS = True  # default is False
+    # POSTMAN_DISALLOW_COPIES_ON_REPLY = True  # default is False
+    # POSTMAN_DISABLE_USER_EMAILING = True  # default is False
+    # POSTMAN_AUTO_MODERATE_AS = True  # default is None
+    # POSTMAN_SHOW_USER_AS = 'get_full_name'  # default is None
+    # POSTMAN_NOTIFIER_APP = None  # default is 'notification'
+    # POSTMAN_MAILER_APP = None  # default is 'mailer'
     # POSTMAN_AUTOCOMPLETER_APP = {
-        # 'name': '', # default is 'ajax_select'
-        # 'field': '', # default is 'AutoCompleteField'
-        # 'arg_name': '', # default is 'channel'
-        # 'arg_default': 'postman_friends', # no default, mandatory to enable the feature
-    # } # default is {}
+        # 'name': '',  # default is 'ajax_select'
+        # 'field': '',  # default is 'AutoCompleteField'
+        # 'arg_name': '',  # default is 'channel'
+        # 'arg_default': 'postman_friends',  # no default, mandatory to enable the feature
+    # }  # default is {}
 
 :file:`urls.py`::
 

File postman/models.py

 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.

File postman/templatetags/postman_tags.py

 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):
     """

File postman/tests.py

 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'", '&lt;me&gt;', self.user1)
         self.check_or_me("'bar'", 'bar', self.user1)
+        self.check_or_me("user", '&lt;me&gt;', self.user1)
+        m = self.c12()
+        self.check_or_me("message.obfuscated_sender", '&lt;me&gt;', 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", '&lt;me&gt;', 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.