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
'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
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
\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
* 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
\r
quickstart\r
moderation\r
+ notification\r
views\r
features\r
tags-filters\r
\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
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
--- /dev/null
+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
\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
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
*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
* {{ 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
\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
Examples\r
--------\r
\r
-``settings.py``::\r
+:file:`settings.py`::\r
\r
INSTALLED_APPS = (\r
# ...\r
# '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
\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
\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
--- /dev/null
+"""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()
--- /dev/null
+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)
--- /dev/null
+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 {}
--- /dev/null
+"""
+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
--- /dev/null
+"""
+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']
# 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"
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
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
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 ""
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 ""
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"
#: .\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
#: .\templates\postman\base.html.py:9
msgid "Archives"
-msgstr ""
+msgstr "Archiven"
#: .\templates\postman\base.html.py:10
msgid "Trash"
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
#: .\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"
#: .\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
#: .\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."
#: .\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
#: .\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,"
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 ":"
msgid "Back"
msgstr "Zurück"
-#: .\templatetags\postman_tags.py:34
+#: .\templatetags\postman_tags.py:35
msgid "<me>"
-msgstr "<ich>"
+msgstr "<Ich>"
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"
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
# 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"
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
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
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 ""
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 ""
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"
#: .\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
#: .\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"
#: .\templates\postman\base.html.py:9
msgid "Archives"
-msgstr ""
+msgstr "Archivos"
#: .\templates\postman\base.html.py:10
msgid "Trash"
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"
#: .\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"
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 ":"
msgid "Back"
msgstr "Volver"
-#: .\templatetags\postman_tags.py:34
+#: .\templatetags\postman_tags.py:35
msgid "<me>"
-msgstr "<mÃ>"
+msgstr "<usuario>"
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"
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"
# 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"
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
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
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"
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 ""
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"
#: .\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
#: .\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"
#: .\templates\postman\base.html.py:9
msgid "Archives"
-msgstr ""
+msgstr "Archivi"
#: .\templates\postman\base.html.py:10
msgid "Trash"
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"
#: .\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"
#: .\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
#: .\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,"
#: .\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 ":"
msgid "Back"
msgstr "Indietro"
-#: .\templatetags\postman_tags.py:34
+#: .\templatetags\postman_tags.py:35
msgid "<me>"
-msgstr "<i>"
+msgstr "<me>"
--- /dev/null
+# 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>"
--- /dev/null
+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)
+
--- /dev/null
+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))
--- /dev/null
+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()
--- /dev/null
+/*
+ This stylesheet is dedicated to the admin site.
+*/
+
+/* Form Fields */
+#id_subject, #id_moderation_reason { width: 50em; }
--- /dev/null
+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
--- /dev/null
+{% 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
--- /dev/null
+{% 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>
--- /dev/null
+{% 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
--- /dev/null
+{% 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
--- /dev/null
+{% 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
--- /dev/null
+{% 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 %} ">» {% trans "Inbox" %}{% if unread_count %} <strong>({{ unread_count }})</strong>{% endif %}</a></li>
+ <li><a href="{% url postman_sent %} ">» {% trans "Sent Messages" %}</a></li>
+ <li><a href="{% url postman_write %} ">» {% trans "Write" %}</a></li>
+ <li><a href="{% url postman_archives %} ">» {% trans "Archives" %}</a></li>
+ <li><a href="{% url postman_trash %} ">» {% trans "Trash" %}</a></li>
+</ul>
+{% endblock %}
\ No newline at end of file
--- /dev/null
+{% 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
--- /dev/null
+{% 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
--- /dev/null
+{% 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
--- /dev/null
+{% 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
--- /dev/null
+{% 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
--- /dev/null
+{% 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
--- /dev/null
+{% 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 %}
--- /dev/null
+{% 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
--- /dev/null
+{% 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
--- /dev/null
+{% 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 %}
--- /dev/null
+{% 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
--- /dev/null
+{% 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> »
+ <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
--- /dev/null
+{% extends "postman/base_write.html" %}
+{% load i18n %}
+{% block pm_write_title %}{% trans "Write"%}{% endblock %}
\ No newline at end of file
--- /dev/null
+"""
+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()
--- /dev/null
+"""
+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 {}
--- /dev/null
+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()
--- /dev/null
+"""
+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
+ )
--- /dev/null
+"""
+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> 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'", '<me>', 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")
--- /dev/null
+"""
+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/'}),
+)
--- /dev/null
+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)
--- /dev/null
+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)