]> git.parisson.com Git - django-postman.git/commitdiff
initial code upload 1.0.0
authorPatrick Samson <pk.samson@gmail.com>
Wed, 5 Jan 2011 08:42:25 +0000 (09:42 +0100)
committerPatrick Samson <pk.samson@gmail.com>
Wed, 5 Jan 2011 08:42:25 +0000 (09:42 +0100)
57 files changed:
docs/features.rst
docs/index.rst
docs/management.rst
docs/notification.rst [new file with mode: 0644]
docs/quickstart.rst
docs/tags-filters.rst
docs/views.rst
postman/__init__.py [new file with mode: 0644]
postman/admin.py [new file with mode: 0644]
postman/context_processors.py [new file with mode: 0644]
postman/fields.py [new file with mode: 0644]
postman/forms.py [new file with mode: 0644]
postman/locale/de/LC_MESSAGES/django.mo
postman/locale/de/LC_MESSAGES/django.po
postman/locale/en/LC_MESSAGES/django.po
postman/locale/es/LC_MESSAGES/django.mo
postman/locale/es/LC_MESSAGES/django.po
postman/locale/fr/LC_MESSAGES/django.mo
postman/locale/fr/LC_MESSAGES/django.po
postman/locale/it/LC_MESSAGES/django.mo
postman/locale/it/LC_MESSAGES/django.po
postman/locale/nl/LC_MESSAGES/django.mo [new file with mode: 0644]
postman/locale/nl/LC_MESSAGES/django.po [new file with mode: 0644]
postman/management/__init__.py [new file with mode: 0644]
postman/management/commands/__init__.py [new file with mode: 0644]
postman/management/commands/postman_checkup.py [new file with mode: 0644]
postman/management/commands/postman_cleanup.py [new file with mode: 0644]
postman/medias/postman/css/admin.css [new file with mode: 0644]
postman/models.py [new file with mode: 0644]
postman/templates/admin/postman/pendingmessage/change_form.html [new file with mode: 0644]
postman/templates/admin/postman/pendingmessage/submit_line.html [new file with mode: 0644]
postman/templates/autocomplete_postman_multiple.html [new file with mode: 0644]
postman/templates/autocomplete_postman_single.html [new file with mode: 0644]
postman/templates/postman/archives.html [new file with mode: 0644]
postman/templates/postman/base.html [new file with mode: 0644]
postman/templates/postman/base_folder.html [new file with mode: 0644]
postman/templates/postman/base_write.html [new file with mode: 0644]
postman/templates/postman/email_user.txt [new file with mode: 0644]
postman/templates/postman/email_user_subject.txt [new file with mode: 0644]
postman/templates/postman/email_visitor.txt [new file with mode: 0644]
postman/templates/postman/email_visitor_subject.txt [new file with mode: 0644]
postman/templates/postman/inbox.html [new file with mode: 0644]
postman/templates/postman/inc_subject_ex.html [new file with mode: 0644]
postman/templates/postman/reply.html [new file with mode: 0644]
postman/templates/postman/sent.html [new file with mode: 0644]
postman/templates/postman/trash.html [new file with mode: 0644]
postman/templates/postman/view.html [new file with mode: 0644]
postman/templates/postman/write.html [new file with mode: 0644]
postman/templatetags/__init__.py [new file with mode: 0644]
postman/templatetags/pagination_tags_for_tests.py [new file with mode: 0644]
postman/templatetags/postman_admin_modify.py [new file with mode: 0644]
postman/templatetags/postman_tags.py [new file with mode: 0644]
postman/test_urls.py [new file with mode: 0644]
postman/tests.py [new file with mode: 0644]
postman/urls.py [new file with mode: 0644]
postman/utils.py [new file with mode: 0644]
postman/views.py [new file with mode: 0644]

index 112a5c6d5c74f54c0e26aff08f0d2871e75f790d..1b690dd2949413892336a39e77548e5fe5e10689 100644 (file)
@@ -199,7 +199,7 @@ Auto-complete field
 An auto-complete functionality may be useful on the recipients field.\r
 \r
 To activate the option, set at least the ``arg_default`` key in the\r
-``POSTMAN_AUTOCOMPLETER_APP`` dictionary.  If the default ajax_select application is used,\r
+``POSTMAN_AUTOCOMPLETER_APP`` dictionary.  If the default ``ajax_select`` application is used,\r
 define a matching entry in the ``AJAX_LOOKUP_CHANNELS`` dictionary.\r
 \r
 Example::\r
@@ -211,13 +211,15 @@ Example::
         'arg_default': 'postman_users',\r
     }\r
 \r
-Support for multiple recipients is not turned on by default by django-ajax-selects.\r
+Support for multiple recipients is not turned on by default by `django-ajax-selects`_.\r
 To allow this capability, you have to pass the option ``multiple: true``.\r
 \r
+.. _`django-ajax-selects`: http://code.google.com/p/django-ajax-selects/\r
+\r
 Make your own templates, based on these two files, given as implementation examples:\r
 \r
-* postman/templates/autocomplete_postman_multiple.html\r
-* postman/templates/autocomplete_postman_single.html\r
+* :file:`postman/templates/autocomplete_postman_multiple.html`\r
+* :file:`postman/templates/autocomplete_postman_single.html`\r
 \r
 These examples include a correction necessary for the support of the 'multiple' option\r
 (in version 1.1.4 of django-ajax-selects).\r
index cb4ee4bdac744fb38b8e9766e6c3c61901099483..b8edc64e568e93b36b78384cb60e6e49ee9a3fe4 100644 (file)
@@ -22,10 +22,10 @@ an email address for the reply. The email is obfuscated to the recipient.
 What is a message ? Roughly a piece of text, about a subject, sent by a sender to a recipient.\r
 Each user has access to a collection of messages, stored in folders:\r
 \r
-    | ``Inbox`` for incoming messages\r
-    | ``Sent`` for sent messages\r
-    | ``Archives`` for archived messages\r
-    | ``Trash`` for messages marked as deleted\r
+    | **Inbox** for incoming messages\r
+    | **Sent** for sent messages\r
+    | **Archives** for archived messages\r
+    | **Trash** for messages marked as deleted\r
 \r
 In folders, messages can be presented in two modes:\r
 \r
@@ -53,8 +53,11 @@ It has support for optional additional applications:
 \r
 * Autocomplete recipient field (default is 'django-ajax-selects'),\r
   with multiple recipient management\r
-* New message notification (default is 'django-notification')\r
-* Asynchronous mailer (default is 'django-mailer')\r
+* New message notification (default is `django-notification`_)\r
+* Asynchronous mailer (default is `django-mailer`_)\r
+\r
+.. _`django-notification`: http://github.com/jtauber/django-notification/\r
+.. _`django-mailer`: http://github.com/jtauber/django-mailer/\r
 \r
 Moderation\r
 ----------\r
@@ -65,8 +68,8 @@ to the recipient.  Possible usages are:
 * to make sure that no direct contact informations are exchanged when the site is an intermediary\r
   and delivers services based on subscription fees.\r
 \r
-Messages are first created in a ``pending`` state. A moderator is in charge to change them to\r
-a ``rejected`` or ``accepted`` state.  This operation can be done in two ways:\r
+Messages are first created in a *pending* state. A moderator is in charge to change them to\r
+a *rejected* or *accepted* state.  This operation can be done in two ways:\r
 \r
 * By a person, through the Admin site. A specially simplified change view is provided,\r
   with one-click buttons to accept or reject the message.\r
@@ -89,6 +92,7 @@ Contents:
 \r
    quickstart\r
    moderation\r
+   notification\r
    views\r
    features\r
    tags-filters\r
index a196b4def6e43bb0748233c85d71fd5a4113022f..ba715f46f93b9dc1677c4db6d77ab0d6ae951ee2 100644 (file)
@@ -20,11 +20,11 @@ So there are some criteria to fulfill by a record to be really deleted from the
 \r
 A management command is provided for this purpose:\r
 \r
-**django-admin.py postman_cleanup**\r
+:command:`django-admin.py postman_cleanup`\r
 \r
 It can be run as a cron job or directly.\r
 \r
-The ``--days`` option can be used to specify the minimal number of days a message/conversation\r
+A :option:`--days` option can be used to specify the minimal number of days a message/conversation\r
 must have been marked for deletion.\r
 Default value is 30 days.\r
 \r
@@ -35,6 +35,6 @@ A management command to run a test suite on the messages presently in the databa
 It checks messages and conversations for possible inconsistencies, in a read-only mode.\r
 No change is made on the data.\r
 \r
-**django-admin.py postman_checkup**\r
+:command:`django-admin.py postman_checkup`\r
 \r
 It can be run directly or better as a nightly cron job.\r
diff --git a/docs/notification.rst b/docs/notification.rst
new file mode 100644 (file)
index 0000000..ec0de66
--- /dev/null
@@ -0,0 +1,47 @@
+Notification\r
+============\r
+\r
+Parties should be notified of these events:\r
+\r
+* when a message is rejected\r
+* when a message or a reply is received\r
+\r
+.. _for_visitors:\r
+\r
+For visitors\r
+------------\r
+An email is sent, using these templates:\r
+\r
+* :file:`postman/email_visitor_subject.txt` for the subject\r
+* :file:`postman/email_visitor.txt` for the body\r
+\r
+The available context variables are:\r
+\r
+* ``site``: the Site instance\r
+* ``object``: the Message instance\r
+* ``action``: 'rejection' or 'acceptance'\r
+\r
+Default templates are provided with the application. Same as for the views, you can override them,\r
+and design yours.\r
+\r
+For users\r
+---------\r
+If a notifier application is configured (see :ref:`optional_settings`), the following labels are used:\r
+\r
+* ``postman_rejection`` to notify the sender of the rejection\r
+* ``postman_message`` to notify the recipient of the reception of a message\r
+* ``postman_reply`` to notify the recipient of the reception of a reply\r
+\r
+Some extra context variables are passed in the call to the notifier application\r
+and so are available in the templates:\r
+\r
+* ``message``: the Message instance\r
+* ``action``: 'rejection' or 'acceptance'\r
+\r
+If no notifier application is used, an email is sent, using these templates:\r
+\r
+* :file:`postman/email_user_subject.txt` for the subject\r
+* :file:`postman/email_user.txt` for the body\r
+\r
+In that case, the information about context variables and templates is the same\r
+as in the :ref:`for_visitors` section above.\r
index 52001286068c3f3a3c8296f134e886c09ff7be66..9653a93d7866ffde0ca21c9dd91349044b4abdb5 100644 (file)
@@ -41,7 +41,7 @@ Required settings
 \r
 Add ``postman`` to the ``INSTALLED_APPS`` setting of your project.\r
 \r
-Run a ``manage.py syncdb``.\r
+Run a :command:`manage.py syncdb`\r
 \r
 Include the URLconf ``postman.urls`` in your project's root URL configuration.\r
 \r
@@ -54,7 +54,7 @@ If you want to make use of a ``postman_unread_count`` context variable in your t
 add ``postman.context_processors.inbox`` to the ``TEMPLATE_CONTEXT_PROCESSORS`` setting\r
 of your project.\r
 \r
-You may specify some additional configuration options in your ``settings.py``:\r
+You may specify some additional configuration options in your :file:`settings.py`:\r
 \r
 ``POSTMAN_DISALLOW_ANONYMOUS``\r
     Set it to True if you do not allow visitors to write to users.\r
@@ -82,6 +82,7 @@ You may specify some additional configuration options in your ``settings.py``:
     *Defaults to*: None.\r
 \r
     To disable the moderation feature (no control, no filter):\r
+\r
     * Set this option to True\r
     * Do not provide any auto-moderation functions\r
 \r
@@ -147,7 +148,7 @@ If the django-ajax-selects application is used, the following URLs are reference
 * {{ MEDIA_URL }}css/jquery.autocomplete.css\r
 * {{ MEDIA_URL }}css/indicator.gif\r
 \r
-The ``postman/base.html`` template extends a ``base.html`` site template,\r
+The :file:`postman/base.html` template extends a :file:`base.html` site template,\r
 in which some blocks are expected:\r
 \r
 * title: in <html><head><title>, at least for a part of the entire title string\r
@@ -157,14 +158,14 @@ in which some blocks are expected:
 \r
 Medias\r
 ~~~~~~\r
-A CSS file is provided with the application, for the Admin site: ``postman/css/admin.css``.\r
+A CSS file is provided with the application, for the Admin site: :file:`postman/css/admin.css`.\r
 It is not obligatory but makes the display more confortable.\r
 \r
-The file is provided under ``postman/medias/``. It's up to you to make it visible to the URL resolver.\r
+The file is provided under :file:`postman/medias/`. It's up to you to make it visible to the URL resolver.\r
 \r
 For example:\r
 \r
-* In a production environment, set /<MEDIA_URL>/postman/ as a symlink to <Postman_module>/medias/postman/\r
+* In a production environment, set :file:`/<MEDIA_URL>/postman/` as a symlink to :file:`<Postman_module>/medias/postman/`\r
 * In a development environment (django's runserver), you can put in the URLconf, something like::\r
 \r
     ('^' + settings.MEDIA_URL.strip('/') + r'/(?P<path>postman/.*)$', 'django.views.static.serve',\r
@@ -175,7 +176,7 @@ See also :ref:`styles` for the stylesheets of views.
 Examples\r
 --------\r
 \r
-``settings.py``::\r
+:file:`settings.py`::\r
 \r
     INSTALLED_APPS = (\r
         # ...\r
@@ -199,6 +200,6 @@ Examples
         # 'arg_default': 'postman_friends', # no default, mandatory to enable the feature\r
     # } # default is {}\r
 \r
-``urls.py``::\r
+:file:`urls.py`::\r
 \r
     (r'^messages/', include('postman.urls')),\r
index 440f9c02a55e0cd9b752979773886fad552a9613..d4a8a3f0bdb9d87660cae5f8db5213360d0eacb6 100644 (file)
@@ -5,15 +5,15 @@ The following tags and filters are available to your templates by loading the li
 \r
     {% load postman_tags %}\r
 \r
-Here are the other special libraries in the ``postman/templatetags/`` directory,\r
+Here are the other special libraries in the :file:`postman/templatetags/` directory,\r
 that are not intended for your site design:\r
 \r
-* ``postman_admin_modify.py``: a library exclusively designed for a customized change_form\r
+* :file:`postman_admin_modify.py`: a library exclusively designed for a customized change_form\r
   template used in the Admin site for the moderation of pending messages.\r
 \r
-* ``pagination_tags_for_tests.py``: a mock of the django-pagination application template tags,\r
+* :file:`pagination_tags_for_tests.py`: a mock of the django-pagination application template tags,\r
   only usable for the test suite in case the real application is not installed.\r
-  To rename to ``pagination_tags.py`` during the test session.\r
+  To rename to :file:`pagination_tags.py` during the test session.\r
 \r
 Tags\r
 ----\r
index 3be4473fa75a5be0d8809d37e6dfe1307c94625d..8d4ae32c424f9c67d668c10aa16f796a6daab16d 100644 (file)
@@ -5,7 +5,7 @@ Custom views
 \r
 styles\r
 ------\r
-Here is a sample of some CSS rules, usable for ``postman/views.html``::\r
+Here is a sample of some CSS rules, usable for :file:`postman/views.html`::\r
 \r
     .pm_message.pm_deleted             { text-decoration: line-through; }\r
     .pm_message.pm_deleted .pm_body    { display: none; }\r
diff --git a/postman/__init__.py b/postman/__init__.py
new file mode 100644 (file)
index 0000000..85a5ccd
--- /dev/null
@@ -0,0 +1,19 @@
+"""A messaging application for Django"""
+
+# following PEP 386: N.N[.N]+[{a|b|c|rc}N[.N]+][.postN][.devN]
+VERSION = (1, 0, 0)
+PREREL = ()
+POST = 0
+DEV = 0
+
+def get_version():
+    version = '.'.join(map(str, VERSION))
+    if PREREL:
+        version += PREREL[0] + '.'.join(map(str, PREREL[1:]))
+    if POST:
+        version += ".post" + str(POST)
+    if DEV:
+        version += ".dev" + str(DEV)
+    return version
+
+__version__ = get_version()
diff --git a/postman/admin.py b/postman/admin.py
new file mode 100644 (file)
index 0000000..d1286be
--- /dev/null
@@ -0,0 +1,183 @@
+from django import forms
+from django.contrib import admin
+from django.db import transaction
+from django.utils.translation import ugettext, ugettext_lazy as _
+
+from postman.models import Message, PendingMessage
+
+class MessageAdminForm(forms.ModelForm):
+    class Meta:
+        model = Message
+    class Media:
+        css = { "all": ("postman/css/admin.css",) }
+
+    def clean(self):
+        """Check data validity and coherence."""
+        cleaned_data = super(MessageAdminForm, self).clean()
+        sender = cleaned_data.get('sender')
+        recipient = cleaned_data.get('recipient')
+        email = cleaned_data.get('email')
+        errors = []
+        if not sender and not recipient:
+            errors.append(ugettext("Sender and Recipient cannot be both undefined."))
+            if 'sender' in cleaned_data:
+                del cleaned_data['sender']
+            if 'recipient' in cleaned_data:
+                del cleaned_data['recipient']
+        elif sender and recipient:
+            if email:
+                errors.append(ugettext("Visitor's email is in excess."))
+                if 'email' in cleaned_data:
+                    del cleaned_data['email']
+        else:
+            if not email:
+                errors.append(ugettext("Visitor's email is missing."))
+                if 'email' in cleaned_data:
+                    del cleaned_data['email']
+        sent_at = cleaned_data.get('sent_at')
+        read_at = cleaned_data.get('read_at')
+        if read_at and read_at < sent_at:
+            errors.append(ugettext("Reading date must be later to sending date."))
+            if 'read_at' in cleaned_data:
+                del cleaned_data['read_at']
+        sender_deleted_at = cleaned_data.get('sender_deleted_at')
+        if sender_deleted_at and sender_deleted_at < sent_at:
+            errors.append(ugettext("Deletion date by sender must be later to sending date."))
+            if 'sender_deleted_at' in cleaned_data:
+                del cleaned_data['sender_deleted_at']
+        recipient_deleted_at = cleaned_data.get('recipient_deleted_at')
+        if recipient_deleted_at and recipient_deleted_at < sent_at:
+            errors.append(ugettext("Deletion date by recipient must be later to sending date."))
+            if 'recipient_deleted_at' in cleaned_data:
+                del cleaned_data['recipient_deleted_at']
+        replied_at = cleaned_data.get('replied_at')
+        obj = self.instance
+        if replied_at:
+            len_begin = len(errors)
+            if replied_at < sent_at:
+                errors.append(ugettext("Response date must be later to sending date."))
+            if not read_at:
+                errors.append(ugettext("The message cannot be replied without having been read."))
+            elif replied_at < read_at:
+                errors.append(ugettext("Response date must be later to reading date."))
+            if not obj.get_replies_count():
+                errors.append(ugettext("Response date cannot be set without at least one reply."))
+            if not obj.thread_id:
+                errors.append(ugettext("The message cannot be replied without being in a conversation."))
+            if len(errors) > len_begin:
+                if 'replied_at' in cleaned_data:
+                    del cleaned_data['replied_at']
+        # if obj.parent_id and not obj.thread_id:# can't be set by the form
+        if errors:
+            raise forms.ValidationError(errors)
+
+        self.initial_status = obj.moderation_status
+        return cleaned_data
+
+class MessageAdmin(admin.ModelAdmin):
+    form = MessageAdminForm
+    search_fields = ('subject', 'body')
+    date_hierarchy = 'sent_at'
+    list_display = ('subject', 'admin_sender', 'admin_recipient', 'sent_at', 'moderation_status')
+    list_filter = ('moderation_status', )
+    fieldsets = (
+        (None, {'fields': (
+            ('sender', 'recipient', 'email'),
+            'sent_at',
+            )}),
+        (_('Message'), {'fields': (
+            'subject',
+            'body',
+            ('parent', 'thread'),
+            )}),
+        (_('Dates'), {'classes': ('collapse', ), 'fields': (
+            ('read_at', 'replied_at'),
+            ('sender_archived', 'recipient_archived'),
+            ('sender_deleted_at', 'recipient_deleted_at'),
+            )}),
+        (_('Moderation'), {'fields': (
+            ('moderation_status', 'moderation_date', 'moderation_by'),
+            'moderation_reason',
+            )}),
+    )
+    readonly_fields = (
+        'parent', 'thread', # no reason to change, and anyway too many objects
+        'moderation_date', 'moderation_by', # automatically set at status change
+    )
+    radio_fields = {'moderation_status': admin.VERTICAL}
+
+    def queryset(self, request):
+        """
+        Add a custom select_related() to avoid a bunch of queries for users
+        in the 'change list' admin view.
+
+        Setting 'list_select_related = True' is not efficient as the default
+        select_related() does not follow foreign keys that have null=True.
+
+        """
+        return super(MessageAdmin, self).queryset(request).select_related('sender', 'recipient')
+
+    @transaction.commit_on_success
+    def save_model(self, request, obj, form, change):
+        """
+        Add some actions around the save.
+
+        Before the save, adjust some constrained fields.
+        After the save, update related objects and notify parties if needed.
+
+        """
+        obj.clean_moderation(form.initial_status, request.user)
+        obj.clean_for_visitor()
+        super(MessageAdmin, self).save_model(request, obj, form, change)
+        obj.update_parent(form.initial_status)
+        obj.notify_users(form.initial_status)
+
+class PendingMessageAdminForm(forms.ModelForm):
+    class Meta:
+        model = PendingMessage
+    class Media:
+        css = { "all": ("postman/css/admin.css",) }
+
+    def clean(self):
+        """Set status according to the button used to submit."""
+        cleaned_data = super(PendingMessageAdminForm, self).clean()
+        obj = self.instance
+        self.initial_status = obj.moderation_status
+        # look for for button names provided by custom admin/postman/pendingmessage/change_form.html
+        if '_saveasaccepted' in self.data:
+            obj.set_accepted()
+        elif '_saveasrejected' in self.data:
+            obj.set_rejected()
+        return cleaned_data
+
+class PendingMessageAdmin(MessageAdmin):
+    form = PendingMessageAdminForm
+    search_fields = ()
+    date_hierarchy = None
+    actions = None
+    list_display = ('subject', 'admin_sender', 'admin_recipient', 'sent_at')
+    list_filter = ()
+    fieldsets = (
+        (None, {'fields': (
+            'admin_sender', 'admin_recipient', 'sent_at',
+            )}),
+        (_('Message'), {'fields': (
+            'subject',
+            'body',
+            )}),
+        (_('Moderation'), {'fields': (
+            'moderation_reason',
+            )}),
+    )
+    readonly_fields = ('admin_sender', 'admin_recipient')
+
+    def has_add_permission(self, request):
+        "Adding is impossible"
+        return False
+
+    def has_delete_permission(self, request, obj=None):
+        "Deleting is impossible"
+        return False
+
+admin.site.register(Message, MessageAdmin)
+admin.site.register(PendingMessage, PendingMessageAdmin)
diff --git a/postman/context_processors.py b/postman/context_processors.py
new file mode 100644 (file)
index 0000000..b4054cc
--- /dev/null
@@ -0,0 +1,8 @@
+from postman.models import Message
+
+def inbox(request):
+    """Provide the count of unread messages for an authenticated user."""
+    if request.user.is_authenticated():
+        return {'postman_unread_count': Message.objects.inbox_unread_count(request.user)}
+    else:
+        return {}
diff --git a/postman/fields.py b/postman/fields.py
new file mode 100644 (file)
index 0000000..c2c70a8
--- /dev/null
@@ -0,0 +1,120 @@
+"""
+Custom fields.
+"""
+
+from django.conf import settings
+from django.contrib.auth.models import User
+from django.core.exceptions import ValidationError
+from django.core.validators import EMPTY_VALUES
+from django.forms.fields import CharField
+from django.utils.translation import ugettext_lazy as _
+
+class BasicCommaSeparatedUserField(CharField):
+    """
+    An internal base class for CommaSeparatedUserField.
+
+    This class is not intended to be used directly in forms.
+    Use CommaSeparatedUserField instead,
+    to benefit from the auto-complete fonctionality if available.
+
+    """
+    default_error_messages = {
+        'unknown': _("Some usernames are unknown or no more active: {users}."),
+        'max': _("Ensure this value has at most {limit_value} distinct items (it has {show_value})."),
+        'min': _("Ensure this value has at least {limit_value} distinct items (it has {show_value})."),
+        'filtered': _("Some usernames are rejected: {users}."),
+        'filtered_user': _("{user.username}"),
+        'filtered_user_with_reason': _("{user.username} ({reason})"),
+    }
+
+    def __init__(self, max=None, min=None, user_filter=None, *args, **kwargs):
+        self.max, self.min, self.user_filter = max, min, user_filter
+        label = kwargs.get('label')
+        if isinstance(label, tuple):
+            self.pluralized_labels = label
+            kwargs.update(label=label[max == 1])
+        super(BasicCommaSeparatedUserField, self).__init__(*args, **kwargs)
+
+    def set_max(self, max):
+        """Supersede the max value and ajust accordingly the label."""
+        pluralized_labels = getattr(self, 'pluralized_labels', None)
+        if pluralized_labels:
+            self.label = pluralized_labels[max == 1]
+        self.max = max
+
+    def to_python(self, value):
+        """Normalize data to an unordered list of distinct, non empty, whitespace-stripped strings."""
+        value = super(BasicCommaSeparatedUserField, self).to_python(value)
+        if value in EMPTY_VALUES: # Return an empty list if no useful input was given.
+            return []
+        return list(set([name.strip() for name in value.split(',') if name and not name.isspace()]))
+
+    def validate(self, value):
+        """Check the limits."""
+        super(BasicCommaSeparatedUserField, self).validate(value)
+        if value in EMPTY_VALUES:
+            return
+        count = len(value)
+        if self.max and count > self.max:
+            raise ValidationError(self.error_messages['max'].format(limit_value=self.max, show_value=count))
+        if self.min and count < self.min:
+            raise ValidationError(self.error_messages['min'].format(limit_value=self.min, show_value=count))
+
+    def clean(self, value):
+        """Check names are valid and filter them."""
+        names = super(BasicCommaSeparatedUserField, self).clean(value)
+        if not names:
+            return []
+        users = list(User.objects.filter(is_active=True, username__in=names))
+        unknown_names = set(names) ^ set([u.username for u in users])
+        errors = []
+        if unknown_names:
+            errors.append(self.error_messages['unknown'].format(users=', '.join(unknown_names)))
+        if self.user_filter:
+            filtered_names = []
+            for u in users[:]:
+                try:
+                    reason = self.user_filter(u)
+                    if reason is not None:
+                        users.remove(u)
+                        filtered_names.append(
+                            self.error_messages[
+                                'filtered_user_with_reason' if reason else 'filtered_user'
+                            ].format(user=u, reason=reason)
+                        )
+                except ValidationError, e:
+                    users.remove(u)
+                    errors.extend(e.messages)
+            if filtered_names:
+                errors.append(self.error_messages['filtered'].format(users=', '.join(filtered_names)))
+        if errors:
+            raise ValidationError(errors)
+        return users
+
+d = getattr(settings, 'POSTMAN_AUTOCOMPLETER_APP', {})
+app_name = d.get('name', 'ajax_select')
+field_name = d.get('field', 'AutoCompleteField')
+arg_name = d.get('arg_name', 'channel')
+arg_default = d.get('arg_default') # the minimum to declare to enable the feature
+
+if app_name in settings.INSTALLED_APPS and arg_default:
+    # does something like "from ajax_select.fields import AutoCompleteField"
+    auto_complete_field = getattr(__import__(app_name + '.fields', globals(), locals(), [field_name]), field_name)
+    is_autocompleted = True
+
+    class CommaSeparatedUserField(BasicCommaSeparatedUserField, auto_complete_field):
+        def __init__(self, *args, **kwargs):
+            if not args and arg_name not in kwargs:
+                kwargs.update([(arg_name,arg_default)])
+            super(CommaSeparatedUserField, self).__init__(*args, **kwargs)
+
+        def set_arg(self, value):
+            """Same as it is done in ajax_select.fields.py for Fields and Widgets."""
+            if hasattr(self, arg_name):
+                setattr(self, arg_name, value)
+            if hasattr(self.widget, arg_name):
+                setattr(self.widget, arg_name, value)
+
+else:
+    CommaSeparatedUserField = BasicCommaSeparatedUserField
+    is_autocompleted = False
diff --git a/postman/forms.py b/postman/forms.py
new file mode 100644 (file)
index 0000000..5b4287d
--- /dev/null
@@ -0,0 +1,197 @@
+"""
+You may define your own custom forms, based or inspired by the following ones.
+
+Examples of customization:
+    recipients = CommaSeparatedUserField(label=("Recipients", "Recipient"),
+        min=2,
+        max=5,
+        user_filter=my_user_filter,
+        channel='my_channel',
+    )
+    can_overwrite_limits = False
+    exchange_filter = staticmethod(my_exchange_filter)
+
+"""
+from django import forms
+from django.conf import settings
+from django.contrib.auth.models import User
+from django.db import transaction
+from django.utils.translation import ugettext, ugettext_lazy as _
+
+from postman.fields import CommaSeparatedUserField
+from postman.models import Message
+from postman.utils import WRAP_WIDTH
+
+class BaseWriteForm(forms.ModelForm):
+    """The base class for other forms."""
+    class Meta:
+        model = Message
+        fields = ('body',)
+        widgets = {
+            # for better confort, ensure a 'cols' of at least
+            # the 'width' of the body quote formatter.
+            'body': forms.Textarea(attrs={'cols': WRAP_WIDTH, 'rows': 12}),
+        }
+
+    error_css_class = 'error'
+    required_css_class = 'required'
+
+    def __init__(self, *args, **kwargs):
+        sender = kwargs.pop('sender', None)
+        exchange_filter = kwargs.pop('exchange_filter', None)
+        user_filter = kwargs.pop('user_filter', None)
+        max = kwargs.pop('max', None)
+        channel = kwargs.pop('channel', None)
+        super(BaseWriteForm, self).__init__(*args, **kwargs)
+
+        self.instance.sender = sender if (sender and sender.is_authenticated()) else None
+        if exchange_filter:
+            self.exchange_filter = exchange_filter
+        if 'recipients' in self.fields:
+            if user_filter and hasattr(self.fields['recipients'], 'user_filter'):
+                self.fields['recipients'].user_filter = user_filter
+
+            if getattr(settings, 'POSTMAN_DISALLOW_MULTIRECIPIENTS', False):
+                max = 1
+            if max is not None and hasattr(self.fields['recipients'], 'set_max') \
+            and getattr(self, 'can_overwrite_limits', True):
+                self.fields['recipients'].set_max(max)
+
+            if channel and hasattr(self.fields['recipients'], 'set_arg'):
+                self.fields['recipients'].set_arg(channel)
+
+    error_messages = {
+        'filtered': _("Writing to some users is not possible: {users}."),
+        'filtered_user': _("{user.username}"),
+        'filtered_user_with_reason': _("{user.username} ({reason})"),
+    }
+    def clean_recipients(self):
+        """Check no filter prohibit the exchange."""
+        recipients = self.cleaned_data['recipients']
+        exchange_filter = getattr(self, 'exchange_filter', None)
+        if exchange_filter:
+            errors = []
+            filtered_names = []
+            recipients_list = recipients[:]
+            for u in recipients_list:
+                try:
+                    reason = exchange_filter(self.instance.sender, u, recipients_list)
+                    if reason is not None:
+                        recipients.remove(u)
+                        filtered_names.append(
+                            self.error_messages[
+                                'filtered_user_with_reason' if reason else 'filtered_user'
+                            ].format(user=u, reason=reason)
+                        )
+                except forms.ValidationError, e:
+                    recipients.remove(u)
+                    errors.extend(e.messages)
+            if filtered_names:
+                errors.append(self.error_messages['filtered'].format(users=', '.join(filtered_names)))
+            if errors:
+                raise forms.ValidationError(errors)
+        return recipients
+
+    @transaction.commit_on_success
+    def save(self, recipient=None, parent=None, auto_moderators=[]):
+        """
+        Save as many messages as there are recipients.
+
+        Additional actions:
+        - If it's a reply, build a conversation
+        - Call auto-moderators
+        - Notify parties if needed
+
+        Return False if one of the messages is rejected.
+
+        """
+        recipients = self.cleaned_data.get('recipients', [])
+        if parent and not parent.thread_id: # at the very first reply, make it a conversation
+            parent.thread = parent
+            parent.save()
+            # but delay the setting of parent.replied_at to the moderation step
+        if parent:
+            self.instance.parent = parent
+            self.instance.thread_id = parent.thread_id
+        initial_moderation = self.instance.get_moderation()
+        initial_dates = self.instance.get_dates()
+        initial_status = self.instance.moderation_status
+        if recipient:
+            if isinstance(recipient, User) and recipient in recipients:
+                recipients.remove(recipient)
+            recipients.insert(0, recipient)
+        is_successful = True
+        for r in recipients:
+            if isinstance(r, User):
+                self.instance.recipient = r
+            else:
+                self.instance.recipient = None
+                self.instance.email = r
+            self.instance.pk = None # force_insert=True is not accessible from here
+            self.instance.auto_moderate(auto_moderators)
+            self.instance.clean_moderation(initial_status)
+            self.instance.clean_for_visitor()
+            m = super(BaseWriteForm, self).save()
+            if self.instance.is_rejected():
+                is_successful = False
+            self.instance.update_parent(initial_status)
+            self.instance.notify_users(initial_status)
+            # some resets for next reuse
+            if not isinstance(r, User):
+                self.instance.email = ''
+            self.instance.set_moderation(*initial_moderation)
+            self.instance.set_dates(*initial_dates)
+        return is_successful
+
+class WriteForm(BaseWriteForm):
+    """The form for an authenticated user, to compose a message."""
+    recipients = CommaSeparatedUserField(label=(_("Recipients"), _("Recipient")))
+
+    class Meta(BaseWriteForm.Meta):
+        fields = ('recipients', 'subject', 'body')
+
+class AnonymousWriteForm(BaseWriteForm):
+    """The form for an anonymous user, to compose a message."""
+    # The 'max' customization should not be permitted here.
+    # The features available to anonymous users should be kept to the strict minimum.
+    can_overwrite_limits = False
+
+    email = forms.EmailField(label=_("Email"))
+    recipients = CommaSeparatedUserField(label=(_("Recipients"), _("Recipient")), max=1) # one recipient is enough
+
+    class Meta(BaseWriteForm.Meta):
+        fields = ('email', 'recipients', 'subject', 'body')
+
+class BaseReplyForm(BaseWriteForm):
+    """The base class for a reply to a message."""
+    def __init__(self, *args, **kwargs):
+        recipient = kwargs.pop('recipient', None)
+        super(BaseReplyForm, self).__init__(*args, **kwargs)
+        self.recipient = recipient
+
+    def clean(self):
+        """Check that the recipient is correctly initialized."""
+        if not self.recipient:
+            raise forms.ValidationError(ugettext("Undefined recipient."))
+        return super(BaseReplyForm, self).clean()
+
+    def save(self, *args, **kwargs):
+        return super(BaseReplyForm, self).save(self.recipient, *args, **kwargs)
+
+class QuickReplyForm(BaseReplyForm):
+    """
+    The form to use in the view of a message or a conversation, for a quick reply.
+
+    The recipient is imposed and a default value for the subject will be provided.
+
+    """
+    pass
+
+allow_copies = not getattr(settings, 'POSTMAN_DISALLOW_COPIES_ON_REPLY', False)
+class FullReplyForm(BaseReplyForm):
+    """The complete reply form."""
+    if allow_copies:
+        recipients = CommaSeparatedUserField(label=(_("Additional recipients"), _("Additional recipient")), required=False)
+
+    class Meta(BaseReplyForm.Meta):
+        fields = (['recipients'] if allow_copies else []) + ['subject', 'body']
index 4545852f31dbdbbaa9fde6926aec891a994ab844..118c8922d980ea9cbf4a9be66b93c84c078c34d8 100644 (file)
Binary files a/postman/locale/de/LC_MESSAGES/django.mo and b/postman/locale/de/LC_MESSAGES/django.mo differ
index ea7be1a5064cf4559381dc3972b59ae482a27c27..4c346ce28e8c92211279bba2aaee1d02c51b61b3 100644 (file)
@@ -2,12 +2,12 @@
 # Copyright (C) YEAR THE PACKAGE'S COPYRIGHT HOLDER
 # This file is distributed under the same license as the PACKAGE package.
 # FIRST AUTHOR <EMAIL@ADDRESS>, YEAR.
-# 
+#
 msgid ""
 msgstr ""
 "Project-Id-Version: django-postman\n"
-"Report-Msgid-Bugs-To: http://bitbucket.org/psam/django-postman/issues\n"
-"POT-Creation-Date: 2010-12-24 18:38+0100\n"
+"Report-Msgid-Bugs-To: \n"
+"POT-Creation-Date: 2010-12-27 14:44+0100\n"
 "PO-Revision-Date: 2010-12-25 11:36+0000\n"
 "Last-Translator: psam <maxcom@laposte.net>\n"
 "Language-Team: LANGUAGE <LL@li.org>\n"
@@ -58,7 +58,7 @@ msgid "Response date cannot be set without at least one reply."
 msgstr ""
 
 #: .\admin.py:66
-msgid "The message cannot be replied without being in a thread."
+msgid "The message cannot be replied without being in a conversation."
 msgstr ""
 
 #: .\admin.py:88 .\admin.py:157 .\templates\postman\view.html.py:5
@@ -78,11 +78,15 @@ msgid "Some usernames are unknown or no more active: {users}."
 msgstr ""
 
 #: .\fields.py:23
-msgid "Ensure this value has at most {limit_value} distinct items (it has {show_value})."
+msgid ""
+"Ensure this value has at most {limit_value} distinct items (it has "
+"{show_value})."
 msgstr ""
 
 #: .\fields.py:24
-msgid "Ensure this value has at least {limit_value} distinct items (it has {show_value})."
+msgid ""
+"Ensure this value has at least {limit_value} distinct items (it has "
+"{show_value})."
 msgstr ""
 
 #: .\fields.py:25
@@ -101,28 +105,28 @@ msgstr "{user.username} ({reason})"
 msgid "Writing to some users is not possible: {users}."
 msgstr ""
 
-#: .\forms.py:149 .\forms.py:161
+#: .\forms.py:148 .\forms.py:160
 msgid "Recipients"
 msgstr ""
 
-#: .\forms.py:149 .\forms.py:161 .\templates\postman\base_folder.html.py:26
+#: .\forms.py:148 .\forms.py:160 .\templates\postman\base_folder.html.py:26
 #: .\templates\postman\reply.html.py:4
 msgid "Recipient"
 msgstr "Empfänger"
 
-#: .\forms.py:160
+#: .\forms.py:159
 msgid "Email"
 msgstr "E-Mail"
 
-#: .\forms.py:176
+#: .\forms.py:175
 msgid "Undefined recipient."
 msgstr ""
 
-#: .\forms.py:195
+#: .\forms.py:194
 msgid "Additional recipients"
 msgstr ""
 
-#: .\forms.py:195
+#: .\forms.py:194
 msgid "Additional recipient"
 msgstr ""
 
@@ -146,87 +150,87 @@ msgstr "betreff"
 msgid "body"
 msgstr "inhalt"
 
-#: .\models.py:199 .\models.py:282
+#: .\models.py:199 .\models.py:281
 msgid "sender"
 msgstr "absender"
 
-#: .\models.py:200 .\models.py:306
+#: .\models.py:200 .\models.py:305
 msgid "recipient"
 msgstr "empfänger"
 
-#: .\models.py:202
+#: .\models.py:201
 msgid "visitor"
-msgstr ""
+msgstr "besucher"
 
-#: .\models.py:203
+#: .\models.py:202
 msgid "parent message"
 msgstr "Übergeordnete nachricht"
 
-#: .\models.py:204
+#: .\models.py:203
 msgid "root message"
 msgstr ""
 
-#: .\models.py:205
+#: .\models.py:204
 msgid "sent at"
 msgstr "gesendet am"
 
-#: .\models.py:206
+#: .\models.py:205
 msgid "read at"
 msgstr "gelesen am"
 
-#: .\models.py:207
+#: .\models.py:206
 msgid "replied at"
 msgstr "beantwortet am"
 
-#: .\models.py:208
+#: .\models.py:207
 msgid "archived by sender"
-msgstr ""
+msgstr "vom absender archiviert"
 
-#: .\models.py:209
+#: .\models.py:208
 msgid "archived by recipient"
-msgstr ""
+msgstr "vom empfänger archiviert"
 
-#: .\models.py:210
+#: .\models.py:209
 msgid "deleted by sender at"
 msgstr "vom absender gelöscht am"
 
-#: .\models.py:211
+#: .\models.py:210
 msgid "deleted by recipient at"
 msgstr "vom empfänger gelöscht am"
 
-#: .\models.py:213
+#: .\models.py:212
 msgid "status"
 msgstr "status"
 
-#: .\models.py:215
+#: .\models.py:214
 msgid "moderator"
 msgstr ""
 
-#: .\models.py:216
+#: .\models.py:215
 msgid "moderated at"
 msgstr ""
 
-#: .\models.py:217
+#: .\models.py:216
 msgid "rejection reason"
 msgstr ""
 
-#: .\models.py:222
+#: .\models.py:221
 msgid "message"
 msgstr "nachricht"
 
-#: .\models.py:223
+#: .\models.py:222
 msgid "messages"
 msgstr "nachrichten"
 
-#: .\models.py:334
+#: .\models.py:333
 msgid "Undefined sender."
 msgstr ""
 
-#: .\models.py:478
+#: .\models.py:473
 msgid "pending message"
 msgstr ""
 
-#: .\models.py:479
+#: .\models.py:474
 msgid "pending messages"
 msgstr ""
 
@@ -258,21 +262,21 @@ msgstr "Nachricht erfolgreich gesendet."
 msgid "Message rejected for at least one recipient."
 msgstr ""
 
-#: .\views.py:277
+#: .\views.py:276
 msgid "Select at least one object."
 msgstr ""
 
-#: .\views.py:283
-msgid "Message(s) or thread(s) successfully archived."
-msgstr ""
+#: .\views.py:282
+msgid "Messages or conversations successfully archived."
+msgstr "Nachrichten oder Konversationen erfolgreich archiviert."
 
-#: .\views.py:288
-msgid "Message(s) or thread(s) successfully deleted."
-msgstr ""
+#: .\views.py:287
+msgid "Messages or conversations successfully deleted."
+msgstr "Nachrichten oder Konversationen erfolgreich gelöscht."
 
-#: .\views.py:293
-msgid "Message(s) or thread(s) successfully recovered."
-msgstr ""
+#: .\views.py:292
+msgid "Messages or conversations successfully recovered."
+msgstr "Nachrichten oder Konversationen erfolgreich wiederhergestellt."
 
 #: .\management\__init__.py:14
 msgid "Message Rejected"
@@ -314,10 +318,12 @@ msgstr ""
 
 #: .\templates\postman\archives.html.py:3
 msgid "Archived Messages"
-msgstr ""
+msgstr "Archivierte Nachrichten"
 
 #: .\templates\postman\archives.html.py:7
-msgid "Messages in this folder will never be removed. You can use this folder for long term storage."
+msgid ""
+"Messages in this folder will never be removed. You can use this folder for "
+"long term storage."
 msgstr ""
 
 #: .\templates\postman\base.html.py:3
@@ -338,7 +344,7 @@ msgstr "Schreiben"
 
 #: .\templates\postman\base.html.py:9
 msgid "Archives"
-msgstr ""
+msgstr "Archiven"
 
 #: .\templates\postman\base.html.py:10
 msgid "Trash"
@@ -349,12 +355,12 @@ msgid "Sorry, this page number is invalid."
 msgstr "Sorry, diese Seite ist ungültig."
 
 #: .\templates\postman\base_folder.html.py:12
-msgid "by thread"
-msgstr ""
+msgid "by conversation"
+msgstr "nach Konversation"
 
 #: .\templates\postman\base_folder.html.py:13
 msgid "by message"
-msgstr ""
+msgstr "nach Nachricht"
 
 #: .\templates\postman\base_folder.html.py:17
 #: .\templates\postman\view.html.py:22
@@ -364,7 +370,7 @@ msgstr "Löschen"
 #: .\templates\postman\base_folder.html.py:18
 #: .\templates\postman\view.html.py:23
 msgid "Archive"
-msgstr ""
+msgstr "Archivieren"
 
 #: .\templates\postman\base_folder.html.py:19
 msgid "Undelete"
@@ -400,7 +406,7 @@ msgstr "Senden"
 
 #: .\templates\postman\email_user.txt.py:1
 msgid "Dear user,"
-msgstr ""
+msgstr "Sehr geehrter Benutzer,"
 
 #: .\templates\postman\email_user.txt.py:3
 #: .\templates\postman\email_visitor.txt.py:3
@@ -422,7 +428,8 @@ msgstr ""
 #: .\templates\postman\email_visitor.txt.py:10
 #, python-format
 msgid "On %(date)s, you sent a message to the user '%(sender)s'."
-msgstr "Am %(date)s, du hast eine Nachricht an den Benutzer '%(sender)s' gesendet."
+msgstr ""
+"Am %(date)s, du hast eine Nachricht an den Benutzer '%(sender)s' gesendet."
 
 #: .\templates\postman\email_user.txt.py:10
 msgid "Your correspondent has given you an answer."
@@ -436,7 +443,7 @@ msgstr ""
 #: .\templates\postman\email_user.txt.py:13
 #, python-format
 msgid "You have received a message from the user '%(sender)s'."
-msgstr "Du hast eine Nachricht von den Benutzer  '%(sender)s' erhalten."
+msgstr "Du hast eine Nachricht von den Benutzer '%(sender)s' erhalten."
 
 #: .\templates\postman\email_user.txt.py:16
 #: .\templates\postman\email_visitor.txt.py:14
@@ -459,7 +466,7 @@ msgstr ""
 #: .\templates\postman\email_visitor_subject.txt.py:1
 #, python-format
 msgid "Message \"%(subject)s\" on the site %(sitename)s"
-msgstr ""
+msgstr "Nachricht \"%(subject)s\" auf der site %(sitename)s"
 
 #: .\templates\postman\email_visitor.txt.py:1
 msgid "Dear visitor,"
@@ -499,12 +506,14 @@ msgid "Deleted Messages"
 msgstr "Gelöschte Nachrichten"
 
 #: .\templates\postman\trash.html.py:10
-msgid "Messages in this folder can be removed from time to time. For long term storage, use instead the archive folder."
+msgid ""
+"Messages in this folder can be removed from time to time. For long term "
+"storage, use instead the archive folder."
 msgstr ""
 
 #: .\templates\postman\view.html.py:5
-msgid "Thread"
-msgstr ""
+msgid "Conversation"
+msgstr "Konversation"
 
 #: .\templates\postman\view.html.py:13
 msgid ":"
@@ -514,6 +523,6 @@ msgstr " :"
 msgid "Back"
 msgstr "Zurück"
 
-#: .\templatetags\postman_tags.py:34
+#: .\templatetags\postman_tags.py:35
 msgid "<me>"
-msgstr "<ich>"
+msgstr "<Ich>"
index 2693ee20499062cf0800b198a490a41daa27bfb0..48b5ddeef89c41cdd9baae63007ff58766287fb0 100644 (file)
@@ -7,7 +7,7 @@ msgid ""
 msgstr ""
 "Project-Id-Version: PACKAGE VERSION\n"
 "Report-Msgid-Bugs-To: \n"
-"POT-Creation-Date: 2010-12-27 13:35+0100\n"
+"POT-Creation-Date: 2010-12-27 14:21+0100\n"
 "PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\n"
 "Last-Translator: FULL NAME <EMAIL@ADDRESS>\n"
 "Language-Team: LANGUAGE <LL@li.org>\n"
@@ -261,15 +261,15 @@ msgid "Select at least one object."
 msgstr ""
 
 #: .\views.py:282
-msgid "Message(s) or conversation(s) successfully archived."
+msgid "Messages or conversations successfully archived."
 msgstr ""
 
 #: .\views.py:287
-msgid "Message(s) or conversation(s) successfully deleted."
+msgid "Messages or conversations successfully deleted."
 msgstr ""
 
 #: .\views.py:292
-msgid "Message(s) or conversation(s) successfully recovered."
+msgid "Messages or conversations successfully recovered."
 msgstr ""
 
 #: .\management\__init__.py:14
index 3c1150b1c971613ac2c137bdcbd1bb4e2fe8f334..9b2a38e3d34cca22b991ccaff54634bf93fec422 100644 (file)
Binary files a/postman/locale/es/LC_MESSAGES/django.mo and b/postman/locale/es/LC_MESSAGES/django.mo differ
index f1eb8f04807dfc5cd3b57d5d97ad261e35774906..8c95cabb8dbaa22b8d776f2f7f15097c1dc68fda 100644 (file)
@@ -2,12 +2,12 @@
 # Copyright (C) YEAR THE PACKAGE'S COPYRIGHT HOLDER
 # This file is distributed under the same license as the PACKAGE package.
 # FIRST AUTHOR <EMAIL@ADDRESS>, YEAR.
-# 
+#
 msgid ""
 msgstr ""
 "Project-Id-Version: django-postman\n"
-"Report-Msgid-Bugs-To: http://bitbucket.org/psam/django-postman/issues\n"
-"POT-Creation-Date: 2010-12-24 18:38+0100\n"
+"Report-Msgid-Bugs-To: \n"
+"POT-Creation-Date: 2010-12-27 15:16+0100\n"
 "PO-Revision-Date: 2010-12-25 15:29+0000\n"
 "Last-Translator: psam <maxcom@laposte.net>\n"
 "Language-Team: LANGUAGE <LL@li.org>\n"
@@ -58,7 +58,7 @@ msgid "Response date cannot be set without at least one reply."
 msgstr ""
 
 #: .\admin.py:66
-msgid "The message cannot be replied without being in a thread."
+msgid "The message cannot be replied without being in a conversation."
 msgstr ""
 
 #: .\admin.py:88 .\admin.py:157 .\templates\postman\view.html.py:5
@@ -78,11 +78,15 @@ msgid "Some usernames are unknown or no more active: {users}."
 msgstr ""
 
 #: .\fields.py:23
-msgid "Ensure this value has at most {limit_value} distinct items (it has {show_value})."
+msgid ""
+"Ensure this value has at most {limit_value} distinct items (it has "
+"{show_value})."
 msgstr ""
 
 #: .\fields.py:24
-msgid "Ensure this value has at least {limit_value} distinct items (it has {show_value})."
+msgid ""
+"Ensure this value has at least {limit_value} distinct items (it has "
+"{show_value})."
 msgstr ""
 
 #: .\fields.py:25
@@ -101,28 +105,28 @@ msgstr "{user.username} ({reason})"
 msgid "Writing to some users is not possible: {users}."
 msgstr ""
 
-#: .\forms.py:149 .\forms.py:161
+#: .\forms.py:148 .\forms.py:160
 msgid "Recipients"
 msgstr "Destinatarios"
 
-#: .\forms.py:149 .\forms.py:161 .\templates\postman\base_folder.html.py:26
+#: .\forms.py:148 .\forms.py:160 .\templates\postman\base_folder.html.py:26
 #: .\templates\postman\reply.html.py:4
 msgid "Recipient"
 msgstr "Destinatario"
 
-#: .\forms.py:160
+#: .\forms.py:159
 msgid "Email"
-msgstr "Correo electrónico"
+msgstr "Correo"
 
-#: .\forms.py:176
+#: .\forms.py:175
 msgid "Undefined recipient."
 msgstr ""
 
-#: .\forms.py:195
+#: .\forms.py:194
 msgid "Additional recipients"
 msgstr ""
 
-#: .\forms.py:195
+#: .\forms.py:194
 msgid "Additional recipient"
 msgstr ""
 
@@ -146,87 +150,87 @@ msgstr "asunto"
 msgid "body"
 msgstr "contenido"
 
-#: .\models.py:199 .\models.py:282
+#: .\models.py:199 .\models.py:281
 msgid "sender"
 msgstr "emisor"
 
-#: .\models.py:200 .\models.py:306
+#: .\models.py:200 .\models.py:305
 msgid "recipient"
 msgstr "destinatario"
 
-#: .\models.py:202
+#: .\models.py:201
 msgid "visitor"
 msgstr ""
 
-#: .\models.py:203
+#: .\models.py:202
 msgid "parent message"
 msgstr "mensaje padre"
 
-#: .\models.py:204
+#: .\models.py:203
 msgid "root message"
 msgstr ""
 
-#: .\models.py:205
+#: .\models.py:204
 msgid "sent at"
 msgstr "enviado a"
 
-#: .\models.py:206
+#: .\models.py:205
 msgid "read at"
 msgstr "leído a"
 
-#: .\models.py:207
+#: .\models.py:206
 msgid "replied at"
 msgstr "respondido a"
 
-#: .\models.py:208
+#: .\models.py:207
 msgid "archived by sender"
 msgstr ""
 
-#: .\models.py:209
+#: .\models.py:208
 msgid "archived by recipient"
 msgstr ""
 
-#: .\models.py:210
+#: .\models.py:209
 msgid "deleted by sender at"
 msgstr ""
 
-#: .\models.py:211
+#: .\models.py:210
 msgid "deleted by recipient at"
 msgstr ""
 
-#: .\models.py:213
+#: .\models.py:212
 msgid "status"
 msgstr ""
 
-#: .\models.py:215
+#: .\models.py:214
 msgid "moderator"
 msgstr ""
 
-#: .\models.py:216
+#: .\models.py:215
 msgid "moderated at"
 msgstr ""
 
-#: .\models.py:217
+#: .\models.py:216
 msgid "rejection reason"
 msgstr ""
 
-#: .\models.py:222
+#: .\models.py:221
 msgid "message"
 msgstr "mensaje"
 
-#: .\models.py:223
+#: .\models.py:222
 msgid "messages"
 msgstr "mensajes"
 
-#: .\models.py:334
+#: .\models.py:333
 msgid "Undefined sender."
 msgstr ""
 
-#: .\models.py:478
+#: .\models.py:473
 msgid "pending message"
 msgstr ""
 
-#: .\models.py:479
+#: .\models.py:474
 msgid "pending messages"
 msgstr ""
 
@@ -258,21 +262,21 @@ msgstr "Mensaje enviado con Ã©xito."
 msgid "Message rejected for at least one recipient."
 msgstr ""
 
-#: .\views.py:277
+#: .\views.py:276
 msgid "Select at least one object."
 msgstr ""
 
-#: .\views.py:283
-msgid "Message(s) or thread(s) successfully archived."
-msgstr ""
+#: .\views.py:282
+msgid "Messages or conversations successfully archived."
+msgstr "Mensajes o conversaciones archivado con Ã©xito."
 
-#: .\views.py:288
-msgid "Message(s) or thread(s) successfully deleted."
-msgstr ""
+#: .\views.py:287
+msgid "Messages or conversations successfully deleted."
+msgstr "Mensajes o conversaciones eliminado con Ã©xito."
 
-#: .\views.py:293
-msgid "Message(s) or thread(s) successfully recovered."
-msgstr ""
+#: .\views.py:292
+msgid "Messages or conversations successfully recovered."
+msgstr "Mensajes o conversaciones recuperado con Ã©xito."
 
 #: .\management\__init__.py:14
 msgid "Message Rejected"
@@ -314,10 +318,12 @@ msgstr "Rechazar"
 
 #: .\templates\postman\archives.html.py:3
 msgid "Archived Messages"
-msgstr ""
+msgstr "Mensajes archivados"
 
 #: .\templates\postman\archives.html.py:7
-msgid "Messages in this folder will never be removed. You can use this folder for long term storage."
+msgid ""
+"Messages in this folder will never be removed. You can use this folder for "
+"long term storage."
 msgstr ""
 
 #: .\templates\postman\base.html.py:3
@@ -326,7 +332,7 @@ msgstr ""
 
 #: .\templates\postman\base.html.py:6
 msgid "Inbox"
-msgstr "Bandeja de entrada"
+msgstr "Recibidos"
 
 #: .\templates\postman\base.html.py:7 .\templates\postman\sent.html.py:3
 msgid "Sent Messages"
@@ -338,7 +344,7 @@ msgstr "Escribe"
 
 #: .\templates\postman\base.html.py:9
 msgid "Archives"
-msgstr ""
+msgstr "Archivos"
 
 #: .\templates\postman\base.html.py:10
 msgid "Trash"
@@ -349,8 +355,8 @@ msgid "Sorry, this page number is invalid."
 msgstr ""
 
 #: .\templates\postman\base_folder.html.py:12
-msgid "by thread"
-msgstr ""
+msgid "by conversation"
+msgstr "por conversación"
 
 #: .\templates\postman\base_folder.html.py:13
 msgid "by message"
@@ -364,7 +370,7 @@ msgstr "Eliminar"
 #: .\templates\postman\base_folder.html.py:18
 #: .\templates\postman\view.html.py:23
 msgid "Archive"
-msgstr ""
+msgstr "Archivar"
 
 #: .\templates\postman\base_folder.html.py:19
 msgid "Undelete"
@@ -499,12 +505,14 @@ msgid "Deleted Messages"
 msgstr "Mensajes eliminados"
 
 #: .\templates\postman\trash.html.py:10
-msgid "Messages in this folder can be removed from time to time. For long term storage, use instead the archive folder."
+msgid ""
+"Messages in this folder can be removed from time to time. For long term "
+"storage, use instead the archive folder."
 msgstr ""
 
 #: .\templates\postman\view.html.py:5
-msgid "Thread"
-msgstr ""
+msgid "Conversation"
+msgstr "Conversación"
 
 #: .\templates\postman\view.html.py:13
 msgid ":"
@@ -514,6 +522,6 @@ msgstr " :"
 msgid "Back"
 msgstr "Volver"
 
-#: .\templatetags\postman_tags.py:34
+#: .\templatetags\postman_tags.py:35
 msgid "<me>"
-msgstr "<mí>"
+msgstr "<usuario>"
index 1b80e8f607deb71a18380af73d9a7c6eb76bd89e..717f65a143a9d21e96c6078510b394b00dce83f2 100644 (file)
Binary files a/postman/locale/fr/LC_MESSAGES/django.mo and b/postman/locale/fr/LC_MESSAGES/django.mo differ
index d4b73bf9526786a4f9c3ae2c5c000dec61d318dd..2a9b082bb917b5b3dc897ad1113e591ab665bdf0 100644 (file)
@@ -7,7 +7,7 @@ msgid ""
 msgstr ""
 "Project-Id-Version: django-postman 1.0.0\n"
 "Report-Msgid-Bugs-To: \n"
-"POT-Creation-Date: 2010-12-27 13:36+0100\n"
+"POT-Creation-Date: 2010-12-27 14:21+0100\n"
 "PO-Revision-Date: 2010-12-15 17:19+0100\n"
 "Last-Translator: Patrick Samson <maxcom@laposte.net>\n"
 "Language-Team: LANGUAGE <LL@li.org>\n"
@@ -277,16 +277,16 @@ msgid "Select at least one object."
 msgstr "Sélectionner au moins un objet."
 
 #: .\views.py:282
-msgid "Message(s) or conversation(s) successfully archived."
-msgstr "Message(s) ou conversation(s) archivé(s) avec succès."
+msgid "Messages or conversations successfully archived."
+msgstr "Messages ou conversations archivés avec succès."
 
 #: .\views.py:287
-msgid "Message(s) or conversation(s) successfully deleted."
-msgstr "Message(s) ou conversation(s) supprimé(s) avec succès."
+msgid "Messages or conversations successfully deleted."
+msgstr "Messages ou conversations supprimés avec succès."
 
 #: .\views.py:292
-msgid "Message(s) or conversation(s) successfully recovered."
-msgstr "Message(s) ou conversation(s) restauré(s) avec succès."
+msgid "Messages or conversations successfully recovered."
+msgstr "Messages ou conversations restaurés avec succès."
 
 #: .\management\__init__.py:14
 msgid "Message Rejected"
index 92b66dbd7e9b4af1813fbd89cbf4765b1f56ebaf..89383828253a1fe5932d7541f7b2e0ff4f3d49a8 100644 (file)
Binary files a/postman/locale/it/LC_MESSAGES/django.mo and b/postman/locale/it/LC_MESSAGES/django.mo differ
index 0fdbf4ccbe2ff9387ab84add24a7588ce5392ea2..97ba077df372b2399b512377e366db7b9e113117 100644 (file)
@@ -2,12 +2,12 @@
 # Copyright (C) YEAR THE PACKAGE'S COPYRIGHT HOLDER
 # This file is distributed under the same license as the PACKAGE package.
 # FIRST AUTHOR <EMAIL@ADDRESS>, YEAR.
-# 
+#
 msgid ""
 msgstr ""
 "Project-Id-Version: django-postman\n"
-"Report-Msgid-Bugs-To: http://bitbucket.org/psam/django-postman/issues\n"
-"POT-Creation-Date: 2010-12-24 18:38+0100\n"
+"Report-Msgid-Bugs-To: \n"
+"POT-Creation-Date: 2010-12-27 15:44+0100\n"
 "PO-Revision-Date: 2010-12-25 14:39+0000\n"
 "Last-Translator: psam <maxcom@laposte.net>\n"
 "Language-Team: LANGUAGE <LL@li.org>\n"
@@ -58,7 +58,7 @@ msgid "Response date cannot be set without at least one reply."
 msgstr ""
 
 #: .\admin.py:66
-msgid "The message cannot be replied without being in a thread."
+msgid "The message cannot be replied without being in a conversation."
 msgstr ""
 
 #: .\admin.py:88 .\admin.py:157 .\templates\postman\view.html.py:5
@@ -78,11 +78,15 @@ msgid "Some usernames are unknown or no more active: {users}."
 msgstr ""
 
 #: .\fields.py:23
-msgid "Ensure this value has at most {limit_value} distinct items (it has {show_value})."
+msgid ""
+"Ensure this value has at most {limit_value} distinct items (it has "
+"{show_value})."
 msgstr ""
 
 #: .\fields.py:24
-msgid "Ensure this value has at least {limit_value} distinct items (it has {show_value})."
+msgid ""
+"Ensure this value has at least {limit_value} distinct items (it has "
+"{show_value})."
 msgstr ""
 
 #: .\fields.py:25
@@ -101,28 +105,28 @@ msgstr "{user.username} ({reason})"
 msgid "Writing to some users is not possible: {users}."
 msgstr "Scrivi per alcuni utenti non Ã¨ possibile: {users}."
 
-#: .\forms.py:149 .\forms.py:161
+#: .\forms.py:148 .\forms.py:160
 msgid "Recipients"
 msgstr "Destinatari"
 
-#: .\forms.py:149 .\forms.py:161 .\templates\postman\base_folder.html.py:26
+#: .\forms.py:148 .\forms.py:160 .\templates\postman\base_folder.html.py:26
 #: .\templates\postman\reply.html.py:4
 msgid "Recipient"
 msgstr "Destinatario"
 
-#: .\forms.py:160
+#: .\forms.py:159
 msgid "Email"
-msgstr "E-mail"
+msgstr "Posta"
 
-#: .\forms.py:176
+#: .\forms.py:175
 msgid "Undefined recipient."
 msgstr ""
 
-#: .\forms.py:195
+#: .\forms.py:194
 msgid "Additional recipients"
 msgstr "Altri destinatari"
 
-#: .\forms.py:195
+#: .\forms.py:194
 msgid "Additional recipient"
 msgstr "Ulteriori destinatario"
 
@@ -146,87 +150,87 @@ msgstr "oggetto"
 msgid "body"
 msgstr "contenuto"
 
-#: .\models.py:199 .\models.py:282
+#: .\models.py:199 .\models.py:281
 msgid "sender"
 msgstr "mittente"
 
-#: .\models.py:200 .\models.py:306
+#: .\models.py:200 .\models.py:305
 msgid "recipient"
 msgstr "destinatario"
 
-#: .\models.py:202
+#: .\models.py:201
 msgid "visitor"
 msgstr "visitatore"
 
-#: .\models.py:203
+#: .\models.py:202
 msgid "parent message"
 msgstr ""
 
-#: .\models.py:204
+#: .\models.py:203
 msgid "root message"
 msgstr ""
 
-#: .\models.py:205
+#: .\models.py:204
 msgid "sent at"
 msgstr "inviato il"
 
-#: .\models.py:206
+#: .\models.py:205
 msgid "read at"
 msgstr "letto il"
 
-#: .\models.py:207
+#: .\models.py:206
 msgid "replied at"
 msgstr "risposto il"
 
-#: .\models.py:208
+#: .\models.py:207
 msgid "archived by sender"
-msgstr ""
+msgstr "archiviato dal mittente"
 
-#: .\models.py:209
+#: .\models.py:208
 msgid "archived by recipient"
-msgstr ""
+msgstr "archiviato dal destinatario"
 
-#: .\models.py:210
+#: .\models.py:209
 msgid "deleted by sender at"
 msgstr "cancellati dal mittente il"
 
-#: .\models.py:211
+#: .\models.py:210
 msgid "deleted by recipient at"
 msgstr "cancellati dal destinatario il"
 
-#: .\models.py:213
+#: .\models.py:212
 msgid "status"
 msgstr ""
 
-#: .\models.py:215
+#: .\models.py:214
 msgid "moderator"
 msgstr ""
 
-#: .\models.py:216
+#: .\models.py:215
 msgid "moderated at"
 msgstr ""
 
-#: .\models.py:217
+#: .\models.py:216
 msgid "rejection reason"
 msgstr ""
 
-#: .\models.py:222
+#: .\models.py:221
 msgid "message"
 msgstr "messaggio"
 
-#: .\models.py:223
+#: .\models.py:222
 msgid "messages"
 msgstr "messaggi"
 
-#: .\models.py:334
+#: .\models.py:333
 msgid "Undefined sender."
 msgstr ""
 
-#: .\models.py:478
+#: .\models.py:473
 msgid "pending message"
 msgstr ""
 
-#: .\models.py:479
+#: .\models.py:474
 msgid "pending messages"
 msgstr ""
 
@@ -258,21 +262,21 @@ msgstr "Messaggio inviato con successo."
 msgid "Message rejected for at least one recipient."
 msgstr ""
 
-#: .\views.py:277
+#: .\views.py:276
 msgid "Select at least one object."
 msgstr ""
 
-#: .\views.py:283
-msgid "Message(s) or thread(s) successfully archived."
-msgstr ""
+#: .\views.py:282
+msgid "Messages or conversations successfully archived."
+msgstr "Messaggi o conversazioni archiviati con successo."
 
-#: .\views.py:288
-msgid "Message(s) or thread(s) successfully deleted."
-msgstr ""
+#: .\views.py:287
+msgid "Messages or conversations successfully deleted."
+msgstr "Messaggi o conversazioni eliminato con successo."
 
-#: .\views.py:293
-msgid "Message(s) or thread(s) successfully recovered."
-msgstr ""
+#: .\views.py:292
+msgid "Messages or conversations successfully recovered."
+msgstr "Messaggi o conversazioni recuperati con successo."
 
 #: .\management\__init__.py:14
 msgid "Message Rejected"
@@ -314,10 +318,12 @@ msgstr "Rifiutare"
 
 #: .\templates\postman\archives.html.py:3
 msgid "Archived Messages"
-msgstr ""
+msgstr "Messaggi archiviati"
 
 #: .\templates\postman\archives.html.py:7
-msgid "Messages in this folder will never be removed. You can use this folder for long term storage."
+msgid ""
+"Messages in this folder will never be removed. You can use this folder for "
+"long term storage."
 msgstr ""
 
 #: .\templates\postman\base.html.py:3
@@ -326,7 +332,7 @@ msgstr ""
 
 #: .\templates\postman\base.html.py:6
 msgid "Inbox"
-msgstr ""
+msgstr "Posta in arrivo"
 
 #: .\templates\postman\base.html.py:7 .\templates\postman\sent.html.py:3
 msgid "Sent Messages"
@@ -338,7 +344,7 @@ msgstr "Scrivi"
 
 #: .\templates\postman\base.html.py:9
 msgid "Archives"
-msgstr ""
+msgstr "Archivi"
 
 #: .\templates\postman\base.html.py:10
 msgid "Trash"
@@ -349,8 +355,8 @@ msgid "Sorry, this page number is invalid."
 msgstr "Spiacenti, questo numero di pagina non valida."
 
 #: .\templates\postman\base_folder.html.py:12
-msgid "by thread"
-msgstr "di conversazione"
+msgid "by conversation"
+msgstr "dal conversazione"
 
 #: .\templates\postman\base_folder.html.py:13
 msgid "by message"
@@ -359,12 +365,12 @@ msgstr "dal messaggio"
 #: .\templates\postman\base_folder.html.py:17
 #: .\templates\postman\view.html.py:22
 msgid "Delete"
-msgstr "Cancella"
+msgstr "Elimina"
 
 #: .\templates\postman\base_folder.html.py:18
 #: .\templates\postman\view.html.py:23
 msgid "Archive"
-msgstr ""
+msgstr "Archivia"
 
 #: .\templates\postman\base_folder.html.py:19
 msgid "Undelete"
@@ -400,7 +406,7 @@ msgstr "Invia"
 
 #: .\templates\postman\email_user.txt.py:1
 msgid "Dear user,"
-msgstr ""
+msgstr "Caro utente,"
 
 #: .\templates\postman\email_user.txt.py:3
 #: .\templates\postman\email_visitor.txt.py:3
@@ -459,7 +465,7 @@ msgstr ""
 #: .\templates\postman\email_visitor_subject.txt.py:1
 #, python-format
 msgid "Message \"%(subject)s\" on the site %(sitename)s"
-msgstr ""
+msgstr "Messaggio \"%(subject)s\" sul sito %(sitename)s"
 
 #: .\templates\postman\email_visitor.txt.py:1
 msgid "Dear visitor,"
@@ -496,15 +502,17 @@ msgstr "Spedito"
 
 #: .\templates\postman\trash.html.py:3
 msgid "Deleted Messages"
-msgstr "Messaggi cancellati"
+msgstr "Messaggi eliminati"
 
 #: .\templates\postman\trash.html.py:10
-msgid "Messages in this folder can be removed from time to time. For long term storage, use instead the archive folder."
+msgid ""
+"Messages in this folder can be removed from time to time. For long term "
+"storage, use instead the archive folder."
 msgstr ""
 
 #: .\templates\postman\view.html.py:5
-msgid "Thread"
-msgstr ""
+msgid "Conversation"
+msgstr "Conversazione"
 
 #: .\templates\postman\view.html.py:13
 msgid ":"
@@ -514,6 +522,6 @@ msgstr " :"
 msgid "Back"
 msgstr "Indietro"
 
-#: .\templatetags\postman_tags.py:34
+#: .\templatetags\postman_tags.py:35
 msgid "<me>"
-msgstr "<i>"
+msgstr "<me>"
diff --git a/postman/locale/nl/LC_MESSAGES/django.mo b/postman/locale/nl/LC_MESSAGES/django.mo
new file mode 100644 (file)
index 0000000..691eda6
Binary files /dev/null and b/postman/locale/nl/LC_MESSAGES/django.mo differ
diff --git a/postman/locale/nl/LC_MESSAGES/django.po b/postman/locale/nl/LC_MESSAGES/django.po
new file mode 100644 (file)
index 0000000..8961a22
--- /dev/null
@@ -0,0 +1,519 @@
+# SOME DESCRIPTIVE TITLE.
+# Copyright (C) YEAR THE PACKAGE'S COPYRIGHT HOLDER
+# This file is distributed under the same license as the PACKAGE package.
+# FIRST AUTHOR <EMAIL@ADDRESS>, YEAR.
+# 
+msgid ""
+msgstr ""
+"Project-Id-Version: django-postman\n"
+"Report-Msgid-Bugs-To: http://bitbucket.org/psam/django-postman/issues\n"
+"POT-Creation-Date: 2010-12-27 14:21+0100\n"
+"PO-Revision-Date: 2010-12-27 15:10+0000\n"
+"Last-Translator: psam <maxcom@laposte.net>\n"
+"Language-Team: LANGUAGE <LL@li.org>\n"
+"MIME-Version: 1.0\n"
+"Content-Type: text/plain; charset=UTF-8\n"
+"Content-Transfer-Encoding: 8bit\n"
+"Language: nl\n"
+"Plural-Forms: nplurals=2; plural=(n != 1)\n"
+
+#: .\admin.py:22
+msgid "Sender and Recipient cannot be both undefined."
+msgstr ""
+
+#: .\admin.py:29
+msgid "Visitor's email is in excess."
+msgstr ""
+
+#: .\admin.py:34
+msgid "Visitor's email is missing."
+msgstr ""
+
+#: .\admin.py:40
+msgid "Reading date must be later to sending date."
+msgstr ""
+
+#: .\admin.py:45
+msgid "Deletion date by sender must be later to sending date."
+msgstr ""
+
+#: .\admin.py:50
+msgid "Deletion date by recipient must be later to sending date."
+msgstr ""
+
+#: .\admin.py:58
+msgid "Response date must be later to sending date."
+msgstr ""
+
+#: .\admin.py:60
+msgid "The message cannot be replied without having been read."
+msgstr ""
+
+#: .\admin.py:62
+msgid "Response date must be later to reading date."
+msgstr ""
+
+#: .\admin.py:64
+msgid "Response date cannot be set without at least one reply."
+msgstr ""
+
+#: .\admin.py:66
+msgid "The message cannot be replied without being in a conversation."
+msgstr ""
+
+#: .\admin.py:88 .\admin.py:157 .\templates\postman\view.html.py:5
+msgid "Message"
+msgstr "Bericht"
+
+#: .\admin.py:93
+msgid "Dates"
+msgstr "Data"
+
+#: .\admin.py:98 .\admin.py:161
+msgid "Moderation"
+msgstr ""
+
+#: .\fields.py:22
+msgid "Some usernames are unknown or no more active: {users}."
+msgstr ""
+
+#: .\fields.py:23
+msgid "Ensure this value has at most {limit_value} distinct items (it has {show_value})."
+msgstr ""
+
+#: .\fields.py:24
+msgid "Ensure this value has at least {limit_value} distinct items (it has {show_value})."
+msgstr ""
+
+#: .\fields.py:25
+msgid "Some usernames are rejected: {users}."
+msgstr ""
+
+#: .\fields.py:26 .\forms.py:65
+msgid "{user.username}"
+msgstr "{user.username}"
+
+#: .\fields.py:27 .\forms.py:66
+msgid "{user.username} ({reason})"
+msgstr "{user.username} ({reason})"
+
+#: .\forms.py:64
+msgid "Writing to some users is not possible: {users}."
+msgstr ""
+
+#: .\forms.py:148 .\forms.py:160
+msgid "Recipients"
+msgstr "Ontvangers"
+
+#: .\forms.py:148 .\forms.py:160 .\templates\postman\base_folder.html.py:26
+#: .\templates\postman\reply.html.py:4
+msgid "Recipient"
+msgstr "Ontvanger"
+
+#: .\forms.py:159
+msgid "Email"
+msgstr "E-mail"
+
+#: .\forms.py:175
+msgid "Undefined recipient."
+msgstr ""
+
+#: .\forms.py:194
+msgid "Additional recipients"
+msgstr ""
+
+#: .\forms.py:194
+msgid "Additional recipient"
+msgstr ""
+
+#: .\models.py:19
+msgid "Pending"
+msgstr ""
+
+#: .\models.py:20
+msgid "Accepted"
+msgstr ""
+
+#: .\models.py:21 .\templates\postman\view.html.py:13
+msgid "Rejected"
+msgstr ""
+
+#: .\models.py:197
+msgid "subject"
+msgstr "onderwerp"
+
+#: .\models.py:198
+msgid "body"
+msgstr "inhoud"
+
+#: .\models.py:199 .\models.py:281
+msgid "sender"
+msgstr "verstuurder"
+
+#: .\models.py:200 .\models.py:305
+msgid "recipient"
+msgstr "ontvanger"
+
+#: .\models.py:201
+msgid "visitor"
+msgstr "bezoeker"
+
+#: .\models.py:202
+msgid "parent message"
+msgstr "hoofdbericht"
+
+#: .\models.py:203
+msgid "root message"
+msgstr ""
+
+#: .\models.py:204
+msgid "sent at"
+msgstr "verstuurd op"
+
+#: .\models.py:205
+msgid "read at"
+msgstr "gelezen op"
+
+#: .\models.py:206
+msgid "replied at"
+msgstr "beantwoord op"
+
+#: .\models.py:207
+msgid "archived by sender"
+msgstr ""
+
+#: .\models.py:208
+msgid "archived by recipient"
+msgstr ""
+
+#: .\models.py:209
+msgid "deleted by sender at"
+msgstr ""
+
+#: .\models.py:210
+msgid "deleted by recipient at"
+msgstr ""
+
+#: .\models.py:212
+msgid "status"
+msgstr ""
+
+#: .\models.py:214
+msgid "moderator"
+msgstr ""
+
+#: .\models.py:215
+msgid "moderated at"
+msgstr ""
+
+#: .\models.py:216
+msgid "rejection reason"
+msgstr ""
+
+#: .\models.py:221
+msgid "message"
+msgstr "bericht"
+
+#: .\models.py:222
+msgid "messages"
+msgstr "berichten"
+
+#: .\models.py:333
+msgid "Undefined sender."
+msgstr ""
+
+#: .\models.py:473
+msgid "pending message"
+msgstr ""
+
+#: .\models.py:474
+msgid "pending messages"
+msgstr ""
+
+#: .\utils.py:32
+msgid "> "
+msgstr "> "
+
+#: .\utils.py:48
+msgid ""
+"\n"
+"\n"
+"{sender} wrote:\n"
+"{body}\n"
+msgstr ""
+"\n"
+"\n"
+"{sender} schreef:\n"
+"{body}\n"
+
+#: .\utils.py:57
+msgid "Re: {subject}"
+msgstr "Antw: {subject}"
+
+#: .\views.py:129 .\views.py:187
+msgid "Message successfully sent."
+msgstr "Bericht succesvol verstuurd."
+
+#: .\views.py:131 .\views.py:189
+msgid "Message rejected for at least one recipient."
+msgstr ""
+
+#: .\views.py:276
+msgid "Select at least one object."
+msgstr ""
+
+#: .\views.py:282
+msgid "Messages or conversations successfully archived."
+msgstr "Berichten of conversaties succesvol gearchiveerd."
+
+#: .\views.py:287
+msgid "Messages or conversations successfully deleted."
+msgstr "Berichten of conversaties succesvol verwijderd."
+
+#: .\views.py:292
+msgid "Messages or conversations successfully recovered."
+msgstr "Berichten of conversaties succesvol hersteld."
+
+#: .\management\__init__.py:14
+msgid "Message Rejected"
+msgstr ""
+
+#: .\management\__init__.py:14
+msgid "Your message has been rejected"
+msgstr ""
+
+#: .\management\__init__.py:15
+msgid "Message Received"
+msgstr "Bericht ontvangen"
+
+#: .\management\__init__.py:15
+msgid "You have received a message"
+msgstr "U hebt een bericht ontvangen"
+
+#: .\management\__init__.py:16
+msgid "Reply Received"
+msgstr "Antwoord ontvangen"
+
+#: .\management\__init__.py:16
+msgid "You have received a reply"
+msgstr "U hebt een antwoord ontvangen"
+
+#: .\templates\admin\postman\pendingmessage\change_form.html.py:17
+msgid "Please correct the error below."
+msgid_plural "Please correct the errors below."
+msgstr[0] "Herstel de fouten hieronder."
+msgstr[1] "Herstel de fout hieronder."
+
+#: .\templates\admin\postman\pendingmessage\submit_line.html.py:6
+msgid "Accept"
+msgstr ""
+
+#: .\templates\admin\postman\pendingmessage\submit_line.html.py:7
+msgid "Reject"
+msgstr ""
+
+#: .\templates\postman\archives.html.py:3
+msgid "Archived Messages"
+msgstr ""
+
+#: .\templates\postman\archives.html.py:7
+msgid "Messages in this folder will never be removed. You can use this folder for long term storage."
+msgstr ""
+
+#: .\templates\postman\base.html.py:3
+msgid "Messaging"
+msgstr ""
+
+#: .\templates\postman\base.html.py:6
+msgid "Inbox"
+msgstr "Postvak In"
+
+#: .\templates\postman\base.html.py:7 .\templates\postman\sent.html.py:3
+msgid "Sent Messages"
+msgstr "Verzonden berichten"
+
+#: .\templates\postman\base.html.py:8 .\templates\postman\write.html.py:3
+msgid "Write"
+msgstr "Schrijven"
+
+#: .\templates\postman\base.html.py:9
+msgid "Archives"
+msgstr "Archieven"
+
+#: .\templates\postman\base.html.py:10
+msgid "Trash"
+msgstr "Prullenbak"
+
+#: .\templates\postman\base_folder.html.py:9
+msgid "Sorry, this page number is invalid."
+msgstr "Sorry, deze pagina is ongeldig."
+
+#: .\templates\postman\base_folder.html.py:12
+msgid "by conversation"
+msgstr "op conversatie"
+
+#: .\templates\postman\base_folder.html.py:13
+msgid "by message"
+msgstr "per bericht"
+
+#: .\templates\postman\base_folder.html.py:17
+#: .\templates\postman\view.html.py:22
+msgid "Delete"
+msgstr "Verwijderen"
+
+#: .\templates\postman\base_folder.html.py:18
+#: .\templates\postman\view.html.py:23
+msgid "Archive"
+msgstr "Archiveren"
+
+#: .\templates\postman\base_folder.html.py:19
+msgid "Undelete"
+msgstr "Herstellen"
+
+#: .\templates\postman\base_folder.html.py:24
+msgid "Action"
+msgstr "Actie"
+
+#: .\templates\postman\base_folder.html.py:25
+msgid "Sender"
+msgstr "Verstuurder"
+
+#: .\templates\postman\base_folder.html.py:27
+msgid "Subject"
+msgstr "Onderwerp"
+
+#: .\templates\postman\base_folder.html.py:28
+msgid "Date"
+msgstr "Datum"
+
+#: .\templates\postman\base_folder.html.py:43
+msgid "g:i A,M j,n/j/y"
+msgstr "G:i,j b,j/n/y"
+
+#: .\templates\postman\base_folder.html.py:51
+msgid "No messages."
+msgstr "Geen berichten."
+
+#: .\templates\postman\base_write.html.py:20
+msgid "Send"
+msgstr "Verzenden"
+
+#: .\templates\postman\email_user.txt.py:1
+msgid "Dear user,"
+msgstr "Beste gebruiker,"
+
+#: .\templates\postman\email_user.txt.py:3
+#: .\templates\postman\email_visitor.txt.py:3
+#, python-format
+msgid "On %(date)s, you asked to send a message to the user '%(recipient)s'."
+msgstr ""
+
+#: .\templates\postman\email_user.txt.py:5
+#: .\templates\postman\email_visitor.txt.py:5
+msgid "Your message has been rejected by the moderator"
+msgstr ""
+
+#: .\templates\postman\email_user.txt.py:5
+#: .\templates\postman\email_visitor.txt.py:5
+msgid ", for the following reason:"
+msgstr ""
+
+#: .\templates\postman\email_user.txt.py:9
+#: .\templates\postman\email_visitor.txt.py:10
+#, python-format
+msgid "On %(date)s, you sent a message to the user '%(sender)s'."
+msgstr ""
+
+#: .\templates\postman\email_user.txt.py:10
+msgid "Your correspondent has given you an answer."
+msgstr ""
+
+#: .\templates\postman\email_user.txt.py:11
+#, python-format
+msgid "You have received a copy of a response from the user '%(sender)s'."
+msgstr ""
+
+#: .\templates\postman\email_user.txt.py:13
+#, python-format
+msgid "You have received a message from the user '%(sender)s'."
+msgstr ""
+
+#: .\templates\postman\email_user.txt.py:16
+#: .\templates\postman\email_visitor.txt.py:14
+msgid "Thank you again for your interest in our services."
+msgstr ""
+
+#: .\templates\postman\email_user.txt.py:17
+#: .\templates\postman\email_visitor.txt.py:16
+msgid "The site administrator"
+msgstr "De sitebeheerder"
+
+#: .\templates\postman\email_user.txt.py:19
+#: .\templates\postman\email_visitor.txt.py:18
+msgid ""
+"Note: This message is issued by an automated system.\n"
+"Do not reply, this would not be taken into account."
+msgstr ""
+
+#: .\templates\postman\email_user_subject.txt.py:1
+#: .\templates\postman\email_visitor_subject.txt.py:1
+#, python-format
+msgid "Message \"%(subject)s\" on the site %(sitename)s"
+msgstr "Bericht \" %(subject)s \" op de site %(sitename)s "
+
+#: .\templates\postman\email_visitor.txt.py:1
+msgid "Dear visitor,"
+msgstr "Beste bezoeker,"
+
+#: .\templates\postman\email_visitor.txt.py:8
+msgid "As a reminder, please find below the content of your message."
+msgstr ""
+
+#: .\templates\postman\email_visitor.txt.py:11
+msgid "Please find below the answer from your correspondent."
+msgstr ""
+
+#: .\templates\postman\email_visitor.txt.py:15
+msgid "For more comfort, we encourage you to open an account on the site."
+msgstr ""
+
+#: .\templates\postman\inbox.html.py:3
+msgid "Received Messages"
+msgstr "Ontvangen berichten"
+
+#: .\templates\postman\inbox.html.py:6
+msgid "Received"
+msgstr "Ontvangen"
+
+#: .\templates\postman\reply.html.py:3 .\templates\postman\view.html.py:25
+#: .\templates\postman\view.html.py:28 .\templates\postman\view.html.py:31
+msgid "Reply"
+msgstr "Beantwoorden"
+
+#: .\templates\postman\sent.html.py:6
+msgid "Sent"
+msgstr "Verstuurde"
+
+#: .\templates\postman\trash.html.py:3
+msgid "Deleted Messages"
+msgstr "Verwijderde berichten"
+
+#: .\templates\postman\trash.html.py:10
+msgid "Messages in this folder can be removed from time to time. For long term storage, use instead the archive folder."
+msgstr ""
+
+#: .\templates\postman\view.html.py:5
+msgid "Conversation"
+msgstr "Conversatie"
+
+#: .\templates\postman\view.html.py:13
+msgid ":"
+msgstr " :"
+
+#: .\templates\postman\view.html.py:20
+msgid "Back"
+msgstr "Terug"
+
+#: .\templatetags\postman_tags.py:35
+msgid "<me>"
+msgstr "<mij>"
diff --git a/postman/management/__init__.py b/postman/management/__init__.py
new file mode 100644 (file)
index 0000000..cefbc1c
--- /dev/null
@@ -0,0 +1,19 @@
+import sys
+
+from django.conf import settings
+from django.db.models import signals
+from django.utils.translation import ugettext_noop as _
+
+name = getattr(settings, 'POSTMAN_NOTIFIER_APP', 'notification')
+if name and name in settings.INSTALLED_APPS:
+    name = name + '.models'
+    __import__(name)
+    notification = sys.modules[name]
+
+    def create_notice_types(*args, **kwargs):
+        notification.create_notice_type("postman_rejection", _("Message Rejected"), _("Your message has been rejected"))
+        notification.create_notice_type("postman_message", _("Message Received"), _("You have received a message"))
+        notification.create_notice_type("postman_reply", _("Reply Received"), _("You have received a reply"))
+
+    signals.post_syncdb.connect(create_notice_types, sender=notification)
+
diff --git a/postman/management/commands/__init__.py b/postman/management/commands/__init__.py
new file mode 100644 (file)
index 0000000..e69de29
diff --git a/postman/management/commands/postman_checkup.py b/postman/management/commands/postman_checkup.py
new file mode 100644 (file)
index 0000000..374076e
--- /dev/null
@@ -0,0 +1,56 @@
+import datetime
+
+from django.core.management.base import NoArgsCommand
+from django.db.models import Q, F, Count
+
+from postman.models import Message
+
+class Command(NoArgsCommand):
+    help = "Can be run as a cron job or directly to check-up data consistency in the database."
+
+    def handle_noargs(self, **options):
+        verbose = int(options.get('verbosity'))
+        if verbose >= 1:
+            self.stdout.write(datetime.datetime.now().strftime("%H:%M:%S ") + "Checking messages and conversations for inconsistencies...\n")
+        checks = [
+            ("Sender and Recipient cannot be both undefined.", Q(sender__isnull=True, recipient__isnull=True)),
+            ("Visitor's email is in excess.", Q(sender__isnull=False, recipient__isnull=False) & ~Q(email='')),
+            ("Visitor's email is missing.", (Q(sender__isnull=True) | Q(recipient__isnull=True)) & Q(email='')),
+            ("Reading date must be later to sending date.", Q(read_at__lt=F('sent_at'))),
+            ("Deletion date by sender must be later to sending date.", Q(sender_deleted_at__lt=F('sent_at'))),
+            ("Deletion date by recipient must be later to sending date.", Q(recipient_deleted_at__lt=F('sent_at'))),
+            ("Response date must be later to sending date.", Q(replied_at__lt=F('sent_at'))),
+            ("The message cannot be replied without having been read.", Q(replied_at__isnull=False, read_at__isnull=True)),
+            ("Response date must be later to reading date.", Q(replied_at__lt=F('read_at'))),
+            # because of the delay due to the moderation, no constraint between replied_at and recipient_deleted_at
+            ("Response date cannot be set without at least one reply.",
+                Q(replied_at__isnull=False), {'cnt': Count('next_messages')}, Q(cnt=0)),
+                # cnt should filter to allow only accepted replies, but do not know how to specify it
+            ("The message cannot be replied without being in a conversation.",
+                Q(replied_at__isnull=False, thread__isnull=True)),
+            ("The message cannot be a reply without being in a conversation.",
+                Q(parent__isnull=False, thread__isnull=True)),
+            ("The reply and its parent are not in a conversation in common.",
+                Q(parent__isnull=False, thread__isnull=False) & (Q(parent__thread__isnull=True) | ~Q(parent__thread=F('thread')))),
+        ]
+        count = 0
+        for c in checks:
+            msgs = Message.objects.filter(c[1])
+            if len(c) >= 4:
+                msgs = msgs.annotate(**c[2]).filter(c[3])
+            if msgs:
+                count += len(msgs)
+                self.report_errors(c[0], msgs)
+        if verbose >= 1:
+            self.stdout.write(datetime.datetime.now().strftime("%H:%M:%S ") +
+                ("Number of inconsistencies found: {0}. See details on the error stream.\n".format(count) if count
+                else "All is correct.\n"))
+
+    def report_errors(self, reason, msgs):
+        self.stderr.write(reason + '\n')
+        self.stderr.write("  {0:6} {1:5} {2:5} {3:10} {4:6} {5:6} {6:16} {7:16} {8:16}\n".format(
+            "Id","From","To","Email","Parent","Thread","Sent","Read","Replied"))
+        for msg in msgs:
+            self.stderr.write(
+                "  {0.pk:6} {0.sender_id:5} {0.recipient_id:5} {0.email:10.10} {0.parent_id:6} {0.thread_id:6}"
+                " {0.sent_at!s:16.16} {0.read_at!s:16.16} {0.replied_at!s:16.16}\n".format(msg))
diff --git a/postman/management/commands/postman_cleanup.py b/postman/management/commands/postman_cleanup.py
new file mode 100644 (file)
index 0000000..194239c
--- /dev/null
@@ -0,0 +1,36 @@
+import datetime
+from optparse import make_option
+
+from django.core.management.base import NoArgsCommand
+from django.db.models import Max, Count, F, Q
+
+from postman.models import Message
+
+class Command(NoArgsCommand):
+    help = """Can be run as a cron job or directly to clean out old data from the database:
+  Messages or conversations marked as deleted by both sender and recipient,
+  more than a minimal number of days ago."""
+    option_list = NoArgsCommand.option_list + (
+        make_option('-d', '--days', type='int', default=30,
+            help='The minimal number of days a message is kept marked as deleted, '
+                 'before to be considered for real deletion [default: %default]'),
+        )
+
+    def handle_noargs(self, **options):
+        verbose = int(options.get('verbosity'))
+        days = options.get('days')
+        date = datetime.date.today() - datetime.timedelta(days=days)
+        if verbose >= 1:
+            self.stdout.write("Erase messages and conversations marked as deleted before %s\n" % date)
+        # for a conversation to be candidate, all messages must satisfy the criteria
+        tpks = Message.objects.filter(thread__isnull=False).values('thread').annotate(
+                cnt=Count('pk'),
+                s_max=Max('sender_deleted_at'),    s_cnt=Count('sender_deleted_at'),
+                r_max=Max('recipient_deleted_at'), r_cnt=Count('recipient_deleted_at')
+            ).order_by().filter(
+                s_cnt=F('cnt'), r_cnt=F('cnt'), s_max__lte=date, r_max__lte=date
+            ).values_list('thread', flat=True)
+        Message.objects.filter(
+            Q(thread__in=tpks) |
+            Q(thread__isnull=True, sender_deleted_at__lte=date, recipient_deleted_at__lte=date)
+        ).delete()
diff --git a/postman/medias/postman/css/admin.css b/postman/medias/postman/css/admin.css
new file mode 100644 (file)
index 0000000..798f45e
--- /dev/null
@@ -0,0 +1,6 @@
+/*
+       This stylesheet is dedicated to the admin site.
+*/
+
+/* Form Fields */
+#id_subject, #id_moderation_reason { width: 50em; }
diff --git a/postman/models.py b/postman/models.py
new file mode 100644 (file)
index 0000000..c413213
--- /dev/null
@@ -0,0 +1,483 @@
+import datetime
+import hashlib
+
+from django.conf import settings
+from django.contrib.auth.models import User
+from django.core.exceptions import ValidationError
+from django.db import models
+from django.utils.text import truncate_words
+from django.utils.translation import ugettext, ugettext_lazy as _
+
+from postman.urls import OPTION_MESSAGES
+from postman.utils import email_visitor, notify_user
+
+# moderation constants
+STATUS_PENDING = 'p'
+STATUS_ACCEPTED = 'a'
+STATUS_REJECTED = 'r'
+STATUS_CHOICES = (
+    (STATUS_PENDING, _('Pending')),
+    (STATUS_ACCEPTED, _('Accepted')),
+    (STATUS_REJECTED, _('Rejected')),
+)
+# ordering constants
+ORDER_BY_KEY = 'o' # as 'order'
+ORDER_BY_FIELDS = {
+    '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
+
+def get_order_by(query_dict):
+    """
+    Return a field name, optionally prefixed for descending order, or None if not found.
+
+    Argument:
+    ``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
+        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
+
+class MessageManager(models.Manager):
+    """The manager for Message."""
+
+    @property
+    def _last_in_thread(self):
+        """Return the latest message id for each conversation."""
+        return self.filter(thread__isnull=False).values('thread').annotate(models.Max('pk'))\
+            .values_list('pk__max', flat=True).order_by()
+
+    def _folder(self, related, filters, option=None, order_by=None):
+        """Base code, in common to the folders."""
+        if related:
+            qs = self.select_related(*related)
+        else:
+            qs = self.all()
+        if order_by:
+            qs = qs.order_by(order_by)
+        if isinstance(filters, (list,tuple)):
+            lookups = models.Q()
+            for filter in filters:
+                lookups |= models.Q(**filter)
+        else:
+            lookups = models.Q(**filters)
+        if option == OPTION_MESSAGES:
+            return qs.filter(lookups)
+            # Adding a 'count' attribute, to be similar to the by-conversation case,
+            # should not be necessary. Otherwise add:
+            # .extra(select={'count': 'SELECT 1'})
+        else:
+            return qs.filter(
+                models.Q(id__in=self._last_in_thread.filter(lookups)) | models.Q(lookups, thread__isnull=True)
+            ).extra(select={'count': 
+            'SELECT COUNT(*) FROM "postman_message" T'
+            ' WHERE T."thread_id" = "postman_message"."thread_id"'
+            })
+            # For single message, 'count' is returned as 0. Should be acceptable if known.
+            # If not, replace "COUNT(*)" by "1+COUNT(*)" and add:
+            # ' AND T."id" <> T."thread_id"'
+
+    def inbox(self, user, related=True, **kwargs):
+        """
+        Return accepted messages received by a user but not marked as archived or deleted.
+        """
+        related = ('sender',) if related else None
+        filters = {
+            'recipient': user,
+            'recipient_archived': False,
+            'recipient_deleted_at__isnull': True,
+            'moderation_status': STATUS_ACCEPTED,
+        }
+        return self._folder(related, filters, **kwargs)
+
+    def inbox_unread_count(self, user):
+        """
+        Return the number of unread messages for a user.
+
+        Designed for context_processors.py and templatetags/postman_tags.py
+
+        """
+        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.
+        """
+        related = ('recipient',)
+        filters = {
+            'sender': user,
+            'sender_archived': False,
+            'sender_deleted_at__isnull': True,
+            # allow to see pending and rejected messages as well
+        }
+        return self._folder(related, filters, **kwargs)
+
+    def archives(self, user, **kwargs):
+        """
+        Return messages belonging to a user and marked as archived.
+        """
+        related = ('sender','recipient')
+        filters = ({
+            'recipient': user,
+            'recipient_archived': True,
+            'recipient_deleted_at__isnull': True,
+            'moderation_status': STATUS_ACCEPTED,
+        }, {
+            'sender': user,
+            'sender_archived': True,
+            'sender_deleted_at__isnull': True,
+        })
+        return self._folder(related, filters, **kwargs)
+
+    def trash(self, user, **kwargs):
+        """
+        Return messages belonging to a user and marked as deleted.
+        """
+        related = ('sender','recipient')
+        filters = ({
+            'recipient': user,
+            'recipient_deleted_at__isnull': False,
+            'moderation_status': STATUS_ACCEPTED,
+        }, {
+            'sender': user,
+            'sender_deleted_at__isnull': False,
+        })
+        return self._folder(related, filters, **kwargs)
+
+    def thread(self, user, filter):
+        """
+        Return message/conversation for display.
+        """
+        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')
+
+    def perms(self, user):
+        """
+        Return a field-lookups filter as a permission controller for a reply request.
+
+        The user must be the recipient of the accepted, non-deleted, message
+
+        """
+        return models.Q(recipient=user) & models.Q(moderation_status=STATUS_ACCEPTED) & models.Q(recipient_deleted_at__isnull=True)
+
+    def set_read(self, user, filter):
+        """
+        Set messages as read.
+        """
+        return self.filter(
+            filter,
+            recipient=user,
+            moderation_status=STATUS_ACCEPTED,
+            read_at__isnull=True,
+        ).update(read_at=datetime.datetime.now())
+
+class Message(models.Model):
+    """
+    A message between a User and another User or an AnonymousUser.
+    """
+
+    SUBJECT_MAX_LENGTH = 120
+
+    subject = models.CharField(_("subject"), max_length=SUBJECT_MAX_LENGTH)
+    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
+    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=datetime.datetime.now)
+    read_at = models.DateTimeField(_("read at"), null=True, blank=True)
+    replied_at = models.DateTimeField(_("replied at"), null=True, blank=True)
+    sender_archived = models.BooleanField(_("archived by sender"))
+    recipient_archived = models.BooleanField(_("archived by recipient"))
+    sender_deleted_at = models.DateTimeField(_("deleted by sender at"), null=True, blank=True)
+    recipient_deleted_at = models.DateTimeField(_("deleted by recipient at"), null=True, blank=True)
+    # moderation fields
+    moderation_status = models.CharField(_("status"), max_length=1, choices=STATUS_CHOICES, default=STATUS_PENDING)
+    moderation_by = models.ForeignKey(User, related_name='moderated_messages',
+        null=True, blank=True, verbose_name=_("moderator"))
+    moderation_date = models.DateTimeField(_("moderated at"), null=True, blank=True)
+    moderation_reason = models.CharField(_("rejection reason"), max_length=120, blank=True)
+
+    objects = MessageManager()
+
+    class Meta:
+        verbose_name = _("message")
+        verbose_name_plural = _("messages")
+        ordering = ['-sent_at']
+
+    def __unicode__(self):
+        return u"{0}>{1}:{2}".format(self.obfuscated_sender, self.obfuscated_recipient, truncate_words(self.subject,5))
+
+    @models.permalink
+    def get_absolute_url(self):
+        return ('postman_view', [str(self.pk)])
+
+    def is_pending(self):
+        """Tell if the message is in the pending state."""
+        return self.moderation_status == STATUS_PENDING
+    def is_rejected(self):
+        """Tell if the message is in the rejected state."""
+        return self.moderation_status == STATUS_REJECTED
+    def is_accepted(self):
+        """Tell if the message is in the accepted state."""
+        return self.moderation_status == STATUS_ACCEPTED
+
+    @property
+    def is_new(self):
+        """Tell if the recipient has not yet read the message."""
+        return self.read_at is None
+
+    @property
+    def is_replied(self):
+        """Tell if the recipient has written a reply to the message."""
+        return self.replied_at is not None
+
+    def _obfuscated_email(self):
+        """
+        Return the email field as obfuscated, to keep it undisclosed.
+
+        Format is:
+            first 4 characters of the hash email + '..' + last 4 characters of the hash email + '@' + domain without TLD
+        Example:
+            foo@domain.com -> 1a2b..e8f9@domain
+
+        """
+        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
+        bits = email.split('@')
+        if len(bits) <> 2:
+            return ''
+        domain = bits[1]
+        return '@'.join((shrunken_digest, domain.rsplit('.',1)[0])) # leave off the TLD to gain some space
+
+    def admin_sender(self):
+        """
+        Return the sender either as a username or as a plain email.
+        Designed for the Admin site.
+
+        """
+        if self.sender:
+            return str(self.sender)
+        else:
+            return '<{0}>'.format(self.email)
+    admin_sender.short_description = _("sender")
+    admin_sender.admin_order_field = 'sender'
+
+    # Give the sender either as a username or as a plain email.
+    clear_sender = property(admin_sender)
+
+    @property
+    def obfuscated_sender(self):
+        """Return the sender either as a username or as an undisclosed email."""
+        if self.sender:
+            return str(self.sender)
+        else:
+            return self._obfuscated_email()
+
+    def admin_recipient(self):
+        """
+        Return the recipient either as a username or as a plain email.
+        Designed for the Admin site.
+
+        """
+        if self.recipient:
+            return str(self.recipient)
+        else:
+            return '<{0}>'.format(self.email)
+    admin_recipient.short_description = _("recipient")
+    admin_recipient.admin_order_field = 'recipient'
+
+    # Give the recipient either as a username or as a plain email.
+    clear_recipient = property(admin_recipient)
+
+    @property
+    def obfuscated_recipient(self):
+        """Return the recipient either as a username or as an undisclosed email."""
+        if self.recipient:
+            return str(self.recipient)
+        else:
+            return self._obfuscated_email()
+
+    def get_replies_count(self):
+        """Return the number of accepted responses."""
+        return self.next_messages.filter(moderation_status=STATUS_ACCEPTED).count()
+
+    def quote(self, format_subject, format_body):
+        """Return a dictionary of quote values to initiate a reply."""
+        return {
+            'subject': format_subject(self.subject)[:self.SUBJECT_MAX_LENGTH],
+            'body': format_body(self.obfuscated_sender, self.body),
+        }
+
+    def clean(self):
+        """Check some validity constraints."""
+        if not (self.sender_id or self.email):
+            raise ValidationError(ugettext("Undefined sender."))
+
+    def clean_moderation(self, initial_status, user=None):
+        """Adjust automatically some fields, according to status workflow."""
+        if self.moderation_status <> initial_status:
+            self.moderation_date = datetime.datetime.now()
+            self.moderation_by = user
+            if self.is_rejected():
+                # even if maybe previously deleted during a temporary 'accepted' stay
+                self.recipient_deleted_at = datetime.datetime.now()
+            elif initial_status == STATUS_REJECTED:
+                # rollback
+                self.recipient_deleted_at = None
+
+    def clean_for_visitor(self):
+        """Do some auto-read and auto-delete, because there is no one to do it (no account)."""
+        if not self.sender_id:
+            # no need to wait for a final moderation status to mark as deleted
+            if not self.sender_deleted_at:
+                self.sender_deleted_at = datetime.datetime.now()
+        elif not self.recipient_id:
+            if self.is_accepted():
+                if not self.read_at:
+                    self.read_at = datetime.datetime.now()
+                if not self.recipient_deleted_at:
+                    self.recipient_deleted_at = datetime.datetime.now()
+            else:
+                # rollbacks
+                if self.read_at:
+                    self.read_at = None
+                # but stay deleted if rejected
+                if self.is_pending() and self.recipient_deleted_at:
+                    self.recipient_deleted_at = None
+
+    def update_parent(self, initial_status):
+        """Update the parent to actualize its response state."""
+        if self.moderation_status <> initial_status:
+            parent = self.parent
+            if self.is_accepted():
+                # keep the very first date; no need to do differently
+                if parent and (not parent.replied_at or self.sent_at < parent.replied_at):
+                    parent.replied_at = self.sent_at
+                    parent.save()
+            elif initial_status == STATUS_ACCEPTED:
+                if parent and parent.replied_at == self.sent_at:
+                    # rollback, but there may be some other valid replies
+                    try:
+                        other_date = parent.next_messages\
+                            .exclude(pk=self.pk).filter(moderation_status=STATUS_ACCEPTED)\
+                            .values_list('sent_at', flat=True)\
+                            .order_by('sent_at')[:1].get()
+                        parent.replied_at = other_date
+                    except Message.DoesNotExist:
+                        parent.replied_at = None
+                    parent.save()
+
+    def notify_users(self, initial_status):
+        """Notify the rejection (to sender) or the acceptance (to recipient) of the message."""
+        if initial_status == STATUS_PENDING:
+            if self.is_rejected():
+                (notify_user if self.sender_id else email_visitor)(self, 'rejection')
+            elif self.is_accepted():
+                (notify_user if self.recipient_id else email_visitor)(self, 'acceptance')
+
+    def get_dates(self):
+        """Get some dates to restore later."""
+        return (self.sender_deleted_at, self.recipient_deleted_at, self.read_at)
+
+    def set_dates(self, sender_deleted_at, recipient_deleted_at, read_at):
+        """Restore some dates."""
+        self.sender_deleted_at = sender_deleted_at
+        self.recipient_deleted_at = recipient_deleted_at
+        self.read_at = read_at
+
+    def get_moderation(self):
+        """Get moderation information to restore later."""
+        return (self.moderation_status, self.moderation_by_id, self.moderation_date, self.moderation_reason)
+
+    def set_moderation(self, status, by_id, date, reason):
+        """Restore moderation information."""
+        self.moderation_status = status
+        self.moderation_by_id = by_id
+        self.moderation_date = date
+        self.moderation_reason = reason
+
+    def auto_moderate(self, moderators):
+        """Run a chain of auto-moderators."""
+        auto = None
+        final_reason = ''
+        percents = []
+        reasons = []
+        if not isinstance(moderators, (list, tuple)):
+            moderators = (moderators,)
+        for moderator in moderators:
+            rating = moderator(self)
+            if rating is None: continue
+            if isinstance(rating, tuple):
+                percent, reason = rating
+            else:
+                percent = rating
+                reason = getattr(moderator, 'default_reason', '')
+            if percent is False: percent = 0
+            if percent is True: percent = 100
+            if not 0 <= percent <= 100: continue
+            if percent == 0:
+                auto = False
+                final_reason = reason
+                break
+            elif percent == 100:
+                auto = True
+                break
+            percents.append(percent)
+            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])
+            auto = average >= 50
+        if auto is None:
+            auto = getattr(settings, 'POSTMAN_AUTO_MODERATE_AS', None)
+        if auto is True:
+            self.moderation_status = STATUS_ACCEPTED
+        elif auto is False:
+            self.moderation_status = STATUS_REJECTED
+            self.moderation_reason = final_reason
+
+class PendingMessageManager(models.Manager):
+    """The manager for PendingMessage."""
+
+    def get_query_set(self):
+        """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.
+    """
+
+    objects = PendingMessageManager()
+
+    class Meta:
+        verbose_name = _("pending message")
+        verbose_name_plural = _("pending messages")
+        proxy = True
+
+    def set_accepted(self):
+        """Set the message as accepted."""
+        self.moderation_status = STATUS_ACCEPTED
+
+    def set_rejected(self):
+        """Set the message as rejected."""
+        self.moderation_status = STATUS_REJECTED
diff --git a/postman/templates/admin/postman/pendingmessage/change_form.html b/postman/templates/admin/postman/pendingmessage/change_form.html
new file mode 100644 (file)
index 0000000..dd6e84c
--- /dev/null
@@ -0,0 +1,45 @@
+{% extends "admin/change_form.html" %}
+{% comment %}
+A copy of contrib/admin/templates/admin/change_form.html, with these changes:
+- add the loading of 'postman_admin_modify' template elements set
+- call block.super for every inner block
+- remplace occurrences of 'submit_row' tag by 'postman_submit_row'
+{% endcomment %}
+{% load i18n admin_modify postman_admin_modify %}
+{% block content %}<div id="content-main">
+{% block object-tools %}{{ block.super }}{% endblock %}
+<form {% if has_file_field %}enctype="multipart/form-data" {% endif %}action="{{ form_url }}" method="post" id="{{ opts.module_name }}_form">{% csrf_token %}{% block form_top %}{{ block.super }}{% endblock %}
+<div>
+{% if is_popup %}<input type="hidden" name="_popup" value="1" />{% endif %}
+{% if save_on_top %}{% postman_submit_row %}{% endif %}
+{% if errors %}
+    <p class="errornote">
+    {% blocktrans count errors|length as counter %}Please correct the error below.{% plural %}Please correct the errors below.{% endblocktrans %}
+    </p>
+    {{ adminform.form.non_field_errors }}
+{% endif %}
+
+{% for fieldset in adminform %}
+  {% include "admin/includes/fieldset.html" %}
+{% endfor %}
+
+{% block after_field_sets %}{{ block.super }}{% endblock %}
+
+{% for inline_admin_formset in inline_admin_formsets %}
+    {% include inline_admin_formset.opts.template %}
+{% endfor %}
+
+{% block after_related_objects %}{{ block.super }}{% endblock %}
+
+{% postman_submit_row %}
+
+{% if adminform and add %}
+   <script type="text/javascript">document.getElementById("{{ adminform.first_field.auto_id }}").focus();</script>
+{% endif %}
+
+{# JavaScript for prepopulated fields #}
+{% prepopulated_fields_js %}
+
+</div>
+</form></div>
+{% endblock %}
\ No newline at end of file
diff --git a/postman/templates/admin/postman/pendingmessage/submit_line.html b/postman/templates/admin/postman/pendingmessage/submit_line.html
new file mode 100644 (file)
index 0000000..db47031
--- /dev/null
@@ -0,0 +1,8 @@
+{% comment %}
+Another set of buttons in replacement of contrib/admin/templates/admin/submit_line.html
+{% endcomment %}
+{% load i18n %}
+<div class="submit-row">
+<input type="submit" value="{% trans 'Accept' %}" class="default" name="_saveasaccepted" />
+<input type="submit" value="{% trans 'Reject' %}" name="_saveasrejected" />
+</div>
diff --git a/postman/templates/autocomplete_postman_multiple.html b/postman/templates/autocomplete_postman_multiple.html
new file mode 100644 (file)
index 0000000..798c7c4
--- /dev/null
@@ -0,0 +1,13 @@
+{% extends "autocomplete.html" %}
+{% block script %}
+       $('#{{ html_id }}').autocomplete('{{ lookup_url }}', {
+               width: 320,
+               formatItem: function(row) { return row[1]; },
+               formatResult: function(row) { return row[2]; },
+               multiple: true,
+               dataType: "text"
+       })
+       $('#{{ html_id }}').result(function(event, data, formatted) {
+               $('#{{ html_id }}').trigger("added");
+       })
+{% endblock %}
\ No newline at end of file
diff --git a/postman/templates/autocomplete_postman_single.html b/postman/templates/autocomplete_postman_single.html
new file mode 100644 (file)
index 0000000..979e068
--- /dev/null
@@ -0,0 +1,12 @@
+{% extends "autocomplete.html" %}
+{% block script %}
+       $('#{{ html_id }}').autocomplete('{{ lookup_url }}', {
+               width: 320,
+               formatItem: function(row) { return row[1]; },
+               formatResult: function(row) { return row[2]; },
+               dataType: "text"
+       })
+       $('#{{ html_id }}').result(function(event, data, formatted) {
+               $('#{{ html_id }}').trigger("added");
+       })
+{% endblock %}
\ No newline at end of file
diff --git a/postman/templates/postman/archives.html b/postman/templates/postman/archives.html
new file mode 100644 (file)
index 0000000..ab3d94a
--- /dev/null
@@ -0,0 +1,8 @@
+{% extends "postman/base_folder.html" %}
+{% load i18n %}
+{% block pm_folder_title %}{% trans "Archived Messages" %}{% endblock %}
+{% block pm_archive_button %}{% endblock %}
+{% block pm_undelete_button %}{% endblock %}
+{% block pm_footer_info %}
+<p>{% trans "Messages in this folder will never be removed. You can use this folder for long term storage." %}</p>
+{% endblock %}
\ No newline at end of file
diff --git a/postman/templates/postman/base.html b/postman/templates/postman/base.html
new file mode 100644 (file)
index 0000000..e1d80bd
--- /dev/null
@@ -0,0 +1,12 @@
+{% extends "base.html" %}
+{% load i18n %}{% load postman_tags %}
+{% block title %}{% trans "Messaging" %}{% endblock %}
+{% block postman_menu %}
+<ul id="postman_menu">{% postman_unread as unread_count %}
+ <li><a href="{% url postman_inbox %} ">&raquo;&nbsp;{% trans "Inbox" %}{% if unread_count %} <strong>({{ unread_count }})</strong>{% endif %}</a></li>
+ <li><a href="{% url postman_sent %} ">&raquo;&nbsp;{% trans "Sent Messages" %}</a></li>
+ <li><a href="{% url postman_write %} ">&raquo;&nbsp;{% trans "Write" %}</a></li>
+ <li><a href="{% url postman_archives %} ">&raquo;&nbsp;{% trans "Archives" %}</a></li>
+ <li><a href="{% url postman_trash %} ">&raquo;&nbsp;{% trans "Trash" %}</a></li>
+</ul>
+{% endblock %}
\ No newline at end of file
diff --git a/postman/templates/postman/base_folder.html b/postman/templates/postman/base_folder.html
new file mode 100644 (file)
index 0000000..700faaf
--- /dev/null
@@ -0,0 +1,55 @@
+{% extends "postman/base.html" %}
+{% load i18n postman_tags %}{% load pagination_tags %}
+{% block content %}
+<div id="postman">
+<h1>{% block pm_folder_title %}{% endblock %}</h1>
+{% if pm_messages %}
+{% autopaginate pm_messages %}
+{% if invalid_page %}
+<p>{% trans "Sorry, this page number is invalid." %}</p>
+{% else %}
+{% block pm_by_modes %}<div id="pm_by_modes">
+<span class="pm_by_mode">{% if by_message %}<a href="{{ by_conversation_url }}">{% endif %}{% trans "by conversation" %}{% if by_message %}</a>{% endif %}</span>
+<span class="pm_by_mode">{% if by_conversation %}<a href="{{ by_message_url }}">{% endif %}{% trans "by message" %}{% if by_conversation %}</a>{% endif %}</span>
+</div>{% endblock pm_by_modes %}
+<form action="{% block pm_form_action %}{% endblock %}" method="post">{% csrf_token %}
+{% block pm_form_buttons %}<span id="pm_buttons">
+{% block pm_delete_button %}<button type="submit" onclick="this.form.action='{% url postman_delete %}'">{% trans "Delete" %}</button>{% endblock %}
+{% block pm_archive_button %}<button type="submit" onclick="this.form.action='{% url postman_archive %}'">{% trans "Archive" %}</button>{% endblock %}
+{% block pm_undelete_button %}<button type="submit" onclick="this.form.action='{% url postman_undelete %}'">{% trans "Undelete" %}</button>{% endblock %}
+</span>{% endblock %}
+<table id="pm_messages">
+ <thead>
+  <tr>
+   <th>{% trans "Action" %}</th>
+{% block pm_sender_header %}   <th><a href="{% postman_order_by sender %}">{% trans "Sender" %}</a></th>{% endblock %}
+{% block pm_recipient_header %}   <th><a href="{% postman_order_by recipient %}">{% trans "Recipient" %}</a></th>{% endblock %}
+   <th><a href="{% postman_order_by subject %}">{% trans "Subject" %}</a></th>
+   <th><a href="{% postman_order_by date %}">{% block pm_date %}{% trans "Date" %}{% endblock %}</a></th>
+  </tr>
+ </thead>
+ <tbody>{% for message in pm_messages %}
+  <tr>
+   <td><input type="checkbox" {% if by_conversation and message.thread_id %}name="tpks" value="{{ message.thread_id }}"{% else %}name="pks" value="{{ message.pk }}"{% endif %} /></td>
+{% block pm_sender_cell %}   <td>{{ message.obfuscated_sender|or_me:user }}{% if message.count %} ({{ message.count }}){% endif %}</td>{% endblock %}
+{% block pm_recipient_cell %}   <td>{{ message.obfuscated_recipient|or_me:user }}{% if message.count %} ({{ message.count }}){% endif %}</td>{% endblock %}
+   <td>{% if message.is_new %}<strong>{% endif %}{% if message.is_replied %}<em>{% endif %}
+{% block pm_subject %}
+    <a href="{% if by_conversation and message.thread_id %}{% url postman_view_conversation message.thread_id %}{% else %}{{message.get_absolute_url }}{% endif %}?next={{ current_url|urlencode }}">
+    {% include "postman/inc_subject_ex.html" %}
+    </a>
+{% endblock %}
+   {% if message.is_replied %}</em>{% endif %}{% if message.is_new %}</strong>{% endif %}</td>
+   <td>{{ message.sent_at|compact_date:_("g:i A,M j,n/j/y") }}</td>
+  </tr>{% endfor %}
+ </tbody>
+</table>
+</form>
+{% paginate %}
+{% endif %}
+{% else %}
+<p>{% trans "No messages." %}</p>
+{% endif %}
+{% block pm_footer_info %}{% endblock %}
+</div>
+{% endblock content %}
\ No newline at end of file
diff --git a/postman/templates/postman/base_write.html b/postman/templates/postman/base_write.html
new file mode 100644 (file)
index 0000000..d454e9e
--- /dev/null
@@ -0,0 +1,23 @@
+{% extends "postman/base.html" %}
+{% load i18n %}
+{% block extrahead %}{{ block.super }}
+{% if is_autocompleted %}
+{# using the available admin jQuery is enough #}
+<script type="text/javascript" src="{% load adminmedia %}{% admin_media_prefix %}js/jquery.min.js"></script>
+{# <script type="text/javascript" src="{{ MEDIA_URL }}js/jquery.min.js"></script> #}
+<script type="text/javascript" src="{{ MEDIA_URL }}js/jquery.autocomplete.min.js"></script>
+<link href="{{ MEDIA_URL }}css/jquery.autocomplete.css" type="text/css" media="all" rel="stylesheet" />
+{% endif %}
+{% endblock %}
+{% block content %}
+<div id="postman">
+<h1>{% block pm_write_title %}{% endblock %}</h1>
+<form action="{% if next_url %}?next={{ next_url|urlencode }}{% endif %}" method="post">{% csrf_token %}
+<table>
+{% block pm_write_recipient %}{% endblock %}
+{{ form.as_table }}
+</table>
+<button type="submit">{% trans "Send" %}</button>
+</form>
+</div>
+{% endblock %}
\ No newline at end of file
diff --git a/postman/templates/postman/email_user.txt b/postman/templates/postman/email_user.txt
new file mode 100644 (file)
index 0000000..c62111b
--- /dev/null
@@ -0,0 +1,20 @@
+{% load i18n %}{% autoescape off %}{% trans "Dear user," %}
+{% if action == 'rejection' %}
+{% blocktrans with object.sent_at|date:"DATETIME_FORMAT" as date and object.obfuscated_recipient as recipient %}On {{ date }}, you asked to send a message to the user '{{ recipient }}'.{% endblocktrans %}
+
+{% trans "Your message has been rejected by the moderator" %}{% if object.moderation_reason %}{% trans ", for the following reason:" %}
+    {{ object.moderation_reason }}{% else %}.{% endif %}
+
+{% else %}{# 'acceptance' #}
+{% if object.parent_id %}{% if object.parent.sender_id == object.recipient_id %}{% blocktrans with object.parent.sent_at|date:"DATETIME_FORMAT" as date and object.obfuscated_sender as sender %}On {{ date }}, you sent a message to the user '{{ sender }}'.{% endblocktrans %}
+{% trans "Your correspondent has given you an answer." %}
+{% else %}{% blocktrans with object.obfuscated_sender as sender %}You have received a copy of a response from the user '{{ sender }}'.{% endblocktrans %}
+{% endif %}
+{% else %}{% blocktrans with object.obfuscated_sender as sender %}You have received a message from the user '{{ sender }}'.{% endblocktrans %}
+{% endif %}
+{% endif %}{# 'acceptance' #}
+{% trans "Thank you again for your interest in our services." %}
+{% trans "The site administrator" %}
+
+{% blocktrans %}Note: This message is issued by an automated system.
+Do not reply, this would not be taken into account.{% endblocktrans %}{% endautoescape %}
\ No newline at end of file
diff --git a/postman/templates/postman/email_user_subject.txt b/postman/templates/postman/email_user_subject.txt
new file mode 100644 (file)
index 0000000..cace60c
--- /dev/null
@@ -0,0 +1 @@
+{% load i18n %}{% autoescape off %}{% blocktrans with object.subject as subject and site.name as sitename %}Message "{{ subject }}" on the site {{ sitename }}{% endblocktrans %}{% endautoescape %}
\ No newline at end of file
diff --git a/postman/templates/postman/email_visitor.txt b/postman/templates/postman/email_visitor.txt
new file mode 100644 (file)
index 0000000..23e18a9
--- /dev/null
@@ -0,0 +1,22 @@
+{% load i18n %}{% autoescape off %}{% trans "Dear visitor," %}
+{% if action == 'rejection' %}
+{% blocktrans with object.sent_at|date:"DATETIME_FORMAT" as date and object.recipient as recipient %}On {{ date }}, you asked to send a message to the user '{{ recipient }}'.{% endblocktrans %}
+
+{% trans "Your message has been rejected by the moderator" %}{% if object.moderation_reason %}{% trans ", for the following reason:" %}
+    {{ object.moderation_reason }}{% else %}.{% endif %}
+
+{% trans "As a reminder, please find below the content of your message." %}
+{% else %}{# 'acceptance' #}
+{% blocktrans with object.parent.sent_at|date:"DATETIME_FORMAT" as date and object.sender as sender %}On {{ date }}, you sent a message to the user '{{ sender }}'.{% endblocktrans %}
+{% trans "Please find below the answer from your correspondent." %}
+{% endif %}
+
+{% trans "Thank you again for your interest in our services." %}
+{% trans "For more comfort, we encourage you to open an account on the site." %}
+{% trans "The site administrator" %}
+
+{% blocktrans %}Note: This message is issued by an automated system.
+Do not reply, this would not be taken into account.{% endblocktrans %}
+-------------------------------------------------------
+{{ object.body }}
+-------------------------------------------------------{% endautoescape %}
\ No newline at end of file
diff --git a/postman/templates/postman/email_visitor_subject.txt b/postman/templates/postman/email_visitor_subject.txt
new file mode 100644 (file)
index 0000000..cace60c
--- /dev/null
@@ -0,0 +1 @@
+{% load i18n %}{% autoescape off %}{% blocktrans with object.subject as subject and site.name as sitename %}Message "{{ subject }}" on the site {{ sitename }}{% endblocktrans %}{% endautoescape %}
\ No newline at end of file
diff --git a/postman/templates/postman/inbox.html b/postman/templates/postman/inbox.html
new file mode 100644 (file)
index 0000000..6da9d25
--- /dev/null
@@ -0,0 +1,7 @@
+{% extends "postman/base_folder.html" %}
+{% load i18n %}
+{% block pm_folder_title %}{% trans "Received Messages" %}{% endblock %}
+{% block pm_undelete_button %}{% endblock %}
+{% block pm_recipient_header %}{% endblock %}
+{% block pm_date %}{% trans "Received" %}{% endblock %}
+{% block pm_recipient_cell %}{% endblock %}
diff --git a/postman/templates/postman/inc_subject_ex.html b/postman/templates/postman/inc_subject_ex.html
new file mode 100644 (file)
index 0000000..9ef1c3c
--- /dev/null
@@ -0,0 +1,22 @@
+{% comment %}
+This file is intended to be included, such as in postman/base_folder.html:
+    {% include "postman/inc_subject_ex.html" %}
+
+It provides an extended subject, as a replacement to a simple {{ message.subject }} tag.
+Enhancements are:
+* limit the subject length to a few words
+* if there is still room up to a maximum number of words, then add the very first words of the body,
+  in a grey style.
+
+Examples:
+With a total of at most 12 words, and 5 words of subject.
+With body: "Lorem ipsum dolor sit amet, consectetur adipisicing elit, sed do eiusmod."
+
+With subject: "a subject of great interest for you":
+"a subject of great interest ... - Lorem ipsum dolor sit amet, consectetur ..."
+
+With subject: "a great interest":
+"a great interest - Lorem ipsum dolor sit amet, consectetur adipisicing elit, sed ..."
+
+{% endcomment %}{% load postman_tags %}{% with message.subject|truncatewords:5 as truncated_subject %}{{ truncated_subject }}
+{% with truncated_subject|wordcount as subject_wc %}{% with 12|sub:subject_wc as wc %}{% if message.body and wc > 0 %} - <span style="color: grey">{{ message.body|truncatewords:wc }}</span>{% endif %}{% endwith %}{% endwith %}{% endwith %}
\ No newline at end of file
diff --git a/postman/templates/postman/reply.html b/postman/templates/postman/reply.html
new file mode 100644 (file)
index 0000000..1c27076
--- /dev/null
@@ -0,0 +1,4 @@
+{% extends "postman/base_write.html" %}
+{% load i18n %}
+{% block pm_write_title %}{% trans "Reply"%}{% endblock %}
+{% block pm_write_recipient %}<tr><th><label>{% trans "Recipient" %}:</label></th><td>{{ recipient }}</td></tr>{% endblock %}
\ No newline at end of file
diff --git a/postman/templates/postman/sent.html b/postman/templates/postman/sent.html
new file mode 100644 (file)
index 0000000..0480f6a
--- /dev/null
@@ -0,0 +1,7 @@
+{% extends "postman/base_folder.html" %}
+{% load i18n %}
+{% block pm_folder_title %}{% trans "Sent Messages" %}{% endblock %}
+{% block pm_undelete_button %}{% endblock %}
+{% block pm_sender_header %}{% endblock %}
+{% block pm_date %}{% trans "Sent" %}{% endblock %}
+{% block pm_sender_cell %}{% endblock %}
diff --git a/postman/templates/postman/trash.html b/postman/templates/postman/trash.html
new file mode 100644 (file)
index 0000000..8982ea3
--- /dev/null
@@ -0,0 +1,11 @@
+{% extends "postman/base_folder.html" %}
+{% load i18n %}
+{% block pm_folder_title %}{% trans "Deleted Messages" %}{% endblock %}
+{% block pm_delete_button %}{% endblock %}
+{% block pm_archive_button %}{% endblock %}
+{% block pm_subject %}{# no link #}
+     {% include "postman/inc_subject_ex.html" %}
+{% endblock %}
+{% block pm_footer_info %}
+<p>{% trans "Messages in this folder can be removed from time to time. For long term storage, use instead the archive folder." %}</p>
+{% endblock %}
\ No newline at end of file
diff --git a/postman/templates/postman/view.html b/postman/templates/postman/view.html
new file mode 100644 (file)
index 0000000..5436208
--- /dev/null
@@ -0,0 +1,36 @@
+{% extends "postman/base.html" %}
+{% load i18n %}{% load postman_tags %}
+{% block content %}
+<div id="postman">
+<h1>{% if pm_messages|length > 1 %}{% trans "Conversation" %}{% else %}{% trans "Message" %}{% endif %}</h1>
+{% for message in pm_messages %}
+<div class="pm_message{% if message.is_pending %} pm_pending{% endif %}{% if message.is_rejected %} pm_rejected{% endif %}{% if message.sender == user and message.sender_archived or message.recipient == user and message.recipient_archived %} pm_archived{% endif %}{% if message.sender == user and message.sender_deleted_at or message.recipient == user and message.recipient_deleted_at %} pm_deleted{% endif %}{% if message.recipient == user and not message.read_at %} pm_unread{% endif %}">
+ <div class="pm_header">
+  <span class="pm_sender">{{ message.obfuscated_sender|or_me:user }}</span> &raquo;
+  <span class="pm_recipient">{{ message.obfuscated_recipient|or_me:user }}</span> |
+  <span class="pm_date">{{ message.sent_at|date:"DATETIME_FORMAT"}}</span> |
+  <span class="pm_subject">{{ message.subject }}</span>
+{% if message.is_rejected %}  <div class="pm_status">{% trans "Rejected" %}{% if message.moderation_reason %}{% trans ":" %} {{ message.moderation_reason }}{% endif %}</div>{% endif %}
+ </div>
+ <div class="pm_body">{{ message.body|linebreaksbr }}</div>
+</div>
+{% if forloop.last %}
+<form action="" method="post">{% csrf_token %}
+<input type="hidden" {% if message.thread_id %}name="tpks" value="{{ message.thread_id }}"{% else %}name="pks" value="{{ message.pk }}"{% endif %} />
+<a href="{{ next_url }}">{% trans "Back" %}</a>
+<span id="pm_buttons">
+<button type="submit" onclick="this.form.action='{% url postman_delete %}?next={{ next_url|urlencode }}'">{% trans "Delete" %}</button>
+{% if not archived %}<button type="submit" onclick="this.form.action='{% url postman_archive %}?next={{ next_url|urlencode }}'">{% trans "Archive" %}</button>{% endif %}
+</span>
+{% if reply_to_pk %}<a href="{% url postman_reply reply_to_pk %}?next={{ next_url|urlencode }}">{% trans "Reply" %}</a>{% endif %}
+</form>
+{% if reply_to_pk %}<hr />
+<h2>{% trans 'Reply' %}</h2>
+<form action="{% url postman_reply reply_to_pk %}?next={{ next_url|urlencode }}" method="post">{% csrf_token %}
+<div id="pm_reply">{{ form.body }}</div>
+<button type="submit">{% trans 'Reply' %}</button>
+</form>{% endif %}
+{% endif %}
+{% endfor %}
+</div>
+{% endblock %}
\ No newline at end of file
diff --git a/postman/templates/postman/write.html b/postman/templates/postman/write.html
new file mode 100644 (file)
index 0000000..1d52b03
--- /dev/null
@@ -0,0 +1,3 @@
+{% extends "postman/base_write.html" %}
+{% load i18n %}
+{% block pm_write_title %}{% trans "Write"%}{% endblock %}
\ No newline at end of file
diff --git a/postman/templatetags/__init__.py b/postman/templatetags/__init__.py
new file mode 100644 (file)
index 0000000..e69de29
diff --git a/postman/templatetags/pagination_tags_for_tests.py b/postman/templatetags/pagination_tags_for_tests.py
new file mode 100644 (file)
index 0000000..014d8a3
--- /dev/null
@@ -0,0 +1,27 @@
+"""
+A mock of django-pagination's pagination_tags.py that do nothing.
+Just to avoid failures in template rendering during the test suite,
+if the real application is not installed.
+
+To activate this mock, just rename it to ``pagination_tags.py``
+for the time of the test session.
+"""
+from django.template import Node, Library
+
+register = Library()
+
+class AutoPaginateNode(Node):
+    def render(self, context):
+        return u''
+
+@register.tag
+def autopaginate(parser, token):
+    return AutoPaginateNode()
+
+class PaginateNode(Node):
+    def render(self, context):
+        return u''
+
+@register.tag
+def paginate(parser, token):
+    return PaginateNode()
diff --git a/postman/templatetags/postman_admin_modify.py b/postman/templatetags/postman_admin_modify.py
new file mode 100644 (file)
index 0000000..7c3052a
--- /dev/null
@@ -0,0 +1,13 @@
+"""
+Written as in contrib/admin/templatetags/admin_modify.py,
+to define a customized version of 'submit_row' tag with a cutomized html template.
+
+In use in templates/admin/postman/pendingmessage/change_form.html.
+"""
+from django import template
+
+register = template.Library()
+
+@register.inclusion_tag('admin/postman/pendingmessage/submit_line.html')
+def postman_submit_row():
+    return {}
diff --git a/postman/templatetags/postman_tags.py b/postman/templatetags/postman_tags.py
new file mode 100644 (file)
index 0000000..e15d9a8
--- /dev/null
@@ -0,0 +1,147 @@
+import datetime
+
+from django.http import QueryDict
+from django.template import Node
+from django.template import TemplateSyntaxError
+from django.template import Library
+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
+
+register = Library()
+
+##########
+# filters
+##########
+
+@register.filter
+def sub(value, arg):
+    """Subtract the arg from the value."""
+    try:
+        return int(value) - int(arg)
+    except (ValueError, TypeError):
+        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
+
+    """
+    return _('<me>') if str(value) == str(arg) else value
+
+@register.filter
+def compact_date(value, arg):
+    """
+    Output a date as short as possible.
+
+    The argument must provide 3 patterns: for same day, for same year, otherwise
+    Typical usage: |compact_date:_("G:i,j b,j/n/y")
+
+    """
+    bits = arg.split(u',')
+    if len(bits) < 3:
+        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])
+
+#######
+# tags
+#######
+
+class OrderByNode(Node):
+    "For use in the postman_order_by tag"
+    def __init__(self, code):
+        self.code = code
+
+    def render(self, context):
+        """
+        Return a formatted GET query string, as "?order_key=order_val".
+
+        Preserves existing GET's keys, if any, such as a page number.
+        For that, the view has to provide request.GET in a 'gets' entry of the context.
+
+        """
+        if 'gets' in context:
+            gets = context['gets'].copy()
+        else:
+            gets = QueryDict('').copy()
+        if ORDER_BY_KEY in gets:
+            code = gets.pop(ORDER_BY_KEY)[0]
+        else:
+            code = None
+        if self.code:
+            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):
+        self.asvar = asvar
+
+    def render(self, context):
+        """
+        Return the count of unread messages for the user found in context,
+        (may be 0) or an empty string.
+        """
+        try:
+            user = context['user']
+            if user.is_anonymous():
+                count = ''
+            else:
+                count = Message.objects.inbox_unread_count(user)
+        except (KeyError, AttributeError):
+            count = ''
+        if self.asvar:
+            context[self.asvar] = count
+            return ''
+        return count
+
+@register.tag
+def postman_order_by(parser, token):
+    """
+    Compose a query string to ask for a specific ordering in messages list.
+
+    The unique argument must be one of the keywords of a set defined in the model.
+    Example::
+
+        <a href="{% postman_order_by subject %}">...</a>
+    """
+    try:
+        tag_name, field_name = token.split_contents()
+        field_code = ORDER_BY_MAPPER[field_name.lower()]
+    except ValueError:
+        raise TemplateSyntaxError("'{0}' tag requires a single argument".format(token.contents.split()[0]))
+    except KeyError:
+        raise TemplateSyntaxError(
+            "'{0}' is not a valid argument to '{1}' tag."
+            " 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):
+    """
+    Give the number of unread messages for a user,
+    or nothing (an empty string) for an anonymous user.
+
+    Storing the count in a variable for further processing is advised, such as::
+
+        {% postman_unread as unread_count %}
+        ...
+        {% if unread_count %}
+            You have <strong>{{ unread_count }}</strong> unread messages.
+        {% endif %}
+    """
+    bits = token.split_contents()
+    if len(bits) > 1:
+        if len(bits) != 3:
+            raise TemplateSyntaxError("'{0}' tag takes no argument or exactly two arguments".format(bits[0]))
+        if bits[1] != 'as':
+            raise TemplateSyntaxError("First argument to '{0}' tag must be 'as'".format(bits[0]))
+        return InboxCountNode(bits[2])
+    else:
+        return InboxCountNode()
diff --git a/postman/test_urls.py b/postman/test_urls.py
new file mode 100644 (file)
index 0000000..1812aa0
--- /dev/null
@@ -0,0 +1,129 @@
+"""
+URLconf for tests.py usage.
+
+"""
+from django.conf import settings
+from django.conf.urls.defaults import *
+from django.forms import ValidationError
+from django.views.generic.simple import redirect_to
+
+from postman.urls import OPTIONS
+
+# user_filter function set
+def user_filter_reason(user):
+    if user.username == 'bar': return 'some reason'
+    return None
+def user_filter_no_reason(user):
+    return ''
+def user_filter_false(user):
+    return False
+def user_filter_exception(user):
+    if user.username == 'bar': raise ValidationError(['first good reason',"anyway, I don't like {0}".format(user.username)])
+    return None
+
+# exchange_filter function set
+def exch_filter_reason(sender, recipient, recipients_list):
+    if recipient.username=='bar': return 'some reason'
+    return None
+def exch_filter_no_reason(sender, recipient, recipients_list):
+    return ''
+def exch_filter_false(sender, recipient, recipients_list):
+    return False
+def exch_filter_exception(sender, recipient, recipients_list):
+    if recipient.username == 'bar': raise ValidationError(['first good reason',"anyway, I don't like {0}".format(recipient.username)])
+    return None
+
+# auto-moderation function set
+def moderate_as_51(message):
+    return 51
+def moderate_as_48(message):
+    return (48, "some reason")
+moderate_as_48.default_reason = 'some default reason'
+
+# quote formatters
+def format_subject(subject):
+    return "Re_ " + subject
+def format_body(sender, body):
+    return "{0} _ {1}".format(sender, body)
+
+postman_patterns = patterns('postman.views',
+    # Basic set
+    url(r'^inbox/(?:(?P<option>'+OPTIONS+')/)?$', 'inbox', name='postman_inbox'),
+    url(r'^sent/(?:(?P<option>'+OPTIONS+')/)?$', 'sent', name='postman_sent'),
+    url(r'^archives/(?:(?P<option>'+OPTIONS+')/)?$', 'archives', name='postman_archives'),
+    url(r'^trash/(?:(?P<option>'+OPTIONS+')/)?$', 'trash', name='postman_trash'),
+    url(r'^write/(?:(?P<recipients>[\w.@+-:]+)/)?$', 'write', name='postman_write'),
+    url(r'^reply/(?P<message_id>[\d]+)/$', 'reply', name='postman_reply'),
+    url(r'^view/(?P<message_id>[\d]+)/$', 'view', name='postman_view'),
+    url(r'^view/t/(?P<thread_id>[\d]+)/$', 'view_conversation', name='postman_view_conversation'),
+    url(r'^archive/$', 'archive', name='postman_archive'),
+    url(r'^delete/$', 'delete', name='postman_delete'),
+    url(r'^undelete/$', 'undelete', name='postman_undelete'),
+    (r'^$', redirect_to, {'url': 'inbox/'}),
+
+    # Customized set
+    # 'success_url'
+    url(r'^write_sent/(?:(?P<recipients>[\w.@+-:]+)/)?$', 'write', {'success_url': 'postman_sent'}, name='postman_write_with_success_url_to_sent'),
+    url(r'^reply_sent/(?P<message_id>[\d]+)/$', 'reply', {'success_url': 'postman_sent'}, name='postman_reply_with_success_url_to_sent'),
+    url(r'^archive_arch/$', 'archive', {'success_url': 'postman_archives'}, name='postman_archive_with_success_url_to_archives'),
+    url(r'^delete_arch/$', 'delete', {'success_url': 'postman_archives'}, name='postman_delete_with_success_url_to_archives'),
+    url(r'^undelete_arch/$', 'undelete', {'success_url': 'postman_archives'}, name='postman_undelete_with_success_url_to_archives'),
+    # 'max'
+    url(r'^write_max/(?:(?P<recipients>[\w.@+-:]+)/)?$', 'write', {'max': 1}, name='postman_write_with_max'),
+    url(r'^reply_max/(?P<message_id>[\d]+)/$', 'reply', {'max': 1}, name='postman_reply_with_max'),
+    # 'user_filter' on write
+    url(r'^write_user_filter_reason/(?:(?P<recipients>[\w.@+-:]+)/)?$', 'write', {'user_filter': user_filter_reason}, name='postman_write_with_user_filter_reason'),
+    url(r'^write_user_filter_no_reason/(?:(?P<recipients>[\w.@+-:]+)/)?$', 'write', {'user_filter': user_filter_no_reason}, name='postman_write_with_user_filter_no_reason'),
+    url(r'^write_user_filter_false/(?:(?P<recipients>[\w.@+-:]+)/)?$', 'write', {'user_filter': user_filter_false}, name='postman_write_with_user_filter_false'),
+    url(r'^write_user_filter_exception/(?:(?P<recipients>[\w.@+-:]+)/)?$', 'write', {'user_filter': user_filter_exception}, name='postman_write_with_user_filter_exception'),
+    # 'user_filter' on reply
+    url(r'^reply_user_filter_reason/(?P<message_id>[\d]+)/$', 'reply', {'user_filter': user_filter_reason}, name='postman_reply_with_user_filter_reason'),
+    url(r'^reply_user_filter_no_reason/(?P<message_id>[\d]+)/$', 'reply', {'user_filter': user_filter_no_reason}, name='postman_reply_with_user_filter_no_reason'),
+    url(r'^reply_user_filter_false/(?P<message_id>[\d]+)/$', 'reply', {'user_filter': user_filter_false}, name='postman_reply_with_user_filter_false'),
+    url(r'^reply_user_filter_exception/(?P<message_id>[\d]+)/$', 'reply', {'user_filter': user_filter_exception}, name='postman_reply_with_user_filter_exception'),
+    # 'exchange_filter' on write
+    url(r'^write_exch_filter_reason/(?:(?P<recipients>[\w.@+-:]+)/)?$', 'write', {'exchange_filter': exch_filter_reason}, name='postman_write_with_exch_filter_reason'),
+    url(r'^write_exch_filter_no_reason/(?:(?P<recipients>[\w.@+-:]+)/)?$', 'write', {'exchange_filter': exch_filter_no_reason}, name='postman_write_with_exch_filter_no_reason'),
+    url(r'^write_exch_filter_false/(?:(?P<recipients>[\w.@+-:]+)/)?$', 'write', {'exchange_filter': exch_filter_false}, name='postman_write_with_exch_filter_false'),
+    url(r'^write_exch_filter_exception/(?:(?P<recipients>[\w.@+-:]+)/)?$', 'write', {'exchange_filter': exch_filter_exception}, name='postman_write_with_exch_filter_exception'),
+    # 'exchange_filter' on reply
+    url(r'^reply_exch_filter_reason/(?P<message_id>[\d]+)/$', 'reply', {'exchange_filter': exch_filter_reason}, name='postman_reply_with_exch_filter_reason'),
+    url(r'^reply_exch_filter_no_reason/(?P<message_id>[\d]+)/$', 'reply', {'exchange_filter': exch_filter_no_reason}, name='postman_reply_with_exch_filter_no_reason'),
+    url(r'^reply_exch_filter_false/(?P<message_id>[\d]+)/$', 'reply', {'exchange_filter': exch_filter_false}, name='postman_reply_with_exch_filter_false'),
+    url(r'^reply_exch_filter_exception/(?P<message_id>[\d]+)/$', 'reply', {'exchange_filter': exch_filter_exception}, name='postman_reply_with_exch_filter_exception'),
+    # 'auto_moderators'
+    url(r'^write_moderate/(?:(?P<recipients>[\w.@+-:]+)/)?$', 'write', {'auto_moderators': (moderate_as_51,moderate_as_48)}, name='postman_write_moderate'),
+    url(r'^reply_moderate/(?P<message_id>[\d]+)/$', 'reply', {'auto_moderators': (moderate_as_51,moderate_as_48)}, name='postman_reply_moderate'),
+    # 'formatters'
+    url(r'^reply_formatters/(?P<message_id>[\d]+)/$', 'reply', {'formatters': (format_subject,format_body)}, name='postman_reply_formatters'),
+    url(r'^view_formatters/(?P<message_id>[\d]+)/$', 'view', {'formatters': (format_subject,format_body)}, name='postman_view_formatters'),
+    # auto-complete
+    url(r'^write_ac/(?:(?P<recipients>[\w.@+-:]+)/)?$', 'write', {'autocomplete_channels': ('postman_multiple', None)}, name='postman_write_auto_complete'),
+    url(r'^reply_ac/(?P<message_id>[\d]+)/$', 'reply', {'autocomplete_channel': 'postman_multiple'}, name='postman_reply_auto_complete'),
+    # 'template_name'
+    url(r'^inbox_template/(?:(?P<option>'+OPTIONS+')/)?$', 'inbox', {'template_name': 'postman/fake.html'}, name='postman_inbox_template'),
+    url(r'^sent_template/(?:(?P<option>'+OPTIONS+')/)?$', 'sent', {'template_name': 'postman/fake.html'}, name='postman_sent_template'),
+    url(r'^archives_template/(?:(?P<option>'+OPTIONS+')/)?$', 'archives', {'template_name': 'postman/fake.html'}, name='postman_archives_template'),
+    url(r'^trash_template/(?:(?P<option>'+OPTIONS+')/)?$', 'trash', {'template_name': 'postman/fake.html'}, name='postman_trash_template'),
+    url(r'^write_template/(?:(?P<recipients>[\w.@+-:]+)/)?$', 'write', {'template_name': 'postman/fake.html'}, name='postman_write_template'),
+    url(r'^reply_template/(?P<message_id>[\d]+)/$', 'reply', {'template_name': 'postman/fake.html'}, name='postman_reply_template'),
+    url(r'^view_template/(?P<message_id>[\d]+)/$', 'view', {'template_name': 'postman/fake.html'}, name='postman_view_template'),
+    url(r'^view_template/t/(?P<thread_id>[\d]+)/$', 'view_conversation', {'template_name': 'postman/fake.html'}, name='postman_view_conversation_template'),
+)
+
+urlpatterns = patterns('',
+    (r'^accounts/login/$', 'django.contrib.auth.views.login'), # because of the login_required decorator
+    (r'^messages/', include(postman_patterns)),
+)
+
+# because of fields.py/AutoCompleteWidget/render()/reverse()
+if 'ajax_select' in settings.INSTALLED_APPS:
+    urlpatterns += patterns('',
+        (r'^ajax_select/', include('ajax_select.urls')), # django-ajax-selects
+    )
+
+# optional
+if 'notification' in settings.INSTALLED_APPS:
+    urlpatterns += patterns('',
+        (r'^notification/', include('notification.urls')), # django-notification
+    )
diff --git a/postman/tests.py b/postman/tests.py
new file mode 100644 (file)
index 0000000..59e3d3b
--- /dev/null
@@ -0,0 +1,1547 @@
+"""
+Test suite.
+
+- Do not put 'mailer' in INSTALLED_APPS, it disturbs the emails counting.
+- Make sure these templates are accessible:
+    registration/login.html
+    base.html
+    404.html
+
+To have a fast test session, you can set a minimal configuration as:
+DATABASES = {
+    'default': {
+        'ENGINE': 'django.db.backends.sqlite3', # Add 'postgresql_psycopg2', 'postgresql', 'mysql', 'sqlite3' or 'oracle'.
+        'NAME': ':memory:',                      # Or path to database file if using sqlite3.
+        'USER': '',                      # Not used with sqlite3.
+        'PASSWORD': '',                  # Not used with sqlite3.
+        'HOST': '',                      # Set to empty string for localhost. Not used with sqlite3.
+        'PORT': '',                      # Set to empty string for default. Not used with sqlite3.
+    }
+}
+INSTALLED_APPS = (
+    'django.contrib.auth',
+    'django.contrib.contenttypes',
+    'django.contrib.sessions',
+    'django.contrib.sites',
+    'django.contrib.admin',
+    # 'pagination', # or use the mock
+    # 'ajax_select', # is an option
+    # 'notification', # is an option
+    'postman',
+)
+
+"""
+import copy
+from datetime import datetime, timedelta
+import re
+import sys
+from time import sleep
+
+from django.conf import settings
+from django.contrib.auth import REDIRECT_FIELD_NAME
+from django.contrib.auth.models import User, AnonymousUser
+from django.core import mail
+from django.core.exceptions import ValidationError
+from django.core.urlresolvers import reverse, clear_url_caches, get_resolver, get_urlconf
+from django.db.models import Q
+from django.http import QueryDict
+from django.template import Template, Context, TemplateSyntaxError, TemplateDoesNotExist
+from django.test import TestCase
+
+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
+from postman.urls import OPTION_MESSAGES
+from postman.utils import format_body, format_subject, notification
+
+if not 'pagination' in settings.INSTALLED_APPS:
+    try:
+        import postman.templatetags.pagination_tags
+    except:
+        sys.exit(
+            "Some templates need templatetags from the django-pagination application.\n"
+            "Add it to the INSTALLED_APPS, or allow a mock by renaming\n"
+            "postman/templatetags/pagination_tags_for_tests.py to pagination_tags.py"
+        )
+
+class GenericTest(TestCase):
+    """
+    Usual generic tests.
+    """
+    def test_version(self):
+        self.assertEqual(sys.modules['postman'].__version__, "1.0.0")
+
+class BaseTest(TestCase):
+    """
+    Common configuration and helper functions for all tests.
+    """
+    urls = 'postman.test_urls'
+
+    def setUp(self):
+        settings.LANGUAGE_CODE = 'en' # do not bother about translation
+        for a in (
+            'POSTMAN_DISALLOW_ANONYMOUS',
+            'POSTMAN_DISALLOW_MULTIRECIPIENTS',
+            'POSTMAN_DISALLOW_COPIES_ON_REPLY',
+            'POSTMAN_AUTO_MODERATE_AS',
+        ):
+            if hasattr(settings, a):
+                delattr(settings, a)
+        settings.POSTMAN_NOTIFIER_APP = None
+        settings.POSTMAN_MAILER_APP = None
+        settings.POSTMAN_AUTOCOMPLETER_APP = {
+            'arg_default': 'postman_single', # no default, mandatory to enable the feature
+        }
+        self.reload_modules()
+
+        self.user1 = User.objects.create_user('foo', 'foo@domain.com', 'pass')
+        self.user2 = User.objects.create_user('bar', 'bar@domain.com', 'pass')
+        self.user3 = User.objects.create_user('baz', 'baz@domain.com', 'pass')
+        self.email = 'qux@domain.com'
+
+    def check_now(self, dt):
+        "Check that a date is now. Well... almost."
+        delta = dt - datetime.now()
+        seconds = delta.days * (24*60*60) + delta.seconds
+        self.assert_(-2 <= seconds <= 2)
+
+    def check_status(self, m, status=STATUS_PENDING, is_new=True, is_replied=False, parent=None, thread=None,
+        moderation_date=False, moderation_by=None, moderation_reason='',
+        sender_archived=False, recipient_archived=False,
+        sender_deleted_at=False, recipient_deleted_at=False):
+        "Check a bunch of properties of a message."
+
+        self.assertEqual(m.is_pending(), status==STATUS_PENDING)
+        self.assertEqual(m.is_rejected(), status==STATUS_REJECTED)
+        self.assertEqual(m.is_accepted(), status==STATUS_ACCEPTED)
+        self.assertEqual(m.is_new, is_new)
+        self.assertEqual(m.is_replied, is_replied)
+        self.check_now(m.sent_at)
+        self.assertEqual(m.parent, parent)
+        self.assertEqual(m.thread, thread)
+        self.assertEqual(m.sender_archived, sender_archived)
+        self.assertEqual(m.recipient_archived, recipient_archived)
+        if sender_deleted_at:
+            if isinstance(sender_deleted_at, datetime):
+                self.assertEqual(m.sender_deleted_at, sender_deleted_at)
+            else:
+                self.assertNotEquals(m.sender_deleted_at, None)
+        else:
+            self.assertEqual(m.sender_deleted_at, None)
+        if recipient_deleted_at:
+            if isinstance(recipient_deleted_at, datetime):
+                self.assertEqual(m.recipient_deleted_at, recipient_deleted_at)
+            else:
+                self.assertNotEquals(m.recipient_deleted_at, None)
+        else:
+            self.assertEqual(m.recipient_deleted_at, None)
+        if moderation_date:
+            if isinstance(moderation_date, datetime):
+                self.assertEqual(m.moderation_date, moderation_date)
+            else:
+                self.assertNotEquals(m.moderation_date, None)
+        else:
+            self.assertEqual(m.moderation_date, None)
+        self.assertEqual(m.moderation_by, moderation_by)
+        self.assertEqual(m.moderation_reason, moderation_reason)
+
+    def create(self, moderation_status=None, *args, **kwargs):
+        "Create a message."
+        # need to sleep between creations
+        # otherwise some sent_at datetimes are equal and ordering predictions are disturbed
+        sleep(0.03)
+        if moderation_status:
+            kwargs.update(moderation_status=moderation_status)
+        return Message.objects.create(subject='s', *args, **kwargs)
+
+    def create_accepted(self, moderation_status=STATUS_ACCEPTED, *args, **kwargs):
+        "Create a message as 'accepted'."
+        return self.create(moderation_status=moderation_status, *args, **kwargs)
+
+    # set of message creations
+    def c12(self, *args, **kwargs):
+        return self.create_accepted(sender=self.user1, recipient=self.user2, *args, **kwargs)
+    def c13(self, *args, **kwargs):
+        return self.create_accepted(sender=self.user1, recipient=self.user3, *args, **kwargs)
+    def c21(self, *args, **kwargs):
+        return self.create_accepted(sender=self.user2, recipient=self.user1, *args, **kwargs)
+    def c23(self, *args, **kwargs):
+        return self.create_accepted(sender=self.user2, recipient=self.user3, *args, **kwargs)
+    def c32(self, *args, **kwargs):
+        return self.create_accepted(sender=self.user3, recipient=self.user2, *args, **kwargs)
+
+    def reload_modules(self):
+        "Reload some modules after a change in settings."
+        clear_url_caches()
+        try:
+            reload(sys.modules['postman.forms'])
+            reload(sys.modules['postman.views'])
+            reload(sys.modules['postman.urls'])
+        except KeyError: # happens once at the setUp
+            pass
+        reload(get_resolver(get_urlconf()).urlconf_module)
+    
+class ViewTest(BaseTest):
+    """
+    Test the views.
+    """
+    def test_home(self):
+        response = self.client.get('/messages/')
+        self.assertRedirects(response, reverse('postman_inbox'), status_code=301, target_status_code=302)
+
+    def check_folder(self, folder):
+        url = reverse('postman_' + folder, args=[OPTION_MESSAGES])
+        template = "postman/{0}.html".format(folder)
+        # anonymous
+        response = self.client.get(url)
+        self.assertRedirects(response, "{0}?{1}={2}".format(settings.LOGIN_URL, REDIRECT_FIELD_NAME, url))
+        # authenticated
+        self.assert_(self.client.login(username='foo', password='pass'))
+        response = self.client.get(url)
+        self.assertTemplateUsed(response, template)
+        url = reverse('postman_' + folder)
+        response = self.client.get(url)
+        self.assertTemplateUsed(response, template)
+
+    def test_inbox(self):
+        self.check_folder('inbox')
+
+    def test_sent(self):
+        self.check_folder('sent')
+
+    def test_archives(self):
+        self.check_folder('archives')
+
+    def test_trash(self):
+        self.check_folder('trash')
+
+    def check_template(self, action, args):
+        # don't want to bother with additional templates; test only the parameter passing
+        url = reverse('postman_' + action + '_template', args=args)
+        self.assertRaises(TemplateDoesNotExist, self.client.get, url)
+
+    def test_template(self):
+        "Test the 'template_name' parameter."
+        m1 = self.c12()
+        m1.read_at, m1.thread = datetime.now(), m1
+        m2 = self.c21(parent=m1, thread=m1.thread)
+        m1.replied_at = m2.sent_at; m1.save()
+        self.assert_(self.client.login(username='foo', password='pass'))
+        for actions, args in [
+            (('inbox', 'sent', 'archives', 'trash', 'write'), []),
+            (('view', 'view_conversation'), [1]),
+            (('reply',), [2]),
+        ]:
+            for action in actions:
+                self.check_template(action, args)
+
+    def test_write_authentication(self):
+        "Test permission and what template & form are used."
+        url = reverse('postman_write')
+        template = "postman/write.html"
+        # anonymous is allowed
+        response = self.client.get(url)
+        self.assertTemplateUsed(response, template)
+        from postman.forms import AnonymousWriteForm
+        self.assert_(isinstance(response.context['form'], AnonymousWriteForm))
+        # anonymous is not allowed
+        settings.POSTMAN_DISALLOW_ANONYMOUS = True
+        self.reload_modules()
+        response = self.client.get(url)
+        self.assertRedirects(response, "{0}?{1}={2}".format(settings.LOGIN_URL, REDIRECT_FIELD_NAME, url))
+        # authenticated
+        self.assert_(self.client.login(username='foo', password='pass'))
+        response = self.client.get(url)
+        self.assertTemplateUsed(response, template)
+        from postman.forms import WriteForm
+        self.assert_(isinstance(response.context['form'], WriteForm))
+
+    def test_write_recipient(self):
+        "Test the passing of recipient names in URL."
+        template = "postman/write.html"
+
+        url = reverse('postman_write', args=['foo'])
+        response = self.client.get(url)
+        self.assertContains(response, 'value="foo"')
+
+        url = reverse('postman_write', args=['foo:bar'])
+        response = self.client.get(url)
+        self.assertContains(response, 'value="bar, foo"')
+
+        url = reverse('postman_write', args=[':foo::intruder:bar:a-b+c@d.com:foo:'])
+        response = self.client.get(url)
+        self.assertContains(response, 'value="bar, foo"')
+
+    def test_write_auto_complete(self):
+        "Test the 'autocomplete_channels' parameter."
+        url = reverse('postman_write_auto_complete')
+        # anonymous
+        response = self.client.get(url)
+        f = response.context['form'].fields['recipients']
+        if hasattr(f, 'channel'): # app may not be in INSTALLED_APPS
+            self.assertEqual(f.channel, 'postman_single')
+        # authenticated
+        self.assert_(self.client.login(username='foo', password='pass'))
+        response = self.client.get(url)
+        f = response.context['form'].fields['recipients']
+        if hasattr(f, 'channel'):
+            self.assertEqual(f.channel, 'postman_multiple')
+
+    def check_init_by_query_string(self, action, args=[]):
+        template = "postman/{0}.html".format(action)
+        url = reverse('postman_' + action, args=args)
+        response = self.client.get(url + '?subject=that%20is%20the%20subject')
+        self.assertContains(response, 'value="that is the subject"')
+        response = self.client.get(url + '?body=this%20is%20my%20body')
+        self.assertContains(response, 'name="body">this is my body')
+
+    def test_write_querystring(self):
+        "Test the prefilling by query string."
+        self.check_init_by_query_string('write')
+
+    def check_message(self, m, is_anonymous=False, subject='s', body='b', recipient_username='bar'):
+        "Check some message properties, status, and that no mail is sent."
+        self.assertEqual(m.subject, subject)
+        self.assertEqual(m.body, body)
+        self.assertEqual(m.email, 'a@b.com' if is_anonymous else '')
+        self.assertEqual(m.sender, self.user1 if not is_anonymous else None)
+        self.assertEqual(m.recipient.username, recipient_username)
+        if is_anonymous:
+            self.check_status(m, sender_deleted_at=True)
+        self.assertEqual(len(mail.outbox), 0)
+
+    def check_write_post(self, extra={}, is_anonymous=False):
+        "Check message generation, redirection, and mandatory fields."
+        url = reverse('postman_write')
+        url_with_success_url = reverse('postman_write_with_success_url_to_sent')
+        data = {'recipients': self.user2.username, 'subject': 's', 'body': 'b'}
+        data.update(extra)
+        # default redirect is to the requestor page
+        response = self.client.post(url, data, HTTP_REFERER=url)
+        self.assertRedirects(response, url)
+        self.check_message(Message.objects.get(pk=1), is_anonymous)
+        # fallback redirect is to inbox. So redirect again when login is required
+        response = self.client.post(url, data)
+        self.assertRedirects(response, reverse('postman_inbox'), target_status_code=302 if is_anonymous else 200)
+        self.check_message(Message.objects.get(pk=2), is_anonymous)
+        # redirect url may be superseded
+        response = self.client.post(url_with_success_url, data, HTTP_REFERER=url)
+        self.assertRedirects(response, reverse('postman_sent'), target_status_code=302 if is_anonymous else 200)
+        self.check_message(Message.objects.get(pk=3), is_anonymous)
+        # query string has highest precedence
+        response = self.client.post(url_with_success_url + '?next=' + url, data, HTTP_REFERER='does not matter')
+        self.assertRedirects(response, url)
+        self.check_message(Message.objects.get(pk=4), is_anonymous)
+
+        for f in data.keys():
+            if f in ('body',): continue
+            d = data.copy()
+            del d[f]
+            response = self.client.post(url, d, HTTP_REFERER=url)
+            self.assertFormError(response, 'form', f, 'This field is required.')
+
+    def test_write_post_anonymous(self):
+        self.check_write_post({'email': 'a@b.com'}, is_anonymous=True)
+
+    def test_write_post_authenticated(self):
+        self.assert_(self.client.login(username='foo', password='pass'))
+        self.check_write_post()
+
+    def test_write_post_multirecipient(self):
+        "Test number of recipients constraint."
+        url = reverse('postman_write')
+        data = {
+            'email': 'a@b.com', 'subject': 's', 'body': 'b',
+            'recipients': '{0}, {1}'.format(self.user2.username, self.user3.username)}
+        # anonymous
+        response = self.client.post(url, data, HTTP_REFERER=url)
+        self.assertFormError(response, 'form', 'recipients', CommaSeparatedUserField.default_error_messages['max'].format(limit_value=1, show_value=2))
+        # authenticated
+        self.assert_(self.client.login(username='foo', password='pass'))
+        del data['email']
+        response = self.client.post(url, data, HTTP_REFERER=url)
+        self.assertRedirects(response, url)
+        self.check_message(Message.objects.get(pk=1))
+        self.check_message(Message.objects.get(pk=2), recipient_username='baz')
+
+        url_with_max = reverse('postman_write_with_max')
+        response = self.client.post(url_with_max, data, HTTP_REFERER=url)
+        self.assertFormError(response, 'form', 'recipients', CommaSeparatedUserField.default_error_messages['max'].format(limit_value=1, show_value=2))
+
+        settings.POSTMAN_DISALLOW_MULTIRECIPIENTS = True
+        response = self.client.post(url, data, HTTP_REFERER=url)
+        self.assertFormError(response, 'form', 'recipients', CommaSeparatedUserField.default_error_messages['max'].format(limit_value=1, show_value=2))
+
+    def test_write_post_filters(self):
+        "Test user- and exchange- filters."
+        url = reverse('postman_write')
+        data = {
+            'subject': 's', 'body': 'b',
+            'recipients': '{0}, {1}'.format(self.user2.username, self.user3.username)}
+        self.assert_(self.client.login(username='foo', password='pass'))
+
+        response = self.client.post(reverse('postman_write_with_user_filter_reason'), data, HTTP_REFERER=url)
+        self.assertFormError(response, 'form', 'recipients', "Some usernames are rejected: bar (some reason).")
+
+        response = self.client.post(reverse('postman_write_with_user_filter_no_reason'), data, HTTP_REFERER=url)
+        self.assertFormError(response, 'form', 'recipients', "Some usernames are rejected: bar, baz.")
+
+        response = self.client.post(reverse('postman_write_with_user_filter_false'), data, HTTP_REFERER=url)
+        self.assertFormError(response, 'form', 'recipients', "Some usernames are rejected: bar, baz.")
+
+        response = self.client.post(reverse('postman_write_with_user_filter_exception'), data, HTTP_REFERER=url)
+        self.assertFormError(response, 'form', 'recipients', ['first good reason',"anyway, I don't like bar"])
+
+        response = self.client.post(reverse('postman_write_with_exch_filter_reason'), data, HTTP_REFERER=url)
+        self.assertFormError(response, 'form', 'recipients', "Writing to some users is not possible: bar (some reason).")
+
+        response = self.client.post(reverse('postman_write_with_exch_filter_no_reason'), data, HTTP_REFERER=url)
+        self.assertFormError(response, 'form', 'recipients', "Writing to some users is not possible: bar, baz.")
+
+        response = self.client.post(reverse('postman_write_with_exch_filter_false'), data, HTTP_REFERER=url)
+        self.assertFormError(response, 'form', 'recipients', "Writing to some users is not possible: bar, baz.")
+
+        response = self.client.post(reverse('postman_write_with_exch_filter_exception'), data, HTTP_REFERER=url)
+        self.assertFormError(response, 'form', 'recipients', ['first good reason',"anyway, I don't like bar"])
+
+    def test_write_post_moderate(self):
+        "Test 'auto_moderators' parameter."
+        url = reverse('postman_write')
+        data = {'subject': 's', 'body': 'b', 'recipients': self.user2.username}
+        self.assert_(self.client.login(username='foo', password='pass'))
+        response = self.client.post(reverse('postman_write_moderate'), data, HTTP_REFERER=url)
+        self.assertRedirects(response, url)
+        self.check_status(Message.objects.get(pk=1), status=STATUS_REJECTED, recipient_deleted_at=True,
+            moderation_date=True, moderation_reason="some reason")
+
+    def test_reply_authentication(self):
+        "Test permission and what template & form are used."
+        template = "postman/reply.html"
+        self.c21(body="this is my body")
+        url = reverse('postman_reply', args=[1])
+        # anonymous
+        response = self.client.get(url)
+        self.assertRedirects(response, "{0}?{1}={2}".format(settings.LOGIN_URL, REDIRECT_FIELD_NAME, url))
+        # authenticated
+        self.assert_(self.client.login(username='foo', password='pass'))
+        response = self.client.get(url)
+        self.assertTemplateUsed(response, template)
+        from postman.forms import FullReplyForm
+        self.assert_(isinstance(response.context['form'], FullReplyForm))
+        self.assertContains(response, 'value="Re: s"')
+        self.assertContains(response, 'name="body">\n\nbar wrote:\n&gt; this is my body')
+        self.assertEqual(response.context['recipient'], 'bar')
+
+    def test_reply_formatters(self):
+        "Test the 'formatters' parameter."
+        template = "postman/reply.html"
+        self.c21(body="this is my body")
+        url = reverse('postman_reply_formatters', args=[1])
+        self.assert_(self.client.login(username='foo', password='pass'))
+        response = self.client.get(url)
+        self.assertTemplateUsed(response, template)
+        self.assertContains(response, 'value="Re_ s"')
+        self.assertContains(response, 'name="body">bar _ this is my body')
+
+    def test_reply_auto_complete(self):
+        "Test the 'autocomplete_channel' parameter."
+        self.c21()
+        url = reverse('postman_reply_auto_complete', args=[1])
+        self.assert_(self.client.login(username='foo', password='pass'))
+        response = self.client.get(url)
+        f = response.context['form'].fields['recipients']
+        if hasattr(f, 'channel'):
+            self.assertEqual(f.channel, 'postman_multiple')
+
+    def check_404(self, view_name, pk):
+        "Return is a 404 page."
+        url = reverse(view_name, args=[pk])
+        response = self.client.get(url)
+        self.assertEqual(response.status_code, 404)
+
+    def check_reply_404(self, pk):
+        self.check_404('postman_reply', pk)
+
+    def test_reply_id(self):
+        "Test all sort of failures."
+        self.assert_(self.client.login(username='foo', password='pass'))
+        # invalid message id
+        self.check_reply_404(1000)
+        # existent message but you are the sender, not the recipient
+        self.check_reply_404(Message.objects.get(pk=self.c12().pk).pk) # create & verify really there
+        # existent message but not yours at all
+        self.check_reply_404(Message.objects.get(pk=self.c23().pk).pk)
+        # existent message but not yet visible to you
+        self.check_reply_404(Message.objects.get(pk=self.create(sender=self.user2, recipient=self.user1).pk).pk)
+        # cannot reply to a deleted message
+        self.check_reply_404(Message.objects.get(pk=self.c21(recipient_deleted_at=datetime.now()).pk).pk)
+
+    def test_reply_querystring(self):
+        "Test the prefilling by query string."
+        self.assert_(self.client.login(username='foo', password='pass'))
+        self.c21()
+        self.check_init_by_query_string('reply', [1])
+
+    def test_reply_post(self):
+        "Test message generation and redirection."
+        self.c21()
+        url = reverse('postman_reply', args=[1])
+        url_with_success_url = reverse('postman_reply_with_success_url_to_sent', args=[1])
+        data = {'subject': 's', 'body': 'b'}
+        self.assert_(self.client.login(username='foo', password='pass'))
+        # default redirect is to the requestor page
+        response = self.client.post(url, data, HTTP_REFERER=url)
+        self.assertRedirects(response, url)
+        self.check_message(Message.objects.get(pk=2))
+        # fallback redirect is to inbox
+        response = self.client.post(url, data)
+        self.assertRedirects(response, reverse('postman_inbox'))
+        self.check_message(Message.objects.get(pk=3))
+        # redirect url may be superseded
+        response = self.client.post(url_with_success_url, data, HTTP_REFERER=url)
+        self.assertRedirects(response, reverse('postman_sent'))
+        self.check_message(Message.objects.get(pk=4))
+        # query string has highest precedence
+        response = self.client.post(url_with_success_url + '?next=' + url, data, HTTP_REFERER='does not matter')
+        self.assertRedirects(response, url)
+        self.check_message(Message.objects.get(pk=5))
+        # missing subject is valid, as in quick reply
+        response = self.client.post(url, {}, HTTP_REFERER=url)
+        self.assertRedirects(response, url)
+        self.check_message(Message.objects.get(pk=6), subject='Re: s', body='')
+
+    def test_reply_post_copies(self):
+        "Test number of recipients constraint."
+        self.c21()
+        url = reverse('postman_reply', args=[1])
+        data = {'subject': 's', 'body': 'b', 'recipients': self.user3.username}
+        self.assert_(self.client.login(username='foo', password='pass'))
+        response = self.client.post(url, data, HTTP_REFERER=url)
+        self.assertRedirects(response, url)
+        self.check_message(Message.objects.get(pk=2))
+        self.check_message(Message.objects.get(pk=3), recipient_username='baz')
+
+        url_with_max = reverse('postman_reply_with_max', args=[1])
+        data.update(recipients='{0}, {1}'.format(self.user2.username, self.user3.username))
+        response = self.client.post(url_with_max, data, HTTP_REFERER=url)
+        self.assertFormError(response, 'form', 'recipients', CommaSeparatedUserField.default_error_messages['max'].format(limit_value=1, show_value=2))
+
+        settings.POSTMAN_DISALLOW_COPIES_ON_REPLY = True
+        self.reload_modules()
+        response = self.client.post(url, data, HTTP_REFERER=url)
+        self.assertRedirects(response, url)
+        self.check_message(Message.objects.get(pk=4))
+        self.assertRaises(Message.DoesNotExist, Message.objects.get, pk=5)
+
+    def test_reply_post_filters(self):
+        "Test user- and exchange- filters."
+        self.c21()
+        url = reverse('postman_reply', args=[1])
+        data = {'subject': 's', 'body': 'b', 'recipients': '{0}, {1}'.format(self.user2.username, self.user3.username)}
+        self.assert_(self.client.login(username='foo', password='pass'))
+
+        response = self.client.post(reverse('postman_reply_with_user_filter_reason', args=[1]), data, HTTP_REFERER=url)
+        self.assertFormError(response, 'form', 'recipients', "Some usernames are rejected: bar (some reason).")
+
+        response = self.client.post(reverse('postman_reply_with_user_filter_no_reason', args=[1]), data, HTTP_REFERER=url)
+        self.assertFormError(response, 'form', 'recipients', "Some usernames are rejected: bar, baz.")
+
+        response = self.client.post(reverse('postman_reply_with_user_filter_false', args=[1]), data, HTTP_REFERER=url)
+        self.assertFormError(response, 'form', 'recipients', "Some usernames are rejected: bar, baz.")
+
+        response = self.client.post(reverse('postman_reply_with_user_filter_exception', args=[1]), data, HTTP_REFERER=url)
+        self.assertFormError(response, 'form', 'recipients', ['first good reason',"anyway, I don't like bar"])
+
+        response = self.client.post(reverse('postman_reply_with_exch_filter_reason', args=[1]), data, HTTP_REFERER=url)
+        self.assertFormError(response, 'form', 'recipients', "Writing to some users is not possible: bar (some reason).")
+
+        response = self.client.post(reverse('postman_reply_with_exch_filter_no_reason', args=[1]), data, HTTP_REFERER=url)
+        self.assertFormError(response, 'form', 'recipients', "Writing to some users is not possible: bar, baz.")
+
+        response = self.client.post(reverse('postman_reply_with_exch_filter_false', args=[1]), data, HTTP_REFERER=url)
+        self.assertFormError(response, 'form', 'recipients', "Writing to some users is not possible: bar, baz.")
+
+        response = self.client.post(reverse('postman_reply_with_exch_filter_exception', args=[1]), data, HTTP_REFERER=url)
+        self.assertFormError(response, 'form', 'recipients', ['first good reason',"anyway, I don't like bar"])
+
+    def test_reply_post_moderate(self):
+        "Test 'auto_moderators' parameter."
+        m = self.c21()
+        url = reverse('postman_reply', args=[1])
+        data = {'subject': 's', 'body': 'b'}
+        self.assert_(self.client.login(username='foo', password='pass'))
+
+        response = self.client.post(reverse('postman_reply_moderate', args=[1]), data, HTTP_REFERER=url)
+        self.assertRedirects(response, url)
+        self.check_status(Message.objects.get(pk=2), status=STATUS_REJECTED, recipient_deleted_at=True,
+            parent=m, thread=m,
+            moderation_date=True, moderation_reason="some reason")
+
+    def test_view_authentication(self):
+        "Test permission, what template and form are used, set-as-read."
+        template = "postman/view.html"
+        self.c12()
+        self.c21()
+        url = reverse('postman_view', args=[1])
+        # anonymous
+        response = self.client.get(url)
+        self.assertRedirects(response, "{0}?{1}={2}".format(settings.LOGIN_URL, REDIRECT_FIELD_NAME, url))
+        # authenticated
+        self.assert_(self.client.login(username='foo', password='pass'))
+        response = self.client.get(url)
+        self.assertTemplateUsed(response, template)
+        self.assertFalse(response.context['archived'])
+        self.assert_(response.context['reply_to_pk'] is None)
+        self.assert_(response.context['form'] is None)
+        self.check_status(Message.objects.get(pk=1), status=STATUS_ACCEPTED)
+
+        url = reverse('postman_view', args=[2])
+        response = self.client.get(url)
+        self.assertFalse(response.context['archived'])
+        self.assertEqual(response.context['reply_to_pk'], 2)
+        from postman.forms import QuickReplyForm
+        self.assert_(isinstance(response.context['form'], QuickReplyForm))
+        self.check_status(Message.objects.get(pk=2), status=STATUS_ACCEPTED, is_new=False)
+
+    def test_view_formatters(self):
+        "Test the 'formatters' parameter."
+        template = "postman/view.html"
+        self.c21(body="this is my body")
+        url = reverse('postman_view_formatters', args=[1])
+        self.assert_(self.client.login(username='foo', password='pass'))
+        response = self.client.get(url)
+        self.assertTemplateUsed(response, template)
+        self.assertNotContains(response, 'value="Re_ s"')
+        self.assertContains(response, 'name="body">bar _ this is my body')
+
+    def check_view_404(self, pk):
+        self.check_404('postman_view', pk)
+
+    def test_view_id(self):
+        "Test all sort of failures."
+        self.assert_(self.client.login(username='foo', password='pass'))
+        # invalid message id
+        self.check_view_404(1000)
+        # existent message but not yours
+        self.check_view_404(Message.objects.get(pk=self.c23().pk).pk) # create & verify really there
+        # existent message but not yet visible to you
+        self.check_view_404(Message.objects.get(pk=self.create(sender=self.user2, recipient=self.user1).pk).pk)
+
+    def test_view_conversation_authentication(self):
+        "Test permission, what template and form are used, number of messages in the conversation, set-as-read."
+        template = "postman/view.html"
+        m1 = self.c12()
+        m1.read_at, m1.thread = datetime.now(), m1
+        m2 = self.c21(parent=m1, thread=m1.thread)
+        m1.replied_at = m2.sent_at; m1.save()
+        url = reverse('postman_view_conversation', args=[1])
+        self.check_status(Message.objects.get(pk=1), status=STATUS_ACCEPTED, is_new=False, is_replied=True, thread=m1)
+        # anonymous
+        response = self.client.get(url)
+        self.assertRedirects(response, "{0}?{1}={2}".format(settings.LOGIN_URL, REDIRECT_FIELD_NAME, url))
+        # authenticated
+        self.assert_(self.client.login(username='foo', password='pass'))
+        response = self.client.get(url)
+        self.assertTemplateUsed(response, template)
+        self.assertFalse(response.context['archived'])
+        self.assertEqual(response.context['reply_to_pk'], 2)
+        from postman.forms import QuickReplyForm
+        self.assert_(isinstance(response.context['form'], QuickReplyForm))
+        self.assertEqual(len(response.context['pm_messages']), 2)
+        self.check_status(Message.objects.get(pk=2), status=STATUS_ACCEPTED, is_new=False, parent=m1, thread=m1)
+
+    def check_view_conversation_404(self, thread_id):
+        self.check_404('postman_view_conversation', thread_id)
+
+    def test_view_conversation_id(self):
+        "Test all sort of failures."
+        self.assert_(self.client.login(username='foo', password='pass'))
+        # invalid conversation id
+        self.check_view_conversation_404(1000)
+        # existent conversation but not yours
+        m1 = self.c23()
+        m1.read_at, m1.thread = datetime.now(), m1
+        m2 = self.c32(parent=m1, thread=m1.thread)
+        m1.replied_at = m2.sent_at; m1.save()
+        self.check_view_conversation_404(m1.thread_id)
+
+    def test_view_conversation(self):
+        "Test message visibility."
+        m1 = self.c12()
+        m1.read_at, m1.thread = datetime.now(), m1
+        m1.save()
+        m2 = self.create(sender=self.user2, recipient=self.user1, parent=m1, thread=m1.thread)
+        url = reverse('postman_view_conversation', args=[1])
+        self.check_status(Message.objects.get(pk=1), status=STATUS_ACCEPTED, is_new=False, thread=m1)
+        # existent response but not yet visible to you
+        self.assert_(self.client.login(username='foo', password='pass'))
+        response = self.client.get(url)
+        self.assertEqual(len(response.context['pm_messages']), 1)
+        self.check_status(Message.objects.get(pk=2), parent=m1, thread=m1)
+        # complete view on the other side
+        self.assert_(self.client.login(username='bar', password='pass'))
+        response = self.client.get(url)
+        self.assertEqual(len(response.context['pm_messages']), 2)
+
+    def check_update(self, view_name, field_bit, field_value=None):
+        "Check permission, redirection, field updates, invalid cases."
+        url = reverse(view_name)
+        url_with_success_url = reverse(view_name + '_with_success_url_to_archives')
+        data = {'pks': ('1', '2', '3')}
+        # anonymous
+        response = self.client.post(url, data)
+        self.assertRedirects(response, "{0}?{1}={2}".format(settings.LOGIN_URL, REDIRECT_FIELD_NAME, url))
+        # authenticated
+        self.assert_(self.client.login(username='foo', password='pass'))
+        # default redirect is to the requestor page
+        redirect_url = reverse('postman_sent')
+        response = self.client.post(url, data, HTTP_REFERER=redirect_url)
+        self.assertRedirects(response, redirect_url)
+        sender_kw = 'sender_{0}'.format(field_bit)
+        recipient_kw = 'recipient_{0}'.format(field_bit)
+        self.check_status(Message.objects.get(pk=1), status=STATUS_ACCEPTED, **{sender_kw: field_value})
+        self.check_status(Message.objects.get(pk=2), status=STATUS_ACCEPTED, **{recipient_kw: field_value})
+        self.check_status(Message.objects.get(pk=3), status=STATUS_ACCEPTED, **{sender_kw: field_value})
+        self.check_status(Message.objects.get(pk=4), status=STATUS_ACCEPTED)
+        # fallback redirect is to inbox
+        response = self.client.post(url, data) # doesn't hurt if already archived|deleted|undeleted
+        self.assertRedirects(response, reverse('postman_inbox'))
+        # redirect url may be superseded
+        response = self.client.post(url_with_success_url, data, HTTP_REFERER=redirect_url)
+        self.assertRedirects(response, reverse('postman_archives'))
+        # query string has highest precedence
+        response = self.client.post(url_with_success_url + '?next=' + redirect_url, data, HTTP_REFERER='does not matter')
+        self.assertRedirects(response, redirect_url)
+        # missing payload
+        response = self.client.post(url)
+        self.assertRedirects(response, reverse('postman_inbox'))
+
+        # not a POST
+        response = self.client.get(url, data)
+        self.assertEqual(response.status_code, 404)
+        # not yours
+        self.assert_(self.client.login(username='baz', password='pass'))
+        response = self.client.post(url, data)
+        self.assertEqual(response.status_code, 404)
+
+    def check_update_conversation(self, view_name, root_msg, field_bit, field_value=None):
+        "Check redirection, field updates, invalid cases."
+        url = reverse(view_name)
+        data = {'tpks': '1'}
+        self.assert_(self.client.login(username='foo', password='pass'))
+        response = self.client.post(url, data)
+        self.assertRedirects(response, reverse('postman_inbox'))
+        sender_kw = 'sender_{0}'.format(field_bit)
+        recipient_kw = 'recipient_{0}'.format(field_bit)
+        self.check_status(Message.objects.get(pk=1), status=STATUS_ACCEPTED, is_new=False, is_replied=True, thread=root_msg, **{sender_kw: field_value})
+        self.check_status(Message.objects.get(pk=2), status=STATUS_ACCEPTED, parent=root_msg, thread=root_msg, **{recipient_kw: field_value})
+        # missing payload
+        response = self.client.post(url)
+        self.assertRedirects(response, reverse('postman_inbox'))
+
+        # not a POST
+        response = self.client.get(url, data)
+        self.assertEqual(response.status_code, 404)
+        # not yours
+        self.assert_(self.client.login(username='baz', password='pass'))
+        response = self.client.post(url, data)
+        self.assertEqual(response.status_code, 404)
+
+    def test_archive(self):
+        "Test archive action on messages."
+        self.c12()
+        self.c21()
+        self.c12()
+        self.c13()
+        self.check_update('postman_archive', 'archived', True)
+
+    def test_archive_conversation(self):
+        "Test archive action on conversations."
+        m1 = self.c12()
+        m1.read_at, m1.thread = datetime.now(), m1
+        m2 = self.c21(parent=m1, thread=m1.thread)
+        m1.replied_at = m2.sent_at; m1.save()
+        self.check_update_conversation('postman_archive', m1, 'archived', True)
+
+    def test_delete(self):
+        "Test delete action on messages."
+        self.c12()
+        self.c21()
+        self.c12()
+        self.c13()
+        self.check_update('postman_delete', 'deleted_at', True)
+
+    def test_delete_conversation(self):
+        "Test delete action on conversations."
+        m1 = self.c12()
+        m1.read_at, m1.thread = datetime.now(), m1
+        m2 = self.c21(parent=m1, thread=m1.thread)
+        m1.replied_at = m2.sent_at; m1.save()
+        self.check_update_conversation('postman_delete', m1, 'deleted_at', True)
+
+    def test_undelete(self):
+        "Test undelete action on messages."
+        self.c12(sender_deleted_at=datetime.now())
+        self.c21(recipient_deleted_at=datetime.now())
+        self.c12(sender_deleted_at=datetime.now())
+        self.c13()
+        self.check_update('postman_undelete', 'deleted_at')
+
+    def test_undelete_conversation(self):
+        "Test undelete action on conversations."
+        m1 = self.c12(sender_deleted_at=datetime.now())
+        m1.read_at, m1.thread = datetime.now(), m1
+        m2 = self.c21(parent=m1, thread=m1.thread, recipient_deleted_at=datetime.now())
+        m1.replied_at = m2.sent_at; m1.save()
+        self.check_update_conversation('postman_undelete', m1, 'deleted_at')
+
+class FieldTest(BaseTest):
+    """
+    Test the CommaSeparatedUserField.
+    """
+    def test_label(self):
+        "Test the plural/singular of the label."
+        f = CommaSeparatedUserField(label=('plural','singular'))
+        self.assertEqual(f.label, 'plural')
+        f.set_max(1)
+        self.assertEqual(f.label, 'singular')
+
+        f = CommaSeparatedUserField(label=('plural','singular'), max=1)
+        self.assertEqual(f.label, 'singular')
+        f.set_max(2)
+        self.assertEqual(f.label, 'plural')
+
+        f = CommaSeparatedUserField(label=('plural','singular'), max=2)
+        self.assertEqual(f.label, 'plural')
+        f.set_max(1)
+        self.assertEqual(f.label, 'singular')
+
+    def test_to_python(self):
+        "Test the conversion to a python list."
+        f = CommaSeparatedUserField()
+        self.assertEqual(f.to_python(''), [])
+        self.assertEqual(f.to_python('foo'), ['foo'])
+        self.assertEqual(frozenset(f.to_python('foo, bar')), frozenset(['foo', 'bar']))
+        self.assertEqual(frozenset(f.to_python('foo, bar,baz')), frozenset(['foo', 'bar', 'baz']))
+        self.assertEqual(f.to_python(' foo , foo '), ['foo'])
+        self.assertEqual(frozenset(f.to_python('foo,, bar,')), frozenset(['foo', 'bar']))
+        self.assertEqual(frozenset(f.to_python(',foo, \t , bar')), frozenset(['foo', 'bar']))
+
+    def test_clean(self):
+        "Test the 'clean' validation."
+        f = CommaSeparatedUserField(required=False)
+        self.assertEqual(f.clean(''), [])
+        self.assertEqual(f.clean('foo'), [self.user1])
+        self.assertEqual(frozenset(f.clean('foo, bar')), frozenset([self.user1, self.user2]))
+        # 'intruder' is not a username
+        self.assertRaises(ValidationError, f.clean, 'foo, intruder, bar')
+        # only active users are considered
+        self.user1.is_active = False
+        self.user1.save()
+        self.assertRaises(ValidationError, f.clean, 'foo, bar')
+
+    def test_user_filter(self):
+        "Test the 'user_filter' argument."
+        f = CommaSeparatedUserField(user_filter=lambda u: None)
+        self.assertEqual(frozenset(f.clean('foo, bar')), frozenset([self.user1, self.user2]))
+        # no reason
+        f = CommaSeparatedUserField(user_filter=lambda u: '' if u == self.user1 else None)
+        self.assertRaises(ValidationError, f.clean, 'foo, bar')
+        # with reason
+        f = CommaSeparatedUserField(user_filter=lambda u: 'some reason' if u == self.user1 else None)
+        self.assertRaises(ValidationError, f.clean, 'foo, bar')
+
+    def test_min(self):
+        "Test the 'min' argument."
+        f = CommaSeparatedUserField(required=False, min=1)
+        self.assertEqual(f.clean(''), [])
+
+        f = CommaSeparatedUserField(min=1)
+        self.assertEqual(f.clean('foo'), [self.user1])
+
+        f = CommaSeparatedUserField(min=2)
+        self.assertEqual(frozenset(f.clean('foo, bar')), frozenset([self.user1, self.user2]))
+        self.assertRaises(ValidationError, f.clean, 'foo')
+
+    def test_max(self):
+        "Test the 'max' argument."
+        f = CommaSeparatedUserField(max=1)
+        self.assertEqual(f.clean('foo'), [self.user1])
+        self.assertRaises(ValidationError, f.clean, 'foo, bar')
+
+class MessageManagerTest(BaseTest):
+    """
+    Test the Message manager.
+    """
+    def test_num_queries(self):
+        "Test the number of queries."
+        # not available in django v1.2.3
+        if not hasattr(self, 'assertNumQueries'):
+            return
+        self.c12()
+        self.c21()
+        self.c12(sender_archived=True, recipient_deleted_at=datetime.now())
+        self.c21(sender_archived=True, recipient_deleted_at=datetime.now())
+        for u in (self.user1, self.user2):
+            with self.assertNumQueries(1):
+                msgs = list(Message.objects.sent(u, option=OPTION_MESSAGES))
+                user = msgs[0].recipient
+            with self.assertNumQueries(1):
+                msgs = list(Message.objects.inbox(u, option=OPTION_MESSAGES))
+                user = msgs[0].sender
+            with self.assertNumQueries(1):
+                msgs = list(Message.objects.archives(u, option=OPTION_MESSAGES))
+                user = msgs[0].sender
+                user = msgs[0].recipient
+            with self.assertNumQueries(1):
+                msgs = list(Message.objects.trash(u, option=OPTION_MESSAGES))
+                user = msgs[0].sender
+                user = msgs[0].recipient
+            with self.assertNumQueries(1):
+                msgs = list(Message.objects.thread(u, Q(pk=1)))
+                user = msgs[0].sender
+                user = msgs[0].recipient
+
+    def test(self):
+        """
+              user1       user2
+        -----------       -----------  read repl
+        arch del             arch del
+                   ---...
+                   ---X            x
+                   ------>|             x    x
+                  |<------|             x    x
+                  |------>
+                   ------>
+                   ------>              x    
+                   <------              
+                    ...---
+              x       X---
+        """
+
+        m1 = self.c12(moderation_status=STATUS_PENDING); 
+        m2 = self.c12(moderation_status=STATUS_REJECTED, recipient_deleted_at=datetime.now())
+        m3 = self.c12()
+        m3.read_at, m3.thread = datetime.now(), m3
+        m4 = self.c21(parent=m3, thread=m3.thread)
+        m3.replied_at = m4.sent_at; m3.save()
+        m4.read_at = datetime.now()
+        m5 = self.c12(parent=m4, thread=m4.thread)
+        m4.replied_at = m5.sent_at; m4.save()
+        m6 = self.c12()
+        m7 = self.c12()
+        m7.read_at = datetime.now(); m7.save()
+        m8 = self.c21()
+        m9 = self.c21(moderation_status=STATUS_PENDING)
+        m10 = self.c21(moderation_status=STATUS_REJECTED, recipient_deleted_at=datetime.now())
+
+        def pk(x): return x.pk
+        def pk_cnt(x): return (x.pk, x.count)
+        self.assertEqual(Message.objects.count(), 10)
+        self.assertEqual(Message.objects.inbox_unread_count(self.user1), 1)
+        self.assertEqual(Message.objects.inbox_unread_count(self.user2), 2)
+        self.assertEqual(self.user1.sent_messages.count(), 6)
+        self.assertEqual(self.user1.received_messages.count(), 4)
+        self.assertEqual(self.user2.sent_messages.count(), 4)
+        self.assertEqual(self.user2.received_messages.count(), 6)
+        self.assertEqual(set(m3.child_messages.all()), set([m3,m4,m5]))
+        self.assertEqual(list(m3.next_messages.all()), [m4])
+        self.assertEqual(m3.get_replies_count(), 1)
+        self.assertEqual(list(m4.next_messages.all()), [m5])
+        self.assertEqual(m4.get_replies_count(), 1)
+        self.assertEqual(m5.get_replies_count(), 0)
+        # by messages
+        self.assertQuerysetEqual(Message.objects.sent(self.user1, option=OPTION_MESSAGES), [7,6,5,3,2,1], transform=pk)
+        self.assertQuerysetEqual(Message.objects.sent(self.user2, option=OPTION_MESSAGES), [10,9,8,4], transform=pk)
+        self.assertQuerysetEqual(Message.objects.inbox(self.user1, option=OPTION_MESSAGES), [8,4], transform=pk)
+        self.assertQuerysetEqual(Message.objects.inbox(self.user2, option=OPTION_MESSAGES), [7,6,5,3], transform=pk)
+        self.assertQuerysetEqual(Message.objects.archives(self.user1, option=OPTION_MESSAGES), [], transform=pk)
+        self.assertQuerysetEqual(Message.objects.archives(self.user2, option=OPTION_MESSAGES), [], transform=pk)
+        self.assertQuerysetEqual(Message.objects.trash(self.user1, option=OPTION_MESSAGES), [], transform=pk)
+        self.assertQuerysetEqual(Message.objects.trash(self.user2, option=OPTION_MESSAGES), [], transform=pk)
+        # by conversations
+        self.assertQuerysetEqual(Message.objects.sent(self.user1), [(7,0),(6,0),(5,3),(2,0),(1,0)], transform=pk_cnt)
+        self.assertQuerysetEqual(Message.objects.sent(self.user2), [(10,0),(9,0),(8,0),(4,3)], transform=pk_cnt)
+        self.assertQuerysetEqual(Message.objects.inbox(self.user1), [(8,0),(4,3)], transform=pk_cnt)
+        self.assertQuerysetEqual(Message.objects.inbox(self.user2), [(7,0),(6,0),(5,3)], transform=pk_cnt)
+
+        self.assertQuerysetEqual(Message.objects.thread(self.user1, Q(thread=3)), [3,4,5], transform=pk)
+        self.assertQuerysetEqual(Message.objects.thread(self.user1, Q(pk=4)), [4], transform=pk)
+        self.assertQuerysetEqual(Message.objects.thread(self.user2, Q(thread=3)), [3,4,5], transform=pk)
+        self.assertQuerysetEqual(Message.objects.thread(self.user2, Q(pk=4)), [4], transform=pk)
+        # mark as archived and deleted
+        """
+              user1       user2
+        -----------       -----------  read repl
+        arch del             arch del
+         X         ---...
+              X    ---X            x
+         X    X    ------>|             x    x
+                  |<------|   X    X    x    x
+                  |------>
+         X         ------>    X
+                   ------>         X    x    
+              X    <------              
+                    ...---         X
+              x       X---    X
+        """
+        m1.sender_archived = True; m1.save()
+        m2.sender_deleted_at = datetime.now(); m2.save()
+        m3.sender_archived, m3.sender_deleted_at = True, datetime.now(); m3.save()
+        m4.sender_archived, m4.sender_deleted_at = True, datetime.now(); m4.save()
+        m6.sender_archived, m6.recipient_archived = True, True; m6.save()
+        m7.recipient_deleted_at = datetime.now(); m7.save()
+        m8.recipient_deleted_at = datetime.now(); m8.save()
+        m9.sender_deleted_at = datetime.now(); m9.save()
+        m10.sender_archived = True; m10.save()
+        self.assertEqual(Message.objects.inbox_unread_count(self.user1), 0)
+        self.assertEqual(Message.objects.inbox_unread_count(self.user2), 1)
+        # by messages
+        self.assertQuerysetEqual(Message.objects.archives(self.user1, option=OPTION_MESSAGES), [6,1], transform=pk)
+        self.assertQuerysetEqual(Message.objects.archives(self.user2, option=OPTION_MESSAGES), [10,6], transform=pk)
+        self.assertQuerysetEqual(Message.objects.trash(self.user1, option=OPTION_MESSAGES), [8,3,2], transform=pk)
+        self.assertQuerysetEqual(Message.objects.trash(self.user2, option=OPTION_MESSAGES), [9,7,4], transform=pk)
+        self.assertQuerysetEqual(Message.objects.sent(self.user1, option=OPTION_MESSAGES), [7,5], transform=pk)
+        self.assertQuerysetEqual(Message.objects.sent(self.user2, option=OPTION_MESSAGES), [8], transform=pk)
+        self.assertQuerysetEqual(Message.objects.inbox(self.user1, option=OPTION_MESSAGES), [4], transform=pk)
+        self.assertQuerysetEqual(Message.objects.inbox(self.user2, option=OPTION_MESSAGES), [5,3], transform=pk)
+        # by conversations
+        self.assertQuerysetEqual(Message.objects.sent(self.user1), [(7,0),(5,3)], transform=pk_cnt)
+        self.assertQuerysetEqual(Message.objects.sent(self.user2), [(8,0)], transform=pk_cnt)
+        self.assertQuerysetEqual(Message.objects.inbox(self.user1), [(4,3)], transform=pk_cnt)
+        self.assertQuerysetEqual(Message.objects.inbox(self.user2), [(5,3)], transform=pk_cnt)
+
+        self.assertQuerysetEqual(Message.objects.thread(self.user1, Q(thread=3)), [3,4,5], transform=pk)
+        self.assertQuerysetEqual(Message.objects.thread(self.user1, Q(pk=4)), [4], transform=pk)
+        self.assertQuerysetEqual(Message.objects.thread(self.user2, Q(thread=3)), [3,4,5], transform=pk)
+        self.assertQuerysetEqual(Message.objects.thread(self.user2, Q(pk=4)), [4], transform=pk)
+        # mark as read
+        self.assertEqual(Message.objects.set_read(self.user2, Q(thread=3)), 1)
+        m = Message.objects.get(pk=5)
+        self.check_status(m, status=STATUS_ACCEPTED, is_new=False, parent=m4, thread=m3)
+        self.check_now(m.read_at)
+        self.assertEqual(Message.objects.set_read(self.user2, Q(pk=6)), 1)
+        m = Message.objects.get(pk=6)
+        self.check_status(m, status=STATUS_ACCEPTED, is_new=False, sender_archived=True, recipient_archived=True)
+        self.check_now(m.read_at)
+        self.assertEqual(Message.objects.set_read(self.user1, Q(pk=8)), 1)
+        m = Message.objects.get(pk=8)
+        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.
+    """
+    def check_parties(self, m, s=None, r=None, email=''):
+        "Check party related properties."
+        obfuscated_email_re = re.compile('^[0-9a-f]{4}..[0-9a-f]{4}@domain$')
+        m.sender, m.recipient, m.email = s, r, email
+        if s or email:
+            m.clean()
+        else:
+            self.assertRaises(ValidationError, m.clean)
+        self.assertEqual(m.admin_sender(), s.username if s else '<'+email+'>')
+        self.assertEqual(m.clear_sender, m.admin_sender())
+        if s:
+            self.assertEqual(m.obfuscated_sender, s.username)
+        elif email:
+            self.assert_(obfuscated_email_re.match(m.obfuscated_sender))
+        else:
+            self.assertEqual(m.obfuscated_sender, '')
+        self.assertEqual(m.admin_recipient(), r.username if r else '<'+email+'>')
+        self.assertEqual(m.clear_recipient, m.admin_recipient())
+        if r:
+            self.assertEqual(m.obfuscated_recipient, r.username)
+        elif email:
+            self.assert_(obfuscated_email_re.match(m.obfuscated_recipient))
+        else:
+            self.assertEqual(m.obfuscated_recipient, '')
+
+    def test_parties(self):
+        "Test sender/recipient/email."
+        m = Message()
+        self.check_parties(m)
+        self.check_parties(m, s=self.user1)
+        self.check_parties(m, r=self.user2)
+        self.check_parties(m, s=self.user1,                   r=self.user2)
+        self.check_parties(m, s=self.user1, email=self.email              )
+        self.check_parties(m,               email=self.email, r=self.user2)
+
+    def test_status(self):
+        "Test status."
+        m = Message.objects.create(subject='s')
+        self.check_status(m)
+        m = Message.objects.create(subject='s', moderation_status=STATUS_REJECTED)
+        self.check_status(m, status=STATUS_REJECTED)
+        m = Message.objects.create(subject='s', moderation_status=STATUS_ACCEPTED)
+        self.check_status(m, status=STATUS_ACCEPTED)
+        m = Message.objects.create(subject='s', read_at=datetime.now())
+        self.check_status(m, is_new=False)
+        m = Message.objects.create(subject='s', replied_at=datetime.now())
+        self.check_status(m, is_replied=True)
+
+    def test_moderated_count(self):
+        "Test 'moderated_messages' count."
+        msg = Message.objects.create(subject='s', moderation_status=STATUS_ACCEPTED,
+            moderation_date=datetime.now(), moderation_by=self.user1)
+        msg.save()
+        self.assertEqual(list(self.user1.moderated_messages.all()), [msg])
+
+    def test_moderation_from_pending(self):
+        "Test moderation management when leaving 'pending' status."
+        msg = Message.objects.create(subject='s')
+        # pending -> pending: nothing changes
+        m = copy.copy(msg)
+        m.clean_moderation(STATUS_PENDING, self.user1)
+        self.check_status(m)
+        # pending -> rejected
+        m = copy.copy(msg)
+        m.moderation_status = STATUS_REJECTED
+        m.clean_moderation(STATUS_PENDING, self.user1) # one try with moderator
+        self.check_status(m, status=STATUS_REJECTED,
+            moderation_date=True, moderation_by=self.user1, recipient_deleted_at=True)
+        self.check_now(m.moderation_date)
+        self.check_now(m.recipient_deleted_at)
+        # pending -> accepted
+        m = copy.copy(msg)
+        m.moderation_status = STATUS_ACCEPTED
+        m.clean_moderation(STATUS_PENDING) # one try without moderator
+        self.check_status(m, status=STATUS_ACCEPTED, moderation_date=True)
+        self.check_now(m.moderation_date)
+
+    def test_moderation_from_rejected(self):
+        "Test moderation management when leaving 'rejected' status."
+        date_in_past = datetime.now() - timedelta(days=2) # any value, just to avoid now()
+        reason = 'some good reason'
+        msg = Message.objects.create(subject='s', moderation_status=STATUS_REJECTED,
+            moderation_date=date_in_past, moderation_by=self.user1, moderation_reason=reason,
+            recipient_deleted_at=date_in_past)
+        # rejected -> rejected: nothing changes
+        m = copy.copy(msg)
+        m.clean_moderation(STATUS_REJECTED, self.user2)
+        self.check_status(m, status=STATUS_REJECTED,
+            moderation_date=date_in_past, moderation_by=self.user1, moderation_reason=reason,
+            recipient_deleted_at=date_in_past)
+        # rejected -> pending
+        m = copy.copy(msg)
+        m.moderation_status = STATUS_PENDING
+        m.clean_moderation(STATUS_REJECTED) # one try without moderator
+        self.check_status(m, status=STATUS_PENDING,
+            moderation_date=True, moderation_reason=reason, recipient_deleted_at=False)
+        self.check_now(m.moderation_date)
+        # rejected -> accepted
+        m = copy.copy(msg)
+        m.moderation_status = STATUS_ACCEPTED
+        m.clean_moderation(STATUS_REJECTED, self.user2) # one try with moderator
+        self.check_status(m, status=STATUS_ACCEPTED,
+            moderation_date=True, moderation_by=self.user2, moderation_reason=reason,
+            recipient_deleted_at=False)
+        self.check_now(m.moderation_date)
+
+    def test_moderation_from_accepted(self):
+        "Test moderation management when leaving 'accepted' status."
+        date_in_past = datetime.now() - timedelta(days=2) # any value, just to avoid now()
+        msg = Message.objects.create(subject='s', moderation_status=STATUS_ACCEPTED,
+            moderation_date=date_in_past, moderation_by=self.user1, recipient_deleted_at=date_in_past)
+        # accepted -> accepted: nothing changes
+        m = copy.copy(msg)
+        m.clean_moderation(STATUS_ACCEPTED, self.user2)
+        self.check_status(m, status=STATUS_ACCEPTED,
+            moderation_date=date_in_past, moderation_by=self.user1, recipient_deleted_at=date_in_past)
+        # accepted -> pending
+        m = copy.copy(msg)
+        m.moderation_status = STATUS_PENDING
+        m.clean_moderation(STATUS_ACCEPTED, self.user2) # one try with moderator
+        self.check_status(m, status=STATUS_PENDING,
+            moderation_date=True, moderation_by=self.user2, recipient_deleted_at=date_in_past)
+        self.check_now(m.moderation_date)
+        # accepted -> rejected
+        m = copy.copy(msg)
+        m.moderation_status = STATUS_REJECTED
+        m.clean_moderation(STATUS_ACCEPTED) # one try without moderator
+        self.check_status(m, status=STATUS_REJECTED, moderation_date=True, recipient_deleted_at=True)
+        self.check_now(m.moderation_date)
+        self.check_now(m.recipient_deleted_at)
+
+    def test_visitor(self):
+        "Test clean_for_visitor()."
+        date_in_past = datetime.now() - timedelta(days=2) # any value, just to avoid now()
+        # as the sender
+        m = Message.objects.create(subject='s', recipient=self.user1)
+        m.clean_for_visitor()
+        self.check_status(m, sender_deleted_at=True)
+        self.check_now(m.sender_deleted_at)
+        # as the recipient
+        msg = Message.objects.create(subject='s', sender=self.user1)
+        # pending
+        m = copy.copy(msg)
+        m.read_at=date_in_past
+        m.recipient_deleted_at=date_in_past
+        m.clean_for_visitor()
+        self.check_status(m, recipient_deleted_at=False)
+        # rejected
+        m = copy.copy(msg)
+        m.moderation_status = STATUS_REJECTED
+        m.read_at=date_in_past
+        m.recipient_deleted_at=date_in_past
+        m.clean_for_visitor()
+        self.check_status(m, status=STATUS_REJECTED, recipient_deleted_at=date_in_past)
+        # accepted
+        m = copy.copy(msg)
+        m.moderation_status = STATUS_ACCEPTED
+        m.clean_for_visitor()
+        self.check_status(m, status=STATUS_ACCEPTED, is_new=False, recipient_deleted_at=True)
+        self.check_now(m.read_at)
+        self.check_now(m.recipient_deleted_at)
+
+    def test_update_parent(self):
+        "Test update_parent()."
+        parent = Message.objects.create(subject='s', sender=self.user1, recipient=self.user2,
+            moderation_status=STATUS_ACCEPTED)
+        parent.thread = parent
+        parent.save()
+        # any previous rejected reply should not interfere
+        rejected_reply = Message.objects.create(subject='s', sender=self.user2, recipient=self.user1,
+            parent=parent, thread=parent.thread, moderation_status=STATUS_REJECTED)
+        # any previous pending reply should not interfere
+        pending_reply = Message.objects.create(subject='s', sender=self.user2, recipient=self.user1,
+            parent=parent, thread=parent.thread, moderation_status=STATUS_PENDING)
+        reply = Message.objects.create(subject='s', sender=self.user2, recipient=self.user1,
+            parent=parent, thread=parent.thread)
+
+        # the reply is accepted
+        r = copy.deepcopy(reply)
+        r.moderation_status = STATUS_ACCEPTED
+        # accepted -> accepted: no change
+        r.update_parent(STATUS_ACCEPTED)
+        self.check_status(r.parent, status=STATUS_ACCEPTED, thread=parent)
+        # pending -> accepted: parent is replied
+        r.update_parent(STATUS_PENDING)
+        p = Message.objects.get(pk=parent.pk) # better to ask the DB to check the save()
+        self.check_status(p, status=STATUS_ACCEPTED, thread=parent, is_replied=True)
+        self.assertEqual(p.replied_at, r.sent_at)
+        # rejected -> accepted: same as pending -> accepted
+        # so check here the acceptance of an anterior date
+        # note: use again the some object for convenience but another reply is more realistic
+        r.sent_at = r.sent_at - timedelta(days=1)
+        r.update_parent(STATUS_REJECTED)
+        p = Message.objects.get(pk=parent.pk)
+        self.check_status(p, status=STATUS_ACCEPTED, thread=parent, is_replied=True)
+        self.assertEqual(p.replied_at, r.sent_at)
+
+        # a reply is withdrawn and no other reply
+        r = copy.deepcopy(reply)
+        r.parent.replied_at = r.sent_at
+        r.moderation_status = STATUS_REJECTED # could be STATUS_PENDING
+        # rejected -> rejected: no change. In real case, parent.replied_at would be already empty
+        r.update_parent(STATUS_REJECTED)
+        self.check_status(r.parent, status=STATUS_ACCEPTED, thread=parent, is_replied=True)
+        # pending -> rejected: no change. In real case, parent.replied_at would be already empty
+        r.update_parent(STATUS_PENDING)
+        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) 
+        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
+
+        # a reply is withdrawn but there is another suitable reply
+        other_reply = Message.objects.create(subject='s', sender=self.user2, recipient=self.user1,
+            parent=parent, thread=parent.thread, moderation_status=STATUS_ACCEPTED)
+        r = copy.deepcopy(reply)
+        r.parent.replied_at = r.sent_at
+        r.moderation_status = STATUS_PENDING # could be STATUS_REJECTED
+        # 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 
+        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
+        r.update_parent(STATUS_ACCEPTED)
+        p = Message.objects.get(pk=parent.pk)
+        self.check_status(p, status=STATUS_ACCEPTED, thread=parent, is_replied=True)
+        self.assertEqual(p.replied_at, other_reply.sent_at)
+        # note: accepted -> pending, with no other suitable reply
+        # is covered in the accepted -> rejected case
+
+    def check_notification(self, m, mail_number, email=None, notice_label=None):
+        "Check number of mails, recipient, and notice creation."
+        m.notify_users(STATUS_PENDING)
+        self.assertEqual(len(mail.outbox), mail_number)
+        if mail_number:
+            self.assertEqual(mail.outbox[0].to, [email])
+        if notification and notice_label:
+            notice = notification.Notice.objects.get(pk=1)
+            self.assertEqual(notice.notice_type.label, notice_label)
+
+    def test_notification_rejection_visitor(self):
+        "Test notify_users() for rejection, from a visitor."
+        m = Message.objects.create(subject='s', moderation_status=STATUS_REJECTED, email=self.email, recipient=self.user2)
+        self.check_notification(m, 1, self.email)
+
+    def test_notification_rejection_user(self):
+        "Test notify_users() for rejection, from a User."
+        m = Message.objects.create(subject='s', moderation_status=STATUS_REJECTED, sender = self.user1, recipient=self.user2)
+        self.check_notification(m, 1, self.user1.email, notice_label='postman_rejection')
+
+    def test_notification_rejection_user_inactive(self):
+        "Test notify_users() for rejection, from a User, but must be active."
+        m = Message.objects.create(subject='s', moderation_status=STATUS_REJECTED, sender = self.user1, recipient=self.user2)
+        self.user1.is_active = False
+        self.check_notification(m, 0, notice_label='postman_rejection')
+
+    def test_notification_acceptance_visitor(self):
+        "Test notify_users() for acceptance, to a visitor."
+        m = Message.objects.create(subject='s', moderation_status=STATUS_ACCEPTED, sender=self.user1, email=self.email)
+        self.check_notification(m, 1, self.email)
+
+    def test_notification_acceptance_user(self):
+        "Test notify_users() for acceptance, to a User."
+        m = Message.objects.create(subject='s', moderation_status=STATUS_ACCEPTED, sender=self.user1, recipient = self.user2)
+        self.check_notification(m, 1, self.user2.email, notice_label='postman_message')
+
+    def test_notification_acceptance_user_inactive(self):
+        "Test notify_users() for acceptance, to a User, but must be active."
+        m = Message.objects.create(subject='s', moderation_status=STATUS_ACCEPTED, sender=self.user1, recipient = self.user2)
+        self.user2.is_active = False
+        self.check_notification(m, 0, notice_label='postman_message')
+
+    def test_notification_acceptance_reply(self):
+        "Test notify_users() for acceptance, for a reply, to a User."
+        p = Message.objects.create(subject='s', moderation_status=STATUS_ACCEPTED, sender=self.user2, recipient=self.user1)
+        m = Message.objects.create(subject='s', moderation_status=STATUS_ACCEPTED, sender=self.user1, recipient=self.user2,
+            parent=p, thread=p)
+        self.check_notification(m, 1, self.user2.email, notice_label='postman_reply')
+
+    def test_dates(self):
+        "Test set_dates(), get_dates()."
+        m = Message()
+        set = datetime.now(), datetime.now(), datetime.now()
+        m.set_dates(*set)
+        get = m.get_dates()
+        self.assertEqual(get, set)
+
+    def test_moderation(self):
+        "Test set_moderation(), get_moderation()."
+        m = Message()
+        set = STATUS_ACCEPTED, self.user1.pk, datetime.now(), 'some reason'
+        m.set_moderation(*set)
+        get = m.get_moderation()
+        self.assertEqual(get, set)
+
+    def check_auto_moderation(self, msg, seq, default):
+        "Check auto-moderation results."
+        for mod, result in seq:
+            m = copy.copy(msg)
+            m.auto_moderate(mod)
+            changes = {}
+            if result is True:
+                changes['status'] = STATUS_ACCEPTED
+            elif result is None:
+                changes['status'] = default
+            else:
+                changes['status'] = STATUS_REJECTED
+                changes['moderation_reason'] = result
+            m.sent_at = datetime.now() # refresh, as we recycle the same base message
+            self.check_status(m, **changes)
+
+    def test_auto_moderation(self):
+        "Test auto-moderation function combination."
+        msg = Message.objects.create(subject='s')
+
+        def moderate_as_none(m):              return None
+        def moderate_as_true(m):              return True
+        def moderate_as_false(m):             return False
+        def moderate_as_0(m):                 return 0
+        def moderate_as_100(m):               return 100
+        def moderate_as_50(m):                return 50
+        def moderate_as_49_default_reason(m): return 49
+        moderate_as_49_default_reason.default_reason = 'moderate_as_49 default_reason'
+        def moderate_as_49_with_reason(m):    return (49, 'moderate_as_49 with_reason')
+        moderate_as_49_with_reason.default_reason = 'is not used'
+        def moderate_as_1(m):                 return (1, 'moderate_as_1')
+        def moderate_as_1_no_reason(m):       return (1, ' ')
+        def moderate_as_2(m):                 return (2, 'moderate_as_2')
+        def moderate_as_98(m):                return 98
+        moderate_as_98.default_reason = 'useless; never used'
+        def moderate_badly_as_negative(m):    return -1
+        def moderate_badly_as_too_high(m):    return 101
+        def moderate_as_0_with_reason(m):     return (0, 'moderate_as_0 with_reason')
+        def invalid_moderator_1(m):           return (0, )
+        def invalid_moderator_2(m):           return (0, 'reason', 'extra')
+
+        for mod in [invalid_moderator_1, invalid_moderator_2]:
+            m = copy.copy(msg)
+            self.assertRaises(ValueError, m.auto_moderate, mod)
+
+        seq = (
+            # no moderator, no valid rating, or moderator is unable to state, default applies
+            ([], None),
+            (moderate_badly_as_negative, None),
+            (moderate_badly_as_too_high, None),
+            (moderate_as_none, None),
+            # firm decision
+            (moderate_as_false, ''),  (moderate_as_0, ''),
+            (moderate_as_true, True), (moderate_as_100, True),
+            # round to up
+            (moderate_as_50, True),
+            # reasons
+            (moderate_as_49_default_reason, moderate_as_49_default_reason.default_reason),
+            (moderate_as_49_with_reason, 'moderate_as_49 with_reason'),
+            # priority is left to right
+            ([moderate_as_none, moderate_as_false, moderate_as_true], ''),
+            ([moderate_as_none, moderate_as_true, moderate_as_false], True),
+            # keep only reasons for ratings below 50, non empty or whitespace
+            ([moderate_as_1, moderate_as_98], 'moderate_as_1'),
+            ([moderate_as_1, moderate_as_2, moderate_as_50], 'moderate_as_1, moderate_as_2'),
+            ([moderate_as_1, moderate_as_1_no_reason, moderate_as_2], 'moderate_as_1, moderate_as_2'),
+            # a firm reject imposes its reason
+            ([moderate_as_1, moderate_as_2, moderate_as_50, moderate_as_0_with_reason], 'moderate_as_0 with_reason'),
+            # neutral or invalid moderators do not count in the average
+            ([moderate_as_50, moderate_as_none, moderate_badly_as_negative, moderate_badly_as_too_high], True),
+        )
+        # no default auto moderation
+        # settings.POSTMAN_AUTO_MODERATE_AS = None
+        self.check_auto_moderation(msg, seq, STATUS_PENDING)
+        # default is: accepted
+        settings.POSTMAN_AUTO_MODERATE_AS = True
+        self.check_auto_moderation(msg, seq, STATUS_ACCEPTED)
+        # default is: rejected
+        settings.POSTMAN_AUTO_MODERATE_AS = False
+        self.check_auto_moderation(msg, seq, STATUS_REJECTED)
+
+class PendingMessageManagerTest(BaseTest):
+    """
+    Test the PendingMessage manager.
+    """
+    def test(self):
+        msg1 = self.create()
+        msg2 = self.create(moderation_status=STATUS_REJECTED)
+        msg3 = self.create(moderation_status=STATUS_ACCEPTED)
+        msg4 = self.create()
+        self.assertQuerysetEqual(PendingMessage.objects.all(), [msg4.pk, msg1.pk], transform=lambda x: x.pk)
+
+class PendingMessageTest(BaseTest):
+    """
+    Test the PendingMessage model.
+    """
+    def test(self):
+        m = PendingMessage()
+        self.assert_(m.is_pending())
+        m.set_accepted()
+        self.assert_(m.is_accepted())
+        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.
+    """
+    def check_sub(self, x, y, value):
+        t = Template("{% load postman_tags %}{% with "+x+"|sub:"+y+" as var %}{{ var }}{% endwith %}")
+        self.assertEqual(t.render(Context({})), value)
+
+    def test_sub(self):
+        "Test '|sub'."
+        self.check_sub('6', '2', '4')
+        self.check_sub('6', "'X'", '6')
+        self.check_sub("'X'", '2', 'X')
+
+    def check_or_me(self, x, value, user=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)
+
+    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)
+
+    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 = datetime.now()
+        default = force_unicode(localize(dt)) # as in template/__init__.py/_render_value_in_context()
+        self.check_compact_date(dt, default, format='')
+        self.check_compact_date(dt, default, format='one')
+        self.check_compact_date(dt, default, format='one,two')
+        self.check_compact_date(dt, dt.strftime('%H:%M'))
+        dt = datetime.now() - timedelta(days=1) # little fail: do not work on Jan, 1st, because the year changes as well
+        self.check_compact_date(dt, dt.strftime('%d %b').lower()) # filter's 'b' is lowercase
+        dt = datetime.now() - timedelta(days=365)
+        self.check_compact_date(dt, dt.strftime('%d/%m/%y'))
+
+class TagsTest(BaseTest):
+    """
+    Test the template tags.
+    """
+    def check_postman_unread(self, value, user=None, asvar=''):
+        t = Template("{% load postman_tags %}{% postman_unread " + asvar +" %}")
+        ctx = Context({'user': user} if user else {})
+        self.assertEqual(t.render(ctx), value)
+        return ctx
+
+    def test_postman_unread(self):
+        "Test 'postman_unread'."
+        self.check_postman_unread('')
+        self.check_postman_unread('', AnonymousUser())
+        self.check_postman_unread('0', self.user1)
+        Message.objects.create(subject='s', recipient=self.user1)
+        self.check_postman_unread('0', self.user1)
+        Message.objects.create(subject='s', recipient=self.user1, moderation_status=STATUS_ACCEPTED)
+        self.check_postman_unread('1', self.user1)
+        ctx = self.check_postman_unread('', self.user1, 'as var')
+        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.assertEqual(r[0], '?')
+        self.assertEqual(set(r[1:].split('&')), set([k+'='+v for k,v in value_list]))
+
+    def test_order_by(self):
+        "Test 'postman_order_by'."
+        for k,v in ORDER_BY_MAPPER.items():
+            self.check_order_by(k, [(ORDER_BY_KEY, v)])
+        self.check_order_by('subject', [(ORDER_BY_KEY, 's')], ORDER_BY_KEY+'=foo')
+        self.check_order_by('subject', [(ORDER_BY_KEY, 'S')], ORDER_BY_KEY+'=s')
+        self.check_order_by('subject', [(ORDER_BY_KEY, 's'), ('page', '12')], 'page=12')
+        self.check_order_by('subject', [('foo', 'bar'), (ORDER_BY_KEY, 's'), ('baz', 'qux')], 'foo=bar&'+ORDER_BY_KEY+'=S&baz=qux')
+        self.assertRaises(TemplateSyntaxError, self.check_order_by, '', None)
+        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.
+    """
+    def test_format_body(self):
+        "Test format_body()."
+        header = "\n\nfoo wrote:\n"
+        footer = "\n"
+        self.assertEqual(format_body(self.user1, "foo bar"), header+"> foo bar"+footer)
+        self.assertEqual(format_body(self.user1, "foo bar", indent='|_'), header+"|_foo bar"+footer)
+        self.assertEqual(format_body(self.user1, width=10, body="34 67 90"), header+"> 34 67 90"+footer)
+        self.assertEqual(format_body(self.user1, width=10, body="34 67 901"), header+"> 34 67\n> 901"+footer)
+        self.assertEqual(format_body(self.user1, width=10, body="> 34 67 901"), header+"> > 34 67 901"+footer)
+        self.assertEqual(format_body(self.user1, width=10,
+            body=    "34 67\n"   "\n" "  \n"   "  .\n"   "End"),
+            header+"> 34 67\n" "> \n" "> \n" ">   .\n" "> End"+footer)
+
+    def test_format_subject(self):
+        "Test format_subject()."
+        self.assertEqual(format_subject("foo bar"), "Re: foo bar")
+        self.assertEqual(format_subject("Re: foo bar"), "Re: foo bar")
+        self.assertEqual(format_subject("rE: foo bar"), "rE: foo bar")
diff --git a/postman/urls.py b/postman/urls.py
new file mode 100644 (file)
index 0000000..cd6495e
--- /dev/null
@@ -0,0 +1,108 @@
+"""
+If the default usage of the views suits you, simply use a line like
+this one in your root URLconf to set up the default URLs::
+
+    (r'^messages/', include('postman.urls')),
+
+Otherwise you may customize the behavior by passing extra parameters.
+
+Recipients Max 
+--------------
+Views supporting the parameter are: ``write``, ``reply``.
+Example::
+    ..., {'max': 3}, name='postman_write'),
+See also the ``POSTMAN_DISALLOW_MULTIRECIPIENTS`` setting
+
+User filter
+-----------
+Views supporting a user filter are: ``write``, ``reply``.
+Example::
+    def my_user_filter(user):
+        if user.get_profile().is_absent:
+            return "is away"
+        return None
+    ...
+    ..., {'user_filter': my_user_filter}, name='postman_write'),
+
+function interface:
+In: a User instance
+Out: None, False, '', 'a reason', or ValidationError
+
+Exchange filter
+---------------
+Views supporting an exchange filter are: ``write``, ``reply``.
+Example::
+    def my_exchange_filter(sender, recipient, recipients_list):
+        if recipient.relationships.exists(sender, RelationshipStatus.objects.blocking()):
+            return "has blacklisted you"
+        return None
+    ...
+    ..., {'exchange_filter': my_exchange_filter}, name='postman_write'),
+
+function interface:
+In:
+    ``sender``: a User instance
+    ``recipient``: a User instance
+    ``recipients_list``: the full list of recipients
+Out: None, False, '', 'a reason', or ValidationError
+
+Auto-complete field
+-------------------
+Views supporting an auto-complete parameter are: ``write``, ``reply``.
+Examples::
+    ..., {'autocomplete_channels': (None,'anonymous_ac')}, name='postman_write'),
+    ..., {'autocomplete_channels': 'write_ac'}, name='postman_write'),
+    ..., {'autocomplete_channel': 'reply_ac'}, name='postman_reply'),
+
+Auto moderators
+---------------
+Views supporting an ``auto-moderators`` parameter are: ``write``, ``reply``.
+Example::
+    def mod1(message):
+        # ...
+        return None
+    def mod2(message):
+        # ...
+        return None
+    mod2.default_reason = 'mod2 default reason'
+    ...
+    ..., {'auto_moderators': (mod1, mod2)}, name='postman_write'),
+    ..., {'auto_moderators': mod1}, name='postman_reply'),
+
+function interface:
+In: ``message``: a Message instance
+Out: rating or (rating, "reason")
+    with reting: None, 0 or False, 100 or True, 1..99
+
+Others
+------
+Refer to documentation.
+    ..., {'form_classes': (MyCustomWriteForm, MyCustomAnonymousWriteForm)}, name='postman_write'),
+    ..., {'form_class': MyCustomFullReplyForm}, name='postman_reply'),
+    ..., {'form_class': MyCustomQuickReplyForm}, name='postman_view'),
+    ..., {'template_name': 'my_custom_view.html'}, name='postman_view'),
+    ..., {'success_url': 'postman_inbox'}, name='postman_reply'),
+    ..., {'formatters': (format_subject,format_body)}, name='postman_reply'),
+    ..., {'formatters': (format_subject,format_body)}, name='postman_view'),
+
+"""
+from django.conf.urls.defaults import *
+from django.views.generic.simple import redirect_to
+
+OPTION_MESSAGES = 'm'
+OPTIONS = OPTION_MESSAGES
+
+urlpatterns = patterns('postman.views',
+    url(r'^inbox/(?:(?P<option>'+OPTIONS+')/)?$', 'inbox', name='postman_inbox'),
+    url(r'^sent/(?:(?P<option>'+OPTIONS+')/)?$', 'sent', name='postman_sent'),
+    url(r'^archives/(?:(?P<option>'+OPTIONS+')/)?$', 'archives', name='postman_archives'),
+    url(r'^trash/(?:(?P<option>'+OPTIONS+')/)?$', 'trash', name='postman_trash'),
+    url(r'^write/(?:(?P<recipients>[\w.@+-:]+)/)?$', 'write', name='postman_write'),
+    url(r'^reply/(?P<message_id>[\d]+)/$', 'reply', name='postman_reply'),
+    url(r'^view/(?P<message_id>[\d]+)/$', 'view', name='postman_view'),
+    url(r'^view/t/(?P<thread_id>[\d]+)/$', 'view_conversation', name='postman_view_conversation'),
+    url(r'^archive/$', 'archive', name='postman_archive'),
+    url(r'^delete/$', 'delete', name='postman_delete'),
+    url(r'^undelete/$', 'undelete', name='postman_undelete'),
+    (r'^$', redirect_to, {'url': 'inbox/'}),
+)
diff --git a/postman/utils.py b/postman/utils.py
new file mode 100644 (file)
index 0000000..d148e61
--- /dev/null
@@ -0,0 +1,94 @@
+import re
+import sys
+from textwrap import TextWrapper
+
+from django.conf import settings
+from django.contrib.sites.models import Site
+from django.template.loader import render_to_string
+from django.utils.encoding import force_unicode
+from django.utils.translation import ugettext, ugettext_lazy as _
+
+# make use of a favourite notifier app such as django-notification
+# but if not installed or not desired, fallback will be to do basic emailing
+name = getattr(settings, 'POSTMAN_NOTIFIER_APP', 'notification')
+if name and name in settings.INSTALLED_APPS:
+    name = name + '.models'
+    __import__(name)
+    notification = sys.modules[name]
+else:
+    notification = None
+
+# give priority to a favourite mailer app such as django-mailer
+# but if not installed or not desired, fallback to django.core.mail
+name = getattr(settings, 'POSTMAN_MAILER_APP', 'mailer')
+if name and name in settings.INSTALLED_APPS:
+    send_mail = __import__(name, globals(), locals(), ['send_mail']).send_mail
+else:
+    from django.core.mail import send_mail
+
+# default wrap width; referenced in forms.py
+WRAP_WIDTH = 55
+
+def format_body(sender, body, indent=_("> "), width=WRAP_WIDTH):
+    """
+    Wrap the text and prepend lines with a prefix.
+
+    The aim is to get lines with at most `width` chars.
+    But does not wrap if the line is already prefixed.
+
+    Prepends each line with a localized prefix, even empty lines.
+    Existing line breaks are preserved.
+    Used for quoting messages in replies.
+
+    """
+    indent = force_unicode(indent) # join() doesn't work on lists with lazy translation objects
+    wrapper = TextWrapper(width=width, initial_indent=indent, subsequent_indent=indent)
+    # rem: TextWrapper doesn't add the indent on an empty text
+    quote = '\n'.join([line.startswith(indent) and indent+line or wrapper.fill(line) or indent for line in body.splitlines()])
+    return ugettext("\n\n{sender} wrote:\n{body}\n").format(sender=sender, body=quote)
+
+def format_subject(subject):
+    """
+    Prepend a pattern to the subject, unless already there.
+
+    Matching is case-insensitive.
+
+    """
+    str = ugettext("Re: {subject}")
+    pattern = '^' + str.replace('{subject}', '.*') + '$'
+    return subject if re.match(pattern, subject, re.IGNORECASE) else str.format(subject=subject)
+
+def email(subject_template, message_template, recipient_list, object, action=None):
+    """Compose and send an email."""
+    site = Site.objects.get_current()
+    ctx_dict = {'site': site, 'object': object, 'action': action}
+    subject = render_to_string(subject_template, ctx_dict)
+    # Email subject *must not* contain newlines
+    subject = ''.join(subject.splitlines())
+    message = render_to_string(message_template, ctx_dict)
+    if settings.DEBUG and getattr(settings, 'DEV_DEBUG', False):
+        print "email from:", settings.DEFAULT_FROM_EMAIL, " - to:", recipient_list, " - subject:", subject
+        print message
+    else:
+        send_mail(subject, message, settings.DEFAULT_FROM_EMAIL, recipient_list, fail_silently=True)
+
+def email_visitor(object, action):
+    """Email a visitor."""
+    email('postman/email_visitor_subject.txt', 'postman/email_visitor.txt', [object.email], object, action)
+
+def notify_user(object, action):
+    """Notify a user."""
+    if action == 'rejection':
+        user = object.sender
+        label = 'postman_rejection'
+    elif action == 'acceptance':
+        user = object.recipient
+        parent = object.parent
+        label = 'postman_reply' if (parent and parent.sender_id == object.recipient_id) else 'postman_message'
+    else:
+        return
+    if notification:
+        notification.send(users=[user], label=label, extra_context={'message': object, 'action': action})
+    else:
+        if user.email and user.is_active:
+            email('postman/email_user_subject.txt', 'postman/email_user.txt', [user.email], object, action)
diff --git a/postman/views.py b/postman/views.py
new file mode 100644 (file)
index 0000000..e999ba0
--- /dev/null
@@ -0,0 +1,294 @@
+import datetime
+import urlparse
+
+from django.conf import settings
+from django.contrib import messages
+from django.contrib.auth.decorators import login_required
+from django.contrib.auth.models import User
+from django.core.urlresolvers import reverse
+from django.db.models import Q
+from django.http import Http404
+from django.shortcuts import render_to_response, get_object_or_404, redirect
+from django.template import RequestContext
+from django.utils.translation import ugettext as _
+
+from postman.fields import is_autocompleted
+from postman.forms import WriteForm, AnonymousWriteForm, QuickReplyForm, FullReplyForm
+from postman.models import Message, get_order_by
+from postman.urls import OPTION_MESSAGES
+from postman.utils import format_subject, format_body
+
+##########
+# Helpers
+##########
+def _get_referer(request):
+    """Return the HTTP_REFERER, if existing."""
+    if 'HTTP_REFERER' in request.META:
+        sr = urlparse.urlsplit(request.META['HTTP_REFERER'])
+        return urlparse.urlunsplit(('','',sr.path,sr.query,sr.fragment))
+
+########
+# Views
+########
+def _folder(request, folder_name, view_name, option, template_name):
+    """Code common to the folders."""
+    kwargs = {}
+    if option:
+        kwargs.update(option=option)
+    order_by = get_order_by(request.GET)
+    if order_by:
+        kwargs.update(order_by=order_by)
+    msgs = getattr(Message.objects, folder_name)(request.user, **kwargs)
+    return render_to_response(template_name, {
+        'pm_messages': msgs,    # avoid 'messages', already used by contrib.messages
+        'by_conversation': option is None,
+        'by_message': option == OPTION_MESSAGES,
+        'by_conversation_url': reverse(view_name),
+        'by_message_url': reverse(view_name, args=[OPTION_MESSAGES]),
+        'current_url': request.get_full_path(),
+        'gets': request.GET, # useful to postman_order_by template tag
+        }, context_instance=RequestContext(request))
+
+@login_required
+def inbox(request, option=None, template_name='postman/inbox.html'):
+    """
+    Display the list of received messages for the current user.
+
+    Optional arguments:
+        ``option``: display option:
+            OPTION_MESSAGES to view all messages
+            default to None to view only the last message for each conversation
+        ``template_name``: the name of the template to use
+
+    """
+    return _folder(request, 'inbox', 'postman_inbox', option, template_name)
+
+@login_required
+def sent(request, option=None, template_name='postman/sent.html'):
+    """
+    Display the list of sent messages for the current user.
+
+    Optional arguments: refer to inbox()
+
+    """
+    return _folder(request, 'sent', 'postman_sent', option, template_name)
+
+@login_required
+def archives(request, option=None, template_name='postman/archives.html'):
+    """
+    Display the list of archived messages for the current user.
+
+    Optional arguments: refer to inbox()
+
+    """
+    return _folder(request, 'archives', 'postman_archives', option, template_name)
+
+@login_required
+def trash(request, option=None, template_name='postman/trash.html'):
+    """
+    Display the list of deleted messages for the current user.
+
+    Optional arguments: refer to inbox()
+
+    """
+    return _folder(request, 'trash', 'postman_trash', option, template_name)
+
+def write(request, recipients=None, form_classes=(WriteForm, AnonymousWriteForm), autocomplete_channels=None,
+        template_name='postman/write.html', success_url=None,
+        user_filter=None, exchange_filter=None, max=None, auto_moderators=[]):
+    """
+    Display a form to compose a message.
+
+    Optional arguments:
+        ``recipients``: a colon-separated list of usernames
+        ``form_classes``: a 2-tuple of form classes
+        ``autocomplete_channels``: a channel name or a 2-tuple of names
+        ``template_name``: the name of the template to use
+        ``success_url``: where to redirect to after a successful POST
+        ``user_filter``: a filter for recipients
+        ``exchange_filter``: a filter for exchanges between a sender and a recipient
+        ``max``: an upper limit for the recipients number
+        ``auto_moderators``: a list of auto-moderation functions
+
+    """
+    user = request.user
+    form_class = form_classes[0] if user.is_authenticated() else form_classes[1]
+    if isinstance(autocomplete_channels, tuple) and len(autocomplete_channels) == 2:
+        channel = autocomplete_channels[user.is_anonymous()]
+    else:
+        channel = autocomplete_channels
+    next_url = _get_referer(request)
+    if request.method == 'POST':
+        form = form_class(request.POST, sender=user, channel=channel,
+            user_filter=user_filter,
+            exchange_filter=exchange_filter,
+            max=max)
+        if form.is_valid():
+            is_successful = form.save(auto_moderators=auto_moderators)
+            if is_successful:
+                messages.success(request, _("Message successfully sent."), fail_silently=True)
+            else:
+                messages.warning(request, _("Message rejected for at least one recipient."), fail_silently=True)
+            return redirect(request.GET.get('next', success_url or next_url or 'postman_inbox'))
+    else:
+        initial = dict(request.GET.items()) # allow optional initializations by query string
+        if recipients:
+            # order_by() is not mandatory, but: a) it doesn't hurt; b) it eases the test suite
+            # and anyway the original ordering cannot be respected.
+            usernames = list(User.objects.values_list('username', flat=True).filter(
+                is_active=True,
+                username__in=[r.strip() for r in recipients.split(':') if r and not r.isspace()],
+            ).order_by('username'))
+            if usernames:
+                initial.update(recipients=', '.join(usernames))
+        form = form_class(initial=initial, channel=channel)
+    return render_to_response(template_name, {
+        'form': form,
+        'is_autocompleted': is_autocompleted,
+        'next_url': request.GET.get('next', next_url),
+        }, context_instance=RequestContext(request))
+if getattr(settings, 'POSTMAN_DISALLOW_ANONYMOUS', False):
+    write = login_required(write)
+
+@login_required
+def reply(request, message_id, form_class=FullReplyForm, formatters=(format_subject,format_body), autocomplete_channel=None,
+        template_name='postman/reply.html', success_url=None,
+        user_filter=None, exchange_filter=None, max=None, auto_moderators=[]):
+    """
+    Display a form to compose a reply.
+
+    Optional arguments:
+        ``form_class``: the form class to use
+        ``formatters``: a 2-tuple of functions to prefill the subject and body fields
+        ``autocomplete_channel``: a channel name
+        ``template_name``: the name of the template to use
+        ``success_url``: where to redirect to after a successful POST
+        ``user_filter``: a filter for recipients
+        ``exchange_filter``: a filter for exchanges between a sender and a recipient
+        ``max``: an upper limit for the recipients number
+        ``auto_moderators``: a list of auto-moderation functions
+
+    """
+    user = request.user
+    perms = Message.objects.perms(user)
+    parent = get_object_or_404(Message, perms, pk=message_id)
+    initial = parent.quote(*formatters)
+    next_url = _get_referer(request)
+    if request.method == 'POST':
+        post = request.POST.copy()
+        if 'subject' not in post: # case of the quick reply form
+            post['subject'] = initial['subject']
+        form = form_class(post, sender=user, recipient=parent.sender or parent.email,
+            channel=autocomplete_channel,
+            user_filter=user_filter,
+            exchange_filter=exchange_filter,
+            max=max)
+        if form.is_valid():
+            is_successful = form.save(parent=parent, auto_moderators=auto_moderators)
+            if is_successful:
+                messages.success(request, _("Message successfully sent."), fail_silently=True)
+            else:
+                messages.warning(request, _("Message rejected for at least one recipient."), fail_silently=True)
+            return redirect(request.GET.get('next', success_url or next_url or 'postman_inbox'))
+    else:
+        initial.update(request.GET.items()) # allow overwriting of the defaults by query string
+        form = form_class(initial=initial, channel=autocomplete_channel)
+    return render_to_response(template_name, {
+        'form': form,
+        'recipient': parent.obfuscated_sender,
+        'is_autocompleted': is_autocompleted,
+        'next_url': request.GET.get('next', next_url),
+        }, context_instance=RequestContext(request))
+
+def _view(request, filter, form_class=QuickReplyForm, formatters=(format_subject,format_body),
+        template_name='postman/view.html'):
+    """
+    Code common to the by-message and by-conversation views.
+
+    Optional arguments:
+        ``form_class``: the form class to use
+        ``formatters``: a 2-tuple of functions to prefill the subject and body fields
+        ``template_name``: the name of the template to use
+
+    """
+    user = request.user
+    msgs = Message.objects.thread(user, filter)
+    if msgs:
+        Message.objects.set_read(user, filter)
+        # are all messages archived ?
+        for m in msgs:
+            if not getattr(m, ('sender' if m.sender == user else 'recipient') + '_archived'):
+                archived = False
+                break
+        else:
+            archived = True
+        # look for the more recent received message, if any
+        for m in reversed(msgs):
+            if m.recipient == user:
+                received = m
+                break
+        else:
+            received = None
+        return render_to_response(template_name, {
+            'pm_messages': msgs,
+            'archived': archived,
+            'reply_to_pk': received.pk if received else None,
+            'form' : form_class(initial=received.quote(*formatters)) if received else None,
+            'next_url': request.GET.get('next', reverse('postman_inbox')),
+            }, context_instance=RequestContext(request))
+    raise Http404
+
+@login_required
+def view(request, message_id, *args, **kwargs):
+    """Display one specific message."""
+    return _view(request, Q(pk=message_id), *args, **kwargs)
+
+@login_required
+def view_conversation(request, thread_id, *args, **kwargs):
+    """Display a conversation."""
+    return _view(request, Q(thread=thread_id), *args, **kwargs)
+
+def _update(request, field_bit, success_msg, field_value=None, success_url=None):
+    """
+    Code common to the archive/delete/undelete actions.
+
+    Arguments:
+        ``field_bit``: a part of the name of the field to update
+        ``success_msg``: the displayed text in case of success
+    Optional arguments:
+        ``field_value``: the value to set in the field
+        ``success_url``: where to redirect to after a successful POST
+
+    """
+    if not request.method == 'POST':
+        raise Http404
+    next_url = _get_referer(request) or 'postman_inbox'
+    pks = request.POST.getlist('pks')
+    tpks = request.POST.getlist('tpks')
+    if pks or tpks:
+        queryset = Message.objects.filter(Q(pk__in=pks) | Q(thread__in=tpks))
+        user = request.user
+        recipient_rows = queryset.filter(recipient=user).update(**{'recipient_{0}'.format(field_bit): field_value})
+        sender_rows = queryset.filter(sender=user).update(**{'sender_{0}'.format(field_bit): field_value})
+        if not (recipient_rows or sender_rows):
+            raise Http404 # abnormal enough, like forged ids
+        messages.success(request, success_msg, fail_silently=True)
+        return redirect(request.GET.get('next', success_url or next_url))
+    else:
+        messages.warning(request, _("Select at least one object."), fail_silently=True)
+        return redirect(next_url)
+
+@login_required
+def archive(request, *args, **kwargs):
+    """Mark messages/conversations as archived."""
+    return _update(request, 'archived', _("Messages or conversations successfully archived."), True, *args, **kwargs)
+
+@login_required
+def delete(request, *args, **kwargs):
+    """Mark messages/conversations as deleted."""
+    return _update(request, 'deleted_at', _("Messages or conversations successfully deleted."), datetime.datetime.now(), *args, **kwargs)
+
+@login_required
+def undelete(request, *args, **kwargs):
+    """Revert messages/conversations from marked as deleted."""
+    return _update(request, 'deleted_at', _("Messages or conversations successfully recovered."), *args, **kwargs)